diff --git a/apps/web/src/services/chat.ts b/apps/web/src/services/chat.ts index 469cbd6..3187b1d 100644 --- a/apps/web/src/services/chat.ts +++ b/apps/web/src/services/chat.ts @@ -1,8 +1,8 @@ import type { Event as NostrEvent } from "nostr-tools"; import { nip19 } from "nostr-tools"; import { SimplePool } from "nostr-tools/pool"; -import { fetchChat, postChatMessage, postChatReaction } from "./api"; -import { hasActiveSigner } from "./signer"; +import { fetchChat, fetchChatPubkeys, postChatMessage, postChatReaction } from "./api"; +import { getActiveSigner, hasActiveSigner } from "./signer"; const RELAYS = ["wss://relay.primal.net"]; const POLL_MS = 2500; @@ -32,6 +32,7 @@ export type ChatProfile = { }; let reactionCache: ChatReaction[] = []; +let cachedRecipients: string[] | null = null; export function canSendChat(): boolean { return hasActiveSigner(); @@ -49,13 +50,13 @@ export function pubkeyFromNpub(npub: string | null): string | null { export async function loadChat(): Promise { const snapshot = await fetchChat(); - reactionCache = snapshot.reactions; - return snapshot.messages.sort((a, b) => a.createdAt - b.createdAt); + 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 { const wanted = new Set(messageIds); - if (!reactionCache.length) reactionCache = (await fetchChat()).reactions; + if (!reactionCache.length) reactionCache = await Promise.all((await fetchChat()).reactions.map(decryptReaction)); return reactionCache.filter((r) => wanted.has(r.messageId)); } @@ -73,12 +74,12 @@ export function subscribeChat( for (const message of snapshot.messages) { if (seenMessages.has(message.id)) continue; seenMessages.add(message.id); - onMessage(message); + onMessage(await decryptMessage(message)); } for (const reaction of snapshot.reactions) { if (seenReactions.has(reaction.id)) continue; seenReactions.add(reaction.id); - onReaction?.(reaction); + onReaction?.(await decryptReaction(reaction)); } } @@ -95,18 +96,22 @@ export function subscribeChat( }; } -export function sendChat( +export async function sendChat( content: string, replyTo?: { id: string; pubkey: string }, ): Promise { - return postChatMessage({ - content, + const encrypted = await encryptPayload(content); + const message = await postChatMessage({ + content: encrypted, ...(replyTo ? { replyToId: replyTo.id, replyToPubkey: replyTo.pubkey } : {}), }); + return { ...message, content }; } -export function sendReaction(message: ChatMessage, content: string): Promise { - return postChatReaction({ messageId: message.id, content }); +export async function sendReaction(message: ChatMessage, content: string): Promise { + const encrypted = await encryptPayload(content); + const reaction = await postChatReaction({ messageId: message.id, content: encrypted }); + return { ...reaction, content }; } export async function loadProfiles(pubkeys: string[]): Promise> { @@ -137,3 +142,63 @@ export async function loadProfiles(pubkeys: string[]): Promise; +}; + +async function encryptPayload(plaintext: string): Promise { + const signer = getActiveSigner(); + if (!signer) throw new Error("Reconnect your signer to encrypt private chat"); + const recipients = await getRecipients(); + try { + const encrypted = await Promise.all( + recipients.map(async (pubkey) => ({ + pubkey, + ciphertext: await signer.encrypt(pubkey, plaintext), + })), + ); + return JSON.stringify({ v: 1, alg: "nip44", room: "gashboard-chat", recipients: encrypted } 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.", + ); + } +} + +async function decryptPayload(value: string, senderPubkey: string): Promise { + const signer = getActiveSigner(); + if (!signer) return "[encrypted - reconnect signer]"; + try { + const own = await signer.getPublicKey(); + const envelope = JSON.parse(value) as EncryptedEnvelope; + if (envelope.v !== 1 || envelope.alg !== "nip44" || 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); + } catch { + return "[encrypted - decrypt failed]"; + } +} + +async function decryptMessage(message: ChatMessage): Promise { + return { ...message, content: await decryptPayload(message.content, message.pubkey) }; +} + +async function decryptReaction(reaction: ChatReaction): Promise { + return { ...reaction, content: await decryptPayload(reaction.content, reaction.pubkey) }; +} + +async function getRecipients(): Promise { + const signer = getActiveSigner(); + const own = await signer?.getPublicKey(); + if (!cachedRecipients) cachedRecipients = await fetchChatPubkeys(); + return [...new Set([...(cachedRecipients ?? []), ...(own ? [own] : [])])].filter(Boolean); +}