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:
@@ -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
|
||||
// Includes per-topic unread counts when the key has an issuing member
|
||||
// (i.e. dashboard keys; CLI-minted keys also carry it). Counts are
|
||||
|
||||
Reference in New Issue
Block a user