Rename all project references from quicproquo/qpq to quicprochat/qpc across documentation, Docker configuration, CI workflows, packaging scripts, operational configs, and build tooling. - Docker: crate paths, binary names, user/group, data dirs, env vars - CI: workflow crate references, binary names, artifact names - Docs: all markdown files under docs/, SDK READMEs, book.toml - Packaging: OpenWrt Makefile, init script, UCI config (file renames) - Scripts: justfile, dev-shell, screenshot, cross-compile, ai_team - Operations: Prometheus config, alert rules, Grafana dashboard - Config: .env.example (QPQ_* → QPC_*), CODEOWNERS paths - Top-level: README, CONTRIBUTING, ROADMAP, CLAUDE.md
262 lines
9.1 KiB
Markdown
262 lines
9.1 KiB
Markdown
# 1:1 Channel Design
|
|
|
|
This page describes the design for first-class 1:1 (direct message) channels in
|
|
quicprochat. 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
|