Use room key encryption for private chat

This commit is contained in:
Dorian
2026-05-06 21:32:47 +01:00
parent 4dd83b6387
commit 0a6571a590
5 changed files with 166 additions and 30 deletions

View File

@@ -107,3 +107,9 @@ export function postChatReaction(body: {
}): Promise<ChatSnapshot["reactions"][number]> {
return authedPost<ChatSnapshot["reactions"][number]>("/api/chat/reactions", body);
}
export function postChatKeyWraps(body: {
wraps: Array<{ recipientPubkey: string; ciphertext: string }>;
}): Promise<{ wraps: ChatSnapshot["keyWraps"] }> {
return authedPost<{ wraps: ChatSnapshot["keyWraps"] }>("/api/chat/key-wraps", body);
}

View File

@@ -1,8 +1,9 @@
import type { Event as NostrEvent } from "nostr-tools";
import { nip19 } from "nostr-tools";
import { SimplePool } from "nostr-tools/pool";
import { fetchChat, fetchChatPubkeys, postChatMessage, postChatReaction } from "./api";
import { fetchChat, fetchChatPubkeys, postChatKeyWraps, postChatMessage, postChatReaction } from "./api";
import { getActiveSigner, hasActiveSigner } from "./signer";
import type { ChatSnapshot } from "../types";
const RELAYS = ["wss://relay.primal.net"];
const POLL_MS = 2500;
@@ -33,6 +34,8 @@ export type ChatProfile = {
let reactionCache: ChatReaction[] = [];
let cachedRecipients: string[] | null = null;
let cachedRoomKey: CryptoKey | null = null;
let cachedRoomKeyRaw = "";
export function canSendChat(): boolean {
return hasActiveSigner();
@@ -50,13 +53,18 @@ export function pubkeyFromNpub(npub: string | null): string | null {
export async function loadChat(): Promise<ChatMessage[]> {
const snapshot = await fetchChat();
await ensureRoomKey(snapshot);
reactionCache = await Promise.all(snapshot.reactions.map(decryptReaction));
return (await Promise.all(snapshot.messages.map(decryptMessage))).sort((a, b) => a.createdAt - b.createdAt);
}
export async function loadReactions(messageIds: string[]): Promise<ChatReaction[]> {
const wanted = new Set(messageIds);
if (!reactionCache.length) reactionCache = await Promise.all((await fetchChat()).reactions.map(decryptReaction));
if (!reactionCache.length) {
const snapshot = await fetchChat();
await ensureRoomKey(snapshot);
reactionCache = await Promise.all(snapshot.reactions.map(decryptReaction));
}
return reactionCache.filter((r) => wanted.has(r.messageId));
}
@@ -71,6 +79,7 @@ export function subscribeChat(
async function poll(): Promise<void> {
if (closed) return;
const snapshot = await fetchChat();
await ensureRoomKey(snapshot);
for (const message of snapshot.messages) {
if (seenMessages.has(message.id)) continue;
seenMessages.add(message.id);
@@ -145,55 +154,61 @@ export function shortPubkey(pubkey: string): string {
type EncryptedEnvelope = {
v: 1;
alg: "nip44";
alg: "aes-gcm";
room: "gashboard-chat";
recipients: Array<{ pubkey: string; ciphertext: string }>;
iv: string;
ciphertext: string;
};
async function encryptPayload(plaintext: string): Promise<string> {
const signer = getActiveSigner();
if (!signer) throw new Error("Reconnect your signer to encrypt private chat");
const recipients = await getRecipients();
const key = await ensureRoomKey();
try {
const encrypted = await Promise.all(
recipients.map(async (pubkey) => ({
pubkey,
ciphertext: await signer.encrypt(pubkey, plaintext),
})),
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
new TextEncoder().encode(plaintext),
);
return JSON.stringify({ v: 1, alg: "nip44", room: "gashboard-chat", recipients: encrypted } satisfies EncryptedEnvelope);
return JSON.stringify({
v: 1,
alg: "aes-gcm",
room: "gashboard-chat",
iv: base64(iv),
ciphertext: base64(new Uint8Array(ciphertext)),
} satisfies EncryptedEnvelope);
} catch (e) {
throw new Error(
e instanceof Error
? `Private chat encryption failed: ${e.message}. Reconnect signer and approve NIP-44 encryption.`
: "Private chat encryption failed. Reconnect signer and approve NIP-44 encryption.",
? `Private chat encryption failed: ${e.message}. Reconnect signer if the room key is missing.`
: "Private chat encryption failed. Reconnect signer if the room key is missing.",
);
}
}
async function decryptPayload(value: string, senderPubkey: string): Promise<string> {
const signer = getActiveSigner();
if (!signer) return "[encrypted - reconnect signer]";
async function decryptPayload(value: string): Promise<string> {
try {
const own = await signer.getPublicKey();
const envelope = JSON.parse(value) as EncryptedEnvelope;
if (envelope.v !== 1 || envelope.alg !== "nip44" || envelope.room !== "gashboard-chat") {
if (envelope.v !== 1 || envelope.alg !== "aes-gcm" || envelope.room !== "gashboard-chat") {
return "[encrypted with unknown chat format]";
}
const item = envelope.recipients.find((r) => r.pubkey === own);
if (!item) return "[encrypted for another recipient]";
return await signer.decrypt(senderPubkey, item.ciphertext);
const key = await ensureRoomKey();
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: unbase64(envelope.iv) },
key,
unbase64(envelope.ciphertext),
);
return new TextDecoder().decode(decrypted);
} catch {
return "[encrypted - decrypt failed]";
}
}
async function decryptMessage(message: ChatMessage): Promise<ChatMessage> {
return { ...message, content: await decryptPayload(message.content, message.pubkey) };
return { ...message, content: await decryptPayload(message.content) };
}
async function decryptReaction(reaction: ChatReaction): Promise<ChatReaction> {
return { ...reaction, content: await decryptPayload(reaction.content, reaction.pubkey) };
return { ...reaction, content: await decryptPayload(reaction.content) };
}
async function getRecipients(): Promise<string[]> {
@@ -202,3 +217,58 @@ async function getRecipients(): Promise<string[]> {
if (!cachedRecipients) cachedRecipients = await fetchChatPubkeys();
return [...new Set([...(cachedRecipients ?? []), ...(own ? [own] : [])])].filter(Boolean);
}
async function ensureRoomKey(snapshot?: ChatSnapshot): Promise<CryptoKey> {
if (cachedRoomKey) return cachedRoomKey;
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);
}
const rawBytes = crypto.getRandomValues(new Uint8Array(32));
const raw = base64(rawBytes);
await importRoomKey(raw);
await publishRoomKey(raw);
return cachedRoomKey!;
}
async function publishRoomKey(raw: string): Promise<void> {
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) => ({
recipientPubkey,
ciphertext: await signer.encrypt(recipientPubkey, raw),
})),
);
await postChatKeyWraps({ wraps });
}
async function importRoomKey(raw: string): Promise<CryptoKey> {
if (cachedRoomKey && cachedRoomKeyRaw === raw) return cachedRoomKey;
cachedRoomKey = await crypto.subtle.importKey("raw", unbase64(raw), { name: "AES-GCM" }, false, [
"encrypt",
"decrypt",
]);
cachedRoomKeyRaw = raw;
return cachedRoomKey;
}
function base64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function unbase64(value: string): Uint8Array {
const binary = atob(value);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
return out;
}

View File

@@ -102,6 +102,12 @@ export type ChatSnapshot = {
content: string;
createdAt: number;
}>;
keyWraps: Array<{
recipientPubkey: string;
senderPubkey: string;
ciphertext: string;
updatedAt: number;
}>;
};
export type ApiError = { error: { code: string; message: string } };