feat(broker+api): per-topic symmetric keys — schema + creator seal
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:
138
.artifacts/specs/2026-05-02-topic-key-onboarding.md
Normal file
138
.artifacts/specs/2026-05-02-topic-key-onboarding.md
Normal 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.
|
||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
meshStream,
|
meshStream,
|
||||||
meshTopic,
|
meshTopic,
|
||||||
meshTopicMember,
|
meshTopicMember,
|
||||||
|
meshTopicMemberKey,
|
||||||
meshTopicMessage,
|
meshTopicMessage,
|
||||||
meshVaultEntry,
|
meshVaultEntry,
|
||||||
meshTask,
|
meshTask,
|
||||||
@@ -557,12 +558,27 @@ export async function createTopic(args: {
|
|||||||
description?: string;
|
description?: string;
|
||||||
visibility?: "public" | "private" | "dm";
|
visibility?: "public" | "private" | "dm";
|
||||||
createdByMemberId?: string;
|
createdByMemberId?: string;
|
||||||
}): Promise<{ id: string; created: boolean }> {
|
}): Promise<{ id: string; created: boolean; encryptedKeyPubkey?: string }> {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select({ id: meshTopic.id })
|
.select({
|
||||||
|
id: meshTopic.id,
|
||||||
|
encryptedKeyPubkey: meshTopic.encryptedKeyPubkey,
|
||||||
|
})
|
||||||
.from(meshTopic)
|
.from(meshTopic)
|
||||||
.where(and(eq(meshTopic.meshId, args.meshId), eq(meshTopic.name, args.name)));
|
.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
|
const [row] = await db
|
||||||
.insert(meshTopic)
|
.insert(meshTopic)
|
||||||
@@ -572,10 +588,119 @@ export async function createTopic(args: {
|
|||||||
description: args.description ?? null,
|
description: args.description ?? null,
|
||||||
visibility: args.visibility ?? "public",
|
visibility: args.visibility ?? "public",
|
||||||
createdByMemberId: args.createdByMemberId ?? null,
|
createdByMemberId: args.createdByMemberId ?? null,
|
||||||
|
encryptedKeyPubkey: topicKeyBundle.senderPubkeyHex,
|
||||||
})
|
})
|
||||||
.returning({ id: meshTopic.id });
|
.returning({ id: meshTopic.id });
|
||||||
if (!row) throw new Error("failed to create topic");
|
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. */
|
/** List topics in a mesh, with member counts. */
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
meshNotification,
|
meshNotification,
|
||||||
meshTopic,
|
meshTopic,
|
||||||
meshTopicMember,
|
meshTopicMember,
|
||||||
|
meshTopicMemberKey,
|
||||||
meshTopicMessage,
|
meshTopicMessage,
|
||||||
messageQueue,
|
messageQueue,
|
||||||
presence,
|
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
|
// GET /v1/notifications — recent @-mentions of the viewer across
|
||||||
// all topics in the key's mesh. Reads from mesh.notification, which
|
// 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
|
// is populated at write time by POST /v1/messages and the broker's
|
||||||
|
|||||||
44
packages/db/migrations/0026_topic_keys.sql
Normal file
44
packages/db/migrations/0026_topic_keys.sql
Normal 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");
|
||||||
@@ -1360,6 +1360,11 @@ export const meshTopic = meshSchema.table(
|
|||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
onUpdate: "cascade",
|
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(),
|
createdAt: timestamp().defaultNow().notNull(),
|
||||||
archivedAt: timestamp(),
|
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
|
* Topic-scoped persistent message history. Direct messages (DMs) stay
|
||||||
* ephemeral via message_queue by design — this table only persists
|
* ephemeral via message_queue by design — this table only persists
|
||||||
@@ -1424,9 +1484,19 @@ export const meshTopicMessage = meshSchema.table(
|
|||||||
senderSessionPubkey: text(),
|
senderSessionPubkey: text(),
|
||||||
nonce: text().notNull(),
|
nonce: text().notNull(),
|
||||||
ciphertext: 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(),
|
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 }) => ({
|
export const meshTopicRelations = relations(meshTopic, ({ one, many }) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user