Created
August 8, 2026 18:38
-
-
Save arnon-shub/ead3e262e94e8dc8a1e3271f32e8296e to your computer and use it in GitHub Desktop.
Chromium repro: receive-side RTCRtpScriptTransform is never invoked on a sendrecv transceiver (works on sendonly/recvonly; Safari 26.6 runs both correctly). Serve both files from one dir, open xbrowser.html — it runs both topologies and prints a verdict. Chromium 151 affected.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Cross-browser probe worker for the repro. Fully self-contained. | |
| // Counts how many times the encrypt/decrypt transforms are actually INVOKED. | |
| let key = null; | |
| const counts = { enc: 0, dec: 0, encErr: 0, decErr: 0, transforms: [] }; | |
| async function importKey(jwk) { | |
| return crypto.subtle.importKey('jwk', jwk, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); | |
| } | |
| // Leave the codec header cleartext, authenticate it as GCM additionalData, | |
| // mutate frame.data in place. | |
| function headerLen(frame) { | |
| const t = frame.type; // undefined for audio | |
| if (t === undefined) return 1; | |
| return t === 'key' ? 10 : 3; | |
| } | |
| let passthrough = false; | |
| async function encryptFrame(frame, controller) { | |
| counts.enc++; | |
| if (passthrough) { controller.enqueue(frame); return; } | |
| if (!key) return; // fail-closed | |
| try { | |
| const src = new Uint8Array(frame.data); | |
| const hl = Math.min(headerLen(frame), src.length); | |
| const header = src.slice(0, hl); | |
| const body = src.slice(hl); | |
| const iv = crypto.getRandomValues(new Uint8Array(12)); | |
| const ct = new Uint8Array(await crypto.subtle.encrypt( | |
| { name: 'AES-GCM', iv, additionalData: header }, key, body)); | |
| const out = new Uint8Array(hl + 12 + ct.length); | |
| out.set(header, 0); out.set(iv, hl); out.set(ct, hl + 12); | |
| frame.data = out.buffer; | |
| controller.enqueue(frame); | |
| } catch (e) { counts.encErr++; } | |
| } | |
| async function decryptFrame(frame, controller) { | |
| counts.dec++; | |
| if (passthrough) { controller.enqueue(frame); return; } | |
| if (!key) return; | |
| try { | |
| const src = new Uint8Array(frame.data); | |
| const hl = Math.min(headerLen(frame), src.length); | |
| const header = src.slice(0, hl); | |
| const iv = src.slice(hl, hl + 12); | |
| const ct = src.slice(hl + 12); | |
| const pt = new Uint8Array(await crypto.subtle.decrypt( | |
| { name: 'AES-GCM', iv, additionalData: header }, key, ct)); | |
| const out = new Uint8Array(hl + pt.length); | |
| out.set(header, 0); out.set(pt, hl); | |
| frame.data = out.buffer; | |
| controller.enqueue(frame); | |
| } catch (e) { counts.decErr++; } | |
| } | |
| self.onmessage = async (e) => { | |
| const d = e.data; | |
| if (d.type === 'setKey') { key = await importKey(d.key); self.postMessage({ type: 'keySet' }); } | |
| if (d.type === 'passthrough') { passthrough = true; self.postMessage({ type: 'keySet' }); } | |
| if (d.type === 'sample') self.postMessage({ type: 'sample', id: d.id, counts: JSON.parse(JSON.stringify(counts)) }); | |
| if (d.type === 'reset') { counts.enc = counts.dec = counts.encErr = counts.decErr = 0; counts.transforms = []; } | |
| }; | |
| if (typeof RTCTransformEvent !== 'undefined') { | |
| self.onrtctransform = (event) => { | |
| const t = event.transformer; | |
| const op = t.options && t.options.operation; | |
| counts.transforms.push(op); | |
| const fn = op === 'encrypt' ? encryptFrame : decryptFrame; | |
| t.readable.pipeThrough(new TransformStream({ transform: fn })).pipeTo(t.writable) | |
| .catch(err => self.postMessage({ type: 'pipeError', op, err: String(err && err.name) })); | |
| }; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html><head><meta charset="utf-8"><title>frame-E2EE sendrecv probe</title> | |
| <style> | |
| body{font:16px/1.5 -apple-system,system-ui,sans-serif;background:#0A1A2F;color:#e2e8f0;padding:24px} | |
| h1{font-size:22px;margin:0 0 4px} .ua{color:#94a3b8;font-size:13px;margin-bottom:18px;word-break:break-all} | |
| table{border-collapse:collapse;margin-top:12px;font-size:20px} td,th{border:1px solid #1e3a5f;padding:10px 16px;text-align:left} | |
| th{background:#0f172a;font-size:14px;color:#94a3b8} | |
| .ok{color:#34d399;font-weight:700} .bad{color:#f87171;font-weight:700} | |
| #verdict{margin-top:20px;font-size:26px;font-weight:800;padding:14px;border-radius:10px;border:2px solid #1e3a5f} | |
| #status{color:#fbbf24;font-size:18px} | |
| </style></head><body> | |
| <h1>Receive-side RTCRtpScriptTransform — sendrecv vs unidirectional</h1> | |
| <div class="ua" id="ua"></div> | |
| <div id="status">running…</div> | |
| <table id="t"><thead><tr><th>topology</th><th>ICE</th><th>framesEncoded</th><th>framesDecoded</th><th>PLI</th><th>decryptFrame() calls</th><th>encryptFrame() calls</th></tr></thead><tbody></tbody></table> | |
| <div id="verdict"></div> | |
| <script> | |
| document.getElementById('ua').textContent = navigator.userAgent; | |
| // Image-beacon: reports at EVERY stage so an unsupported/error path can't | |
| // fail silently (works without fetch/CORS considerations). | |
| function report(stage, obj) { | |
| try { new Image().src = '/report?stage=' + stage + '&data=' + | |
| encodeURIComponent(JSON.stringify(Object.assign({ stage, ua: navigator.userAgent }, obj))); } catch (e) {} | |
| } | |
| report('env', { isSecureContext: window.isSecureContext, | |
| hasSubtle: !!(window.crypto && window.crypto.subtle), | |
| hasCaptureStream: !!document.createElement('canvas').captureStream }); | |
| report('load', { hasScriptTransform: typeof RTCRtpScriptTransform !== 'undefined', | |
| hasEncodedStreams: typeof RTCRtpSender !== 'undefined' && 'createEncodedStreams' in RTCRtpSender.prototype }); | |
| const rows = []; | |
| const sleep = ms => new Promise(r => setTimeout(r, ms)); | |
| function sourceTrack() { | |
| // Canvas source: real encodable frames, NO camera permission prompt — | |
| // so this runs unattended in Safari and Chrome alike. | |
| const c = document.createElement('canvas'); c.width = 320; c.height = 240; | |
| const ctx = c.getContext('2d'); let i = 0; | |
| setInterval(() => { | |
| i++; ctx.fillStyle = `hsl(${i % 360},70%,45%)`; ctx.fillRect(0, 0, 320, 240); | |
| ctx.fillStyle = '#fff'; ctx.font = '40px sans-serif'; ctx.fillText('f' + i, 20 + (i % 100), 120); | |
| }, 33); | |
| return c.captureStream(30).getVideoTracks()[0]; | |
| } | |
| function sample(w) { | |
| return new Promise(res => { | |
| const id = Math.random().toString(36).slice(2); | |
| const h = e => { if (e.data.type === 'sample' && e.data.id === id) { w.removeEventListener('message', h); res(e.data.counts) } }; | |
| w.addEventListener('message', h); | |
| w.postMessage({ type: 'sample', id }); | |
| }); | |
| } | |
| async function run(topology) { | |
| // ?gum=1 : call getUserMedia once first. A granted gUM is Chromium's | |
| // trigger to expose real host IPs instead of mDNS-only ICE candidates — | |
| // headless bundled Chromium has no mDNS responder, so without this the | |
| // canvas-only probe never completes loopback ICE there. Manual Safari | |
| // runs omit it (no fake-device flags → would prompt for the camera). | |
| if (new URLSearchParams(location.search).has('gum') && !window.__gum) { | |
| try { window.__gum = await navigator.mediaDevices.getUserMedia({ video: true }); } | |
| catch (e) { report('gumfail', { msg: e.message }); } | |
| } | |
| const w = new Worker('probe-worker.js'); | |
| w.onerror = ev => report('workererror', { msg: String(ev && ev.message), file: String(ev && ev.filename) }); | |
| const jwk = await crypto.subtle.exportKey('jwk', | |
| await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])); | |
| await new Promise((r, rej) => { | |
| const to = setTimeout(() => { report('keytimeout', { topology }); rej(new Error('worker key timeout')) }, 6000); | |
| const h = e => { if (e.data.type === 'keySet') { clearTimeout(to); w.removeEventListener('message', h); r() } }; | |
| w.addEventListener('message', h); | |
| w.postMessage(new URLSearchParams(location.search).has('passthrough') ? { type: 'passthrough' } : { type: 'setKey', key: jwk }); | |
| }); | |
| const pc1 = new RTCPeerConnection(), pc2 = new RTCPeerConnection(); | |
| pc1.onicecandidate = e => e.candidate && pc2.addIceCandidate(e.candidate); | |
| pc2.onicecandidate = e => e.candidate && pc1.addIceCandidate(e.candidate); | |
| pc2.ontrack = e => { e.receiver.transform = new RTCRtpScriptTransform(w, { operation: 'decrypt' }); }; | |
| if (topology === 'bidi') { | |
| // Reverse direction added BEFORE negotiation → one shared sendrecv m-line. | |
| pc1.ontrack = e => { e.receiver.transform = new RTCRtpScriptTransform(w, { operation: 'decrypt' }); }; | |
| const s2 = pc2.addTrack(sourceTrack()); | |
| s2.transform = new RTCRtpScriptTransform(w, { operation: 'encrypt' }); | |
| } | |
| const s1 = pc1.addTrack(sourceTrack()); | |
| s1.transform = new RTCRtpScriptTransform(w, { operation: 'encrypt' }); | |
| // Force VP8 on both ends: the probe worker's header offsets are VP8-specific | |
| // (Safari would otherwise negotiate H.264 and the comparison wouldn't be | |
| // apples-to-apples). | |
| try { | |
| if (!new URLSearchParams(location.search).has('vp8')) throw new Error('vp8 forcing off'); | |
| const caps = RTCRtpSender.getCapabilities('video'); | |
| const vp8 = caps.codecs.filter(c => /VP8/i.test(c.mimeType)); | |
| if (vp8.length) for (const pc of [pc1, pc2]) for (const t of pc.getTransceivers()) | |
| if (t.setCodecPreferences) t.setCodecPreferences(vp8); | |
| report('codec', { forced: vp8.map(c => c.mimeType) }); | |
| } catch (e) { report('codecfail', { msg: e.message }); } | |
| const offer = await pc1.createOffer(); await pc1.setLocalDescription(offer); await pc2.setRemoteDescription(offer); | |
| const ans = await pc2.createAnswer(); await pc2.setLocalDescription(ans); await pc1.setRemoteDescription(ans); | |
| await sleep(9000); | |
| let dec = 0, pli = 0, enc = 0; | |
| (await pc2.getStats()).forEach(v => { if (v.type === 'inbound-rtp' && v.kind === 'video') { dec += v.framesDecoded || 0; pli += v.pliCount || 0 } }); | |
| (await pc1.getStats()).forEach(v => { if (v.type === 'outbound-rtp' && v.kind === 'video') { enc += v.framesEncoded || 0 } }); | |
| const ice = pc1.iceConnectionState + '/' + pc2.iceConnectionState; | |
| const dirs = pc1.getTransceivers().map(t => t.currentDirection || t.direction).join(','); | |
| const c = await sample(w); | |
| pc1.close(); pc2.close(); w.terminate(); | |
| return { topology: topology + ' (' + dirs + ')', ice, sent: enc, dec, pli, decCalls: c.dec, encCalls: c.enc }; | |
| } | |
| (async () => { | |
| try { | |
| if (typeof RTCRtpScriptTransform === 'undefined') { | |
| document.getElementById('verdict').innerHTML = '<span class="bad">RTCRtpScriptTransform UNSUPPORTED in this browser</span>'; | |
| document.getElementById('status').textContent = 'done'; | |
| report('unsupported', {}); | |
| window.__done = true; window.__rows = []; return; | |
| } | |
| const only = new URLSearchParams(location.search).get('t'); | |
| for (const t of (only ? [only] : ['uni', 'bidi'])) { | |
| const r = await run(t); | |
| rows.push(r); report('row', { r }); | |
| const tr = document.createElement('tr'); | |
| tr.innerHTML = `<td>${r.topology}</td><td>${r.ice}</td><td>${r.sent}</td><td>${r.dec}</td><td>${r.pli}</td>` + | |
| `<td class="${r.decCalls > 0 ? 'ok' : 'bad'}">${r.decCalls}${r.decCalls > 0 ? ' ✓ RUNS' : ' ✗ NEVER'}</td>` + | |
| `<td class="${r.encCalls > 0 ? 'ok' : 'bad'}">${r.encCalls}</td>`; | |
| document.querySelector('#t tbody').appendChild(tr); | |
| } | |
| const uni = rows[0], bidi = rows[1] || rows[0]; | |
| const noMedia = uni.sent === 0; | |
| const reproduced = rows.length === 2 && uni.decCalls > 0 && bidi.decCalls === 0; | |
| document.getElementById('verdict').innerHTML = noMedia | |
| ? '<span class="bad">NO MEDIA FLOWED (ICE never connected) — result meaningless</span>' | |
| : reproduced | |
| ? '<span class="bad">REPRODUCED — receive transform is INERT on sendrecv</span>' | |
| : (bidi.decCalls > 0 | |
| ? '<span class="ok">NOT reproduced — decrypt runs on sendrecv here</span>' | |
| : '<span class="bad">INCONCLUSIVE — decrypt never ran in either topology</span>'); | |
| document.getElementById('status').textContent = 'done'; | |
| window.__rows = rows; window.__done = true; | |
| report('done', { rows, verdict: document.getElementById('verdict').textContent }); | |
| } catch (e) { | |
| document.getElementById('verdict').innerHTML = '<span class="bad">ERROR: ' + e.message + '</span>'; | |
| document.getElementById('status').textContent = 'error'; | |
| window.__err = e.message; window.__done = true; | |
| report('error', { message: e.message }); | |
| } | |
| })(); | |
| </script></body></html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment