From dd1da10b8a84e401ecd9e921076de19acadd1bcc Mon Sep 17 00:00:00 2001 From: Yonle Date: Thu, 16 Nov 2023 21:53:58 +0700 Subject: [PATCH] introduce nip42 and public/personal usage Signed-off-by: Yonle --- README.md | 2 +- auth.js | 42 ++++++++++++++++++++++++++++++++++++++++++ bouncer/bouncer1.js | 36 ++++++++++++++++++++++++++++++++++-- bouncer/bouncer2.js | 36 ++++++++++++++++++++++++++++++++++-- config.js.example | 27 ++++++++++++++++++++++++--- http.js | 1 + nip42.js | 19 +++++++++++++++++++ package.json | 1 + 8 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 auth.js create mode 100644 nip42.js diff --git a/README.md b/README.md index 65d4fe2..c73304f 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ cd bostr npm i ``` -Rename `config.js.example` as `config.js`, Start editing the file and fill some required fields accordingly to your needs. +Rename `config.js.example` as `config.js`, Start editing the file and fill some required fields accordingly to your needs. You could either run it for everyone or only for yourself. ## Running ``` diff --git a/auth.js b/auth.js new file mode 100644 index 0000000..e9eae1d --- /dev/null +++ b/auth.js @@ -0,0 +1,42 @@ +const { validateEvent, verifySignature } = require("nostr-tools"); + +module.exports = (authKey, authorized, authorized_keys, data, ws, req) => { + if (!validateEvent(data)) { + ws.send(JSON.stringify(["NOTICE", "error: invalid challenge response."])); + return false; + } + + if (!verifySignature(data)) { + ws.send(JSON.stringify(["OK", data.id, false, "signature verification failed."])); + return false; + } + + if (!authorized_keys.includes(data.pubkey)) { + ws.send(JSON.stringify(["OK", data.id, false, "unauthorized."])); + return false; + } + + if (data.kind != 22242) { + ws.send(JSON.stringify(["OK", data.id, false, "not kind 22242."])); + return false; + } + + if (authorized) { + ws.send(JSON.stringify(["OK", data.id, false, "already authorized."])); + return false; + } + + const tags = new Map(data.tags); + if (!tags.get("relay").includes(req.headers.host)) { + ws.send(JSON.stringify(["OK", data.id, false, "unmatched relay url."])); + return false; + }; + + if (tags.get("challenge") !== authKey) { + ws.send(JSON.stringify(["OK", data.id, false, "unmatched challenge string."])); + return false; + } + + ws.send(JSON.stringify(["OK", data.id, true, `Welcome ${data.pubkey}`])); + return true; +} diff --git a/bouncer/bouncer1.js b/bouncer/bouncer1.js index e8a8baa..d916161 100644 --- a/bouncer/bouncer1.js +++ b/bouncer/bouncer1.js @@ -1,6 +1,11 @@ const SQLite = require("better-sqlite3"); const WebSocket = require("ws"); -const { relays, tmp_store, log_about_relays } = require("../config"); +const { validateEvent, nip19 } = require("nostr-tools"); +const auth = require("../auth.js"); +const nip42 = require("../nip42.js"); + +let { relays, tmp_store, log_about_relays, authorized_keys, private_keys } = require("../config"); + const socks = new Set(); const sess = new SQLite((process.env.IN_MEMORY || tmp_store != "disk") ? null : (__dirname + "/../.temporary.db")); const csess = new Map(); @@ -17,10 +22,21 @@ sess.exec("CREATE TABLE IF NOT EXISTS sess (cID TEXT, subID TEXT, filter TEXT);" sess.exec("CREATE TABLE IF NOT EXISTS events (cID TEXT, subID TEXT, eID TEXT);"); // To prevent transmitting duplicates sess.exec("CREATE TABLE IF NOT EXISTS recentEvents (cID TEXT, data TEXT);"); +authorized_keys = authorized_keys?.map(i => i.startsWith("npub") ? nip19.decode(i).data : i); + // CL - User socket module.exports = (ws, req) => { + let authKey = null; + let authorized = true; + ws.id = process.pid + Math.floor(Math.random() * 1000) + "_" + csess.size; + if (authorized_keys?.length) { + authKey = Date.now() + Math.random().toString(36); + authorized = false; + ws.send(JSON.stringify(["AUTH", authKey])); + } + console.log(process.pid, `->- ${req.headers["x-forwarded-for"]?.split(",")[0] || req.socket.address()?.address} connected as ${ws.id}`); ws.on("message", data => { try { @@ -33,7 +49,9 @@ module.exports = (ws, req) => { switch (data[0]) { case "EVENT": - if (!data[1]?.id) return ws.send(JSON.stringify(["NOTICE", "error: no event id."])); + if (!validateEvent(data[1])) return ws.send(JSON.stringify(["NOTICE", "error: invalid event"])); + if (data[1].kind == 22242) return ws.send(JSON.stringify(["OK", data[1]?.id, false, "rejected: kind 22242"])); + if (!authorized) return ws.send(JSON.stringify(["OK", data[1]?.id, false, "unauthorized."])); sess.prepare("INSERT INTO recentEvents VALUES (?, ?);").run(ws.id, JSON.stringify(data)); bc(data); ws.send(JSON.stringify(["OK", data[1]?.id, true, ""])); @@ -41,6 +59,7 @@ module.exports = (ws, req) => { case "REQ": if (data.length < 3) return ws.send(JSON.stringify(["NOTICE", "error: bad request."])); if (typeof(data[2]) !== "object") return ws.send(JSON.stringify(["NOTICE", "expected filter to be obj, instead gives the otherwise."])); + if (!authorized) return ws.send(JSON.stringify(["NOTICE", "unauthorized."])); data[1] = ws.id + ":" + data[1]; // eventname -> 1_eventname bc(data); @@ -52,6 +71,7 @@ module.exports = (ws, req) => { break; case "CLOSE": if (typeof(data[1]) !== "string") return ws.send(JSON.stringify(["NOTICE", "error: bad request."])); + if (!authorized) return ws.send(JSON.stringify(["NOTICE", "unauthorized."])); data[1] = ws.id + ":" + data[1]; bc(data); pendingEOSE.delete(data[1]); @@ -60,6 +80,12 @@ module.exports = (ws, req) => { sess.prepare("DELETE FROM sess WHERE cID = ? AND subID = ?;").run(ws.id, data[1]); sess.prepare("DELETE FROM events WHERE cID = ? AND subID = ?;").run(ws.id, data[1]); break; + case "AUTH": + if (auth(authKey, authorized, authorized_keys, data[1], ws, req)) { + ws.pubkey = data[1].pubkey; + authorized = true; + } + break; default: console.warn(process.pid, "---", "Unknown command:", data.join(" ")); ws.send(JSON.stringify(["NOTICE", "error: unrecognized command."])); @@ -184,6 +210,12 @@ function newConn(addr) { reqLimit.delete(subID); break; } + case "AUTH": { + if (!private_keys || typeof(data[1]) !== "string") return; + const pubkey = authorized_keys[0]; + nip42(relay, pubkey, private_keys[pubkey], data[1]); + break; + } } }); diff --git a/bouncer/bouncer2.js b/bouncer/bouncer2.js index 9af98f5..65a9a5f 100644 --- a/bouncer/bouncer2.js +++ b/bouncer/bouncer2.js @@ -1,6 +1,11 @@ const SQLite = require("better-sqlite3"); const WebSocket = require("ws"); -const { relays, tmp_store, log_about_relays } = require("../config"); +const { validateEvent, nip19 } = require("nostr-tools"); +const auth = require("../auth.js"); +const nip42 = require("../nip42.js"); + +let { relays, tmp_store, log_about_relays, authorized_keys, private_keys } = require("../config"); + const socks = new Set(); const sess = new SQLite((process.env.IN_MEMORY || tmp_store != "disk") ? null : (__dirname + "/../.temporary.db")); const csess = new Map(); @@ -17,10 +22,21 @@ sess.exec("CREATE TABLE IF NOT EXISTS sess (cID TEXT, subID TEXT, filter TEXT);" sess.exec("CREATE TABLE IF NOT EXISTS events (cID TEXT, subID TEXT, eID TEXT);"); // To prevent transmitting duplicates sess.exec("CREATE TABLE IF NOT EXISTS recentEvents (cID TEXT, data TEXT);"); +authorized_keys = authorized_keys?.map(i => i.startsWith("npub") ? nip19.decode(i).data : i); + // CL - User socket module.exports = (ws, req) => { + let authKey = null; + let authorized = true; + ws.id = process.pid + Math.floor(Math.random() * 1000) + "_" + csess.size; + if (authorized_keys?.length) { + authKey = Date.now() + Math.random().toString(36); + authorized = false; + ws.send(JSON.stringify(["AUTH", authKey])); + } + console.log(process.pid, `->- ${req.headers["x-forwarded-for"]?.split(",")[0] || req.socket.address()?.address} connected as ${ws.id}`); ws.on("message", data => { try { @@ -33,7 +49,9 @@ module.exports = (ws, req) => { switch (data[0]) { case "EVENT": - if (!data[1]?.id) return ws.send(JSON.stringify(["NOTICE", "error: no event id."])); + if (!validateEvent(data[1])) return ws.send(JSON.stringify(["NOTICE", "error: invalid event"])); + if (data[1].kind == 22242) return ws.send(JSON.stringify(["OK", data[1]?.id, false, "rejected: kind 22242"])); + if (!authorized) return ws.send(JSON.stringify(["OK", data[1]?.id, false, "unauthorized."])); sess.prepare("INSERT INTO recentEvents VALUES (?, ?);").run(ws.id, JSON.stringify(data)); bc(data, ws.id); ws.send(JSON.stringify(["OK", data[1]?.id, true, ""])); @@ -41,6 +59,7 @@ module.exports = (ws, req) => { case "REQ": if (data.length < 3) return ws.send(JSON.stringify(["NOTICE", "error: bad request."])); if (typeof(data[2]) !== "object") return ws.send(JSON.stringify(["NOTICE", "expected filter to be obj, instead gives the otherwise."])); + if (!authorized) return ws.send(JSON.stringify(["NOTICE", "unauthorized."])); // eventname -> 1_eventname bc(data, ws.id); sess.prepare("INSERT INTO sess VALUES (?, ?, ?);").run(ws.id, data[1], JSON.stringify(data[2])); @@ -51,6 +70,7 @@ module.exports = (ws, req) => { break; case "CLOSE": if (typeof(data[1]) !== "string") return ws.send(JSON.stringify(["NOTICE", "error: bad request."])); + if (!authorized) return ws.send(JSON.stringify(["NOTICE", "unauthorized."])); bc(data, ws.id); pendingEOSE.delete(ws.id + ":" + data[1]); reqLimit.delete(ws.id + ":" + data[1]); @@ -58,6 +78,12 @@ module.exports = (ws, req) => { sess.prepare("DELETE FROM sess WHERE cID = ? AND subID = ?;").run(ws.id, data[1]); sess.prepare("DELETE FROM events WHERE cID = ? AND subID = ?;").run(ws.id, data[1]); break; + case "AUTH": + if (auth(authKey, authorized, authorized_keys, data[1], ws, req)) { + ws.pubkey = data[1].pubkey; + authorized = true; + } + break; default: console.warn(process.pid, "---", "Unknown command:", data.join(" ")); ws.send(JSON.stringify(["NOTICE", "error: unrecognized command."])); @@ -187,6 +213,12 @@ function newConn(addr, id) { pendingEOSE.delete(id + ":" + data[1]); reqLimit.delete(id + ":" + data[1]); break; + case "AUTH": { + if (!private_keys || typeof(data[1]) !== "string") return; + const pubkey = csess.get(id)?.pubkey; + nip42(relay, pubkey, private_keys[pubkey], data[1]); + break; + } } }); diff --git a/config.js.example b/config.js.example index eaa5701..131883c 100644 --- a/config.js.example +++ b/config.js.example @@ -7,8 +7,8 @@ module.exports = { port: "8080", // Bouncing mode - // 1 -> Fast. Bouncer connects to at startup. - // 2 -> Accurate. Every clients has their own sessions (recommended). + // 1 -> Fast. Bouncer connects to at startup. Useful for bots + // 2 -> Accurate. Every clients has their own sessions. Useful for normal users (recommended) mode: 2, // Clusters. @@ -21,6 +21,27 @@ module.exports = { // Log about bouncer connection with relays? log_about_relays: false, + // For personal usage. Leaving this empty allows everyone to use this bostr instance. + // NOTE: Requires NIP-42 on client. + authorized_keys: [ + // "pubkey-in-hex", + // "npub ....", + // .... + ], + + // For personal usage. Leaving this empty completely disables NIP-42 function. + // Private key of authorized user. It could be your key. + // NOTE: + // - NIP-42 (auth) is ONLY supported with provided + // - While both mode could interact with NIP-42 relays, + // - It's best to use Mode 2 if you have more than + // - Mode 1 could only work with ONE + + // Warning: Ensure that is NOT empty so only could access this bostr bouncer + private_keys: { + // "pubkey": "privatekey" + } + // Server information. // Only for when nostr client requesting server information. server_meta: { @@ -31,7 +52,7 @@ module.exports = { // Some nostr client may read the following for compatibility check. // You may change the supported_nips to match with what your relays supported. - "supported_nips": [1,2,9,11,12,15,16,20,22,33,40,50] + "supported_nips": [1,2,9,11,12,15,16,20,22,33,40,42,50] "version": "1.0.0" }, diff --git a/http.js b/http.js index 3c9d1cb..21517ca 100644 --- a/http.js +++ b/http.js @@ -31,6 +31,7 @@ server.on('request', (req, res) => { }); res.write(`\nI have ${wss.clients.size} clients currently connected to this bouncer${(process.env.CLUSTERS || config.clusters) > 1 ? " on this cluster" : ""}.\n`); + if (config?.authorized_keys?.length) res.write("\nNOTE: This relay has configured for personal use only. Only authorized users could use this bostr relay.\n"); res.write(`\nConnect to this bouncer with nostr client: ws://${req.headers.host} or wss://${req.headers.host}\n\n---\n`); res.end("Powered by Bostr - Open source nostr Bouncer\nhttps://github.com/Yonle/bostr"); } else { diff --git a/nip42.js b/nip42.js new file mode 100644 index 0000000..6c0aa59 --- /dev/null +++ b/nip42.js @@ -0,0 +1,19 @@ +const { getEventHash, getSignature } = require("nostr-tools"); + +module.exports = (relay, pubkey, privkey, challenge) => { + if (!privkey) return; + let signed_challenge = { + pubkey, + created_at: Math.floor(Date.now() / 1000), + kind: 22242, + tags: [ + ["relay", relay.url], + ["challenge", challenge] + ], + content: "" + } + + signed_challenge.id = getEventHash(signed_challenge); + signed_challenge.sig = getSignature(signed_challenge, privkey); + relay.send(JSON.stringify(["AUTH", signed_challenge])); +} diff --git a/package.json b/package.json index 2719b8f..b377a05 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "homepage": "https://github.com/Yonle/bostr#readme", "dependencies": { "better-sqlite3": "^9.1.1", + "nostr-tools": "^1.17.0", "ws": "^8.14.2" }, "engines": {