Phase 2 (infra layer) of v0.3.0. Topics now generate a 32-byte
XSalsa20-Poly1305 key on creation; the broker seals one copy via
crypto_box for the topic creator using an ephemeral x25519
sender keypair (whose public half lives on
topic.encrypted_key_pubkey). Topic key plaintext leaves memory
immediately after the creator's seal — the broker can't read it.
Schema 0026:
+ topic.encrypted_key_pubkey (text, nullable for legacy v0.2.0)
+ topic_message.body_version (integer, 1=plaintext / 2=v2 cipher)
+ topic_member_key (id, topic_id, member_id,
encrypted_key, nonce, rotated_at)
API:
+ GET /v1/topics/:name/key — return the calling member's sealed
copy. 404 if no copy exists yet (joined post-creation, no peer
has re-sealed). 409 if the topic is legacy unencrypted.
Open question parked: how new joiners get their sealed copy
without ceding plaintext to the broker. Spec at
.artifacts/specs/2026-05-02-topic-key-onboarding.md picks
member-driven re-seal (Option B). Pending-seals endpoint, seal
POST, and the actual on-the-wire encryption ship in phase 3.
Mention fan-out from phase 1 (notification table) is decoupled
from ciphertext, so /v1/notifications + MentionsSection keep
working unchanged through both phases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5.6 KiB
Topic-key onboarding — v0.3.0 phase 2
The schema for per-topic encryption is shipped (migration 0026). The
broker generates a 32-byte XSalsa20-Poly1305 key when a topic is
created and seals one copy for the creator via crypto_box. The open
question is how new joiners get their sealed copy without giving
the broker the plaintext.
This spec covers the three live options, picks one for v0.3.0 phase 2, and parks the rest as future cuts. Implementation is not in this spec — that follows once we ship the chosen flow.
The constraint
The broker holds:
topic.encrypted_key_pubkey— the ephemeral x25519 pubkey used to seal each member's copy. Public. The matching secret is discarded immediately after creation — only the topic creator's session knows the topic key briefly during sealing, then it leaves memory.topic_member_key.(encrypted_key, nonce)— per-member sealed ciphertext.
The broker must not be able to decrypt any sealed copy. So when a new member joins a topic that already exists, the broker can't seal a copy for them by itself.
Option A — server-side escrow (REJECTED)
Broker holds the topic key encrypted under its own service key + per- member sealed copies. Re-sealing for new members is a server-only operation.
Why rejected: the broker can read every message in every topic forever. Calling that "per-topic encryption" misleads users. Worse than today's plaintext-base64 because it implies a security property the design doesn't deliver.
Option B — member-driven re-seal (CHOSEN for phase 2)
When a new member joins, an existing member's CLIENT decrypts their own sealed copy of the topic key, then seals a new copy for the joiner and POSTs it to the broker.
Wire:
- New member joins via
claudemesh topic join <topic>— broker insertstopic_memberrow, notopic_member_keyrow. - New member calls
GET /v1/topics/:name/key→ 404 withkey_not_sealed_for_member. - Existing online members (any of them) periodically poll
GET /v1/topics/:name/pending-seals(new endpoint) and see the new joiner. - Existing member's client:
- Decrypts their own sealed copy via
crypto_box_openwith their x25519 secret +topic.encrypted_key_pubkey. - Generates a fresh ephemeral x25519 keypair.
- Seals the topic key for the joiner via
crypto_boxwith the joiner's pubkey + the new ephemeral. - POSTs the result to
POST /v1/topics/:name/seal.
- Decrypts their own sealed copy via
- Broker stores the new
topic_member_keyrow. - New member's
GET /v1/topics/:name/keynow returns 200.
Trust model: broker never sees plaintext. Assumes at least one existing member is online when the joiner connects. Worst case the joiner waits — UI shows "waiting for a peer to share the topic key" until somebody seals.
Open detail — sender pubkey identity: each re-seal uses a fresh ephemeral pubkey. Either:
(a) Store ALL ephemeral pubkeys ever used to seal copies of this
topic, indexed by member, so the joiner can pick the right one
when decrypting. Adds a new table.
(b) Embed the ephemeral pubkey in the sealed payload itself (
encrypted_key becomes <32-byte ephem_pubkey><crypto_box_easy>).
Decoder pulls the prefix, uses it as the sender pubkey. No schema
change beyond what 0026 already ships.
(b) wins on simplicity. Phase 2 implementation uses it.
Option C — leaderless protocol (DEFERRED)
MLS, TreeKEM, or similar continuous group key agreement. Right answer for groups >50 members. Overkill for v0.3.0 — implementation cost is 4-6 weeks of focused work, and the threat model gain over Option B only matters if we believe a member's machine can be silently compromised long enough to leak the topic key but short enough that they aren't kicked from the topic.
Park for v0.4.0 or v0.5.0. Revisit when we onboard a customer that asks for FS (forward secrecy) on group chat.
Phase-2 implementation checklist
Schema (0026 — done):
topic.encrypted_key_pubkey(legacy field, will be unused in Option B's "embed in payload" mode, but keeping it for forward-compat if we ever switch to Option C)topic_member_key.(encrypted_key, nonce)topic_message.body_version(1 = v0.2.0 plaintext, 2 = v0.3.0 ciphertext)
API (some done — see annotations):
GET /v1/topics/:name/key— fetch the calling member's sealed copyGET /v1/topics/:name/pending-seals— list members without keysPOST /v1/topics/:name/seal— submit a re-sealed copy
Broker:
createTopicgenerates topic key + seals for creatorjoinTopicbecomes a "pending" insert — no key seal- (optional) WS notification to online topic members when a new joiner arrives, so re-seal latency is sub-second instead of polling-bound
Client (CLI + web):
- On topic open, fetch sealed key, decrypt + cache in memory
- On send, encrypt body with topic key, set
body_version: 2 - On render, decrypt v2 messages with cached key; v1 stays base64 plaintext (legacy)
- Background re-seal loop — poll for pending joiners, seal, POST
UX:
- "waiting for a peer to share the topic key" state when GET key returns 404
- "you are the only online member — joiners can't read messages until someone else logs in" warning when sole online holder goes offline
The phase-2 commit ships only the schema + creator-seal + GET /key. The pending-seals endpoint, seal POST, and client encryption land in phase 3 once this spec gets a code review. Mention fan-out from phase 1 already works for both v1 and v2 messages, so /v1/notifications keeps working through the cutover.