From dedb44179d3cff54b22df1389a42fce223db6a7c Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sun, 31 May 2026 11:06:59 -0400 Subject: [PATCH] =?UTF-8?q?zebra-spaces:=20JS=E2=86=94Go=20protocol=20pari?= =?UTF-8?q?ty=20+=20crypto=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Makefile | 23 +++- test/zebra-spaces.test.js | 263 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 test/zebra-spaces.test.js diff --git a/Makefile b/Makefile index 5b43bc9..f1618eb 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ LDFLAGS = $(shell pkg-config --libs libpulse) -lrt -lpthread PULSE = src/pulse.c -.PHONY: all clean serve blog test test-all test-web stamp zebrad +.PHONY: all clean serve blog test test-all test-web test-zebra-spaces stamp zebrad # stamp each web page with today's date + its own md5/sha256 (run before deploy) stamp: @@ -19,7 +19,24 @@ test: test/unit test-web: @node test/web-protocol.test.js -test-all: test/unit test/integration test/functional test-web +# zebra-spaces JS↔Go protocol parity + vault + ed25519 + (optionally) a live +# server flow. The live-server tier auto-runs when proxy.unturf.com sits +# alongside this checkout AND has a Go toolchain — we build the relay binary +# transparently and point the test at it. Otherwise that tier is skipped and +# only the pure-protocol/crypto tiers run. +test-zebra-spaces: + @bin=""; \ + if [ -d ../proxy.unturf.com/cmd/zebra-spaces-signal ]; then \ + go=$$(command -v go || echo /home/fox/.local/go/bin/go); \ + if [ -x "$$go" ]; then \ + echo "Building zebra-spaces-signal for live-server test..."; \ + (cd ../proxy.unturf.com && "$$go" build -o /tmp/zspc-signal-test ./cmd/zebra-spaces-signal/) && bin=/tmp/zspc-signal-test; \ + fi; \ + fi; \ + ZEBRA_SPACES_BINARY=$$bin node test/zebra-spaces.test.js; \ + rc=$$?; rm -f /tmp/zspc-signal-test; exit $$rc + +test-all: test/unit test/integration test/functional test-web test-zebra-spaces @echo "--- unit ---" @./test/unit @echo "--- integration ---" @@ -28,6 +45,8 @@ test-all: test/unit test/integration test/functional test-web @./test/functional @echo "--- web protocol ---" @node test/web-protocol.test.js + @echo "--- zebra-spaces ---" + @$(MAKE) -s test-zebra-spaces test/unit: test/unit.c include/zebra.h include/modem.h test/test.h $(CC) $(CFLAGS) -o $@ test/unit.c diff --git a/test/zebra-spaces.test.js b/test/zebra-spaces.test.js new file mode 100644 index 0000000..e4bc0da --- /dev/null +++ b/test/zebra-spaces.test.js @@ -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); +})();