Persist encrypted chat and preserve room keys when inviting viewers

This commit is contained in:
yaya
2026-10-06 08:49:06 +01:00
parent 6650ae2ca2
commit 5db0d5acc3
8 changed files with 195 additions and 97 deletions
+19
View File
@@ -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.
+3 -2
View File
@@ -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:
+14 -11
View File
@@ -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 });
});
+61 -62
View File
@@ -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;
}
+2
View File
@@ -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(/\/$/, ""),
@@ -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);
@@ -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");
});
+40 -22
View File
@@ -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<ChatReaction> {
return { ...reaction, content: await decryptPayload(reaction.content) };
}
async function getRecipients(): Promise<string[]> {
const signer = getActiveSigner();
const own = await signer?.getPublicKey();
if (!cachedRecipients) cachedRecipients = await fetchChatPubkeys();
return [...new Set([...(cachedRecipients ?? []), ...(own ? [own] : [])])].filter(Boolean);
}
let roomKeyPending: Promise<Uint8Array> | null = null;
async function ensureRoomKey(snapshot?: ChatSnapshot): Promise<Uint8Array> {
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<Uint8Array> {
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<void> {
async function publishRoomKey(raw: string, recipients: string[]): Promise<void> {
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<Uint8Array> {