# 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