// 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 = (window.__WZP_BASE_URL || '') + '/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} 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;