Persist encrypted chat and preserve room keys when inviting viewers
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user