Make Nostr chat private
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user