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

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