/ keet-cli / sovereign-broadcast.js
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  }