// LAN-Synchronisation zwischen MrTerm-Clients (Peer-to-Peer, Ende-zu-Ende verschlüsselt). // // Finden: UDP-Broadcast auf Port 47811 ("Beacon" mit Geräte-ID, Name, TCP-Port, koppelbar ja/nein). // Koppeln: X25519-Schlüsselaustausch mit Commitment (der Initiator legt sich per Hash auf seinen Schlüssel fest, // bevor er den des anderen kennt). Beide Geräte zeigen denselben 6-stelligen Vergleichscode (SAS); // der Nutzer bestätigt auf beiden Geräten. Ergebnis: dauerhafter Kopplungsschlüssel (32 Byte). // Sitzung: Gegenseitige Authentisierung per HMAC mit dem Kopplungsschlüssel, frische X25519-Schlüssel je Sitzung // (Forward Secrecy), danach alle Nachrichten AES-256-GCM-verschlüsselt. // Abgleich: Beide Seiten senden einen Schnappschuss; pro Eintrag gewinnt die neueste Änderung (updatedAt), // Löschvermerke (tombstones) entfernen Einträge. Geheimnisse (SSH-Schlüssel, Passwörter, VPN-Konfigurationen) // werden nur übertragen, wenn der Nutzer sie freigegeben hat. const dgram = require('dgram'); const net = require('net'); const os = require('os'); const crypto = require('crypto'); const i18n = require('../i18n'); const fs = require('fs'); const { execFileSync } = require('child_process'); // Lokale Firewall erkennen, die Broadcasts/eingehende Verbindungen blockieren könnte (Linux). // Ob die Ports bereits freigegeben sind, lässt sich ohne root nicht prüfen (UFW-Regeln sind nur für root lesbar). function localFirewall() { if (process.platform !== 'linux') return null; const read = (f) => { try { return fs.readFileSync(f, 'utf8'); } catch { return ''; } }; const active = (unit) => { try { return execFileSync('systemctl', ['is-active', unit], { timeout: 3000 }).toString().trim() === 'active'; } catch { return false; } }; const os = (read('/etc/os-release').match(/^ID=(.*)$/m) || [])[1]?.replace(/"/g, '') || ''; const ufw = /^ENABLED=yes/m.test(read('/etc/ufw/ufw.conf')) || active('ufw'); const firewalld = active('firewalld'); if (!ufw && !firewalld && os !== 'cachyos') return null; return { os, ufw, firewalld }; } const UDP_PORT = 47811; const TCP_PORT = 47812; const PROTO = 1; const COLLECTIONS = ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns']; const SECRETS = { keys: ['privateKey', 'passphrase'], hosts: ['password'], vpns: ['config', 'password'] }; const SPKI_X25519 = Buffer.from('302a300506032b656e032100', 'hex'); const MAX_FRAME = 32 * 1024 * 1024; const sha = (...parts) => crypto.createHash('sha256').update(Buffer.concat(parts.map((p) => Buffer.from(p)))).digest(); const hmac = (key, ...parts) => crypto.createHmac('sha256', key).update(Buffer.concat(parts.map((p) => Buffer.from(p)))).digest(); const hkdf = (ikm, salt, info) => Buffer.from(crypto.hkdfSync('sha256', ikm, salt, info, 32)); const ts = (x) => x?.updatedAt || x?.createdAt || 0; function x25519() { const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519'); return { privateKey, pub: publicKey.export({ type: 'spki', format: 'der' }).subarray(-32) }; } function dh(privateKey, peerPub) { const pk = crypto.createPublicKey({ key: Buffer.concat([SPKI_X25519, Buffer.from(peerPub)]), format: 'der', type: 'spki' }); return crypto.diffieHellman({ privateKey, publicKey: pk }); } // ---------------------------------------------------------------- Framing (4 Byte Länge + Nutzlast) class Channel { constructor(sock) { this.sock = sock; this.buf = Buffer.alloc(0); this.queue = []; this.waiters = []; this.keys = null; // { send, recv } nach dem Handshake sock.on('data', (d) => this.onData(d)); const fail = (e) => { this.closed = e || new Error('closed'); this.waiters.splice(0).forEach((w) => w.reject(this.closed)); }; sock.on('close', () => fail()); sock.on('error', (e) => fail(e)); sock.setTimeout(180000, () => sock.destroy(new Error('timeout'))); } onData(d) { this.buf = Buffer.concat([this.buf, d]); while (this.buf.length >= 4) { const len = this.buf.readUInt32BE(0); if (len > MAX_FRAME) { this.sock.destroy(new Error('frame too large')); return; } if (this.buf.length < 4 + len) break; const frame = this.buf.subarray(4, 4 + len); this.buf = this.buf.subarray(4 + len); let msg; try { msg = this.decode(frame); } catch (e) { this.sock.destroy(e); return; } const w = this.waiters.shift(); if (w) w.resolve(msg); else this.queue.push(msg); } } encode(obj) { const plain = Buffer.from(JSON.stringify(obj)); if (!this.keys) return plain; const iv = crypto.randomBytes(12); const c = crypto.createCipheriv('aes-256-gcm', this.keys.send, iv); return Buffer.concat([iv, c.update(plain), c.final(), c.getAuthTag()]); } decode(frame) { if (!this.keys) return JSON.parse(frame.toString('utf8')); const d = crypto.createDecipheriv('aes-256-gcm', this.keys.recv, frame.subarray(0, 12)); d.setAuthTag(frame.subarray(frame.length - 16)); return JSON.parse(Buffer.concat([d.update(frame.subarray(12, frame.length - 16)), d.final()]).toString('utf8')); } send(obj) { const p = this.encode(obj); const h = Buffer.alloc(4); h.writeUInt32BE(p.length); this.sock.write(Buffer.concat([h, p])); } recv(type, timeout = 30000) { const msg = this.queue.length ? Promise.resolve(this.queue.shift()) : this.closed ? Promise.reject(this.closed) : new Promise((resolve, reject) => this.waiters.push({ resolve, reject })); let t; const to = new Promise((_, rej) => { t = setTimeout(() => rej(new Error('timeout')), timeout); }); return Promise.race([msg, to]).finally(() => clearTimeout(t)).then((m) => { if (m?.t === 'error') throw new Error(m.msg || 'remote error'); if (type && m?.t !== type) throw new Error(`unexpected message ${m?.t}`); return m; }); } close() { try { this.sock.end(); } catch {} } } // ---------------------------------------------------------------- Zusammenführen // Schnappschuss für ein Gerät; nicht freigegebene Geheimnisse werden entfernt function snapshot(data) { const share = data.sync?.share || {}; const out = { tombstones: data.tombstones || [], knownHosts: data.knownHosts || {} }; for (const c of COLLECTIONS) { out[c] = (data[c] || []).map((x) => { const fields = SECRETS[c]; if (!fields || (share[c] || []).includes(x.id)) return x; const y = { ...x, _noSecret: true }; fields.forEach((f) => delete y[f]); return y; }); } return out; } // Eingehenden Schnappschuss übernehmen; liefert true, wenn sich etwas geändert hat function merge(data, incoming) { let changed = false; const tombs = new Map((data.tombstones || []).map((t) => [`${t.c}|${t.id}`, t])); for (const t of incoming.tombstones || []) { const k = `${t.c}|${t.id}`; if (!tombs.has(k) || tombs.get(k).at < t.at) { tombs.set(k, t); changed = true; } } data.tombstones = [...tombs.values()]; for (const c of COLLECTIONS) { const list = data[c] || (data[c] = []); for (const inc of incoming[c] || []) { if (!inc?.id) continue; const tomb = tombs.get(`${c}|${inc.id}`); if (tomb && tomb.at >= ts(inc)) continue; const i = list.findIndex((x) => x.id === inc.id); const local = list[i]; if (local && ts(local) >= ts(inc)) continue; const { _noSecret, ...item } = inc; // Nicht freigegebene Geheimnisse: lokale Werte behalten if (_noSecret && local) (SECRETS[c] || []).forEach((f) => { if (local[f] !== undefined) item[f] = local[f]; }); if (i >= 0) list[i] = item; else list.push(item); changed = true; } // Löschvermerke anwenden const before = list.length; data[c] = list.filter((x) => { const t = tombs.get(`${c}|${x.id}`); return !t || t.at < ts(x); }); if (data[c].length !== before) changed = true; } for (const [id, kh] of Object.entries(incoming.knownHosts || {})) { const t = tombs.get(`knownHosts|${id}`); if (t && t.at >= (kh.addedAt || 0)) continue; const local = data.knownHosts[id]; if (!local || (kh.addedAt || 0) > (local.addedAt || 0)) { data.knownHosts[id] = kh; changed = true; } } for (const t of tombs.values()) { if (t.c === 'knownHosts' && data.knownHosts[t.id] && (data.knownHosts[t.id].addedAt || 0) <= t.at) { delete data.knownHosts[t.id]; changed = true; } } return changed; } // ---------------------------------------------------------------- Dienst class SyncService { /** * @param {import('./store').Store} store * @param {(event: string, payload?: any) => void} emit Ereignisse an die Oberfläche * @param {(req: { code, name, id }) => Promise} confirmPair Vergleichscode anzeigen und Bestätigung abwarten */ constructor(store, emit, confirmPair) { this.store = store; this.emit = emit; this.confirmPair = confirmPair; this.seen = new Map(); // id -> { id, name, address, port, pairable, at } this.pairable = false; this.running = false; this.lastError = ''; this.syncing = new Set(); this.firewall = localFirewall(); } get cfg() { const d = this.store.get(); d.sync ||= { enabled: false, peers: [], share: { keys: [], hosts: [], vpns: [] } }; const s = d.sync; if (!s.deviceId) s.deviceId = crypto.randomUUID(); if (!s.deviceName) s.deviceName = process.env.MRTERM_DEVICE || os.hostname(); s.peers ||= []; s.share ||= { keys: [], hosts: [], vpns: [] }; return s; } // ------------------------------------------------ Start/Stop async start() { if (this.running || this.store.sealed || !this.cfg.enabled) return; this.running = true; this.server = net.createServer((sock) => this.accept(sock).catch(() => sock.destroy())); this.server.on('error', () => {}); await new Promise((res) => { this.server.once('error', () => this.server.listen(0, res)); this.server.listen(TCP_PORT, res); }); this.port = this.server.address().port; this.udp = dgram.createSocket({ type: 'udp4', reuseAddr: true }); this.udp.on('error', () => {}); this.udp.on('message', (msg, rinfo) => this.onBeacon(msg, rinfo)); await new Promise((res) => this.udp.bind(UDP_PORT, () => { try { this.udp.setBroadcast(true); } catch {} res(); })); this.beaconTimer = setInterval(() => this.beacon(), 5000); this.syncTimer = setInterval(() => this.syncAll(), 120000); this.beacon(); this.emitState(); } stop() { if (!this.running) return; this.running = false; clearInterval(this.beaconTimer); clearInterval(this.syncTimer); clearTimeout(this.debounce); try { this.server.close(); } catch {} try { this.udp.close(); } catch {} this.seen.clear(); this.emitState(); } setEnabled(on, name) { const c = this.cfg; c.enabled = !!on; if (name) c.deviceName = String(name).slice(0, 60); this.store.save(); if (on) this.start(); else this.stop(); return this.status(); } // ------------------------------------------------ Finden broadcastAddrs() { const out = new Set(['255.255.255.255']); let ifaces = {}; try { ifaces = os.networkInterfaces(); } catch {} // Android: je nach Version eingeschränkt for (const list of Object.values(ifaces)) { for (const a of list || []) { if (a.family !== 'IPv4' || a.internal) continue; const ip = a.address.split('.').map(Number), mask = a.netmask.split('.').map(Number); out.add(ip.map((o, i) => (o & mask[i]) | (~mask[i] & 255)).join('.')); } } return [...out]; } beacon() { if (!this.running) return; const c = this.cfg; const msg = Buffer.from(JSON.stringify({ mrterm: PROTO, id: c.deviceId, name: c.deviceName, port: this.port, pairable: this.pairable })); for (const addr of this.broadcastAddrs()) this.udp.send(msg, UDP_PORT, addr, () => {}); // Einträge älter als 20 s gelten als offline for (const [id, p] of this.seen) if (Date.now() - p.at > 20000) { this.seen.delete(id); this.emitState(); } } onBeacon(buf, rinfo) { let b; try { b = JSON.parse(buf.toString('utf8')); } catch { return; } if (b?.mrterm !== PROTO || !b.id || b.id === this.cfg.deviceId || !Number.isInteger(b.port)) return; const known = this.seen.get(b.id); this.seen.set(b.id, { id: b.id, name: String(b.name || '').slice(0, 60), address: rinfo.address, port: b.port, pairable: !!b.pairable, at: Date.now() }); const peer = this.cfg.peers.find((p) => p.id === b.id); if (peer && peer.address !== rinfo.address) { peer.address = rinfo.address; peer.port = b.port; } if (!known) { this.emitState(); if (peer) this.syncWith(peer).catch(() => {}); } else if (known.pairable !== !!b.pairable) this.emitState(); } setPairable(on) { this.pairable = !!on; clearTimeout(this.pairTimer); if (on) this.pairTimer = setTimeout(() => this.setPairable(false), 180000); this.beacon(); this.emitState(); } // ------------------------------------------------ Status für die Oberfläche status() { const c = this.cfg; return { enabled: c.enabled, running: this.running, deviceId: c.deviceId, deviceName: c.deviceName, pairable: this.pairable, port: this.port, peers: c.peers.map((p) => ({ id: p.id, name: p.name, lastSync: p.lastSync || 0, online: this.seen.has(p.id), address: p.address, error: p.error || '' })), nearby: [...this.seen.values()].filter((p) => !c.peers.some((x) => x.id === p.id)).map(({ id, name, address, pairable }) => ({ id, name, address, pairable })), share: c.share, asked: !!c.asked, sealed: !!this.store.sealed, firewall: this.firewall, }; } emitState() { this.emit('sync:state', this.status()); } // ------------------------------------------------ Verbindungen connect(address, port) { return new Promise((resolve, reject) => { const sock = net.connect({ host: address, port, timeout: 8000 }); sock.once('connect', () => { sock.setTimeout(0); resolve(new Channel(sock)); }); sock.once('timeout', () => { sock.destroy(); reject(new Error('timeout')); }); sock.once('error', reject); }); } // Eingehende Verbindung: Kopplung oder Synchronisation async accept(sock) { const ch = new Channel(sock); try { const first = await ch.recv(null, 10000); if (first.t === 'pair') await this.pairResponder(ch, first); else if (first.t === 'hello') await this.sessionResponder(ch, first); } catch (e) { try { ch.send({ t: 'error', msg: e.message }); } catch {} } finally { ch.close(); } } // ------------------------------------------------ Kopplung // Initiator (Gerät, auf dem der Nutzer ein anderes Gerät ausgewählt hat) async pair(id) { const target = this.seen.get(id); if (!target) throw new Error('Device not found'); const ch = await this.connect(target.address, target.port); try { const me = x25519(); const nonce = crypto.randomBytes(16); const c = this.cfg; ch.send({ t: 'pair', v: PROTO, id: c.deviceId, name: c.deviceName, commit: sha(me.pub, nonce).toString('base64') }); const r = await ch.recv('pair-pub'); ch.send({ t: 'pair-reveal', pub: me.pub.toString('base64'), nonce: nonce.toString('base64') }); const peerPub = Buffer.from(r.pub, 'base64'); const { code, key } = this.derivePair(dh(me.privateKey, peerPub), peerPub, me.pub, r.id, c.deviceId); return await this.finishPair(ch, { id: r.id, name: r.name, address: target.address, port: target.port }, code, key, 'initiator'); } finally { ch.close(); } } // Gegenseite: nur solange "koppelbar" aktiv ist async pairResponder(ch, msg) { if (!this.pairable) throw new Error(i18n.t('Device is not ready for pairing')); const me = x25519(); const c = this.cfg; ch.send({ t: 'pair-pub', id: c.deviceId, name: c.deviceName, pub: me.pub.toString('base64') }); const rev = await ch.recv('pair-reveal'); const peerPub = Buffer.from(rev.pub, 'base64'); if (!sha(peerPub, Buffer.from(rev.nonce, 'base64')).equals(Buffer.from(msg.commit, 'base64'))) throw new Error('Commitment mismatch'); const { code, key } = this.derivePair(dh(me.privateKey, peerPub), me.pub, peerPub, c.deviceId, msg.id); const addr = ch.sock.remoteAddress?.replace(/^::ffff:/, ''); await this.finishPair(ch, { id: msg.id, name: String(msg.name || '').slice(0, 60), address: addr, port: this.seen.get(msg.id)?.port || TCP_PORT }, code, key, 'responder'); } // Vergleichscode und Kopplungsschlüssel aus dem Schlüsselaustausch (Reihenfolge: Responder, Initiator) derivePair(shared, pubR, pubI, idR, idI) { const transcript = Buffer.concat([pubR, pubI, Buffer.from(`${idR}|${idI}`)]); const code = String(sha('mrterm-sas', transcript).readUInt32BE(0) % 1000000).padStart(6, '0'); const key = hkdf(shared, transcript, 'mrterm-pair-key'); return { code, key }; } async finishPair(ch, peer, code, key, role) { const ok = await this.confirmPair({ code, name: peer.name, id: peer.id, role }); ch.send({ t: 'pair-confirm', ok, mac: ok ? hmac(key, 'confirm', role).toString('base64') : '' }); const r = await ch.recv('pair-confirm', 120000); const other = role === 'initiator' ? 'responder' : 'initiator'; if (!ok) throw new Error(i18n.t('Pairing was rejected on this device.')); if (!r.ok) throw new Error(i18n.t('Pairing was rejected on the other device.')); if (!crypto.timingSafeEqual(Buffer.from(r.mac, 'base64'), hmac(key, 'confirm', other))) throw new Error(i18n.t('Pairing verification failed.')); const c = this.cfg; c.peers = [...c.peers.filter((p) => p.id !== peer.id), { ...peer, key: key.toString('base64'), pairedAt: Date.now() }]; this.store.save(); this.setPairable(false); this.emit('sync:paired', { id: peer.id, name: peer.name }); this.emitState(); if (role === 'initiator') setTimeout(() => this.syncWith(c.peers.find((p) => p.id === peer.id)).catch(() => {}), 1500); return true; } unpair(id) { const c = this.cfg; c.peers = c.peers.filter((p) => p.id !== id); this.store.save(); this.emitState(); return this.status(); } // ------------------------------------------------ Sitzung (authentisiert + verschlüsselt) sessionKeys(pairKey, shared, transcript, initiator) { const k1 = hkdf(Buffer.concat([pairKey, shared]), transcript, 'mrterm-i2r'); const k2 = hkdf(Buffer.concat([pairKey, shared]), transcript, 'mrterm-r2i'); return initiator ? { send: k1, recv: k2 } : { send: k2, recv: k1 }; } async syncWith(peer) { if (!peer || !this.running || this.store.sealed || this.syncing.has(peer.id)) return; const live = this.seen.get(peer.id); const address = live?.address || peer.address; const port = live?.port || peer.port || TCP_PORT; if (!address) return; this.syncing.add(peer.id); let ch; try { ch = await this.connect(address, port); const key = Buffer.from(peer.key, 'base64'); const me = x25519(); const nI = crypto.randomBytes(16); const c = this.cfg; ch.send({ t: 'hello', v: PROTO, id: c.deviceId, eph: me.pub.toString('base64'), nonce: nI.toString('base64') }); const r = await ch.recv('hello'); if (r.id !== peer.id) throw new Error('Unexpected device'); const transcript = Buffer.concat([Buffer.from(c.deviceId), Buffer.from(peer.id), me.pub, Buffer.from(r.eph, 'base64'), nI, Buffer.from(r.nonce, 'base64')]); if (!crypto.timingSafeEqual(Buffer.from(r.mac, 'base64'), hmac(key, 'responder', transcript))) throw new Error('Authentication failed'); ch.send({ t: 'auth', mac: hmac(key, 'initiator', transcript).toString('base64') }); ch.keys = this.sessionKeys(key, dh(me.privateKey, Buffer.from(r.eph, 'base64')), transcript, true); ch.send({ t: 'state', data: snapshot(this.store.get()) }); const theirs = await ch.recv('state'); this.applyRemote(theirs.data); Object.assign(peer, { lastSync: Date.now(), error: '', address, port }); this.store.muted = true; this.store.save(); this.store.muted = false; } catch (e) { peer.error = e.message; throw e; } finally { this.syncing.delete(peer.id); ch?.close(); this.emitState(); } } async sessionResponder(ch, msg) { const c = this.cfg; const peer = c.peers.find((p) => p.id === msg.id); if (!peer || this.store.sealed) throw new Error('Unknown device'); const key = Buffer.from(peer.key, 'base64'); const me = x25519(); const nR = crypto.randomBytes(16); const transcript = Buffer.concat([Buffer.from(msg.id), Buffer.from(c.deviceId), Buffer.from(msg.eph, 'base64'), me.pub, Buffer.from(msg.nonce, 'base64'), nR]); ch.send({ t: 'hello', id: c.deviceId, eph: me.pub.toString('base64'), nonce: nR.toString('base64'), mac: hmac(key, 'responder', transcript).toString('base64') }); const a = await ch.recv('auth'); if (!crypto.timingSafeEqual(Buffer.from(a.mac, 'base64'), hmac(key, 'initiator', transcript))) throw new Error('Authentication failed'); ch.keys = this.sessionKeys(key, dh(me.privateKey, Buffer.from(msg.eph, 'base64')), transcript, false); const theirs = await ch.recv('state'); ch.send({ t: 'state', data: snapshot(this.store.get()) }); this.applyRemote(theirs.data); Object.assign(peer, { lastSync: Date.now(), error: '', address: ch.sock.remoteAddress?.replace(/^::ffff:/, '') || peer.address }); this.store.muted = true; this.store.save(); this.store.muted = false; this.emitState(); } applyRemote(data) { if (merge(this.store.get(), data || {})) { this.store.muted = true; this.store.save(); this.store.muted = false; this.emit('sync:changed'); // Änderungen an weitere gekoppelte Geräte weitergeben this.schedule(3000); } } // Nach lokalen Änderungen (entprellt) mit allen erreichbaren Geräten abgleichen schedule(delay = 2000) { if (!this.running) return; clearTimeout(this.debounce); this.debounce = setTimeout(() => this.syncAll(), delay); } async syncAll() { const results = await Promise.allSettled(this.cfg.peers.filter((p) => this.seen.has(p.id) || p.address).map((p) => this.syncWith(p))); return results.filter((r) => r.status === 'fulfilled').length; } setShare(share) { const clean = (a) => (Array.isArray(a) ? a.filter((x) => typeof x === 'string') : []); const c = this.cfg; c.share = { keys: clean(share?.keys), hosts: clean(share?.hosts), vpns: clean(share?.vpns), declined: { keys: [], hosts: [], vpns: [] } }; c.asked = true; this.store.save(); return this.status(); } // Einzelnes neues Geheimnis teilen oder ablehnen (Ablehnung wird gemerkt, damit nicht erneut gefragt wird) shareItem(c, id, yes) { if (!SECRETS[c] || typeof id !== 'string') throw new Error('Invalid'); const sh = this.cfg.share; sh.declined ||= { keys: [], hosts: [], vpns: [] }; sh[c] = (sh[c] || []).filter((x) => x !== id); sh.declined[c] = (sh.declined[c] || []).filter((x) => x !== id); (yes ? sh[c] : sh.declined[c]).push(id); this.store.save(); if (yes) this.schedule(500); return this.status(); } // Gerät per IP hinzufügen (wenn Broadcasts im Netz blockiert sind) async probe(address, port = TCP_PORT) { if (!/^[\w.:-]+$/.test(address)) throw new Error('Invalid address'); // Kurzer Test, ob dort ein MrTerm lauscht; das Gerät erscheint danach unter "In der Nähe" const ch = await this.connect(address, Number(port) || TCP_PORT); ch.close(); this.seen.set(`manual:${address}`, { id: `manual:${address}`, name: address, address, port: Number(port) || TCP_PORT, pairable: true, at: Date.now() + 600000 }); this.emitState(); return true; } } module.exports = { SyncService, snapshot, merge, Channel };