UK
apps/jumphp-ide-remote / ui / js / channel.js · commit b81e652
// 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;