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>
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:
-
Parse parameters. Extract
identityKey,package, andauth. -
Validate auth. Call
validate_auth()(see Auth Validation below). -
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)" -
Compute fingerprint.
SHA-256(package_bytes)produces a 32-byte digest. -
Store.
FileBackedStore::upload_key_package(identity_key, package)pushes the package to the back of the identity'sVecDequeand flushes to disk. -
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:
-
Parse and validate
identityKey(32 bytes) andauth. -
Pop from queue.
FileBackedStore::fetch_key_package(identity_key)callsVecDeque::pop_front()on the identity's queue, removing and returning the oldest KeyPackage. The updated map is flushed to disk. -
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
deviceIdfield 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:
- Validate
identityKey(32 bytes) andhybridPublicKey(non-empty). FileBackedStore::upload_hybrid_key(identity_key, hybrid_pk)stores the key, overwriting any previous value for this identity.- 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:
- Validate
identityKey(32 bytes). - Look up the hybrid public key in the store. Unlike
fetchKeyPackage, this does not remove the key -- it can be fetched repeatedly. - Return the key bytes, or empty
Dataif 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.
Related Pages
- KeyPackage Exchange Flow -- end-to-end upload and fetch flow including client-side logic
- Delivery Service Internals -- the DS half of NodeService
- Storage Backend -- FileBackedStore persistence model
- GroupMember Lifecycle -- how KeyPackages are generated and consumed
- Auth, Devices, and Tokens -- planned auth improvements
- NodeService Schema -- Cap'n Proto schema reference
- Hybrid KEM -- post-quantum envelope encryption