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>(new Set());
const lastMarkReadAtRef = useRef(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(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
) : null}
+ {keyState === "not_sealed" ? (
+
+ π 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}