feat(web+api): browser-side per-topic encryption (v0.3.0 phase 3.5)
Closes the v1-vs-v2 split between CLI and dashboard. The web chat
panel now reads and writes the same crypto_secretbox-under-topic-key
ciphertext that CLI 1.8.0+ writes — every encrypted topic finally
renders correctly from the browser.
API
- POST /v1/me/peer-pubkey replaces the throwaway pubkey that
mutations.ts mints at mesh-create time with one whose secret the
browser actually holds. Idempotent; auth via the dashboard apikey
whose issuedByMemberId is the row to update.
Web
- apps/web/src/services/crypto/identity.ts — IndexedDB-backed
ed25519 identity, lazy-init on first use. Generates once per
browser-profile; survives reload. ed25519 → x25519 derivation for
crypto_box decrypt. Module-cached after first call.
- apps/web/src/services/crypto/topic-key.ts — mirrors the CLI
topic-key service. Fetches GET /v1/topics/:name/key, decrypts the
sealed copy with our x25519 secret, caches the 32-byte symmetric
key in-memory keyed by (apikey-prefix, topic). encryptMessage /
decryptMessage map directly onto crypto_secretbox{,_open}.
- apps/web/src/modules/mesh/topic-chat-panel.tsx — on mount:
registers our pubkey, fetches the topic key, polls /key every 5s
while not_sealed (matching the CLI's 30s re-seal cadence). Render
branches on bodyVersion: v2 -> decrypted-cache, v1 -> legacy
base64. Send branches: encrypts under the topic key when key is
ready, falls back to v1 plaintext on legacy or not-yet-sealed
topics. Composer shows a 🔒 v0.3.0 / "waiting for re-seal" badge.
Adds libsodium-wrappers + @types to apps/web. Browser bundle picks
up its own copy; the existing CLI/broker/API copies are untouched.
Threat model: IndexedDB is per-origin and not exfiltratable from
other sites; XSS or a malicious extension still wins, same as for
any browser-stored secret. Documented divergence from the CLI's
~/.claudemesh-stored keypair in the identity module's preamble.
This commit is contained in:
@@ -73,6 +73,7 @@
|
|||||||
"@turbostarter/eslint-config": "workspace:*",
|
"@turbostarter/eslint-config": "workspace:*",
|
||||||
"@turbostarter/prettier-config": "workspace:*",
|
"@turbostarter/prettier-config": "workspace:*",
|
||||||
"@turbostarter/tsconfig": "workspace:*",
|
"@turbostarter/tsconfig": "workspace:*",
|
||||||
|
"@types/libsodium-wrappers": "0.7.14",
|
||||||
"@types/node": "catalog:node22",
|
"@types/node": "catalog:node22",
|
||||||
"@types/qrcode": "1.5.6",
|
"@types/qrcode": "1.5.6",
|
||||||
"@types/react": "catalog:react19",
|
"@types/react": "catalog:react19",
|
||||||
|
|||||||
@@ -5,12 +5,22 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
|
|
||||||
import { Button } from "@turbostarter/ui-web/button";
|
import { Button } from "@turbostarter/ui-web/button";
|
||||||
|
|
||||||
|
import {
|
||||||
|
decryptMessage,
|
||||||
|
encryptMessage,
|
||||||
|
getTopicKey,
|
||||||
|
registerBrowserPeerPubkey,
|
||||||
|
} from "~/services/crypto/topic-key";
|
||||||
|
|
||||||
interface TopicMessage {
|
interface TopicMessage {
|
||||||
id: string;
|
id: string;
|
||||||
senderPubkey: string;
|
senderPubkey: string;
|
||||||
senderName: string;
|
senderName: string;
|
||||||
nonce: string;
|
nonce: string;
|
||||||
ciphertext: string;
|
ciphertext: string;
|
||||||
|
/** 1 = legacy plaintext-base64. 2 = crypto_secretbox under topic key. */
|
||||||
|
bodyVersion?: number;
|
||||||
|
replyToId?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,12 +45,28 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encode plaintext into the broker's wire format. v0.2.0 uses base64
|
* v1 (legacy plaintext-base64) decode path. v0.2.0 messages used this
|
||||||
* plaintext in the `ciphertext` field — real per-topic symmetric keys
|
* fake-encryption stub; real v0.3.0 ciphertext is decrypted via the
|
||||||
* land in v0.3.0. Same applies to the random nonce: it satisfies the
|
* topic key — see `decryptForRender` below.
|
||||||
* schema but isn't cryptographically meaningful yet.
|
|
||||||
*/
|
*/
|
||||||
function encodeOutgoing(plaintext: string): { ciphertext: string; nonce: string } {
|
function decodeV1(ciphertext: string): string {
|
||||||
|
try {
|
||||||
|
const decoded =
|
||||||
|
typeof window === "undefined"
|
||||||
|
? Buffer.from(ciphertext, "base64").toString("utf-8")
|
||||||
|
: new TextDecoder().decode(
|
||||||
|
Uint8Array.from(atob(ciphertext), (c) => c.charCodeAt(0)),
|
||||||
|
);
|
||||||
|
return decoded;
|
||||||
|
} catch {
|
||||||
|
return "[decode failed]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encode v1 plaintext for the rare fallback path when a topic has no
|
||||||
|
* encryption key (legacy v0.2.0 topics). v0.3.0+ topics encrypt via
|
||||||
|
* `encryptMessage` from the topic-key service. */
|
||||||
|
function encodeV1Outgoing(plaintext: string): { ciphertext: string; nonce: string } {
|
||||||
const bytes = new TextEncoder().encode(plaintext);
|
const bytes = new TextEncoder().encode(plaintext);
|
||||||
const ciphertext =
|
const ciphertext =
|
||||||
typeof window === "undefined"
|
typeof window === "undefined"
|
||||||
@@ -55,20 +81,6 @@ function encodeOutgoing(plaintext: string): { ciphertext: string; nonce: string
|
|||||||
return { ciphertext, nonce };
|
return { ciphertext, nonce };
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeIncoming(ciphertext: string): string {
|
|
||||||
try {
|
|
||||||
const decoded =
|
|
||||||
typeof window === "undefined"
|
|
||||||
? Buffer.from(ciphertext, "base64").toString("utf-8")
|
|
||||||
: new TextDecoder().decode(
|
|
||||||
Uint8Array.from(atob(ciphertext), (c) => c.charCodeAt(0)),
|
|
||||||
);
|
|
||||||
return decoded;
|
|
||||||
} catch {
|
|
||||||
return "[decode failed]";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render plaintext with @mentions highlighted in clay. We split on the
|
* Render plaintext with @mentions highlighted in clay. We split on the
|
||||||
* mention regex and rebuild as alternating spans so React can reconcile
|
* mention regex and rebuild as alternating spans so React can reconcile
|
||||||
@@ -187,6 +199,19 @@ export function TopicChatPanel({
|
|||||||
const seenIdsRef = useRef<Set<string>>(new Set());
|
const seenIdsRef = useRef<Set<string>>(new Set());
|
||||||
const lastMarkReadAtRef = useRef<number>(0);
|
const lastMarkReadAtRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// v0.3.0 per-topic encryption state.
|
||||||
|
// `topicKey` is the 32-byte symmetric key for the active topic (null =
|
||||||
|
// unencrypted / not yet sealed for this browser). `keyState` distinguishes
|
||||||
|
// the three reasons we might not have a key yet, so the UI can show the
|
||||||
|
// right message ("waiting for a CLI peer to share the key" vs "topic is
|
||||||
|
// legacy plaintext" vs "decrypt failed").
|
||||||
|
const [topicKey, setTopicKey] = useState<Uint8Array | null>(null);
|
||||||
|
const [keyState, setKeyState] = useState<
|
||||||
|
"loading" | "ready" | "not_sealed" | "topic_unencrypted" | "error"
|
||||||
|
>("loading");
|
||||||
|
// Decrypted plaintext per message id, computed lazily on render.
|
||||||
|
const [decrypted, setDecrypted] = useState<Map<string, string>>(new Map());
|
||||||
|
|
||||||
const headers = useMemo(
|
const headers = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
Authorization: `Bearer ${apiKeySecret}`,
|
Authorization: `Bearer ${apiKeySecret}`,
|
||||||
@@ -238,6 +263,95 @@ export function TopicChatPanel({
|
|||||||
void markRead();
|
void markRead();
|
||||||
}, [loadHistory, markRead]);
|
}, [loadHistory, markRead]);
|
||||||
|
|
||||||
|
// Per-topic encryption bootstrap.
|
||||||
|
//
|
||||||
|
// On mount: register the browser's IndexedDB-persisted pubkey against
|
||||||
|
// mesh.member.peer_pubkey (idempotent), then ask /v1/topics/:name/key
|
||||||
|
// for our sealed copy. If no peer has sealed for us yet (404), poll
|
||||||
|
// every 5s — the CLI's 30s re-seal loop will eventually catch up.
|
||||||
|
// If the topic is unencrypted (legacy v0.2.0), fall through to v1.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const tryFetchKey = async (firstAttempt: boolean) => {
|
||||||
|
try {
|
||||||
|
if (firstAttempt) {
|
||||||
|
// Idempotent — only writes on first run / after rotation.
|
||||||
|
await registerBrowserPeerPubkey(apiKeySecret);
|
||||||
|
}
|
||||||
|
const res = await getTopicKey({ apiKeySecret, topicName });
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.ok && res.topicKey) {
|
||||||
|
setTopicKey(res.topicKey);
|
||||||
|
setKeyState("ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.error === "topic_unencrypted") {
|
||||||
|
setTopicKey(null);
|
||||||
|
setKeyState("topic_unencrypted");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.error === "not_sealed") {
|
||||||
|
setTopicKey(null);
|
||||||
|
setKeyState("not_sealed");
|
||||||
|
// Re-poll: a CLI peer's re-seal loop runs every 30s, so 5s
|
||||||
|
// here gives a quick reaction without hammering the server.
|
||||||
|
pollTimer = setTimeout(() => void tryFetchKey(false), 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setKeyState("error");
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setKeyState("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void tryFetchKey(true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (pollTimer) clearTimeout(pollTimer);
|
||||||
|
};
|
||||||
|
}, [apiKeySecret, topicName]);
|
||||||
|
|
||||||
|
// Decrypt any v2 messages that we haven't decrypted yet. Runs after
|
||||||
|
// `messages` updates (history backfill, SSE delivery) and after
|
||||||
|
// `topicKey` lands.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!topicKey) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const additions = new Map<string, string>();
|
||||||
|
for (const m of messages) {
|
||||||
|
if ((m.bodyVersion ?? 1) !== 2) continue;
|
||||||
|
if (decrypted.has(m.id)) continue;
|
||||||
|
const plain = await decryptMessage(topicKey, m.ciphertext, m.nonce);
|
||||||
|
additions.set(m.id, plain ?? "[decrypt failed]");
|
||||||
|
}
|
||||||
|
if (cancelled || additions.size === 0) return;
|
||||||
|
setDecrypted((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const [k, v] of additions) next.set(k, v);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [messages, topicKey, decrypted]);
|
||||||
|
|
||||||
|
// Render-time text resolution: v2 -> decrypted cache; v1 -> legacy decode.
|
||||||
|
// Falls back to a placeholder if v2 hasn't been decrypted yet (the
|
||||||
|
// useEffect above will fill it in).
|
||||||
|
const resolveText = useCallback(
|
||||||
|
(m: TopicMessage): string => {
|
||||||
|
if ((m.bodyVersion ?? 1) === 2) {
|
||||||
|
return decrypted.get(m.id) ?? "🔒 decrypting…";
|
||||||
|
}
|
||||||
|
return decodeV1(m.ciphertext);
|
||||||
|
},
|
||||||
|
[decrypted],
|
||||||
|
);
|
||||||
|
|
||||||
// Roster — refresh every 20s so online state stays roughly current.
|
// Roster — refresh every 20s so online state stays roughly current.
|
||||||
// Tighter cadence isn't worth a dedicated SSE channel for v1.6.x.
|
// Tighter cadence isn't worth a dedicated SSE channel for v1.6.x.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -435,7 +549,24 @@ export function TopicChatPanel({
|
|||||||
setSending(true);
|
setSending(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const { ciphertext, nonce } = encodeOutgoing(text);
|
let ciphertext: string;
|
||||||
|
let nonce: string;
|
||||||
|
let bodyVersion: 1 | 2;
|
||||||
|
if (topicKey && keyState === "ready") {
|
||||||
|
const enc = await encryptMessage(topicKey, text);
|
||||||
|
ciphertext = enc.ciphertext;
|
||||||
|
nonce = enc.nonce;
|
||||||
|
bodyVersion = 2;
|
||||||
|
} else {
|
||||||
|
// Legacy unencrypted topic, or sealed-key not yet available.
|
||||||
|
// Sending v1 plaintext keeps the chat working in either case;
|
||||||
|
// CLI peers on encrypted topics will read it as v1 (alongside
|
||||||
|
// their v2 traffic) without the round-trip breaking.
|
||||||
|
const enc = encodeV1Outgoing(text);
|
||||||
|
ciphertext = enc.ciphertext;
|
||||||
|
nonce = enc.nonce;
|
||||||
|
bodyVersion = 1;
|
||||||
|
}
|
||||||
const mentions = extractMentions(text);
|
const mentions = extractMentions(text);
|
||||||
const res = await fetch("/api/v1/messages", {
|
const res = await fetch("/api/v1/messages", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -444,6 +575,7 @@ export function TopicChatPanel({
|
|||||||
topic: topicName,
|
topic: topicName,
|
||||||
ciphertext,
|
ciphertext,
|
||||||
nonce,
|
nonce,
|
||||||
|
bodyVersion,
|
||||||
...(mentions.length > 0 ? { mentions } : {}),
|
...(mentions.length > 0 ? { mentions } : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -491,10 +623,10 @@ export function TopicChatPanel({
|
|||||||
const filteredMessages = useMemo(() => {
|
const filteredMessages = useMemo(() => {
|
||||||
if (!searchTerm) return messages;
|
if (!searchTerm) return messages;
|
||||||
return messages.filter((m) =>
|
return messages.filter((m) =>
|
||||||
decodeIncoming(m.ciphertext).toLowerCase().includes(searchTerm) ||
|
resolveText(m).toLowerCase().includes(searchTerm) ||
|
||||||
(m.senderName ?? "").toLowerCase().includes(searchTerm),
|
(m.senderName ?? "").toLowerCase().includes(searchTerm),
|
||||||
);
|
);
|
||||||
}, [messages, searchTerm]);
|
}, [messages, searchTerm, resolveText]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[70vh] flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
|
<div className="flex h-[70vh] flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
|
||||||
@@ -588,7 +720,15 @@ export function TopicChatPanel({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[var(--cm-fg)] text-sm leading-relaxed whitespace-pre-wrap break-words">
|
<p className="text-[var(--cm-fg)] text-sm leading-relaxed whitespace-pre-wrap break-words">
|
||||||
{renderWithMentions(decodeIncoming(m.ciphertext))}
|
{(m.bodyVersion ?? 1) === 2 ? (
|
||||||
|
<span
|
||||||
|
className="mr-1 text-[var(--cm-fg-tertiary)]"
|
||||||
|
title="end-to-end encrypted (v0.3.0 per-topic)"
|
||||||
|
>
|
||||||
|
🔒
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{renderWithMentions(resolveText(m))}
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -693,6 +833,23 @@ export function TopicChatPanel({
|
|||||||
error · {error}
|
error · {error}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{keyState === "not_sealed" ? (
|
||||||
|
<p
|
||||||
|
className="mb-2 text-[10px] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={monoStyle}
|
||||||
|
title="The CLI's 30s re-seal loop will share the topic key with this browser shortly. Messages you send now go as v1 plaintext."
|
||||||
|
>
|
||||||
|
🔒 waiting for a CLI peer to share the topic key — sending v1 plaintext until then
|
||||||
|
</p>
|
||||||
|
) : keyState === "ready" ? (
|
||||||
|
<p
|
||||||
|
className="mb-2 text-[10px] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={monoStyle}
|
||||||
|
title="Messages you send are encrypted with the topic's symmetric key (crypto_secretbox)."
|
||||||
|
>
|
||||||
|
🔒 end-to-end encrypted (v0.3.0)
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<form
|
<form
|
||||||
className="relative flex gap-2"
|
className="relative flex gap-2"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
|
|||||||
136
apps/web/src/services/crypto/identity.ts
Normal file
136
apps/web/src/services/crypto/identity.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Browser-side persistent peer identity for claudemesh.
|
||||||
|
*
|
||||||
|
* Stores an ed25519 keypair in IndexedDB so the same browser tab,
|
||||||
|
* the same browser after a reload, and the same user across reloads
|
||||||
|
* keeps the same identity. Without this, every page-reload would
|
||||||
|
* mint a new pubkey and the broker's per-topic-key seal would have
|
||||||
|
* to chase a moving target.
|
||||||
|
*
|
||||||
|
* The keypair lives at `claudemesh-identity / kp / default`. There's
|
||||||
|
* one identity per browser profile, shared across every mesh the
|
||||||
|
* dashboard user is in. The matching `mesh.member.peer_pubkey` rows
|
||||||
|
* are kept in sync server-side via `POST /v1/me/peer-pubkey`.
|
||||||
|
*
|
||||||
|
* Threat model: IndexedDB is per-origin and not exfiltratable from
|
||||||
|
* other sites. A malicious extension or full XSS still wins — same
|
||||||
|
* as for any browser-stored secret. The CLI's own keypair has
|
||||||
|
* stronger guarantees because it lives in `~/.claudemesh/` outside
|
||||||
|
* of the browser. We document the divergence in the dashboard UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import sodium from "libsodium-wrappers";
|
||||||
|
|
||||||
|
export interface BrowserIdentity {
|
||||||
|
/** ed25519 public key — registered as `mesh.member.peer_pubkey`. */
|
||||||
|
edPub: Uint8Array;
|
||||||
|
/** ed25519 secret key — never leaves IndexedDB. */
|
||||||
|
edSec: Uint8Array;
|
||||||
|
/** x25519 public key, derived from edPub. Used in `crypto_box`. */
|
||||||
|
xPub: Uint8Array;
|
||||||
|
/** x25519 secret key, derived from edSec. Used in `crypto_box_open`. */
|
||||||
|
xSec: Uint8Array;
|
||||||
|
/** Hex form of `edPub` — what the API and DB store. */
|
||||||
|
edPubHex: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DB_NAME = "claudemesh-identity";
|
||||||
|
const STORE = "kp";
|
||||||
|
const KEY = "default";
|
||||||
|
|
||||||
|
let cached: BrowserIdentity | null = null;
|
||||||
|
let initPromise: Promise<BrowserIdentity> | null = null;
|
||||||
|
|
||||||
|
async function openDb(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, 1);
|
||||||
|
req.onupgradeneeded = () => {
|
||||||
|
req.result.createObjectStore(STORE);
|
||||||
|
};
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readKeypair(): Promise<{
|
||||||
|
edPub: Uint8Array;
|
||||||
|
edSec: Uint8Array;
|
||||||
|
} | null> {
|
||||||
|
const db = await openDb();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, "readonly");
|
||||||
|
const req = tx.objectStore(STORE).get(KEY);
|
||||||
|
req.onsuccess = () => {
|
||||||
|
const v = req.result as
|
||||||
|
| { edPub: Uint8Array; edSec: Uint8Array }
|
||||||
|
| undefined;
|
||||||
|
resolve(v ?? null);
|
||||||
|
};
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeKeypair(kp: {
|
||||||
|
edPub: Uint8Array;
|
||||||
|
edSec: Uint8Array;
|
||||||
|
}): Promise<void> {
|
||||||
|
const db = await openDb();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, "readwrite");
|
||||||
|
tx.objectStore(STORE).put(kp, KEY);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get-or-create the browser's persistent identity. First call on a
|
||||||
|
* given origin generates a fresh ed25519 keypair, persists it, and
|
||||||
|
* derives the matching x25519 pair. Subsequent calls return the
|
||||||
|
* in-memory cache.
|
||||||
|
*
|
||||||
|
* Server registration (`POST /v1/me/peer-pubkey`) is the caller's
|
||||||
|
* responsibility — this module only manages the local keypair.
|
||||||
|
*/
|
||||||
|
export async function getBrowserIdentity(): Promise<BrowserIdentity> {
|
||||||
|
if (cached) return cached;
|
||||||
|
if (initPromise) return initPromise;
|
||||||
|
|
||||||
|
initPromise = (async () => {
|
||||||
|
await sodium.ready;
|
||||||
|
let stored = await readKeypair();
|
||||||
|
if (!stored) {
|
||||||
|
const kp = sodium.crypto_sign_keypair();
|
||||||
|
stored = { edPub: kp.publicKey, edSec: kp.privateKey };
|
||||||
|
await writeKeypair(stored);
|
||||||
|
}
|
||||||
|
const xPub = sodium.crypto_sign_ed25519_pk_to_curve25519(stored.edPub);
|
||||||
|
const xSec = sodium.crypto_sign_ed25519_sk_to_curve25519(stored.edSec);
|
||||||
|
cached = {
|
||||||
|
edPub: stored.edPub,
|
||||||
|
edSec: stored.edSec,
|
||||||
|
xPub,
|
||||||
|
xSec,
|
||||||
|
edPubHex: sodium.to_hex(stored.edPub),
|
||||||
|
};
|
||||||
|
return cached;
|
||||||
|
})();
|
||||||
|
return initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wipe the local identity. The server-side `mesh.member.peer_pubkey`
|
||||||
|
* is NOT cleared by this — call `POST /v1/me/peer-pubkey` again with
|
||||||
|
* a fresh pubkey after rotation.
|
||||||
|
*/
|
||||||
|
export async function clearBrowserIdentity(): Promise<void> {
|
||||||
|
cached = null;
|
||||||
|
initPromise = null;
|
||||||
|
const db = await openDb();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, "readwrite");
|
||||||
|
tx.objectStore(STORE).delete(KEY);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
220
apps/web/src/services/crypto/topic-key.ts
Normal file
220
apps/web/src/services/crypto/topic-key.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* Browser port of the CLI's per-topic key crypto.
|
||||||
|
*
|
||||||
|
* Mirrors apps/cli/src/services/crypto/topic-key.ts so a single mental
|
||||||
|
* model covers both surfaces:
|
||||||
|
*
|
||||||
|
* 1. UI mints a REST apikey for the dashboard user.
|
||||||
|
* 2. UI ensures `mesh.member.peer_pubkey` matches the browser's
|
||||||
|
* IndexedDB-persisted identity via POST /v1/me/peer-pubkey.
|
||||||
|
* 3. UI fetches GET /v1/topics/:name/key. Once any CLI peer has
|
||||||
|
* re-sealed the topic key for this member, the response carries
|
||||||
|
* `<32-byte sender x25519 pubkey> || crypto_box(topicKey)`.
|
||||||
|
* 4. UI converts the browser's ed25519 secret to x25519 and
|
||||||
|
* crypto_box_open's the seal.
|
||||||
|
* 5. Plaintext topic key is cached in-memory (per apikey + topic)
|
||||||
|
* and used for crypto_secretbox encrypt + decrypt of v2 message
|
||||||
|
* bodies.
|
||||||
|
*
|
||||||
|
* Cache key uses the apikey prefix so a logout clears it implicitly.
|
||||||
|
* Refresh on logout / 401 to avoid leaking keys across sessions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import sodium from "libsodium-wrappers";
|
||||||
|
|
||||||
|
import { getBrowserIdentity } from "./identity";
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
topicKey: Uint8Array;
|
||||||
|
fetchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<string, CacheEntry>();
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return `${apiKeySecret.slice(0, 12)}:${topicName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSealed(
|
||||||
|
apiKeySecret: string,
|
||||||
|
topicName: string,
|
||||||
|
): Promise<{ ok: true; data: SealedKeyResponse } | { ok: false; status: number; message?: string }> {
|
||||||
|
const res = await fetch(`/api/v1/topics/${encodeURIComponent(topicName)}/key`, {
|
||||||
|
headers: { Authorization: `Bearer ${apiKeySecret}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let message: string | undefined;
|
||||||
|
try {
|
||||||
|
const body = (await res.json()) as { error?: string };
|
||||||
|
message = body.error;
|
||||||
|
} catch {
|
||||||
|
// empty
|
||||||
|
}
|
||||||
|
return { ok: false, status: res.status, message };
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as SealedKeyResponse;
|
||||||
|
return { ok: true, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTopicKey(args: {
|
||||||
|
apiKeySecret: string;
|
||||||
|
topicName: string;
|
||||||
|
/** Bypass cache — useful after a re-seal lands. */
|
||||||
|
fresh?: boolean;
|
||||||
|
}): Promise<TopicKeyResult> {
|
||||||
|
const cacheId = cacheKey(args.apiKeySecret, args.topicName);
|
||||||
|
if (!args.fresh) {
|
||||||
|
const cached = cache.get(cacheId);
|
||||||
|
if (cached) return { ok: true, topicKey: cached.topicKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sealed = await fetchSealed(args.apiKeySecret, args.topicName);
|
||||||
|
if (!sealed.ok) {
|
||||||
|
if (sealed.status === 404) return { ok: false, error: "not_sealed" };
|
||||||
|
if (sealed.status === 409)
|
||||||
|
return { ok: false, error: "topic_unencrypted" };
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "network",
|
||||||
|
message: sealed.message ?? `HTTP ${sealed.status}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await sodium.ready;
|
||||||
|
const identity = await getBrowserIdentity();
|
||||||
|
|
||||||
|
let topicKey: Uint8Array;
|
||||||
|
try {
|
||||||
|
const blob = sodium.from_base64(
|
||||||
|
sealed.data.encryptedKey,
|
||||||
|
sodium.base64_variants.ORIGINAL,
|
||||||
|
);
|
||||||
|
const nonce = sodium.from_base64(
|
||||||
|
sealed.data.nonce,
|
||||||
|
sodium.base64_variants.ORIGINAL,
|
||||||
|
);
|
||||||
|
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,
|
||||||
|
identity.xSec,
|
||||||
|
);
|
||||||
|
} 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 with the topic key. Output matches the
|
||||||
|
* v0.3.0 wire format: bodyVersion=2, ciphertext+nonce both base64.
|
||||||
|
*/
|
||||||
|
export async function encryptMessage(
|
||||||
|
topicKey: Uint8Array,
|
||||||
|
plaintext: string,
|
||||||
|
): Promise<{ ciphertext: string; nonce: string }> {
|
||||||
|
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 ciphertext body. Returns null on auth failure so the
|
||||||
|
* caller can render a placeholder rather than crash.
|
||||||
|
*/
|
||||||
|
export async function decryptMessage(
|
||||||
|
topicKey: Uint8Array,
|
||||||
|
ciphertextB64: string,
|
||||||
|
nonceB64: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the browser's identity pubkey on the server so the next
|
||||||
|
* CLI re-seal pass can include this browser as a recipient. Idempotent.
|
||||||
|
*
|
||||||
|
* Returns `{ changed }` so callers can react (e.g. nudge "waiting for
|
||||||
|
* a CLI peer to share the topic key" until the next re-seal lands).
|
||||||
|
*/
|
||||||
|
export async function registerBrowserPeerPubkey(
|
||||||
|
apiKeySecret: string,
|
||||||
|
): Promise<{ memberId: string; pubkey: string; changed: boolean }> {
|
||||||
|
const identity = await getBrowserIdentity();
|
||||||
|
const res = await fetch("/api/v1/me/peer-pubkey", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKeySecret}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ pubkey: identity.edPubHex }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail: string;
|
||||||
|
try {
|
||||||
|
const j = (await res.json()) as { error?: string };
|
||||||
|
detail = j.error ?? `HTTP ${res.status}`;
|
||||||
|
} catch {
|
||||||
|
detail = `HTTP ${res.status}`;
|
||||||
|
}
|
||||||
|
throw new Error(`peer-pubkey registration failed: ${detail}`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as { memberId: string; pubkey: string; changed: boolean };
|
||||||
|
}
|
||||||
@@ -271,6 +271,61 @@ export const v1Router = new Hono<Env>()
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// POST /v1/me/peer-pubkey — register the caller's persistent peer pubkey.
|
||||||
|
//
|
||||||
|
// Browser users get a throwaway ed25519 pubkey at mesh-create time
|
||||||
|
// (no secret retained). To participate in v0.3.0 per-topic encryption
|
||||||
|
// they must replace it with a pubkey whose secret they actually hold
|
||||||
|
// (persisted in IndexedDB). This endpoint writes the new pubkey on the
|
||||||
|
// mesh.member row identified by the apikey's issuer; the broker / CLI
|
||||||
|
// re-seal loop then picks them up as a regular topic-key recipient
|
||||||
|
// within ~30s.
|
||||||
|
//
|
||||||
|
// Idempotent: same pubkey → no-op; different pubkey → updates and
|
||||||
|
// bumps `joined_at` so re-sealers notice the change. We do NOT
|
||||||
|
// invalidate the existing sealed topic_member_key rows here —
|
||||||
|
// they're keyed by member, not pubkey, and the next CLI re-seal pass
|
||||||
|
// will overwrite them with copies sealed to the new pubkey.
|
||||||
|
.post(
|
||||||
|
"/me/peer-pubkey",
|
||||||
|
validate(
|
||||||
|
"json",
|
||||||
|
z.object({
|
||||||
|
pubkey: z
|
||||||
|
.string()
|
||||||
|
.length(64)
|
||||||
|
.regex(/^[0-9a-f]{64}$/i, "must be 64 lowercase hex chars"),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const key = c.var.apiKey;
|
||||||
|
if (!key.issuedByMemberId) {
|
||||||
|
return c.json({ error: "api_key_has_no_issuer" }, 400);
|
||||||
|
}
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
const newPubkey = body.pubkey.toLowerCase();
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ peerPubkey: meshMember.peerPubkey })
|
||||||
|
.from(meshMember)
|
||||||
|
.where(eq(meshMember.id, key.issuedByMemberId));
|
||||||
|
if (!existing) {
|
||||||
|
return c.json({ error: "member_not_found" }, 404);
|
||||||
|
}
|
||||||
|
const changed = existing.peerPubkey !== newPubkey;
|
||||||
|
if (changed) {
|
||||||
|
await db
|
||||||
|
.update(meshMember)
|
||||||
|
.set({ peerPubkey: newPubkey })
|
||||||
|
.where(eq(meshMember.id, key.issuedByMemberId));
|
||||||
|
}
|
||||||
|
return c.json({
|
||||||
|
memberId: key.issuedByMemberId,
|
||||||
|
pubkey: newPubkey,
|
||||||
|
changed,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// GET /v1/topics — list topics in the key's mesh
|
// GET /v1/topics — list topics in the key's mesh
|
||||||
// Includes per-topic unread counts when the key has an issuing member
|
// Includes per-topic unread counts when the key has an issuing member
|
||||||
// (i.e. dashboard keys; CLI-minted keys also carry it). Counts are
|
// (i.e. dashboard keys; CLI-minted keys also carry it). Counts are
|
||||||
|
|||||||
46
pnpm-lock.yaml
generated
46
pnpm-lock.yaml
generated
@@ -453,6 +453,9 @@ importers:
|
|||||||
'@turbostarter/tsconfig':
|
'@turbostarter/tsconfig':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../tooling/typescript
|
version: link:../../tooling/typescript
|
||||||
|
'@types/libsodium-wrappers':
|
||||||
|
specifier: 0.7.14
|
||||||
|
version: 0.7.14
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: catalog:node22
|
specifier: catalog:node22
|
||||||
version: 22.16.0
|
version: 22.16.0
|
||||||
@@ -21848,7 +21851,7 @@ snapshots:
|
|||||||
'@sentry/bundler-plugin-core': 4.6.1(encoding@0.1.13)
|
'@sentry/bundler-plugin-core': 4.6.1(encoding@0.1.13)
|
||||||
unplugin: 1.0.1
|
unplugin: 1.0.1
|
||||||
uuid: 9.0.1
|
uuid: 9.0.1
|
||||||
webpack: 5.100.2(esbuild@0.25.0)
|
webpack: 5.100.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- encoding
|
- encoding
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -30980,6 +30983,15 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
esbuild: 0.25.0
|
esbuild: 0.25.0
|
||||||
|
|
||||||
|
terser-webpack-plugin@5.3.14(webpack@5.100.2):
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
jest-worker: 27.5.1
|
||||||
|
schema-utils: 4.3.2
|
||||||
|
serialize-javascript: 6.0.2
|
||||||
|
terser: 5.43.1
|
||||||
|
webpack: 5.100.2
|
||||||
|
|
||||||
terser@5.43.1:
|
terser@5.43.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/source-map': 0.3.10
|
'@jridgewell/source-map': 0.3.10
|
||||||
@@ -31729,6 +31741,38 @@ snapshots:
|
|||||||
|
|
||||||
webpack-virtual-modules@0.5.0: {}
|
webpack-virtual-modules@0.5.0: {}
|
||||||
|
|
||||||
|
webpack@5.100.2:
|
||||||
|
dependencies:
|
||||||
|
'@types/eslint-scope': 3.7.7
|
||||||
|
'@types/estree': 1.0.8
|
||||||
|
'@types/json-schema': 7.0.15
|
||||||
|
'@webassemblyjs/ast': 1.14.1
|
||||||
|
'@webassemblyjs/wasm-edit': 1.14.1
|
||||||
|
'@webassemblyjs/wasm-parser': 1.14.1
|
||||||
|
acorn: 8.16.0
|
||||||
|
acorn-import-phases: 1.0.4(acorn@8.16.0)
|
||||||
|
browserslist: 4.25.1
|
||||||
|
chrome-trace-event: 1.0.4
|
||||||
|
enhanced-resolve: 5.18.3
|
||||||
|
es-module-lexer: 1.7.0
|
||||||
|
eslint-scope: 5.1.1
|
||||||
|
events: 3.3.0
|
||||||
|
glob-to-regexp: 0.4.1
|
||||||
|
graceful-fs: 4.2.11
|
||||||
|
json-parse-even-better-errors: 2.3.1
|
||||||
|
loader-runner: 4.3.0
|
||||||
|
mime-types: 2.1.35
|
||||||
|
neo-async: 2.6.2
|
||||||
|
schema-utils: 4.3.2
|
||||||
|
tapable: 2.2.2
|
||||||
|
terser-webpack-plugin: 5.3.14(webpack@5.100.2)
|
||||||
|
watchpack: 2.4.4
|
||||||
|
webpack-sources: 3.3.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@swc/core'
|
||||||
|
- esbuild
|
||||||
|
- uglify-js
|
||||||
|
|
||||||
webpack@5.100.2(esbuild@0.25.0):
|
webpack@5.100.2(esbuild@0.25.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/eslint-scope': 3.7.7
|
'@types/eslint-scope': 3.7.7
|
||||||
|
|||||||
Reference in New Issue
Block a user