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>
13 KiB
KeyPackage Exchange Flow
MLS KeyPackages are single-use tokens that enable a group creator to add a new member. The KeyPackage contains the member's HPKE init public key, their MLS credential (Ed25519 public key), and a signature proving ownership. The quicnprotochat Authentication Service (AS) provides a simple upload/fetch interface for distributing KeyPackages between clients.
This page describes the end-to-end flow: from client-side generation through server-side storage to peer-side retrieval and consumption.
Sources:
crates/quicnprotochat-core/src/group.rs(client-side generation)crates/quicnprotochat-server/src/main.rs(server-side handlers)crates/quicnprotochat-server/src/storage.rs(server-side persistence)crates/quicnprotochat-client/src/lib.rs(client-side RPC calls)schemas/node.capnp(wire schema)
Upload Flow
The upload flow moves a freshly generated KeyPackage from a client to the server, where it is stored for later retrieval by a peer.
Client Server (AS)
| |
| 1. GroupMember::generate_key_package() |
| -> TLS-encoded KeyPackage bytes |
| -> HPKE init key stored in backend |
| |
| 2. uploadKeyPackage RPC |
| identityKey = Ed25519 pub key (32 B) |
| package = TLS-encoded bytes |
| auth = Auth struct |
| ----------------------------------------> |
| | 3. Validate inputs:
| | - identityKey == 32 bytes
| | - package non-empty
| | - package < 1 MB
| | - auth version valid
| |
| | 4. Compute SHA-256(package)
| |
| | 5. Store: push_back to
| | DashMap<Vec<u8>, VecDeque<Vec<u8>>>
| | keyed by identity_key
| |
| 6. Response: fingerprint (SHA-256 hash) |
| <---------------------------------------- |
| |
| 7. Verify: local SHA-256 == server SHA-256|
| |
Step-by-Step
-
Client generates KeyPackage. The client calls
GroupMember::generate_key_package(), which internally:- Builds an MLS
CredentialWithKeyfrom the Ed25519 public key (CredentialType::Basic). - Calls
KeyPackage::builder().build()with the ciphersuiteMLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519, theStoreCryptobackend, and theIdentityKeypairas the signer. - openmls generates an ephemeral HPKE key pair (X25519) and stores the
private key in the backend's
DiskKeyStore. - Returns the TLS-serialized KeyPackage bytes.
See GroupMember Lifecycle for the critical invariant about backend identity.
- Builds an MLS
-
Client sends
uploadKeyPackageRPC. The request includes:identityKey: The raw 32-byte Ed25519 public key.package: The TLS-encoded KeyPackage bytes.auth: An Auth struct with version and optional access token.
-
Server validates inputs. The server checks:
identityKeyis exactly 32 bytes (Ed25519 public key size).packageis non-empty.packagedoes not exceedMAX_KEYPACKAGE_BYTES(1 MB).- The
Authstruct version is acceptable (0 for legacy, 1 for token-based).
-
Server computes fingerprint.
SHA-256(package_bytes)produces a 32-byte digest used as a tamper-detection fingerprint. -
Server stores the KeyPackage. The package bytes are pushed to the back of a
VecDeque<Vec<u8>>keyed by the identity key in the server'sFileBackedStore. This allows multiple KeyPackages per identity (clients should upload several to handle concurrent invitations). The store flushes to disk after every mutation. -
Server returns the fingerprint. The SHA-256 digest is sent back in the response's
fingerprintfield. -
Client verifies the fingerprint. The client computes its own
SHA-256(package_bytes)and compares it to the server-returned value. A mismatch indicates tampering (the server or a MITM modified the package in transit) and the client aborts with afingerprint mismatcherror.
Fetch Flow
The fetch flow allows a peer to retrieve a stored KeyPackage for a target identity, consuming it in the process (single-use per RFC 9420).
Peer Server (AS)
| |
| 1. fetchKeyPackage RPC |
| identityKey = target's Ed25519 pub key |
| auth = Auth struct |
| ----------------------------------------> |
| | 2. Validate inputs:
| | - identityKey == 32 bytes
| | - auth version valid
| |
| | 3. Pop front of VecDeque
| | (FIFO, single-use)
| | Flush updated map to disk
| |
| 4. Response: package bytes (or empty) |
| <---------------------------------------- |
| |
| 5. If non-empty: |
| KeyPackageIn::tls_deserialize() |
| .validate(crypto, MLS10) |
| -> trusted KeyPackage for add_member() |
| |
Step-by-Step
-
Peer sends
fetchKeyPackageRPC. The request includes the target's Ed25519 public key (32 bytes) and an Auth context. -
Server validates inputs. Same identity key length check as upload (32 bytes).
-
Server pops from the front of the queue.
VecDeque::pop_front()returns the oldest uploaded KeyPackage. This enforces FIFO ordering and single-use semantics: once fetched, the KeyPackage is permanently removed from the server. This is a hard requirement of the MLS specification -- reusing a KeyPackage would allow an attacker to create conflicting group states.The store is flushed to disk after the pop, ensuring the removal survives server restarts.
-
Server returns the package bytes. If the queue was empty (no KeyPackages available), the response contains an empty
Datafield. The client checks for emptiness to distinguish "no packages available" from "package fetched." -
Peer deserializes and validates. The peer uses
KeyPackageIn::tls_deserialize()followed by.validate(crypto, ProtocolVersion::Mls10)to verify the KeyPackage signature. The validatedKeyPackagecan then be passed toGroupMember::add_member().
Fingerprint Verification
The fingerprint mechanism provides a simple tamper-detection check:
Client Server Client
SHA-256(pkg) ---------> store pkg -----------> SHA-256(pkg)
| SHA-256(pkg) --------> |
| | |
+---- compare: local_fp == server_fp --------+
What it detects:
- A malicious server replacing the package bytes.
- A network-layer MITM modifying the package in transit (though QUIC/TLS already prevents this).
What it does NOT detect:
- A malicious server that simply returns the correct fingerprint for a package
it has replaced (since the server computes the hash itself). True
KeyPackage authenticity requires verifying the Ed25519 signature inside
the KeyPackage, which openmls does during
validate().
The fingerprint is best understood as a transport-level integrity check, not a cryptographic proof of authenticity. The real authenticity guarantee comes from the MLS KeyPackage signature verified on the receiving side.
Storage Model
On the server, KeyPackages are stored in a FileBackedStore:
FileBackedStore
+-- key_packages: Mutex<HashMap<Vec<u8>, VecDeque<Vec<u8>>>>
| ^ ^
| | |
| identity_key queue of TLS-encoded
| (32 bytes) KeyPackage bytes
|
+-- Persisted to: data/keypackages.bin (bincode serialized)
Each identity key maps to a FIFO queue of KeyPackage bytes. A client should upload multiple KeyPackages so that peers can concurrently fetch them without contention. If the queue is exhausted, fetches return empty until the client uploads more.
The storage format uses the QueueMapV1 wrapper for bincode serialization:
#[derive(Serialize, Deserialize, Default)]
struct QueueMapV1 {
map: HashMap<Vec<u8>, VecDeque<Vec<u8>>>,
}
See Storage Backend for details on persistence, flush-on-write semantics, and the V1/V2 delivery map migration.
Input Validation Summary
| Field | Constraint | Error on Violation |
|---|---|---|
identityKey |
Exactly 32 bytes | "identityKey must be exactly 32 bytes, got {n}" |
package |
Non-empty | "package must not be empty" |
package |
At most 1 MB (1,048,576) | "package exceeds max size (1048576 bytes)" |
auth.version |
0 (legacy) or 1 (current) | "unsupported auth version {v}" |
auth.token |
Non-empty when version=1 | "auth.version=1 requires non-empty accessToken" |
Wire Schema
From schemas/node.capnp:
uploadKeyPackage @0 (identityKey :Data, package :Data, auth :Auth)
-> (fingerprint :Data);
fetchKeyPackage @1 (identityKey :Data, auth :Auth)
-> (package :Data);
The Auth struct is shared across all RPC methods:
struct Auth {
version @0 :UInt16; # 0 = legacy/none, 1 = token-based
accessToken @1 :Data; # opaque bearer token
deviceId @2 :Data; # optional UUID for auditing
}
See NodeService Schema for the complete schema reference.
Client-Side Usage
The CLI exposes two commands for KeyPackage exchange:
register / register-state
Generates a fresh KeyPackage and uploads it. register uses an ephemeral
identity; register-state loads from (or initializes) a persistent state file.
# Ephemeral registration (for testing)
quicnprotochat register --server 127.0.0.1:7000
# Persistent registration (production)
quicnprotochat register-state --state alice.bin --server 127.0.0.1:7000
Output:
identity_key : 7a3f... (64 hex chars, 32 bytes)
fingerprint : 9e1c... (SHA-256 of KeyPackage)
KeyPackage uploaded successfully.
fetch-key
Fetches a peer's KeyPackage by their hex-encoded Ed25519 public key:
quicnprotochat fetch-key --server 127.0.0.1:7000 7a3f...
Security Considerations
-
Single-use enforcement. The server's
pop_front()semantics ensure each KeyPackage is consumed exactly once, satisfying RFC 9420's requirement. However, a malicious server could duplicate KeyPackages before deletion. True single-use is enforced at the MLS protocol level: duplicate KeyPackage usage would be detected when processing the Welcome (mismatched group state). -
No authentication on fetch. Currently, anyone can fetch any identity's KeyPackage. This is intentional for the MVP but means an attacker could exhaust a victim's KeyPackage supply. The Auth, Devices, and Tokens plan addresses this with token-based access control.
-
HPKE init key lifetime. The HPKE init private key lives in the
DiskKeyStorefrom generation until the Welcome is processed. For persistent clients usingDiskKeyStore::persistent(), this key survives process restarts. For ephemeral clients, the key exists only in memory and is lost if the process exits beforejoin_group()is called.
Related Pages
- GroupMember Lifecycle -- the MLS state machine that generates and consumes KeyPackages
- Authentication Service Internals -- server-side KeyPackage handling
- Delivery Service Internals -- how the Welcome message is relayed after
add_member() - Storage Backend --
FileBackedStorepersistence model - NodeService Schema -- Cap'n Proto schema reference