Use room key encryption for private chat
This commit is contained in:
@@ -1,19 +1,26 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { requireAuth } from "../auth/middleware.js";
|
||||
import { addMessage, addReaction, listChat } from "./store.js";
|
||||
import { addMessage, addReaction, listChat, upsertKeyWrap } from "./store.js";
|
||||
|
||||
export const chatRouter = Router();
|
||||
|
||||
const MessageBody = z.object({
|
||||
content: z.string().trim().min(1).max(2000),
|
||||
content: z.string().trim().min(1).max(12000),
|
||||
replyToId: z.string().max(128).optional(),
|
||||
replyToPubkey: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
const ReactionBody = z.object({
|
||||
messageId: z.string().min(1).max(128),
|
||||
content: z.string().trim().min(1).max(32),
|
||||
content: z.string().trim().min(1).max(12000),
|
||||
});
|
||||
|
||||
const KeyWrapBody = z.object({
|
||||
wraps: z.array(z.object({
|
||||
recipientPubkey: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
ciphertext: z.string().trim().min(1).max(12000),
|
||||
})).min(1).max(10),
|
||||
});
|
||||
|
||||
chatRouter.use(requireAuth);
|
||||
@@ -42,3 +49,15 @@ chatRouter.post("/reactions", (req, res) => {
|
||||
});
|
||||
res.status(201).json(reaction);
|
||||
});
|
||||
|
||||
chatRouter.post("/key-wraps", (req, res) => {
|
||||
const body = KeyWrapBody.parse(req.body);
|
||||
const wraps = body.wraps.map((wrap) =>
|
||||
upsertKeyWrap({
|
||||
recipientPubkey: wrap.recipientPubkey,
|
||||
senderPubkey: req.session!.pubkey,
|
||||
ciphertext: wrap.ciphertext,
|
||||
}),
|
||||
);
|
||||
res.status(201).json({ wraps });
|
||||
});
|
||||
|
||||
@@ -15,14 +15,26 @@ export type StoredChatReaction = {
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type StoredChatKeyWrap = {
|
||||
recipientPubkey: string;
|
||||
senderPubkey: string;
|
||||
ciphertext: string;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
const MAX_MESSAGES = 500;
|
||||
const MAX_REACTIONS = 2000;
|
||||
|
||||
const messages: StoredChatMessage[] = [];
|
||||
const reactions: StoredChatReaction[] = [];
|
||||
const keyWraps: StoredChatKeyWrap[] = [];
|
||||
|
||||
export function listChat(): { messages: StoredChatMessage[]; reactions: StoredChatReaction[] } {
|
||||
return { messages: [...messages], reactions: [...reactions] };
|
||||
export function listChat(): {
|
||||
messages: StoredChatMessage[];
|
||||
reactions: StoredChatReaction[];
|
||||
keyWraps: StoredChatKeyWrap[];
|
||||
} {
|
||||
return { messages: [...messages], reactions: [...reactions], keyWraps: [...keyWraps] };
|
||||
}
|
||||
|
||||
export function addMessage(input: {
|
||||
@@ -60,3 +72,26 @@ export function addReaction(input: {
|
||||
if (reactions.length > MAX_REACTIONS) reactions.splice(0, reactions.length - MAX_REACTIONS);
|
||||
return reaction;
|
||||
}
|
||||
|
||||
export function upsertKeyWrap(input: {
|
||||
recipientPubkey: string;
|
||||
senderPubkey: string;
|
||||
ciphertext: string;
|
||||
}): StoredChatKeyWrap {
|
||||
const updatedAt = Math.floor(Date.now() / 1000);
|
||||
const existing = keyWraps.find((wrap) => wrap.recipientPubkey === input.recipientPubkey);
|
||||
if (existing) {
|
||||
existing.senderPubkey = input.senderPubkey;
|
||||
existing.ciphertext = input.ciphertext;
|
||||
existing.updatedAt = updatedAt;
|
||||
return existing;
|
||||
}
|
||||
const wrap: StoredChatKeyWrap = {
|
||||
recipientPubkey: input.recipientPubkey,
|
||||
senderPubkey: input.senderPubkey,
|
||||
ciphertext: input.ciphertext,
|
||||
updatedAt,
|
||||
};
|
||||
keyWraps.push(wrap);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -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