zebra-report/test/zebra-spaces.test.js
Russell Ballestrini dedb44179d
zebra-spaces: JS↔Go protocol parity + crypto tests
test/zebra-spaces.test.js — pure Node, four tiers:

1. pure protocol parity: extracts sigJoin/sigAction directly from
   web/zebra-spaces.html (so the test tracks the shipped page),
   compares produced 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 one byte the test fails — exactly the
   silent break that would kill promotions in production.

2. ed25519 sign/verify: WebCrypto Ed25519 round-trip + tamper detection,
   the same crypto stack the page uses for signed role transitions.

3. vault round-trip: PBKDF2 600k + AES-GCM, mirrors vaultExport/Import
   in the page. Verifies wrong-password rejection.

4. live server (optional): if ZEBRA_SPACES_BINARY is set, launches the
   relay, dials over real WebSocket, drives full join -> mic-invite ->
   accept flow using browser APIs end to end.

Makefile: 'test-zebra-spaces' target auto-builds the relay binary
from ../proxy.unturf.com when present so the live tier runs without
manual setup. 'test-all' now includes it.
2026-05-31 11:06:59 -04:00

263 lines
11 KiB
JavaScript

#!/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);
})();