| 1 | #!/usr/bin/env node |
| 2 | // A throwaway NIP-46 bunker for trying the Bunker URL sign-in flow locally. |
| 3 | // It runs a minimal in-memory relay on 127.0.0.1:8789, answers connect, |
| 4 | // ping, get_public_key and sign_event requests for a fresh user key, and |
| 5 | // prints the bunker URL to paste into the sign-in page. Set BUNKER_USER_SECRET |
| 6 | // to a hex secret to sign as a specific key. |
| 7 | import { WebSocketServer, WebSocket } from "ws" ; |
| 8 | import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure" ; |
| 9 | import * as nip44 from "nostr-tools/nip44" ; |
| 10 | import { hexToBytes } from "@noble/hashes/utils.js" ; |
| 11 | |
| 12 | globalThis.WebSocket = WebSocket; |
| 13 | |
| 14 | const port = Number(process.env.BUNKER_PORT ?? 8789); |
| 15 | const bunkerSecret = generateSecretKey(); |
| 16 | const bunkerPubkey = getPublicKey(bunkerSecret); |
| 17 | const userSecret = process.env.BUNKER_USER_SECRET ? hexToBytes(process.env.BUNKER_USER_SECRET) : generateSecretKey(); |
| 18 | const userPubkey = getPublicKey(userSecret); |
| 19 | const clients = new Map(); |
| 20 | const events = []; |
| 21 | |
| 22 | const matches = (event, filter) => { |
| 23 | if (filter.kinds && !filter.kinds.includes(event.kind)) return false; |
| 24 | if (filter.authors && !filter.authors.includes(event.pubkey)) return false; |
| 25 | for (const [key, values] of Object.entries(filter)) { |
| 26 | if (key[0] === "#" && !event.tags.some(tag => tag[0] === key.slice(1) && values.includes(tag[1]))) return false; |
| 27 | } |
| 28 | return true; |
| 29 | }; |
| 30 | |
| 31 | const broadcast = event => { |
| 32 | events.push(event); |
| 33 | for (const [peer, subscriptions] of clients) { |
| 34 | for (const [id, filters] of subscriptions) { |
| 35 | if (filters.some(filter => matches(event, filter))) peer.send(JSON.stringify(["EVENT" , id, event])); |
| 36 | } |
| 37 | } |
| 38 | }; |
| 39 | |
| 40 | const server = new WebSocketServer({ host: "127.0.0.1" , port }); |
| 41 | server.on("connection" , socket => { |
| 42 | const subscriptions = new Map(); |
| 43 | clients.set(socket, subscriptions); |
| 44 | socket.on("message" , raw => { |
| 45 | let message; |
| 46 | try { message = JSON.parse(raw); } catch { return; } |
| 47 | if (message[0] === "REQ" ) { |
| 48 | const [id, ...filters] = message.slice(1); |
| 49 | subscriptions.set(id, filters); |
| 50 | for (const event of events) { |
| 51 | if (filters.some(filter => matches(event, filter))) socket.send(JSON.stringify(["EVENT" , id, event])); |
| 52 | } |
| 53 | socket.send(JSON.stringify(["EOSE" , id])); |
| 54 | } else if (message[0] === "EVENT" ) { |
| 55 | broadcast(message[1]); |
| 56 | socket.send(JSON.stringify(["OK" , message[1].id, true, "" ])); |
| 57 | } else if (message[0] === "CLOSE" ) { |
| 58 | subscriptions.delete(message[1]); |
| 59 | } |
| 60 | }); |
| 61 | socket.on("close" , () => clients.delete(socket)); |
| 62 | }); |
| 63 | |
| 64 | const reply = (clientPubkey, payload) => { |
| 65 | const conversation = nip44.getConversationKey(bunkerSecret, clientPubkey); |
| 66 | broadcast(finalizeEvent({ |
| 67 | kind: 24133, |
| 68 | tags: [["p" , clientPubkey]], |
| 69 | content: nip44.encrypt(JSON.stringify(payload), conversation), |
| 70 | created_at: Math.floor(Date.now() / 1000) |
| 71 | }, bunkerSecret)); |
| 72 | }; |
| 73 | |
| 74 | const answer = request => { |
| 75 | switch (request.method) { |
| 76 | case "connect" : return "ack" ; |
| 77 | case "ping" : return "pong" ; |
| 78 | case "get_public_key" : return userPubkey; |
| 79 | case "sign_event" : return JSON.stringify(finalizeEvent(JSON.parse(request.params[0]), userSecret)); |
| 80 | default: throw Error("unsupported method " + request.method); |
| 81 | } |
| 82 | }; |
| 83 | |
| 84 | const handled = new WeakSet(); |
| 85 | setInterval(() => { |
| 86 | for (const event of events) { |
| 87 | if (event.kind !== 24133 || event.pubkey === bunkerPubkey || handled.has(event)) continue; |
| 88 | handled.add(event); |
| 89 | try { |
| 90 | const conversation = nip44.getConversationKey(bunkerSecret, event.pubkey); |
| 91 | const request = JSON.parse(nip44.decrypt(event.content, conversation)); |
| 92 | reply(event.pubkey, { id: request.id, result: answer(request) }); |
| 93 | } catch (err) { |
| 94 | console.error("bunker request failed:" , err.message); |
| 95 | } |
| 96 | } |
| 97 | }, 50); |
| 98 | |
| 99 | console.log("user pubkey: " + userPubkey); |
| 100 | console.log(`bunker://${bunkerPubkey}?relay=${encodeURIComponent("ws://127.0.0.1:" + port)}` ); |