Files
quicproquo/docs/src/internals/authentication-service.md
Christian Nennemann f334ed3d43 feat: add post-quantum hybrid KEM + SQLCipher persistence
Feature 1 — Post-Quantum Hybrid KEM (X25519 + ML-KEM-768):
- Create hybrid_kem.rs with keygen, encrypt, decrypt + 11 unit tests
- Wire format: version(1) | x25519_eph_pk(32) | mlkem_ct(1088) | nonce(12) | ct
- Add uploadHybridKey/fetchHybridKey RPCs to node.capnp schema
- Server: hybrid key storage in FileBackedStore + RPC handlers
- Client: hybrid keypair in StoredState, auto-wrap/unwrap in send/recv/invite/join
- demo-group runs full hybrid PQ envelope round-trip

Feature 2 — SQLCipher Persistence:
- Extract Store trait from FileBackedStore API
- Create SqlStore (rusqlite + bundled-sqlcipher) with encrypted-at-rest SQLite
- Schema: key_packages, deliveries, hybrid_keys tables with indexes
- Server CLI: --store-backend=sql, --db-path, --db-key flags
- 5 unit tests for SqlStore (FIFO, round-trip, upsert, channel isolation)

Also includes: client lib.rs refactor, auth config, TOML config file support,
mdBook documentation, and various cleanups by user.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:07:48 +01:00

9.9 KiB

Authentication Service Internals

The Authentication Service (AS) stores and distributes single-use MLS KeyPackages. It is one of the two logical services exposed through the unified NodeService RPC interface. The AS also stores hybrid (X25519 + ML-KEM-768) public keys for post-quantum envelope encryption.

This page covers the server-side implementation of KeyPackage storage, the Auth struct validation logic, and the hybrid key endpoints.

Sources:

  • crates/quicnprotochat-server/src/main.rs (RPC handlers, auth validation)
  • crates/quicnprotochat-server/src/storage.rs (FileBackedStore)
  • schemas/node.capnp (wire schema)

KeyPackage Storage

Data Model

KeyPackages are stored in a FileBackedStore using a Mutex-protected HashMap:

key_packages: Mutex<HashMap<Vec<u8>, VecDeque<Vec<u8>>>>
                            ^                ^
                            |                |
                       identity_key     FIFO queue of
                       (32-byte Ed25519  TLS-encoded
                        public key)      KeyPackage bytes

Each identity can have multiple KeyPackages queued. This is essential because KeyPackages are single-use (per RFC 9420): once fetched by a peer, they are permanently removed. Clients should upload several KeyPackages to handle concurrent group invitations.

The map is persisted to data/keypackages.bin using bincode serialization, wrapped in the QueueMapV1 struct. See Storage Backend for persistence details.

uploadKeyPackage

uploadKeyPackage @0 (identityKey :Data, package :Data, auth :Auth)
    -> (fingerprint :Data);

Handler logic:

  1. Parse parameters. Extract identityKey, package, and auth.

  2. Validate auth. Call validate_auth() (see Auth Validation below).

  3. Validate inputs:

    Check Constraint Error Message
    Identity key length Exactly 32 bytes "identityKey must be exactly 32 bytes, got {n}"
    Package non-empty package.len() > 0 "package must not be empty"
    Package size cap package.len() <= 1,048,576 "package exceeds max size (1048576 bytes)"
  4. Compute fingerprint. SHA-256(package_bytes) produces a 32-byte digest.

  5. Store. FileBackedStore::upload_key_package(identity_key, package) pushes the package to the back of the identity's VecDeque and flushes to disk.

  6. Return fingerprint. The SHA-256 hash is set in the response.

The fingerprint allows the uploading client to verify that the server stored the exact bytes it sent. See KeyPackage Exchange Flow for the client-side verification logic.

fetchKeyPackage

fetchKeyPackage @1 (identityKey :Data, auth :Auth) -> (package :Data);

Handler logic:

  1. Parse and validate identityKey (32 bytes) and auth.

  2. Pop from queue. FileBackedStore::fetch_key_package(identity_key) calls VecDeque::pop_front() on the identity's queue, removing and returning the oldest KeyPackage. The updated map is flushed to disk.

  3. Return. If a KeyPackage was available, set it in the response. If the queue was empty (or the identity has no entry), return empty Data.

Single-use semantics: The pop_front() operation ensures each KeyPackage is returned exactly once. This is critical for MLS security -- reusing a KeyPackage would allow conflicting group states. The removal is atomic with respect to the Mutex lock, so concurrent fetch requests will not receive the same package.

Empty response handling: The client checks package.is_empty() to distinguish between "no packages available" and "package fetched." An empty response is not an error -- it means the target identity has exhausted their KeyPackage supply and needs to upload more.


Auth Validation

All NodeService RPC methods accept an Auth struct:

struct Auth {
  version     @0 :UInt16;  # 0 = legacy/none, 1 = token-based
  accessToken @1 :Data;    # opaque bearer token
  deviceId    @2 :Data;    # optional UUID for auditing
}

The server validates this struct through the validate_auth function:

validate_auth(cfg, auth)
    |
    +-- version == 0?
    |     +-- cfg.allow_legacy_v0 == true?  -> OK
    |     +-- cfg.allow_legacy_v0 == false? -> ERROR "auth version 0 disabled"
    |
    +-- version == 1?
    |     +-- accessToken empty?            -> ERROR "requires non-empty accessToken"
    |     +-- cfg.required_token is Some?
    |     |     +-- token matches?          -> OK
    |     |     +-- token mismatch?         -> ERROR "invalid accessToken"
    |     +-- cfg.required_token is None?   -> OK (any non-empty token accepted)
    |
    +-- version >= 2?                       -> ERROR "unsupported auth version"

AuthConfig

The server's auth behavior is controlled by AuthConfig:

struct AuthConfig {
    required_token: Option<Vec<u8>>,  // None = accept any token
    allow_legacy_v0: bool,            // true = accept version 0 (no auth)
}

Configured via CLI flags / environment variables:

Flag / Env Var Default Purpose
--auth-token / QUICNPROTOCHAT_AUTH_TOKEN None Required bearer token. If unset, any non-empty token is accepted for version 1.
--allow-auth-v0 / QUICNPROTOCHAT_ALLOW_AUTH_V0 true Whether to accept auth.version=0 (legacy, unauthenticated) requests.

Version Semantics

Version Meaning Token Required?
0 Legacy / unauthenticated No. Token is ignored. Server must have allow_legacy_v0 = true.
1 Token-based authentication Yes. Must be non-empty. Must match required_token if configured.
2+ Reserved for future use Rejected.

Current Limitations

The current auth implementation is intentionally minimal:

  • No identity binding. The access token is not tied to a specific Ed25519 identity. Any valid token can upload or fetch KeyPackages for any identity.
  • No rate limiting. There is no per-identity or per-IP rate limiting.
  • No token rotation. Tokens are static strings configured at server startup.
  • No device management. The deviceId field is accepted but not used for authorization decisions.

The Auth, Devices, and Tokens roadmap item addresses these gaps with a proper token issuance and validation system.


Hybrid Key Endpoints

The AS also stores hybrid (X25519 + ML-KEM-768) public keys for post-quantum envelope encryption. Unlike KeyPackages, hybrid keys are not single-use -- they are stored persistently and can be fetched multiple times.

uploadHybridKey

uploadHybridKey @6 (identityKey :Data, hybridPublicKey :Data) -> ();

Handler logic:

  1. Validate identityKey (32 bytes) and hybridPublicKey (non-empty).
  2. FileBackedStore::upload_hybrid_key(identity_key, hybrid_pk) stores the key, overwriting any previous value for this identity.
  3. Flushes to data/hybridkeys.bin.

The storage model is simpler than KeyPackages: a flat HashMap<Vec<u8>, Vec<u8>> (identity key to hybrid public key bytes). There is no queue -- each identity has at most one hybrid public key.

fetchHybridKey

fetchHybridKey @7 (identityKey :Data) -> (hybridPublicKey :Data);

Handler logic:

  1. Validate identityKey (32 bytes).
  2. Look up the hybrid public key in the store. Unlike fetchKeyPackage, this does not remove the key -- it can be fetched repeatedly.
  3. Return the key bytes, or empty Data if none is stored.

See Hybrid KEM for how the client uses these keys to wrap MLS payloads in post-quantum envelopes.


NodeServiceImpl Structure

The server-side implementation struct:

struct NodeServiceImpl {
    store:    Arc<FileBackedStore>,                  // shared across connections
    waiters:  Arc<DashMap<Vec<u8>, Arc<Notify>>>,    // long-poll notification
    auth_cfg: Arc<AuthConfig>,                       // auth policy
}

All connections share the same store and waiters via Arc. The DashMap<Vec<u8>, Arc<Notify>> is keyed by recipient key and provides the push-notification mechanism for fetchWait. See Delivery Service Internals for the long-polling implementation.


Connection Model

QUIC endpoint (port 7000)
  +-- TLS 1.3 handshake (self-signed cert by default)
       +-- Accept bidirectional stream
            +-- capnp-rpc VatNetwork (Side::Server)
                 +-- NodeServiceImpl { store, waiters, auth_cfg }

Each QUIC connection opens one bidirectional stream for Cap'n Proto RPC. The capnp-rpc crate uses Rc<RefCell<>> internally, making it !Send. All RPC tasks run on a tokio::task::LocalSet to satisfy this constraint.

The server generates a self-signed TLS certificate on first start if no certificate files exist. Certificate and key paths are configurable via --tls-cert and --tls-key.


Health Endpoint

health @5 () -> (status :Text);

A simple readiness probe. Returns "ok" unconditionally. No auth validation is performed. Useful for infrastructure health checks and measuring QUIC round-trip time.