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.
This commit is contained in:
parent
1e76f62f71
commit
dedb44179d
2 changed files with 284 additions and 2 deletions
263
test/zebra-spaces.test.js
Normal file
263
test/zebra-spaces.test.js
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
#!/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);
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue