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