nostr-bridge.js
1 /** 2 * Nostr Bridge for Keet CLI 3 * 4 * Publishes and receives encrypted room invites via Nostr. 5 * Uses NIP-04 encrypted direct messages to share room keys. 6 * 7 * This allows you to: 8 * 1. Create a room on desktop → auto-publish invite to Nostr 9 * 2. Receive invites on any device with a Nostr client 10 * 3. Join rooms across devices without manual key exchange 11 */ 12 13 import { 14 generateSecretKey, 15 getPublicKey, 16 finalizeEvent, 17 nip04, 18 nip19 19 } from 'nostr-tools' 20 import { Relay } from 'nostr-tools/relay' 21 import crypto from 'crypto' 22 import fs from 'fs' 23 import path from 'path' 24 import os from 'os' 25 26 // Default relays 27 const DEFAULT_RELAYS = [ 28 'wss://relay.damus.io', 29 'wss://nos.lol', 30 'wss://relay.nostr.band' 31 ] 32 33 // Storage for keys 34 const CONFIG_DIR = path.join(os.homedir(), '.keet-cli') 35 const KEYS_FILE = path.join(CONFIG_DIR, 'nostr-keys.json') 36 37 export class NostrBridge { 38 constructor(options = {}) { 39 this.relays = options.relays || DEFAULT_RELAYS 40 this.secretKey = null 41 this.publicKey = null 42 this.connections = [] 43 this.onInvite = options.onInvite || null 44 } 45 46 async init() { 47 // Load or generate keys 48 fs.mkdirSync(CONFIG_DIR, { recursive: true }) 49 50 if (fs.existsSync(KEYS_FILE)) { 51 const data = JSON.parse(fs.readFileSync(KEYS_FILE, 'utf-8')) 52 this.secretKey = Buffer.from(data.secretKey, 'hex') 53 this.publicKey = data.publicKey 54 } else { 55 this.secretKey = generateSecretKey() 56 this.publicKey = getPublicKey(this.secretKey) 57 58 fs.writeFileSync(KEYS_FILE, JSON.stringify({ 59 secretKey: Buffer.from(this.secretKey).toString('hex'), 60 publicKey: this.publicKey 61 }, null, 2)) 62 } 63 64 console.log(`[nostr] Public key: ${this.publicKey.slice(0, 16)}...`) 65 console.log(`[nostr] npub: ${nip19.npubEncode(this.publicKey)}`) 66 67 return this 68 } 69 70 async connect() { 71 console.log('[nostr] Connecting to relays...') 72 73 for (const url of this.relays) { 74 try { 75 const relay = await Relay.connect(url) 76 console.log(`[nostr] Connected to ${url}`) 77 this.connections.push(relay) 78 79 // Subscribe to DMs for our pubkey 80 relay.subscribe([ 81 { 82 kinds: [4], // Encrypted DM 83 '#p': [this.publicKey], 84 since: Math.floor(Date.now() / 1000) - 3600 // Last hour 85 } 86 ], { 87 onevent: (event) => this._handleEvent(event), 88 oneose: () => console.log(`[nostr] Subscription synced on ${url}`) 89 }) 90 } catch (e) { 91 console.log(`[nostr] Failed to connect to ${url}: ${e.message}`) 92 } 93 } 94 95 if (this.connections.length === 0) { 96 throw new Error('Could not connect to any relays') 97 } 98 99 return this 100 } 101 102 async _handleEvent(event) { 103 try { 104 // Decrypt the message 105 const decrypted = await nip04.decrypt(this.secretKey, event.pubkey, event.content) 106 107 // Check if it's a room invite 108 if (decrypted.startsWith('keet-cli:room:')) { 109 const parts = decrypted.split(':') 110 const roomData = { 111 type: 'invite', 112 inviteCode: parts[2], 113 roomName: parts[3] || null, 114 from: event.pubkey.slice(0, 8), 115 timestamp: event.created_at 116 } 117 118 console.log(`\r[nostr] Room invite received from ${roomData.from}`) 119 console.log(` Invite: ${roomData.inviteCode.slice(0, 16)}...`) 120 121 if (this.onInvite) { 122 this.onInvite(roomData) 123 } 124 } else { 125 console.log(`\r[nostr] Message from ${event.pubkey.slice(0, 8)}: ${decrypted.slice(0, 50)}...`) 126 } 127 } catch (e) { 128 // Ignore decryption errors (messages not for us) 129 } 130 } 131 132 async publishRoomInvite(inviteCode, roomName = null, recipientPubkey = null) { 133 // Format: keet-cli:room:<invite>:<roomName> 134 const message = `keet-cli:room:${inviteCode}:${roomName || ''}` 135 136 // If no recipient, send to self (for cross-device sync) 137 const recipient = recipientPubkey || this.publicKey 138 139 // Encrypt the message 140 const encrypted = await nip04.encrypt(this.secretKey, recipient, message) 141 142 // Create the event 143 const event = finalizeEvent({ 144 kind: 4, // Encrypted DM 145 created_at: Math.floor(Date.now() / 1000), 146 tags: [['p', recipient]], 147 content: encrypted 148 }, this.secretKey) 149 150 // Publish to all connected relays 151 let published = 0 152 for (const relay of this.connections) { 153 try { 154 await relay.publish(event) 155 published++ 156 } catch (e) { 157 console.log(`[nostr] Failed to publish to relay: ${e.message}`) 158 } 159 } 160 161 console.log(`[nostr] Room invite published to ${published} relay(s)`) 162 return event.id 163 } 164 165 async publishContext(context, recipientPubkey = null) { 166 // Publish Phoenix context or other data 167 const recipient = recipientPubkey || this.publicKey 168 const encrypted = await nip04.encrypt(this.secretKey, recipient, JSON.stringify(context)) 169 170 const event = finalizeEvent({ 171 kind: 4, 172 created_at: Math.floor(Date.now() / 1000), 173 tags: [['p', recipient]], 174 content: encrypted 175 }, this.secretKey) 176 177 let published = 0 178 for (const relay of this.connections) { 179 try { 180 await relay.publish(event) 181 published++ 182 } catch (e) { 183 // Ignore 184 } 185 } 186 187 return published 188 } 189 190 getPublicKey() { 191 return this.publicKey 192 } 193 194 getNpub() { 195 return nip19.npubEncode(this.publicKey) 196 } 197 198 async close() { 199 for (const relay of this.connections) { 200 try { 201 relay.close() 202 } catch (e) { 203 // Ignore 204 } 205 } 206 this.connections = [] 207 } 208 } 209 210 // CLI for standalone usage 211 async function main() { 212 const args = process.argv.slice(2) 213 214 if (args[0] === '--help' || args[0] === '-h') { 215 console.log(` 216 Nostr Bridge for Keet CLI 217 218 Usage: 219 node nostr-bridge.js Show your npub 220 node nostr-bridge.js --listen Listen for room invites 221 node nostr-bridge.js --publish <invite> Publish a room invite 222 node nostr-bridge.js --context <json> Publish context data 223 224 Options: 225 --to <npub> Recipient public key (default: self) 226 --relays <...> Comma-separated relay URLs 227 `) 228 process.exit(0) 229 } 230 231 const bridge = new NostrBridge() 232 await bridge.init() 233 234 if (args[0] === '--listen') { 235 bridge.onInvite = (invite) => { 236 console.log('') 237 console.log('To join this room:') 238 console.log(` keet-cli ${invite.inviteCode}`) 239 console.log('') 240 } 241 242 await bridge.connect() 243 console.log('') 244 console.log('Listening for room invites...') 245 console.log('Press Ctrl+C to stop.') 246 247 // Keep alive 248 process.stdin.resume() 249 250 } else if (args[0] === '--publish' && args[1]) { 251 await bridge.connect() 252 await bridge.publishRoomInvite(args[1], args[2] || null) 253 await bridge.close() 254 255 } else if (args[0] === '--context' && args[1]) { 256 await bridge.connect() 257 const context = JSON.parse(args[1]) 258 const count = await bridge.publishContext(context) 259 console.log(`Published to ${count} relays`) 260 await bridge.close() 261 262 } else { 263 // Just show keys 264 console.log('') 265 console.log('Your Nostr identity:') 266 console.log(` npub: ${bridge.getNpub()}`) 267 console.log(` hex: ${bridge.getPublicKey()}`) 268 console.log('') 269 console.log('Add this npub to your contacts to receive room invites.') 270 console.log('Use --listen to wait for invites, or --publish to share a room.') 271 } 272 } 273 274 // Run if called directly 275 const isMain = process.argv[1]?.endsWith('nostr-bridge.js') 276 if (isMain) { 277 main().catch(err => { 278 console.error('Error:', err.message) 279 process.exit(1) 280 }) 281 }