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
+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");
});