From 7f6af0137dd2e6cad35499aedbccdae83aeae52a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 2 May 2026 23:22:26 +0100 Subject: [PATCH] feat(api+web): browser claims + re-seals encryption on v1 topics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last gap from phase 3.5: web-created topics start as v1 plaintext (mutations.ts ensureGeneralTopic doesn't generate a key, because the dashboard owner has a throwaway pubkey with no secret). Once the browser identity is registered via /v1/me/peer-pubkey, the chat panel can lazily upgrade the topic to v2. API (POST /v1/topics/:name/claim-key) - Atomic claim: only succeeds when topic.encrypted_key_pubkey IS NULL. Body carries the new senderPubkey + the caller's sealed copy of the freshly-generated topic key. Race losers get 409 with the winning senderPubkey so they fall through to the regular fetch path. Idempotent at topic_member_key level. Web - claimTopicKey() in services/crypto/topic-key.ts: generates a fresh 32-byte symmetric key, seals for self, POSTs the claim. Returns the in-memory key so the caller can encrypt immediately without a follow-up GET /key round-trip. - sealTopicKeyFor(): mirrors the CLI helper so a browser holder can re-seal for newcomers (CLI peers, other browsers) instead of the topic going dark when only a browser has the key. - TopicChatPanel: when keyState === "topic_unencrypted", composer now shows a "🔓 plaintext (v1) — encryption not yet enabled" line with an "enable encryption" button. Click → claimTopicKey → state flips to "ready" → 🔒 v0.3.0 banner appears. On race-lost, falls through to fetch. - New 30s re-seal loop fires while holding the key: polls /pending-seals, seals via sealTopicKeyFor for each pending target, POSTs to /seal. Same cadence + soft-fail discipline as the CLI. Net effect: any dashboard user can convert legacy v1 topics to v2 with a single click, and CLI peers joining later will receive a sealed copy from the browser's re-seal loop without manual action. --- .../web/src/modules/mesh/topic-chat-panel.tsx | 89 +++++++++++++ apps/web/src/services/crypto/topic-key.ts | 110 +++++++++++++++ packages/api/src/modules/mesh/v1-router.ts | 126 ++++++++++++++++++ 3 files changed, 325 insertions(+) diff --git a/apps/web/src/modules/mesh/topic-chat-panel.tsx b/apps/web/src/modules/mesh/topic-chat-panel.tsx index 0b7b792..5d1a24a 100644 --- a/apps/web/src/modules/mesh/topic-chat-panel.tsx +++ b/apps/web/src/modules/mesh/topic-chat-panel.tsx @@ -6,10 +6,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@turbostarter/ui-web/button"; import { + claimTopicKey, decryptMessage, encryptMessage, getTopicKey, registerBrowserPeerPubkey, + sealTopicKeyFor, } from "~/services/crypto/topic-key"; interface TopicMessage { @@ -313,6 +315,56 @@ export function TopicChatPanel({ }; }, [apiKeySecret, topicName]); + // Browser-side re-seal loop. While we hold the topic key, every 30s + // we look for newly-joined topic members who don't yet have a sealed + // copy and seal it for them. Mirrors the CLI re-seal path so a topic + // claimed-by-browser doesn't go dark for CLI joiners. + useEffect(() => { + if (!topicKey || keyState !== "ready") return; + let cancelled = false; + const reseal = async () => { + try { + const res = await fetch( + `/api/v1/topics/${encodeURIComponent(topicName)}/pending-seals`, + { headers, cache: "no-store" }, + ); + if (!res.ok) return; + const json = (await res.json()) as { + pending: Array<{ memberId: string; pubkey: string; displayName: string }>; + }; + for (const target of json.pending) { + if (cancelled) return; + const sealed = await sealTopicKeyFor(topicKey, target.pubkey); + if (!sealed) continue; + try { + await fetch( + `/api/v1/topics/${encodeURIComponent(topicName)}/seal`, + { + method: "POST", + headers, + body: JSON.stringify({ + memberId: target.memberId, + encryptedKey: sealed.encryptedKey, + nonce: sealed.nonce, + }), + }, + ); + } catch { + // Another holder likely sealed first — fine to swallow. + } + } + } catch { + // Soft-fail; next tick retries. + } + }; + void reseal(); + const t = setInterval(reseal, 30_000); + return () => { + cancelled = true; + clearInterval(t); + }; + }, [topicKey, keyState, headers, topicName]); + // Decrypt any v2 messages that we haven't decrypted yet. Runs after // `messages` updates (history backfill, SSE delivery) and after // `topicKey` lands. @@ -849,6 +901,43 @@ export function TopicChatPanel({ > 🔒 end-to-end encrypted (v0.3.0)

+ ) : keyState === "topic_unencrypted" ? ( +
+ + 🔓 plaintext (v1) — encryption not yet enabled + + +
) : null}
{ + try { + await sodium.ready; + const identity = await getBrowserIdentity(); + const recipientX25519 = sodium.crypto_sign_ed25519_pk_to_curve25519( + sodium.from_hex(recipientPubkeyHex), + ); + const nonceBytes = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES); + const cipher = sodium.crypto_box_easy( + topicKey, + nonceBytes, + recipientX25519, + identity.xSec, + ); + // Wire format mirrors the CLI: <32-byte sender x25519 pubkey> || cipher. + const blob = new Uint8Array(32 + cipher.length); + blob.set(identity.xPub, 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; + } +} + +/** + * Bootstrap encryption on a v1 topic. Generates a fresh 32-byte topic + * key, seals it for the calling browser via crypto_box, and POSTs to + * `/v1/topics/:name/claim-key`. The endpoint is atomic (only succeeds + * if the topic's encrypted_key_pubkey is currently NULL); 409 means + * another peer beat us to the claim and we should fall back to the + * regular fetch path. + * + * Returns the new in-memory topic key on success so the caller can + * use it immediately without a follow-up `getTopicKey` round-trip. + */ +export async function claimTopicKey(args: { + apiKeySecret: string; + topicName: string; +}): Promise<{ ok: true; topicKey: Uint8Array } | { ok: false; error: string; senderPubkey?: string }> { + await sodium.ready; + const identity = await getBrowserIdentity(); + + // Fresh symmetric key — 32 bytes for crypto_secretbox. + const topicKey = sodium.randombytes_buf(sodium.crypto_secretbox_KEYBYTES); + + // Seal it for ourselves with our x25519 keypair. Wire format: + // <32 bytes browser-x25519-pubkey> || crypto_box(topicKey, ...) + // matches what the broker writes for creator-seal in broker.ts. + const nonceBytes = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES); + const cipher = sodium.crypto_box_easy( + topicKey, + nonceBytes, + identity.xPub, + identity.xSec, + ); + const blob = new Uint8Array(32 + cipher.length); + blob.set(identity.xPub, 0); + blob.set(cipher, 32); + + const res = await fetch( + `/api/v1/topics/${encodeURIComponent(args.topicName)}/claim-key`, + { + method: "POST", + headers: { + Authorization: `Bearer ${args.apiKeySecret}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + // The senderPubkey stored on the topic row is the ED25519 + // (we use it to seal subsequent re-seals); the per-member + // wire format embeds the sender's x25519 pubkey inline. Use + // the ed25519 here because that's what the broker schema + // expects (see topic.encrypted_key_pubkey docstring). + encryptedKeyPubkey: identity.edPubHex, + encryptedKey: sodium.to_base64(blob, sodium.base64_variants.ORIGINAL), + nonce: sodium.to_base64(nonceBytes, sodium.base64_variants.ORIGINAL), + }), + }, + ); + + if (!res.ok) { + let detail: string; + let senderPubkey: string | undefined; + try { + const j = (await res.json()) as { error?: string; senderPubkey?: string }; + detail = j.error ?? `HTTP ${res.status}`; + senderPubkey = j.senderPubkey; + } catch { + detail = `HTTP ${res.status}`; + } + return { ok: false, error: detail, ...(senderPubkey ? { senderPubkey } : {}) }; + } + return { ok: true, topicKey }; +} diff --git a/packages/api/src/modules/mesh/v1-router.ts b/packages/api/src/modules/mesh/v1-router.ts index 08ba668..9e9eb7f 100644 --- a/packages/api/src/modules/mesh/v1-router.ts +++ b/packages/api/src/modules/mesh/v1-router.ts @@ -811,6 +811,132 @@ export const v1Router = new Hono() }); }) + // POST /v1/topics/:name/claim-key — bootstrap encryption on a v1 topic. + // + // Used by the dashboard's first encryption-aware client to convert a + // legacy plaintext topic into v0.3.0 ciphertext. The browser: + // 1. Generates a fresh 32-byte topic key. + // 2. Seals it for itself via crypto_box (its IndexedDB-held secret). + // 3. POSTs encryptedKeyPubkey + encryptedKey + nonce here. + // + // The endpoint is *atomic*: the UPDATE only succeeds when the topic + // currently has no encryption key. If a different client claimed + // first (race), this returns 409 + the existing senderPubkey so the + // loser can fall back to the regular fetch-and-decrypt path. + // + // Subsequent peers (CLI re-seal loop, browser-side re-seal in a future + // patch) seal the same topic key for new joiners — they don't go + // through this endpoint. + .post( + "/topics/:name/claim-key", + validate( + "json", + z.object({ + encryptedKeyPubkey: z + .string() + .length(64) + .regex(/^[0-9a-f]{64}$/i, "must be 64 lowercase hex chars"), + encryptedKey: z.string().min(1).max(4096), + nonce: z.string().min(1).max(64), + }), + ), + async (c) => { + const key = c.var.apiKey; + requireCapability(key, "send"); + const name = c.req.param("name"); + requireTopicScope(key, name); + + if (!key.issuedByMemberId) { + return c.json({ error: "api_key_has_no_issuer" }, 400); + } + const body = c.req.valid("json"); + const newSenderPubkey = body.encryptedKeyPubkey.toLowerCase(); + + 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: "already_encrypted", + topic: name, + senderPubkey: topic.encryptedKeyPubkey, + hint: "another peer claimed first — fetch /key to receive your sealed copy (re-seal pending)", + }, + 409, + ); + } + + // Atomic claim: only set encryptedKeyPubkey if it's still NULL. + // Postgres UPDATE ... WHERE encrypted_key_pubkey IS NULL returns + // 0 rows on race, which we surface as 409. + const updated = await db + .update(meshTopic) + .set({ encryptedKeyPubkey: newSenderPubkey }) + .where( + and( + eq(meshTopic.id, topic.id), + isNull(meshTopic.encryptedKeyPubkey), + ), + ) + .returning({ id: meshTopic.id }); + if (updated.length === 0) { + // Race lost — re-read so the client gets the winning sender pubkey. + const [latest] = await db + .select({ encryptedKeyPubkey: meshTopic.encryptedKeyPubkey }) + .from(meshTopic) + .where(eq(meshTopic.id, topic.id)); + return c.json( + { + error: "already_encrypted", + topic: name, + senderPubkey: latest?.encryptedKeyPubkey ?? null, + }, + 409, + ); + } + + // Persist the caller's sealed copy. Idempotent on (topic, member). + await db + .insert(meshTopicMemberKey) + .values({ + topicId: topic.id, + memberId: key.issuedByMemberId, + encryptedKey: body.encryptedKey, + nonce: body.nonce, + }) + .onConflictDoUpdate({ + target: [meshTopicMemberKey.topicId, meshTopicMemberKey.memberId], + set: { + encryptedKey: body.encryptedKey, + nonce: body.nonce, + rotatedAt: new Date(), + }, + }); + + return c.json({ + topic: name, + topicId: topic.id, + senderPubkey: newSenderPubkey, + memberId: key.issuedByMemberId, + claimed: true, + }); + }, + ) + // 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.