feat(api+web): browser claims + re-seals encryption on v1 topics
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

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.
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 23:22:26 +01:00
parent 2e57173ed9
commit 7f6af0137d
3 changed files with 325 additions and 0 deletions

View File

@@ -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)
</p>
) : keyState === "topic_unencrypted" ? (
<div
className="mb-2 flex items-center justify-between gap-3 text-[10px] text-[var(--cm-fg-tertiary)]"
style={monoStyle}
>
<span title="This topic was created before per-topic encryption shipped. Click to generate a key and seal it for everyone going forward.">
🔓 plaintext (v1) encryption not yet enabled
</span>
<button
type="button"
className="rounded border border-[var(--cm-border)] px-2 py-0.5 text-[10px] text-[var(--cm-fg-secondary)] hover:bg-[var(--cm-bg-hover)] disabled:cursor-not-allowed disabled:opacity-60"
disabled={sending}
onClick={async () => {
setError(null);
const result = await claimTopicKey({ apiKeySecret, topicName });
if (result.ok) {
setTopicKey(result.topicKey);
setKeyState("ready");
return;
}
if (result.error.includes("already_encrypted")) {
// Race lost — refetch via the regular path.
const refetch = await getTopicKey({ apiKeySecret, topicName, fresh: true });
if (refetch.ok && refetch.topicKey) {
setTopicKey(refetch.topicKey);
setKeyState("ready");
} else {
setKeyState(refetch.error === "not_sealed" ? "not_sealed" : "error");
}
} else {
setError(`claim failed: ${result.error}`);
}
}}
>
enable encryption
</button>
</div>
) : null}
<form
className="relative flex gap-2"

View File

@@ -218,3 +218,113 @@ export async function registerBrowserPeerPubkey(
}
return (await res.json()) as { memberId: string; pubkey: string; changed: boolean };
}
/**
* Seal the topic key for another member's pubkey. Mirrors the CLI
* `sealTopicKeyFor` so a browser holder can re-seal for newcomers
* (CLI peers, other browsers) instead of the topic going dark when
* the only holder is a browser session.
*
* Returns null if the recipient pubkey is malformed (junk in the DB
* or a pre-encryption legacy member).
*/
export async function sealTopicKeyFor(
topicKey: Uint8Array,
recipientPubkeyHex: string,
): Promise<{ encryptedKey: string; nonce: string } | 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 };
}