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 }