Encrypt private API chat with NIP-44

This commit is contained in:
Dorian
2026-05-06 21:10:25 +01:00
parent 0ff7bc46fc
commit 4dd83b6387

View File

@@ -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<ChatMessage[]> {
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<ChatReaction[]> {
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<ChatMessage> {
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<ChatReaction> {
return postChatReaction({ messageId: message.id, content });
export async function sendReaction(message: ChatMessage, content: string): Promise<ChatReaction> {
const encrypted = await encryptPayload(content);
const reaction = await postChatReaction({ messageId: message.id, content: encrypted });
return { ...reaction, content };
}
export async function loadProfiles(pubkeys: string[]): Promise<Record<string, ChatProfile>> {
@@ -137,3 +142,63 @@ export async function loadProfiles(pubkeys: string[]): Promise<Record<string, Ch
export function shortPubkey(pubkey: string): string {
return `${pubkey.slice(0, 8)}:${pubkey.slice(-4)}`;
}
type EncryptedEnvelope = {
v: 1;
alg: "nip44";
room: "gashboard-chat";
recipients: Array<{ pubkey: 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();
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<string> {
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<ChatMessage> {
return { ...message, content: await decryptPayload(message.content, message.pubkey) };
}
async function decryptReaction(reaction: ChatReaction): Promise<ChatReaction> {
return { ...reaction, content: await decryptPayload(reaction.content, reaction.pubkey) };
}
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);
}