diff --git a/.artifacts/specs/2026-05-02-topic-key-onboarding.md b/.artifacts/specs/2026-05-02-topic-key-onboarding.md index 55b2cea..e296360 100644 --- a/.artifacts/specs/2026-05-02-topic-key-onboarding.md +++ b/.artifacts/specs/2026-05-02-topic-key-onboarding.md @@ -79,7 +79,43 @@ ephemeral pubkey. Either: 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. +**(b) wins on simplicity. Phase 3 implementation ships it. Both the +broker creator-seal and the CLI re-seal write the +`<32-byte sender pubkey>` blob.** `topic.encrypted_key_pubkey` +becomes informational only — the wire-format truth is the inline prefix. + +## Web client gap (phase 3.5) + +The CLI side of phase 3 ships in this cut. The web side does NOT — +because web member rows have `peerPubkey` registered server-side but +the corresponding ed25519 SECRET is discarded immediately after +generation (see `mutations.ts:createMyMesh`). Without the secret the +browser can't `crypto_box_open` its sealed topic key. + +Three fixes, in increasing order of effort: + +1. **Browser-side persistent identity (recommended)** — generate an + ed25519 keypair in the browser on first dashboard visit, store the + secret in IndexedDB, sync the public half to `mesh.member.peerPubkey` + via a new `POST /v1/me/peer-pubkey` endpoint. Topic keys then seal + to the new pubkey; web user decrypts locally. Existing #general + topics need a re-seal cycle (the v0.3.0 phase-3 re-seal loop in + the CLI already does this for any pending member, including web + ones). Spec lift: ~3 hours, mostly browser code + a sync endpoint. + +2. **Server-held secret** — keep the member's ed25519 secret server- + side. Trivial to implement, but the broker can read everything, + defeating the security claim. **Rejected.** + +3. **JWT-derived keys** — derive the member's keypair from a stable + user-secret (e.g. PBKDF2 over their session JWT). Means cross- + device same key, but needs the JWT to include ~32 bytes of stable + key material. Tied to v2.0.0 daemon redesign. **Deferred.** + +Phase 3 ships option 1 deferred; web stays on v1 plaintext until 3.5. +The CLI re-seal loop in `topic tail` already handles re-sealing for +web members ONCE they have a real pubkey — no broker work needed +when 3.5 lands. ## Option C — leaderless protocol (DEFERRED) @@ -95,44 +131,48 @@ asks for FS (forward secrecy) on group chat. --- -## Phase-2 implementation checklist +## 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.encrypted_key_pubkey` (informational; wire truth is the + inline 32-byte prefix on each `topic_member_key.encryptedKey`) - [x] `topic_member_key.(encrypted_key, nonce)` -- [x] `topic_message.body_version` (1 = v0.2.0 plaintext, 2 = v0.3.0 ciphertext) +- [x] `topic_message.body_version` (1 = plaintext, 2 = v2 ciphertext) -API (some done — see annotations): +API (phase 3 — done): - [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 +- [x] `GET /v1/topics/:name/pending-seals` — list members without keys +- [x] `POST /v1/topics/:name/seal` — submit a re-sealed copy +- [x] `GET /v1/topics/:name/messages` returns `bodyVersion` +- [x] `GET /v1/topics/:name/stream` emits `bodyVersion` +- [x] `POST /v1/messages` accepts `bodyVersion` (1|2) + skips regex + mention extraction on v2 -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 +Broker / web mutation (phase 3 — done): +- [x] `createTopic` generates topic key + seals for creator with + inline-sender-pubkey blob format +- [x] `ensureGeneralTopic` (web) mirrors the same flow -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 +Client — CLI (phase 3 — done): +- [x] `services/crypto/topic-key.ts` — fetch + decrypt + encrypt + reseal helpers +- [x] `topic tail` decrypts v2 messages on render +- [x] `topic post` encrypts v2 on send via REST POST /v1/messages +- [x] Background re-seal loop in `topic tail` (30s cadence) -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 +Client — web (phase 3.5 — DEFERRED): +- [ ] Browser-side persistent identity (IndexedDB) +- [ ] `POST /v1/me/peer-pubkey` sync endpoint +- [ ] Web chat panel encrypt-on-send + decrypt-on-render (currently v1) -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. +UX surfaces (phase 3 — done in CLI): +- [x] "waiting for a peer to share the topic key" warning on tail +- [ ] (web) "your encryption keys are pending — pair this browser" + banner once 3.5 lands + +Mention fan-out from phase 1 already works for both v1 and v2 +messages, so `/v1/notifications` keeps working through the cutover. + +The phase-3 cut ships full CLI encryption + re-seal flow. Web remains +on v1 plaintext until 3.5 lands the browser identity layer. Mixed +CLI+web meshes in the meantime should keep using v1 sends OR accept +that web members can't read v2 messages. diff --git a/apps/broker/src/broker.ts b/apps/broker/src/broker.ts index b48d7cf..c15acd8 100644 --- a/apps/broker/src/broker.ts +++ b/apps/broker/src/broker.ts @@ -683,20 +683,28 @@ async function sealTopicKeyForMember(args: { recipientX25519, args.bundle.senderSecret, ); + // Embed sender x25519 pubkey as the first 32 bytes so re-sealed + // copies (which carry their own sender pubkey from a different + // member) decode the same way as creator-sealed copies. + const blob = new Uint8Array(32 + sealed.length); + blob.set(args.bundle.senderPubkey, 0); + blob.set(sealed, 32); + const encryptedKey = sodium.to_base64(blob, sodium.base64_variants.ORIGINAL); + const nonceB64 = sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL); 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), + encryptedKey, + nonce: nonceB64, }) .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), + encryptedKey, + nonce: nonceB64, rotatedAt: new Date(), }, }); diff --git a/apps/cli/README.md b/apps/cli/README.md index b49ac0c..9a0df0e 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,7 +2,9 @@ Peer mesh for Claude Code sessions. Connect multiple Claude Code instances into a shared mesh with real-time messaging, shared state, memory, file sharing, vector store, scheduled jobs, and more — all driven from the `claudemesh` CLI. The MCP server is a tool-less push-pipe that delivers inbound peer messages to Claude as `` interrupts; everything else lives behind CLI verbs that Claude learns from the auto-installed `claudemesh` skill. -> **What's new in 1.7.0:** terminal parity for the v1.6.x server features. New verbs: `claudemesh topic tail` (live SSE message stream — Ctrl-C to exit), `claudemesh notification list` (recent `@you` mentions across topics), `claudemesh member list` (mesh roster with online dots, distinct from `peer list`'s live-session view). Each command auto-mints a 5-minute read-only apikey via the WebSocket and revokes it on exit, so no token plumbing is needed. +> **What's new in 1.8.0:** per-topic end-to-end encryption (v0.3.0 phase 3, CLI side). `claudemesh topic post ` encrypts the body with `crypto_secretbox` under the topic's symmetric key — broker stores ciphertext only. `claudemesh topic tail` now decrypts v2 messages on render and runs a background re-seal loop every 30s, so new topic joiners get their sealed keys without manual action. `topic-key` cache is process-only — kill the CLI, the key forgets. Web dashboard reads v1 plaintext for now (phase 3.5 brings browser-side identity). +> +> **What was new in 1.7.0:** terminal parity for the v1.6.x server features. New verbs: `claudemesh topic tail` (live SSE message stream — Ctrl-C to exit), `claudemesh notification list` (recent `@you` mentions across topics), `claudemesh member list` (mesh roster with online dots, distinct from `peer list`'s live-session view). Each command auto-mints a 5-minute read-only apikey via the WebSocket and revokes it on exit, so no token plumbing is needed. > > **What was new in 1.6.0:** topics (channel pub/sub), API keys for human/REST clients, and bridge peers that forward a topic between two meshes. New verbs: `claudemesh topic`, `claudemesh apikey`, `claudemesh bridge`. A REST surface at `https://claudemesh.com/api/v1/*` (messages, topics, peers, history) accepts `Authorization: Bearer cm_...` keys, so any HTTPS client can participate without WebSocket + ed25519 plumbing. **Note**: REST lives on the web host (`claudemesh.com`), not the broker host (`ic.claudemesh.com`) — the broker only speaks WebSocket. > @@ -45,7 +47,8 @@ USAGE claudemesh profile view or edit your profile claudemesh topic ... create, list, join, send to topics - claudemesh topic tail live SSE tail of a topic + claudemesh topic tail live SSE tail of a topic (decrypts v2) + claudemesh topic post encrypted REST post (v2 ciphertext) claudemesh member list mesh roster with online state claudemesh notification list recent @-mentions of you claudemesh apikey ... issue, list, revoke API keys (REST clients) diff --git a/apps/cli/package.json b/apps/cli/package.json index be171c6..2f32510 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.7.0", + "version": "1.8.0", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", diff --git a/apps/cli/src/commands/index.ts b/apps/cli/src/commands/index.ts index 39338e2..21f1be3 100644 --- a/apps/cli/src/commands/index.ts +++ b/apps/cli/src/commands/index.ts @@ -29,4 +29,5 @@ export { runSeedTestMesh } from "./seed-test-mesh.js"; export { runNotificationList } from "./notification.js"; export { runMemberList } from "./member.js"; export { runTopicTail } from "./topic-tail.js"; +export { runTopicPost } from "./topic-post.js"; export { withMesh } from "./connect.js"; diff --git a/apps/cli/src/commands/topic-post.ts b/apps/cli/src/commands/topic-post.ts new file mode 100644 index 0000000..60db813 --- /dev/null +++ b/apps/cli/src/commands/topic-post.ts @@ -0,0 +1,130 @@ +/** + * `claudemesh topic post ` — REST-encrypted send. + * + * Distinct from `claudemesh topic send` (WS-based, currently v1 + * plaintext). This verb: + * 1. Mints an ephemeral REST apikey scoped to the topic. + * 2. Fetches + decrypts the topic key (crypto_box). + * 3. Encrypts the body with crypto_secretbox under the topic key. + * 4. POSTs body_version: 2 ciphertext to /api/v1/messages. + * 5. Revokes the apikey. + * + * If the topic doesn't yet have a sealed key for this member (404 + * not_sealed) we surface a clear error and skip — the user must wait + * for a holder to re-seal. + */ + +import { withRestKey } from "~/services/api/with-rest-key.js"; +import { request } from "~/services/api/client.js"; +import { + getTopicKey, + encryptMessage, +} from "~/services/crypto/topic-key.js"; +import { render } from "~/ui/render.js"; +import { clay, dim, green } from "~/ui/styles.js"; +import { EXIT } from "~/constants/exit-codes.js"; + +export interface TopicPostFlags { + mesh?: string; + json?: boolean; + /** Force v1 plaintext send even if the topic is encrypted. */ + plaintext?: boolean; +} + +interface PostResponse { + messageId: string | null; + historyId: string | null; + topic: string; + topicId: string; + notifications: number; +} + +export async function runTopicPost( + topicName: string, + message: string, + flags: TopicPostFlags, +): Promise { + if (!topicName || !message) { + render.err("Usage: claudemesh topic post "); + return EXIT.INVALID_ARGS; + } + const cleanName = topicName.replace(/^#/, ""); + + // Extract @-mention tokens for write-time fan-out so the server can + // populate notifications without reading ciphertext. + const mentions: string[] = []; + const mentionRe = /(^|[^A-Za-z0-9_-])@([A-Za-z0-9_-]{1,64})(?=$|[^A-Za-z0-9_-])/g; + let m: RegExpExecArray | null; + while ((m = mentionRe.exec(message)) !== null) { + mentions.push(m[2]!.toLowerCase()); + if (mentions.length >= 16) break; + } + + return withRestKey( + { + meshSlug: flags.mesh ?? null, + purpose: `post-${cleanName}`, + capabilities: ["read", "send"], + topicScopes: [cleanName], + }, + async ({ secret, mesh }) => { + let bodyVersion: 1 | 2 = 1; + let ciphertext: string; + let nonce: string; + + if (flags.plaintext) { + // Explicit v1: caller wants plaintext. Encode UTF-8 → base64. + ciphertext = Buffer.from(message, "utf-8").toString("base64"); + nonce = Buffer.from(new Uint8Array(24)).toString("base64"); + } else { + const keyResult = await getTopicKey({ + apiKeySecret: secret, + memberSecretKeyHex: mesh.secretKey, + topicName: cleanName, + }); + if (keyResult.ok && keyResult.topicKey) { + const enc = await encryptMessage(keyResult.topicKey, message); + ciphertext = enc.ciphertext; + nonce = enc.nonce; + bodyVersion = 2; + } else if (keyResult.error === "topic_unencrypted") { + // Legacy v0.2.0 topic — fall back to v1 plaintext. + ciphertext = Buffer.from(message, "utf-8").toString("base64"); + nonce = Buffer.from(new Uint8Array(24)).toString("base64"); + } else { + render.err( + `cannot encrypt for #${cleanName}: ${keyResult.error ?? "unknown"}${ + keyResult.message ? " — " + keyResult.message : "" + }`, + ); + return EXIT.INTERNAL_ERROR; + } + } + + const result = await request({ + path: "/api/v1/messages", + method: "POST", + token: secret, + body: { + topic: cleanName, + ciphertext, + nonce, + bodyVersion, + ...(mentions.length > 0 ? { mentions } : {}), + }, + }); + + if (flags.json) { + console.log(JSON.stringify({ ...result, bodyVersion, mentions })); + return EXIT.SUCCESS; + } + + const versionTag = bodyVersion === 2 ? green("🔒 v2") : dim("v1"); + render.ok( + "posted", + `${clay("#" + cleanName)} ${versionTag} ${dim(`(${result.notifications} mentions)`)}`, + ); + return EXIT.SUCCESS; + }, + ); +} diff --git a/apps/cli/src/commands/topic-tail.ts b/apps/cli/src/commands/topic-tail.ts index fa88809..8ce8239 100644 --- a/apps/cli/src/commands/topic-tail.ts +++ b/apps/cli/src/commands/topic-tail.ts @@ -8,8 +8,13 @@ import { URLS } from "~/constants/urls.js"; import { withRestKey } from "~/services/api/with-rest-key.js"; import { request } from "~/services/api/client.js"; +import { + getTopicKey, + decryptMessage, + sealTopicKeyFor, +} from "~/services/crypto/topic-key.js"; import { render } from "~/ui/render.js"; -import { bold, clay, dim } from "~/ui/styles.js"; +import { bold, clay, dim, yellow } from "~/ui/styles.js"; import { EXIT } from "~/constants/exit-codes.js"; export interface TopicTailFlags { @@ -26,6 +31,7 @@ interface TopicMessage { senderName: string; nonce: string; ciphertext: string; + bodyVersion?: number; createdAt: string; } @@ -35,7 +41,11 @@ interface HistoryResponse { messages: TopicMessage[]; } -function decodeCiphertext(b64: string): string { +/** + * v1 (legacy plaintext-base64) decode. v2 messages are decrypted via + * the topic key separately — see decryptForRender below. + */ +function decodeV1(b64: string): string { try { return Buffer.from(b64, "base64").toString("utf-8"); } catch { @@ -43,6 +53,16 @@ function decodeCiphertext(b64: string): string { } } +async function decryptForRender( + m: TopicMessage, + topicKey: Uint8Array | null, +): Promise { + if ((m.bodyVersion ?? 1) === 1) return decodeV1(m.ciphertext); + if (!topicKey) return "[encrypted — no topic key]"; + const plain = await decryptMessage(topicKey, m.ciphertext, m.nonce); + return plain ?? "[decrypt failed]"; +} + function fmtTime(iso: string): string { try { return new Date(iso).toLocaleTimeString([], { @@ -55,14 +75,19 @@ function fmtTime(iso: string): string { } } -function printMessage(m: TopicMessage, json: boolean): void { - const text = decodeCiphertext(m.ciphertext); +async function printMessage( + m: TopicMessage, + topicKey: Uint8Array | null, + json: boolean, +): Promise { + const text = await decryptForRender(m, topicKey); if (json) { console.log(JSON.stringify({ ...m, message: text })); return; } + const v2Marker = (m.bodyVersion ?? 1) === 2 ? dim("🔒 ") : ""; process.stdout.write( - ` ${dim(fmtTime(m.createdAt))} ${bold(m.senderName || m.senderPubkey.slice(0, 8))} ${text}\n`, + ` ${dim(fmtTime(m.createdAt))} ${bold(m.senderName || m.senderPubkey.slice(0, 8))} ${v2Marker}${text}\n`, ); } @@ -118,7 +143,89 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise capabilities: ["read"], topicScopes: [cleanName], }, - async ({ secret, meshSlug }) => { + async ({ secret, meshSlug, mesh }) => { + // Fetch + decrypt the topic key once. Stays in memory for this + // invocation; tail dies → key forgotten. v1 topics return + // not_sealed/topic_unencrypted and we just don't decrypt. + const keyResult = await getTopicKey({ + apiKeySecret: secret, + memberSecretKeyHex: mesh.secretKey, + topicName: cleanName, + }); + const topicKey = keyResult.ok ? keyResult.topicKey ?? null : null; + + // Re-seal background loop. While we hold the topic key, every + // 30s we look for newly-joined members who don't have a sealed + // copy yet, seal the key for each, and POST. Soft-failures stay + // silent so a flaky network doesn't spam the tail output. + let resealTimer: ReturnType | null = null; + if (topicKey) { + const reseal = async () => { + try { + const pending = await request<{ + pending: Array<{ + memberId: string; + pubkey: string; + displayName: string; + }>; + }>({ + path: `/api/v1/topics/${encodeURIComponent(cleanName)}/pending-seals`, + token: secret, + }); + for (const target of pending.pending) { + const sealed = await sealTopicKeyFor( + topicKey, + target.pubkey, + mesh.secretKey, + ); + if (!sealed) continue; + try { + await request({ + path: `/api/v1/topics/${encodeURIComponent(cleanName)}/seal`, + method: "POST", + token: secret, + body: { + memberId: target.memberId, + encryptedKey: sealed.encryptedKey, + nonce: sealed.nonce, + }, + }); + if (!flags.json) { + render.info( + dim(`re-sealed topic key for ${target.displayName}`), + ); + } + } catch { + // Another holder likely sealed first — ignore. + } + } + } catch { + // Soft-fail; next tick retries. + } + }; + void reseal(); + resealTimer = setInterval(reseal, 30_000); + } + if (!flags.json && !keyResult.ok) { + if (keyResult.error === "topic_unencrypted") { + render.info( + dim("topic is on v1 (plaintext) — encryption will activate after creator-seal"), + ); + } else if (keyResult.error === "not_sealed") { + render.warn( + yellow( + "no topic key sealed for you yet — wait for a holder to re-seal", + ), + ); + } else if (keyResult.error === "decrypt_failed") { + render.warn( + yellow( + `topic key fetched but decrypt failed: ${keyResult.message ?? ""}`, + ), + ); + } + } + // 1. Backfill the most recent N messages so the user sees context // when they tail an active topic. if (!flags.forwardOnly && limit > 0) { @@ -134,7 +241,7 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise } // History is newest-first; reverse for chronological display. for (const m of history.messages.slice().reverse()) { - printMessage(m, flags.json ?? false); + await printMessage(m, topicKey, flags.json ?? false); } } catch (err) { render.warn(`backfill failed: ${(err as Error).message}`); @@ -176,7 +283,7 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise if (ev.event === "message") { try { const m = JSON.parse(ev.data) as TopicMessage; - printMessage(m, flags.json ?? false); + await printMessage(m, topicKey, flags.json ?? false); } catch { // skip malformed } @@ -190,6 +297,7 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise } finally { process.removeListener("SIGINT", onSig); process.removeListener("SIGTERM", onSig); + if (resealTimer) clearInterval(resealTimer); } }, ); diff --git a/apps/cli/src/entrypoints/cli.ts b/apps/cli/src/entrypoints/cli.ts index c7f1c65..e2f3b48 100644 --- a/apps/cli/src/entrypoints/cli.ts +++ b/apps/cli/src/entrypoints/cli.ts @@ -120,7 +120,8 @@ Topic (conversation scope, v0.2.0) claudemesh topic history fetch message history [--limit --before] claudemesh topic read mark all as read claudemesh topic tail live SSE tail [--limit --forward-only] - claudemesh send "#topic" "msg" send to a topic + claudemesh topic post encrypted REST post (v0.3.0 v2) + claudemesh send "#topic" "msg" send to a topic (WS path, v1 plaintext) claudemesh member list mesh roster with online state [--online] claudemesh notification list recent @-mentions of you [--since ] @@ -586,7 +587,17 @@ async function main(): Promise { const { runTopicTail } = await import("~/commands/topic-tail.js"); process.exit(await runTopicTail(arg, tailFlags)); } - else { console.error("Usage: claudemesh topic "); process.exit(EXIT.INVALID_ARGS); } + else if (sub === "post") { + const postFlags = { + mesh: flags.mesh as string, + json: !!flags.json, + plaintext: !!flags.plaintext, + }; + const message = positionals.slice(2).join(" "); + const { runTopicPost } = await import("~/commands/topic-post.js"); + process.exit(await runTopicPost(arg, message, postFlags)); + } + else { console.error("Usage: claudemesh topic "); process.exit(EXIT.INVALID_ARGS); } break; } diff --git a/apps/cli/src/services/crypto/topic-key.ts b/apps/cli/src/services/crypto/topic-key.ts new file mode 100644 index 0000000..03ac222 --- /dev/null +++ b/apps/cli/src/services/crypto/topic-key.ts @@ -0,0 +1,245 @@ +/** + * Per-topic symmetric-key cache + crypto_box plumbing. + * + * Lifecycle: + * 1. CLI command minted a REST apikey via withRestKey(). + * 2. Caller asks for a topic key by (mesh_secret_key, topic_name). + * 3. We fetch GET /v1/topics/:name/key for the sealed copy + sender pubkey. + * 4. We convert the mesh's ed25519 secret to x25519, then crypto_box_open + * the sealed key. Plaintext key is cached in-process and used to + * encrypt + decrypt v2 message bodies. + * + * Failures: + * - 404 key_not_sealed_for_member: caller is in the topic but no peer + * has re-sealed the key for them yet. Caller surfaces a "waiting for + * a peer to share the topic key" message and falls back to v1 path. + * - 409 topic_unencrypted: legacy v0.2.0 topic. Caller stays on v1. + * - decrypt failure: server fed us a junk seal. Caller re-fetches + * once; if still bad, surface error and fall back. + * + * The cache is keyed on (apiKeyHash, topicName) so it never crosses + * sessions. Process-only — no disk persistence. + */ + +import { request } from "~/services/api/client.js"; +import { ApiError } from "~/services/api/errors.js"; + +interface CacheEntry { + topicKey: Uint8Array; + fetchedAt: number; +} + +const cache = new Map(); + +interface SealedKeyResponse { + topic: string; + topicId: string; + encryptedKey: string; + nonce: string; + senderPubkey: string; + createdAt: string; +} + +export type TopicKeyError = + | "not_sealed" + | "topic_unencrypted" + | "decrypt_failed" + | "bad_member_secret" + | "network"; + +export interface TopicKeyResult { + ok: boolean; + topicKey?: Uint8Array; + error?: TopicKeyError; + message?: string; +} + +function cacheKey(apiKeySecret: string, topicName: string): string { + // First 12 chars of the apikey is plenty to dedupe within a session + // and short enough to avoid keeping the full secret in a Map key. + return `${apiKeySecret.slice(0, 12)}:${topicName}`; +} + +export async function getTopicKey(args: { + apiKeySecret: string; + memberSecretKeyHex: string; + topicName: string; + /** Bypass cache — useful after a re-seal. */ + fresh?: boolean; +}): Promise { + const cacheId = cacheKey(args.apiKeySecret, args.topicName); + if (!args.fresh) { + const cached = cache.get(cacheId); + if (cached) return { ok: true, topicKey: cached.topicKey }; + } + + let sealed: SealedKeyResponse; + try { + sealed = await request({ + path: `/api/v1/topics/${encodeURIComponent(args.topicName)}/key`, + token: args.apiKeySecret, + }); + } catch (e) { + if (e instanceof ApiError) { + if (e.status === 404) return { ok: false, error: "not_sealed" }; + if (e.status === 409) return { ok: false, error: "topic_unencrypted" }; + } + return { + ok: false, + error: "network", + message: e instanceof Error ? e.message : String(e), + }; + } + + const sodium = (await import("libsodium-wrappers")).default; + await sodium.ready; + + let recipientX25519Secret: Uint8Array; + try { + const ed = sodium.from_hex(args.memberSecretKeyHex); + recipientX25519Secret = sodium.crypto_sign_ed25519_sk_to_curve25519(ed); + } catch { + return { ok: false, error: "bad_member_secret" }; + } + + let topicKey: Uint8Array; + try { + const blob = sodium.from_base64( + sealed.encryptedKey, + sodium.base64_variants.ORIGINAL, + ); + const nonce = sodium.from_base64( + sealed.nonce, + sodium.base64_variants.ORIGINAL, + ); + // Wire format: first 32 bytes = sender x25519 pubkey, rest = + // crypto_box ciphertext. The topic.encryptedKeyPubkey on the topic + // record is the original creator's sender; subsequent re-seals + // each carry their own sender pubkey, so the joiner can decrypt + // regardless of who sealed for them. + if (blob.length < 32 + sodium.crypto_box_MACBYTES) { + return { + ok: false, + error: "decrypt_failed", + message: "sealed key blob too short to contain sender pubkey + cipher", + }; + } + const senderX25519 = blob.slice(0, 32); + const cipher = blob.slice(32); + topicKey = sodium.crypto_box_open_easy( + cipher, + nonce, + senderX25519, + recipientX25519Secret, + ); + } catch (e) { + return { + ok: false, + error: "decrypt_failed", + message: e instanceof Error ? e.message : String(e), + }; + } + + cache.set(cacheId, { topicKey, fetchedAt: Date.now() }); + return { ok: true, topicKey }; +} + +/** + * Encrypt a UTF-8 plaintext message body with the topic's symmetric + * key via crypto_secretbox. Returns base64 ciphertext + base64 nonce + * suitable for POST /v1/messages with bodyVersion: 2. + */ +export async function encryptMessage( + topicKey: Uint8Array, + plaintext: string, +): Promise<{ ciphertext: string; nonce: string }> { + const sodium = (await import("libsodium-wrappers")).default; + await sodium.ready; + const nonceBytes = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const cipher = sodium.crypto_secretbox_easy( + sodium.from_string(plaintext), + nonceBytes, + topicKey, + ); + return { + ciphertext: sodium.to_base64(cipher, sodium.base64_variants.ORIGINAL), + nonce: sodium.to_base64(nonceBytes, sodium.base64_variants.ORIGINAL), + }; +} + +/** + * Decrypt a v2 message body. Returns null on auth failure (bad key + * or tampering) — caller should fall back to a placeholder string, + * not crash the renderer. + */ +export async function decryptMessage( + topicKey: Uint8Array, + ciphertextB64: string, + nonceB64: string, +): Promise { + try { + const sodium = (await import("libsodium-wrappers")).default; + await sodium.ready; + const cipher = sodium.from_base64( + ciphertextB64, + sodium.base64_variants.ORIGINAL, + ); + const nonce = sodium.from_base64(nonceB64, sodium.base64_variants.ORIGINAL); + const plain = sodium.crypto_secretbox_open_easy(cipher, nonce, topicKey); + return sodium.to_string(plain); + } catch { + return null; + } +} + +/** + * Seal a topic key for another member — used by the re-seal flow when + * a holder helps onboard a new joiner. Returns the bundle ready to + * POST to /v1/topics/:name/seal. + */ +export async function sealTopicKeyFor( + topicKey: Uint8Array, + recipientPubkeyHex: string, + ourMemberSecretKeyHex: string, +): Promise<{ + /** base64( our_x25519_pubkey || crypto_box(topicKey) ). */ + encryptedKey: string; + nonce: string; +} | null> { + try { + const sodium = (await import("libsodium-wrappers")).default; + await sodium.ready; + const recipientX25519 = sodium.crypto_sign_ed25519_pk_to_curve25519( + sodium.from_hex(recipientPubkeyHex), + ); + const ourEdSecret = sodium.from_hex(ourMemberSecretKeyHex); + const ourX25519Secret = sodium.crypto_sign_ed25519_sk_to_curve25519( + ourEdSecret, + ); + // Derive our x25519 public from our ed25519 public half (back half + // of the secret key contains the ed25519 pubkey per libsodium spec). + const ourEdPublic = ourEdSecret.slice(32, 64); + const ourX25519Public = sodium.crypto_sign_ed25519_pk_to_curve25519( + ourEdPublic, + ); + const nonceBytes = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES); + const cipher = sodium.crypto_box_easy( + topicKey, + nonceBytes, + recipientX25519, + ourX25519Secret, + ); + // Embed sender pubkey as the first 32 bytes so the recipient can + // decrypt without a separate lookup. Matches the format the broker's + // creator-seal writes (see broker.ts sealTopicKeyForMember). + const blob = new Uint8Array(32 + cipher.length); + blob.set(ourX25519Public, 0); + blob.set(cipher, 32); + return { + encryptedKey: sodium.to_base64(blob, sodium.base64_variants.ORIGINAL), + nonce: sodium.to_base64(nonceBytes, sodium.base64_variants.ORIGINAL), + }; + } catch { + return null; + } +} diff --git a/packages/api/src/modules/mesh/mutations.ts b/packages/api/src/modules/mesh/mutations.ts index 2852c02..f748f30 100644 --- a/packages/api/src/modules/mesh/mutations.ts +++ b/packages/api/src/modules/mesh/mutations.ts @@ -262,10 +262,16 @@ export const ensureGeneralTopic = async ( recipientX25519, senderKp.privateKey, ); + // Embed sender x25519 pubkey as the first 32 bytes so future + // re-sealed copies (carrying a different sender) decode the same + // way as creator-sealed copies. + const blob = new Uint8Array(32 + sealed.length); + blob.set(senderKp.publicKey, 0); + blob.set(sealed, 32); await db.insert(meshTopicMemberKey).values({ topicId: row.id, memberId: owner.id, - encryptedKey: sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL), + encryptedKey: sodium.to_base64(blob, sodium.base64_variants.ORIGINAL), nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL), }).onConflictDoNothing(); } catch { diff --git a/packages/api/src/modules/mesh/v1-router.ts b/packages/api/src/modules/mesh/v1-router.ts index a3da68f..7d138d3 100644 --- a/packages/api/src/modules/mesh/v1-router.ts +++ b/packages/api/src/modules/mesh/v1-router.ts @@ -58,6 +58,13 @@ const sendMessageSchema = z.object({ /** base64 nonce. */ nonce: z.string().min(1), priority: z.enum(["now", "next", "low"]).optional().default("next"), + /** + * Body format version. 1 = base64-of-plaintext (v0.2.0 placeholder), + * 2 = crypto_secretbox under the topic's symmetric key (v0.3.0). The + * server does not look inside ciphertext either way; this field + * tells readers how to interpret it. + */ + bodyVersion: z.literal(1).or(z.literal(2)).optional().default(1), /** * Optional list of `@` mentions extracted client-side * from the plaintext. Capped at 16 to bound notification fan-out @@ -160,6 +167,7 @@ export const v1Router = new Hono() senderMemberId, nonce: body.nonce, ciphertext: body.ciphertext, + bodyVersion: body.bodyVersion, }) .returning({ id: meshTopicMessage.id }); @@ -176,12 +184,17 @@ export const v1Router = new Hono() .returning({ id: messageQueue.id }); // Mention fan-out → notification rows. Client-extracted mentions - // win when present (post-encryption clients MUST extract and send); - // otherwise we regex the base64 plaintext as a transitional fallback. + // win when present (v2 ciphertext clients MUST extract and send; + // server can't read v2 bodies). v1 plaintext falls back to a regex + // on the body so legacy senders don't lose mention notifications. let mentionTokens = body.mentions?.map((s) => s.toLowerCase().replace(/^@/, "")); - if (!mentionTokens || mentionTokens.length === 0) { + if ( + (!mentionTokens || mentionTokens.length === 0) && + body.bodyVersion === 1 + ) { mentionTokens = extractMentionsFromBase64(body.ciphertext); } + if (!mentionTokens) mentionTokens = []; let notifications = 0; if (historyRow && mentionTokens.length > 0) { const recipients = await db @@ -386,6 +399,7 @@ export const v1Router = new Hono() senderName: meshMember.displayName, nonce: meshTopicMessage.nonce, ciphertext: meshTopicMessage.ciphertext, + bodyVersion: meshTopicMessage.bodyVersion, createdAt: meshTopicMessage.createdAt, }) .from(meshTopicMessage) @@ -413,6 +427,7 @@ export const v1Router = new Hono() senderName: r.senderName, nonce: r.nonce, ciphertext: r.ciphertext, + bodyVersion: r.bodyVersion, createdAt: r.createdAt.toISOString(), })), }); @@ -484,6 +499,7 @@ export const v1Router = new Hono() senderName: meshMember.displayName, nonce: meshTopicMessage.nonce, ciphertext: meshTopicMessage.ciphertext, + bodyVersion: meshTopicMessage.bodyVersion, createdAt: meshTopicMessage.createdAt, }) .from(meshTopicMessage) @@ -510,6 +526,7 @@ export const v1Router = new Hono() senderName: r.senderName, nonce: r.nonce, ciphertext: r.ciphertext, + bodyVersion: r.bodyVersion, createdAt: r.createdAt.toISOString(), }), }); @@ -681,6 +698,178 @@ export const v1Router = new Hono() }); }) + // GET /v1/topics/:name/pending-seals — list topic members that don't + // yet have a sealed copy of the topic key. Members who hold the key + // poll this and re-seal for any pending recipient via POST /seal. + // + // Returns roster format so the caller can do the crypto: + // { pending: [{ memberId, pubkey, displayName }] } + // + // Caps at 50 — if more are pending the next poll picks up the rest. + // Anyone with read capability + topic scope can list (any holder can + // re-seal; the trust model accepts that). + .get("/topics/:name/pending-seals", async (c) => { + const key = c.var.apiKey; + requireCapability(key, "read"); + const name = c.req.param("name"); + requireTopicScope(key, name); + + 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({ pending: [], senderPubkey: null }); + } + + // Member is "pending" iff joined the topic but has no key row yet. + // LEFT JOIN topic_member_key on the same (topic, member) pair — + // NULL = pending. + const rows = await db + .select({ + memberId: meshTopicMember.memberId, + pubkey: meshMember.peerPubkey, + displayName: meshMember.displayName, + }) + .from(meshTopicMember) + .innerJoin(meshMember, eq(meshMember.id, meshTopicMember.memberId)) + .leftJoin( + meshTopicMemberKey, + and( + eq(meshTopicMemberKey.topicId, meshTopicMember.topicId), + eq(meshTopicMemberKey.memberId, meshTopicMember.memberId), + ), + ) + .where( + and( + eq(meshTopicMember.topicId, topic.id), + isNull(meshMember.revokedAt), + isNull(meshTopicMemberKey.id), + ), + ) + .limit(50); + + return c.json({ + topic: name, + topicId: topic.id, + senderPubkey: topic.encryptedKeyPubkey, + pending: rows, + }); + }) + + // POST /v1/topics/:name/seal — submit a re-sealed copy of the topic + // key for a specific member. Body: {memberId, encryptedKey, nonce}. + // Idempotent on (topicId, memberId) — re-submitting overwrites. + // + // The CALLER must already hold the topic key (otherwise their seal + // would be garbage). Server can't verify that at submission time — + // the joiner verifies on first decrypt by attempting crypto_box_open + // and discarding the row if it fails. Bad seals waste a round-trip + // but can't break the security model. + .post( + "/topics/:name/seal", + validate( + "json", + z.object({ + memberId: z.string().min(1), + encryptedKey: z.string().min(1), + nonce: z.string().min(1), + }), + ), + async (c) => { + const key = c.var.apiKey; + requireCapability(key, "send"); + const name = c.req.param("name"); + requireTopicScope(key, name); + + const body = c.req.valid("json"); + + 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 — no key to seal", + }, + 409, + ); + } + + // Recipient must be a non-revoked member of the same mesh AND + // already a topic_member (joined the topic). Otherwise we'd let + // anyone seal for any member, which the joiner would then accept + // on first GET /key — that's a denial-of-content vector. + const [recipient] = await db + .select({ id: meshMember.id }) + .from(meshTopicMember) + .innerJoin(meshMember, eq(meshMember.id, meshTopicMember.memberId)) + .where( + and( + eq(meshTopicMember.topicId, topic.id), + eq(meshMember.id, body.memberId), + eq(meshMember.meshId, key.meshId), + isNull(meshMember.revokedAt), + ), + ); + if (!recipient) { + return c.json({ error: "recipient_not_in_topic" }, 404); + } + + const now = new Date(); + await db + .insert(meshTopicMemberKey) + .values({ + topicId: topic.id, + memberId: body.memberId, + encryptedKey: body.encryptedKey, + nonce: body.nonce, + }) + .onConflictDoUpdate({ + target: [meshTopicMemberKey.topicId, meshTopicMemberKey.memberId], + set: { + encryptedKey: body.encryptedKey, + nonce: body.nonce, + rotatedAt: now, + }, + }); + + return c.json({ + topic: name, + topicId: topic.id, + memberId: body.memberId, + sealedAt: now.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