Skip to content

E2EE: frames published by Firefox cannot be decrypted by Chrome ("missing key at index 241") — the reverse direction works #2103

Description

@devousmane

Summary

With end-to-end encryption enabled and a single key at index 0 shared by both
participants, a video track published by Firefox is never decrypted by a Chromium
receiver. The receiver logs missing key at index 241, then 147, then 96 — values
that cannot be a key index, since only index 0 exists.

The opposite direction is fine: a track published by Chromium is decrypted by Firefox with
no decryption failure at all. So this is not key distribution, and not our key provider.

Environment

livekit-client 2.22.3
livekit-server v1.13.6 (self-hosted, Docker)
Publisher Firefox 153 (Playwright build firefox-1538)
Receiver Chromium (Playwright 1.62.1, chromium-1234)
Key provider ExternalE2EEKeyProvider, setKey(<base64>), one key, index 0, identical on both sides
Room options e2ee: { keyProvider, worker }, adaptiveStream: true, dynacast: true, videoCaptureDefaults.resolution = h360. No publishDefaults.videoCodec is set.

Reproduced three times, same signature each time, driven by Playwright (one Firefox
context, one Chromium context, same room, same key).

Interop matrix (measured, one session each, 25 s of media)

Publisher → Receiver Video packets / frames decoded on the receiver Key failures
Chromium → Chromium works 0
Chromium → Firefox 23 / 6 0
Firefox → Firefox 401 / 354 and 337 / 300 both ways 0
Firefox → Chromium 10 / 0 15+ (missing key at index 241, 192, 128)

Firefox → Firefox is the important row: Gecko decrypts what Gecko publishes, with no
failure at all. So frames published by Firefox are encrypted and do carry a
trailer that a Gecko receiver finds. Only a Chromium receiver cannot parse them.

What the receiver sees

  • setting codec on cryptor to {codec: vp8} — so the receiving cryptor is on VP8;
  • then, repeatedly, missing key at index 241 / 147 / 96;
  • no frame is ever decoded; the participant's video stays black.

Why we think the frame layout, not the key, is at fault

In livekit-client.e2ee.worker, the receiver reads the key index from the last byte of
the frame
:

const data = new Uint8Array(encodedFrame.data);
const keyIndex = data[encodedFrame.data.byteLength - 1];

and the number of unencrypted header bytes depends on the detected codec
(getUnencryptedBytes): VP8 uses {key: 10, delta: 3}, VP9 uses 0, H.264/H.265 go
through NALU processing, AV1 is rejected outright. When codec detection fails, it falls
back to this.videoCodec.

Values like 241/147/96 are what you would read if the last byte were payload rather than
the packet trailer — i.e. if the sender's frame did not carry the trailer where the
receiver expects it, or if the two sides disagreed on the frame layout.

What we measured, so you don't have to ask

  • Codec is not the explanation. We read RTCRtpSender/RTCRtpReceiver stats on both
    sides: both publish video/VP8, and the Chromium receiver's cryptor logs
    setting codec on cryptor to {codec: vp8}. So the unencrypted-prefix length
    (UNENCRYPTED_BYTES for VP8) should match on both ends.
  • Encryption is definitely happening on the Firefox publisher. Firefox → Firefox
    decodes 300+ frames with zero decryption failures.
  • So the remaining suspect is the frame trailer as written on the Gecko publish path
    (RTCRtpScriptTransform) versus as read on the Chromium receive path: either its
    position, or whether writing into frame.data survives on Gecko.

Two generations of the browser API, and a recently reworked trailer

Two facts that may or may not be related, but that we could not ignore:

  1. The two browsers are on two different generations of the encoded-transform API.
    Chromium shipped the original createEncodedStreams in 2020; the spec then changed, and
    Firefox shipped the standardised RTCRtpScriptTransform in 2023 (Safari in 2022).
    Chromium only shipped RTCRtpScriptTransform itself in Chrome 141, while keeping the
    legacy entry point working. So a Firefox publisher and a Chromium publisher do not
    necessarily hand the worker a frame with the same conventions, even for the same codec.
  2. The trailer this SDK writes was reworked shortly before the version we run:
    2.20.0Rename PacketTrailer to FrameMetadata, 2.20.1Add support for
    user_data frame metadata trailer type
    . We are on 2.22.3, so both sides of our test
    use the same trailer code; we mention it only because the symptom is exactly "the last
    byte is not where the receiver expects it".

We have no evidence tying either fact to the failure. They are offered as context, not as
a diagnosis.

Pinning the codec does not help (measured 2026-09-14)

We tried the workaround we were about to ask you about, on both participants:

publishDefaults.videoCodec Result on the Chromium receiver
unset (default) missing key at index 241, 147, 96 — 0 frames decoded
"vp8" missing key at index 241 — 0 frames decoded
"h264" missing key at index 155 — 0 frames decoded
"vp9" missing key at index 241 — 0 frames decoded

Four remarks:

  • pinning VP8 changes nothing because VP8 was already what both sides negotiated — we
    report it only so nobody repeats the experiment;
  • H.264 takes a different path in the worker (getUnencryptedBytes → NALU processing
    instead of the VP8 {key: 10, delta: 3} prefix), and fails identically, only with a
    different absurd key index;
  • VP9 is the decisive one. In your own worker, getUnencryptedBytes returns
    { unencryptedBytes: 0, requiresNALUProcessing: false } for VP9 — the entire frame is
    encrypted, no codec-specific parsing, no NALU handling, nothing skipped on either side.
    This is the shortest possible path through the encryption logic. It fails exactly the
    same way
    , down to the same key index (241) as the unset-codec run;
  • with the receiver-side codec branch reduced to the smallest case there is and the
    failure unchanged, the receiver's per-codec unencrypted-byte logic can be ruled out as
    a class
    , not just for one codec. Whatever breaks interoperability is upstream of that
    branch entirely.

So the failure does not depend on codec detection or on how many header bytes are skipped,
in any of the four configurations we tried. That leaves the trailer itself — its presence
or its position in the frame as it arrives on the Chromium receive path when the frame was
written by Firefox's RTCRtpScriptTransform path.

What we ruled out

  • Not key distribution: the same key string is set on both sides, and Chromium → Firefox
    works with that same key.
  • Not our capability gate: isE2EESupported() returns true on Firefox, and encryption
    does activate — Firefox publishes encrypted frames, they simply cannot be read.
  • Not ICE/transport: the session connects, media flows, and audio behaves; only the
    decryption of Firefox-published video fails.

Why it matters to us

We run teleconsultation for a health platform. A patient joining from Firefox appears to be
in the room and believes they are seen, while the practitioner on Chrome sees nothing.

Because that failure is silent on the patient's side, we now block room entry entirely
for Gecko-based browsers rather than warn about it — the room does not open, and there is
no way to override it. We detect the family by the frame-insertion mechanism
(createEncodedStreams vs RTCRtpScriptTransform), not by user agent. This is
deliberately broader than the defect: Firefox → Firefox works, but we cannot know which
browser the other participant will use before the session starts.

We would rather not ship that restriction.

Question

Is Firefox-as-publisher with E2EE expected to interoperate with Chromium receivers in
2.22.x?

If it is, we would appreciate a pointer on where to look next: forcing the codec on both
sides does not help (see above), the key is shared and works in the other direction, and
the trailer code is the same build on both ends. Is there a known difference in how the
frame buffer behaves when written from the RTCRtpScriptTransform path?

If it is not expected to work, we would suggest saying so in the E2EE documentation —
isE2EESupported() returns true on Firefox and encryption does activate, so nothing at
the API level warns an integrator that the session will be one-way.

We checked first: docs.livekit.io/transport/encryption (overview),
docs.livekit.io/transport/encryption/start (the JS getting-started guide), and
docs.livekit.io/transport/media/advanced (the codec reference) — none of them mention
Firefox, Gecko, RTCRtpScriptTransform, createEncodedStreams, or any cross-browser
limitation under E2EE. So we believe this genuinely isn't documented anywhere yet, rather
than us having missed it.

One unrelated discrepancy noticed along the way, worth a separate line either way: the
codec reference lists AV1 (including SVC) as a supported publishing codec, while
getUnencryptedBytes in the E2EE worker throws "av1 is not yet supported for end to end encryption" the moment E2EE is on. A reader following the codec page into an E2EE room
would hit that at runtime with no warning beforehand.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions