/ keet-cli / inter-mesh.js
inter-mesh.js
  1  #!/usr/bin/env node
  2  /**
  3   * Inter-Mesh Protocol - Cross-Operator Communication
  4   *
  5   * Enables communication between separate sovereign meshes.
  6   * Each operator maintains sovereignty while connecting to others.
  7   *
  8   * Phase 1: Direct Channels (1:1 encrypted messaging)
  9   */
 10  
 11  import { getPublicKey, nip44, nip19, finalizeEvent, verifyEvent } from 'nostr-tools'
 12  import { Relay } from 'nostr-tools/relay'
 13  import * as secp256k1 from '@noble/secp256k1'
 14  import { sha256 } from '@noble/hashes/sha2.js'
 15  import { pbkdf2 } from '@noble/hashes/pbkdf2.js'
 16  import { randomBytes } from 'crypto'
 17  import fs from 'fs'
 18  import path from 'path'
 19  import os from 'os'
 20  
 21  // ═══════════════════════════════════════════════════════════════════════════
 22  // CONFIGURATION
 23  // ═══════════════════════════════════════════════════════════════════════════
 24  const CONFIG_DIR = process.env.SOVEREIGN_HOME || path.join(os.homedir(), '.sovereign')
 25  const TRUST_FILE = path.join(CONFIG_DIR, 'trust.json')
 26  const MESSAGES_DIR = path.join(CONFIG_DIR, 'messages')
 27  
 28  // Nostr relays for inter-mesh communication
 29  const RELAYS = [
 30    'wss://relay.damus.io',
 31    'wss://nos.lol',
 32    'wss://relay.nostr.band'
 33  ]
 34  
 35  // Event kinds
 36  const KIND_CONNECT_REQUEST = 20001  // Ephemeral
 37  const KIND_CONNECT_ACCEPT = 20002   // Ephemeral
 38  const KIND_DIRECT_MESSAGE = 20003   // Ephemeral encrypted
 39  
 40  // Trust levels
 41  export const TrustLevel = {
 42    UNKNOWN: 0,
 43    CONNECTED: 1,
 44    TRUSTED: 2,
 45    BONDED: 3
 46  }
 47  
 48  // ═══════════════════════════════════════════════════════════════════════════
 49  // TRUST STORAGE
 50  // ═══════════════════════════════════════════════════════════════════════════
 51  
 52  /**
 53   * Load trust data from file
 54   */
 55  export function loadTrust() {
 56    fs.mkdirSync(CONFIG_DIR, { recursive: true })
 57  
 58    if (!fs.existsSync(TRUST_FILE)) {
 59      return {
 60        connections: {},
 61        pending: {},
 62        blocked: []
 63      }
 64    }
 65  
 66    try {
 67      return JSON.parse(fs.readFileSync(TRUST_FILE, 'utf8'))
 68    } catch {
 69      return { connections: {}, pending: {}, blocked: [] }
 70    }
 71  }
 72  
 73  /**
 74   * Save trust data to file
 75   */
 76  export function saveTrust(trust) {
 77    fs.mkdirSync(CONFIG_DIR, { recursive: true })
 78    fs.writeFileSync(TRUST_FILE, JSON.stringify(trust, null, 2))
 79  }
 80  
 81  /**
 82   * Add a connection
 83   */
 84  export function addConnection(pubkey, name, level = TrustLevel.CONNECTED) {
 85    const trust = loadTrust()
 86    trust.connections[pubkey] = {
 87      name: name || pubkey.slice(0, 8),
 88      level,
 89      since: new Date().toISOString(),
 90      lastSeen: null
 91    }
 92    // Remove from pending if it was there
 93    delete trust.pending[pubkey]
 94    saveTrust(trust)
 95    return trust.connections[pubkey]
 96  }
 97  
 98  /**
 99   * Add a pending connection request
100   */
101  export function addPending(pubkey, name, direction) {
102    const trust = loadTrust()
103    trust.pending[pubkey] = {
104      name: name || pubkey.slice(0, 8),
105      direction, // 'incoming' or 'outgoing'
106      timestamp: new Date().toISOString()
107    }
108    saveTrust(trust)
109  }
110  
111  /**
112   * Get connection by name or pubkey
113   */
114  export function getConnection(nameOrPubkey) {
115    const trust = loadTrust()
116  
117    // Try direct pubkey lookup
118    if (trust.connections[nameOrPubkey]) {
119      return { pubkey: nameOrPubkey, ...trust.connections[nameOrPubkey] }
120    }
121  
122    // Try name lookup
123    for (const [pubkey, conn] of Object.entries(trust.connections)) {
124      if (conn.name.toLowerCase() === nameOrPubkey.toLowerCase()) {
125        return { pubkey, ...conn }
126      }
127    }
128  
129    return null
130  }
131  
132  /**
133   * List all connections
134   */
135  export function listConnections() {
136    const trust = loadTrust()
137    return Object.entries(trust.connections).map(([pubkey, conn]) => ({
138      pubkey,
139      ...conn
140    }))
141  }
142  
143  /**
144   * List pending requests
145   */
146  export function listPending() {
147    const trust = loadTrust()
148    return Object.entries(trust.pending).map(([pubkey, req]) => ({
149      pubkey,
150      ...req
151    }))
152  }
153  
154  /**
155   * Set trust level for a connection
156   */
157  export function setTrustLevel(pubkey, level) {
158    const trust = loadTrust()
159    if (trust.connections[pubkey]) {
160      trust.connections[pubkey].level = level
161      saveTrust(trust)
162      return true
163    }
164    return false
165  }
166  
167  // ═══════════════════════════════════════════════════════════════════════════
168  // IDENTITY
169  // ═══════════════════════════════════════════════════════════════════════════
170  
171  /**
172   * Derive Nostr keypair from mnemonic
173   */
174  export function deriveNostrKeys(mnemonic) {
175    const normalized = mnemonic.toLowerCase().trim().replace(/\s+/g, ' ')
176    const mnemonicBytes = new TextEncoder().encode(normalized)
177  
178    // Derive master seed using PBKDF2
179    const masterSeed = pbkdf2(sha256, mnemonicBytes,
180      new TextEncoder().encode('sovereign-mesh'),
181      { c: 2048, dkLen: 64 }
182    )
183  
184    // Derive Nostr secret key
185    const secretKey = pbkdf2(sha256, masterSeed,
186      new TextEncoder().encode('nostr'),
187      { c: 1, dkLen: 32 }
188    )
189  
190    const secretKeyHex = Buffer.from(secretKey).toString('hex')
191    const publicKey = getPublicKey(secretKeyHex)
192  
193    return {
194      secretKey: secretKeyHex,
195      publicKey
196    }
197  }
198  
199  /**
200   * Format mesh address from pubkey
201   */
202  export function formatMeshAddress(pubkey) {
203    return `sovereign:${pubkey}`
204  }
205  
206  /**
207   * Parse mesh address to pubkey
208   */
209  export function parseMeshAddress(address) {
210    if (address.startsWith('sovereign:')) {
211      return address.slice(10)
212    }
213    // Assume it's a raw pubkey
214    return address
215  }
216  
217  // ═══════════════════════════════════════════════════════════════════════════
218  // ENCRYPTION (NIP-44)
219  // ═══════════════════════════════════════════════════════════════════════════
220  
221  /**
222   * Encrypt a message for a recipient
223   */
224  export function encryptMessage(secretKey, recipientPubkey, plaintext) {
225    try {
226      // nip44.encrypt expects Uint8Array secret key
227      const secretKeyBytes = hexToBytes(secretKey)
228      return nip44.encrypt(plaintext, nip44.getConversationKey(secretKeyBytes, recipientPubkey))
229    } catch (err) {
230      // Fallback: simple XOR encryption if NIP-44 fails
231      console.warn('[inter-mesh] NIP-44 encryption failed, using fallback')
232      return fallbackEncrypt(secretKey, recipientPubkey, plaintext)
233    }
234  }
235  
236  /**
237   * Decrypt a message from a sender
238   */
239  export function decryptMessage(secretKey, senderPubkey, ciphertext) {
240    try {
241      const secretKeyBytes = hexToBytes(secretKey)
242      return nip44.decrypt(ciphertext, nip44.getConversationKey(secretKeyBytes, senderPubkey))
243    } catch (err) {
244      // Fallback decryption
245      return fallbackDecrypt(secretKey, senderPubkey, ciphertext)
246    }
247  }
248  
249  // Fallback encryption using shared secret
250  function fallbackEncrypt(secretKey, recipientPubkey, plaintext) {
251    const sharedSecret = computeSharedSecret(secretKey, recipientPubkey)
252    const nonce = randomBytes(16)
253    const key = sha256(Buffer.concat([sharedSecret, nonce]))
254  
255    const plaintextBytes = Buffer.from(plaintext, 'utf8')
256    const cipherBytes = Buffer.alloc(plaintextBytes.length)
257  
258    for (let i = 0; i < plaintextBytes.length; i++) {
259      cipherBytes[i] = plaintextBytes[i] ^ key[i % 32]
260    }
261  
262    return nonce.toString('base64') + ':' + cipherBytes.toString('base64')
263  }
264  
265  function fallbackDecrypt(secretKey, senderPubkey, ciphertext) {
266    const [nonceB64, cipherB64] = ciphertext.split(':')
267    const nonce = Buffer.from(nonceB64, 'base64')
268    const cipherBytes = Buffer.from(cipherB64, 'base64')
269  
270    const sharedSecret = computeSharedSecret(secretKey, senderPubkey)
271    const key = sha256(Buffer.concat([sharedSecret, nonce]))
272  
273    const plaintextBytes = Buffer.alloc(cipherBytes.length)
274    for (let i = 0; i < cipherBytes.length; i++) {
275      plaintextBytes[i] = cipherBytes[i] ^ key[i % 32]
276    }
277  
278    return plaintextBytes.toString('utf8')
279  }
280  
281  function computeSharedSecret(secretKey, pubkey) {
282    // Compute ECDH shared secret
283    const secretKeyBytes = hexToBytes(secretKey)
284    const pubkeyBytes = hexToBytes('02' + pubkey) // Compressed pubkey
285  
286    try {
287      const shared = secp256k1.getSharedSecret(secretKeyBytes, pubkeyBytes)
288      return Buffer.from(sha256(shared.slice(1, 33)))
289    } catch {
290      // If ECDH fails, use hash of concatenated keys
291      return Buffer.from(sha256(secretKey + pubkey))
292    }
293  }
294  
295  function hexToBytes(hex) {
296    const bytes = new Uint8Array(hex.length / 2)
297    for (let i = 0; i < hex.length; i += 2) {
298      bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16)
299    }
300    return bytes
301  }
302  
303  // ═══════════════════════════════════════════════════════════════════════════
304  // NOSTR MESSAGING
305  // ═══════════════════════════════════════════════════════════════════════════
306  
307  /**
308   * Connect to relays and return array of relay connections
309   */
310  export async function connectToRelays(relays = RELAYS) {
311    const connections = []
312  
313    for (const url of relays) {
314      try {
315        const relay = await Relay.connect(url)
316        connections.push(relay)
317      } catch (err) {
318        console.warn(`[inter-mesh] Failed to connect to ${url}`)
319      }
320    }
321  
322    return connections
323  }
324  
325  /**
326   * Send a connection request to another operator
327   */
328  export async function sendConnectRequest(secretKey, myPubkey, targetPubkey, myName) {
329    const relays = await connectToRelays()
330    if (relays.length === 0) {
331      throw new Error('Could not connect to any relays')
332    }
333  
334    const content = encryptMessage(secretKey, targetPubkey, JSON.stringify({
335      type: 'connect_request',
336      name: myName,
337      timestamp: Date.now()
338    }))
339  
340    const event = finalizeEvent({
341      kind: KIND_CONNECT_REQUEST,
342      created_at: Math.floor(Date.now() / 1000),
343      tags: [['p', targetPubkey]],
344      content
345    }, hexToBytes(secretKey))
346  
347    // Publish to all relays
348    const results = await Promise.allSettled(
349      relays.map(relay => relay.publish(event))
350    )
351  
352    // Close relays
353    relays.forEach(r => r.close())
354  
355    const succeeded = results.filter(r => r.status === 'fulfilled').length
356    return succeeded > 0
357  }
358  
359  /**
360   * Send a connection accept response
361   */
362  export async function sendConnectAccept(secretKey, myPubkey, targetPubkey, myName) {
363    const relays = await connectToRelays()
364    if (relays.length === 0) {
365      throw new Error('Could not connect to any relays')
366    }
367  
368    const content = encryptMessage(secretKey, targetPubkey, JSON.stringify({
369      type: 'connect_accept',
370      name: myName,
371      timestamp: Date.now()
372    }))
373  
374    const event = finalizeEvent({
375      kind: KIND_CONNECT_ACCEPT,
376      created_at: Math.floor(Date.now() / 1000),
377      tags: [['p', targetPubkey]],
378      content
379    }, hexToBytes(secretKey))
380  
381    const results = await Promise.allSettled(
382      relays.map(relay => relay.publish(event))
383    )
384  
385    relays.forEach(r => r.close())
386  
387    const succeeded = results.filter(r => r.status === 'fulfilled').length
388    return succeeded > 0
389  }
390  
391  /**
392   * Send a direct message to a connected operator
393   */
394  export async function sendDirectMessage(secretKey, myPubkey, targetPubkey, message) {
395    const relays = await connectToRelays()
396    if (relays.length === 0) {
397      throw new Error('Could not connect to any relays')
398    }
399  
400    const content = encryptMessage(secretKey, targetPubkey, JSON.stringify({
401      type: 'direct_message',
402      text: message,
403      timestamp: Date.now()
404    }))
405  
406    const event = finalizeEvent({
407      kind: KIND_DIRECT_MESSAGE,
408      created_at: Math.floor(Date.now() / 1000),
409      tags: [['p', targetPubkey]],
410      content
411    }, hexToBytes(secretKey))
412  
413    const results = await Promise.allSettled(
414      relays.map(relay => relay.publish(event))
415    )
416  
417    relays.forEach(r => r.close())
418  
419    const succeeded = results.filter(r => r.status === 'fulfilled').length
420    return succeeded > 0
421  }
422  
423  /**
424   * Listen for incoming messages (runs until cancelled)
425   */
426  export async function listenForMessages(secretKey, myPubkey, onMessage) {
427    const relays = await connectToRelays()
428    if (relays.length === 0) {
429      throw new Error('Could not connect to any relays')
430    }
431  
432    const since = Math.floor(Date.now() / 1000) - 3600 // Last hour
433  
434    for (const relay of relays) {
435      relay.subscribe([
436        {
437          kinds: [KIND_CONNECT_REQUEST, KIND_CONNECT_ACCEPT, KIND_DIRECT_MESSAGE],
438          '#p': [myPubkey],
439          since
440        }
441      ], {
442        onevent(event) {
443          try {
444            if (!verifyEvent(event)) return
445  
446            const senderPubkey = event.pubkey
447            const decrypted = decryptMessage(secretKey, senderPubkey, event.content)
448            const payload = JSON.parse(decrypted)
449  
450            onMessage({
451              kind: event.kind,
452              from: senderPubkey,
453              payload,
454              timestamp: event.created_at * 1000
455            })
456          } catch (err) {
457            // Ignore messages we can't decrypt (not for us)
458          }
459        }
460      })
461    }
462  
463    // Return cleanup function
464    return () => {
465      relays.forEach(r => r.close())
466    }
467  }
468  
469  /**
470   * Check for pending messages (one-shot)
471   */
472  export async function checkMessages(secretKey, myPubkey, since = null) {
473    const relays = await connectToRelays()
474    if (relays.length === 0) {
475      return []
476    }
477  
478    const sinceTimestamp = since ? Math.floor(since / 1000) : Math.floor(Date.now() / 1000) - 86400
479    const messages = []
480  
481    await Promise.all(relays.map(relay =>
482      new Promise((resolve) => {
483        const sub = relay.subscribe([
484          {
485            kinds: [KIND_CONNECT_REQUEST, KIND_CONNECT_ACCEPT, KIND_DIRECT_MESSAGE],
486            '#p': [myPubkey],
487            since: sinceTimestamp
488          }
489        ], {
490          onevent(event) {
491            try {
492              if (!verifyEvent(event)) return
493  
494              const senderPubkey = event.pubkey
495              const decrypted = decryptMessage(secretKey, senderPubkey, event.content)
496              const payload = JSON.parse(decrypted)
497  
498              messages.push({
499                kind: event.kind,
500                from: senderPubkey,
501                payload,
502                timestamp: event.created_at * 1000
503              })
504            } catch {
505              // Ignore
506            }
507          },
508          oneose() {
509            sub.close()
510            resolve()
511          }
512        })
513  
514        // Timeout after 5 seconds
515        setTimeout(() => {
516          sub.close()
517          resolve()
518        }, 5000)
519      })
520    ))
521  
522    relays.forEach(r => r.close())
523  
524    // Deduplicate by timestamp + sender
525    const seen = new Set()
526    return messages.filter(m => {
527      const key = `${m.from}:${m.timestamp}`
528      if (seen.has(key)) return false
529      seen.add(key)
530      return true
531    }).sort((a, b) => a.timestamp - b.timestamp)
532  }
533  
534  // ═══════════════════════════════════════════════════════════════════════════
535  // MESSAGE STORAGE
536  // ═══════════════════════════════════════════════════════════════════════════
537  
538  /**
539   * Store a message locally
540   */
541  export function storeMessage(message) {
542    fs.mkdirSync(MESSAGES_DIR, { recursive: true })
543  
544    const date = new Date(message.timestamp).toISOString().split('T')[0]
545    const file = path.join(MESSAGES_DIR, `${date}.json`)
546  
547    let messages = []
548    if (fs.existsSync(file)) {
549      messages = JSON.parse(fs.readFileSync(file, 'utf8'))
550    }
551  
552    messages.push(message)
553    fs.writeFileSync(file, JSON.stringify(messages, null, 2))
554  }
555  
556  /**
557   * Get recent messages
558   */
559  export function getRecentMessages(days = 7) {
560    fs.mkdirSync(MESSAGES_DIR, { recursive: true })
561  
562    const messages = []
563    const now = Date.now()
564  
565    for (let i = 0; i < days; i++) {
566      const date = new Date(now - i * 86400000).toISOString().split('T')[0]
567      const file = path.join(MESSAGES_DIR, `${date}.json`)
568  
569      if (fs.existsSync(file)) {
570        const dayMessages = JSON.parse(fs.readFileSync(file, 'utf8'))
571        messages.push(...dayMessages)
572      }
573    }
574  
575    return messages.sort((a, b) => b.timestamp - a.timestamp)
576  }
577  
578  // ═══════════════════════════════════════════════════════════════════════════
579  // EXPORTS
580  // ═══════════════════════════════════════════════════════════════════════════
581  
582  export {
583    RELAYS,
584    KIND_CONNECT_REQUEST,
585    KIND_CONNECT_ACCEPT,
586    KIND_DIRECT_MESSAGE
587  }