Skip to content

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

Draft
lukasIO wants to merge 2 commits into
mainfrom
fix/e2ee-safari-h264-sender-codec
Draft

lukasIO wants to merge 2 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.

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.
@changeset-bot

changeset-bot Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9c18e5b

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 112.28 KB (-0.04% 🔽)
dist/livekit-client.umd.js 121.42 KB (-0.03% 🔽)

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 1 potential issue.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

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.

🟡 Early Safari frames use wrong framing

When Safari negotiates H.264 instead of requested H.265, publishedCodec seeds the sender with H.265. The cryptor uses VP8 framing until LocalTrackPublished updates it, so early H.264 frames fail decryption.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@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
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.

This branch has not been deployed

No deployments
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.

1 participant