feat: 6 web client variants — all wire-compatible with WZP protocol
3 new WZP-WS variants (speak WZP wire format over WebSocket):
- wzp-ws.js (Variant 4): WZP MediaHeader + raw PCM, no WASM
- wzp-ws-fec.js (Variant 5): WZP + WASM RaptorQ FEC (block=5, symbol=2048)
- wzp-ws-full.js (Variant 6): WZP + FEC + ChaCha20-Poly1305 E2E encryption
Wire protocol compliance (verified against wzp-proto/src/packet.rs):
- MediaHeader 12-byte bit layout: V(1)|T(1)|CodecID(4)|Q(1)|FecRatioHi(1)
- FEC ratio 7-bit encoding across byte0-byte1 boundary
- All fields big-endian (seq u16, timestamp u32)
- Crypto nonce: session_id[4] + seq_be[4] + direction[1] + pad[3]
- HKDF info: "warzone-session-key" (matches wzp-crypto)
Auth flow (matches wzp-relay/src/ws.rs):
- First WS message: {"type":"auth","token":"..."}
- Relay responds: {"type":"auth_ok"} or {"type":"auth_error"}
- All 6 variants handle auth_ok/auth_error text messages
Updated:
- wzp-core.js: detectVariant() accepts ws, ws-fec, ws-full
- index.html: script map + ClientClass dispatch for all 6 variants
- index.html: WASM auto-loading for variants with loadWasm()
URL patterns:
?variant=pure Variant 1: Raw PCM over WS (bridge needed)
?variant=hybrid Variant 2: Raw PCM + WASM FEC (bridge needed)
?variant=full Variant 3: WebTransport + FEC + crypto (no bridge)
?variant=ws Variant 4: WZP protocol over WS (relay direct)
?variant=ws-fec Variant 5: WZP + FEC over WS (relay direct)
?variant=ws-full Variant 6: WZP + FEC + E2E crypto over WS (relay direct)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
749
crates/wzp-web/static/js/wzp-ws-full.js
Normal file
749
crates/wzp-web/static/js/wzp-ws-full.js
Normal file
@@ -0,0 +1,749 @@
|
||||
// WarzonePhone — WZP-WS-Full client (Variant 6).
|
||||
// WebSocket transport, WZP wire protocol, WASM FEC + ChaCha20-Poly1305 E2E.
|
||||
// Full encryption — relay sees only ciphertext.
|
||||
// Sends MediaPacket-formatted frames with FEC + encryption.
|
||||
// Ready for direct relay WS support (no bridge translation needed).
|
||||
|
||||
'use strict';
|
||||
|
||||
// WASM module path (served from /wasm/ by the wzp-web bridge).
|
||||
const WZP_WS_FULL_WASM_PATH = '/wasm/wzp_wasm.js';
|
||||
|
||||
// 12-byte MediaHeader size (matches wzp-proto MediaHeader::WIRE_SIZE).
|
||||
const WZP_WS_FULL_HEADER_SIZE = 12;
|
||||
|
||||
// FEC wire header: block_id(1) + symbol_idx(1) + is_repair(1) = 3 bytes.
|
||||
const WZP_WS_FULL_FEC_HEADER_SIZE = 3;
|
||||
|
||||
// FEC parameters.
|
||||
// A 960-sample Int16 PCM frame = 1920 bytes. Symbol size = 2048
|
||||
// (1920 payload + 2-byte length prefix + 126 bytes padding).
|
||||
const WZP_WS_FULL_BLOCK_SIZE = 5;
|
||||
const WZP_WS_FULL_SYMBOL_SIZE = 2048;
|
||||
|
||||
// Length prefix size within each FEC symbol.
|
||||
const WZP_WS_FULL_LENGTH_PREFIX = 2;
|
||||
|
||||
// ChaCha20-Poly1305 tag size (16 bytes).
|
||||
const WZP_WS_FULL_TAG_SIZE = 16;
|
||||
|
||||
// X25519 public key size (32 bytes).
|
||||
const WZP_WS_FULL_PUBKEY_SIZE = 32;
|
||||
|
||||
class WZPWsFullClient {
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {string} options.wsUrl WebSocket URL (ws://host/ws/room)
|
||||
* @param {string} options.room Room name
|
||||
* @param {Function} options.onAudio callback(Int16Array) for playback
|
||||
* @param {Function} options.onStatus callback(string) for UI status
|
||||
* @param {Function} options.onStats callback(Object) for UI stats
|
||||
*/
|
||||
constructor(options) {
|
||||
this.wsUrl = options.wsUrl;
|
||||
this.room = options.room;
|
||||
this.authToken = options.authToken || null;
|
||||
this.onAudio = options.onAudio || null;
|
||||
this.onStatus = options.onStatus || null;
|
||||
this.onStats = options.onStats || null;
|
||||
|
||||
this.ws = null;
|
||||
this.seq = 0;
|
||||
this.startTimestamp = 0;
|
||||
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
|
||||
this._startTime = 0;
|
||||
this._statsInterval = null;
|
||||
this._connected = false;
|
||||
this._authenticated = false;
|
||||
|
||||
// WASM instances.
|
||||
this._wasmModule = null;
|
||||
this.fecEncoder = null;
|
||||
this.fecDecoder = null;
|
||||
this.cryptoSession = null;
|
||||
this._keyExchange = null;
|
||||
this.wasmReady = false;
|
||||
|
||||
// Key exchange state.
|
||||
this._keyExchangeComplete = false;
|
||||
this._keyExchangeResolve = null;
|
||||
this._keyExchangeReject = null;
|
||||
|
||||
// Current FEC block counter for outgoing packets.
|
||||
this._fecBlockId = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the WASM module (FEC + Crypto).
|
||||
* Called automatically by connect(), or can be called early.
|
||||
*/
|
||||
async loadWasm() {
|
||||
if (this.wasmReady) return;
|
||||
try {
|
||||
this._wasmModule = await import(WZP_WS_FULL_WASM_PATH);
|
||||
await this._wasmModule.default();
|
||||
this.wasmReady = true;
|
||||
console.log('[wzp-ws-full] WASM module loaded successfully');
|
||||
} catch (e) {
|
||||
console.error('[wzp-ws-full] WASM module failed to load:', e);
|
||||
this.wasmReady = false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 12-byte WZP MediaHeader.
|
||||
*
|
||||
* @param {number} seq Sequence number (u16)
|
||||
* @param {number} timestampMs Milliseconds since session start
|
||||
* @param {boolean} isRepair True if this is a FEC repair symbol
|
||||
* @param {number} codecId Codec ID (0=RawPcm16, 1=Opus16k, 2=Opus48k)
|
||||
* @param {number} fecBlock FEC block ID (u8)
|
||||
* @param {number} fecSymbol FEC symbol index (u8)
|
||||
* @param {number} fecRatio FEC ratio (0.0 to ~2.0)
|
||||
* @param {boolean} hasQuality Whether a quality report is attached
|
||||
* @returns {Uint8Array} 12-byte header
|
||||
*/
|
||||
_buildHeader(seq, timestampMs, isRepair = false, codecId = 0, fecBlock = 0, fecSymbol = 0, fecRatio = 0, hasQuality = false) {
|
||||
const buf = new ArrayBuffer(WZP_WS_FULL_HEADER_SIZE);
|
||||
const view = new DataView(buf);
|
||||
|
||||
const fecRatioEncoded = Math.min(127, Math.round(fecRatio * 63.5));
|
||||
const byte0 = ((0 & 0x01) << 7) // version=0
|
||||
| ((isRepair ? 1 : 0) << 6) // T bit
|
||||
| ((codecId & 0x0F) << 2) // CodecID
|
||||
| ((hasQuality ? 1 : 0) << 1) // Q bit
|
||||
| ((fecRatioEncoded >> 6) & 0x01); // FecRatioHi
|
||||
view.setUint8(0, byte0);
|
||||
|
||||
const byte1 = (fecRatioEncoded & 0x3F) << 2;
|
||||
view.setUint8(1, byte1);
|
||||
|
||||
view.setUint16(2, seq & 0xFFFF); // big-endian (default for DataView)
|
||||
view.setUint32(4, timestampMs & 0xFFFFFFFF); // big-endian
|
||||
view.setUint8(8, fecBlock & 0xFF);
|
||||
view.setUint8(9, fecSymbol & 0xFF);
|
||||
view.setUint8(10, 0); // reserved
|
||||
view.setUint8(11, 0); // csrc_count
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a 12-byte MediaHeader from received binary data.
|
||||
*
|
||||
* @param {Uint8Array} data At least 12 bytes
|
||||
* @returns {Object|null} Parsed header fields, or null if too short
|
||||
*/
|
||||
_parseHeader(data) {
|
||||
if (data.byteLength < WZP_WS_FULL_HEADER_SIZE) return null;
|
||||
const view = new DataView(data.buffer || data, data.byteOffset || 0, 12);
|
||||
const byte0 = view.getUint8(0);
|
||||
const byte1 = view.getUint8(1);
|
||||
const fecRatioEncoded = ((byte0 & 0x01) << 6) | ((byte1 >> 2) & 0x3F);
|
||||
return {
|
||||
version: (byte0 >> 7) & 1,
|
||||
isRepair: !!((byte0 >> 6) & 1),
|
||||
codecId: (byte0 >> 2) & 0x0F,
|
||||
hasQuality: !!((byte0 >> 1) & 1),
|
||||
fecRatio: fecRatioEncoded / 63.5,
|
||||
seq: view.getUint16(2),
|
||||
timestamp: view.getUint32(4),
|
||||
fecBlock: view.getUint8(8),
|
||||
fecSymbol: view.getUint8(9),
|
||||
reserved: view.getUint8(10),
|
||||
csrcCount: view.getUint8(11),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad a PCM frame into a FEC symbol with a 2-byte length prefix.
|
||||
*
|
||||
* @param {Uint8Array} pcmBytes Raw PCM bytes
|
||||
* @returns {Uint8Array} Padded symbol of WZP_WS_FULL_SYMBOL_SIZE bytes
|
||||
*/
|
||||
_padToSymbol(pcmBytes) {
|
||||
const symbol = new Uint8Array(WZP_WS_FULL_SYMBOL_SIZE);
|
||||
const len = pcmBytes.length;
|
||||
symbol[0] = (len >> 8) & 0xFF;
|
||||
symbol[1] = len & 0xFF;
|
||||
symbol.set(pcmBytes, WZP_WS_FULL_LENGTH_PREFIX);
|
||||
return symbol;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the original PCM payload from a FEC symbol (strip prefix + padding).
|
||||
*
|
||||
* @param {Uint8Array} symbol Symbol data
|
||||
* @returns {Uint8Array} Original PCM bytes
|
||||
*/
|
||||
_unpadSymbol(symbol) {
|
||||
const len = (symbol[0] << 8) | symbol[1];
|
||||
if (len > WZP_WS_FULL_SYMBOL_SIZE - WZP_WS_FULL_LENGTH_PREFIX) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
return symbol.slice(WZP_WS_FULL_LENGTH_PREFIX, WZP_WS_FULL_LENGTH_PREFIX + len);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open WebSocket connection, load WASM, and perform key exchange.
|
||||
*
|
||||
* Key exchange protocol over WebSocket:
|
||||
* 1. After WS open, send our 32-byte X25519 public key as first binary message.
|
||||
* 2. First received binary message of exactly 32 bytes = peer's public key.
|
||||
* 3. Derive shared secret, create WzpCryptoSession.
|
||||
* 4. All subsequent binary messages are encrypted MediaPackets.
|
||||
*
|
||||
* @returns {Promise<void>} resolves when connected and key exchange completes
|
||||
*/
|
||||
async connect() {
|
||||
if (this._connected) return;
|
||||
|
||||
// Load WASM first (needed for key exchange).
|
||||
await this.loadWasm();
|
||||
|
||||
// Prepare key exchange.
|
||||
this._keyExchange = new this._wasmModule.WzpKeyExchange();
|
||||
this._keyExchangeComplete = false;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this._status('Connecting (WZP-WS-Full) to room: ' + this.room + '...');
|
||||
|
||||
this.ws = new WebSocket(this.wsUrl);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.seq = 0;
|
||||
this.startTimestamp = Date.now();
|
||||
this.stats = { sent: 0, recv: 0, fecRecovered: 0, encrypted: 0, decrypted: 0 };
|
||||
this._startTime = Date.now();
|
||||
this._fecBlockId = 0;
|
||||
|
||||
// Send auth if token provided.
|
||||
if (this.authToken) {
|
||||
this.ws.send(JSON.stringify({ type: 'auth', token: this.authToken }));
|
||||
this._authenticated = false;
|
||||
} else {
|
||||
this._authenticated = true;
|
||||
// No auth needed — proceed directly to key exchange.
|
||||
this._status('Performing key exchange...');
|
||||
const ourPub = this._keyExchange.public_key();
|
||||
this.ws.send(new Uint8Array(ourPub).buffer);
|
||||
}
|
||||
|
||||
// Store resolve/reject for key exchange completion.
|
||||
this._keyExchangeResolve = resolve;
|
||||
this._keyExchangeReject = reject;
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
// Handle text messages (auth responses).
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'auth_ok') {
|
||||
this._authenticated = true;
|
||||
this._status('Authenticated, performing key exchange...');
|
||||
// Auth succeeded — now send public key for key exchange.
|
||||
const ourPub = this._keyExchange.public_key();
|
||||
this.ws.send(new Uint8Array(ourPub).buffer);
|
||||
}
|
||||
if (msg.type === 'auth_error') {
|
||||
this._status('Auth failed: ' + (msg.reason || 'unknown'));
|
||||
if (this._keyExchangeReject) {
|
||||
this._keyExchangeReject(new Error('Auth failed: ' + (msg.reason || 'unknown')));
|
||||
this._keyExchangeResolve = null;
|
||||
this._keyExchangeReject = null;
|
||||
}
|
||||
this._cleanup();
|
||||
}
|
||||
} catch(e) { /* ignore non-JSON text */ }
|
||||
return;
|
||||
}
|
||||
if (!this._keyExchangeComplete) {
|
||||
this._handleKeyExchange(event);
|
||||
} else {
|
||||
this._handleMessage(event);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
const was = this._connected;
|
||||
this._cleanup();
|
||||
if (was) {
|
||||
this._status('Disconnected');
|
||||
} else if (this._keyExchangeReject) {
|
||||
this._keyExchangeReject(new Error('Connection closed during key exchange'));
|
||||
this._keyExchangeResolve = null;
|
||||
this._keyExchangeReject = null;
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
if (!this._connected) {
|
||||
this._cleanup();
|
||||
if (this._keyExchangeReject) {
|
||||
this._keyExchangeReject(new Error('WebSocket connection failed'));
|
||||
this._keyExchangeResolve = null;
|
||||
this._keyExchangeReject = null;
|
||||
} else {
|
||||
reject(new Error('WebSocket connection failed'));
|
||||
}
|
||||
} else {
|
||||
this._status('Connection error');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the key exchange: first binary message of 32 bytes = peer's public key.
|
||||
*/
|
||||
_handleKeyExchange(event) {
|
||||
if (!(event.data instanceof ArrayBuffer)) return;
|
||||
const data = new Uint8Array(event.data);
|
||||
|
||||
if (data.length === WZP_WS_FULL_PUBKEY_SIZE) {
|
||||
// Received peer's public key — derive shared secret.
|
||||
try {
|
||||
const peerPub = data;
|
||||
const secret = this._keyExchange.derive_shared_secret(peerPub);
|
||||
this.cryptoSession = new this._wasmModule.WzpCryptoSession(secret);
|
||||
|
||||
// Free key exchange object (no longer needed).
|
||||
this._keyExchange.free();
|
||||
this._keyExchange = null;
|
||||
|
||||
// Initialize FEC encoder/decoder.
|
||||
this.fecEncoder = new this._wasmModule.WzpFecEncoder(
|
||||
WZP_WS_FULL_BLOCK_SIZE,
|
||||
WZP_WS_FULL_SYMBOL_SIZE
|
||||
);
|
||||
this.fecDecoder = new this._wasmModule.WzpFecDecoder(
|
||||
WZP_WS_FULL_BLOCK_SIZE,
|
||||
WZP_WS_FULL_SYMBOL_SIZE
|
||||
);
|
||||
|
||||
this._keyExchangeComplete = true;
|
||||
this._connected = true;
|
||||
this._startStatsTimer();
|
||||
this._status('Connected (WZP-WS-Full) to room: ' + this.room + ' (encrypted, FEC active)');
|
||||
|
||||
if (this._keyExchangeResolve) {
|
||||
this._keyExchangeResolve();
|
||||
this._keyExchangeResolve = null;
|
||||
this._keyExchangeReject = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[wzp-ws-full] Key exchange failed:', e);
|
||||
if (this._keyExchangeReject) {
|
||||
this._keyExchangeReject(new Error('Key exchange failed: ' + e.message));
|
||||
this._keyExchangeResolve = null;
|
||||
this._keyExchangeReject = null;
|
||||
}
|
||||
this._cleanup();
|
||||
}
|
||||
}
|
||||
// Ignore non-32-byte messages during key exchange.
|
||||
}
|
||||
|
||||
/**
|
||||
* Close WebSocket and clean up all resources.
|
||||
*/
|
||||
disconnect() {
|
||||
this._connected = false;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this._stopStatsTimer();
|
||||
if (this.cryptoSession) {
|
||||
try { this.cryptoSession.free(); } catch (_) { /* ignore */ }
|
||||
this.cryptoSession = null;
|
||||
}
|
||||
if (this.fecEncoder) {
|
||||
try { this.fecEncoder.free(); } catch (_) { /* ignore */ }
|
||||
this.fecEncoder = null;
|
||||
}
|
||||
if (this.fecDecoder) {
|
||||
try { this.fecDecoder.free(); } catch (_) { /* ignore */ }
|
||||
this.fecDecoder = null;
|
||||
}
|
||||
if (this._keyExchange) {
|
||||
try { this._keyExchange.free(); } catch (_) { /* ignore */ }
|
||||
this._keyExchange = null;
|
||||
}
|
||||
this._keyExchangeComplete = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a PCM audio frame with FEC encoding + encryption over the WebSocket.
|
||||
*
|
||||
* Pipeline: PCM -> pad to FEC symbol -> FEC encode -> encrypt -> WS send.
|
||||
*
|
||||
* Each FEC symbol is encrypted individually with ChaCha20-Poly1305. The
|
||||
* 12-byte MediaHeader is used as AAD (authenticated but not encrypted),
|
||||
* so the relay can inspect routing fields without decrypting the payload.
|
||||
*
|
||||
* Wire format per packet:
|
||||
* header(12) + ciphertext(symbol_size) + tag(16)
|
||||
*
|
||||
* @param {ArrayBuffer} pcmBuffer 960-sample Int16 PCM (1920 bytes)
|
||||
*/
|
||||
async sendAudio(pcmBuffer) {
|
||||
if (!this._connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
if (!this.cryptoSession || !this.fecEncoder) return;
|
||||
|
||||
const pcmBytes = new Uint8Array(pcmBuffer);
|
||||
|
||||
// Pad PCM frame to FEC symbol size with length prefix.
|
||||
const symbol = this._padToSymbol(pcmBytes);
|
||||
|
||||
// Feed to FEC encoder. Returns wire data when block completes.
|
||||
const fecOutput = this.fecEncoder.add_symbol(symbol);
|
||||
|
||||
if (fecOutput) {
|
||||
// Block completed — encrypt and send all packets (source + repair).
|
||||
const fecPacketSize = WZP_WS_FULL_FEC_HEADER_SIZE + WZP_WS_FULL_SYMBOL_SIZE;
|
||||
const timestampMs = Date.now() - this.startTimestamp;
|
||||
|
||||
for (let offset = 0; offset + fecPacketSize <= fecOutput.length; offset += fecPacketSize) {
|
||||
const blockId = fecOutput[offset];
|
||||
const symbolIdx = fecOutput[offset + 1];
|
||||
const isRepair = fecOutput[offset + 2] !== 0;
|
||||
const symbolData = fecOutput.slice(
|
||||
offset + WZP_WS_FULL_FEC_HEADER_SIZE,
|
||||
offset + fecPacketSize
|
||||
);
|
||||
|
||||
// Build WZP MediaHeader (used as AAD for encryption).
|
||||
// fecRatio ~0.5 for 50% repair overhead.
|
||||
const header = this._buildHeader(
|
||||
this.seq,
|
||||
timestampMs,
|
||||
isRepair,
|
||||
0, // codecId = RawPcm16
|
||||
blockId,
|
||||
symbolIdx,
|
||||
0.5, // fecRatio
|
||||
false // hasQuality
|
||||
);
|
||||
|
||||
// Encrypt: header as AAD, FEC symbol data as plaintext.
|
||||
// Returns ciphertext + tag (symbol_size + 16 bytes).
|
||||
const ciphertext = this.cryptoSession.encrypt(header, symbolData);
|
||||
this.stats.encrypted++;
|
||||
|
||||
// Wire frame: header(12) + ciphertext_with_tag
|
||||
const packet = new Uint8Array(WZP_WS_FULL_HEADER_SIZE + ciphertext.length);
|
||||
packet.set(header, 0);
|
||||
packet.set(ciphertext, WZP_WS_FULL_HEADER_SIZE);
|
||||
|
||||
this.ws.send(packet.buffer);
|
||||
this.seq = (this.seq + 1) & 0xFFFF;
|
||||
this.stats.sent++;
|
||||
}
|
||||
|
||||
this._fecBlockId++;
|
||||
}
|
||||
// If block not yet complete, accumulate (no packets sent yet).
|
||||
}
|
||||
|
||||
/**
|
||||
* Test crypto + FEC roundtrip entirely in WASM (no network).
|
||||
* Simulates: key exchange -> encrypt -> FEC encode -> simulate loss ->
|
||||
* FEC decode -> decrypt -> verify.
|
||||
*
|
||||
* @returns {Object} Test results
|
||||
*/
|
||||
testCryptoFec() {
|
||||
if (!this.wasmReady || !this._wasmModule) {
|
||||
return { success: false, error: 'WASM module not loaded' };
|
||||
}
|
||||
|
||||
const t0 = performance.now();
|
||||
const wasm = this._wasmModule;
|
||||
|
||||
// --- Key exchange ---
|
||||
const alice = new wasm.WzpKeyExchange();
|
||||
const bob = new wasm.WzpKeyExchange();
|
||||
const aliceSecret = alice.derive_shared_secret(bob.public_key());
|
||||
const bobSecret = bob.derive_shared_secret(alice.public_key());
|
||||
|
||||
let secretsMatch = aliceSecret.length === bobSecret.length;
|
||||
if (secretsMatch) {
|
||||
for (let i = 0; i < aliceSecret.length; i++) {
|
||||
if (aliceSecret[i] !== bobSecret[i]) { secretsMatch = false; break; }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Crypto sessions ---
|
||||
const aliceSession = new wasm.WzpCryptoSession(aliceSecret);
|
||||
const bobSession = new wasm.WzpCryptoSession(bobSecret);
|
||||
|
||||
// --- Encrypt + FEC encode ---
|
||||
const encoder = new wasm.WzpFecEncoder(WZP_WS_FULL_BLOCK_SIZE, WZP_WS_FULL_SYMBOL_SIZE);
|
||||
const decoder = new wasm.WzpFecDecoder(WZP_WS_FULL_BLOCK_SIZE, WZP_WS_FULL_SYMBOL_SIZE);
|
||||
|
||||
// Generate test PCM frames (known data).
|
||||
const originalFrames = [];
|
||||
for (let i = 0; i < WZP_WS_FULL_BLOCK_SIZE; i++) {
|
||||
const frame = new Uint8Array(1920);
|
||||
for (let j = 0; j < 1920; j++) {
|
||||
frame[j] = ((i * 37 + 7) + j) & 0xFF;
|
||||
}
|
||||
originalFrames.push(frame);
|
||||
}
|
||||
|
||||
// Pad and FEC-encode.
|
||||
const paddedSymbols = [];
|
||||
let wireData = null;
|
||||
for (const frame of originalFrames) {
|
||||
const sym = new Uint8Array(WZP_WS_FULL_SYMBOL_SIZE);
|
||||
sym[0] = (frame.length >> 8) & 0xFF;
|
||||
sym[1] = frame.length & 0xFF;
|
||||
sym.set(frame, 2);
|
||||
paddedSymbols.push(sym);
|
||||
|
||||
const result = encoder.add_symbol(sym);
|
||||
if (result) wireData = result;
|
||||
}
|
||||
|
||||
if (!wireData) wireData = encoder.flush();
|
||||
|
||||
// Parse FEC packets and encrypt each one.
|
||||
const FEC_HDR = WZP_WS_FULL_FEC_HEADER_SIZE;
|
||||
const fecPacketSize = FEC_HDR + WZP_WS_FULL_SYMBOL_SIZE;
|
||||
const encryptedPackets = [];
|
||||
|
||||
if (wireData) {
|
||||
for (let offset = 0; offset + fecPacketSize <= wireData.length; offset += fecPacketSize) {
|
||||
const blockId = wireData[offset];
|
||||
const symbolIdx = wireData[offset + 1];
|
||||
const isRepair = wireData[offset + 2] !== 0;
|
||||
const symbolData = wireData.slice(offset + FEC_HDR, offset + fecPacketSize);
|
||||
|
||||
// Build header for AAD (match wire protocol bit layout).
|
||||
const header = new Uint8Array(WZP_WS_FULL_HEADER_SIZE);
|
||||
const fecRatioEncoded = Math.min(127, Math.round(0.5 * 63.5)); // 50% FEC
|
||||
header[0] = ((isRepair ? 1 : 0) << 6)
|
||||
| ((0 & 0x0F) << 2) // codecId=0
|
||||
| ((fecRatioEncoded >> 6) & 0x01); // FecRatioHi
|
||||
header[1] = (fecRatioEncoded & 0x3F) << 2; // FecRatioLo
|
||||
header[8] = blockId;
|
||||
header[9] = symbolIdx;
|
||||
|
||||
// Encrypt with Alice's session.
|
||||
const ciphertext = aliceSession.encrypt(header, symbolData);
|
||||
|
||||
encryptedPackets.push({
|
||||
blockId, symbolIdx, isRepair, header, ciphertext,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sourcePackets = encryptedPackets.filter(p => !p.isRepair).length;
|
||||
const repairPackets = encryptedPackets.filter(p => p.isRepair).length;
|
||||
|
||||
// --- Simulate 30% loss (drop 2 of ~7 packets) ---
|
||||
const dropIndices = new Set([1, 3]);
|
||||
const surviving = encryptedPackets.filter((_, i) => !dropIndices.has(i));
|
||||
|
||||
// --- Decrypt + FEC decode on Bob's side ---
|
||||
let fecDecoded = null;
|
||||
let decryptOk = true;
|
||||
|
||||
for (const pkt of surviving) {
|
||||
let symbolData;
|
||||
try {
|
||||
symbolData = bobSession.decrypt(pkt.header, pkt.ciphertext);
|
||||
} catch (e) {
|
||||
decryptOk = false;
|
||||
break;
|
||||
}
|
||||
|
||||
const result = decoder.add_symbol(pkt.blockId, pkt.symbolIdx, pkt.isRepair, symbolData);
|
||||
if (result) {
|
||||
fecDecoded = result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Verify recovered frames ---
|
||||
let fecOk = false;
|
||||
if (fecDecoded) {
|
||||
fecOk = true;
|
||||
for (let i = 0; i < WZP_WS_FULL_BLOCK_SIZE && fecOk; i++) {
|
||||
const symOffset = i * WZP_WS_FULL_SYMBOL_SIZE;
|
||||
if (symOffset + WZP_WS_FULL_SYMBOL_SIZE > fecDecoded.length) {
|
||||
fecOk = false;
|
||||
break;
|
||||
}
|
||||
const sym = fecDecoded.slice(symOffset, symOffset + WZP_WS_FULL_SYMBOL_SIZE);
|
||||
const len = (sym[0] << 8) | sym[1];
|
||||
const recovered = sym.slice(2, 2 + len);
|
||||
|
||||
if (recovered.length !== originalFrames[i].length) {
|
||||
fecOk = false;
|
||||
break;
|
||||
}
|
||||
for (let j = 0; j < recovered.length; j++) {
|
||||
if (recovered[j] !== originalFrames[i][j]) {
|
||||
fecOk = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup WASM objects.
|
||||
alice.free();
|
||||
bob.free();
|
||||
aliceSession.free();
|
||||
bobSession.free();
|
||||
encoder.free();
|
||||
decoder.free();
|
||||
|
||||
const elapsed = performance.now() - t0;
|
||||
|
||||
return {
|
||||
success: secretsMatch && decryptOk && fecOk,
|
||||
secretsMatch,
|
||||
decryptOk,
|
||||
fecOk,
|
||||
sourcePackets,
|
||||
repairPackets,
|
||||
totalPackets: encryptedPackets.length,
|
||||
dropped: dropIndices.size,
|
||||
surviving: surviving.length,
|
||||
elapsed: elapsed.toFixed(2) + 'ms',
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
_handleMessage(event) {
|
||||
if (!(event.data instanceof ArrayBuffer)) return;
|
||||
const data = new Uint8Array(event.data);
|
||||
if (data.length < WZP_WS_FULL_HEADER_SIZE) return;
|
||||
|
||||
const header = this._parseHeader(data);
|
||||
if (!header) return;
|
||||
|
||||
this.stats.recv++;
|
||||
|
||||
if (!this.cryptoSession || !this.fecDecoder) return;
|
||||
|
||||
// Extract header bytes (AAD) and ciphertext.
|
||||
const headerBytes = data.slice(0, WZP_WS_FULL_HEADER_SIZE);
|
||||
const ciphertext = data.slice(WZP_WS_FULL_HEADER_SIZE);
|
||||
|
||||
// Decrypt.
|
||||
let symbolData;
|
||||
try {
|
||||
symbolData = this.cryptoSession.decrypt(headerBytes, ciphertext);
|
||||
this.stats.decrypted++;
|
||||
} catch (e) {
|
||||
// Decryption failure — corrupted or replayed packet.
|
||||
console.warn('[wzp-ws-full] decrypt failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Feed decrypted symbol to FEC decoder.
|
||||
const decoded = this.fecDecoder.add_symbol(
|
||||
header.fecBlock,
|
||||
header.fecSymbol,
|
||||
header.isRepair,
|
||||
symbolData
|
||||
);
|
||||
|
||||
if (decoded) {
|
||||
this.stats.fecRecovered++;
|
||||
|
||||
// decoded is concatenated padded symbols.
|
||||
// Each symbol is WZP_WS_FULL_SYMBOL_SIZE bytes with a 2-byte length prefix.
|
||||
for (let off = 0; off + WZP_WS_FULL_SYMBOL_SIZE <= decoded.length; off += WZP_WS_FULL_SYMBOL_SIZE) {
|
||||
const symbol = decoded.slice(off, off + WZP_WS_FULL_SYMBOL_SIZE);
|
||||
const pcmBytes = this._unpadSymbol(symbol);
|
||||
|
||||
if (pcmBytes.length > 0 && pcmBytes.length % 2 === 0) {
|
||||
const pcm = new Int16Array(
|
||||
pcmBytes.buffer,
|
||||
pcmBytes.byteOffset,
|
||||
pcmBytes.byteLength / 2
|
||||
);
|
||||
if (this.onAudio) this.onAudio(pcm);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_startStatsTimer() {
|
||||
this._stopStatsTimer();
|
||||
this._statsInterval = setInterval(() => {
|
||||
if (!this._connected) {
|
||||
this._stopStatsTimer();
|
||||
return;
|
||||
}
|
||||
const elapsed = (Date.now() - this._startTime) / 1000;
|
||||
const loss = this.stats.sent > 0
|
||||
? Math.max(0, 1 - this.stats.recv / this.stats.sent)
|
||||
: 0;
|
||||
if (this.onStats) {
|
||||
this.onStats({
|
||||
sent: this.stats.sent,
|
||||
recv: this.stats.recv,
|
||||
loss: loss,
|
||||
elapsed: elapsed,
|
||||
encrypted: this.stats.encrypted,
|
||||
decrypted: this.stats.decrypted,
|
||||
fecRecovered: this.stats.fecRecovered,
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
_stopStatsTimer() {
|
||||
if (this._statsInterval) {
|
||||
clearInterval(this._statsInterval);
|
||||
this._statsInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
_status(msg) {
|
||||
if (this.onStatus) this.onStatus(msg);
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
this._connected = false;
|
||||
this._keyExchangeComplete = false;
|
||||
this._stopStatsTimer();
|
||||
if (this.ws) {
|
||||
try { this.ws.close(); } catch (_) { /* ignore */ }
|
||||
this.ws = null;
|
||||
}
|
||||
if (this.cryptoSession) {
|
||||
try { this.cryptoSession.free(); } catch (_) { /* ignore */ }
|
||||
this.cryptoSession = null;
|
||||
}
|
||||
if (this.fecEncoder) {
|
||||
try { this.fecEncoder.free(); } catch (_) { /* ignore */ }
|
||||
this.fecEncoder = null;
|
||||
}
|
||||
if (this.fecDecoder) {
|
||||
try { this.fecDecoder.free(); } catch (_) { /* ignore */ }
|
||||
this.fecDecoder = null;
|
||||
}
|
||||
if (this._keyExchange) {
|
||||
try { this._keyExchange.free(); } catch (_) { /* ignore */ }
|
||||
this._keyExchange = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
window.WZPWsFullClient = WZPWsFullClient;
|
||||
Reference in New Issue
Block a user