From 5db0d5acc32a8c0d2b3ea9d48c95652c74eebd59 Mon Sep 17 00:00:00 2001 From: yaya Date: Tue, 6 Oct 2026 08:49:06 +0100 Subject: [PATCH] Persist encrypted chat and preserve room keys when inviting viewers --- apps/gashboard/README.md | 19 +++ apps/gashboard/manifest.yml | 5 +- docker/gashboard/apps/api/src/chat/routes.ts | 25 ++-- docker/gashboard/apps/api/src/chat/store.ts | 123 +++++++++--------- docker/gashboard/apps/api/src/config.ts | 2 + .../gashboard/apps/api/tests/access.test.ts | 6 + docker/gashboard/apps/api/tests/chat.test.ts | 50 +++++++ .../gashboard/apps/web/src/services/chat.ts | 62 +++++---- 8 files changed, 195 insertions(+), 97 deletions(-) create mode 100644 docker/gashboard/apps/api/tests/chat.test.ts diff --git a/apps/gashboard/README.md b/apps/gashboard/README.md index 000721af..9088fcfd 100644 --- a/apps/gashboard/README.md +++ b/apps/gashboard/README.md @@ -108,3 +108,22 @@ HTTPS, companion, preserved-data reinstall and reboot acceptance remain separate Payouts are not configured and no real miner shares were submitted in this check. These are node test deployments of review candidates, not catalog publication. + +### Private chat persistence and invitations + +Encrypted messages, reactions and per-recipient room-key wraps are saved atomically +in `/data/chat.json` (mode 0600), alongside the viewer list. The server never saves +the plaintext room key or decrypted messages. Back up the whole app data directory; +keep chat history and key wraps together. Invalid saved chat data stops startup +instead of silently discarding history. + +An existing member with chat open shares the existing room key with newly invited +viewers. If no existing member is online, the new viewer sees a pending-key message; +an existing member must open chat, then the viewer can reopen the panel. A new +viewer or simultaneous first visitor cannot replace the established key. Existing +history becomes readable to invited viewers. Revoking membership blocks API access +but cannot erase a key or history already received by that viewer. + +When upgrading from 0.2.0, export the authenticated `/api/chat` snapshot to +`/data/chat.json` before stopping the old container: that version holds chat only +in memory. Preserve the snapshot and data-directory backup through the upgrade. diff --git a/apps/gashboard/manifest.yml b/apps/gashboard/manifest.yml index 3e520e6e..4e216d32 100644 --- a/apps/gashboard/manifest.yml +++ b/apps/gashboard/manifest.yml @@ -1,7 +1,7 @@ app: id: gashboard name: Gashboard - version: 0.2.0 + version: 0.2.1 description: A playful mining dashboard for your DATUM fleet, with live hashrates, share history, lottery odds, chat and a Nostr viewer access list. upstream: kind: internal @@ -9,7 +9,7 @@ app: build: context: /opt/archipelago/docker/gashboard dockerfile: Dockerfile - tag: localhost/archipelago-gashboard:0.2.0 + tag: localhost/archipelago-gashboard:0.2.1 network: archy-net data_uid: "1000:1000" derived_env: @@ -58,6 +58,7 @@ app: - DATUM_ADMIN_USER=admin - CONTRIBUTION_LEDGER_PATH=/data/contribution-ledger.json - ACCESS_LIST_PATH=/data/access.json + - CHAT_STORE_PATH=/data/chat.json - ARCHIPELAGO_PROVIDER_PATH=/data/nostr-provider.js - MEMPOOL_API_URL=https://mempool.space/api hooks: diff --git a/docker/gashboard/apps/api/src/chat/routes.ts b/docker/gashboard/apps/api/src/chat/routes.ts index 160d3912..f52bc3e2 100644 --- a/docker/gashboard/apps/api/src/chat/routes.ts +++ b/docker/gashboard/apps/api/src/chat/routes.ts @@ -1,7 +1,12 @@ import { Router } from "express"; import { z } from "zod"; import { requireAuth } from "../auth/middleware.js"; -import { addMessage, addReaction, listChat, upsertKeyWrap } from "./store.js"; +import { ChatStore } from "./store.js"; +import { config } from "../config.js"; +import { isPubkeyAllowed } from "../nostr/allowlist.js"; +import { forbidden } from "../errors.js"; + +const chat = new ChatStore(config.chatStorePath); export const chatRouter = Router(); @@ -26,12 +31,12 @@ const KeyWrapBody = z.object({ chatRouter.use(requireAuth); chatRouter.get("/", (_req, res) => { - res.json(listChat()); + res.json(chat.list()); }); chatRouter.post("/messages", (req, res) => { const body = MessageBody.parse(req.body); - const message = addMessage({ + const message = chat.addMessage({ pubkey: req.session!.pubkey, content: body.content, ...(body.replyToId ? { replyToId: body.replyToId } : {}), @@ -42,7 +47,7 @@ chatRouter.post("/messages", (req, res) => { chatRouter.post("/reactions", (req, res) => { const body = ReactionBody.parse(req.body); - const reaction = addReaction({ + const reaction = chat.addReaction({ pubkey: req.session!.pubkey, messageId: body.messageId, content: body.content, @@ -52,12 +57,10 @@ chatRouter.post("/reactions", (req, res) => { chatRouter.post("/key-wraps", (req, res) => { const body = KeyWrapBody.parse(req.body); - const wraps = body.wraps.map((wrap) => - upsertKeyWrap({ - recipientPubkey: wrap.recipientPubkey, - senderPubkey: req.session!.pubkey, - ciphertext: wrap.ciphertext, - }), - ); + if (body.wraps.some(w => !isPubkeyAllowed(w.recipientPubkey))) throw forbidden(); + if (new Set(body.wraps.map(w => w.recipientPubkey)).size !== body.wraps.length) { + throw forbidden("duplicate_recipients"); + } + const wraps = chat.shareKey(req.session!.pubkey, body.wraps); res.status(201).json({ wraps }); }); diff --git a/docker/gashboard/apps/api/src/chat/store.ts b/docker/gashboard/apps/api/src/chat/store.ts index 65f93b24..b4de2b46 100644 --- a/docker/gashboard/apps/api/src/chat/store.ts +++ b/docker/gashboard/apps/api/src/chat/store.ts @@ -1,3 +1,9 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { AppError } from "../errors.js"; + export type StoredChatMessage = { id: string; pubkey: string; @@ -25,73 +31,66 @@ export type StoredChatKeyWrap = { const MAX_MESSAGES = 500; const MAX_REACTIONS = 2000; -const messages: StoredChatMessage[] = []; -const reactions: StoredChatReaction[] = []; -const keyWraps: StoredChatKeyWrap[] = []; - -export function listChat(): { +export type ChatSnapshot = { messages: StoredChatMessage[]; reactions: StoredChatReaction[]; keyWraps: StoredChatKeyWrap[]; -} { - return { messages: [...messages], reactions: [...reactions], keyWraps: [...keyWraps] }; -} +}; -export function addMessage(input: { - pubkey: string; - content: string; - replyToId?: string; - replyToPubkey?: string; -}): StoredChatMessage { - const message: StoredChatMessage = { - id: crypto.randomUUID(), - pubkey: input.pubkey, - content: input.content, - createdAt: Math.floor(Date.now() / 1000), - replyToId: input.replyToId ?? "", - replyToPubkey: input.replyToPubkey ?? "", - }; - messages.push(message); - if (messages.length > MAX_MESSAGES) messages.splice(0, messages.length - MAX_MESSAGES); - return message; -} +const pubkey = z.string().regex(/^[0-9a-f]{64}$/); +const savedChat = z.object({ + messages: z.array(z.object({ id: z.string(), pubkey, content: z.string(), createdAt: z.number(), replyToId: z.string(), replyToPubkey: z.string() })).max(MAX_MESSAGES), + reactions: z.array(z.object({ id: z.string(), pubkey, messageId: z.string(), content: z.string(), createdAt: z.number() })).max(MAX_REACTIONS), + keyWraps: z.array(z.object({ recipientPubkey: pubkey, senderPubkey: pubkey, ciphertext: z.string(), updatedAt: z.number() })), +}); -export function addReaction(input: { - pubkey: string; - messageId: string; - content: string; -}): StoredChatReaction { - const reaction: StoredChatReaction = { - id: crypto.randomUUID(), - pubkey: input.pubkey, - messageId: input.messageId, - content: input.content, - createdAt: Math.floor(Date.now() / 1000), - }; - reactions.push(reaction); - if (reactions.length > MAX_REACTIONS) reactions.splice(0, reactions.length - MAX_REACTIONS); - return reaction; -} +export class ChatStore { + private state: ChatSnapshot; -export function upsertKeyWrap(input: { - recipientPubkey: string; - senderPubkey: string; - ciphertext: string; -}): StoredChatKeyWrap { - const updatedAt = Math.floor(Date.now() / 1000); - const existing = keyWraps.find((wrap) => wrap.recipientPubkey === input.recipientPubkey); - if (existing) { - existing.senderPubkey = input.senderPubkey; - existing.ciphertext = input.ciphertext; - existing.updatedAt = updatedAt; - return existing; + constructor(private readonly file: string) { + try { + this.state = savedChat.parse(JSON.parse(readFileSync(file, "utf8"))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + this.state = { messages: [], reactions: [], keyWraps: [] }; + } + } + + list(): ChatSnapshot { return structuredClone(this.state); } + + private save(next: ChatSnapshot): void { + mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 }); + writeFileSync(`${this.file}.tmp`, JSON.stringify(next) + "\n", { mode: 0o600 }); + renameSync(`${this.file}.tmp`, this.file); + this.state = next; + } + + addMessage(input: { pubkey: string; content: string; replyToId?: string; replyToPubkey?: string }): StoredChatMessage { + const message = { ...input, id: randomUUID(), createdAt: Math.floor(Date.now() / 1000), replyToId: input.replyToId ?? "", replyToPubkey: input.replyToPubkey ?? "" }; + this.save({ ...this.state, messages: [...this.state.messages, message].slice(-MAX_MESSAGES) }); + return message; + } + + addReaction(input: { pubkey: string; messageId: string; content: string }): StoredChatReaction { + const reaction = { ...input, id: randomUUID(), createdAt: Math.floor(Date.now() / 1000) }; + this.save({ ...this.state, reactions: [...this.state.reactions, reaction].slice(-MAX_REACTIONS) }); + return reaction; + } + + shareKey(senderPubkey: string, wraps: Array<{ recipientPubkey: string; ciphertext: string }>): StoredChatKeyWrap[] { + // Only a member who already holds the room key can add recipients. Existing + // wraps are immutable: concurrent first visits must not replace a room key. + if (this.state.keyWraps.length && !this.state.keyWraps.some(w => w.recipientPubkey === senderPubkey)) { + throw new AppError(409, "chat_key_pending", "Ask an existing chat member to open chat and share the room key."); + } + if (!this.state.keyWraps.length && !wraps.some(w => w.recipientPubkey === senderPubkey)) { + throw new AppError(400, "chat_self_wrap_required", "Include your own encrypted room key."); + } + if (wraps.some(w => this.state.keyWraps.some(existing => existing.recipientPubkey === w.recipientPubkey))) { + throw new AppError(409, "chat_key_exists", "The room key is already shared. Reload chat."); + } + const added = wraps.map(w => ({ ...w, senderPubkey, updatedAt: Math.floor(Date.now() / 1000) })); + this.save({ ...this.state, keyWraps: [...this.state.keyWraps, ...added] }); + return added; } - const wrap: StoredChatKeyWrap = { - recipientPubkey: input.recipientPubkey, - senderPubkey: input.senderPubkey, - ciphertext: input.ciphertext, - updatedAt, - }; - keyWraps.push(wrap); - return wrap; } diff --git a/docker/gashboard/apps/api/src/config.ts b/docker/gashboard/apps/api/src/config.ts index 59de475e..25045528 100644 --- a/docker/gashboard/apps/api/src/config.ts +++ b/docker/gashboard/apps/api/src/config.ts @@ -20,6 +20,7 @@ const RawEnv = z.object({ NOSTR_ALLOWED_NPUBS: z.string().default(""), NOSTR_OWNER_PUBKEYS: z.string().default(""), + CHAT_STORE_PATH: z.string().min(1).default("/data/chat.json"), ACCESS_LIST_PATH: z.string().default("/data/access.json"), ARCHIPELAGO_PROVIDER_PATH: z.string().optional(), @@ -61,6 +62,7 @@ export const config = { logLevel: parsed.LOG_LEVEL, corsOrigin: parsed.CORS_ORIGIN, staticDir: parsed.STATIC_DIR, + chatStorePath: parsed.CHAT_STORE_PATH, providerPath: parsed.ARCHIPELAGO_PROVIDER_PATH, datum: { url: (parsed.DATUM_URL ?? "http://127.0.0.1:21000").replace(/\/$/, ""), diff --git a/docker/gashboard/apps/api/tests/access.test.ts b/docker/gashboard/apps/api/tests/access.test.ts index a8eb5809..bd2031e2 100644 --- a/docker/gashboard/apps/api/tests/access.test.ts +++ b/docker/gashboard/apps/api/tests/access.test.ts @@ -34,6 +34,7 @@ test("signed logins, owner-only invitations, immediate revocation, and provider process.env.JWT_SECRET = "local-test-only-secret-32-characters-long"; process.env.DATUM_ADMIN_PASSWORD = "local-test-only"; process.env.NOSTR_OWNER_PUBKEYS = ownerHex; + process.env.CHAT_STORE_PATH = join(dir, "chat.json"); process.env.ACCESS_LIST_PATH = join(dir, "api-access.json"); process.env.ARCHIPELAGO_PROVIDER_PATH = join(dir, "provider.js"); process.env.LOG_LEVEL = "silent"; @@ -65,6 +66,11 @@ test("signed logins, owner-only invitations, immediate revocation, and provider assert.equal(viewerLogin.status, 200); const viewerToken = (await viewerLogin.json()).token as string; assert.equal((await call("/api/datum/stats", viewerToken)).status, 200); + assert.equal((await call("/api/chat/key-wraps", ownerToken, "POST", { wraps: [{ recipientPubkey: ownerHex, ciphertext: "owner-wrap" }] })).status, 201); + assert.equal((await call("/api/chat/key-wraps", viewerToken, "POST", { wraps: [{ recipientPubkey: viewerHex, ciphertext: "replacement" }] })).status, 409); + assert.equal((await call("/api/chat/key-wraps", ownerToken, "POST", { wraps: [{ recipientPubkey: getPublicKey(outsider), ciphertext: "outsider-wrap" }] })).status, 403); + assert.equal((await call("/api/chat/key-wraps", ownerToken, "POST", { wraps: [{ recipientPubkey: viewerHex, ciphertext: "viewer-wrap" }] })).status, 201); + assert.equal((await call("/api/chat/key-wraps", ownerToken, "POST", { wraps: [{ recipientPubkey: ownerHex, ciphertext: "replacement" }] })).status, 409); assert.equal((await call("/api/access", viewerToken, "POST", { npub: nip19.npubEncode(getPublicKey(outsider)) })).status, 403); assert.deepEqual(await (await call("/api/access", viewerToken)).json(), { isOwner: false, members: [] }); assert.equal((await call(`/api/access/${nip19.npubEncode(ownerHex)}`, ownerToken, "DELETE")).status, 400); diff --git a/docker/gashboard/apps/api/tests/chat.test.ts b/docker/gashboard/apps/api/tests/chat.test.ts new file mode 100644 index 00000000..a95bc9b1 --- /dev/null +++ b/docker/gashboard/apps/api/tests/chat.test.ts @@ -0,0 +1,50 @@ +import { after, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +process.env.JWT_SECRET = "local-test-only-secret-32-characters-long"; +process.env.DATUM_ADMIN_PASSWORD = "local-test-only"; +process.env.NOSTR_OWNER_PUBKEYS = "a".repeat(64); +const { ChatStore } = await import("../src/chat/store.js"); +const dir = mkdtempSync(join(tmpdir(), "gashboard-chat-")); +after(() => rmSync(dir, { recursive: true, force: true })); +const owner = "a".repeat(64); +const viewer = "b".repeat(64); +const newcomer = "c".repeat(64); + +test("encrypted history, reactions and key wraps survive restart with private permissions", () => { + const file = join(dir, "chat.json"); + const store = new ChatStore(file); + store.shareKey(owner, [{ recipientPubkey: owner, ciphertext: "encrypted-key" }]); + const message = store.addMessage({ pubkey: owner, content: "encrypted-message" }); + store.addReaction({ pubkey: owner, messageId: message.id, content: "encrypted-reaction" }); + assert.deepEqual(new ChatStore(file).list(), store.list()); + assert.equal(statSync(file).mode & 0o777, 0o600); + const copy = store.list(); + copy.keyWraps[0]!.ciphertext = "changed"; + assert.equal(store.list().keyWraps[0]!.ciphertext, "encrypted-key"); + writeFileSync(file, "{}"); + assert.throws(() => new ChatStore(file)); +}); + +test("new invitations and simultaneous first visits cannot overwrite the room key", () => { + const store = new ChatStore(join(dir, "invites.json")); + store.shareKey(owner, [{ recipientPubkey: owner, ciphertext: "original-key" }]); + assert.throws(() => store.shareKey(viewer, [{ recipientPubkey: viewer, ciphertext: "different-key" }]), /existing chat member/); + store.shareKey(owner, [{ recipientPubkey: viewer, ciphertext: "same-key-for-viewer" }]); + assert.throws(() => store.shareKey(viewer, [{ recipientPubkey: viewer, ciphertext: "replacement-key" }]), /already shared/); + store.shareKey(viewer, [{ recipientPubkey: newcomer, ciphertext: "same-key-for-newcomer" }]); + assert.equal(store.list().keyWraps[0]!.ciphertext, "original-key"); + assert.equal(store.list().keyWraps.length, 3); +}); + +test("failed persistence does not report or retain an unsaved message", () => { + const parent = join(dir, "blocked"); + const store = new ChatStore(join(parent, "chat.json")); + writeFileSync(parent, "not a directory"); + assert.throws(() => store.addMessage({ pubkey: owner, content: "ciphertext" })); + assert.deepEqual(store.list().messages, []); + assert.equal(readFileSync(parent, "utf8"), "not a directory"); +}); diff --git a/docker/gashboard/apps/web/src/services/chat.ts b/docker/gashboard/apps/web/src/services/chat.ts index 3fbc0e63..85b41466 100644 --- a/docker/gashboard/apps/web/src/services/chat.ts +++ b/docker/gashboard/apps/web/src/services/chat.ts @@ -34,7 +34,6 @@ export type ChatProfile = { }; let reactionCache: ChatReaction[] = []; -let cachedRecipients: string[] | null = null; let cachedRoomKey: Uint8Array | null = null; let cachedRoomKeyRaw = ""; @@ -42,7 +41,6 @@ export function clearChatSession(): void { cachedRoomKey?.fill(0); cachedRoomKey = null; cachedRoomKeyRaw = ""; - cachedRecipients = null; reactionCache = []; } @@ -216,43 +214,63 @@ async function decryptReaction(reaction: ChatReaction): Promise { return { ...reaction, content: await decryptPayload(reaction.content) }; } -async function getRecipients(): Promise { - const signer = getActiveSigner(); - const own = await signer?.getPublicKey(); - if (!cachedRecipients) cachedRecipients = await fetchChatPubkeys(); - return [...new Set([...(cachedRecipients ?? []), ...(own ? [own] : [])])].filter(Boolean); -} +let roomKeyPending: Promise | null = null; async function ensureRoomKey(snapshot?: ChatSnapshot): Promise { - if (cachedRoomKey) return cachedRoomKey; + if (roomKeyPending) return roomKeyPending; + if (cachedRoomKey && !snapshot) return cachedRoomKey; + roomKeyPending = unlockRoom(snapshot); + try { return await roomKeyPending; } + finally { roomKeyPending = null; } +} + +async function unlockRoom(snapshot?: ChatSnapshot): Promise { const signer = getActiveSigner(); if (!signer) throw new Error("Reconnect your signer to unlock private chat"); const own = await signer.getPublicKey(); const current = snapshot ?? await fetchChat(); const wrap = current.keyWraps.find((item) => item.recipientPubkey === own); if (wrap) { - const raw = await signer.decrypt(wrap.senderPubkey, wrap.ciphertext); - return importRoomKey(raw); + if (!cachedRoomKey) await importRoomKey(await signer.decrypt(wrap.senderPubkey, wrap.ciphertext)); + const recipients = await fetchChatPubkeys(); + const missing = recipients.filter(key => !current.keyWraps.some(w => w.recipientPubkey === key)); + await publishRoomKey(cachedRoomKeyRaw, missing); + return cachedRoomKey!; + } + if (current.keyWraps.length || current.messages.length || current.reactions.length) { + throw new Error("Your chat invitation is waiting for its encryption key. Ask an existing chat member to open chat, then reopen this panel."); } - const rawBytes = crypto.getRandomValues(new Uint8Array(32)); - const raw = base64(rawBytes); + const raw = base64(crypto.getRandomValues(new Uint8Array(32))); + // Publish our own wrap first to claim an empty room atomically. A competing + // first visitor must use the winning key, never overwrite it with their own. + try { + await postChatKeyWraps({ wraps: [{ recipientPubkey: own, ciphertext: await signer.encrypt(own, raw) }] }); + } catch (error) { + if ((error as { code?: string }).code?.startsWith("chat_key_")) return unlockRoom(); + throw error; + } await importRoomKey(raw); - await publishRoomKey(raw); - return cachedRoomKey!; + return unlockRoom(); } -async function publishRoomKey(raw: string): Promise { +async function publishRoomKey(raw: string, recipients: string[]): Promise { + if (!recipients.length) return; const signer = getActiveSigner(); if (!signer) throw new Error("Reconnect your signer to share private chat key"); - const recipients = await getRecipients(); - const wraps = await Promise.all( - recipients.map(async (recipientPubkey) => ({ + // The API accepts ten recipients per request; invitations support larger rooms. + for (let offset = 0; offset < recipients.length; offset += 10) { + const wraps = await Promise.all(recipients.slice(offset, offset + 10).map(async recipientPubkey => ({ recipientPubkey, ciphertext: await signer.encrypt(recipientPubkey, raw), - })), - ); - await postChatKeyWraps({ wraps }); + }))); + try { await postChatKeyWraps({ wraps }); } + catch (error) { + // Another existing member may have shared these recipients during signing. + // Polling refreshes the remaining recipients without replacing any wrap. + if ((error as { code?: string }).code !== "chat_key_exists") throw error; + } + } } async function importRoomKey(raw: string): Promise {