// IDE Remote channel: end-to-end encrypted JSON messages over a relay (WebSocket) or a byte stream (direct TCP). // One file used by both ends (the IDE webview and the remote app). No dependency: WebCrypto (AES-256-GCM) only. // Contract: docs/ide-remote.md ("Channel", "Relay protocol"). // // Wire frame: [1B version=1][1B flags: bit0 = more parts follow][12B IV][ciphertext + 16B tag] // IV = [direction: 0x01 host->remote, 0x02 remote->host][3B random sender nonce][8B big-endian counter] // AAD = the 2 first bytes. A frame whose (direction, sender nonce) counter is not strictly above the last accepted one is dropped. // Plaintext = UTF-8 JSON of one message, cut in parts of 48 KB when longer (flag bit0 on every part but the last). const VERSION = 1; const PART_SIZE = 48 * 1024; const MAX_MESSAGE = 8 * 1024 * 1024; const MAX_STREAM_FRAME = 1024 * 1024; const MAX_SENDERS = 256; const DIR_HOST = 0x01; const DIR_REMOTE = 0x02; const BROADCAST = 255; const subtle = () => globalThis.crypto.subtle; const enc = new TextEncoder(); const dec = new TextDecoder(); // ---- base64url ----------------------------------------------------------------------------------------------------- export function b64uEncode(bytes) { let s = ''; for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } export function b64uDecode(text) { const s = String(text).replace(/-/g, '+').replace(/_/g, '/'); const bin = atob(s + '='.repeat((4 - (s.length % 4)) % 4)); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } function random(n) { return globalThis.crypto.getRandomValues(new Uint8Array(n)); } async function importKey(key) { if (key && typeof key === 'object' && key.type === 'secret') return key; // already a CryptoKey const raw = typeof key === 'string' ? b64uDecode(key) : new Uint8Array(key); if (raw.length !== 32) throw new Error('channel key must be 32 bytes'); return subtle().importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); } function concat(...parts) { const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } return out; } // ---- tiny emitter -------------------------------------------------------------------------------------------------- class Emitter { #h = new Map(); on(name, fn) { if (!this.#h.has(name)) this.#h.set(name, new Set()); this.#h.get(name).add(fn); return this; } off(name, fn) { this.#h.get(name)?.delete(fn); return this; } emit(name, ...args) { for (const fn of [...(this.#h.get(name) || [])]) { try { fn(...args); } catch (e) { if (name !== 'error') queueMicrotask(() => { throw e; }); } } } } // ---- the channel --------------------------------------------------------------------------------------------------- export class Channel extends Emitter { /** * @param {{role:'host'|'remote', key:string|Uint8Array|CryptoKey, transport:object}} o * Transport interface: on('open'|'close'|'frame'|'peer'|'error', fn), send(bytes, to?), close(), optional start(), peers(). * 'frame' gets (bytes, from). */ static async open({ role, key, transport }) { if (role !== 'host' && role !== 'remote') throw new Error("role must be 'host' or 'remote'"); const ch = new Channel(role, await importKey(key), transport); setTimeout(() => ch.#start(), 0); // the caller attaches its listeners first return ch; } static newPairing() { return { room: b64uEncode(random(24)), key: b64uEncode(random(32)) }; } static pairingUrl(base, { room, key, direct } = {}) { let h = `#r=${room}&k=${key}`; if (direct && direct.length) h += `&d=${(Array.isArray(direct) ? direct : [direct]).join(',')}`; return String(base).replace(/#.*$/, '') + h; } /** Parses `#r=..&k=..[&d=..]` (or a whole URL). Returns {room, key, direct:[]} or null when absent / malformed. */ static parsePairing(hashOrUrl) { const text = String(hashOrUrl || ''); const i = text.indexOf('#'); const params = new URLSearchParams(i >= 0 ? text.slice(i + 1) : text); const room = params.get('r'); const key = params.get('k'); if (!room || !key || !/^[A-Za-z0-9_-]{32,64}$/.test(room) || !/^[A-Za-z0-9_-]{43}$/.test(key)) return null; const direct = (params.get('d') || '').split(',').map((s) => s.trim()).filter(Boolean); return { room, key, direct }; } #role; #key; #transport; #sendDir; #recvDir; #nonce; #counter = 0; #seen = new Map(); // "dir:nonce" -> last accepted counter #parts = new Map(); // sender tag -> {chunks, size} #queue = []; // {obj, to, resolve, reject} waiting for open #isOpen = false; #closed = false; #chain = Promise.resolve(); constructor(role, key, transport) { super(); this.#role = role; this.#key = key; this.#transport = transport; this.#sendDir = role === 'host' ? DIR_HOST : DIR_REMOTE; this.#recvDir = role === 'host' ? DIR_REMOTE : DIR_HOST; this.#nonce = random(3); } get role() { return this.#role; } get isOpen() { return this.#isOpen; } peers() { return this.#transport.peers ? this.#transport.peers() : []; } #start() { const t = this.#transport; t.on('open', () => { this.#isOpen = true; this.emit('open'); this.#flush(); }); t.on('close', (why) => { const was = this.#isOpen; this.#isOpen = false; if (was && !this.#closed) this.emit('close', why); }); t.on('peer', (info) => this.emit('peer', info)); t.on('error', (e) => this.emit('error', e)); t.on('frame', (bytes, from) => { this.#chain = this.#chain.then(() => this.#receive(bytes, from)).catch(() => {}); }); t.start?.(); } /** Queues a JSON object (until open). `opts.to`: a relay peer id (host answering one device); default every other peer. */ send(obj, opts = {}) { if (this.#closed) return Promise.reject(new Error('channel closed')); return new Promise((resolve, reject) => { this.#queue.push({ obj, to: opts.to, resolve, reject }); if (this.#isOpen) this.#flush(); }); } close() { if (this.#closed) return; this.#closed = true; for (const q of this.#queue.splice(0)) q.reject(new Error('channel closed')); this.#transport.close(); this.emit('close', 'closed'); } #flush() { this.#chain = this.#chain.then(async () => { while (this.#isOpen && this.#queue.length) { const q = this.#queue.shift(); try { const body = enc.encode(JSON.stringify(q.obj)); const n = Math.max(1, Math.ceil(body.length / PART_SIZE)); for (let p = 0; p < n; p++) { const frame = await this.#seal(body.subarray(p * PART_SIZE, (p + 1) * PART_SIZE), p < n - 1 ? 1 : 0); await this.#transport.send(frame, q.to); } q.resolve(); } catch (e) { q.reject(e); } } }).catch(() => {}); } async #seal(plain, flags) { const iv = new Uint8Array(12); iv[0] = this.#sendDir; iv.set(this.#nonce, 1); const c = this.#counter++; const dv = new DataView(iv.buffer); dv.setUint32(4, Math.floor(c / 4294967296)); dv.setUint32(8, c >>> 0); const head = new Uint8Array([VERSION, flags]); const ct = new Uint8Array(await subtle().encrypt({ name: 'AES-GCM', iv, additionalData: head, tagLength: 128 }, this.#key, plain)); return concat(head, iv, ct); } async #receive(bytes, from) { try { if (!(bytes instanceof Uint8Array)) bytes = new Uint8Array(bytes); if (bytes.length < 2 + 12 + 16 || bytes[0] !== VERSION || bytes[2] !== this.#recvDir) return; // not for us: silent const iv = bytes.subarray(2, 14); const dv = new DataView(iv.buffer, iv.byteOffset, 12); const counter = dv.getUint32(4) * 4294967296 + dv.getUint32(8); const tag = `${iv[0]}:${iv[1]}.${iv[2]}.${iv[3]}`; const last = this.#seen.get(tag); if (last !== undefined && counter <= last) return; // replay let plain; try { plain = new Uint8Array(await subtle().decrypt({ name: 'AES-GCM', iv, additionalData: bytes.subarray(0, 2), tagLength: 128 }, this.#key, bytes.subarray(14))); } catch { this.emit('error', Object.assign(new Error('bad frame (wrong key or tampered)'), { code: 'decrypt' })); return; } // authenticated: now (and only now) the counter moves; bound the table this.#seen.delete(tag); this.#seen.set(tag, counter); if (this.#seen.size > MAX_SENDERS) this.#seen.delete(this.#seen.keys().next().value); const sender = `${tag}/${from ?? ''}`; let acc = this.#parts.get(sender); if (!acc) { acc = { chunks: [], size: 0 }; this.#parts.set(sender, acc); } acc.chunks.push(plain); acc.size += plain.length; if (acc.size > MAX_MESSAGE) { this.#parts.delete(sender); this.emit('error', Object.assign(new Error('message too large'), { code: 'size' })); return; } if (bytes[1] & 1) return; // more parts follow this.#parts.delete(sender); let obj; try { obj = JSON.parse(dec.decode(acc.chunks.length === 1 ? acc.chunks[0] : concat(...acc.chunks))); } catch { this.emit('error', Object.assign(new Error('bad JSON'), { code: 'json' })); return; } this.emit('message', obj, from); } catch (e) { this.emit('error', e); } } } // ---- transport: php-wasm-network relay (WebSocket) ----------------------------------------------------------------- /** * relayTransport({url, room, role, WebSocket?, minDelay?, maxDelay?, keepAlive?}) * host: {t:'create', room, max:16} ; remote: {t:'join', room}. Binary frames [to][payload] -> [from][payload]. * Reconnects with backoff (the host re-enters its room by id); a remote whose room is missing retries the join. */ export function relayTransport({ url, room, role, WebSocket: WS = globalThis.WebSocket, minDelay = 500, maxDelay = 15000, keepAlive = 15000, joinRetry = 2000 }) { const em = new Emitter(); let ws = null; let stopped = false; let delay = minDelay; let timer = null; let pingTimer = null; let retryTimer = null; let inRoom = false; let peers = new Set(); let me = -1; const text = (o) => { if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); }; const enter = () => text(role === 'host' ? { t: 'create', room, max: 16 } : { t: 'join', room }); function connect() { if (stopped) return; let sock; try { sock = new WS(url); } catch (e) { em.emit('error', e); return schedule(); } ws = sock; sock.binaryType = 'arraybuffer'; sock.onopen = () => { pingTimer = setInterval(() => text({ t: 'ping', id: 0 }), keepAlive); }; sock.onmessage = (ev) => { if (sock !== ws) return; if (typeof ev.data !== 'string') { const b = new Uint8Array(ev.data); if (b.length > 1) em.emit('frame', b.subarray(1), b[0]); return; } let m; try { m = JSON.parse(ev.data); } catch { return; } if (m.t === 'hello') enter(); else if (m.t === 'room') { inRoom = true; delay = minDelay; me = m.peer; peers = new Set((m.peers || []).filter((p) => p !== me)); clearTimeout(retryTimer); em.emit('open'); if (peers.size) em.emit('peer', { type: 'join', peer: [...peers][0], peers: [...peers] }); } else if (m.t === 'peer-join') { peers.add(m.peer); em.emit('peer', { type: 'join', peer: m.peer, peers: [...peers] }); } else if (m.t === 'peer-leave') { peers.delete(m.peer); em.emit('peer', { type: 'leave', peer: m.peer, peers: [...peers] }); } else if (m.t === 'error') { em.emit('error', Object.assign(new Error(m.message || m.code), { code: m.code })); if (!inRoom && !stopped) { clearTimeout(retryTimer); retryTimer = setTimeout(enter, joinRetry); } // host not there yet / room full } }; const dropped = () => { if (sock !== ws) return; clearInterval(pingTimer); clearTimeout(retryTimer); const was = inRoom; inRoom = false; peers = new Set(); ws = null; try { sock.close(); } catch { /* already closed */ } if (was) em.emit('close', 'relay connection lost'); schedule(); }; sock.onclose = dropped; sock.onerror = dropped; // some runtimes never send close after a failed connect (dropped is idempotent) } function schedule() { if (stopped) return; clearTimeout(timer); timer = setTimeout(connect, delay * (0.75 + Math.random() * 0.5)); delay = Math.min(maxDelay, delay * 2); } return Object.assign(em, { start: connect, peers: () => [...peers], send(bytes, to) { if (!ws || ws.readyState !== 1 || !inRoom) throw new Error('relay not connected'); ws.send(concat(new Uint8Array([to === undefined ? BROADCAST : to]), bytes)); }, close() { stopped = true; clearTimeout(timer); clearTimeout(retryTimer); clearInterval(pingTimer); try { ws?.close(); } catch { /* already closed */ } ws = null; inRoom = false; }, }); } // ---- transport: any byte stream with 4-byte big-endian length prefix ----------------------------------------------- /** * streamTransport({send, onData, onClose?, close?}) * send(bytes) writes bytes to the stream (direct TCP through the PHP pipe, `Tcp`, ...) * onData(cb) subscribes: cb(Uint8Array | ArrayBuffer) for every chunk read, fragmented as the stream likes * onClose(cb) optional subscription: cb(reason) when the stream ends * The returned transport also has feed(bytes) to push chunks by hand. It is open as soon as the channel starts. */ export function streamTransport({ send, onData, onClose, close }) { const em = new Emitter(); let buf = new Uint8Array(0); let done = false; const feed = (chunk) => { if (done) return; buf = buf.length ? concat(buf, new Uint8Array(chunk)) : new Uint8Array(chunk); while (buf.length >= 4) { const len = new DataView(buf.buffer, buf.byteOffset, 4).getUint32(0); if (len > MAX_STREAM_FRAME) { em.emit('error', Object.assign(new Error('stream frame too large'), { code: 'size' })); buf = new Uint8Array(0); return transport.close(); } if (buf.length < 4 + len) return; const frame = buf.slice(4, 4 + len); buf = buf.subarray(4 + len); em.emit('frame', frame, undefined); } }; const transport = Object.assign(em, { feed, peers: () => [], start() { onData?.(feed); onClose?.((why) => { if (!done) { done = true; em.emit('close', why || 'stream closed'); } }); queueMicrotask(() => em.emit('open')); }, async send(bytes) { const out = new Uint8Array(4 + bytes.length); new DataView(out.buffer).setUint32(0, bytes.length); out.set(bytes, 4); await send(out); }, close() { if (done) return; done = true; try { close?.(); } catch { /* ignore */ } em.emit('close', 'closed'); }, }); return transport; } export const relay = relayTransport; export const stream = streamTransport; Channel.relayTransport = relayTransport; Channel.streamTransport = streamTransport;