sovereign-broadcast.js
1 #!/usr/bin/env node 2 /** 3 * Sovereign Broadcast - Encrypted Public Nostr Events 4 * 5 * Broadcast encrypted messages to Nostr that only you can read. 6 * Uses AES-256-GCM symmetric encryption with a personal key. 7 * 8 * Flow: 9 * 1. Generate your sovereign key (once) 10 * 2. Publish encrypted events to public Nostr feed 11 * 3. Any device with your key can subscribe and decrypt 12 * 4. No need to pre-register devices 13 * 14 * Usage: 15 * sovereign-broadcast init # Generate key, send via DM 16 * sovereign-broadcast publish <message> # Publish encrypted event 17 * sovereign-broadcast subscribe # Watch for events 18 * sovereign-broadcast send-key <npub> # Send key to another identity 19 */ 20 21 import crypto from 'crypto' 22 import fs from 'fs' 23 import path from 'path' 24 import os from 'os' 25 import { 26 generateSecretKey, 27 getPublicKey, 28 finalizeEvent, 29 nip04, 30 nip19 31 } from 'nostr-tools' 32 import { Relay } from 'nostr-tools/relay' 33 34 // Configuration 35 const CONFIG_DIR = path.join(os.homedir(), '.sovereign') 36 const KEY_FILE = path.join(CONFIG_DIR, 'broadcast-key.json') 37 const NOSTR_KEY_FILE = path.join(CONFIG_DIR, 'nostr-identity.json') 38 39 // Custom event kind for sovereign broadcast (in the replaceable range) 40 const SOVEREIGN_EVENT_KIND = 30078 // Parameterized replaceable 41 42 // Default relays 43 const DEFAULT_RELAYS = [ 44 'wss://relay.damus.io', 45 'wss://nos.lol', 46 'wss://relay.nostr.band', 47 'wss://nostr.wine' 48 ] 49 50 // AES-256-GCM encryption 51 function encrypt(plaintext, key) { 52 const iv = crypto.randomBytes(12) 53 const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) 54 55 let encrypted = cipher.update(plaintext, 'utf8', 'base64') 56 encrypted += cipher.final('base64') 57 58 const authTag = cipher.getAuthTag() 59 60 // Format: iv:authTag:ciphertext (all base64) 61 return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}` 62 } 63 64 function decrypt(ciphertext, key) { 65 const parts = ciphertext.split(':') 66 if (parts.length !== 3) { 67 throw new Error('Invalid ciphertext format') 68 } 69 70 const iv = Buffer.from(parts[0], 'base64') 71 const authTag = Buffer.from(parts[1], 'base64') 72 const encrypted = parts[2] 73 74 const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv) 75 decipher.setAuthTag(authTag) 76 77 let decrypted = decipher.update(encrypted, 'base64', 'utf8') 78 decrypted += decipher.final('utf8') 79 80 return decrypted 81 } 82 83 class SovereignBroadcast { 84 constructor(options = {}) { 85 this.relays = options.relays || DEFAULT_RELAYS 86 this.sovereignKey = null // AES-256 symmetric key 87 this.nostrSecretKey = null // Nostr signing key 88 this.nostrPublicKey = null 89 this.identifier = null // Your sovereign identifier 90 this.connections = [] 91 this.onMessage = options.onMessage || null 92 } 93 94 async init() { 95 fs.mkdirSync(CONFIG_DIR, { recursive: true }) 96 97 // Load or generate Nostr identity 98 if (fs.existsSync(NOSTR_KEY_FILE)) { 99 const data = JSON.parse(fs.readFileSync(NOSTR_KEY_FILE, 'utf8')) 100 this.nostrSecretKey = Buffer.from(data.secretKey, 'hex') 101 this.nostrPublicKey = data.publicKey 102 this.identifier = data.identifier || this.nostrPublicKey.slice(0, 16) 103 } else { 104 this.nostrSecretKey = generateSecretKey() 105 this.nostrPublicKey = getPublicKey(this.nostrSecretKey) 106 this.identifier = this.nostrPublicKey.slice(0, 16) 107 108 fs.writeFileSync(NOSTR_KEY_FILE, JSON.stringify({ 109 secretKey: Buffer.from(this.nostrSecretKey).toString('hex'), 110 publicKey: this.nostrPublicKey, 111 identifier: this.identifier 112 }, null, 2)) 113 } 114 115 // Load or generate sovereign broadcast key 116 if (fs.existsSync(KEY_FILE)) { 117 const data = JSON.parse(fs.readFileSync(KEY_FILE, 'utf8')) 118 this.sovereignKey = Buffer.from(data.key, 'hex') 119 console.log('[sovereign] Loaded existing broadcast key') 120 } else { 121 this.sovereignKey = crypto.randomBytes(32) // AES-256 122 123 fs.writeFileSync(KEY_FILE, JSON.stringify({ 124 key: this.sovereignKey.toString('hex'), 125 created: new Date().toISOString(), 126 identifier: this.identifier 127 }, null, 2)) 128 console.log('[sovereign] Generated new broadcast key') 129 } 130 131 console.log(`[sovereign] Identifier: ${this.identifier}`) 132 console.log(`[sovereign] Nostr pubkey: ${this.nostrPublicKey.slice(0, 16)}...`) 133 console.log(`[sovereign] npub: ${nip19.npubEncode(this.nostrPublicKey)}`) 134 135 return this 136 } 137 138 async connect() { 139 console.log('[sovereign] Connecting to relays...') 140 141 for (const url of this.relays) { 142 try { 143 const relay = await Relay.connect(url) 144 console.log(`[sovereign] Connected to ${url}`) 145 this.connections.push(relay) 146 } catch (e) { 147 console.log(`[sovereign] Failed to connect to ${url}`) 148 } 149 } 150 151 if (this.connections.length === 0) { 152 throw new Error('Could not connect to any relays') 153 } 154 155 return this 156 } 157 158 async subscribe() { 159 console.log(`[sovereign] Subscribing to events for identifier: ${this.identifier}`) 160 161 for (const relay of this.connections) { 162 relay.subscribe([ 163 { 164 kinds: [SOVEREIGN_EVENT_KIND], 165 '#d': [this.identifier], 166 since: Math.floor(Date.now() / 1000) - 86400 // Last 24 hours 167 } 168 ], { 169 onevent: (event) => this._handleEvent(event), 170 oneose: () => console.log(`[sovereign] Subscription synced`) 171 }) 172 } 173 174 return this 175 } 176 177 _handleEvent(event) { 178 try { 179 // Decrypt the content 180 const decrypted = decrypt(event.content, this.sovereignKey) 181 const data = JSON.parse(decrypted) 182 183 const time = new Date(event.created_at * 1000).toLocaleString() 184 console.log(`\n[${time}] Sovereign broadcast received:`) 185 console.log(` Type: ${data.type || 'unknown'}`) 186 187 if (data.type === 'room_invite') { 188 console.log(` Room: ${data.roomName || 'unnamed'}`) 189 console.log(` Invite: ${data.inviteCode}`) 190 console.log(` Join: keet-cli ${data.inviteCode}`) 191 } else if (data.type === 'phoenix_state') { 192 console.log(` Session: ${data.sessionId}`) 193 console.log(` Topics: ${data.topics?.join(', ') || 'none'}`) 194 } else if (data.type === 'context') { 195 console.log(` ${data.text?.slice(0, 100)}...`) 196 } else { 197 console.log(` Data: ${JSON.stringify(data).slice(0, 100)}...`) 198 } 199 200 if (this.onMessage) { 201 this.onMessage(data, event) 202 } 203 } catch (e) { 204 // Can't decrypt - not for us or corrupted 205 console.log(`[sovereign] Could not decrypt event: ${e.message}`) 206 } 207 } 208 209 async publish(data) { 210 // Encrypt the data 211 const plaintext = typeof data === 'string' ? data : JSON.stringify(data) 212 const ciphertext = encrypt(plaintext, this.sovereignKey) 213 214 // Create Nostr event 215 const event = finalizeEvent({ 216 kind: SOVEREIGN_EVENT_KIND, 217 created_at: Math.floor(Date.now() / 1000), 218 tags: [ 219 ['d', this.identifier], // Parameterized replaceable identifier 220 ['t', 'sovereign'], // Tag for filtering 221 ['t', data.type || 'message'] 222 ], 223 content: ciphertext 224 }, this.nostrSecretKey) 225 226 // Publish to all relays 227 let published = 0 228 for (const relay of this.connections) { 229 try { 230 await relay.publish(event) 231 published++ 232 } catch (e) { 233 // Ignore publish errors 234 } 235 } 236 237 console.log(`[sovereign] Published to ${published} relay(s)`) 238 return event.id 239 } 240 241 async publishRoomInvite(inviteCode, roomName = null) { 242 return this.publish({ 243 type: 'room_invite', 244 inviteCode, 245 roomName, 246 timestamp: Date.now() 247 }) 248 } 249 250 async publishPhoenixState(state, sessionId) { 251 return this.publish({ 252 type: 'phoenix_state', 253 sessionId, 254 ...state, 255 timestamp: Date.now() 256 }) 257 } 258 259 async publishContext(text, metadata = {}) { 260 return this.publish({ 261 type: 'context', 262 text, 263 ...metadata, 264 timestamp: Date.now() 265 }) 266 } 267 268 async sendKeyViaDM(recipientNpub) { 269 // Decode recipient npub 270 const recipientHex = nip19.decode(recipientNpub).data 271 272 // Create message with the sovereign key 273 const message = JSON.stringify({ 274 type: 'sovereign_key', 275 key: this.sovereignKey.toString('hex'), 276 identifier: this.identifier, 277 instructions: 'Save this to ~/.sovereign/broadcast-key.json to decrypt broadcasts' 278 }) 279 280 // Encrypt with NIP-04 281 const encrypted = await nip04.encrypt(this.nostrSecretKey, recipientHex, message) 282 283 // Create DM event 284 const event = finalizeEvent({ 285 kind: 4, 286 created_at: Math.floor(Date.now() / 1000), 287 tags: [['p', recipientHex]], 288 content: encrypted 289 }, this.nostrSecretKey) 290 291 // Publish 292 let published = 0 293 for (const relay of this.connections) { 294 try { 295 await relay.publish(event) 296 published++ 297 } catch (e) { 298 // Ignore 299 } 300 } 301 302 console.log(`[sovereign] Key sent via DM to ${published} relay(s)`) 303 console.log(`[sovereign] Recipient should save the key to ~/.sovereign/broadcast-key.json`) 304 return event.id 305 } 306 307 getKeyForExport() { 308 return { 309 key: this.sovereignKey.toString('hex'), 310 identifier: this.identifier, 311 nostrPubkey: this.nostrPublicKey 312 } 313 } 314 315 async importKey(keyHex, identifier = null) { 316 this.sovereignKey = Buffer.from(keyHex, 'hex') 317 if (identifier) { 318 this.identifier = identifier 319 } 320 321 fs.writeFileSync(KEY_FILE, JSON.stringify({ 322 key: this.sovereignKey.toString('hex'), 323 created: new Date().toISOString(), 324 identifier: this.identifier, 325 imported: true 326 }, null, 2)) 327 328 console.log('[sovereign] Key imported successfully') 329 } 330 331 async close() { 332 for (const relay of this.connections) { 333 try { 334 relay.close() 335 } catch (e) { 336 // Ignore 337 } 338 } 339 this.connections = [] 340 } 341 } 342 343 // CLI 344 async function main() { 345 const args = process.argv.slice(2) 346 const command = args[0] 347 348 if (command === '--help' || command === '-h' || !command) { 349 console.log(` 350 Sovereign Broadcast - Encrypted Public Nostr Events 351 352 Usage: 353 sovereign-broadcast init Initialize and show your key 354 sovereign-broadcast publish <type> <data> Publish encrypted event 355 sovereign-broadcast subscribe Watch for your broadcasts 356 sovereign-broadcast send-key <npub> Send key to npub via DM 357 sovereign-broadcast import-key <hex> Import a sovereign key 358 sovereign-broadcast room <invite> [name] Publish room invite 359 sovereign-broadcast context <text> Publish context text 360 361 Options: 362 --relays <urls> Comma-separated relay URLs 363 364 How it works: 365 1. Run 'init' to generate your sovereign key 366 2. Send the key to your phone via 'send-key <your-npub>' 367 3. On phone, save the key to ~/.sovereign/broadcast-key.json 368 4. Now any device with the key can publish/subscribe 369 370 All events are: 371 - Published publicly on Nostr 372 - Encrypted with AES-256-GCM 373 - Only decryptable with your sovereign key 374 - Tagged with your identifier for filtering 375 376 Examples: 377 # Initialize 378 sovereign-broadcast init 379 380 # Send key to your phone's Nostr identity 381 sovereign-broadcast send-key npub1abc... 382 383 # Publish a room invite (encrypted, public) 384 sovereign-broadcast room 94fb328... sovereign-os 385 386 # Subscribe to your broadcasts on another device 387 sovereign-broadcast subscribe 388 `) 389 process.exit(0) 390 } 391 392 const broadcast = new SovereignBroadcast() 393 await broadcast.init() 394 await broadcast.connect() 395 396 switch (command) { 397 case 'init': 398 console.log('') 399 console.log('Your sovereign broadcast is ready!') 400 console.log('') 401 console.log('Key (save this for other devices):') 402 console.log(` ${broadcast.sovereignKey.toString('hex')}`) 403 console.log('') 404 console.log('Identifier:') 405 console.log(` ${broadcast.identifier}`) 406 console.log('') 407 console.log('To set up another device:') 408 console.log(` sovereign-broadcast send-key <your-npub>`) 409 console.log('') 410 break 411 412 case 'publish': 413 const type = args[1] || 'message' 414 const data = args[2] || '{}' 415 await broadcast.publish({ type, data: JSON.parse(data) }) 416 break 417 418 case 'subscribe': 419 await broadcast.subscribe() 420 console.log('') 421 console.log('Listening for sovereign broadcasts...') 422 console.log('Press Ctrl+C to stop.') 423 process.stdin.resume() 424 break 425 426 case 'send-key': 427 const npub = args[1] 428 if (!npub) { 429 console.log('Usage: sovereign-broadcast send-key <npub>') 430 process.exit(1) 431 } 432 await broadcast.sendKeyViaDM(npub) 433 break 434 435 case 'import-key': 436 const keyHex = args[1] 437 const identifier = args[2] 438 if (!keyHex) { 439 console.log('Usage: sovereign-broadcast import-key <hex> [identifier]') 440 process.exit(1) 441 } 442 await broadcast.importKey(keyHex, identifier) 443 break 444 445 case 'room': 446 const inviteCode = args[1] 447 const roomName = args[2] 448 if (!inviteCode) { 449 console.log('Usage: sovereign-broadcast room <invite-code> [room-name]') 450 process.exit(1) 451 } 452 await broadcast.publishRoomInvite(inviteCode, roomName) 453 break 454 455 case 'context': 456 const text = args.slice(1).join(' ') 457 if (!text) { 458 console.log('Usage: sovereign-broadcast context <text>') 459 process.exit(1) 460 } 461 await broadcast.publishContext(text) 462 break 463 464 default: 465 console.log(`Unknown command: ${command}`) 466 console.log('Run with --help for usage') 467 process.exit(1) 468 } 469 470 // Close connections unless subscribing 471 if (command !== 'subscribe') { 472 await broadcast.close() 473 } 474 } 475 476 // Export for use as module 477 export { SovereignBroadcast, encrypt, decrypt } 478 479 // Run if called directly 480 const isMain = process.argv[1]?.endsWith('sovereign-broadcast.js') 481 if (isMain) { 482 main().catch(err => { 483 console.error('Error:', err.message) 484 process.exit(1) 485 }) 486 }