Files
quicproquo/docs/src/roadmap/dm-channels.md
Christian Nennemann 2e081ead8e chore: rename quicproquo → quicprochat in docs, Docker, CI, and packaging
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
2026-03-21 19:14:06 +01:00

9.1 KiB

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 and Production Readiness WBS (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:

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).
  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.
  • 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 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