diff --git a/.artifacts/specs/2026-05-02-topic-key-onboarding.md b/.artifacts/specs/2026-05-02-topic-key-onboarding.md new file mode 100644 index 0000000..55b2cea --- /dev/null +++ b/.artifacts/specs/2026-05-02-topic-key-onboarding.md @@ -0,0 +1,138 @@ +# 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:** + +1. New member joins via `claudemesh topic join ` — broker + inserts `topic_member` row, no `topic_member_key` row. +2. New member calls `GET /v1/topics/:name/key` → 404 with + `key_not_sealed_for_member`. +3. Existing online members (any of them) periodically poll + `GET /v1/topics/:name/pending-seals` (new endpoint) and see the + new joiner. +4. Existing member's client: + - Decrypts their own sealed copy via `crypto_box_open` with their + x25519 secret + `topic.encrypted_key_pubkey`. + - Generates a fresh ephemeral x25519 keypair. + - Seals the topic key for the joiner via `crypto_box` with the + joiner's pubkey + the new ephemeral. + - POSTs the result to `POST /v1/topics/:name/seal`. +5. Broker stores the new `topic_member_key` row. +6. New member's `GET /v1/topics/:name/key` now 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>`). + 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): +- [x] `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) +- [x] `topic_member_key.(encrypted_key, nonce)` +- [x] `topic_message.body_version` (1 = v0.2.0 plaintext, 2 = v0.3.0 ciphertext) + +API (some done — see annotations): +- [x] `GET /v1/topics/:name/key` — fetch the calling member's sealed copy +- [ ] `GET /v1/topics/:name/pending-seals` — list members without keys +- [ ] `POST /v1/topics/:name/seal` — submit a re-sealed copy + +Broker: +- [x] `createTopic` generates topic key + seals for creator +- [ ] `joinTopic` becomes 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. diff --git a/apps/broker/src/broker.ts b/apps/broker/src/broker.ts index c35797c..f713ebf 100644 --- a/apps/broker/src/broker.ts +++ b/apps/broker/src/broker.ts @@ -47,6 +47,7 @@ import { meshStream, meshTopic, meshTopicMember, + meshTopicMemberKey, meshTopicMessage, meshVaultEntry, meshTask, @@ -557,12 +558,27 @@ export async function createTopic(args: { description?: string; visibility?: "public" | "private" | "dm"; createdByMemberId?: string; -}): Promise<{ id: string; created: boolean }> { +}): Promise<{ id: string; created: boolean; encryptedKeyPubkey?: string }> { const existing = await db - .select({ id: meshTopic.id }) + .select({ + id: meshTopic.id, + encryptedKeyPubkey: meshTopic.encryptedKeyPubkey, + }) .from(meshTopic) .where(and(eq(meshTopic.meshId, args.meshId), eq(meshTopic.name, args.name))); - if (existing[0]) return { id: existing[0].id, created: false }; + if (existing[0]) { + return { + id: existing[0].id, + created: false, + encryptedKeyPubkey: existing[0].encryptedKeyPubkey ?? undefined, + }; + } + + // Generate the topic's per-message symmetric key + an ephemeral + // sender keypair used to seal it for each member. The plaintext + // topicKey is held in memory only long enough to seal one copy per + // member; the broker never persists it. + const topicKeyBundle = await generateTopicKeyBundle(); const [row] = await db .insert(meshTopic) @@ -572,10 +588,119 @@ export async function createTopic(args: { description: args.description ?? null, visibility: args.visibility ?? "public", createdByMemberId: args.createdByMemberId ?? null, + encryptedKeyPubkey: topicKeyBundle.senderPubkeyHex, }) .returning({ id: meshTopic.id }); if (!row) throw new Error("failed to create topic"); - return { id: row.id, created: true }; + + // Seal a copy for the creator immediately. Other members get sealed + // copies as they join via joinTopic(). + if (args.createdByMemberId) { + await sealTopicKeyForMember({ + topicId: row.id, + memberId: args.createdByMemberId, + bundle: topicKeyBundle, + }); + } + + return { + id: row.id, + created: true, + encryptedKeyPubkey: topicKeyBundle.senderPubkeyHex, + }; +} + +/** + * Generate a per-topic symmetric key + an ephemeral x25519 sender keypair + * used to seal it. Returns the bundle in a form that callers can hand to + * sealTopicKeyForMember() repeatedly without ever persisting the key + * plaintext. + * + * crypto_kx is the libsodium primitive matching v0.1's mesh handshake, + * but we only need a fresh x25519 pair here — keyPair() suffices. + */ +async function generateTopicKeyBundle(): Promise<{ + topicKey: Uint8Array; + senderSecret: Uint8Array; + senderPubkey: Uint8Array; + senderPubkeyHex: string; +}> { + const sodium = await import("libsodium-wrappers"); + await sodium.ready; + const topicKey = sodium.randombytes_buf(32); + const sender = sodium.crypto_box_keypair(); + return { + topicKey, + senderSecret: sender.privateKey, + senderPubkey: sender.publicKey, + senderPubkeyHex: sodium.to_hex(sender.publicKey), + }; +} + +interface TopicKeyBundle { + topicKey: Uint8Array; + senderSecret: Uint8Array; + senderPubkey: Uint8Array; + senderPubkeyHex: string; +} + +/** + * Seal the topic key for one member using crypto_box. Idempotent on + * (topicId, memberId) — calling again rotates the cipher but not the + * underlying key (rotation is a separate flow). + * + * The recipient's peer pubkey is the ed25519 key they registered with + * the broker. crypto_box wants x25519, so we convert. Members decrypt + * with crypto_box_open + sender pubkey + their own x25519 secret + * (derived from their ed25519 secret the same way). + */ +async function sealTopicKeyForMember(args: { + topicId: string; + memberId: string; + bundle: TopicKeyBundle; +}): Promise { + const [member] = await db + .select({ peerPubkey: memberTable.peerPubkey }) + .from(memberTable) + .where(eq(memberTable.id, args.memberId)); + if (!member) return; + + const sodium = await import("libsodium-wrappers"); + await sodium.ready; + let recipientX25519: Uint8Array; + try { + const ed = sodium.from_hex(member.peerPubkey); + recipientX25519 = sodium.crypto_sign_ed25519_pk_to_curve25519(ed); + } catch { + // Recipient pubkey isn't a valid ed25519 key — skip silently. The + // member won't be able to read v2 messages on this topic until + // their identity is regenerated. + return; + } + const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES); + const sealed = sodium.crypto_box_easy( + args.bundle.topicKey, + nonce, + recipientX25519, + args.bundle.senderSecret, + ); + + await db + .insert(meshTopicMemberKey) + .values({ + topicId: args.topicId, + memberId: args.memberId, + encryptedKey: sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL), + nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL), + }) + .onConflictDoUpdate({ + target: [meshTopicMemberKey.topicId, meshTopicMemberKey.memberId], + set: { + encryptedKey: sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL), + nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL), + rotatedAt: new Date(), + }, + }); } /** List topics in a mesh, with member counts. */ diff --git a/packages/api/src/modules/mesh/v1-router.ts b/packages/api/src/modules/mesh/v1-router.ts index 12f3b12..a3da68f 100644 --- a/packages/api/src/modules/mesh/v1-router.ts +++ b/packages/api/src/modules/mesh/v1-router.ts @@ -34,6 +34,7 @@ import { meshNotification, meshTopic, meshTopicMember, + meshTopicMemberKey, meshTopicMessage, messageQueue, presence, @@ -595,6 +596,91 @@ export const v1Router = new Hono() }); }) + // GET /v1/topics/:name/key — fetch the calling member's sealed copy + // of the topic's symmetric key. v0.3.0 phase 2. + // + // The broker stores `crypto_box(topic_key, recipient_x25519, + // ephemeral_sender_x25519)` per (topic, member). Clients decrypt with + // their ed25519→x25519-converted secret + the topic's ephemeral + // sender pubkey on `topic.encrypted_key_pubkey`. + // + // Returns 404 when no sealed copy exists for this member yet — + // expected when the member joined a topic after creation and no + // other peer has re-sealed the key for them. UI surfaces a "pending + // — waiting for re-seal from another member" state in that case. + // Spec for the re-seal flow lives at + // `.artifacts/specs/2026-05-02-topic-key-onboarding.md`. + .get("/topics/:name/key", async (c) => { + const key = c.var.apiKey; + requireCapability(key, "read"); + const name = c.req.param("name"); + requireTopicScope(key, name); + + if (!key.issuedByMemberId) { + return c.json({ error: "api_key_has_no_issuer" }, 400); + } + + const [topic] = await db + .select({ + id: meshTopic.id, + encryptedKeyPubkey: meshTopic.encryptedKeyPubkey, + }) + .from(meshTopic) + .where( + and( + eq(meshTopic.meshId, key.meshId), + eq(meshTopic.name, name), + isNull(meshTopic.archivedAt), + ), + ); + if (!topic) { + return c.json({ error: "topic_not_found", topic: name }, 404); + } + if (!topic.encryptedKeyPubkey) { + return c.json( + { + error: "topic_unencrypted", + topic: name, + hint: "legacy v0.2.0 topic — messages are base64 plaintext", + }, + 409, + ); + } + + const [sealed] = await db + .select({ + encryptedKey: meshTopicMemberKey.encryptedKey, + nonce: meshTopicMemberKey.nonce, + createdAt: meshTopicMemberKey.createdAt, + }) + .from(meshTopicMemberKey) + .where( + and( + eq(meshTopicMemberKey.topicId, topic.id), + eq(meshTopicMemberKey.memberId, key.issuedByMemberId), + ), + ); + if (!sealed) { + return c.json( + { + error: "key_not_sealed_for_member", + topic: name, + hint: "join the topic, then ask an existing member to re-seal", + }, + 404, + ); + } + + return c.json({ + topic: name, + topicId: topic.id, + encryptedKey: sealed.encryptedKey, + nonce: sealed.nonce, + senderPubkey: topic.encryptedKeyPubkey, + createdAt: sealed.createdAt.toISOString(), + }); + }) + // GET /v1/notifications — recent @-mentions of the viewer across // all topics in the key's mesh. Reads from mesh.notification, which // is populated at write time by POST /v1/messages and the broker's diff --git a/packages/db/migrations/0026_topic_keys.sql b/packages/db/migrations/0026_topic_keys.sql new file mode 100644 index 0000000..76d27dd --- /dev/null +++ b/packages/db/migrations/0026_topic_keys.sql @@ -0,0 +1,44 @@ +-- Per-topic symmetric encryption keys (v0.3.0 phase 2 — schema layer). +-- +-- Each topic gets a freshly-generated 32-byte XSalsa20-Poly1305 symmetric +-- key. That key is encrypted once per topic member with libsodium +-- crypto_box (recipient pubkey + sender ephemeral keypair) so only the +-- intended member can decrypt their copy. Server stores ciphertext only; +-- it can no longer read message bodies. +-- +-- Writes are versioned via topic_message.body_version: +-- 1 = legacy v0.2.0 base64-of-plaintext (still readable) +-- 2 = real ciphertext (sealed to the topic key, server-blind) +-- +-- Old messages stay v1; new clients send v2. Mention fan-out is already +-- decoupled from ciphertext via the notification table (migration 0025), +-- so /v1/notifications keeps working through the cutover. + +ALTER TABLE "mesh"."topic" + ADD COLUMN IF NOT EXISTS "encrypted_key_pubkey" text; +COMMENT ON COLUMN "mesh"."topic"."encrypted_key_pubkey" IS + 'Ephemeral x25519 sender pubkey used to seal per-member copies of the topic symmetric key. Null = legacy v0.2.0 topic with no encryption.'; + +ALTER TABLE "mesh"."topic_message" + ADD COLUMN IF NOT EXISTS "body_version" integer NOT NULL DEFAULT 1; + +CREATE INDEX IF NOT EXISTS "topic_message_by_version" + ON "mesh"."topic_message" ("body_version"); + +CREATE TABLE IF NOT EXISTS "mesh"."topic_member_key" ( + "id" text PRIMARY KEY NOT NULL, + "topic_id" text NOT NULL REFERENCES "mesh"."topic"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "member_id" text NOT NULL REFERENCES "mesh"."member"("id") ON DELETE CASCADE ON UPDATE CASCADE, + /** crypto_box ciphertext of the 32-byte topic key, sealed for this member. */ + "encrypted_key" text NOT NULL, + /** 24-byte nonce used to seal `encrypted_key`. */ + "nonce" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "rotated_at" timestamp +); + +CREATE UNIQUE INDEX IF NOT EXISTS "topic_member_key_unique" + ON "mesh"."topic_member_key" ("topic_id", "member_id"); + +CREATE INDEX IF NOT EXISTS "topic_member_key_by_member" + ON "mesh"."topic_member_key" ("member_id"); diff --git a/packages/db/src/schema/mesh.ts b/packages/db/src/schema/mesh.ts index 85f90e0..3d1e0ab 100644 --- a/packages/db/src/schema/mesh.ts +++ b/packages/db/src/schema/mesh.ts @@ -1360,6 +1360,11 @@ export const meshTopic = meshSchema.table( onDelete: "set null", onUpdate: "cascade", }), + /** + * Ephemeral x25519 sender pubkey used to seal per-member topic-key + * copies via crypto_box. Null on legacy v0.2.0 topics (no encryption). + */ + encryptedKeyPubkey: text(), createdAt: timestamp().defaultNow().notNull(), archivedAt: timestamp(), }, @@ -1395,6 +1400,61 @@ export const meshTopicMember = meshSchema.table( ], ); +/** + * Per-(topic, member) sealed copy of the topic's symmetric key. v0.3.0 + * phase 2 — each topic_member gets a crypto_box ciphertext of the 32-byte + * topic key, sealed to their peer pubkey using an ephemeral sender + * keypair stored on `topic.encryptedKeyPubkey`. The server holds only + * ciphertext; it can't read message bodies. + */ +export const meshTopicMemberKey = meshSchema.table( + "topic_member_key", + { + id: text().primaryKey().notNull().$defaultFn(generateId), + topicId: text() + .references(() => meshTopic.id, { + onDelete: "cascade", + onUpdate: "cascade", + }) + .notNull(), + memberId: text() + .references(() => meshMember.id, { + onDelete: "cascade", + onUpdate: "cascade", + }) + .notNull(), + encryptedKey: text().notNull(), + nonce: text().notNull(), + createdAt: timestamp().defaultNow().notNull(), + rotatedAt: timestamp(), + }, + (t) => [ + uniqueIndex("topic_member_key_unique").on(t.topicId, t.memberId), + index("topic_member_key_by_member").on(t.memberId), + ], +); + +export const meshTopicMemberKeyRelations = relations( + meshTopicMemberKey, + ({ one }) => ({ + topic: one(meshTopic, { + fields: [meshTopicMemberKey.topicId], + references: [meshTopic.id], + }), + member: one(meshMember, { + fields: [meshTopicMemberKey.memberId], + references: [meshMember.id], + }), + }), +); + +export const selectMeshTopicMemberKeySchema = + createSelectSchema(meshTopicMemberKey); +export const insertMeshTopicMemberKeySchema = + createInsertSchema(meshTopicMemberKey); +export type SelectMeshTopicMemberKey = typeof meshTopicMemberKey.$inferSelect; +export type InsertMeshTopicMemberKey = typeof meshTopicMemberKey.$inferInsert; + /** * Topic-scoped persistent message history. Direct messages (DMs) stay * ephemeral via message_queue by design — this table only persists @@ -1424,9 +1484,19 @@ export const meshTopicMessage = meshSchema.table( senderSessionPubkey: text(), nonce: text().notNull(), ciphertext: text().notNull(), + /** + * Body-format version. 1 = legacy base64-of-plaintext (v0.2.0). 2 = + * crypto_secretbox under the topic key (v0.3.0). Readers branch on + * this; mention fan-out is decoupled via the notification table so + * a v2 message still resolves @-mentions correctly. + */ + bodyVersion: integer().notNull().default(1), createdAt: timestamp().defaultNow().notNull(), }, - (t) => [index("topic_message_by_topic_time").on(t.topicId, t.createdAt)], + (t) => [ + index("topic_message_by_topic_time").on(t.topicId, t.createdAt), + index("topic_message_by_version").on(t.bodyVersion), + ], ); export const meshTopicRelations = relations(meshTopic, ({ one, many }) => ({