Files
quicproquo/docs/src/internals/keypackage-exchange.md
Christian Nennemann 2e081ead8e chore: rename quicproquo → quicprochat in docs, Docker, CI, and packaging
Rename all project references from quicproquo/qpq to quicprochat/qpc
across documentation, Docker configuration, CI workflows, packaging
scripts, operational configs, and build tooling.

- Docker: crate paths, binary names, user/group, data dirs, env vars
- CI: workflow crate references, binary names, artifact names
- Docs: all markdown files under docs/, SDK READMEs, book.toml
- Packaging: OpenWrt Makefile, init script, UCI config (file renames)
- Scripts: justfile, dev-shell, screenshot, cross-compile, ai_team
- Operations: Prometheus config, alert rules, Grafana dashboard
- Config: .env.example (QPQ_* → QPC_*), CODEOWNERS paths
- Top-level: README, CONTRIBUTING, ROADMAP, CLAUDE.md
2026-03-21 19:14:06 +01:00

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 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.

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.

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

  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 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 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<Vec<u8>> 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).

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:

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)
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:

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 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.