Rename the entire workspace:
- Crate packages: quicnprotochat-{core,proto,server,client,gui,p2p,mobile} -> quicproquo-*
- Binary names: quicnprotochat -> qpq, quicnprotochat-server -> qpq-server,
quicnprotochat-gui -> qpq-gui
- Default files: *-state.bin -> qpq-state.bin, *-server.toml -> qpq-server.toml,
*.db -> qpq.db
- Environment variable prefix: QUICNPROTOCHAT_* -> QPQ_*
- App identifier: chat.quicnproto.gui -> chat.quicproquo.gui
- Proto package: quicnprotochat.bench -> quicproquo.bench
- All documentation, Docker, CI, and script references updated
HKDF domain-separation strings and P2P ALPN remain unchanged for
backward compatibility with existing encrypted state and wire protocol.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
9.1 KiB
1:1 Channel Design
This page describes the design for first-class 1:1 (direct message) channels in quicproquo. 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 and Production Readiness WBS (Phase 4).
Goals
- First-class 1:1 channels. Each conversation between two participants has
a unique
channelId, enabling per-channel authorisation, storage, and eviction. - Per-channel authorisation. The server enforces that only the two channel members can enqueue and fetch messages for a given channel.
- 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.
- 7-day message retention. Messages older than 7 days are evicted. This is configurable but defaults to 7 days.
- 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:
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):
- Validate the
Authtoken (see Auth, Devices, and Tokens). - Look up the channel by
channelId. - Verify that the caller's identity (from the token) is one of the channel's two members.
- Verify that
recipientKeyis the other member of the channel (prevents sending to yourself or to a non-member). - Apply rate limits (50 r/s per identity, 5 MB payload cap).
- Enqueue the payload.
Fetch Authorisation
When a client calls fetch(auth, channelId, recipientKey) or
fetchWait(auth, channelId, recipientKey, timeout):
- Validate the
Authtoken. - Verify that the caller's identity matches
recipientKey. - Verify that
recipientKeyis a member of the specified channel. - 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:
- 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. - 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. - 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
channelIddiscovery; cachechannelIdin the client state file. - Audit logging: Log channel creation, authz failures, send/recv events with redaction of ciphertext. See Auth, Devices, and Tokens 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 -- M4 (CLI subcommands) and M6 (persistence)
- Production Readiness WBS -- Phase 4 (Delivery Semantics)
- Auth, Devices, and Tokens -- token validation and identity binding
- Wire Format: Delivery Schema -- current delivery schema
- Wire Format: NodeService Schema -- RPC interface
- Architecture Overview -- system diagram and service model