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:
261
docs/src/roadmap/dm-channels.md
Normal file
261
docs/src/roadmap/dm-channels.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# 1:1 Channel Design
|
||||
|
||||
This page describes the design for first-class 1:1 (direct message) channels in
|
||||
quicnprotochat. Channels provide per-conversation authorisation, MLS-encrypted
|
||||
payloads, message retention with TTL eviction, and backward compatibility with
|
||||
the legacy delivery model.
|
||||
|
||||
For the broader roadmap context, see [Milestones](milestones.md) and
|
||||
[Production Readiness WBS](production-readiness.md) (Phase 4).
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. **First-class 1:1 channels.** Each conversation between two participants has
|
||||
a unique `channelId`, enabling per-channel authorisation, storage, and
|
||||
eviction.
|
||||
2. **Per-channel authorisation.** The server enforces that only the two channel
|
||||
members can enqueue and fetch messages for a given channel.
|
||||
3. **MLS-encrypted payloads.** All message content is MLS ciphertext. The server
|
||||
never sees plaintext. Channel metadata (ID + participant keys) is the only
|
||||
information the server holds.
|
||||
4. **7-day message retention.** Messages older than 7 days are evicted. This is
|
||||
configurable but defaults to 7 days.
|
||||
5. **24-hour KeyPackage TTL.** KeyPackages expire after 24 hours. Clients must
|
||||
rotate KeyPackages before expiry to remain reachable.
|
||||
|
||||
---
|
||||
|
||||
## Schema Changes (Cap'n Proto)
|
||||
|
||||
### New Fields
|
||||
|
||||
The following fields are added to the existing `NodeService` RPC methods:
|
||||
|
||||
| RPC Method | New Field | Type | Description |
|
||||
|------------|-----------|------|-------------|
|
||||
| `enqueue` | `channelId` | `Data` (UUID, 16 bytes) | Target channel |
|
||||
| `fetch` | `channelId` | `Data` (UUID, 16 bytes) | Channel to fetch from |
|
||||
| `fetchWait` | `channelId` | `Data` (UUID, 16 bytes) | Channel to long-poll |
|
||||
| All messages | `version` | `UInt16` | Wire version for forward compat |
|
||||
|
||||
### Version Field
|
||||
|
||||
The `version` field on delivery messages allows the server to reject messages
|
||||
with unknown versions. The current version is `1`. Clients that do not set
|
||||
`channelId` are treated as version `0` (legacy mode).
|
||||
|
||||
### New RPC Method
|
||||
|
||||
A new `createChannel` method is added to `NodeService`:
|
||||
|
||||
```capnp
|
||||
createChannel @N (
|
||||
auth :Auth,
|
||||
peerKey :Data # Ed25519 public key of the other participant
|
||||
) -> (
|
||||
channelId :Data # UUID, 16 bytes
|
||||
);
|
||||
```
|
||||
|
||||
The server generates the `channelId`, stores the membership, and returns the ID
|
||||
to the caller. The peer discovers the channel when they receive a message
|
||||
addressed to it (or via a separate discovery mechanism in a future milestone).
|
||||
|
||||
---
|
||||
|
||||
## AuthZ Model
|
||||
|
||||
### Channel Membership
|
||||
|
||||
Each channel has exactly two members, identified by their Ed25519 public keys:
|
||||
|
||||
```
|
||||
Channel {
|
||||
channelId: UUID (16 bytes)
|
||||
members: {a_key: Ed25519PubKey, b_key: Ed25519PubKey}
|
||||
created_at: Timestamp
|
||||
}
|
||||
```
|
||||
|
||||
The server stores this mapping and enforces it on every operation.
|
||||
|
||||
### Enqueue Authorisation
|
||||
|
||||
When a client calls `enqueue(auth, channelId, recipientKey, payload)`:
|
||||
|
||||
1. Validate the `Auth` token (see [Auth, Devices, and Tokens](authz-plan.md)).
|
||||
2. Look up the channel by `channelId`.
|
||||
3. Verify that the caller's identity (from the token) is one of the channel's
|
||||
two members.
|
||||
4. Verify that `recipientKey` is the *other* member of the channel (prevents
|
||||
sending to yourself or to a non-member).
|
||||
5. Apply rate limits (50 r/s per identity, 5 MB payload cap).
|
||||
6. Enqueue the payload.
|
||||
|
||||
### Fetch Authorisation
|
||||
|
||||
When a client calls `fetch(auth, channelId, recipientKey)` or
|
||||
`fetchWait(auth, channelId, recipientKey, timeout)`:
|
||||
|
||||
1. Validate the `Auth` token.
|
||||
2. Verify that the caller's identity matches `recipientKey`.
|
||||
3. Verify that `recipientKey` is a member of the specified channel.
|
||||
4. Return messages for `(channelId, recipientKey)`, filtering out expired
|
||||
messages (TTL check).
|
||||
|
||||
---
|
||||
|
||||
## Storage Model
|
||||
|
||||
### Channels Table
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `channel_id` | UUID (16 bytes) | Primary key |
|
||||
| `member_a_key` | Ed25519 public key (32 bytes) | First member |
|
||||
| `member_b_key` | Ed25519 public key (32 bytes) | Second member |
|
||||
| `created_at` | Timestamp | Channel creation time |
|
||||
|
||||
A unique constraint on `(member_a_key, member_b_key)` (sorted) prevents
|
||||
duplicate channels between the same pair of identities.
|
||||
|
||||
### Delivery Queue
|
||||
|
||||
Messages are keyed by `(channelId, recipient_key)`:
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `channel_id` | UUID (16 bytes) | Channel |
|
||||
| `recipient_key` | Ed25519 public key (32 bytes) | Intended recipient |
|
||||
| `payload` | Bytes | MLS ciphertext (opaque to server) |
|
||||
| `received_at` | Timestamp | Server receive time |
|
||||
| `sequence_no` | UInt64 | Per-channel, per-recipient monotonic counter |
|
||||
|
||||
### TTL Eviction
|
||||
|
||||
Messages are evicted in two ways:
|
||||
|
||||
1. **Fetch-time check:** When a client fetches messages, the server filters out
|
||||
any message where `received_at + TTL < now`. This is the primary eviction
|
||||
path.
|
||||
2. **Background sweep:** A periodic task (configurable interval, default 1 hour)
|
||||
scans for and deletes expired messages. This prevents unbounded storage
|
||||
growth from inactive channels.
|
||||
|
||||
Default TTL values:
|
||||
|
||||
| Entity | TTL | Configurable |
|
||||
|--------|-----|-------------|
|
||||
| Messages | 7 days | Yes |
|
||||
| KeyPackages | 24 hours | Yes |
|
||||
|
||||
---
|
||||
|
||||
## Flows
|
||||
|
||||
### Create Channel
|
||||
|
||||
```
|
||||
Alice Server Bob
|
||||
| | |
|
||||
|-- createChannel(auth, bob_key) | |
|
||||
| |-- generate channelId |
|
||||
| |-- store {channelId, |
|
||||
| | alice_key, bob_key} |
|
||||
|<- channelId ------------------| |
|
||||
| | |
|
||||
```
|
||||
|
||||
Alice receives the `channelId` and can now send messages to Bob on this channel.
|
||||
Bob discovers the channel when he receives the first message (the `channelId` is
|
||||
included in the delivery metadata).
|
||||
|
||||
### Send (with AuthZ)
|
||||
|
||||
```
|
||||
Alice Server
|
||||
| |
|
||||
|-- enqueue(auth, channelId, |
|
||||
| bob_key, mls_ciphertext) |
|
||||
| |-- validate auth token
|
||||
| |-- lookup channel membership
|
||||
| |-- verify alice_key in members
|
||||
| |-- verify bob_key is recipient
|
||||
| |-- check rate limits
|
||||
| |-- store (channelId, bob_key,
|
||||
| | payload, received_at, seq)
|
||||
|<- ok (sequence_no) ------------|
|
||||
| |
|
||||
```
|
||||
|
||||
### Receive (with TTL)
|
||||
|
||||
```
|
||||
Bob Server
|
||||
| |
|
||||
|-- fetchWait(auth, channelId, |
|
||||
| bob_key, timeout) |
|
||||
| |-- validate auth token
|
||||
| |-- verify bob_key in channel
|
||||
| |-- query (channelId, bob_key)
|
||||
| |-- filter: received_at + 7d > now
|
||||
| |-- return non-expired messages
|
||||
|<- messages[] ------------------|
|
||||
| |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
### Legacy Mode (channelId = nil)
|
||||
|
||||
When `channelId` is empty or absent:
|
||||
|
||||
- The server treats the request as a legacy delivery (pre-channel behavior).
|
||||
- Messages are routed solely by `recipientKey`, without channel-level authz.
|
||||
- This mode can be disabled in production via server configuration.
|
||||
|
||||
### Version Negotiation
|
||||
|
||||
The `version` field on delivery messages allows clean rejection of future schema
|
||||
changes:
|
||||
|
||||
| Version | Behavior |
|
||||
|---------|----------|
|
||||
| 0 | Legacy mode: no `channelId`, no per-channel authz |
|
||||
| 1 | Channel-aware: `channelId` required, authz enforced |
|
||||
|
||||
The server rejects messages with `version > max_supported`.
|
||||
|
||||
---
|
||||
|
||||
## Open Items
|
||||
|
||||
These items are deferred to future milestones:
|
||||
|
||||
- **Persistence backend:** The current `DashMap`-based store must be extended to
|
||||
SQLite (or SQLCipher) for durable channel and delivery state. See
|
||||
[Milestones: M6](milestones.md#m6----persistence-planned).
|
||||
- **Channel discovery API:** A dedicated RPC for Bob to discover channels he is
|
||||
a member of, rather than relying on first-message discovery.
|
||||
- **Client UX:** Map peer identity to `channelId` discovery; cache `channelId`
|
||||
in the client state file.
|
||||
- **Audit logging:** Log channel creation, authz failures, send/recv events with
|
||||
redaction of ciphertext. See [Auth, Devices, and Tokens](authz-plan.md) for
|
||||
the audit logging design.
|
||||
- **Multi-device:** A single account on multiple devices sharing the same
|
||||
channel. Requires per-device delivery queues and MLS multi-device support.
|
||||
|
||||
---
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [Milestones](milestones.md) -- M4 (CLI subcommands) and M6 (persistence)
|
||||
- [Production Readiness WBS](production-readiness.md) -- Phase 4 (Delivery Semantics)
|
||||
- [Auth, Devices, and Tokens](authz-plan.md) -- token validation and identity binding
|
||||
- [Wire Format: Delivery Schema](../wire-format/delivery-schema.md) -- current delivery schema
|
||||
- [Wire Format: NodeService Schema](../wire-format/node-service-schema.md) -- RPC interface
|
||||
- [Architecture Overview](../architecture/overview.md) -- system diagram and service model
|
||||
Reference in New Issue
Block a user