feat(api+web): browser claims + re-seals encryption on v1 topics
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:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user