Encrypt private API chat with NIP-44
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user