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:
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user