Make Nostr chat private

This commit is contained in:
Dorian
2026-05-06 20:56:29 +01:00
parent 3b69ed26ed
commit cb21c693d0
6 changed files with 218 additions and 83 deletions

View File

@@ -6,6 +6,7 @@ import { issueSession } from "./jwt.js";
import { requireAuth } from "./middleware.js";
import { unauthorized, forbidden } from "../errors.js";
import { logger } from "../logger.js";
import { config } from "../config.js";
export const authRouter = Router();
@@ -48,3 +49,7 @@ authRouter.post("/login", loginLimiter, (req, res, next) => {
authRouter.get("/me", requireAuth, (req, res) => {
res.json({ npub: req.session!.npub });
});
authRouter.get("/chat-config", requireAuth, (_req, res) => {
res.json({ pubkeys: config.nostr.allowedHexPubkeys });
});

View File

@@ -204,7 +204,7 @@ async function scrollBottom(): Promise<void> {
<aside :class="['chat-drawer', { open }]">
<header>
<div>
<div class="label">nostr chat</div>
<div class="label">encrypted nostr chat</div>
<h2 class="glow-cyan">mining desk heckle box</h2>
</div>
<button class="thin" aria-label="close chat" @click="emit('close')">×</button>

View File

@@ -64,3 +64,8 @@ async function authedGet<T>(path: string): Promise<T> {
export async function fetchSnapshot(): Promise<DatumSnapshot> {
return authedGet<DatumSnapshot>("/api/datum/stats");
}
export async function fetchChatPubkeys(): Promise<string[]> {
const res = await authedGet<{ pubkeys: string[] }>("/api/auth/chat-config");
return res.pubkeys;
}

View File

@@ -1,11 +1,12 @@
import type { Event as NostrEvent, EventTemplate } from "nostr-tools";
import { nip19 } from "nostr-tools";
import type { Filter } from "nostr-tools/filter";
import { SimplePool } from "nostr-tools/pool";
import { fetchChatPubkeys } from "./api";
import { getActiveSigner, hasActiveSigner } from "./signer";
const RELAYS = ["wss://relay.primal.net"];
const CHAT_TAG = "gashboard-chat";
const PRIVATE_CHAT_KIND = 4;
const CHAT_ROOM = "gashboard-chat";
const pool = new SimplePool();
export type ChatMessage = {
@@ -31,6 +32,29 @@ export type ChatProfile = {
picture: string;
};
type PrivatePayload =
| {
room: typeof CHAT_ROOM;
v: 1;
type: "message";
id: string;
content: string;
createdAt: number;
replyToId?: string;
replyToPubkey?: string;
}
| {
room: typeof CHAT_ROOM;
v: 1;
type: "reaction";
id: string;
messageId: string;
content: string;
createdAt: number;
};
let cachedRecipients: string[] | null = null;
export function canSendChat(): boolean {
return hasActiveSigner();
}
@@ -46,36 +70,45 @@ export function pubkeyFromNpub(npub: string | null): string | null {
}
export async function loadChat(): Promise<ChatMessage[]> {
const events = await pool.querySync(
RELAYS,
{ kinds: [1], "#t": [CHAT_TAG], limit: 80 },
{ maxWait: 3000 },
);
return dedupe(events.map(asMessage)).sort((a, b) => a.createdAt - b.createdAt);
const { messages } = await loadPrivateChat();
return dedupe(messages).sort((a, b) => a.createdAt - b.createdAt);
}
export async function loadReactions(messageIds: string[]): Promise<ChatReaction[]> {
const ids = [...new Set(messageIds)].filter(Boolean);
if (!ids.length) return [];
const events = await pool.querySync(
RELAYS,
{ kinds: [7], "#e": ids, "#t": [CHAT_TAG], limit: 500 },
{ maxWait: 3000 },
);
return dedupeReactions(events.map(asReaction).filter((r) => r.messageId));
const wanted = new Set(messageIds);
const { reactions } = await loadPrivateChat();
return dedupeReactions(reactions.filter((r) => wanted.has(r.messageId)));
}
export function subscribeChat(
onMessage: (message: ChatMessage) => void,
onReaction?: (reaction: ChatReaction) => void,
): { close: () => void } {
const since = Math.floor(Date.now() / 1000) - 60;
return pool.subscribeMany(RELAYS, [{ kinds: [1, 7], "#t": [CHAT_TAG], since }], {
onevent(event) {
if (event.kind === 7) onReaction?.(asReaction(event));
else onMessage(asMessage(event));
},
const signer = getActiveSigner();
let closed = false;
let sub: { close: () => void } | null = null;
if (!signer) return { close: () => {} };
void signer.getPublicKey().then((ownPubkey) => {
if (closed) return;
const since = Math.floor(Date.now() / 1000) - 60;
sub = pool.subscribeMany(RELAYS, [{ kinds: [PRIVATE_CHAT_KIND], "#p": [ownPubkey], since }], {
onevent(event) {
void decryptEvent(event).then((payload) => {
if (!payload || closed) return;
if (payload.type === "reaction") onReaction?.(asReaction(payload, event.pubkey));
else onMessage(asMessage(payload, event.pubkey));
});
},
});
});
return {
close() {
closed = true;
sub?.close();
},
};
}
export async function sendChat(
@@ -83,49 +116,36 @@ export async function sendChat(
replyTo?: { id: string; pubkey: string },
): Promise<ChatMessage> {
const signer = getActiveSigner();
if (!signer) throw new Error("Chat needs the active signer. Log in again with your signer to send.");
const tags = [
["t", CHAT_TAG],
["client", "gashboard"],
];
if (replyTo) {
tags.push(["e", replyTo.id, "", "reply"]);
tags.push(["p", replyTo.pubkey]);
}
const template: EventTemplate = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags,
if (!signer) throw new Error("Private chat needs the active signer. Reconnect your signer to send.");
const pubkey = await signer.getPublicKey();
const payload: PrivatePayload = {
room: CHAT_ROOM,
v: 1,
type: "message",
id: crypto.randomUUID(),
content,
createdAt: Math.floor(Date.now() / 1000),
...(replyTo ? { replyToId: replyTo.id, replyToPubkey: replyTo.pubkey } : {}),
};
const signed = await signer.signEvent(template);
const results = await Promise.allSettled(pool.publish(RELAYS, signed));
if (!results.some((r) => r.status === "fulfilled")) {
throw new Error("Could not publish chat message to relay");
}
return asMessage(signed);
await publishPrivatePayload(payload);
return asMessage(payload, pubkey);
}
export async function sendReaction(message: ChatMessage, content: string): Promise<ChatReaction> {
const signer = getActiveSigner();
if (!signer) throw new Error("Reaction needs the active signer. Reconnect your signer to react.");
const template: EventTemplate = {
kind: 7,
created_at: Math.floor(Date.now() / 1000),
tags: [
["e", message.id],
["p", message.pubkey],
["t", CHAT_TAG],
["client", "gashboard"],
],
if (!signer) throw new Error("Private reactions need the active signer. Reconnect your signer to react.");
const pubkey = await signer.getPublicKey();
const payload: PrivatePayload = {
room: CHAT_ROOM,
v: 1,
type: "reaction",
id: crypto.randomUUID(),
messageId: message.id,
content,
createdAt: Math.floor(Date.now() / 1000),
};
const signed = await signer.signEvent(template);
const results = await Promise.allSettled(pool.publish(RELAYS, signed));
if (!results.some((r) => r.status === "fulfilled")) {
throw new Error("Could not publish reaction to relay");
}
return asReaction(signed);
await publishPrivatePayload(payload);
return asReaction(payload, pubkey);
}
export async function loadProfiles(pubkeys: string[]): Promise<Record<string, ChatProfile>> {
@@ -157,16 +177,92 @@ export function shortPubkey(pubkey: string): string {
return `${pubkey.slice(0, 8)}:${pubkey.slice(-4)}`;
}
function asMessage(event: NostrEvent): ChatMessage {
const replyTag = [...event.tags].reverse().find((tag) => tag[0] === "e" && (tag[3] === "reply" || tag[3] === "root" || tag[1]));
const replyPubkey = event.tags.find((tag) => tag[0] === "p")?.[1] ?? "";
async function loadPrivateChat(): Promise<{ messages: ChatMessage[]; reactions: ChatReaction[] }> {
const signer = getActiveSigner();
if (!signer) return { messages: [], reactions: [] };
const ownPubkey = await signer.getPublicKey();
const events = await pool.querySync(
RELAYS,
{ kinds: [PRIVATE_CHAT_KIND], "#p": [ownPubkey], limit: 500 },
{ maxWait: 3500 },
);
const payloads = await Promise.all(events.map((event) => decryptEvent(event)));
const messages: ChatMessage[] = [];
const reactions: ChatReaction[] = [];
for (let i = 0; i < events.length; i += 1) {
const payload = payloads[i];
const event = events[i];
if (!payload || !event) continue;
if (payload.type === "reaction") reactions.push(asReaction(payload, event.pubkey));
else messages.push(asMessage(payload, event.pubkey));
}
return { messages, reactions };
}
async function publishPrivatePayload(payload: PrivatePayload): Promise<void> {
const signer = getActiveSigner();
if (!signer) throw new Error("Private chat needs the active signer.");
const recipients = await getRecipients();
const plaintext = JSON.stringify(payload);
const signedEvents = await Promise.all(
recipients.map(async (recipient) => {
const encrypted = await signer.encrypt(recipient, plaintext);
const template: EventTemplate = {
kind: PRIVATE_CHAT_KIND,
created_at: Math.floor(Date.now() / 1000),
tags: [
["p", recipient],
["client", "gashboard"],
],
content: encrypted,
};
return signer.signEvent(template);
}),
);
const results = await Promise.allSettled(signedEvents.flatMap((event) => pool.publish(RELAYS, event)));
if (!results.some((r) => r.status === "fulfilled")) {
throw new Error("Could not publish private chat event to relay");
}
}
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);
}
async function decryptEvent(event: NostrEvent): Promise<PrivatePayload | null> {
const signer = getActiveSigner();
if (!signer) return null;
try {
const plaintext = await signer.decrypt(event.pubkey, event.content);
const payload = JSON.parse(plaintext) as PrivatePayload;
if (payload.room !== CHAT_ROOM || payload.v !== 1) return null;
return payload;
} catch {
return null;
}
}
function asMessage(payload: Extract<PrivatePayload, { type: "message" }>, pubkey: string): ChatMessage {
return {
id: event.id,
pubkey: event.pubkey,
content: event.content,
createdAt: event.created_at,
replyToId: replyTag?.[1] ?? "",
replyToPubkey: replyPubkey,
id: payload.id,
pubkey,
content: payload.content,
createdAt: payload.createdAt,
replyToId: payload.replyToId ?? "",
replyToPubkey: payload.replyToPubkey ?? "",
};
}
function asReaction(payload: Extract<PrivatePayload, { type: "reaction" }>, pubkey: string): ChatReaction {
return {
id: payload.id,
pubkey,
messageId: payload.messageId,
content: payload.content || "+",
createdAt: payload.createdAt,
};
}
@@ -174,16 +270,6 @@ function dedupe(messages: ChatMessage[]): ChatMessage[] {
return [...new Map(messages.map((m) => [m.id, m])).values()];
}
function asReaction(event: NostrEvent): ChatReaction {
return {
id: event.id,
pubkey: event.pubkey,
messageId: event.tags.find((tag) => tag[0] === "e")?.[1] ?? "",
content: event.content || "+",
createdAt: event.created_at,
};
}
function dedupeReactions(reactions: ChatReaction[]): ChatReaction[] {
return [...new Map(reactions.map((r) => [r.id, r])).values()];
}

View File

@@ -13,6 +13,8 @@ export type Signer = {
kind: SignerKind;
getPublicKey(): Promise<string>;
signEvent(template: EventTemplate): Promise<NostrEvent>;
encrypt(pubkey: string, plaintext: string): Promise<string>;
decrypt(pubkey: string, ciphertext: string): Promise<string>;
};
const activeSigner = ref<Signer | null>(null);
@@ -51,8 +53,18 @@ export async function loginWithExtension(): Promise<string> {
kind: template.kind,
created_at: template.created_at,
tags: template.tags,
content: template.content,
})) as NostrEvent,
content: template.content,
})) as NostrEvent,
encrypt: async (target: string, plaintext: string) => {
if (ext.nip44) return ext.nip44.encrypt(target, plaintext);
if (ext.nip04) return ext.nip04.encrypt(target, plaintext);
throw new Error("Your extension cannot encrypt private chat messages");
},
decrypt: async (sender: string, ciphertext: string) => {
if (ext.nip44) return ext.nip44.decrypt(sender, ciphertext);
if (ext.nip04) return ext.nip04.decrypt(sender, ciphertext);
throw new Error("Your extension cannot decrypt private chat messages");
},
};
saveSignerSession({ kind: "extension", pubkey });
return pubkey;
@@ -80,6 +92,8 @@ export async function loginWithBunker(bunkerUri: string): Promise<string> {
getPublicKey: async () => pubkey,
signEvent: async (template: EventTemplate) =>
(await signer.signEvent(template)) as NostrEvent,
encrypt: async (target: string, plaintext: string) => signer.nip44Encrypt(target, plaintext),
decrypt: async (sender: string, ciphertext: string) => signer.nip44Decrypt(sender, ciphertext),
};
saveSignerSession({
kind: "bunker",
@@ -133,8 +147,11 @@ async function connectRemoteApp(opts: { openApp: boolean }): Promise<string> {
publishMethod: publishToRelays,
});
const permissions =
NostrConnectSigner.buildSigningPermissions?.([27235]) ?? ["sign_event:27235"];
const permissions = [
...(NostrConnectSigner.buildSigningPermissions?.([27235, 4]) ?? ["sign_event:27235", "sign_event:4"]),
"nip44_encrypt",
"nip44_decrypt",
];
if (!pending) {
savePendingRemoteLogin({
key: Array.from(clientSigner.key),
@@ -165,6 +182,8 @@ async function connectRemoteApp(opts: { openApp: boolean }): Promise<string> {
getPublicKey: async () => pubkey,
signEvent: async (template: EventTemplate) =>
(await signer.signEvent(template)) as NostrEvent,
encrypt: async (target: string, plaintext: string) => signer.nip44Encrypt(target, plaintext),
decrypt: async (sender: string, ciphertext: string) => signer.nip44Decrypt(sender, ciphertext),
};
saveSignerSession({
kind: "bunker",
@@ -203,8 +222,18 @@ export async function restoreSavedSigner(): Promise<boolean> {
kind: template.kind,
created_at: template.created_at,
tags: template.tags,
content: template.content,
})) as NostrEvent,
content: template.content,
})) as NostrEvent,
encrypt: async (target: string, plaintext: string) => {
if (ext.nip44) return ext.nip44.encrypt(target, plaintext);
if (ext.nip04) return ext.nip04.encrypt(target, plaintext);
throw new Error("Your extension cannot encrypt private chat messages");
},
decrypt: async (sender: string, ciphertext: string) => {
if (ext.nip44) return ext.nip44.decrypt(sender, ciphertext);
if (ext.nip04) return ext.nip04.decrypt(sender, ciphertext);
throw new Error("Your extension cannot decrypt private chat messages");
},
};
saveSignerSession({ kind: "extension", pubkey });
return true;
@@ -233,6 +262,8 @@ export async function restoreSavedSigner(): Promise<boolean> {
getPublicKey: async () => saved.pubkey,
signEvent: async (template: EventTemplate) =>
(await signer.signEvent(template)) as NostrEvent,
encrypt: async (target: string, plaintext: string) => signer.nip44Encrypt(target, plaintext),
decrypt: async (sender: string, ciphertext: string) => signer.nip44Decrypt(sender, ciphertext),
};
return true;
}

View File

@@ -106,6 +106,14 @@ declare global {
content: string;
sig: string;
}>;
nip44?: {
encrypt(pubkey: string, plaintext: string): Promise<string>;
decrypt(pubkey: string, ciphertext: string): Promise<string>;
};
nip04?: {
encrypt(pubkey: string, plaintext: string): Promise<string>;
decrypt(pubkey: string, ciphertext: string): Promise<string>;
};
};
}
}