Use room key encryption for private chat
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
Reference in New Issue
Block a user