Skip to content

Instantly share code, notes, and snippets.

@arnon-shub
Created August 8, 2026 18:38
Show Gist options
  • Select an option

  • Save arnon-shub/ead3e262e94e8dc8a1e3271f32e8296e to your computer and use it in GitHub Desktop.

Select an option

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.
// 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) }));
};
}
<!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