basic nip86 implmentation

This commit is contained in:
Danny Morabito 2025-08-09 00:42:41 +02:00
parent 22a4fe069f
commit df94aed0d0
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
6 changed files with 370 additions and 56 deletions

31
biome.json Normal file
View file

@ -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"
}
}
}

View file

@ -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=="],
}
}

View file

@ -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);

View file

@ -8,5 +8,8 @@
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"nostr-tools": "^2.16.2"
}
}

View file

@ -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<boolean> {
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<Nip42ProxySocketData>,
message: any[],
) => ws.send(JSON.stringify(message), true);
const sendAuth = (ws: ServerWebSocket<Nip42ProxySocketData>) =>
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<boolean> {
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<Nip42ProxySocketData>, message: any[]) => ws.send(JSON.stringify(message), true);
const sendAuth = (ws: ServerWebSocket<Nip42ProxySocketData>) => 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<Nip42ProxySocketData, {}>({
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);
}

67
src/utils.ts Normal file
View file

@ -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<boolean> {
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<boolean> {
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<number> {
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<number> {
const pubkeys = await getAllAllowedPubkeys();
if (!pubkeys.includes(pubkey)) pubkeys.push(pubkey);
return await saveAllowedPubkeys(pubkeys);
}
export async function banPubkey(pubkey: string): Promise<number> {
const pubkeys = await getAllAllowedPubkeys();
const filtered = pubkeys.filter((p: string) => p !== pubkey);
return await saveAllowedPubkeys(filtered);
}