├── .gitignore ├── prisma ├── migrations │ ├── 20240207053336_add_name_npub_index │ │ └── migration.sql │ ├── migration_lock.toml │ ├── 20240205093445_add_names │ │ └── migration.sql │ ├── 20231201090828_init │ │ └── migration.sql │ ├── 20240219080503_add_name_disabled │ │ └── migration.sql │ ├── 20240205093957_add_name_timestamp │ │ └── migration.sql │ └── 20231201094126_type_change │ │ └── migration.sql └── schema.prisma ├── package.json ├── README ├── src ├── crypto.js ├── nip44.js ├── test.js └── index.js └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .env.local 3 | node_modules 4 | prisma/*.db 5 | prisma/*.db-journal 6 | -------------------------------------------------------------------------------- /prisma/migrations/20240207053336_add_name_npub_index/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateIndex 2 | CREATE INDEX "Names_npub_idx" ON "Names"("npub"); 3 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /prisma/migrations/20240205093445_add_names/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Names" ( 3 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | "name" TEXT NOT NULL, 5 | "npub" TEXT NOT NULL 6 | ); 7 | 8 | -- CreateIndex 9 | CREATE UNIQUE INDEX "Names_name_key" ON "Names"("name"); 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "noauthd", 3 | "version": "0.1.0", 4 | "description": "noauth daemon - server side of noauth nostr key manager", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "artur@nostr.band", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@nostr-dev-kit/ndk": "^2.4.0", 13 | "@prisma/client": "^5.6.0", 14 | "body-parser": "^1.20.2", 15 | "dotenv": "^16.3.1", 16 | "express": "^4.18.2", 17 | "web-push": "^3.6.6", 18 | "websocket-polyfill": "^0.0.3" 19 | }, 20 | "devDependencies": { 21 | "prisma": "^5.6.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /prisma/migrations/20231201090828_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "PushSubs" ( 3 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | "pushId" TEXT NOT NULL, 5 | "timestamp" INTEGER NOT NULL, 6 | "npub" TEXT NOT NULL, 7 | "pushSubscription" TEXT NOT NULL, 8 | "relays" TEXT NOT NULL 9 | ); 10 | 11 | -- CreateTable 12 | CREATE TABLE "NpubData" ( 13 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 14 | "timestamp" INTEGER NOT NULL, 15 | "npub" TEXT NOT NULL, 16 | "data" TEXT NOT NULL, 17 | "pwh2" TEXT NOT NULL, 18 | "salt" TEXT NOT NULL 19 | ); 20 | 21 | -- CreateIndex 22 | CREATE UNIQUE INDEX "PushSubs_pushId_key" ON "PushSubs"("pushId"); 23 | -------------------------------------------------------------------------------- /prisma/migrations/20240219080503_add_name_disabled/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA foreign_keys=OFF; 3 | CREATE TABLE "new_Names" ( 4 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 5 | "name" TEXT NOT NULL, 6 | "npub" TEXT NOT NULL, 7 | "timestamp" BIGINT NOT NULL, 8 | "disabled" INTEGER NOT NULL DEFAULT 0 9 | ); 10 | INSERT INTO "new_Names" ("id", "name", "npub", "timestamp") SELECT "id", "name", "npub", "timestamp" FROM "Names"; 11 | DROP TABLE "Names"; 12 | ALTER TABLE "new_Names" RENAME TO "Names"; 13 | CREATE UNIQUE INDEX "Names_name_key" ON "Names"("name"); 14 | CREATE INDEX "Names_npub_idx" ON "Names"("npub"); 15 | PRAGMA foreign_key_check; 16 | PRAGMA foreign_keys=ON; 17 | -------------------------------------------------------------------------------- /prisma/migrations/20240205093957_add_name_timestamp/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `timestamp` to the `Names` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- RedefineTables 8 | PRAGMA foreign_keys=OFF; 9 | CREATE TABLE "new_Names" ( 10 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 11 | "name" TEXT NOT NULL, 12 | "npub" TEXT NOT NULL, 13 | "timestamp" BIGINT NOT NULL 14 | ); 15 | INSERT INTO "new_Names" ("id", "name", "npub") SELECT "id", "name", "npub" FROM "Names"; 16 | DROP TABLE "Names"; 17 | ALTER TABLE "new_Names" RENAME TO "Names"; 18 | CREATE UNIQUE INDEX "Names_name_key" ON "Names"("name"); 19 | PRAGMA foreign_key_check; 20 | PRAGMA foreign_keys=ON; 21 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "sqlite" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | model PushSubs { 11 | id Int @id @default(autoincrement()) 12 | pushId String @unique 13 | timestamp BigInt 14 | npub String 15 | pushSubscription String 16 | relays String 17 | } 18 | 19 | model NpubData { 20 | id Int @id @default(autoincrement()) 21 | timestamp BigInt 22 | npub String @unique 23 | data String 24 | pwh2 String 25 | salt String 26 | } 27 | 28 | model Names { 29 | id Int @id @default(autoincrement()) 30 | name String @unique 31 | npub String 32 | timestamp BigInt 33 | disabled Int @default(0) 34 | 35 | @@index([npub]) 36 | } 37 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | Noauth Daemon 2 | ------------- 3 | 4 | Server for Noauth Nostr key manager. 5 | 6 | API: 7 | 8 | POST /subscribe({ 9 | npub: string, 10 | pushSubscription: json, // result of pushManager.subscribe 11 | relays: string[] // which relays to watch for nip46 rpc 12 | }) 13 | 14 | Server starts watching the relays for nip46 rpc and if it 15 | detects that some requests don't have matching replies (signer 16 | is sleeping) then it sends a push message to the signer. 17 | Authorized using nip98. 18 | 19 | POST /put({ 20 | npub: string, 21 | data: string, // encrypted nsec 22 | pwh: string // password hash 23 | }) 24 | 25 | Server stores this data and will serve it back later 26 | with /get. Authorized using nip98. 27 | 28 | POST /get({ 29 | npub: string, 30 | pwh: string // password hash 31 | }) 32 | 33 | Server will return the data previously saved by /put, 34 | pwh must match the one provided to /put (no access 35 | to keys is needed). -------------------------------------------------------------------------------- /src/crypto.js: -------------------------------------------------------------------------------- 1 | const { pbkdf2, randomBytes } = require('node:crypto'); 2 | 3 | const ITERATIONS = 10000 4 | const SALT_SIZE = 16 5 | const HASH_SIZE = 32 6 | const HASH_ALGO = 'sha256' 7 | const TOKEN_SIZE = 16 8 | 9 | function getCreateAccountToken() { 10 | return randomBytes(TOKEN_SIZE).toString('hex') 11 | } 12 | 13 | async function makePwh2(pwh, salt) { 14 | return new Promise((ok, fail) => { 15 | salt = salt || randomBytes(SALT_SIZE) 16 | pbkdf2(pwh, salt, ITERATIONS, HASH_SIZE, HASH_ALGO, (err, hash) => { 17 | if (err) fail(err) 18 | else ok({ pwh2: hash.toString('hex'), salt: salt.toString('hex') }) 19 | }) 20 | }) 21 | } 22 | 23 | function countLeadingZeros(hex) { 24 | let count = 0; 25 | 26 | for (let i = 0; i < hex.length; i++) { 27 | const nibble = parseInt(hex[i], 16); 28 | if (nibble === 0) { 29 | count += 4; 30 | } else { 31 | count += Math.clz32(nibble) - 28; 32 | break; 33 | } 34 | } 35 | 36 | return count; 37 | } 38 | 39 | module.exports = { 40 | makePwh2, 41 | countLeadingZeros, 42 | getCreateAccountToken 43 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Nostr.Band 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /prisma/migrations/20231201094126_type_change/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to alter the column `timestamp` on the `NpubData` table. The data in that column could be lost. The data in that column will be cast from `Int` to `BigInt`. 5 | - You are about to alter the column `timestamp` on the `PushSubs` table. The data in that column could be lost. The data in that column will be cast from `Int` to `BigInt`. 6 | 7 | */ 8 | -- RedefineTables 9 | PRAGMA foreign_keys=OFF; 10 | CREATE TABLE "new_NpubData" ( 11 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 12 | "timestamp" BIGINT NOT NULL, 13 | "npub" TEXT NOT NULL, 14 | "data" TEXT NOT NULL, 15 | "pwh2" TEXT NOT NULL, 16 | "salt" TEXT NOT NULL 17 | ); 18 | INSERT INTO "new_NpubData" ("data", "id", "npub", "pwh2", "salt", "timestamp") SELECT "data", "id", "npub", "pwh2", "salt", "timestamp" FROM "NpubData"; 19 | DROP TABLE "NpubData"; 20 | ALTER TABLE "new_NpubData" RENAME TO "NpubData"; 21 | CREATE UNIQUE INDEX "NpubData_npub_key" ON "NpubData"("npub"); 22 | CREATE TABLE "new_PushSubs" ( 23 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 24 | "pushId" TEXT NOT NULL, 25 | "timestamp" BIGINT NOT NULL, 26 | "npub" TEXT NOT NULL, 27 | "pushSubscription" TEXT NOT NULL, 28 | "relays" TEXT NOT NULL 29 | ); 30 | INSERT INTO "new_PushSubs" ("id", "npub", "pushId", "pushSubscription", "relays", "timestamp") SELECT "id", "npub", "pushId", "pushSubscription", "relays", "timestamp" FROM "PushSubs"; 31 | DROP TABLE "PushSubs"; 32 | ALTER TABLE "new_PushSubs" RENAME TO "PushSubs"; 33 | CREATE UNIQUE INDEX "PushSubs_pushId_key" ON "PushSubs"("pushId"); 34 | PRAGMA foreign_key_check; 35 | PRAGMA foreign_keys=ON; 36 | -------------------------------------------------------------------------------- /src/nip44.js: -------------------------------------------------------------------------------- 1 | const { chacha20 } = require("@noble/ciphers/chacha"); 2 | const { 3 | concatBytes, 4 | randomBytes, 5 | utf8ToBytes, 6 | } = require("@noble/hashes/utils"); 7 | const { equalBytes } = require("@noble/ciphers/utils"); 8 | const { secp256k1 } = require("@noble/curves/secp256k1"); 9 | const { expand, extract } = require("@noble/hashes/hkdf"); 10 | const { sha256 } = require("@noble/hashes/sha256"); 11 | const { hmac } = require("@noble/hashes/hmac"); 12 | const { base64 } = require("@scure/base"); 13 | const { getPublicKey } = require("nostr-tools"); 14 | 15 | // from https://github.com/nbd-wtf/nostr-tools 16 | 17 | const decoder = new TextDecoder(); 18 | 19 | const u = { 20 | minPlaintextSize: 0x0001, // 1b msg => padded to 32b 21 | maxPlaintextSize: 0xffff, // 65535 (64kb-1) => padded to 64kb 22 | 23 | utf8Encode: utf8ToBytes, 24 | utf8Decode(bytes) { 25 | return decoder.decode(bytes); 26 | }, 27 | 28 | getConversationKey(privkeyA, pubkeyB) { 29 | const sharedX = secp256k1 30 | .getSharedSecret(privkeyA, "02" + pubkeyB) 31 | .subarray(1, 33); 32 | return extract(sha256, sharedX, "nip44-v2"); 33 | }, 34 | 35 | getMessageKeys(conversationKey, nonce) { 36 | const keys = expand(sha256, conversationKey, nonce, 76); 37 | return { 38 | chacha_key: keys.subarray(0, 32), 39 | chacha_nonce: keys.subarray(32, 44), 40 | hmac_key: keys.subarray(44, 76), 41 | }; 42 | }, 43 | 44 | calcPaddedLen(len) { 45 | if (!Number.isSafeInteger(len) || len < 1) 46 | throw new Error("expected positive integer"); 47 | if (len <= 32) return 32; 48 | const nextPower = 1 << (Math.floor(Math.log2(len - 1)) + 1); 49 | const chunk = nextPower <= 256 ? 32 : nextPower / 8; 50 | return chunk * (Math.floor((len - 1) / chunk) + 1); 51 | }, 52 | 53 | writeU16BE(num) { 54 | if ( 55 | !Number.isSafeInteger(num) || 56 | num < u.minPlaintextSize || 57 | num > u.maxPlaintextSize 58 | ) 59 | throw new Error( 60 | "invalid plaintext size: must be between 1 and 65535 bytes" 61 | ); 62 | const arr = new Uint8Array(2); 63 | new DataView(arr.buffer).setUint16(0, num, false); 64 | return arr; 65 | }, 66 | 67 | pad(plaintext) { 68 | const unpadded = u.utf8Encode(plaintext); 69 | const unpaddedLen = unpadded.length; 70 | const prefix = u.writeU16BE(unpaddedLen); 71 | const suffix = new Uint8Array(u.calcPaddedLen(unpaddedLen) - unpaddedLen); 72 | return concatBytes(prefix, unpadded, suffix); 73 | }, 74 | 75 | unpad(padded) { 76 | const unpaddedLen = new DataView(padded.buffer).getUint16(0); 77 | const unpadded = padded.subarray(2, 2 + unpaddedLen); 78 | if ( 79 | unpaddedLen < u.minPlaintextSize || 80 | unpaddedLen > u.maxPlaintextSize || 81 | unpadded.length !== unpaddedLen || 82 | padded.length !== 2 + u.calcPaddedLen(unpaddedLen) 83 | ) 84 | throw new Error("invalid padding"); 85 | return u.utf8Decode(unpadded); 86 | }, 87 | 88 | hmacAad(key, message, aad) { 89 | if (aad.length !== 32) 90 | throw new Error("AAD associated data must be 32 bytes"); 91 | const combined = concatBytes(aad, message); 92 | return hmac(sha256, key, combined); 93 | }, 94 | 95 | // metadata: always 65b (version: 1b, nonce: 32b, max: 32b) 96 | // plaintext: 1b to 0xffff 97 | // padded plaintext: 32b to 0xffff 98 | // ciphertext: 32b+2 to 0xffff+2 99 | // raw payload: 99 (65+32+2) to 65603 (65+0xffff+2) 100 | // compressed payload (base64): 132b to 87472b 101 | decodePayload(payload) { 102 | if (typeof payload !== "string") 103 | throw new Error("payload must be a valid string"); 104 | const plen = payload.length; 105 | if (plen < 132 || plen > 87472) 106 | throw new Error("invalid payload length: " + plen); 107 | if (payload[0] === "#") throw new Error("unknown encryption version"); 108 | let data; 109 | try { 110 | data = base64.decode(payload); 111 | } catch (error) { 112 | throw new Error("invalid base64: " + error.message); 113 | } 114 | const dlen = data.length; 115 | if (dlen < 99 || dlen > 65603) 116 | throw new Error("invalid data length: " + dlen); 117 | const vers = data[0]; 118 | if (vers !== 2) throw new Error("unknown encryption version " + vers); 119 | return { 120 | nonce: data.subarray(1, 33), 121 | ciphertext: data.subarray(33, -32), 122 | mac: data.subarray(-32), 123 | }; 124 | }, 125 | }; 126 | 127 | function encryptNip44(plaintext, conversationKey, nonce = randomBytes(32)) { 128 | const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys( 129 | conversationKey, 130 | nonce 131 | ); 132 | const padded = u.pad(plaintext); 133 | const ciphertext = chacha20(chacha_key, chacha_nonce, padded); 134 | const mac = u.hmacAad(hmac_key, ciphertext, nonce); 135 | return base64.encode( 136 | concatBytes(new Uint8Array([2]), nonce, ciphertext, mac) 137 | ); 138 | } 139 | 140 | function decryptNip44(payload, conversationKey) { 141 | const { nonce, ciphertext, mac } = u.decodePayload(payload); 142 | const { chacha_key, chacha_nonce, hmac_key } = u.getMessageKeys( 143 | conversationKey, 144 | nonce 145 | ); 146 | const calculatedMac = u.hmacAad(hmac_key, ciphertext, nonce); 147 | if (!equalBytes(calculatedMac, mac)) throw new Error("invalid MAC"); 148 | const padded = chacha20(chacha_key, chacha_nonce, ciphertext); 149 | return u.unpad(padded); 150 | } 151 | 152 | class Nip44 { 153 | cache = new Map(); 154 | 155 | createKey(privkey, pubkey) { 156 | return u.getConversationKey(privkey, pubkey); 157 | } 158 | 159 | getKey(privkey, pubkey, extractable) { 160 | const id = getPublicKey(privkey) + pubkey; 161 | let cryptoKey = this.cache.get(id); 162 | if (cryptoKey) return cryptoKey; 163 | 164 | const key = this.createKey(privkey, pubkey); 165 | this.cache.set(id, key); 166 | return key; 167 | } 168 | 169 | encrypt(privkey, pubkey, text) { 170 | const key = this.getKey(privkey, pubkey); 171 | return encryptNip44(text, key); 172 | } 173 | 174 | decrypt(privkey, pubkey, data) { 175 | const key = this.getKey(privkey, pubkey); 176 | return decryptNip44(data, key); 177 | } 178 | } 179 | 180 | module.exports = { 181 | Nip44, 182 | }; 183 | -------------------------------------------------------------------------------- /src/test.js: -------------------------------------------------------------------------------- 1 | require("websocket-polyfill"); 2 | const { 3 | default: NDK, 4 | NDKEvent, 5 | NDKPrivateKeySigner, 6 | NDKNip46Signer, 7 | } = require("@nostr-dev-kit/ndk"); 8 | const { createHash } = require("node:crypto"); 9 | const { nip19, getPublicKey, generatePrivateKey } = require("nostr-tools"); 10 | 11 | global.crypto = require("node:crypto"); 12 | 13 | const ndk = new NDK({ 14 | enableOutboxModel: false, 15 | explicitRelayUrls: ["wss://relay.nsec.app"], 16 | }); 17 | 18 | const LOCAL = true; 19 | const BUNKER_PUBKEY = LOCAL 20 | ? "44f9def756f8575aed604408a5c8f5a09d01633015fc65894fdd12af77457f3a" 21 | : "e24a86943d37a91ab485d6f9a7c66097c25ddd67e8bd1b75ed252a3c266cf9bb"; 22 | 23 | const sk = generatePrivateKey(); 24 | console.log("test pubkey", getPublicKey(sk)); 25 | const signer = new NDKNip46Signer( 26 | ndk, 27 | BUNKER_PUBKEY, 28 | new NDKPrivateKeySigner(sk) 29 | ); 30 | 31 | async function sendPost({ url, method, headers, body }) { 32 | console.log("sendPost", url, headers, body); 33 | const r = await fetch(url, { 34 | method, 35 | headers: { 36 | "Content-Type": "application/json", 37 | ...headers, 38 | }, 39 | body, 40 | }); 41 | if (r.status !== 200 && r.status !== 201) { 42 | console.log("Fetch error", url, method, r.status); 43 | const body = await r.json(); 44 | throw new Error("Failed to fetch " + url, { cause: body }); 45 | } 46 | 47 | return await r.json(); 48 | } 49 | 50 | function sha256(data) { 51 | return createHash("sha256").update(data, "utf8").digest().toString("hex"); 52 | } 53 | 54 | function countLeadingZeros(hex) { 55 | let count = 0 56 | 57 | for (let i = 0; i < hex.length; i++) { 58 | const nibble = parseInt(hex[i], 16) 59 | if (nibble === 0) { 60 | count += 4 61 | } else { 62 | count += Math.clz32(nibble) - 28 63 | break 64 | } 65 | } 66 | 67 | return count 68 | } 69 | 70 | function minePow(e, target) { 71 | let ctr = 0 72 | 73 | let nonceTagIdx = e.tags.findIndex((a) => a[0] === 'nonce') 74 | if (nonceTagIdx === -1) { 75 | nonceTagIdx = e.tags.length 76 | e.tags.push(['nonce', ctr.toString(), target.toString()]) 77 | } 78 | do { 79 | e.tags[nonceTagIdx][1] = (++ctr).toString() 80 | e.id = createId(e) 81 | } while (countLeadingZeros(e.id) < target) 82 | 83 | return e 84 | } 85 | 86 | function createId(e) { 87 | const payload = [0, e.pubkey, e.created_at, e.kind, e.tags, e.content] 88 | return sha256(JSON.stringify(payload)) 89 | } 90 | 91 | async function sendPostAuthd({ 92 | sk, 93 | url, 94 | method = 'GET', 95 | body = '', 96 | pow = 0, 97 | }) { 98 | const pubkey = getPublicKey(sk); 99 | const signer = new NDKPrivateKeySigner(sk); 100 | 101 | const authEvent = new NDKEvent(ndk, { 102 | pubkey: pubkey, 103 | kind: 27235, 104 | created_at: Math.floor(Date.now() / 1000), 105 | content: '', 106 | tags: [ 107 | ['u', url], 108 | ['method', method], 109 | ], 110 | }) 111 | if (body) authEvent.tags.push(['payload', await sha256(body)]) 112 | 113 | // generate pow on auth evevnt 114 | if (pow) { 115 | const start = Date.now() 116 | const powEvent = authEvent.rawEvent() 117 | const minedEvent = minePow(powEvent, pow) 118 | console.log('mined pow of', pow, 'in', Date.now() - start, 'ms', minedEvent) 119 | authEvent.tags = minedEvent.tags 120 | } 121 | 122 | authEvent.sig = await authEvent.sign(signer); 123 | 124 | const auth = Buffer.from(JSON.stringify(authEvent.rawEvent())).toString( 125 | "base64" 126 | ); 127 | 128 | return await sendPost({ 129 | url, 130 | method, 131 | headers: { 132 | Authorization: `Nostr ${auth}`, 133 | }, 134 | body, 135 | }) 136 | } 137 | 138 | // OAuth flow 139 | signer.on("authUrl", async (url) => { 140 | console.log("nostr login auth url", url); 141 | const u = new URL(url); 142 | const token = u.searchParams.get("token"); 143 | console.log({ token }); 144 | const sk = generatePrivateKey(); 145 | console.log("created account", getPublicKey(sk)); 146 | await sendPostAuthd({ 147 | sk, 148 | method: "POST", 149 | url: LOCAL 150 | ? "http://localhost:8000/created" 151 | : "https://noauthd.nsec.app/created", 152 | body: JSON.stringify({ 153 | npub: nip19.npubEncode(getPublicKey(sk)), 154 | token, 155 | }), 156 | }); 157 | }); 158 | 159 | if (process.argv.length >= 3) { 160 | if (process.argv[2] === "check_names") { 161 | const sk = generatePrivateKey(); 162 | const npub = nip19.npubEncode(getPublicKey(sk)) 163 | const sk1 = generatePrivateKey(); 164 | const npub1 = nip19.npubEncode(getPublicKey(sk1)) 165 | const name = npub.substring(0, 10); 166 | console.log("create name", npub, name); 167 | const test = async () => { 168 | await sendPostAuthd({ 169 | sk, 170 | method: "POST", 171 | url: LOCAL 172 | ? "http://localhost:8000/name" 173 | : "https://noauthd.nsec.app/name", 174 | body: JSON.stringify({ 175 | npub, 176 | name, 177 | }), 178 | pow: 15 179 | }); 180 | console.log("created"); 181 | await sendPostAuthd({ 182 | sk, 183 | method: "PUT", 184 | url: LOCAL 185 | ? "http://localhost:8000/name" 186 | : "https://noauthd.nsec.app/name", 187 | body: JSON.stringify({ 188 | npub, 189 | name: name, 190 | newNpub: npub1 191 | }), 192 | }); 193 | console.log("transferred") 194 | await sendPostAuthd({ 195 | sk: sk1, 196 | method: "POST", 197 | url: LOCAL 198 | ? "http://localhost:8000/name" 199 | : "https://noauthd.nsec.app/name", 200 | body: JSON.stringify({ 201 | npub: npub1, 202 | name, 203 | }), 204 | pow: 16 205 | }); 206 | // await sendPostAuthd({ 207 | // sk: sk1, 208 | // method: "DELETE", 209 | // url: LOCAL 210 | // ? "http://localhost:8000/name" 211 | // : "https://noauthd.nsec.app/name", 212 | // body: JSON.stringify({ 213 | // npub: npub1, 214 | // name: name, 215 | // }), 216 | // }); 217 | // console.log("deleted"); 218 | } 219 | test() 220 | } 221 | } else { 222 | const params = [ 223 | "test", 224 | "nsec.app", 225 | "", 226 | "sign_event:4" 227 | // email? 228 | ]; 229 | ndk 230 | .connect() 231 | .then(async () => { 232 | console.log("sending", params); 233 | signer.rpc.sendRequest( 234 | BUNKER_PUBKEY, 235 | "create_account", 236 | params, 237 | undefined, 238 | (res) => { 239 | console.log({ res }); 240 | } 241 | ); 242 | }) 243 | .then(console.log); 244 | } 245 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | require("websocket-polyfill"); 2 | const webpush = require("web-push"); 3 | const { 4 | default: NDK, 5 | NDKRelaySet, 6 | NDKRelay, 7 | NDKPrivateKeySigner, 8 | NDKNip46Backend, 9 | NDKEvent, 10 | } = require("@nostr-dev-kit/ndk"); 11 | const { createHash } = require("node:crypto"); 12 | const express = require("express"); 13 | const bodyParser = require("body-parser"); 14 | const { nip19, getPublicKey, verifySignature } = require("nostr-tools"); 15 | const { 16 | makePwh2, 17 | countLeadingZeros, 18 | getCreateAccountToken, 19 | } = require("./crypto"); 20 | const { PrismaClient } = require("@prisma/client"); 21 | const { Nip44 } = require("./nip44"); 22 | 23 | global.crypto = require("node:crypto"); 24 | 25 | const prisma = new PrismaClient(); 26 | 27 | // generate your own keypair with "web-push generate-vapid-keys" 28 | const PUSH_PUBKEY = process.env.PUSH_PUBKEY; 29 | const PUSH_SECKEY = process.env.PUSH_SECRET; 30 | const BUNKER_NSEC = process.env.BUNKER_NSEC; 31 | const BUNKER_RELAY = process.env.BUNKER_RELAY; 32 | const BUNKER_ORIGIN = process.env.BUNKER_ORIGIN; 33 | const BUNKER_DOMAIN = process.env.BUNKER_DOMAIN; 34 | const BUNKER_IFRAME_DOMAINS = (process.env.BUNKER_IFRAME_DOMAINS || "") 35 | .split(",") 36 | .filter((d) => !!d); 37 | 38 | // settings 39 | const port = Number(process.env.PORT || 8000); 40 | const EMAIL = "artur@nostr.band"; // admin email 41 | const MAX_RELAYS = 3; // no more than 3 relays monitored per pubkey 42 | const MAX_BATCH_SIZE = 500; // pubkeys per sub 43 | const MIN_PAUSE = 1000; // 1 sec 44 | const MAX_PAUSE = 3600000; // 1 hour 45 | const MAX_DATA = 1 << 10; // 1kb 46 | const POW_PERIOD = 3600000; // 1h 47 | const MIN_POW = 11; 48 | 49 | // global ndk 50 | const ndk = new NDK({ 51 | enableOutboxModel: false, 52 | explicitRelayUrls: [BUNKER_RELAY], 53 | }); 54 | 55 | // set application/json middleware 56 | const app = express(); 57 | //app.use(express.json()); 58 | 59 | // our identity for the push servers 60 | webpush.setVapidDetails(`mailto:${EMAIL}`, PUSH_PUBKEY, PUSH_SECKEY); 61 | 62 | // subs - npub:state 63 | const relays = new Map(); 64 | const pushSubs = new Map(); 65 | const sourcePsubs = new Map(); 66 | const relayQueue = []; 67 | const npubData = new Map(); 68 | const bunkerTokens = new Map(); 69 | const ipNamePows = new Map(); 70 | 71 | let bunkerSigner = null; 72 | 73 | async function push(psub) { 74 | console.log(new Date(), "push for", psub.pubkey, "psub", psub.id); 75 | try { 76 | const r = await webpush.sendNotification( 77 | psub.pushSubscription, 78 | JSON.stringify( 79 | { 80 | cmd: "wakeup", 81 | pubkey: psub.pubkey, 82 | }, 83 | { 84 | timeout: 3000, 85 | TTL: 60, // don't store it for too long, it just needs to wakeup 86 | urgency: "high", // deliver immediately 87 | } 88 | ) 89 | ); 90 | console.log("push sent for", psub.pubkey, r); 91 | } catch (e) { 92 | console.log( 93 | new Date(), 94 | "push failed for", 95 | psub.pubkey, 96 | "code", 97 | e.statusCode, 98 | "headers", 99 | e.headers 100 | ); 101 | 102 | // reset 103 | psub.lastPush = 0; 104 | 105 | switch (e.statusCode) { 106 | // 429 Too many requests. Meaning your application server has reached a rate limit with a push service. The push service should include a 'Retry-After' header to indicate how long before another request can be made. 107 | case 429: 108 | // FIXME mark psub as 'quite' until Retry-After 109 | // FIXME use psub.lastPush for Retry-After 110 | break; 111 | // 400 Invalid request. This generally means one of your headers is invalid or improperly formatted. 112 | case 400: 113 | // 413 Payload size too large. The minimum size payload a push service must support is 4096 bytes (or 4kb). 114 | case 413: 115 | // WTF? 116 | break; 117 | // 404 Not Found. This is an indication that the subscription is expired and can't be used. In this case you should delete the `PushSubscription` and wait for the client to resubscribe the user. 118 | case 400: 119 | // 410 Gone. The subscription is no longer valid and should be removed from application server. This can be reproduced by calling `unsubscribe()` on a `PushSubscription`. 120 | case 410: 121 | // it's gone! 122 | return false; 123 | } 124 | } 125 | 126 | psub.lastPush = Date.now(); 127 | return true; 128 | } 129 | 130 | function clearTimer(psub) { 131 | if (psub.timer) { 132 | clearTimeout(psub.timer); 133 | psub.timer = undefined; 134 | } 135 | } 136 | 137 | function restartTimer(psub) { 138 | if (psub.timer) clearTimeout(psub.timer); 139 | 140 | // arm a timer, if bunker doesn't reply withing 141 | // pause then we will send a push 142 | psub.timer = setTimeout(async () => { 143 | psub.timer = undefined; 144 | 145 | const now = Date.now(); 146 | 147 | // we've been pushing already and it's not waking up, 148 | // so we won't bother pushing again to avoid 149 | // annoying the push server 150 | if (psub.nextPush > now) { 151 | console.log( 152 | new Date(), 153 | "skip push for", 154 | psub.pubkey, 155 | "until", 156 | new Date(psub.nextPush) 157 | ); 158 | return; 159 | } 160 | 161 | const ok = await push(psub); 162 | if (!ok) { 163 | // drop this psub! 164 | unsubscribe(psub); 165 | } 166 | 167 | // multiplicative backoff 168 | psub.backoffMs = (psub.backoffMs || MIN_PAUSE) * 2; 169 | // crop 170 | psub.backoffMs = Math.min(psub.backoffMs, MAX_PAUSE); 171 | 172 | // schedule next push 173 | psub.nextPush = Date.now() + psub.backoffMs; 174 | }, MIN_PAUSE); 175 | } 176 | 177 | function getP(e) { 178 | return e.tags.find((t) => t.length > 1 && t[0] === "p")?.[1]; 179 | } 180 | 181 | function processRequest(r, e) { 182 | // ignore old requests in case relay sends them for some reason 183 | if (e.created_at < Date.now() / 1000 - 10) return; 184 | 185 | const pubkey = getP(e); 186 | const pr = pubkey + r.url; 187 | const psubs = sourcePsubs.get(pr); 188 | console.log(new Date(), "request for", pubkey, "at", r.url, "subs", psubs); 189 | for (const id of psubs) { 190 | const psub = pushSubs.get(id); 191 | // start timer on first request 192 | if (!psub.timer) { 193 | restartTimer(psub); 194 | } 195 | } 196 | } 197 | 198 | function processReply(r, e) { 199 | const pubkey = e.pubkey; 200 | console.log(new Date(), "reply from", pubkey, "on", r.url); 201 | 202 | const pr = pubkey + r.url; 203 | const psubs = sourcePsubs.get(pr); 204 | if (!psubs) { 205 | console.log("skip unknown reply from", pubkey); 206 | return; 207 | } 208 | for (const id of psubs) { 209 | const psub = pushSubs.get(id); 210 | 211 | // it's alive, reset backoff and pending push 212 | psub.backoffMs = 0; 213 | clearTimer(psub); 214 | } 215 | } 216 | 217 | function unsubscribeFromRelay(psub, relayUrl) { 218 | console.log("unsubscribeFromRelay", psub.id, psub.pubkey, "from", relayUrl); 219 | 220 | // remove from global pubkey+relay=psub table 221 | const pr = psub.pubkey + relayUrl; 222 | const psubs = sourcePsubs.get(pr).filter((pi) => pi != psub.id); 223 | if (psubs.length > 0) { 224 | // still some other psub uses the same pubkey on this relay 225 | sourcePsubs.set(pr, psubs); 226 | } else { 227 | // this pubkey is no longer on this relay 228 | const relay = relays.get(relayUrl); 229 | relay.unsubQueue.push(psub.pubkey); 230 | relayQueue.push(relayUrl); 231 | 232 | sourcePsubs.delete(pr); 233 | } 234 | } 235 | 236 | function unsubscribe(psub) { 237 | console.log("unsubscribe", psub.id, psub.pubkey); 238 | 239 | for (const url of psub.relays) unsubscribeFromRelay(psub, url); 240 | 241 | pushSubs.delete(psub.id); 242 | 243 | // drop from db 244 | prisma.pushSubs 245 | .delete({ 246 | where: { pushId: psub.id }, 247 | }) 248 | .then((r) => console.log("deleted psub", psub.id, r)); 249 | } 250 | 251 | function subscribe(psub, relayUrls) { 252 | const pubkey = psub.pubkey; 253 | const oldRelays = psub.relays.filter((r) => !relayUrls.includes(r)); 254 | const newRelays = relayUrls.filter((r) => !psub.relays.includes(r)); 255 | 256 | console.log({ oldRelays, newRelays }); 257 | 258 | // store 259 | psub.relays = relayUrls; 260 | 261 | for (const url of oldRelays) unsubscribeFromRelay(psub, url); 262 | 263 | for (const url of newRelays) { 264 | let relay = relays.get(url); 265 | if (!relay) { 266 | relay = { 267 | url, 268 | unsubQueue: [], 269 | subQueue: [], 270 | subs: new Map(), 271 | pubkeySubs: new Map(), 272 | }; 273 | relays.set(url, relay); 274 | } 275 | relay.subQueue.push(pubkey); 276 | relayQueue.push(url); 277 | 278 | // add to global pubkey+relay=psub table 279 | const pr = pubkey + relay.url; 280 | const psubs = sourcePsubs.get(pr) || []; 281 | psubs.push(psub.id); 282 | sourcePsubs.set(pr, psubs); 283 | } 284 | } 285 | 286 | function ensureRelay(url) { 287 | if (ndk.pool.relays.get(url)) return; 288 | 289 | const ndkRelay = new NDKRelay(url); 290 | let first = true; 291 | ndkRelay.on("connect", () => { 292 | if (first) { 293 | first = false; 294 | return; 295 | } 296 | 297 | // retry all existing subs 298 | console.log(new Date(), "resubscribing to relay", url); 299 | const r = relays.get(url); 300 | for (const pubkey of r.pubkeySubs.keys()) { 301 | r.unsubQueue.push(pubkey); 302 | r.subQueue.push(pubkey); 303 | } 304 | relayQueue.push(url); 305 | }); 306 | 307 | ndk.pool.addRelay(ndkRelay); 308 | } 309 | 310 | function createPubkeySub(r) { 311 | ensureRelay(r.url); 312 | 313 | const batchSize = Math.min(r.subQueue.length, MAX_BATCH_SIZE); 314 | const pubkeys = [...new Set(r.subQueue.splice(0, batchSize))]; 315 | 316 | const since = Math.floor(Date.now() / 1000) - 10; 317 | const requestFilter = { 318 | kinds: [24133], 319 | "#p": pubkeys, 320 | // older requests have likely expired 321 | since, 322 | }; 323 | const replyFilter = { 324 | kinds: [24133], 325 | authors: pubkeys, 326 | since, 327 | }; 328 | 329 | const sub = ndk.subscribe( 330 | [requestFilter, replyFilter], 331 | { 332 | closeOnEose: false, 333 | subId: `pubkeys_${Math.random()}`, 334 | }, 335 | NDKRelaySet.fromRelayUrls([r.url], ndk), 336 | /* autoStart */ false 337 | ); 338 | sub.on("event", (e) => { 339 | // console.log("event by ", e.pubkey, "to", getP(e)); 340 | try { 341 | if (pubkeys.includes(e.pubkey)) { 342 | processReply(r, e); 343 | } else { 344 | processRequest(r, e); 345 | } 346 | } catch (err) { 347 | console.log("error", err); 348 | } 349 | }); 350 | sub.start(); 351 | 352 | // set sub pubkeys 353 | sub.pubkeys = pubkeys; 354 | 355 | return sub; 356 | } 357 | 358 | function processRelayQueue() { 359 | const closeQueue = []; 360 | 361 | // take queue, clear it 362 | const uniqRelays = new Set(relayQueue); 363 | relayQueue.length = 0; 364 | if (uniqRelays.size > 0) console.log({ uniqRelays }); 365 | 366 | // process active relay queue 367 | for (const url of uniqRelays.values()) { 368 | const r = relays.get(url); 369 | console.log( 370 | new Date(), 371 | "update relay", 372 | url, 373 | "sub", 374 | r.subQueue.length, 375 | "unsub", 376 | r.unsubQueue.length 377 | ); 378 | 379 | // first handle the unsubs 380 | for (const p of new Set(r.unsubQueue).values()) { 381 | // a NDK sub id matching this pubkey on this relay 382 | const subId = r.pubkeySubs.get(p); 383 | // unmap pubkey from NDK sub id 384 | r.pubkeySubs.delete(p); 385 | // get the NDK sub by id 386 | const sub = r.subs.get(subId); 387 | // put back all NDK sub's pubkeys except the removed ones 388 | r.subQueue.push( 389 | ...sub.pubkeys.filter((sp) => !r.unsubQueue.includes(sp)) 390 | ); 391 | // mark this sub for closure 392 | closeQueue.push(sub); 393 | // delete sub from relay's store, 394 | // it's now owned by the closeQueue 395 | r.subs.delete(subId); 396 | } 397 | // clear the queue 398 | r.unsubQueue = []; 399 | 400 | // now create new NDK subs for new pubkeys and for old 401 | // pubkeys from updated subs 402 | r.subQueue = [...new Set(r.subQueue).values()]; 403 | while (r.subQueue.length > 0) { 404 | // create NDK sub for the next batch of pubkeys 405 | // from subQueue, and remove those pubkeys from subQueue 406 | const sub = createPubkeySub(r); 407 | 408 | // map sub's pubkeys to subId 409 | for (const p of sub.pubkeys) r.pubkeySubs.set(p, sub.subId); 410 | 411 | // store NDK sub itself 412 | r.subs.set(sub.subId, sub); 413 | } 414 | } 415 | 416 | // close old subs after new subs have activated 417 | setTimeout(() => { 418 | for (const sub of closeQueue) { 419 | sub.stop(); 420 | console.log(new Date(), "closing sub", sub.subId); 421 | } 422 | }, 500); 423 | 424 | // schedule the next processing 425 | setTimeout(processRelayQueue, 1000); 426 | } 427 | 428 | // schedule the next processing 429 | setTimeout(processRelayQueue, 1000); 430 | 431 | function digest(algo, data) { 432 | const hash = createHash(algo); 433 | hash.update(data); 434 | return hash.digest("hex"); 435 | } 436 | 437 | function isValidName(name) { 438 | const REGEX = /^[a-z0-9_\-.]{2,128}$/; 439 | return REGEX.test(name); 440 | } 441 | 442 | function getIp(req) { 443 | return req.header("x-real-ip") || req.ip; 444 | } 445 | 446 | function getMinPow(name, req) { 447 | let minPow = MIN_POW; 448 | if (name.length <= 5) { 449 | minPow += 3; 450 | } 451 | 452 | const ip = getIp(req); 453 | 454 | // have a record for this ip? 455 | let { pow: lastPow = 0, tm = 0 } = ipNamePows.get(ip) || {}; 456 | console.log("minPow", { name, ip, lastPow, tm, headers: req.headers }); 457 | if (lastPow) { 458 | // refill: reduce the pow threshold once per passed period 459 | const age = Date.now() - tm; 460 | const refill = Math.floor(age / POW_PERIOD); 461 | lastPow -= refill; 462 | } 463 | 464 | // if have lastPow - increment it and return 465 | if (lastPow && lastPow >= minPow) { 466 | minPow = lastPow + 1; 467 | } 468 | 469 | return minPow; 470 | } 471 | 472 | // nip98 473 | async function verifyAuthNostr(req, npub, path, minPow = 0) { 474 | try { 475 | const { type, data: pubkey } = nip19.decode(npub); 476 | if (type !== "npub") return false; 477 | 478 | const { authorization } = req.headers; 479 | //console.log("req authorization", pubkey, authorization); 480 | if (!authorization.startsWith("Nostr ")) return false; 481 | const data = authorization.split(" ")[1].trim(); 482 | if (!data) return false; 483 | 484 | const json = atob(data); 485 | const event = JSON.parse(json); 486 | // console.log("req authorization event", event); 487 | 488 | const now = Math.floor(Date.now() / 1000); 489 | if (event.pubkey !== pubkey) return false; 490 | if (event.kind !== 27235) return false; 491 | if (event.created_at < now - 60 || event.created_at > now + 60) 492 | return false; 493 | 494 | const pow = countLeadingZeros(event.id); 495 | console.log("pow", pow, "min", minPow, "id", event.id); 496 | if (minPow && pow < minPow) return false; 497 | 498 | const u = event.tags.find((t) => t.length === 2 && t[0] === "u")?.[1]; 499 | const method = event.tags.find( 500 | (t) => t.length === 2 && t[0] === "method" 501 | )?.[1]; 502 | const payload = event.tags.find( 503 | (t) => t.length === 2 && t[0] === "payload" 504 | )?.[1]; 505 | if (method !== req.method) return false; 506 | 507 | const url = new URL(u); 508 | // console.log({ url }) 509 | if (url.origin !== process.env.ORIGIN || url.pathname !== path) 510 | return false; 511 | 512 | if (req.rawBody.length > 0) { 513 | const hash = digest("sha256", req.rawBody.toString()); 514 | // console.log({ hash, payload, body: req.rawBody.toString() }) 515 | if (hash !== payload) return false; 516 | } else if (payload) { 517 | return false; 518 | } 519 | 520 | // finally after all cheap checks are done, 521 | // verify the signature 522 | if (!verifySignature(event)) return false; 523 | 524 | return true; 525 | } catch (e) { 526 | console.log("auth error", e); 527 | return false; 528 | } 529 | } 530 | 531 | async function addPsub(id, pubkey, pushSubscription, relays) { 532 | let psub = pushSubs.get(id); 533 | if (psub) { 534 | // update endpoint 535 | psub.pushSubscription = pushSubscription; 536 | // the bunker is alive, reset backoff timer 537 | psub.backoffMs = 0; 538 | psub.nextPush = 0; 539 | console.log(new Date(), "sub updated", pubkey, psub, relays); 540 | } else { 541 | // new sub for this id 542 | psub = { 543 | id, 544 | pubkey, 545 | pushSubscription, 546 | relays: [], 547 | timer: undefined, 548 | backoffMs: 0, 549 | lastPush: 0, 550 | nextPush: 0, 551 | }; 552 | 553 | console.log(new Date(), "sub created", pubkey, psub, relays); 554 | } 555 | 556 | // update relaySubs if needed 557 | subscribe(psub, relays); 558 | 559 | // update 560 | pushSubs.set(id, psub); 561 | } 562 | 563 | // json middleware that saves the original body for nip98 auth 564 | app.use( 565 | bodyParser.json({ 566 | verify: function (req, res, buf, encoding) { 567 | req.rawBody = buf; 568 | }, 569 | }) 570 | ); 571 | 572 | // CORS headers 573 | app.use(function (req, res, next) { 574 | res 575 | .header("Access-Control-Allow-Origin", "*") 576 | .header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") 577 | .header( 578 | "Access-Control-Allow-Headers", 579 | "accept,content-type,x-requested-with,authorization" 580 | ); 581 | next(); 582 | }); 583 | 584 | const SUBSCRIBE_PATH = "/subscribe"; 585 | app.post(SUBSCRIBE_PATH, async (req, res) => { 586 | try { 587 | const { npub, pushSubscription, relays } = req.body; 588 | 589 | if (!(await verifyAuthNostr(req, npub, SUBSCRIBE_PATH))) { 590 | console.log("auth failed", npub); 591 | res.status(403).send({ 592 | error: `Bad auth`, 593 | }); 594 | return; 595 | } 596 | 597 | if (relays.length > MAX_RELAYS) { 598 | console.log("too many relays", relays.length); 599 | res.status(400).send({ 600 | error: `Too many relays, max ${MAX_RELAYS}`, 601 | }); 602 | return; 603 | } 604 | 605 | const { data: pubkey } = nip19.decode(npub); 606 | const id = digest("sha1", pubkey + pushSubscription.endpoint); 607 | 608 | await addPsub(id, pubkey, pushSubscription, relays); 609 | 610 | // write to db w/o blocking the client 611 | prisma.pushSubs 612 | .upsert({ 613 | where: { pushId: id }, 614 | create: { 615 | pushId: id, 616 | npub, 617 | pushSubscription: JSON.stringify(pushSubscription), 618 | relays: JSON.stringify(relays), 619 | timestamp: Date.now(), 620 | }, 621 | update: { 622 | pushSubscription: JSON.stringify(pushSubscription), 623 | relays: JSON.stringify(relays), 624 | timestamp: Date.now(), 625 | }, 626 | }) 627 | .then((dbr) => console.log({ dbr })); 628 | 629 | // reply ok 630 | res.status(201).send({ 631 | ok: true, 632 | }); 633 | } catch (e) { 634 | console.log(new Date(), "error req from ", req.ip, e.toString()); 635 | res.status(400).send({ 636 | error: "Internal error", 637 | }); 638 | } 639 | }); 640 | 641 | const PUT_PATH = "/put"; 642 | app.post(PUT_PATH, async (req, res) => { 643 | try { 644 | const { npub, data, pwh } = req.body; 645 | 646 | if (!(await verifyAuthNostr(req, npub, PUT_PATH))) { 647 | console.log("auth failed", npub); 648 | res.status(403).send({ 649 | error: `Bad auth`, 650 | }); 651 | return; 652 | } 653 | 654 | if (data.length > MAX_DATA || pwh.length > MAX_DATA) { 655 | console.log("data too long"); 656 | res.status(400).send({ 657 | error: `Data too long, max ${MAX_DATA}`, 658 | }); 659 | return; 660 | } 661 | 662 | const start = Date.now(); 663 | const { pwh2, salt } = await makePwh2(pwh); 664 | console.log( 665 | new Date(), 666 | "put", 667 | npub, 668 | data, 669 | pwh2, 670 | salt, 671 | "in", 672 | Date.now() - start, 673 | "ms" 674 | ); 675 | npubData.set(npub, { data, pwh2, salt }); 676 | 677 | // write to db w/o blocking the client 678 | prisma.npubData 679 | .upsert({ 680 | where: { npub }, 681 | create: { 682 | npub, 683 | data, 684 | pwh2, 685 | salt, 686 | timestamp: Date.now(), 687 | }, 688 | update: { 689 | data, 690 | pwh2, 691 | salt, 692 | timestamp: Date.now(), 693 | }, 694 | }) 695 | .then((dbr) => console.log({ dbr })); 696 | 697 | // reply ok 698 | res.status(201).send({ 699 | ok: true, 700 | }); 701 | } catch (e) { 702 | console.log(new Date(), "error req from ", req.ip, e.toString()); 703 | res.status(400).send({ 704 | error: "Internal error", 705 | }); 706 | } 707 | }); 708 | 709 | const GET_PATH = "/get"; 710 | app.post(GET_PATH, async (req, res) => { 711 | try { 712 | const { npub, pwh } = req.body; 713 | 714 | const { type } = nip19.decode(npub); 715 | if (type !== "npub") { 716 | console.log("bad npub", npub); 717 | res.status(400).send({ 718 | error: "Bad npub", 719 | }); 720 | return; 721 | } 722 | 723 | const d = npubData.get(npub); 724 | if (!d) { 725 | console.log("no data for npub", npub); 726 | res.status(404).send({ 727 | error: "Not found", 728 | }); 729 | return; 730 | } 731 | 732 | const start = Date.now(); 733 | const { pwh2 } = await makePwh2(pwh, Buffer.from(d.salt, "hex")); 734 | console.log( 735 | new Date(), 736 | "get", 737 | npub, 738 | pwh2, 739 | d.salt, 740 | "in", 741 | Date.now() - start, 742 | "ms" 743 | ); 744 | 745 | if (d.pwh2 !== pwh2) { 746 | console.log("bad pwh npub", npub); 747 | res.status(403).send({ 748 | error: "Forbidden", 749 | }); 750 | return; 751 | } 752 | 753 | console.log(new Date(), "get", npub, d.data); 754 | 755 | // reply ok 756 | res.status(200).send({ 757 | data: d.data, 758 | }); 759 | } catch (e) { 760 | console.log(new Date(), "error req from ", req.ip, e.toString()); 761 | res.status(400).send({ 762 | error: "Internal error", 763 | }); 764 | } 765 | }); 766 | 767 | const NAME_PATH = "/name"; 768 | app.post(NAME_PATH, async (req, res) => { 769 | try { 770 | const { npub, name } = req.body; 771 | 772 | if (!isValidName(name)) { 773 | console.log("invalid name", name); 774 | res.status(400).send({ 775 | error: `Bad name`, 776 | }); 777 | return; 778 | } 779 | 780 | const minPow = getMinPow(name, req); 781 | 782 | if (!(await verifyAuthNostr(req, npub, NAME_PATH, minPow))) { 783 | console.log("auth failed", npub); 784 | res.status(403).send({ 785 | error: `Bad auth`, 786 | minPow, 787 | }); 788 | return; 789 | } 790 | 791 | const names = await prisma.names.findMany({ 792 | where: { 793 | npub, 794 | name, 795 | }, 796 | }); 797 | const alreadyAssigned = names.find((n) => n.name === name); 798 | if (alreadyAssigned) { 799 | console.log("Name", name, "already assigned to", npub); 800 | 801 | const status = alreadyAssigned.disabled ? 201 : 200; 802 | if (alreadyAssigned.disabled) { 803 | const dbr = await prisma.names.update({ 804 | where: { 805 | npub, 806 | name, 807 | }, 808 | data: { 809 | timestamp: Date.now(), 810 | disabled: 0, 811 | }, 812 | }); 813 | console.log("Name", name, "activated for", npub, { dbr }); 814 | } 815 | 816 | // reply ok 817 | res.status(status).send({ 818 | ok: true, 819 | }); 820 | return; 821 | } 822 | 823 | try { 824 | const dbr = await prisma.names.create({ 825 | data: { 826 | npub, 827 | name, 828 | timestamp: Date.now(), 829 | }, 830 | }); 831 | console.log({ dbr }); 832 | } catch (e) { 833 | res.status(400).send({ 834 | error: "Name taken", 835 | }); 836 | return; 837 | } 838 | 839 | // update minPow for this ip 840 | ipNamePows.set(getIp(req), { pow: minPow, tm: Date.now() }); 841 | 842 | // reply ok 843 | res.status(201).send({ 844 | ok: true, 845 | }); 846 | } catch (e) { 847 | console.log(new Date(), "error req from ", req.ip, e.toString()); 848 | res.status(500).send({ 849 | error: "Internal error", 850 | }); 851 | } 852 | }); 853 | 854 | app.get(NAME_PATH, async (req, res) => { 855 | try { 856 | const { npub } = req.query; 857 | if (!npub) { 858 | res.status(400).send({ error: "Specify npub" }); 859 | return; 860 | } 861 | 862 | const recs = await prisma.names.findMany({ 863 | where: { 864 | npub, 865 | }, 866 | }); 867 | console.log("npub", npub, recs); 868 | 869 | const data = { 870 | names: recs.filter((r) => !r.disabled).map((r) => r.name), 871 | }; 872 | 873 | res.status(200).send(data); 874 | } catch (e) { 875 | console.log(new Date(), "error req from ", req.ip, e.toString()); 876 | res.status(500).send({ 877 | error: "Internal error", 878 | }); 879 | } 880 | }); 881 | 882 | app.delete(NAME_PATH, async (req, res) => { 883 | try { 884 | const { npub, name } = req.body; 885 | 886 | if (!(await verifyAuthNostr(req, npub, NAME_PATH))) { 887 | console.log("auth failed", npub); 888 | res.status(403).send({ 889 | error: `Bad auth`, 890 | }); 891 | return; 892 | } 893 | 894 | try { 895 | const deletedName = await prisma.names.delete({ 896 | where: { 897 | npub, 898 | name, 899 | }, 900 | }); 901 | console.log({ deletedName }); 902 | } catch (e) { 903 | console.log("Failed to delete name", name, npub); 904 | res.status(404).send({ 905 | error: "Name not found", 906 | }); 907 | return; 908 | } 909 | 910 | // reply ok 911 | res.status(200).send({ 912 | ok: true, 913 | }); 914 | } catch (e) { 915 | console.log(new Date(), "error req from ", req.ip, e.toString()); 916 | res.status(500).send({ 917 | error: "Internal error", 918 | }); 919 | } 920 | }); 921 | 922 | app.put(NAME_PATH, async (req, res) => { 923 | try { 924 | const { npub, name, newNpub } = req.body; 925 | 926 | const { type } = nip19.decode(newNpub); 927 | if (type !== "npub") { 928 | console.log("bad new npub", newNpub); 929 | res.status(400).send({ 930 | error: "Bad new npub", 931 | }); 932 | return; 933 | } 934 | 935 | if (!(await verifyAuthNostr(req, npub, NAME_PATH))) { 936 | console.log("auth failed", npub); 937 | res.status(403).send({ 938 | error: `Bad auth`, 939 | }); 940 | return; 941 | } 942 | 943 | try { 944 | const deletedName = await prisma.names.delete({ 945 | where: { 946 | npub, 947 | name, 948 | }, 949 | }); 950 | console.log({ deletedName }); 951 | } catch (e) { 952 | console.log("Failed to delete name", name, npub); 953 | res.status(404).send({ 954 | error: "Name not found", 955 | }); 956 | return; 957 | } 958 | 959 | try { 960 | const dbr = await prisma.names.create({ 961 | data: { 962 | npub: newNpub, 963 | name, 964 | timestamp: Date.now(), 965 | disabled: 1, 966 | }, 967 | }); 968 | console.log({ dbr }); 969 | } catch (e) { 970 | res.status(400).send({ 971 | error: "Name taken", 972 | }); 973 | return; 974 | } 975 | 976 | // reply ok 977 | res.status(201).send({ 978 | ok: true, 979 | }); 980 | } catch (e) { 981 | console.log(new Date(), "error req from ", req.ip, e.toString()); 982 | res.status(500).send({ 983 | error: "Internal error", 984 | }); 985 | } 986 | }); 987 | 988 | const JSON_PATH = "/.well-known/nostr.json"; 989 | app.get(JSON_PATH, async (req, res) => { 990 | try { 991 | const { data: bunkerNsec } = nip19.decode(BUNKER_NSEC); 992 | const bunkerPubkey = getPublicKey(bunkerNsec); 993 | 994 | const dc = BUNKER_IFRAME_DOMAINS.length; 995 | const iframe_domain = dc 996 | ? BUNKER_IFRAME_DOMAINS[Math.floor(Math.random() * dc)] 997 | : new URL(BUNKER_ORIGIN).hostname; 998 | const data = { 999 | names: { 1000 | _: bunkerPubkey, 1001 | }, 1002 | nip46: { 1003 | iframe_url: `https://${iframe_domain}/iframe`, 1004 | relays: [BUNKER_RELAY], 1005 | nostrconnect_url: `${BUNKER_ORIGIN}/`, 1006 | }, 1007 | // old ndk fails without it 1008 | relays: {}, 1009 | }; 1010 | data.nip46[bunkerPubkey] = [BUNKER_RELAY]; 1011 | 1012 | const { name } = req.query; 1013 | if (!name) { 1014 | res.status(200).send(data); 1015 | return; 1016 | } 1017 | 1018 | const rec = await prisma.names.findUnique({ 1019 | where: { 1020 | name, 1021 | }, 1022 | }); 1023 | console.log("name", name, rec); 1024 | 1025 | if (rec && !rec.disabled) { 1026 | const { data: pubkey } = nip19.decode(rec.npub); 1027 | data.names[rec.name] = pubkey; 1028 | data.nip46[pubkey] = [BUNKER_RELAY]; 1029 | data.relays[pubkey] = [BUNKER_RELAY]; 1030 | } 1031 | 1032 | res.status(200).send(data); 1033 | } catch (e) { 1034 | console.log(new Date(), "error req from ", req.ip, e.toString()); 1035 | res.status(500).send({ 1036 | error: "Internal error", 1037 | }); 1038 | } 1039 | }); 1040 | 1041 | const CREATED_PATH = "/created"; 1042 | app.post(CREATED_PATH, async (req, res) => { 1043 | try { 1044 | const { npub, token } = req.body; 1045 | 1046 | const { type, data: pubkey } = nip19.decode(npub); 1047 | if (type !== "npub") { 1048 | console.log("bad npub", npub); 1049 | res.status(400).send({ 1050 | error: "Bad npub", 1051 | }); 1052 | return; 1053 | } 1054 | 1055 | const cb = bunkerTokens.get(token); 1056 | if (!cb) { 1057 | console.log("bad token", token, npub); 1058 | res.status(400).send({ 1059 | error: "Bad token", 1060 | }); 1061 | return; 1062 | } 1063 | 1064 | if (!(await verifyAuthNostr(req, npub, CREATED_PATH))) { 1065 | console.log("auth failed", npub); 1066 | res.status(403).send({ 1067 | error: `Bad auth`, 1068 | minPow, 1069 | }); 1070 | return; 1071 | } 1072 | 1073 | // redeem 1074 | bunkerTokens.delete(token); 1075 | 1076 | // if cb throws we will inform the client 1077 | // that they should connect to the app manually 1078 | await cb(pubkey); 1079 | 1080 | // reply ok 1081 | res.status(200).send({ 1082 | ok: true, 1083 | }); 1084 | } catch (e) { 1085 | console.log(new Date(), "error req from ", req.ip, e.toString()); 1086 | res.status(500).send({ 1087 | error: "Internal error", 1088 | }); 1089 | } 1090 | }); 1091 | 1092 | async function loadFromDb() { 1093 | const start = Date.now(); 1094 | 1095 | const psubs = await prisma.pushSubs.findMany(); 1096 | for (const ps of psubs) { 1097 | const { type, data: pubkey } = nip19.decode(ps.npub); 1098 | if (type !== "npub") continue; 1099 | try { 1100 | await addPsub( 1101 | ps.pushId, 1102 | pubkey, 1103 | JSON.parse(ps.pushSubscription), 1104 | JSON.parse(ps.relays) 1105 | ); 1106 | } catch (e) { 1107 | console.log("load error", e); 1108 | } 1109 | } 1110 | 1111 | const datas = await prisma.npubData.findMany(); 1112 | for (const d of datas) { 1113 | npubData.set(d.npub, { 1114 | data: d.data, 1115 | pwh2: d.pwh2, 1116 | salt: d.salt, 1117 | }); 1118 | } 1119 | 1120 | console.log( 1121 | "loaded from db in", 1122 | Date.now() - start, 1123 | "ms psubs", 1124 | psubs.length, 1125 | "datas", 1126 | datas.length 1127 | ); 1128 | } 1129 | 1130 | class CreateAccountHandlingStrategy { 1131 | constructor() {} 1132 | 1133 | async handle(backend, id, remotePubkey, params) { 1134 | // generate token 1135 | const token = getCreateAccountToken(); 1136 | 1137 | // params 1138 | const [name = "", domain = "", email = "", perms = ""] = params; 1139 | 1140 | if (domain !== BUNKER_DOMAIN) throw new Error("Bad domain"); 1141 | 1142 | const appNpub = nip19.npubEncode(remotePubkey); 1143 | 1144 | // format auth url 1145 | const url = `${BUNKER_ORIGIN}/create?name=${name}&token=${token}&appNpub=${appNpub}&perms=${perms}&email=${email}`; 1146 | console.log("sending auth_url", url, "to", remotePubkey); 1147 | await backend.sendResponse( 1148 | id, 1149 | remotePubkey, 1150 | "auth_url", 1151 | undefined, 1152 | url 1153 | ); 1154 | 1155 | // wait for token redeemal 1156 | const tokenPromise = new Promise((ok) => { 1157 | // will resolve if app redeems the token 1158 | bunkerTokens.set(token, ok); 1159 | }); 1160 | 1161 | // timeout to avoid 1162 | const timeoutPromise = new Promise((_, err) => 1163 | setTimeout(() => { 1164 | // release memory 1165 | bunkerTokens.delete(token); 1166 | 1167 | // throw 1168 | err("Timeout"); 1169 | }, 3600000) 1170 | ); 1171 | 1172 | // if tokenPromise resolves it returns the new pubkey 1173 | return await Promise.race([tokenPromise, timeoutPromise]); 1174 | } 1175 | } 1176 | 1177 | class PrivateKeySigner extends NDKPrivateKeySigner { 1178 | nip44 = new Nip44(); 1179 | _pubkey = ''; 1180 | 1181 | constructor(privateKey) { 1182 | super(privateKey); 1183 | this._pubkey = getPublicKey(privateKey); 1184 | } 1185 | 1186 | get pubkey() { 1187 | return this._pubkey; 1188 | } 1189 | 1190 | encryptNip44(recipient, value) { 1191 | return Promise.resolve(this.nip44.encrypt(this.privateKey, recipient.pubkey, value)); 1192 | } 1193 | 1194 | decryptNip44(sender, value) { 1195 | return Promise.resolve(this.nip44.decrypt(this.privateKey, sender.pubkey, value)); 1196 | } 1197 | } 1198 | 1199 | class Nip46Backend extends NDKNip46Backend { 1200 | _signer = undefined; 1201 | 1202 | constructor(ndk, signer) { 1203 | super(ndk, signer, () => Promise.resolve(true)); 1204 | signer.user().then((u) => (this.npub = nip19.npubEncode(u.pubkey))); 1205 | this._signer = signer; 1206 | } 1207 | 1208 | isNip04(ciphertext) { 1209 | const l = ciphertext.length; 1210 | if (l < 28) return false; 1211 | return ciphertext[l - 28] === '?' && ciphertext[l - 27] === 'i' && ciphertext[l - 26] === 'v' && ciphertext[l - 25] === '='; 1212 | } 1213 | 1214 | async parseEvent(event) { 1215 | const remoteUser = this.ndk.getUser({ pubkey: event.pubkey }); 1216 | remoteUser.ndk = this.ndk; 1217 | const decrypt = this.isNip04(event.content) 1218 | ? this._signer.decrypt 1219 | : this._signer.decryptNip44; 1220 | console.log("client event nip04", this.isNip04(event.content)); 1221 | const decryptedContent = await decrypt.call( 1222 | this._signer, 1223 | remoteUser, 1224 | event.content 1225 | ); 1226 | const parsedContent = JSON.parse(decryptedContent); 1227 | const { id, method, params, result, error } = parsedContent; 1228 | 1229 | if (method) { 1230 | return { id, pubkey: event.pubkey, method, params, event }; 1231 | } else { 1232 | return { id, result, error, event }; 1233 | } 1234 | } 1235 | 1236 | async sendResponse( 1237 | id, 1238 | remotePubkey, 1239 | result, 1240 | kind = 24133, 1241 | error 1242 | ) { 1243 | const res = { id, result } 1244 | if (error) { 1245 | res.error = error 1246 | } 1247 | 1248 | const localUser = await this.signer.user() 1249 | const remoteUser = this.ndk.getUser({ pubkey: remotePubkey }) 1250 | const event = new NDKEvent(this.ndk, { 1251 | kind, 1252 | content: JSON.stringify(res), 1253 | tags: [['p', remotePubkey]], 1254 | pubkey: localUser.pubkey, 1255 | }) 1256 | 1257 | event.content = await this._signer.encryptNip44(remoteUser, event.content) 1258 | await event.sign(this._signer) 1259 | await event.publish(); 1260 | return event 1261 | } 1262 | 1263 | async handleIncomingEvent(event) { 1264 | if (event.created_at < Date.now() / 1000 - 10) return; 1265 | 1266 | let req; 1267 | try { 1268 | req = await this.parseEvent(event); 1269 | } catch (e) { 1270 | this.debug("failed to parse event", event.rawEvent(), e); 1271 | // ignore unsupported methods 1272 | return; 1273 | } 1274 | const { id, method, params } = req; 1275 | const remotePubkey = event.pubkey; 1276 | let response; 1277 | 1278 | // validate signature explicitly 1279 | if (!verifySignature(event.rawEvent())) { 1280 | this.debug("invalid signature", event.rawEvent()); 1281 | return; 1282 | } 1283 | 1284 | const strategy = this.handlers[method]; 1285 | if (strategy) { 1286 | try { 1287 | response = await strategy.handle(this, id, remotePubkey, params); 1288 | console.log( 1289 | Date.now(), 1290 | "req", 1291 | id, 1292 | "method", 1293 | method, 1294 | "result", 1295 | response 1296 | ); 1297 | } catch (e) { 1298 | this.debug("error handling event", e, { id, method, params }); 1299 | this.sendResponse(id, remotePubkey, "error", undefined, e.message); 1300 | } 1301 | } else { 1302 | this.debug("unsupported method", { method, params }); 1303 | // ignore unsupported methods 1304 | return; 1305 | } 1306 | 1307 | if (response) { 1308 | this.debug(`sending response to ${remotePubkey}`, response); 1309 | this.sendResponse(id, remotePubkey, response); 1310 | } else { 1311 | this.sendResponse( 1312 | id, 1313 | remotePubkey, 1314 | "error", 1315 | undefined, 1316 | "Not authorized" 1317 | ); 1318 | } 1319 | } 1320 | } 1321 | 1322 | async function startBunker() { 1323 | const { data: bunkerSk } = nip19.decode(BUNKER_NSEC); 1324 | bunkerSigner = new Nip46Backend( 1325 | ndk, 1326 | new PrivateKeySigner(bunkerSk), 1327 | () => true 1328 | ); 1329 | bunkerSigner.handlers = { 1330 | get_public_key: bunkerSigner.handlers.get_public_key, 1331 | create_account: new CreateAccountHandlingStrategy(), 1332 | }; 1333 | bunkerSigner.start(); 1334 | console.log("starting bunker"); 1335 | } 1336 | 1337 | console.log(process.argv); 1338 | if (process.argv.length >= 3) { 1339 | if (process.argv[2] === "list_names") { 1340 | prisma.names.findMany().then((names) => { 1341 | for (const n of names) console.log(n.id, n.name, n.npub, n.timestamp); 1342 | }); 1343 | return; 1344 | } else if (process.argv[2] === "delete_name") { 1345 | if (process.argv.length < 4) { 1346 | console.log("enter name"); 1347 | return; 1348 | } 1349 | const name = process.argv[3]; 1350 | prisma.names.delete({ where: { name } }).then((r) => { 1351 | console.log("deleted", name, r); 1352 | }); 1353 | return; 1354 | } 1355 | } 1356 | 1357 | // start bunker 1358 | ndk.connect().then(startBunker); 1359 | 1360 | // start server 1361 | loadFromDb().then(() => { 1362 | app.listen(port, () => { 1363 | console.log(`Listening on port ${port}!`); 1364 | }); 1365 | }); 1366 | --------------------------------------------------------------------------------