From a3cf9b938e791cc2d7b1a95e5f080fa08f9d33f1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?=
<35082514+alezmad@users.noreply.github.com>
Date: Sat, 2 May 2026 22:59:08 +0100
Subject: [PATCH] feat(web+api): browser-side per-topic encryption (v0.3.0
phase 3.5)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
apps/web/package.json | 1 +
.../web/src/modules/mesh/topic-chat-panel.tsx | 203 ++++++++++++++--
apps/web/src/services/crypto/identity.ts | 136 +++++++++++
apps/web/src/services/crypto/topic-key.ts | 220 ++++++++++++++++++
packages/api/src/modules/mesh/v1-router.ts | 55 +++++
pnpm-lock.yaml | 46 +++-
6 files changed, 637 insertions(+), 24 deletions(-)
create mode 100644 apps/web/src/services/crypto/identity.ts
create mode 100644 apps/web/src/services/crypto/topic-key.ts
diff --git a/apps/web/package.json b/apps/web/package.json
index c34e504..c27df76 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -73,6 +73,7 @@
"@turbostarter/eslint-config": "workspace:*",
"@turbostarter/prettier-config": "workspace:*",
"@turbostarter/tsconfig": "workspace:*",
+ "@types/libsodium-wrappers": "0.7.14",
"@types/node": "catalog:node22",
"@types/qrcode": "1.5.6",
"@types/react": "catalog:react19",
diff --git a/apps/web/src/modules/mesh/topic-chat-panel.tsx b/apps/web/src/modules/mesh/topic-chat-panel.tsx
index ba9b3b6..0b7b792 100644
--- a/apps/web/src/modules/mesh/topic-chat-panel.tsx
+++ b/apps/web/src/modules/mesh/topic-chat-panel.tsx
@@ -5,12 +5,22 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@turbostarter/ui-web/button";
+import {
+ decryptMessage,
+ encryptMessage,
+ getTopicKey,
+ registerBrowserPeerPubkey,
+} from "~/services/crypto/topic-key";
+
interface TopicMessage {
id: string;
senderPubkey: string;
senderName: string;
nonce: string;
ciphertext: string;
+ /** 1 = legacy plaintext-base64. 2 = crypto_secretbox under topic key. */
+ bodyVersion?: number;
+ replyToId?: string | null;
createdAt: string;
}
@@ -35,12 +45,28 @@ interface Props {
}
/**
- * Encode plaintext into the broker's wire format. v0.2.0 uses base64
- * plaintext in the `ciphertext` field — real per-topic symmetric keys
- * land in v0.3.0. Same applies to the random nonce: it satisfies the
- * schema but isn't cryptographically meaningful yet.
+ * v1 (legacy plaintext-base64) decode path. v0.2.0 messages used this
+ * fake-encryption stub; real v0.3.0 ciphertext is decrypted via the
+ * topic key — see `decryptForRender` below.
*/
-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 ciphertext =
typeof window === "undefined"
@@ -55,20 +81,6 @@ function encodeOutgoing(plaintext: string): { ciphertext: string; nonce: string
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
* mention regex and rebuild as alternating spans so React can reconcile
@@ -187,6 +199,19 @@ export function TopicChatPanel({
const seenIdsRef = useRef
+ 🔒 waiting for a CLI peer to share the topic key — sending v1 plaintext until then +
+ ) : keyState === "ready" ? ( ++ 🔒 end-to-end encrypted (v0.3.0) +
+ ) : null}