Skip to content

Seed the E2EE sender cryptor with the publish codec - #2131

Merged
lukasIO merged 6 commits into
mainfrom
fix/e2ee-safari-h264-sender-codec
Oct 14, 2026
Merged

lukasIO merged 6 commits into
mainfrom
fix/e2ee-safari-h264-sender-codec

Conversation

@lukasIO

@lukasIO lukasIO commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

The problem

The E2EE sender cryptor can encrypt the first frames of a publish without knowing the video codec.

LocalSenderCreated fires inside negotiate(), directly after createSender. That event installs the encryption transform, so the encoder starts to produce frames from that point. LocalTrackPublished fires much later, after addTrack and after the SDP round trip. The Safari-only handler in E2eeManager posts updateCodec on that second event. For the whole window between the two events, the sender cryptor has videoCodec === undefined.

Chrome does not care. getUnencryptedBytes reads this.getVideoCodec(frame) ?? this.videoCodec, and the per-frame metadata carries the codec. Safari has neither source. There is no metadata.mimeType, and there is no payload type to look up in the rtpMap. The codec must therefore come from byte detection on the frame itself. If that detection fails, the catch block falls back to the VP8 shape: a clear prefix of 1 or 10 bytes, and requiresNALUProcessing: false. That also skips the writeRbsp call. A subscriber cannot decode an H.264 frame that carries a VP8-shaped clear prefix and unescaped ciphertext.

What this changes

E2eeManager.setupE2EESender passed undefined as the codec argument of handleSender. It now passes the publish codec from track.publishOptions?.videoCodec, limited to h264 and h265.

  • The limit is deliberate. At this point the codec is the one the client requested, not the one the server confirmed. A wrong vp8 seed would skip the NALU path completely. A wrong NALU seed costs one logged fallback at most.
  • No new plumbing. handleSender(codec?) already puts the codec on the EncodeMessage. The worker already passes it to setupTransform, which applies it under if (codec). The later LocalTrackPublished correction therefore still wins and stays authoritative.
  • Chrome behavior does not change, because per-frame metadata still takes precedence.

The second part of the change is diagnostics. logNALUFallbackOnce logged only the error and the payload type. It now also logs the detected codec, the frame type, the byte length, and the first 16 bytes as hex.

What this does not do

This is a correctness fix and a diagnostics fix. It is not a verified Safari fix.

Nobody reproduced the symptom locally, and there is no capture of the failing frame bytes. The seed cannot change the encrypt result for a frame that already parses, because processNALUsForEncryption resolves knownCodec ?? detectCodecFromNALUs(...) over the same NALU indices and the same slice predicate. The value of the seed is the other half: it turns a silent unknown early return into a thrown error that reaches the new hex log. The two parts only work as a pair, so they ship together. The next user report will show whether Safari sends length-prefixed (AVCC) frames instead of Annex-B, or something else.

This supersedes #1652, which touches findNALUIndices only. That PR stays open. This change does not close it.

Testing

  • src/e2ee/E2eeManager.test.ts: the seed reaches handleSender for h264 and for h265, and vp8 stays unseeded.
  • src/e2ee/worker/FrameCryptor.test.ts: the enriched fallback log fires once.
  • npx tsc --noEmit clean. npx vitest run: 866 passed, 1 skipped. npm run format clean.

@changeset-bot

changeset-bot Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0f9dd59

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
livekit-client Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
dist/livekit-client.esm.mjs 113.04 KB (+0.02% 🔺)
dist/livekit-client.umd.js 122.17 KB (+0.06% 🔺)

devin-ai-integration[bot]

This comment was marked as resolved.

@lukasIO lukasIO changed the title Apply review cleanups to the E2EE sender codec seed Seed the E2EE sender cryptor with the publish codec Oct 5, 2026
@lukasIO
lukasIO marked this pull request as draft October 5, 2026 00:03

@1egoman 1egoman left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally makes sense to me - note I have not tested this, only reviewed the code by eye. I can attempt to test it but you understand the context more so I think you'd probably be the better person.

Is the reason this is still in draft because it still needs to be tested?

Comment thread src/e2ee/worker/naluUtils.ts Outdated
Comment thread src/e2ee/E2eeManager.ts
track.mediaStreamID,
undefined,
isVideoTrack(track)
publishedCodec === 'h264' || publishedCodec === 'h265' ? publishedCodec : undefined,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: I'm not sure if I understand why this has to only be h264 / h265 - based on the issue description, wouldn't be ok to be able to pass in any codec here and let downstream code fall back to this always if this.getVideoCodec(frame) returns a nullish value?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The seed never reaches processNALUsForEncryption. It lands in this.videoCodec, which getUnencryptedBytes reads at FrameCryptor.ts:869 as this.getVideoCodec(frame) ?? this.videoCodec and then branches on before any byte detection runs: av1 throws at :880, vp8 returns at :885, vp9 at :888, and none of the three reach the NALU path at :898.

The value available here is publishOptions.videoCodec — the codec the client asked for, which negotiation can contradict — so a wrong vp8 seed would silently skip NALU processing and ship a VP8-shaped clear prefix on an H.264 frame. h264 and h265 are the only two values where guessing wrong is harmless: it costs one logged fallback, and since the frame bytes now outrank the seed it cannot move the offset at all.

@lukasIO

lukasIO commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

Is the reason this is still in draft because it still needs to be tested?

The code side is tested: naluUtils.test.ts covers the detection fix directly — an h264 frame whose SEI precedes the IDR slice was being read as h265 and sliced at offset 6 instead of 13, which encrypted the slice header with no error raised.

What is missing is a repro of the original Safari report. Nobody has reproduced it and there is no capture of the failing frame bytes, so I cannot claim this is the fix for that symptom — only that it fixes a real mis-detection on the same code path. That gap needs a Safari repro rather than another read of the diff.

Comment thread src/e2ee/worker/naluUtils.ts Outdated
@lukasIO
lukasIO marked this pull request as ready for review October 9, 2026 13:09
lukasIO and others added 5 commits October 14, 2026 14:57
Drop a FrameCryptor test that passed with or without the seed, since
NALU auto-detection returns h264 for its payload anyway. Keep the
Uint8Array construction inside the try block and pass the caller's
already-computed payloadType into the fallback log, so the logged value
cannot disagree with the key that suppresses it. Hoist the duplicated
isVideoTrack call, and dispose the manager in the seeding tests so they
stop leaking a log-level listener.
detectCodecFromNALUs checked the h264 and h265 predicates inside one
loop and returned on the first hit, so a non-slice h264 NALU that reads
as an h265 slice type (AUD 0x09, SEI 0x06) outranked the real h264 slice
behind it. Run the h264 pass to completion first instead; h265 headers
are even at nuh_layer_id 0, so they cannot mask to an h264 slice type.

processNALUsForEncryption also let knownCodec override detection
outright. That codec can be the one the client asked for rather than the
one that was negotiated, and parsing h264 as h265 lands the offset
before the slice header and encrypts it without raising anything. Prefer
a conclusive read of the bytes and keep knownCodec for the inconclusive
case.
Co-authored-by: Ryan Gaus <ryan.gaus@livekit.io>
@lukasIO
lukasIO force-pushed the fix/e2ee-safari-h264-sender-codec branch from 51388a2 to 2a55ad7 Compare October 14, 2026 13:00
@lukasIO
lukasIO merged commit d325ced into main Oct 14, 2026
6 checks passed
@lukasIO
lukasIO deleted the fix/e2ee-safari-h264-sender-codec branch October 14, 2026 13:09
This was referenced Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants