From df94aed0d0b3a552379bf52e313e3e5a3ff3329a Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Sat, 9 Aug 2025 00:42:41 +0200 Subject: [PATCH] basic nip86 implmentation --- biome.json | 31 ++++++ bun.lock | 29 ++++++ index.ts | 25 +++-- package.json | 3 + src/main.ts | 271 +++++++++++++++++++++++++++++++++++++++++---------- src/utils.ts | 67 +++++++++++++ 6 files changed, 370 insertions(+), 56 deletions(-) create mode 100644 biome.json create mode 100644 src/utils.ts diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..5f36d03 --- /dev/null +++ b/biome.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "ignore": [] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/bun.lock b/bun.lock index c6f8faf..9e52ef6 100644 --- a/bun.lock +++ b/bun.lock @@ -3,6 +3,9 @@ "workspaces": { "": { "name": "nip42-proxy", + "dependencies": { + "nostr-tools": "^2.16.2", + }, "devDependencies": { "@types/bun": "latest", }, @@ -12,6 +15,18 @@ }, }, "packages": { + "@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="], + + "@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="], + + "@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="], + + "@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="], + + "@scure/bip32": ["@scure/bip32@1.3.1", "", { "dependencies": { "@noble/curves": "~1.1.0", "@noble/hashes": "~1.3.1", "@scure/base": "~1.1.0" } }, "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A=="], + + "@scure/bip39": ["@scure/bip39@1.2.1", "", { "dependencies": { "@noble/hashes": "~1.3.0", "@scure/base": "~1.1.0" } }, "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg=="], + "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], "@types/node": ["@types/node@24.2.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw=="], @@ -22,8 +37,22 @@ "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "nostr-tools": ["nostr-tools@2.16.2", "", { "dependencies": { "@noble/ciphers": "^0.5.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.1", "@scure/base": "1.1.1", "@scure/bip32": "1.3.1", "@scure/bip39": "1.2.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-ZxH9EbSt5ypURZj2TGNJxZd0Omb5ag5KZSu8IyJMCdLyg2KKz+2GA0sP/cSawCQEkyviIN4eRT4G2gB/t9lMRw=="], + + "nostr-wasm": ["nostr-wasm@0.1.0", "", {}, "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="], + "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + + "@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], + + "@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="], + + "@scure/bip32/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], + + "@scure/bip39/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], + + "@scure/bip32/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="], } } diff --git a/index.ts b/index.ts index 236dc47..a62228e 100644 --- a/index.ts +++ b/index.ts @@ -1,8 +1,21 @@ -import { main } from "./src/main.ts"; +import { main, type RelayConfig } from "./src/main.ts"; -let allowUnauthedPublish = Boolean(process.env.ALLOW_UNAUTHED_PUBLISH) || false; -let relay = process.env.RELAY_URL ?? Bun.argv[Bun.argv.length - 1]; -if (!relay?.startsWith("wss://") && !relay?.startsWith("ws://")) - relay = "wss://relay.arx-ccn.com"; +let config: RelayConfig = { + allowUnauthedPublish: Boolean(process.env.ALLOW_UNAUTHED_PUBLISH) || false, + relay: process.env.RELAY_URL!, + name: process.env.RELAY_NAME, + description: process.env.RELAY_DESCRIPTION, + banner: process.env.RELAY_BANNER, + icon: process.env.RELAY_ICON, + contact: process.env.RELAY_CONTACT, + policy: process.env.RELAY_POLICY, + adminPubkey: process.env.ADMIN_PUBKEY, +}; -main(relay, allowUnauthedPublish) +if ( + !config.relay || + (!config.relay?.startsWith("wss://") && !config.relay?.startsWith("ws://")) +) + config.relay = "wss://relay.arx-ccn.com"; + +main(config); diff --git a/package.json b/package.json index 90b4c81..eabe435 100644 --- a/package.json +++ b/package.json @@ -8,5 +8,8 @@ }, "peerDependencies": { "typescript": "^5" + }, + "dependencies": { + "nostr-tools": "^2.16.2" } } diff --git a/src/main.ts b/src/main.ts index f4ddcf6..d4ed007 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,99 +1,270 @@ import type { ServerWebSocket } from "bun"; - -interface Event { - kind: number; - tags: string[][]; - content: string; - created_at: number; - pubkey: string; - id: string; - sig: string; -} +import { + allowPubkey, + banPubkey, + getAllAllowedPubkeys, + isPubkeyAllowed, + validateAuthEvent, +} from "./utils.ts"; +import { getGitCommitHash } from "./utils.ts" with { type: "macro" }; +import { nip98 } from "nostr-tools"; type Nip42ProxySocketData = { authenticated: boolean; authToken: string; - remoteWs: WebSocket; + remoteWs?: WebSocket; }; -async function validateAuthEvent(event: Event, challenge: string): Promise { - if (event.kind !== 22242) return false; - const last30Seconds = Math.floor(Date.now() / 1000) - 30; - if (event.created_at < last30Seconds) return false; - const challengeTag = event.tags.find(tag => tag[0] === 'challenge')?.[1]; - if (challengeTag !== challenge) return false; - return await isPubkeyAllowed(event); +const sendMessage = ( + ws: ServerWebSocket, + message: any[], +) => ws.send(JSON.stringify(message), true); +const sendAuth = (ws: ServerWebSocket) => + sendMessage(ws, [ + "AUTH", + ws.data.authToken, + "This is an authenticated relay.", + ]); + +export interface RelayConfig { + adminPubkey?: string; + relay: string; + name?: string; + description?: string; + icon?: string; + banner?: string; + contact?: string; + policy?: string; + allowUnauthedPublish: boolean; } -async function isPubkeyAllowed(event: Event): Promise { - const file = Bun.file("./allowed-pubkeys.json"); - if (!await file.exists()) return true; - const allowedPubkeys = JSON.parse(await file.text()); - return allowedPubkeys.includes(event.pubkey); -} +export function main({ + relay, + adminPubkey, + name, + description, + icon, + banner, + contact, + policy, + allowUnauthedPublish, +}: RelayConfig) { + function relayInfo() { + return new Response( + JSON.stringify({ + name, + description, + banner, + pubkey: adminPubkey, + contact, + software: "https://git.arx-ccn.com/Arx/nip42-proxy.git", + supported_nips: [1, 2, 4, 9, 11, 22, 28, 40, 42, 70, 77, 86], + version: `nip42-proxy - ${getGitCommitHash()}`, + posting_policy: policy, + icon, + limitation: { + auth_required: true, + restricted_writes: true, + }, + }), + { headers: { "content-type": "application/json; charset=utf-8" } }, + ); + } -const sendMessage = (ws: ServerWebSocket, message: any[]) => ws.send(JSON.stringify(message), true); -const sendAuth = (ws: ServerWebSocket) => sendMessage(ws, ["AUTH", ws.data.authToken, "This is an authenticated relay."]); + async function relayRPC( + config: RelayConfig, + token: string, + url: string, + method: string, + params: any[], + ) { + if (method === "supportedmethods") + return new Response( + JSON.stringify({ + result: [ + "supportedmethods", + "banpubkey", + "allowpubkey", + "listallowedpubkeys", + ], + }), + ); + const valid = await nip98.validateToken(token, url, "POST"); + if (!token || !valid) + return new Response( + JSON.stringify({ + error: "You are not authorized", + }), + ); + const event = await nip98.unpackEventFromToken(token); + if (adminPubkey && event.pubkey !== adminPubkey) + return new Response( + JSON.stringify({ + error: "You are not authorized", + }), + ); + else if (!(await isPubkeyAllowed(event))) + return new Response( + JSON.stringify({ + error: "You are not authorized", + }), + ); + + if (method === "allowpubkey") { + const [pubkey] = params; + if (!pubkey) + return new Response( + JSON.stringify({ + error: `Missing pubkey parameter`, + }), + ); + await allowPubkey(pubkey); + return new Response( + JSON.stringify({ + result: true, + }), + ); + } + if (method === "listallowedpubkeys") { + const resp = await getAllAllowedPubkeys(); + return new Response( + JSON.stringify({ + result: resp.map((k: string) => ({ pubkey: k })), + }), + ); + } + if (method === "banpubkey") { + const [pubkey] = params; + if (!pubkey) + return new Response( + JSON.stringify({ + error: `Missing pubkey parameter`, + }), + ); + await banPubkey(pubkey); + return new Response( + JSON.stringify({ + result: true, + }), + ); + } + return new Response( + JSON.stringify({ + error: `Unsupported method ${method}`, + }), + ); + } -export function main(mainRelayUrl: string, allowUnauthedPublish: boolean) { const server = Bun.serve({ - fetch(req, server) { - const upgrade = server.upgrade(req, { + async fetch(req, server) { + const upgraded = server.upgrade(req, { data: { authenticated: false, - authToken: Bun.randomUUIDv7() - } + authToken: Bun.randomUUIDv7(), + }, }); - if (!upgrade) console.log(`http request`) - return new Response("this is a proxy. Use http to access it"); + if (upgraded) return undefined as any; + const url = new URL(req.url); + if (url.pathname === "/") { + switch (req.headers.get("content-type")) { + case "application/nostr+json": + return relayInfo(); + case "application/nostr+json+rpc": + const token = req.headers.get("Authorization") || ""; + let data = await req.json(); + if (!data) + return new Response( + JSON.stringify({ + error: "Please send a valid nostr rpc request", + }), + ); + let { method, params } = await req.json(); + params ||= []; + return relayRPC(adminPubkey, token, req.url, method, params); + break; + default: + return new Response("Nip42 Proxy"); + } + } + if (url.pathname === "/api/health") + return new Response( + JSON.stringify({ + activeRequests: server.pendingRequests, + activeWebsockets: server.pendingWebSockets, + }), + ); }, websocket: { async message(ws, msg) { - const [command, ...data] = JSON.parse(msg); + const [command, ...data] = JSON.parse(msg as string); if (!ws.data.authenticated) { if (command === "REQ") { const [name, ...filters] = data; - sendMessage(ws, ["CLOSED", name, 'auth-required: you must authenticate first']); + sendMessage(ws, [ + "CLOSED", + name, + "auth-required: you must authenticate first", + ]); } if (command === "EVENT") { const [event] = data; - if (allowUnauthedPublish && await isPubkeyAllowed(event)) { + if (allowUnauthedPublish && (await isPubkeyAllowed(event))) { ws.data.remoteWs.send(msg); return; } - sendMessage(ws, ["OK", event.id, false, 'auth-required: you must authenticate first']); + sendMessage(ws, [ + "OK", + event.id, + false, + "auth-required: you must authenticate first", + ]); } if (command === "AUTH") { const [event] = data; const valid = await validateAuthEvent(event, ws.data.authToken); - if (!valid) return sendMessage(ws, ['NOTICE', "Invalid auth event"]); - sendMessage(ws, ['OK', event.id, true, "successully authenticated. welcome"]); + if (!valid) + return sendMessage(ws, ["NOTICE", "Invalid auth event"]); + sendMessage(ws, [ + "OK", + event.id, + true, + "successully authenticated. welcome", + ]); ws.data.authenticated = true; return; } return sendAuth(ws); } else if (ws.data.authenticated && command === "AUTH") { const [event] = data; - return sendMessage(ws, ['OK', event.id, true, 'you were already authenticated']) + return sendMessage(ws, [ + "OK", + event.id, + true, + "you were already authenticated", + ]); } ws.data.remoteWs.send(msg); }, open(ws) { - const remoteWs = new WebSocket(mainRelayUrl); - remoteWs.addEventListener("message", data => ws.send(data.data, true)); + const remoteWs = new WebSocket(relay); + remoteWs.addEventListener("message", (data) => + ws.send( + (data as MessageEvent).data as string | ArrayBufferLike, + true, + ), + ); remoteWs.addEventListener("open", () => { ws.data.remoteWs = remoteWs; sendAuth(ws); - }) - remoteWs.addEventListener("error", console.error) + }); + remoteWs.addEventListener("error", console.error); }, perMessageDeflate: true, - perMessageDeflateCompressionLevel: 9 + perMessageDeflateCompressionLevel: 9, close(ws) { ws.data.remoteWs?.close(); - } - } - }) - console.log('Server listening @', server.url.host) + }, + }, + }); + console.log("Server listening @", server.url.host); } - diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..1eaed03 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,67 @@ +import type { Event } from "nostr-tools"; + +export function getGitCommitHash() { + try { + const { stdout } = Bun.spawnSync({ + cmd: ["git", "rev-parse", "HEAD"], + stdout: "pipe", + stderr: "pipe", + }); + const out = stdout?.toString()?.trim(); + return out?.split("\n")[0] || "unknown"; + } catch { + return "unknown"; + } +} + +export async function validateAuthEvent( + event: Event, + challenge: string, +): Promise { + if (event.kind !== 22242) return false; + const last30Seconds = Math.floor(Date.now() / 1000) - 30; + if (event.created_at < last30Seconds) return false; + const challengeTag = event.tags.find((tag) => tag[0] === "challenge")?.[1]; + if (challengeTag !== challenge) return false; + return await isPubkeyAllowed(event); +} + +export async function isPubkeyAllowed(event: Event): Promise { + const file = Bun.file("./allowed-pubkeys.json"); + if (!(await file.exists())) return true; + try { + const allowedPubkeys = JSON.parse(await file.text()); + return ( + Array.isArray(allowedPubkeys) && allowedPubkeys.includes(event.pubkey) + ); + } catch { + // If the file is malformed, default to deny + return false; + } +} + +export async function getAllAllowedPubkeys() { + const file = Bun.file("./allowed-pubkeys.json"); + try { + if (await file.exists()) return JSON.parse(await file.text()); + } catch {} + return [] as string[]; +} + +export async function saveAllowedPubkeys(pubkeys: string[]): Promise { + const file = Bun.file("./allowed-pubkeys.json"); + const unique = Array.from(new Set(pubkeys)); + return await file.write(JSON.stringify(unique)); +} + +export async function allowPubkey(pubkey: string): Promise { + const pubkeys = await getAllAllowedPubkeys(); + if (!pubkeys.includes(pubkey)) pubkeys.push(pubkey); + return await saveAllowedPubkeys(pubkeys); +} + +export async function banPubkey(pubkey: string): Promise { + const pubkeys = await getAllAllowedPubkeys(); + const filtered = pubkeys.filter((p: string) => p !== pubkey); + return await saveAllowedPubkeys(filtered); +}