Skip to content

Keep the H.264 PPS id readable in E2EE keyframes - #2181

Merged
lukasIO merged 2 commits into
mainfrom
claude/livekit-client-sdk-2180-8f7c1e
Oct 27, 2026
Merged

lukasIO merged 2 commits into
mainfrom
claude/livekit-client-sdk-2180-8f7c1e

Conversation

@lukasIO

@lukasIO lukasIO commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

With E2EE and H.264, Chrome subscribers drop keyframes from publishers that use Intel Quick Sync, and video stalls for seconds. The e2ee worker leaves only the first 2 bytes of the slice NALU unencrypted. Chrome reads pic_parameter_set_id from an IDR slice before the decrypt transform runs. Quick Sync codes slice_type in 7 bits, so the PPS id is the first encrypted bit. The receiver reads a random id and drops the frame. In #2180, 48 of 94 Quick Sync keyframes read as the correct PPS id 0, and one run stalled for 21 s. Fixes #2180.

Change

The sender keeps the encrypted bits readable as the true PPS id. The receiver takes the IV from the frame trailer, so the sender can pick any fresh IV, and receivers need no change.

  • parseH264IdrPpsId() reads first_mb_in_slice, slice_type and pic_parameter_set_id after RBSP unescaping, the way Chrome does.
  • For an IDR slice, the encode path compares the PPS id of the plaintext with the PPS id of the bytes it sends.
  • If the ids differ, it encrypts again with the next IV from makeIV, up to MAX_PPS_ID_ENCRYPT_ATTEMPTS (16).
  • The check escapes only the first 16 bytes of ciphertext, and writeRbsp escapes the whole frame once, after the loop. A retry costs only the AES-GCM encryption.

Delta frames and VideoToolbox keyframes, where the id sits in the plaintext byte, need no extra encryption. For Quick Sync, one bit must match, so a keyframe takes 2 encryptions on average.

A longer plaintext prefix that ends after pic_parameter_set_id also fixes the bug, but I dropped it because it changes the wire format. Old JS receivers and the native SDKs (payload_start_offset + 2 in webrtc-sdk/webrtc) then fail to decrypt.

Failure case

A slice can have more than one PPS id bit past the plaintext byte, for example with a non-zero PPS id. Each extra bit halves the chance that an attempt matches. If all 16 attempts miss, the frame goes out as the last attempt produced it. That is the behavior without this change.

Verified

  • npx vitest run src/e2ee/worker passes, 80 tests. The new parseH264IdrPpsId tests use the Quick Sync and VideoToolbox bytes from E2EE with H.264: encrypted pic_parameter_set_id makes Chrome drop keyframes from some encoders #2180, a non-zero id, a non-IDR slice and a truncated slice.
  • The new FrameCryptor test encrypts 16 Quick Sync keyframes. It checks that each one reads as PPS id 0 and decrypts to the original frame. With MAX_PPS_ID_ENCRYPT_ATTEMPTS set to 1, the test fails on the first frame that reads as a non-zero PPS id.
  • One retry takes 0.016 ms for a 10 KB keyframe, 0.042 ms for 150 KB and 0.085 ms for 500 KB. This is the median of 200 runs of AES-GCM encryption plus the 16-byte escape, in Node 24. Escaping the whole frame on each retry costs 0.04 to 2.1 ms for the same sizes.
  • npx tsc --noEmit, prettier and eslint pass. CI test passes.

Not done yet

  • No end-to-end run with a Quick Sync publisher and a Chrome subscriber.
  • H.265: the 2 plaintext bytes are only the NAL header, so slice_pic_parameter_set_id is always encrypted. It is not known whether Chrome reads it before the transform.
  • Native senders that use webrtc-sdk/webrtc have the same bug. That fix is a separate change in that repo.

🤖 Generated with Claude Code

Chrome reads pic_parameter_set_id from an H.264 IDR slice before the
decrypt transform runs, and drops the keyframe if the id names an
unknown PPS. Encoders such as Intel Quick Sync code slice_type in 7 bits,
so the id lies past the 2 unencrypted slice bytes and gets encrypted.

The sender now encrypts again with a fresh IV, up to 16 times, until the
sent bytes still read as the true PPS id. The receiver takes the IV from
the frame trailer, so the wire format and receivers do not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fb14286

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

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
dist/livekit-client.esm.mjs 116 KB (0%)
dist/livekit-client.umd.js 125.2 KB (0%)
dist/core/core.mjs 78.6 KB (0%)

@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: 1 flag

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

Devin Review

@lukasIO lukasIO self-assigned this Oct 25, 2026
writeRbsp takes about 95% of an encryption attempt on a large keyframe.
The retry loop escaped the whole frame on each attempt, but the PPS id
check needs only the first 16 bytes. Escape those for the check, and
escape the whole frame once, after the loop. A retry then costs only the
AES-GCM encryption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

👍 Would it be worth adding some sort of e2e test for this?

@lukasIO
lukasIO merged commit de930e8 into main Oct 27, 2026
6 checks passed
@lukasIO
lukasIO deleted the claude/livekit-client-sdk-2180-8f7c1e branch October 27, 2026 09:12
@github-actions github-actions Bot mentioned this pull request Oct 27, 2026
@DamienAbcLabs

Copy link
Copy Markdown

Thanks for the quick fix in #2181. We tested it before release on a build of the changeset-release/main branch (2.24.0, dc9703f): an Intel i7-1165G7 sender (Quick Sync H.264 encoder), Edge 156, E2EE on, LiveKit Cloud.

  • On 2.23.0: the subscriber's first video stalled for up to 9.3 s, with 25 PLIs in a block of 12 calls.
  • On the 2.24.0 build: every first frame arrived within 0.5 s, with 0 PLIs.

We have production participants on Intel laptops with E2EE, so this fix matters to us. Is there an expected date for the 2.24.0 release (#2158)?

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.

E2EE with H.264: encrypted pic_parameter_set_id makes Chrome drop keyframes from some encoders

3 participants