feat(web+api): browser-side per-topic encryption (v0.3.0 phase 3.5)
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 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:
Alejandro Gutiérrez
2026-05-02 22:59:08 +01:00
parent ce321c0a21
commit a3cf9b938e
6 changed files with 637 additions and 24 deletions

View 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);
});
}

View 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 };
}