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:
@@ -6,10 +6,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { Button } from "@turbostarter/ui-web/button";
|
import { Button } from "@turbostarter/ui-web/button";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
claimTopicKey,
|
||||||
decryptMessage,
|
decryptMessage,
|
||||||
encryptMessage,
|
encryptMessage,
|
||||||
getTopicKey,
|
getTopicKey,
|
||||||
registerBrowserPeerPubkey,
|
registerBrowserPeerPubkey,
|
||||||
|
sealTopicKeyFor,
|
||||||
} from "~/services/crypto/topic-key";
|
} from "~/services/crypto/topic-key";
|
||||||
|
|
||||||
interface TopicMessage {
|
interface TopicMessage {
|
||||||
@@ -313,6 +315,56 @@ export function TopicChatPanel({
|
|||||||
};
|
};
|
||||||
}, [apiKeySecret, topicName]);
|
}, [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
|
// Decrypt any v2 messages that we haven't decrypted yet. Runs after
|
||||||
// `messages` updates (history backfill, SSE delivery) and after
|
// `messages` updates (history backfill, SSE delivery) and after
|
||||||
// `topicKey` lands.
|
// `topicKey` lands.
|
||||||
@@ -849,6 +901,43 @@ export function TopicChatPanel({
|
|||||||
>
|
>
|
||||||
🔒 end-to-end encrypted (v0.3.0)
|
🔒 end-to-end encrypted (v0.3.0)
|
||||||
</p>
|
</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}
|
) : null}
|
||||||
<form
|
<form
|
||||||
className="relative flex gap-2"
|
className="relative flex gap-2"
|
||||||
|
|||||||
@@ -218,3 +218,113 @@ export async function registerBrowserPeerPubkey(
|
|||||||
}
|
}
|
||||||
return (await res.json()) as { memberId: string; pubkey: string; changed: boolean };
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -811,6 +811,132 @@ export const v1Router = new Hono<Env>()
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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
|
// 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
|
// 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.
|
// poll this and re-seal for any pending recipient via POST /seal.
|
||||||
|
|||||||
Reference in New Issue
Block a user