feat(broker+api): per-topic symmetric keys — schema + creator seal
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

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>
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 20:28:10 +01:00
parent 1a238d4178
commit da5103a315
5 changed files with 468 additions and 5 deletions

View File

@@ -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 <topic>` — 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><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):
- [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.

View File

@@ -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<void> {
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. */

View File

@@ -34,6 +34,7 @@ import {
meshNotification,
meshTopic,
meshTopicMember,
meshTopicMemberKey,
meshTopicMessage,
messageQueue,
presence,
@@ -595,6 +596,91 @@ export const v1Router = new Hono<Env>()
});
})
// 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

View File

@@ -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");

View File

@@ -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 }) => ({