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:
149
docs/src/wire-format/auth-schema.md
Normal file
149
docs/src/wire-format/auth-schema.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Auth Schema
|
||||
|
||||
**Schema file:** `schemas/auth.capnp`
|
||||
**File ID:** `@0xb3a8f1c2e4d97650`
|
||||
|
||||
The `AuthenticationService` interface defines the RPC contract for uploading and fetching MLS KeyPackages. It is the standalone version of the Authentication Service; in the current architecture, these methods are integrated into the unified [NodeService](node-service-schema.md) interface.
|
||||
|
||||
---
|
||||
|
||||
## Full schema listing
|
||||
|
||||
```capnp
|
||||
# auth.capnp -- Authentication Service RPC interface.
|
||||
#
|
||||
# Clients call uploadKeyPackage before joining any group so that peers can
|
||||
# fetch their key material to add them. Each KeyPackage is single-use (MLS
|
||||
# requirement): fetchKeyPackage removes and returns one package atomically.
|
||||
#
|
||||
# The server indexes packages by the raw Ed25519 public key bytes (32 bytes),
|
||||
# not a fingerprint, so callers must know the target's identity public key
|
||||
# out-of-band (e.g. from a directory or QR code scan).
|
||||
#
|
||||
# ID generated with: capnp id
|
||||
@0xb3a8f1c2e4d97650;
|
||||
|
||||
interface AuthenticationService {
|
||||
# Upload a single-use KeyPackage for later retrieval by peers.
|
||||
#
|
||||
# identityKey : Ed25519 public key bytes (exactly 32 bytes).
|
||||
# package : openmls-serialised KeyPackage blob (TLS encoding).
|
||||
#
|
||||
# Returns the SHA-256 fingerprint of `package`. Clients should record this
|
||||
# and compare it against the fingerprint returned by a peer's fetchKeyPackage
|
||||
# to detect tampering.
|
||||
uploadKeyPackage @0 (identityKey :Data, package :Data) -> (fingerprint :Data);
|
||||
|
||||
# Fetch and atomically remove one KeyPackage for a given identity key.
|
||||
#
|
||||
# Returns empty Data if no KeyPackage is currently stored for this identity.
|
||||
# Callers should handle the empty case by asking the target to upload more
|
||||
# packages before retrying.
|
||||
fetchKeyPackage @1 (identityKey :Data) -> (package :Data);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Method-by-method analysis
|
||||
|
||||
### `uploadKeyPackage @0`
|
||||
|
||||
```
|
||||
uploadKeyPackage (identityKey :Data, package :Data) -> (fingerprint :Data)
|
||||
```
|
||||
|
||||
**Purpose:** A client uploads a single-use MLS KeyPackage so that peers can later fetch it to add the client to a group.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Size | Description |
|
||||
|---|---|---|---|
|
||||
| `identityKey` | `Data` | Exactly 32 bytes | The uploader's raw Ed25519 public key bytes. This is the index key under which the package is stored. |
|
||||
| `package` | `Data` | Variable (bounded by transport max) | An openmls-serialised KeyPackage blob in TLS encoding. Contains the client's HPKE init key, credential, and signature. |
|
||||
|
||||
**Return value:**
|
||||
|
||||
| Field | Type | Size | Description |
|
||||
|---|---|---|---|
|
||||
| `fingerprint` | `Data` | 32 bytes | SHA-256 digest of the uploaded `package` bytes. |
|
||||
|
||||
**Fingerprint semantics:** The returned fingerprint allows the uploading client to verify that the server stored the package correctly. More importantly, when a peer later fetches a KeyPackage, it can compare the fetched package's SHA-256 hash against the fingerprint (communicated out-of-band) to detect tampering by a malicious server.
|
||||
|
||||
**Idempotency:** Uploading the same package twice appends a second copy to the queue. The server does not deduplicate. Clients should avoid uploading duplicates to conserve their KeyPackage supply.
|
||||
|
||||
### `fetchKeyPackage @1`
|
||||
|
||||
```
|
||||
fetchKeyPackage (identityKey :Data) -> (package :Data)
|
||||
```
|
||||
|
||||
**Purpose:** Fetch and atomically remove one KeyPackage for a given identity. This is the mechanism by which a group creator obtains a peer's key material in order to add them to a group via MLS `add_members()`.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Size | Description |
|
||||
|---|---|---|---|
|
||||
| `identityKey` | `Data` | Exactly 32 bytes | The raw Ed25519 public key of the target peer whose KeyPackage is being requested. |
|
||||
|
||||
**Return value:**
|
||||
|
||||
| Field | Type | Size | Description |
|
||||
|---|---|---|---|
|
||||
| `package` | `Data` | Variable, or 0 bytes | The fetched KeyPackage blob, or empty `Data` if no packages are stored for this identity. |
|
||||
|
||||
**Atomic removal:** The fetch operation is destructive: it removes the returned KeyPackage from the server's store in the same operation that returns it. This guarantees MLS's single-use requirement -- a KeyPackage is never served to two different requesters.
|
||||
|
||||
**Empty response handling:** Callers must check for an empty response. An empty `package` means the target has no KeyPackages available. The caller should either:
|
||||
1. Retry after a delay, hoping the target uploads more packages.
|
||||
2. Signal the user that the target is unreachable for group addition.
|
||||
|
||||
---
|
||||
|
||||
## Indexing by raw Ed25519 public key
|
||||
|
||||
The Authentication Service indexes KeyPackages by the **raw 32-byte Ed25519 public key**, not by a fingerprint or any higher-level identifier. This design choice has several implications:
|
||||
|
||||
1. **No directory service required for lookup.** The caller must already know the target's Ed25519 public key (obtained out-of-band via QR code scan, manual exchange, or a future directory service).
|
||||
|
||||
2. **Consistent with DS indexing.** The [Delivery Service](delivery-schema.md) uses the same 32-byte Ed25519 key as its queue index, so a single key serves as the universal identifier across both services.
|
||||
|
||||
3. **No ambiguity.** Unlike fingerprints (which could collide if truncated) or human-readable names (which require a mapping layer), the raw public key is the canonical, collision-resistant identifier.
|
||||
|
||||
---
|
||||
|
||||
## Single-use semantics
|
||||
|
||||
MLS requires that each KeyPackage be used at most once to preserve the forward secrecy of the initial key exchange. The Authentication Service enforces this by atomically removing the KeyPackage on fetch.
|
||||
|
||||
**Consequences for clients:**
|
||||
|
||||
- Clients should **pre-upload multiple KeyPackages** after generating their identity, so that several peers can add them to groups concurrently without exhausting the supply.
|
||||
- Clients should **monitor their KeyPackage count** on the server (via a future monitoring endpoint or periodic re-upload) and replenish when the supply runs low.
|
||||
- If a client has zero KeyPackages stored, it is effectively unreachable for new group invitations until it uploads more.
|
||||
|
||||
For the design rationale behind single-use KeyPackages, see [ADR-005: Single-Use KeyPackages](../design-rationale/adr-005-single-use-keypackages.md).
|
||||
|
||||
---
|
||||
|
||||
## Relationship to NodeService
|
||||
|
||||
In the current unified architecture, the Authentication Service methods are exposed as part of the [NodeService interface](node-service-schema.md):
|
||||
|
||||
| AuthenticationService Method | NodeService Method | Additional Parameters |
|
||||
|---|---|---|
|
||||
| `uploadKeyPackage @0` | `uploadKeyPackage @0` | `auth :Auth` |
|
||||
| `fetchKeyPackage @1` | `fetchKeyPackage @1` | `auth :Auth` |
|
||||
|
||||
The standalone `AuthenticationService` interface remains in the schema for documentation purposes and for use in contexts where the full NodeService is not needed.
|
||||
|
||||
---
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Wire Format Overview](overview.md) -- serialisation pipeline context
|
||||
- [NodeService Schema](node-service-schema.md) -- unified interface that subsumes AuthenticationService
|
||||
- [Delivery Schema](delivery-schema.md) -- the companion service for message routing
|
||||
- [Envelope Schema](envelope-schema.md) -- legacy framing that used `keyPackageUpload`/`keyPackageFetch` message types
|
||||
- [ADR-005: Single-Use KeyPackages](../design-rationale/adr-005-single-use-keypackages.md) -- design rationale for atomic removal on fetch
|
||||
- [ADR-004: MLS-Unaware Delivery Service](../design-rationale/adr-004-mls-unaware-ds.md) -- why the server does not inspect MLS content
|
||||
193
docs/src/wire-format/delivery-schema.md
Normal file
193
docs/src/wire-format/delivery-schema.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Delivery Schema
|
||||
|
||||
**Schema file:** `schemas/delivery.capnp`
|
||||
**File ID:** `@0xc5d9e2b4f1a83076`
|
||||
|
||||
The `DeliveryService` interface defines the RPC contract for the store-and-forward message relay. The DS is intentionally MLS-unaware: it routes opaque byte strings by recipient key and optional channel ID without parsing or inspecting the content.
|
||||
|
||||
---
|
||||
|
||||
## Full schema listing
|
||||
|
||||
```capnp
|
||||
# delivery.capnp -- Delivery Service RPC interface.
|
||||
#
|
||||
# The Delivery Service is a simple store-and-forward relay. It does not parse
|
||||
# MLS messages -- all payloads are opaque byte strings routed by recipient key.
|
||||
#
|
||||
# Callers are responsible for:
|
||||
# - Routing Welcome messages to the correct new member after add_members().
|
||||
# - Routing Commit messages to any existing group members (other than self).
|
||||
# - Routing Application messages to the intended recipient(s).
|
||||
#
|
||||
# The DS indexes queues by the recipient's raw Ed25519 public key (32 bytes),
|
||||
# matching the indexing scheme used by the Authentication Service.
|
||||
#
|
||||
# ID generated with: capnp id
|
||||
@0xc5d9e2b4f1a83076;
|
||||
|
||||
interface DeliveryService {
|
||||
# Enqueue an opaque payload for delivery to a recipient.
|
||||
#
|
||||
# recipientKey : Ed25519 public key of the intended recipient (exactly 32 bytes).
|
||||
# payload : Opaque byte string -- a TLS-encoded MlsMessageOut blob or any
|
||||
# other framed data the application layer wants to deliver.
|
||||
# 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).
|
||||
#
|
||||
# The payload is appended to the recipient's FIFO queue. Returns immediately;
|
||||
# the recipient retrieves it via `fetch`.
|
||||
enqueue @0 (recipientKey :Data, payload :Data, channelId :Data, version :UInt16) -> ();
|
||||
|
||||
# Fetch and atomically drain all queued payloads for a given recipient.
|
||||
#
|
||||
# recipientKey : Ed25519 public key of the caller (exactly 32 bytes).
|
||||
# channelId : Optional channel identifier (empty for legacy).
|
||||
# version : Schema/wire version. Must be 0 (legacy) or 1 (this spec).
|
||||
#
|
||||
# Returns the complete queue in FIFO order and clears it. Returns an empty
|
||||
# list if there are no pending messages.
|
||||
fetch @1 (recipientKey :Data, channelId :Data, version :UInt16) -> (payloads :List(Data));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Method-by-method analysis
|
||||
|
||||
### `enqueue @0`
|
||||
|
||||
```
|
||||
enqueue (recipientKey :Data, payload :Data, channelId :Data, version :UInt16) -> ()
|
||||
```
|
||||
|
||||
**Purpose:** Append an opaque payload to a recipient's delivery queue. The DS stores the payload until the recipient fetches it. The call returns immediately after the payload is enqueued; it does not block until delivery.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Size | Description |
|
||||
|---|---|---|---|
|
||||
| `recipientKey` | `Data` | Exactly 32 bytes | Ed25519 public key of the intended recipient. Used as the primary queue index. |
|
||||
| `payload` | `Data` | Variable (bounded by transport max) | Opaque byte string. Typically a TLS-encoded `MlsMessageOut` blob, but the DS does not inspect it. |
|
||||
| `channelId` | `Data` | 0 bytes (legacy) or 16 bytes (UUID) | Channel identifier for channel-aware routing. Empty `Data` is treated as the legacy default channel. |
|
||||
| `version` | `UInt16` | 2 bytes | Schema/wire version. `0` = legacy (no channel routing), `1` = current spec (channel-aware). |
|
||||
|
||||
**Return value:** Void. The method returns `()` on success. Errors are surfaced as Cap'n Proto RPC exceptions.
|
||||
|
||||
**Queue semantics:** Payloads are appended in FIFO order. The DS does not deduplicate, reorder, or inspect payloads. Multiple enqueue calls for the same recipient and channel ID are simply appended to the queue in the order they arrive.
|
||||
|
||||
### `fetch @1`
|
||||
|
||||
```
|
||||
fetch (recipientKey :Data, channelId :Data, version :UInt16) -> (payloads :List(Data))
|
||||
```
|
||||
|
||||
**Purpose:** Fetch and atomically drain all queued payloads for a given recipient on a given channel. This is the "pull" side of the store-and-forward relay.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Size | Description |
|
||||
|---|---|---|---|
|
||||
| `recipientKey` | `Data` | Exactly 32 bytes | Ed25519 public key of the caller. Must match the key used in the enqueue calls. |
|
||||
| `channelId` | `Data` | 0 bytes (legacy) or 16 bytes (UUID) | Channel identifier. Must match the `channelId` used during enqueue. |
|
||||
| `version` | `UInt16` | 2 bytes | Schema/wire version. Must match the version used during enqueue. |
|
||||
|
||||
**Return value:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `payloads` | `List(Data)` | All queued payloads in FIFO order. Empty list if no messages are pending. |
|
||||
|
||||
**Atomic drain:** The fetch operation returns the entire queue and clears it in a single atomic operation. There is no "peek" or partial fetch. This simplifies the concurrency model: the client processes all returned payloads and does not need to track which ones it has already seen.
|
||||
|
||||
---
|
||||
|
||||
## Channel-aware routing
|
||||
|
||||
The `channelId` field enables per-channel queue separation. Each unique `(recipientKey, channelId)` pair maps to an independent FIFO queue on the server.
|
||||
|
||||
### Compound key structure
|
||||
|
||||
```text
|
||||
Queue Key = recipientKey (32 bytes) || channelId (0 or 16 bytes)
|
||||
```
|
||||
|
||||
When `channelId` is empty (0 bytes), the queue key degenerates to just the `recipientKey`, preserving backward compatibility with legacy clients that do not use channels.
|
||||
|
||||
### Channel ID format
|
||||
|
||||
The recommended format for `channelId` is a 16-byte UUID (128-bit, typically UUID v4). The DS treats the channel ID as an opaque byte string and does not parse its structure. Using UUIDs provides:
|
||||
|
||||
1. **Collision resistance** -- 2^122 random bits (for UUID v4) makes accidental collision negligible.
|
||||
2. **Privacy** -- The channel ID reveals no information about the channel's participants or purpose.
|
||||
3. **Fixed size** -- 16 bytes is compact and predictable for indexing.
|
||||
|
||||
### Use cases
|
||||
|
||||
| Scenario | channelId | recipientKey | Result |
|
||||
|---|---|---|---|
|
||||
| Legacy client, no channels | Empty (0 bytes) | Alice's Ed25519 key | Single queue for all of Alice's messages |
|
||||
| 1:1 channel between Alice and Bob | UUID of the 1:1 channel | Alice's Ed25519 key | Separate queue for this specific channel |
|
||||
| Group channel | UUID of the group channel | Alice's Ed25519 key | Separate queue for this group's messages to Alice |
|
||||
|
||||
---
|
||||
|
||||
## Version field
|
||||
|
||||
The `version` field provides a mechanism for wire-level schema evolution without breaking existing clients.
|
||||
|
||||
| Version | Semantics |
|
||||
|---|---|
|
||||
| `0` | Legacy mode. `channelId` is ignored (treated as empty). Behaves like the pre-channel DeliveryService. |
|
||||
| `1` | Current specification. `channelId` is used for channel-aware routing. |
|
||||
|
||||
The server validates the version field and rejects unknown versions as protocol errors. Clients must set the version field to match the schema revision they implement.
|
||||
|
||||
---
|
||||
|
||||
## FIFO queue semantics
|
||||
|
||||
The Delivery Service provides strict FIFO ordering within each `(recipientKey, channelId)` queue:
|
||||
|
||||
1. **Enqueue order is preserved.** Payloads are returned by `fetch` in the exact order they were enqueued.
|
||||
2. **Atomic drain.** Each `fetch` call returns all pending payloads and clears the queue. There is no risk of partial reads or interleaving.
|
||||
3. **No persistence guarantees (current implementation).** The in-memory queue is lost on server restart. Persistent storage is planned for a future milestone.
|
||||
4. **No redelivery.** Once a payload is returned by `fetch`, it is permanently removed. If the client crashes before processing it, the payload is lost. Reliable delivery with acknowledgments is a future enhancement.
|
||||
|
||||
---
|
||||
|
||||
## MLS-unaware design
|
||||
|
||||
The DS intentionally does not parse, validate, or inspect MLS messages. All payloads are opaque `Data` blobs. This design has several consequences:
|
||||
|
||||
- **Security:** The server cannot extract plaintext from MLS ciphertext, even if compromised.
|
||||
- **Simplicity:** The DS has no dependency on openmls or any MLS library.
|
||||
- **Flexibility:** The same DS can carry non-MLS payloads (e.g., signaling, metadata) without modification.
|
||||
- **No server-side optimization:** The DS cannot optimize delivery based on MLS message type (e.g., fanning out a Commit to all group members). The client must enqueue separately for each recipient.
|
||||
|
||||
For the full design rationale, see [ADR-004: MLS-Unaware Delivery Service](../design-rationale/adr-004-mls-unaware-ds.md).
|
||||
|
||||
---
|
||||
|
||||
## Relationship to NodeService
|
||||
|
||||
In the current unified architecture, the Delivery Service methods are exposed as part of the [NodeService interface](node-service-schema.md) with additional methods:
|
||||
|
||||
| DeliveryService Method | NodeService Method | Additional Parameters |
|
||||
|---|---|---|
|
||||
| `enqueue @0` | `enqueue @2` | `auth :Auth` |
|
||||
| `fetch @1` | `fetch @3` | `auth :Auth` |
|
||||
| *(none)* | `fetchWait @4` | `auth :Auth`, `timeoutMs :UInt64` |
|
||||
|
||||
The `fetchWait` method is a NodeService extension that provides long-polling semantics: it blocks until either new payloads arrive or the timeout expires. This avoids the latency and bandwidth overhead of repeated `fetch` polling.
|
||||
|
||||
---
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Wire Format Overview](overview.md) -- serialisation pipeline context
|
||||
- [NodeService Schema](node-service-schema.md) -- unified interface that subsumes DeliveryService
|
||||
- [Auth Schema](auth-schema.md) -- the companion service for KeyPackage management
|
||||
- [Envelope Schema](envelope-schema.md) -- legacy framing that used `mlsWelcome`/`mlsCommit`/`mlsApplication` message types
|
||||
- [ADR-004: MLS-Unaware Delivery Service](../design-rationale/adr-004-mls-unaware-ds.md) -- why the DS does not inspect MLS content
|
||||
151
docs/src/wire-format/envelope-schema.md
Normal file
151
docs/src/wire-format/envelope-schema.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Envelope Schema
|
||||
|
||||
**Schema file:** `schemas/envelope.capnp`
|
||||
**File ID:** `@0xe4a7f2c8b1d63509`
|
||||
|
||||
The Envelope is the legacy top-level wire message used in M1 for all quicnprotochat traffic over the Noise channel. Every frame exchanged between peers was serialised as an Envelope, with the Delivery Service routing by `(groupId, msgType)` without inspecting the payload.
|
||||
|
||||
> **Note:** The Envelope is the M1-era framing format. The current M3+ architecture uses Cap'n Proto RPC directly via the [NodeService](node-service-schema.md) interface. The Envelope schema remains in the codebase for backward compatibility and for use in integration tests that exercise the Noise transport path.
|
||||
|
||||
---
|
||||
|
||||
## Full schema listing
|
||||
|
||||
```capnp
|
||||
# envelope.capnp -- top-level wire message for all quicnprotochat traffic.
|
||||
#
|
||||
# Every frame exchanged over the Noise channel is serialised as an Envelope.
|
||||
# The Delivery Service routes by (groupId, msgType) without inspecting payload.
|
||||
#
|
||||
# Field sizing rationale:
|
||||
# groupId / senderId : 32 bytes -- SHA-256 digest
|
||||
# payload : opaque -- MLS blob or control data; size bounded by
|
||||
# the Noise transport max message size (65535 B)
|
||||
# timestampMs : UInt64 -- unix epoch milliseconds; sufficient until year 292M
|
||||
#
|
||||
# ID generated with: capnp id
|
||||
@0xe4a7f2c8b1d63509;
|
||||
|
||||
struct Envelope {
|
||||
# Message type discriminant -- determines how payload is interpreted.
|
||||
msgType @0 :MsgType;
|
||||
|
||||
# 32-byte SHA-256 digest of the group name.
|
||||
# The Delivery Service uses this as its routing key.
|
||||
# Zero-filled for point-to-point control messages (ping, keyPackageUpload, etc.).
|
||||
groupId @1 :Data;
|
||||
|
||||
# 32-byte SHA-256 digest of the sender's Ed25519 identity public key.
|
||||
senderId @2 :Data;
|
||||
|
||||
# Opaque payload. Interpretation is determined by msgType.
|
||||
payload @3 :Data;
|
||||
|
||||
# Unix timestamp in milliseconds at the time of send.
|
||||
timestampMs @4 :UInt64;
|
||||
|
||||
enum MsgType {
|
||||
ping @0;
|
||||
pong @1;
|
||||
keyPackageUpload @2;
|
||||
keyPackageFetch @3;
|
||||
keyPackageResponse @4;
|
||||
mlsWelcome @5;
|
||||
mlsCommit @6;
|
||||
mlsApplication @7;
|
||||
error @8;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Field-by-field analysis
|
||||
|
||||
### `msgType @0 :MsgType`
|
||||
|
||||
A 16-bit enum discriminant (Cap'n Proto enums are encoded as UInt16). Determines how the `payload` field should be interpreted. The discriminant is the first field in the struct for efficient dispatch: a router can read the first two bytes of the struct section to decide how to handle the message without parsing any pointer fields.
|
||||
|
||||
### `groupId @1 :Data`
|
||||
|
||||
A 32-byte `Data` field containing the SHA-256 digest of the group name. The Delivery Service uses this as its primary routing key when the Envelope-based protocol is active.
|
||||
|
||||
**Sizing rationale:** SHA-256 produces a 32-byte (256-bit) digest. This is stored as a variable-length `Data` field rather than a fixed-size blob because Cap'n Proto does not have a fixed-size array type. Implementations must validate that the field contains exactly 32 bytes.
|
||||
|
||||
**Special case:** For point-to-point control messages (`ping`, `pong`, `keyPackageUpload`, `keyPackageFetch`), the `groupId` is zero-filled (32 zero bytes) because these messages are not associated with any group.
|
||||
|
||||
### `senderId @2 :Data`
|
||||
|
||||
A 32-byte `Data` field containing the SHA-256 digest of the sender's Ed25519 identity public key. This allows the receiver to identify the sender without inspecting the MLS-layer credentials.
|
||||
|
||||
**Sizing rationale:** Same as `groupId` -- SHA-256 digest, 32 bytes.
|
||||
|
||||
### `payload @3 :Data`
|
||||
|
||||
An opaque byte string whose interpretation depends on `msgType`. The payload is bounded by the Noise transport maximum message size of 65,535 bytes (see [Framing Codec](framing-codec.md)).
|
||||
|
||||
### `timestampMs @4 :UInt64`
|
||||
|
||||
Unix epoch timestamp in milliseconds, set by the sender at the time of send. Encoded as a `UInt64`, which provides sufficient range until approximately year 292,000,000 -- effectively unlimited for practical purposes.
|
||||
|
||||
The timestamp is sender-asserted and **not** authenticated by the server. Receivers should treat it as advisory (for display ordering) rather than authoritative.
|
||||
|
||||
---
|
||||
|
||||
## MsgType enum
|
||||
|
||||
The `MsgType` enum defines nine message types. Each variant determines how the `payload` field is interpreted:
|
||||
|
||||
| Ordinal | Variant | Payload Contents | Direction |
|
||||
|---|---|---|---|
|
||||
| 0 | `ping` | Empty | Client -> Server or Peer -> Peer |
|
||||
| 1 | `pong` | Empty | Server -> Client or Peer -> Peer |
|
||||
| 2 | `keyPackageUpload` | openmls-serialised KeyPackage blob (TLS encoding) | Client -> Server |
|
||||
| 3 | `keyPackageFetch` | Target identity key (32 bytes, raw Ed25519 public key) | Client -> Server |
|
||||
| 4 | `keyPackageResponse` | openmls-serialised KeyPackage blob, or empty if none stored | Server -> Client |
|
||||
| 5 | `mlsWelcome` | `MLSMessage` blob (Welcome variant) | Peer -> Peer (via DS) |
|
||||
| 6 | `mlsCommit` | `MLSMessage` blob (PublicMessage / Commit variant) | Peer -> Group (via DS) |
|
||||
| 7 | `mlsApplication` | `MLSMessage` blob (PrivateMessage / Application variant) | Peer -> Group (via DS) |
|
||||
| 8 | `error` | UTF-8 error description string | Any direction |
|
||||
|
||||
### Control messages (0-1)
|
||||
|
||||
`ping` and `pong` are keepalive probes with empty payloads. They serve as health checks over long-lived Noise connections.
|
||||
|
||||
### Authentication messages (2-4)
|
||||
|
||||
`keyPackageUpload`, `keyPackageFetch`, and `keyPackageResponse` implement the Authentication Service protocol over the Envelope format. In the current architecture, these operations are handled by the [NodeService RPC](node-service-schema.md) methods `uploadKeyPackage` and `fetchKeyPackage` instead.
|
||||
|
||||
### MLS messages (5-7)
|
||||
|
||||
`mlsWelcome`, `mlsCommit`, and `mlsApplication` carry MLS protocol messages as opaque blobs. The Envelope does not inspect or validate the MLS content; it simply transports the bytes between peers via the Delivery Service.
|
||||
|
||||
### Error messages (8)
|
||||
|
||||
`error` carries a UTF-8 string describing an error condition. Used for protocol-level error reporting (e.g., "no KeyPackage found for identity").
|
||||
|
||||
---
|
||||
|
||||
## Relationship to NodeService
|
||||
|
||||
The Envelope schema was the original M1 wire format, where all communication was multiplexed over a single Noise-encrypted TCP stream. With the transition to QUIC + TLS 1.3 and Cap'n Proto RPC in M3, the Envelope's role has been superseded by the [NodeService interface](node-service-schema.md), which provides typed RPC methods for each operation.
|
||||
|
||||
The key differences:
|
||||
|
||||
| Aspect | Envelope (M1) | NodeService RPC (M3+) |
|
||||
|---|---|---|
|
||||
| Dispatch | Manual, based on `msgType` enum | Automatic, Cap'n Proto RPC method dispatch |
|
||||
| Type safety | Payload is opaque `Data` | Each method has typed parameters and return values |
|
||||
| Transport | Noise\_XX over TCP | QUIC + TLS 1.3 |
|
||||
| Auth | Implicit (Noise handshake authenticates peers) | Explicit `Auth` struct per method call |
|
||||
|
||||
---
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Wire Format Overview](overview.md) -- serialisation pipeline context
|
||||
- [NodeService Schema](node-service-schema.md) -- the current RPC interface that replaced Envelope-based dispatch
|
||||
- [Auth Schema](auth-schema.md) -- standalone Authentication Service interface
|
||||
- [Delivery Schema](delivery-schema.md) -- standalone Delivery Service interface
|
||||
- [Framing Codec](framing-codec.md) -- length-prefixed framing that wraps serialised Envelopes
|
||||
- [ADR-002: Cap'n Proto over MessagePack](../design-rationale/adr-002-capnproto.md) -- why Cap'n Proto was chosen for the wire format
|
||||
221
docs/src/wire-format/framing-codec.md
Normal file
221
docs/src/wire-format/framing-codec.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# Length-Prefixed Framing Codec
|
||||
|
||||
**Source file:** `crates/quicnprotochat-core/src/codec.rs`
|
||||
|
||||
The `LengthPrefixedCodec` is a stateless Tokio codec that frames byte payloads with a 4-byte little-endian length prefix. It is the bridge between Cap'n Proto serialisation (which produces a byte buffer of variable length) and the Noise transport (which needs discrete message boundaries over a TCP byte stream).
|
||||
|
||||
---
|
||||
|
||||
## Wire format
|
||||
|
||||
```text
|
||||
+----------------------------+--------------------------------------+
|
||||
| length (4 bytes, LE u32) | payload (length bytes) |
|
||||
+----------------------------+--------------------------------------+
|
||||
```
|
||||
|
||||
Each frame consists of:
|
||||
|
||||
1. A **4-byte length field** encoded as a little-endian unsigned 32-bit integer (`u32`). This gives a theoretical maximum payload size of 4,294,967,295 bytes, but the actual limit is much lower (see below).
|
||||
2. A **payload** of exactly `length` bytes. The codec treats the payload as opaque -- it does not inspect or interpret the bytes.
|
||||
|
||||
### Byte order: little-endian
|
||||
|
||||
The length prefix uses **little-endian** byte order. This was a deliberate choice for consistency with Cap'n Proto's segment table encoding, which also uses little-endian 32-bit integers. Benefits of this choice:
|
||||
|
||||
- **No endianness confusion.** A developer inspecting a raw byte dump sees uniform little-endian encoding throughout the entire frame (length header + Cap'n Proto header + Cap'n Proto data).
|
||||
- **Native performance on common architectures.** x86-64 and AArch64 (in its default little-endian mode) can read the length field without byte-swapping.
|
||||
- **Alignment with Cap'n Proto conventions.** Cap'n Proto defines its canonical byte order as little-endian (segment count and segment sizes are LE u32).
|
||||
|
||||
### Example encoding
|
||||
|
||||
For the ASCII payload `"le-check"` (8 bytes), the encoded frame is:
|
||||
|
||||
```text
|
||||
Offset Hex Meaning
|
||||
------ ------------------ -------
|
||||
0x00 08 00 00 00 Length = 8 (little-endian)
|
||||
0x04 6C 65 2D 63 68 65 Payload: "le-che"
|
||||
0x0A 63 6B Payload: "ck"
|
||||
```
|
||||
|
||||
Total frame size: 4 (header) + 8 (payload) = 12 bytes.
|
||||
|
||||
---
|
||||
|
||||
## Frame size limit
|
||||
|
||||
```rust
|
||||
/// Maximum Noise protocol message size in bytes (per RFC / Noise spec S3).
|
||||
pub const NOISE_MAX_MSG: usize = 65_535;
|
||||
```
|
||||
|
||||
The maximum payload size is **65,535 bytes** (64 KiB - 1), matching the Noise protocol specification's maximum message size. This constant is defined as `NOISE_MAX_MSG` in the codec module.
|
||||
|
||||
Any frame with a payload exceeding this limit is rejected as a protocol violation:
|
||||
|
||||
- **On encode:** `Encoder::encode()` returns `CodecError::FrameTooLarge` before writing any bytes to the buffer.
|
||||
- **On decode:** `Decoder::decode()` returns `CodecError::FrameTooLarge` upon reading a length field that exceeds the limit, without attempting to read the payload bytes.
|
||||
|
||||
In both cases, the error is **unrecoverable**. The connection should be closed rather than retried, because an oversized frame indicates either a bug or a malicious peer.
|
||||
|
||||
### Relationship to Noise plaintext limit
|
||||
|
||||
The `NOISE_MAX_MSG` constant (65,535 bytes) represents the maximum Noise *message* size, which includes the Poly1305 authentication tag (16 bytes). The maximum *plaintext* per Noise transport frame is therefore:
|
||||
|
||||
```rust
|
||||
/// Maximum plaintext bytes per Noise transport frame.
|
||||
pub const MAX_PLAINTEXT_LEN: usize = 65_519; // 65,535 - 16
|
||||
```
|
||||
|
||||
This constant is defined in `crates/quicnprotochat-core/src/error.rs`. The codec operates at the ciphertext level (framing Noise messages, not plaintext), so it uses `NOISE_MAX_MSG` as its limit.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
The codec implements Tokio's `Encoder<Bytes>` and `Decoder` traits, making it compatible with `tokio_util::codec::Framed` for use with any `AsyncRead + AsyncWrite` stream.
|
||||
|
||||
### Struct
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct LengthPrefixedCodec;
|
||||
```
|
||||
|
||||
The codec is **stateless** -- it holds no internal buffering state. This means it is `Clone`, `Copy`, and `Default`, and multiple codec instances are interchangeable.
|
||||
|
||||
### Encoder
|
||||
|
||||
```rust
|
||||
impl Encoder<Bytes> for LengthPrefixedCodec {
|
||||
type Error = CodecError;
|
||||
|
||||
fn encode(&mut self, item: Bytes, dst: &mut BytesMut) -> Result<(), Self::Error> {
|
||||
let len = item.len();
|
||||
if len > NOISE_MAX_MSG {
|
||||
return Err(CodecError::FrameTooLarge {
|
||||
len,
|
||||
max: NOISE_MAX_MSG,
|
||||
});
|
||||
}
|
||||
dst.reserve(4 + len);
|
||||
dst.put_u32_le(len as u32);
|
||||
dst.extend_from_slice(&item);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Check payload size against `NOISE_MAX_MSG`. Reject if oversized.
|
||||
2. Reserve exactly `4 + len` bytes in the output buffer to avoid reallocation.
|
||||
3. Write the 4-byte little-endian length prefix.
|
||||
4. Copy the payload bytes.
|
||||
|
||||
### Decoder
|
||||
|
||||
```rust
|
||||
impl Decoder for LengthPrefixedCodec {
|
||||
type Item = BytesMut;
|
||||
type Error = CodecError;
|
||||
|
||||
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
|
||||
if src.len() < 4 {
|
||||
src.reserve(4_usize.saturating_sub(src.len()));
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let frame_len = u32::from_le_bytes([src[0], src[1], src[2], src[3]]) as usize;
|
||||
|
||||
if frame_len > NOISE_MAX_MSG {
|
||||
return Err(CodecError::FrameTooLarge {
|
||||
len: frame_len,
|
||||
max: NOISE_MAX_MSG,
|
||||
});
|
||||
}
|
||||
|
||||
let total = 4 + frame_len;
|
||||
if src.len() < total {
|
||||
src.reserve(total - src.len());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
src.advance(4);
|
||||
Ok(Some(src.split_to(frame_len)))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. **Check for header completeness.** If fewer than 4 bytes are available, reserve the remaining bytes and return `Ok(None)` (the standard Tokio Decoder contract for "need more data").
|
||||
2. **Peek at the length field** without advancing the cursor. This avoids mutating buffer state when the full frame is not yet available.
|
||||
3. **Validate the length.** If it exceeds `NOISE_MAX_MSG`, return an error immediately.
|
||||
4. **Check for payload completeness.** If fewer than `4 + frame_len` bytes are available, reserve the difference and return `Ok(None)`.
|
||||
5. **Consume the frame.** Advance past the 4-byte header, then split the payload from the front of the buffer.
|
||||
|
||||
The `reserve()` calls in steps 1 and 4 are a performance optimization: they hint to Tokio how many additional bytes the decoder needs, avoiding O(n) polling behavior where the decoder is called once per incoming byte.
|
||||
|
||||
---
|
||||
|
||||
## Error handling
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CodecError {
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("frame length {len} exceeds maximum {max} bytes")]
|
||||
FrameTooLarge { len: usize, max: usize },
|
||||
}
|
||||
```
|
||||
|
||||
The codec produces two error variants:
|
||||
|
||||
| Variant | Cause | Recovery |
|
||||
|---|---|---|
|
||||
| `Io` | The underlying TCP stream returned an I/O error. Auto-converted from `std::io::Error` via the `From` impl required by `tokio-util`. | Depends on the I/O error. Typically the connection is broken and should be dropped. |
|
||||
| `FrameTooLarge` | A frame's length field exceeds `NOISE_MAX_MSG` (65,535 bytes). | **Unrecoverable.** The connection should be closed. An oversized frame indicates a protocol violation -- either a bug or a malicious peer. |
|
||||
|
||||
---
|
||||
|
||||
## Transport context
|
||||
|
||||
The `LengthPrefixedCodec` is used in the **Noise transport path** (M1 stack), where Cap'n Proto messages and Noise handshake messages are sent over a raw TCP stream that has no built-in message boundaries.
|
||||
|
||||
In the **QUIC transport path** (M3+ stack), the codec is **not used**. QUIC provides native stream framing through its stream abstraction, and the `capnp-rpc` crate handles message delimitation internally. The QUIC path also does not need the 65,535-byte frame limit because QUIC flow control operates at a different level.
|
||||
|
||||
```text
|
||||
Noise path: App -> Cap'n Proto -> LengthPrefixedCodec -> Noise encrypt -> TCP
|
||||
QUIC path: App -> Cap'n Proto RPC -> capnp-rpc stream adapter -> QUIC stream -> UDP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test coverage
|
||||
|
||||
The codec module includes comprehensive tests that verify:
|
||||
|
||||
| Test | What it validates |
|
||||
|---|---|
|
||||
| `round_trip_empty_payload` | Empty payloads encode and decode correctly (0-length frame) |
|
||||
| `round_trip_small_payload` | Small payloads survive a round trip without corruption |
|
||||
| `round_trip_max_size_payload` | A payload of exactly `NOISE_MAX_MSG` bytes (the maximum) encodes and decodes correctly |
|
||||
| `oversized_encode_returns_error` | Encoding a payload of `NOISE_MAX_MSG + 1` bytes returns `FrameTooLarge` |
|
||||
| `oversized_length_field_decode_returns_error` | Decoding a frame with a length field exceeding `NOISE_MAX_MSG` returns `FrameTooLarge` |
|
||||
| `partial_payload_returns_none` | A frame with a valid header but incomplete payload returns `None` (need more data) |
|
||||
| `partial_header_returns_none` | A buffer with fewer than 4 bytes returns `None` (need more data) |
|
||||
| `length_field_is_little_endian` | The encoded length of `"le-check"` (8 bytes) produces `[0x08, 0x00, 0x00, 0x00]` |
|
||||
|
||||
---
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Wire Format Overview](overview.md) -- where the codec fits in the serialisation pipeline
|
||||
- [Envelope Schema](envelope-schema.md) -- the Cap'n Proto messages that the codec frames (M1 path)
|
||||
- [NodeService Schema](node-service-schema.md) -- the RPC messages carried over QUIC (M3+ path, does not use this codec)
|
||||
- [ADR-003: RPC Inside the Noise Tunnel](../design-rationale/adr-003-rpc-inside-noise.md) -- why the codec sits between Cap'n Proto and Noise
|
||||
- [Protocol Layers Overview](../protocol-layers/overview.md) -- how all the layers stack
|
||||
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
|
||||
107
docs/src/wire-format/overview.md
Normal file
107
docs/src/wire-format/overview.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Wire Format Overview
|
||||
|
||||
This section documents the serialisation pipeline that transforms application-level data structures into encrypted bytes on the wire. Every byte exchanged between quicnprotochat clients and the server passes through this pipeline, so understanding it is prerequisite to reading the protocol deep dives or the server/client source code.
|
||||
|
||||
---
|
||||
|
||||
## Serialisation pipeline
|
||||
|
||||
Data flows through four stages on the send path. The receive path reverses the order.
|
||||
|
||||
```text
|
||||
Stage 1 Stage 2 Stage 3 Stage 4
|
||||
-------- -------- -------- --------
|
||||
Application Cap'n Proto Length-prefixed Transport
|
||||
data serialisation framing encryption
|
||||
|
||||
ParsedEnvelope capnp::serialize [u32 LE len][payload] Noise ChaCha20-Poly1305
|
||||
or RPC call (zero-copy bytes) or QUIC/TLS 1.3
|
||||
|
||||
| | | |
|
||||
v v v v
|
||||
Rust structs Canonical byte Framed byte stream Encrypted
|
||||
& method representation ready for transport ciphertext
|
||||
invocations (no deserialization on the wire
|
||||
needed on receive)
|
||||
```
|
||||
|
||||
### Stage 1: Application creates a message or RPC call
|
||||
|
||||
At the application layer, the client or server constructs a typed Cap'n Proto message. In the legacy Envelope path (M1), this means building an `Envelope` struct with a `MsgType` discriminant, group ID, sender ID, and opaque payload. In the current NodeService path (M3+), this means invoking a Cap'n Proto RPC method such as `enqueue()` or `fetchKeyPackage()`.
|
||||
|
||||
- **Envelope** (legacy): see [Envelope Schema](envelope-schema.md)
|
||||
- **NodeService** (current): see [NodeService Schema](node-service-schema.md)
|
||||
- **AuthenticationService** (standalone): see [Auth Schema](auth-schema.md)
|
||||
- **DeliveryService** (standalone): see [Delivery Schema](delivery-schema.md)
|
||||
|
||||
### Stage 2: Cap'n Proto serialises to bytes
|
||||
|
||||
Cap'n Proto converts the in-memory message to its canonical wire representation. This is a **zero-copy** format: the byte layout in memory is identical to the byte layout on the wire. No serialisation or deserialisation pass is required; readers can traverse the bytes in-place using pointer arithmetic.
|
||||
|
||||
The wire representation consists of:
|
||||
|
||||
1. A **segment table** -- a list of segment sizes encoded as little-endian 32-bit integers.
|
||||
2. One or more **segments** -- contiguous runs of 8-byte aligned words containing struct data, list data, and far pointers.
|
||||
|
||||
Cap'n Proto's canonical form is deterministic for a given message, which makes it suitable for signing: two implementations that build the same logical message will produce identical bytes.
|
||||
|
||||
### Stage 3: Length-prefixed framing
|
||||
|
||||
Before the serialised bytes enter the transport, they are wrapped in a length-prefixed frame:
|
||||
|
||||
```text
|
||||
+----------------------------+--------------------------------------+
|
||||
| length (4 bytes, LE u32) | payload (length bytes) |
|
||||
+----------------------------+--------------------------------------+
|
||||
```
|
||||
|
||||
The length prefix is encoded as a **little-endian** 32-bit unsigned integer. Little-endian was chosen for consistency with Cap'n Proto's own segment table encoding, which also uses little-endian integers. This avoids byte-order confusion when the same buffer contains both framing headers and Cap'n Proto data.
|
||||
|
||||
The maximum payload size is **65,535 bytes**, matching the Noise protocol's maximum message size. Frames exceeding this limit are rejected as protocol violations. See [Framing Codec](framing-codec.md) for the full `LengthPrefixedCodec` implementation.
|
||||
|
||||
> **Note:** This framing stage applies only to the Noise transport path. The QUIC transport uses native QUIC stream framing, which provides its own length delimitation. Cap'n Proto RPC over QUIC relies on the `capnp-rpc` crate's built-in stream adapter rather than `LengthPrefixedCodec`.
|
||||
|
||||
### Stage 4: Transport encryption
|
||||
|
||||
The framed byte stream is encrypted by the transport layer:
|
||||
|
||||
| Transport | Encryption | Authentication | When Used |
|
||||
|---|---|---|---|
|
||||
| **Noise\_XX over TCP** | ChaCha20-Poly1305 (per-session key from XX handshake) | Mutual, via static X25519 keys | M1 stack, peer-to-peer, integration tests |
|
||||
| **QUIC + TLS 1.3** | AES-128-GCM or ChaCha20-Poly1305 (negotiated by TLS) | Server cert (rustls/quinn) | M3+ primary transport |
|
||||
|
||||
In both cases, the transport layer treats the payload as opaque bytes. It does not inspect or interpret the Cap'n Proto content. This clean separation means the serialisation format can evolve independently of the transport.
|
||||
|
||||
---
|
||||
|
||||
## Little-endian framing rationale
|
||||
|
||||
Cap'n Proto uses little-endian encoding for its segment table (the header that precedes each serialised message). The `LengthPrefixedCodec` uses the same byte order for its 4-byte length field. This consistency means:
|
||||
|
||||
1. A developer inspecting a raw byte dump sees uniform endianness throughout.
|
||||
2. On little-endian architectures (x86-64, AArch64 in LE mode), both the framing header and the Cap'n Proto header can be read without byte-swapping.
|
||||
3. There is no risk of accidentally mixing big-endian and little-endian headers in the same stream.
|
||||
|
||||
---
|
||||
|
||||
## Schema index
|
||||
|
||||
The Cap'n Proto schemas that define the wire-level messages are documented on dedicated pages:
|
||||
|
||||
| Schema File | Documentation Page | Purpose |
|
||||
|---|---|---|
|
||||
| `schemas/envelope.capnp` | [Envelope Schema](envelope-schema.md) | Legacy message envelope (M1) |
|
||||
| `schemas/auth.capnp` | [Auth Schema](auth-schema.md) | Authentication Service RPC interface |
|
||||
| `schemas/delivery.capnp` | [Delivery Schema](delivery-schema.md) | Delivery Service RPC interface |
|
||||
| `schemas/node.capnp` | [NodeService Schema](node-service-schema.md) | Unified node RPC (current) |
|
||||
|
||||
The length-prefixed framing codec that wraps serialised messages is documented at [Framing Codec](framing-codec.md).
|
||||
|
||||
---
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Architecture Overview](../architecture/overview.md) -- system-level view of how services compose
|
||||
- [Protocol Layers Overview](../protocol-layers/overview.md) -- how transport, framing, and E2E encryption stack
|
||||
- [ADR-002: Cap'n Proto over MessagePack](../design-rationale/adr-002-capnproto.md) -- why Cap'n Proto was chosen
|
||||
- [ADR-003: RPC Inside the Noise Tunnel](../design-rationale/adr-003-rpc-inside-noise.md) -- why RPC runs inside the encrypted channel
|
||||
Reference in New Issue
Block a user