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>
This commit is contained in:
2026-02-22 08:07:48 +01:00
parent d1ddef4cea
commit f334ed3d43
81 changed files with 14502 additions and 2289 deletions

View File

@@ -0,0 +1,258 @@
# NodeService Schema
**Schema file:** `schemas/node.capnp`
**File ID:** `@0xd5ca5648a9cc1c28`
The `NodeService` interface is the unified Cap'n Proto RPC surface that every quicnprotochat client talks to. It combines the Authentication Service and Delivery Service into a single interface, adds long-polling support (`fetchWait`), a health probe (`health`), and hybrid KEM key management. Every method that mutates state or accesses per-user data accepts an `Auth` struct for versioned authentication.
---
## Full schema listing
```capnp
# node.capnp -- Unified quicnprotochat node RPC interface.
#
# Combines Authentication and Delivery operations into a single service.
#
# ID generated with: capnp id
@0xd5ca5648a9cc1c28;
interface NodeService {
# Upload a single-use KeyPackage for later retrieval by peers.
# identityKey : Ed25519 public key bytes (32 bytes)
# package : TLS-encoded openmls KeyPackage
# auth : Auth context (versioned). For legacy clients, pass an empty
# struct or version=0.
uploadKeyPackage @0 (identityKey :Data, package :Data, auth :Auth)
-> (fingerprint :Data);
# Fetch and atomically remove one KeyPackage for a given identity key.
# Returns empty Data if none are stored.
fetchKeyPackage @1 (identityKey :Data, auth :Auth) -> (package :Data);
# Enqueue an opaque payload for delivery to a recipient.
# channelId : Optional channel identifier (empty for legacy). A 16-byte UUID
# is recommended for 1:1 channels.
# version : Schema/wire version. Must be 0 (legacy) or 1 (this spec).
enqueue @2 (recipientKey :Data, payload :Data, channelId :Data,
version :UInt16, auth :Auth) -> ();
# Fetch and drain all queued payloads for the recipient.
fetch @3 (recipientKey :Data, channelId :Data, version :UInt16, auth :Auth)
-> (payloads :List(Data));
# Long-poll: wait up to timeoutMs for new payloads, then drain queue.
fetchWait @4 (recipientKey :Data, channelId :Data, version :UInt16,
timeoutMs :UInt64, auth :Auth) -> (payloads :List(Data));
# Health probe for readiness/liveness.
health @5 () -> (status :Text);
# Upload the hybrid (X25519 + ML-KEM-768) public key for sealed envelope
# encryption.
uploadHybridKey @6 (identityKey :Data, hybridPublicKey :Data) -> ();
# Fetch a peer's hybrid public key (for post-quantum envelope encryption).
fetchHybridKey @7 (identityKey :Data) -> (hybridPublicKey :Data);
}
struct Auth {
version @0 :UInt16; # 0 = legacy/none, 1 = token-based auth
accessToken @1 :Data; # opaque bearer token issued at login
deviceId @2 :Data; # optional UUID bytes for auditing/rate limiting
}
```
---
## Interface methods
### Authentication methods
#### `uploadKeyPackage @0`
```
uploadKeyPackage (identityKey :Data, package :Data, auth :Auth) -> (fingerprint :Data)
```
Uploads a single-use MLS KeyPackage. Identical semantics to the standalone [AuthenticationService](auth-schema.md) method, with the addition of the `auth` parameter for access control.
| Parameter | Type | Size | Description |
|---|---|---|---|
| `identityKey` | `Data` | 32 bytes | Uploader's raw Ed25519 public key |
| `package` | `Data` | Variable | TLS-encoded openmls KeyPackage blob |
| `auth` | `Auth` | Struct | Authentication context (see [Auth struct](#auth-struct) below) |
**Returns:** `fingerprint :Data` -- 32-byte SHA-256 digest of the stored package.
#### `fetchKeyPackage @1`
```
fetchKeyPackage (identityKey :Data, auth :Auth) -> (package :Data)
```
Fetches and atomically removes one KeyPackage for the specified identity key. Returns empty `Data` if no packages are stored. See [Auth Schema](auth-schema.md) for full single-use semantics and [ADR-005](../design-rationale/adr-005-single-use-keypackages.md) for the design rationale.
### Delivery methods
#### `enqueue @2`
```
enqueue (recipientKey :Data, payload :Data, channelId :Data, version :UInt16, auth :Auth) -> ()
```
Enqueues an opaque payload for delivery. Identical semantics to the standalone [DeliveryService](delivery-schema.md) `enqueue` method, with the addition of the `auth` parameter.
| Parameter | Type | Size | Description |
|---|---|---|---|
| `recipientKey` | `Data` | 32 bytes | Recipient's raw Ed25519 public key |
| `payload` | `Data` | Variable | Opaque byte string (typically MLS ciphertext) |
| `channelId` | `Data` | 0 or 16 bytes | Channel identifier (empty for legacy, UUID recommended) |
| `version` | `UInt16` | 2 bytes | Wire version: `0` = legacy, `1` = current |
| `auth` | `Auth` | Struct | Authentication context |
#### `fetch @3`
```
fetch (recipientKey :Data, channelId :Data, version :UInt16, auth :Auth) -> (payloads :List(Data))
```
Fetches and atomically drains all queued payloads for the specified recipient and channel. Returns an empty list if no messages are pending. See [Delivery Schema](delivery-schema.md) for full queue semantics.
#### `fetchWait @4`
```
fetchWait (recipientKey :Data, channelId :Data, version :UInt16, timeoutMs :UInt64, auth :Auth)
-> (payloads :List(Data))
```
**Long-polling variant of `fetch`.** This method blocks on the server side until either:
1. One or more payloads become available in the queue, **or**
2. The `timeoutMs` duration expires.
In case (1), the method returns all available payloads and drains the queue, identical to `fetch`. In case (2), the method returns an empty list.
| Parameter | Type | Description |
|---|---|---|
| `timeoutMs` | `UInt64` | Maximum wait time in milliseconds. A value of `0` means return immediately (equivalent to `fetch`). |
**Why long-polling?** Without `fetchWait`, clients must poll the server at a fixed interval, which wastes bandwidth when no messages are pending and introduces latency equal to half the polling interval on average. Long-polling provides near-real-time delivery while avoiding busy-wait overhead.
**Server implementation:** The server holds the RPC response open until a payload is enqueued for the recipient or the timeout fires. The underlying mechanism is a `tokio::sync::Notify` per recipient, which is woken by `enqueue`.
### Infrastructure methods
#### `health @5`
```
health () -> (status :Text)
```
A readiness/liveness probe that takes no parameters and returns a human-readable status string (e.g., `"ok"`). This method:
- Does not require authentication (`auth` is not a parameter).
- Is suitable for use as a Kubernetes or Docker health check endpoint.
- Can be extended in future versions to report more detailed status (e.g., queue depth, uptime).
### Hybrid KEM methods
#### `uploadHybridKey @6`
```
uploadHybridKey (identityKey :Data, hybridPublicKey :Data) -> ()
```
Uploads the client's hybrid (X25519 + ML-KEM-768) public key for post-quantum sealed envelope encryption. Peers fetch this key to encrypt payloads with post-quantum protection before enqueuing them.
| Parameter | Type | Description |
|---|---|---|
| `identityKey` | `Data` | Uploader's 32-byte Ed25519 public key (index key) |
| `hybridPublicKey` | `Data` | Concatenated X25519 public key (32 bytes) + ML-KEM-768 encapsulation key |
#### `fetchHybridKey @7`
```
fetchHybridKey (identityKey :Data) -> (hybridPublicKey :Data)
```
Fetches a peer's hybrid public key. Unlike `fetchKeyPackage`, this is **not** a destructive operation -- the hybrid key persists across fetches because it is a long-lived public key, not a single-use package.
---
## Auth struct
```capnp
struct Auth {
version @0 :UInt16;
accessToken @1 :Data;
deviceId @2 :Data;
}
```
The `Auth` struct is attached to every mutating or per-user method call. It provides a versioned authentication context that supports clean schema evolution.
### Fields
| Field | Type | Description |
|---|---|---|
| `version` | `UInt16` | Authentication protocol version. Determines how `accessToken` and `deviceId` are interpreted. |
| `accessToken` | `Data` | Opaque bearer token issued at login. The server validates this token against its auth backend. |
| `deviceId` | `Data` | Optional device identifier (UUID bytes). Used for auditing, rate limiting, and per-device session management. |
### Version semantics
| Version | Behavior |
|---|---|
| `0` | **Legacy / no authentication.** The server ignores `accessToken` and `deviceId`. All requests are accepted unconditionally. This is the default for M1-M3 development. |
| `1` | **Token-based authentication.** The server validates `accessToken` and rejects requests with missing or invalid tokens. `deviceId` is used for audit logging. |
### Backward compatibility
The `version` field enables a clean migration path:
1. **Existing clients** that do not set the `Auth` struct (or set `version=0`) continue to work with servers running in legacy mode.
2. **New clients** set `version=1` and provide a valid `accessToken`.
3. **The server** inspects `version` to decide which validation path to use. When the migration is complete, the server can reject `version=0` requests.
This pattern avoids the need for a breaking schema change when authentication is introduced.
---
## Method ordinal summary
| Ordinal | Method | Origin | Category |
|---|---|---|---|
| `@0` | `uploadKeyPackage` | AuthenticationService | Auth |
| `@1` | `fetchKeyPackage` | AuthenticationService | Auth |
| `@2` | `enqueue` | DeliveryService | Delivery |
| `@3` | `fetch` | DeliveryService | Delivery |
| `@4` | `fetchWait` | NodeService (new) | Delivery |
| `@5` | `health` | NodeService (new) | Infrastructure |
| `@6` | `uploadHybridKey` | NodeService (new) | Auth / PQ |
| `@7` | `fetchHybridKey` | NodeService (new) | Auth / PQ |
Ordinals are stable and must not be reused. New methods are appended with the next available ordinal. This is a fundamental Cap'n Proto schema evolution rule: removing a method does not free its ordinal.
---
## Schema evolution
Cap'n Proto supports forward-compatible schema evolution through several mechanisms, all of which are used in the NodeService interface:
1. **New methods can be added** by appending with a new ordinal. Old clients ignore unknown methods; new clients can call them.
2. **New struct fields can be added** to `Auth` (or any other struct) by appending with a new field number. Old structs that lack the new field will read the default value.
3. **The `version` field** provides application-level versioning on top of Cap'n Proto's structural versioning, allowing the server to change validation behavior without changing the schema.
---
## Further reading
- [Wire Format Overview](overview.md) -- serialisation pipeline context
- [Auth Schema](auth-schema.md) -- standalone Authentication Service interface (subset of NodeService)
- [Delivery Schema](delivery-schema.md) -- standalone Delivery Service interface (subset of NodeService)
- [Envelope Schema](envelope-schema.md) -- legacy M1 framing that NodeService replaced
- [Framing Codec](framing-codec.md) -- length-prefixed framing used in the Noise transport path
- [Architecture Overview](../architecture/overview.md) -- system-level view showing NodeService in context
- [ADR-005: Single-Use KeyPackages](../design-rationale/adr-005-single-use-keypackages.md) -- why fetchKeyPackage is destructive
- [ADR-004: MLS-Unaware DS](../design-rationale/adr-004-mls-unaware-ds.md) -- why payloads are opaque