#!/usr/bin/env node /** * 光湖桥 · 独立参考实现 · guanghu-bridge/v1 * * 定位:光湖桥通信层的独立运行时 + 协议参考实现(P3 最小闭环)。 * 不依赖任何桌面壳,不依赖第三方包,仅用 Node 内置模块。 * * 分层(对齐 HLP-CURRENT-ARCH-001): * 传输层 TCP + NDJSON 帧(可选 TLS 后续接) * 会话层 X25519 密钥交换 + HKDF + AES-256-GCM,中转只见密文 * 消息层 信封 schema=guanghu.guanghu-bridge-message/v1 * 事实层 中转节点将信封指纹沉淀 facts/*.ndjson(耐久层入口,Git 可挂) * * 运行方式: * node bridge-node.mjs relay --port 9701 --state-dir * node bridge-node.mjs node --id <节点号> --relay host:port --state-dir * node bridge-node.mjs selftest [--workdir ] */ import net from 'node:net'; import fs from 'node:fs'; import path from 'node:path'; import readline from 'node:readline'; import process from 'node:process'; import { generateKeyPairSync, diffieHellman, hkdfSync, randomBytes, createCipheriv, createDecipheriv, createHash, createHmac, createPublicKey, createPrivateKey, randomUUID, } from 'node:crypto'; export const SCHEMA = 'guanghu.guanghu-bridge-message/v1'; const MAX_LINE = 1024 * 1024; // ────────────────────────── 密钥与加解密 ────────────────────────── function ensureDir(p) { fs.mkdirSync(p, { recursive: true }); return p; } function loadOrCreateIdentity(nodeId, stateDir) { const dir = ensureDir(path.join(stateDir, 'identities')); const file = path.join(dir, `${nodeId}.key.json`); if (fs.existsSync(file)) { const j = JSON.parse(fs.readFileSync(file, 'utf8')); return { nodeId, privateKey: createPrivateKey({ key: Buffer.from(j.priv_der_b64, 'base64'), format: 'der', type: 'pkcs8' }), pubB64: j.pub_der_b64, }; } const { publicKey, privateKey } = generateKeyPairSync('x25519'); const pubDer = publicKey.export({ type: 'spki', format: 'der' }); const privDer = privateKey.export({ type: 'pkcs8', format: 'der' }); fs.writeFileSync(file, JSON.stringify({ schema: 'guanghu.guanghu-bridge-identity/v1', node_id: nodeId, pub_der_b64: pubDer.toString('base64'), priv_der_b64: privDer.toString('base64'), created_at: new Date().toISOString(), }, null, 2)); return { nodeId, privateKey, pubB64: pubDer.toString('base64') }; } function importPub(b64) { return createPublicKey({ key: Buffer.from(b64, 'base64'), format: 'der', type: 'spki' }); } function sharedIkm(privKey, peerPubB64) { return diffieHellman({ privateKey: privKey, publicKey: importPub(peerPubB64) }); } function deriveKeys(ikm) { return { msg: Buffer.from(hkdfSync('sha256', ikm, 'guanghu-bridge-v1', 'msg', 32)), receipt: Buffer.from(hkdfSync('sha256', ikm, 'guanghu-bridge-v1', 'receipt', 32)), }; } function seal(key, plaintext, aad) { const iv = randomBytes(12); const c = createCipheriv('aes-256-gcm', key, iv); c.setAAD(Buffer.from(aad)); const ct = Buffer.concat([c.update(plaintext, 'utf8'), c.final()]); return { iv_b64: iv.toString('base64'), ct_b64: ct.toString('base64'), tag_b64: c.getAuthTag().toString('base64') }; } function open(key, payload, aad) { const d = createDecipheriv('aes-256-gcm', key, Buffer.from(payload.iv_b64, 'base64')); d.setAAD(Buffer.from(aad)); d.setAuthTag(Buffer.from(payload.tag_b64, 'base64')); return Buffer.concat([d.update(Buffer.from(payload.ct_b64, 'base64')), d.final()]).toString('utf8'); } function signReceipt(key, msgId, from, to, ts) { return createHmac('sha256', key).update(`${msgId}|${from}|${to}|${ts}`).digest('base64'); } // ────────────────────────── NDJSON 帧 ────────────────────────── function attachFraming(socket, onLine) { let buf = ''; socket.on('data', (chunk) => { buf += chunk.toString('utf8'); let idx; while ((idx = buf.indexOf('\n')) >= 0) { const line = buf.slice(0, idx).trim(); buf = buf.slice(idx + 1); if (!line) continue; if (line.length > MAX_LINE) { socket.destroy(); return; } try { onLine(JSON.parse(line)); } catch { /* 坏帧丢弃,不断连 */ } } }); return (obj) => { if (!socket.destroyed) socket.write(JSON.stringify(obj) + '\n'); }; } // ────────────────────────── 中转(会合点)────────────────────────── export function startRelay({ port = 0, stateDir, log = () => {} }) { ensureDir(stateDir); const offlineDir = ensureDir(path.join(stateDir, 'offline')); const factsDir = ensureDir(path.join(stateDir, 'facts')); const registryFile = path.join(stateDir, 'relay-registry.json'); const registry = fs.existsSync(registryFile) ? JSON.parse(fs.readFileSync(registryFile, 'utf8')) : {}; const online = new Map(); // node_id -> send() // 会合点自身身份:用户自有节点的公钥可信任,用于离线信封混合加密 const relayIdentity = loadOrCreateIdentity('__RELAY__', stateDir); const saveRegistry = () => fs.writeFileSync(registryFile, JSON.stringify(registry, null, 2)); const factsFile = () => path.join(factsDir, `${new Date().toISOString().slice(0, 10)}.ndjson`); function sinkFact(env) { // 耐久事实层入口:只沉淀信封指纹与元数据,不落密文副本 fs.appendFileSync(factsFile(), JSON.stringify({ schema: SCHEMA, kind: 'FACT_SINK', ts: new Date().toISOString(), msg_id: env.id, from: env.from, to: env.to, envelope_sha256: createHash('sha256').update(JSON.stringify(env)).digest('hex'), }) + '\n'); } function reencryptForRecipient(env) { // 离线信封原以会合点公钥加密;收件人上线后转成收件人公钥加密再投递, // 中转仅在信封生命周期内短暂见到明文 if (!env.hybrid) return env; const entry = registry[env.to]; if (!entry || !entry.pub) return null; try { const inKeys = deriveKeys(sharedIkm(relayIdentity.privateKey, env.from_pub)); const text = open(inKeys.msg, env.payload, env.id); const outKeys = deriveKeys(sharedIkm(relayIdentity.privateKey, entry.pub)); const converted = { ...env, hybrid: false }; delete converted.from_pub; converted.sealed_by = 'RELAY'; // 收件人据此选解密钥 converted.relay_pub = relayIdentity.pubB64; converted.payload = seal(outKeys.msg, text, env.id); return converted; } catch (e) { log(`relay: 离线信封 ${env.id} 转加密失败(${e.message})`); return null; } } function deliver(env) { const send = online.get(env.to); if (send) { const finalEnv = env.hybrid ? reencryptForRecipient(env) : env; if (!finalEnv) { fs.appendFileSync(path.join(offlineDir, `${env.to}.ndjson`), JSON.stringify(env) + '\n'); return false; } send(finalEnv); log(`relay: 投递 ${env.id} → ${env.to}(在线${env.hybrid ? '·转加密' : ''})`); return true; } fs.appendFileSync(path.join(offlineDir, `${env.to}.ndjson`), JSON.stringify(env) + '\n'); log(`relay: ${env.to} 离线,${env.id} 入离线队列`); return false; } function flushOffline(nodeId) { const file = path.join(offlineDir, `${nodeId}.ndjson`); if (!fs.existsSync(file)) return 0; const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean); fs.unlinkSync(file); let n = 0; for (const line of lines) { try { if (deliver(JSON.parse(line))) n++; } catch { /* 坏行跳过 */ } } return n; } const server = net.createServer((socket) => { let peer = null; const send = attachFraming(socket, (msg) => { if (msg.type === 'HELLO') { peer = msg.node_id; registry[peer] = { pub: msg.pub, last_seen: new Date().toISOString() }; saveRegistry(); online.set(peer, send); send({ schema: SCHEMA, type: 'HELLO_ACK', node_id: peer }); const n = flushOffline(peer); log(`relay: 节点 ${peer} 接入${n ? `,补发 ${n} 条离线消息` : ''}`); return; } if (!peer) return; if (msg.type === 'SEND' && msg.envelope) { const env = msg.envelope; sinkFact(env); // 实时是光,事实层是湖底 deliver(env); send({ schema: SCHEMA, type: 'RELAY_ACK', id: env.id }); return; } if (msg.type === 'RECEIPT' || msg.type === 'MSG') { // RECEIPT 与转发类信封同样按 to 路由 deliver(msg); return; } if (msg.type === 'GET_PUB') { // 收件人未登记时回发会合点公钥(混合加密降级),发送端不阻塞 const entry = registry[msg.to]; send({ schema: SCHEMA, type: 'PUB', node_id: msg.to, pub: entry ? entry.pub : relayIdentity.pubB64, hybrid: !entry, }); return; } }); socket.on('close', () => { if (peer && online.get(peer) === send) online.delete(peer); }); }); return new Promise((resolve) => { server.listen(port, '127.0.0.1', () => { const actual = server.address().port; log(`relay: 会合点启动 127.0.0.1:${actual}`); resolve({ port: actual, stateDir, registry, relayPubB64: relayIdentity.pubB64, onlineNodes: () => [...online.keys()], close: () => new Promise((r) => server.close(r)), }); }); }); } // ────────────────────────── 节点 ────────────────────────── export function startNode({ id, relayHost, relayPort, stateDir, log = () => {}, onMessage = () => {} }) { ensureDir(stateDir); const identity = loadOrCreateIdentity(id, stateDir); const receiptsDir = ensureDir(path.join(stateDir, 'receipts', id)); const pubCache = new Map(); // node_id -> {pub, hybrid} const inbox = []; let sendFn = null; const socket = net.connect(relayPort, relayHost); async function ensurePeerPub(peerId) { // 收到离线补发/陌生对端消息时,缓存里可能只有混合占位公钥,先拉真实公钥 let entry = pubCache.get(peerId); if (entry && !entry.hybrid) return entry; sendFn({ schema: SCHEMA, type: 'GET_PUB', to: peerId }); for (let i = 0; i < 100; i++) { await new Promise((r) => setTimeout(r, 20)); entry = pubCache.get(peerId); if (entry && !entry.hybrid) return entry; } return null; } sendFn = attachFraming(socket, (msg) => { if (msg.type === 'HELLO_ACK') { log(`node ${id}: 已接入会合点`); return; } if (msg.type === 'PUB') { if (msg.pub) pubCache.set(msg.node_id, { pub: msg.pub, hybrid: !!msg.hybrid }); return; } if (msg.type === 'MSG') { void (async () => { const senderEntry = await ensurePeerPub(msg.from); // sealed_by=RELAY:离线补发信封由会合点转加密,用会合点公钥解密; // 其余用发件人公钥解密 const decPub = msg.sealed_by === 'RELAY' ? msg.relay_pub : (senderEntry && senderEntry.pub); let text = null; if (decPub) { try { const decKeys = deriveKeys(sharedIkm(identity.privateKey, decPub)); text = open(decKeys.msg, msg.payload, msg.id); // 回送达回执:签名键始终用与发件人的共享密钥,保证发件人可验 if (senderEntry) { const rKeys = deriveKeys(sharedIkm(identity.privateKey, senderEntry.pub)); const ts = new Date().toISOString(); sendFn({ schema: SCHEMA, type: 'RECEIPT', id: randomUUID(), msg_id: msg.id, from: id, to: msg.from, ts, sig: signReceipt(rKeys.receipt, msg.id, id, msg.from, ts), }); } } catch (e) { log(`node ${id}: 解密失败 ${msg.id}(${e.message})`); } } inbox.push({ msg_id: msg.id, from: msg.from, text, ts: msg.ts }); onMessage({ msg_id: msg.id, from: msg.from, text }); })(); return; } if (msg.type === 'RECEIPT') { // 校验并落回执 void (async () => { const entry = await ensurePeerPub(msg.from); let verified = false; if (entry) { const keys = deriveKeys(sharedIkm(identity.privateKey, entry.pub)); verified = signReceipt(keys.receipt, msg.msg_id, msg.from, msg.to, msg.ts) === msg.sig; } fs.writeFileSync(path.join(receiptsDir, `${msg.msg_id}.json`), JSON.stringify({ schema: 'guanghu.guanghu-bridge-delivery-receipt/v1', msg_id: msg.msg_id, from: msg.from, to: msg.to, ts: msg.ts, signature_verified: verified, }, null, 2)); log(`node ${id}: 收到送达回执 ${msg.msg_id}(签名${verified ? '验证通过' : '未通过'})`); })(); return; } }); socket.on('connect', () => { sendFn({ schema: SCHEMA, type: 'HELLO', node_id: id, pub: identity.pubB64 }); }); async function resolvePub(to) { if (pubCache.has(to)) return pubCache.get(to); sendFn({ schema: SCHEMA, type: 'GET_PUB', to }); for (let i = 0; i < 50 && !pubCache.has(to); i++) await new Promise((r) => setTimeout(r, 20)); return pubCache.get(to) || null; } async function sendText(to, text) { const entry = await resolvePub(to); if (!entry) throw new Error(`会合点未应答 ${to} 的公钥解析`); const keys = deriveKeys(sharedIkm(identity.privateKey, entry.pub)); const env = { schema: SCHEMA, type: 'MSG', id: randomUUID(), from: id, to, ts: new Date().toISOString(), payload: null, // hybrid=true:收件人未登记,信封暂以会合点公钥加密,投递前转加密 hybrid: entry.hybrid ? true : undefined, from_pub: entry.hybrid ? identity.pubB64 : undefined, }; env.payload = seal(keys.msg, text, env.id); // aad 绑定信封 id,防篡改 sendFn({ schema: SCHEMA, type: 'SEND', envelope: env }); log(`node ${id}: 发出 ${env.id} → ${to}${entry.hybrid ? '(离线·混合加密)' : ''}`); return env.id; } return { id, inbox, sendText, receiptsDir, close: () => new Promise((r) => { socket.end(); socket.on('close', r); setTimeout(r, 200); }), }; } // ────────────────────────── 自检 ────────────────────────── export async function selftest(workdir) { const logs = []; const log = (s) => logs.push(s); const root = ensureDir(workdir); const relay = await startRelay({ port: 0, stateDir: path.join(root, 'relay'), log }); const nodeA = await startNode({ id: 'NODE-A', relayHost: '127.0.0.1', relayPort: relay.port, stateDir: path.join(root, 'node-a'), log, onMessage: () => {}, }); await new Promise((r) => setTimeout(r, 150)); // 场景 1:B 离线时 A 发消息 → 离线队列 const m1 = await nodeA.sendText('NODE-B', '冰朔,这是离线补发的第一条。'); await new Promise((r) => setTimeout(r, 150)); // 场景 2:B 接入 → 补发 → 解密 → 回执回到 A const received = []; const nodeB = await startNode({ id: 'NODE-B', relayHost: '127.0.0.1', relayPort: relay.port, stateDir: path.join(root, 'node-b'), log, onMessage: (m) => received.push(m), }); await new Promise((r) => setTimeout(r, 400)); // 场景 3:双在线直发 const m2 = await nodeA.sendText('NODE-B', '第二条,双在线直发。'); await new Promise((r) => setTimeout(r, 400)); // 校验 const facts = fs.readdirSync(path.join(root, 'relay', 'facts')).length > 0; const receiptFiles = fs.readdirSync(path.join(root, 'node-a', 'receipts', 'NODE-A')); const ok = received.length === 2 && received[0].text === '冰朔,这是离线补发的第一条。' && received[1].text === '第二条,双在线直发。' && facts && receiptFiles.length === 2; await nodeA.close(); await nodeB.close(); await relay.close(); const report = { schema: SCHEMA, kind: 'SELFTEST_REPORT', ts: new Date().toISOString(), result: ok ? 'PASS' : 'FAIL', checks: { offline_queue_and_redelivery: received[0]?.text === '冰朔,这是离线补发的第一条。', online_direct_delivery: received[1]?.text === '第二条,双在线直发。', e2e_decrypt_ok: received.every((r) => r.text !== null), fact_sink_written: facts, delivery_receipts_signed: receiptFiles.length === 2, }, msg_ids: [m1, m2], }; fs.writeFileSync(path.join(root, 'SELFTEST-REPORT.json'), JSON.stringify(report, null, 2)); return report; } // ────────────────────────── stdio 桥接 ────────────────────────── function socketStdioBridge(node, emit) { // 回执事件:监听回执目录新增文件(简化:由 onMessage 之外补一层轮询不优雅, // 改为包一层:直接读 node 内部不可行,用定时器扫描回执目录) const seen = new Set(); const scan = () => { try { for (const f of fs.readdirSync(node.receiptsDir)) { if (seen.has(f)) continue; seen.add(f); try { const r = JSON.parse(fs.readFileSync(path.join(node.receiptsDir, f), 'utf8')); emit({ event: 'receipt', msg_id: r.msg_id, from: r.from, verified: r.signature_verified }); } catch { /* 半写入文件下轮再读 */ } } } catch { /* 目录尚未建立 */ } }; const timer = setInterval(scan, 300); const rl = readline.createInterface({ input: process.stdin }); rl.on('line', async (line) => { let cmd; try { cmd = JSON.parse(line); } catch { return; } if (cmd.cmd === 'send' && cmd.to && typeof cmd.text === 'string') { try { const msgId = await node.sendText(cmd.to, cmd.text); emit({ event: 'sent', msg_id: msgId, to: cmd.to }); } catch (e) { emit({ event: 'error', message: e.message }); } } else if (cmd.cmd === 'quit') { clearInterval(timer); process.exit(0); } }); rl.on('close', () => { clearInterval(timer); process.exit(0); }); emit({ event: 'connected', node_id: node.id }); } // ────────────────────────── CLI ────────────────────────── function parseArgs(argv) { const args = {}; for (let i = 0; i < argv.length; i++) { if (!argv[i].startsWith('--')) continue; const key = argv[i].slice(2); if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) { args[key] = argv[i + 1]; i++; } else args[key] = true; // 无值开关(如 --stdio) } return args; } const [mode, ...rest] = process.argv.slice(2); if (mode === 'relay') { const a = parseArgs(rest); startRelay({ port: Number(a.port || 9701), stateDir: a['state-dir'] || './bridge-state/relay', log: console.log }); } else if (mode === 'node') { const a = parseArgs(rest); const [host, port] = (a.relay || '127.0.0.1:9701').split(':'); const stdio = process.argv.includes('--stdio'); const emit = (obj) => { if (stdio) process.stdout.write(JSON.stringify(obj) + '\n'); }; const node = startNode({ id: a.id, relayHost: host, relayPort: Number(port), stateDir: a['state-dir'] || `./bridge-state/${a.id}`, log: stdio ? ((s) => console.error(s)) : console.log, onMessage: (m) => { if (stdio) emit({ event: 'message', msg_id: m.msg_id, from: m.from, text: m.text }); else console.log(`〔收到 ${m.from}〕${m.text}`); }, }); if (stdio) { // stdio JSON 模式:供 Electron/Tauri 壳以子进程集成(日志走 stderr) socketStdioBridge(node, emit); } else { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.on('line', async (line) => { const mm = line.match(/^send\s+(\S+)\s+(.+)$/); if (mm) { try { await node.sendText(mm[1], mm[2]); } catch (e) { console.error(`发送失败:${e.message}`); } } else if (line.trim() === 'quit') process.exit(0); else console.log('用法:send <对端节点号> <文本>'); }); rl.on('close', () => process.exit(0)); // stdin 结束即退出,不挂死 } } else if (mode === 'selftest') { const a = parseArgs(rest); const report = await selftest(a.workdir || './bridge-selftest'); console.log(JSON.stringify(report, null, 2)); process.exit(report.result === 'PASS' ? 0 : 1); } else if (mode) { console.log('用法:bridge-node.mjs relay|node|selftest'); }