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:
258
docs/src/wire-format/node-service-schema.md
Normal file
258
docs/src/wire-format/node-service-schema.md
Normal 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
|
||||
Reference in New Issue
Block a user