#!/usr/bin/env node /* zebra-spaces functional tests — runs the protocol code from * web/zebra-spaces.html the same way the page does, without a browser. * * pure-protocol (always runs): extracts sigJoin/sigAction directly from * the shipped page, then compares the produced canonical bytes against * fixtures pinned to the Go-side unit tests in * proxy.unturf.com/cmd/zebra-spaces-signal/main_test.go. If JS drifts * from Go by even one byte the test fails — exactly the protocol drift * that would silently break promotions in production. * * vault round-trip: backup + restore an Ed25519 JWK with PBKDF2 + * AES-GCM, identical to vaultExport/vaultImport in the page. * * live-server (optional, runs if ZEBRA_SPACES_BINARY env var points at * a built binary): launches the relay on a free port, walks the full * join -> mic-invite -> accept flow over a real WebSocket using browser * APIs (globalThis.WebSocket, crypto.subtle), proving the page's exact * code path produces wire bytes the server accepts. */ const fs = require('fs'); const path = require('path'); const net = require('net'); const { spawn } = require('child_process'); const PAGE = path.join(__dirname, '..', 'web', 'zebra-spaces.html'); const HTML = fs.readFileSync(PAGE, 'utf8'); let passed = 0, failed = 0; function ok(name) { passed++; console.log(' ✓ ' + name); } function fail(name, err) { failed++; console.log(' ✗ ' + name + ' — ' + (err && err.message || err)); } function eq(a, b, name) { const av = typeof a === 'object' ? JSON.stringify(a) : String(a); const bv = typeof b === 'object' ? JSON.stringify(b) : String(b); if (av === bv) ok(name); else fail(name, 'expected ' + bv + ' got ' + av); } /* ===== extract the page's pure protocol helpers ===== */ function extractFn(src, name) { const re = new RegExp('function\\s+' + name + '\\s*\\('); const m = src.match(re); if (!m) throw new Error('could not find function ' + name); let i = src.indexOf('{', m.index + m[0].length), depth = 0, j = i; for (; j < src.length; j++) { if (src[j] === '{') depth++; else if (src[j] === '}') { if (--depth === 0) { j++; break; } } } return src.slice(m.index, j); } eval(extractFn(HTML, 'sigJoin')); eval(extractFn(HTML, 'sigAction')); function bytesEq(a, b) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; } function bytesFromString(s) { return new TextEncoder().encode(s); } /* ===== TIER 1 — pure protocol parity vs Go fixtures ===== */ function tier1() { console.log('\n[pure protocol parity]'); /* sigJoin: matches TestUnitSigJoin in main_test.go */ try { const got = sigJoin('rm123', 'n42', 'PUB', 'alice'); const want = bytesFromString('zebra-spaces|v1|join|rm123|n42|PUB|alice'); if (bytesEq(got, want)) ok('sigJoin canonical bytes (fixture from Go unit test)'); else fail('sigJoin canonical bytes', 'JS produced ' + new TextDecoder().decode(got)); } catch (e) { fail('sigJoin canonical bytes', e); } /* sigAction: matches the four cases in TestUnitSigAction */ const sigActionFixtures = [ { args: ['R', 7n, 'mic-invite', 'target-uuid'], want: 'zebra-spaces|v1|R|7|mic-invite|target-uuid' }, { args: ['R', 9n, 'promote', 'tgt', 'cohost'], want: 'zebra-spaces|v1|R|9|promote|tgt|cohost' }, { args: ['R', 0n, 'boot', 'x'], want: 'zebra-spaces|v1|R|0|boot|x' }, { args: ['R', 3n, 'block-peer', 'PUBKEYB64'], want: 'zebra-spaces|v1|R|3|block-peer|PUBKEYB64' }, ]; for (const tc of sigActionFixtures) { try { /* the page's sigAction takes epoch as Number, not BigInt — match its API */ const epoch = Number(tc.args[1]); const got = sigAction(tc.args[0], epoch, ...tc.args.slice(2)); const want = bytesFromString(tc.want); if (bytesEq(got, want)) ok('sigAction ' + tc.args[2]); else fail('sigAction ' + tc.args[2], 'JS produced ' + new TextDecoder().decode(got)); } catch (e) { fail('sigAction ' + tc.args[2], e); } } } /* ===== TIER 2 — Ed25519 sign + verify with WebCrypto ===== */ async function tier2() { console.log('\n[ed25519 sign/verify (the page\'s crypto stack)]'); if (!globalThis.crypto || !globalThis.crypto.subtle) { fail('webcrypto present', 'no globalThis.crypto.subtle'); return; } try { const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']); const msg = bytesFromString('hello zebra'); const sig = new Uint8Array(await crypto.subtle.sign('Ed25519', kp.privateKey, msg)); const okSig = await crypto.subtle.verify('Ed25519', kp.publicKey, sig, msg); if (okSig) ok('Ed25519 round-trip'); else fail('Ed25519 round-trip', 'verify=false'); /* sig length is 64 bytes */ if (sig.length === 64) ok('Ed25519 signature length = 64'); else fail('Ed25519 signature length', 'got ' + sig.length); /* tamper-detection */ msg[0] ^= 0xFF; const tampered = await crypto.subtle.verify('Ed25519', kp.publicKey, sig, msg); if (!tampered) ok('Ed25519 detects tampering'); else fail('Ed25519 detects tampering', 'verified tampered message'); } catch (e) { fail('Ed25519 round-trip', e); } } /* ===== TIER 3 — vault round-trip (PBKDF2 + AES-GCM matches the page) ===== */ async function vaultRoundTrip() { console.log('\n[vault round-trip (PBKDF2 + AES-GCM)]'); const PBKDF2_ITER = 600000; /* mirror vaultExport/vaultImport's byte layout: salt[16] | iv[12] | ct */ async function deriveKey(password, salt, usage) { const base = await crypto.subtle.importKey('raw', bytesFromString(password), 'PBKDF2', false, ['deriveKey']); return crypto.subtle.deriveKey({ name: 'PBKDF2', salt, iterations: PBKDF2_ITER, hash: 'SHA-256' }, base, { name: 'AES-GCM', length: 256 }, false, usage); } try { const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']); const jwk = await crypto.subtle.exportKey('jwk', kp.privateKey); const pw = 'correct horse battery staple'; const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await deriveKey(pw, salt, ['encrypt']); const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, bytesFromString(JSON.stringify(jwk)))); const key2 = await deriveKey(pw, salt, ['decrypt']); const pt = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key2, ct)); const jwk2 = JSON.parse(new TextDecoder().decode(pt)); eq(jwk2.x, jwk.x, 'vault round-trip recovers JWK x (pubkey)'); eq(jwk2.d, jwk.d, 'vault round-trip recovers JWK d (private scalar)'); /* wrong password fails (AES-GCM auth tag mismatch) */ let threw = false; try { const bad = await deriveKey('wrong password', salt, ['decrypt']); await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, bad, ct); } catch (_) { threw = true; } if (threw) ok('vault rejects wrong password'); else fail('vault rejects wrong password', 'decrypt succeeded with wrong pw'); } catch (e) { fail('vault round-trip', e); } } /* ===== TIER 4 — live server (optional) ===== */ async function freePort() { return new Promise(res => { const s = net.createServer().listen(0, () => { const p = s.address().port; s.close(() => res(p)); }); }); } async function liveServer() { console.log('\n[live server (set ZEBRA_SPACES_BINARY=/path/to/zebra-spaces-signal to enable)]'); const bin = process.env.ZEBRA_SPACES_BINARY; if (!bin) { console.log(' (skipped — no binary)'); return; } if (!fs.existsSync(bin)) { fail('binary exists', bin); return; } const port = await freePort(); const child = spawn(bin, [], { env: { ...process.env, ZEBRA_SPACES_ADDR: ':' + port }, stdio: 'pipe' }); /* discard server stdout so it doesn't intermix with test output */ child.stdout.on('data', () => {}); child.stderr.on('data', () => {}); /* wait for the listener to be up */ await new Promise(res => setTimeout(res, 250)); const room = 'jsfunc' + Math.random().toString(36).slice(2, 10); const url = 'ws://localhost:' + port + '/zebra-spaces-signal?room=' + encodeURIComponent(room); async function makePeer(handle) { const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']); const rawPub = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey)); const pubB64 = Buffer.from(rawPub).toString('base64'); const ws = new WebSocket(url); const queue = []; const waiters = []; ws.onmessage = (ev) => { const m = JSON.parse(ev.data); if (waiters.length) waiters.shift()(m); else queue.push(m); }; function recv() { return new Promise(res => { if (queue.length) res(queue.shift()); else waiters.push(res); }); } async function expect(typ, ...skip) { const skipSet = new Set(skip); while (true) { const m = await Promise.race([recv(), new Promise((_, rej) => setTimeout(() => rej(new Error('timeout waiting for ' + typ)), 2000))]); if (m.type === typ) return m; if (!skipSet.has(m.type)) throw new Error('expected ' + typ + ' got ' + JSON.stringify(m)); } } await new Promise(res => { ws.onopen = res; }); const nonce = Array.from(crypto.getRandomValues(new Uint8Array(16))).map(b => b.toString(16).padStart(2,'0')).join(''); const sig = new Uint8Array(await crypto.subtle.sign('Ed25519', kp.privateKey, sigJoin(room, nonce, pubB64, handle))); ws.send(JSON.stringify({ type:'join', pubkey:pubB64, handle, nonce, sig: Buffer.from(sig).toString('base64') })); const w = await expect('welcome'); return { ws, kp, pubB64, uuid: w.your_uuid, role: w.role, epoch: w.epoch, expect, recv, sign: async (action, ...args) => { const s = new Uint8Array(await crypto.subtle.sign('Ed25519', kp.privateKey, sigAction(room, w.epoch, action, ...args))); return Buffer.from(s).toString('base64'); } }; } try { const host = await makePeer('host-alice'); eq(host.role, 'host', 'first joiner is host'); const lst = await makePeer('list-bob'); eq(lst.role, 'listener', 'second joiner is listener'); await host.expect('peer-joined'); /* mic-invite using the JS-generated sig — proves the server accepts * exactly what web/zebra-spaces.html produces */ host.ws.send(JSON.stringify({ type: 'mic-invite', to: lst.uuid, epoch: host.epoch, sig: await host.sign('mic-invite', lst.uuid) })); await host.expect('mic-invite'); await lst.expect('mic-invite'); ok('mic-invite accepted by server using JS canonical sig'); lst.ws.send(JSON.stringify({ type: 'accept-mic', epoch: host.epoch })); const rc = await lst.expect('role-change'); eq(rc.role, 'speaker', 'accept-mic promotes invitee to speaker'); eq(rc.epoch, host.epoch + 1, 'accept-mic increments room epoch'); host.ws.close(); lst.ws.close(); } catch (e) { fail('live server flow', e); } finally { child.kill('SIGTERM'); await new Promise(res => setTimeout(res, 100)); } } (async () => { tier1(); await tier2(); await vaultRoundTrip(); await liveServer(); console.log('\n' + (failed === 0 ? '✓ all ' + passed + ' assertions passed' : '✗ ' + failed + ' failed, ' + passed + ' passed')); process.exit(failed === 0 ? 0 : 1); })();