# 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 quicprochat Authentication Service (AS) provides a simple upload/fetch interface for distributing KeyPackages between clients. **Expiry and refresh:** KeyPackages are consumed on fetch (single-use). The server may also enforce a TTL (e.g. 24h). Clients should upload a fresh KeyPackage periodically or on demand so they remain invitable. The CLI provides `refresh-keypackage`: load existing state, generate a new KeyPackage, upload to the AS. See [Running the Client](../getting-started/running-the-client.md#refresh-keypackage). This page describes the end-to-end flow: from client-side generation through server-side storage to peer-side retrieval and consumption. **Sources:** - `crates/quicprochat-core/src/group.rs` (client-side generation) - `crates/quicprochat-server/src/main.rs` (server-side handlers) - `crates/quicprochat-server/src/storage.rs` (server-side persistence) - `crates/quicprochat-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. ```text 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, VecDeque>> | | keyed by identity_key | | | 6. Response: fingerprint (SHA-256 hash) | | <---------------------------------------- | | | | 7. Verify: local SHA-256 == server SHA-256| | | ``` ### Step-by-Step 1. **Client generates KeyPackage.** The client calls `GroupMember::generate_key_package()`, which internally: - Builds an MLS `CredentialWithKey` from the Ed25519 public key (`CredentialType::Basic`). - Calls `KeyPackage::builder().build()` with the ciphersuite `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519`, the `StoreCrypto` backend, and the `IdentityKeypair` as 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](group-member-lifecycle.md) for the critical invariant about backend identity. 2. **Client sends `uploadKeyPackage` RPC.** The request includes: - `identityKey`: The raw 32-byte Ed25519 public key. - `package`: The TLS-encoded KeyPackage bytes. - `auth`: An [Auth struct](../wire-format/auth-schema.md) with version and optional access token. 3. **Server validates inputs.** The server checks: - `identityKey` is exactly 32 bytes (Ed25519 public key size). - `package` is non-empty. - `package` does not exceed `MAX_KEYPACKAGE_BYTES` (1 MB). - The `Auth` struct version is acceptable (0 for legacy, 1 for token-based). 4. **Server computes fingerprint.** `SHA-256(package_bytes)` produces a 32-byte digest used as a tamper-detection fingerprint. 5. **Server stores the KeyPackage.** The package bytes are pushed to the back of a `VecDeque>` keyed by the identity key in the server's `FileBackedStore`. This allows multiple KeyPackages per identity (clients should upload several to handle concurrent invitations). The store flushes to disk after every mutation. 6. **Server returns the fingerprint.** The SHA-256 digest is sent back in the response's `fingerprint` field. 7. **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 a `fingerprint mismatch` error. --- ## 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). ```text 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 1. **Peer sends `fetchKeyPackage` RPC.** The request includes the target's Ed25519 public key (32 bytes) and an Auth context. 2. **Server validates inputs.** Same identity key length check as upload (32 bytes). 3. **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. 4. **Server returns the package bytes.** If the queue was empty (no KeyPackages available), the response contains an empty `Data` field. The client checks for emptiness to distinguish "no packages available" from "package fetched." 5. **Peer deserializes and validates.** The peer uses `KeyPackageIn::tls_deserialize()` followed by `.validate(crypto, ProtocolVersion::Mls10)` to verify the KeyPackage signature. The validated `KeyPackage` can then be passed to `GroupMember::add_member()`. --- ## Fingerprint Verification The fingerprint mechanism provides a simple tamper-detection check: ```text 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`: ```text FileBackedStore +-- key_packages: Mutex, VecDeque>>> | ^ ^ | | | | 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: ```rust #[derive(Serialize, Deserialize, Default)] struct QueueMapV1 { map: HashMap, VecDeque>>, } ``` See [Storage Backend](storage-backend.md) 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`: ```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: ```capnp 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](../wire-format/node-service-schema.md) 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. ```bash # Ephemeral registration (for testing) qpc register --server 127.0.0.1:7000 # Persistent registration (production) qpc 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: ```bash qpc fetch-key --server 127.0.0.1:7000 7a3f... ``` --- ## Security Considerations 1. **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). 2. **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](../roadmap/authz-plan.md) plan addresses this with token-based access control. 3. **HPKE init key lifetime.** The HPKE init private key lives in the `DiskKeyStore` from generation until the Welcome is processed. For persistent clients using `DiskKeyStore::persistent()`, this key survives process restarts. For ephemeral clients, the key exists only in memory and is lost if the process exits before `join_group()` is called. --- ## Related Pages - [GroupMember Lifecycle](group-member-lifecycle.md) -- the MLS state machine that generates and consumes KeyPackages - [Authentication Service Internals](authentication-service.md) -- server-side KeyPackage handling - [Delivery Service Internals](delivery-service.md) -- how the Welcome message is relayed after `add_member()` - [Storage Backend](storage-backend.md) -- `FileBackedStore` persistence model - [NodeService Schema](../wire-format/node-service-schema.md) -- Cap'n Proto schema reference