Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brown-rings-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'livekit-client': patch
---

Seed the E2EE sender cryptor with the publish codec for h264 and h265. When a non-slice NALU comes before the slice, read an h264 frame as h264. If NALU processing falls back to VP8 handling, log the first bytes of the frame.
43 changes: 43 additions & 0 deletions src/e2ee/E2eeManager.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LogLevel, getWorkerLogLevelListenerCount, setLogLevel, workerLogger } from '../logger';
import Room from '../room/Room';
import { ParticipantEvent } from '../room/events';
import { Track } from '../room/track/Track';
import type { VideoCodec } from '../room/track/options';
import { E2EEManager } from './E2eeManager';
import { BaseKeyProvider } from './KeyProvider';

Expand Down Expand Up @@ -194,3 +197,43 @@ describe('E2EEManager GC cleanup', () => {
},
);
});

describe('E2EEManager sender codec seeding', () => {
function publishVideo(videoCodec: VideoCodec | undefined) {
const { room, worker, manager } = makeManager();
room.localParticipant.identity = 'me';
manager.setup(room);

const sender = {
createEncodedStreams: () => ({
readable: new ReadableStream(),
writable: new WritableStream(),
}),
} as unknown as RTCRtpSender;
const track = {
isLocal: true,
kind: Track.Kind.Video,
mediaStreamID: 'ms1',
publishOptions: { videoCodec },
};

room.localParticipant.emit(ParticipantEvent.LocalSenderCreated, sender, track as any);
manager.dispose();

return worker.postMessage.mock.calls.map(([m]) => m).find((m: any) => m?.kind === 'encode');
}

it('seeds the publish codec for h264', () => {
expect(publishVideo('h264')?.data.codec).toBe('h264');
});

it('seeds the publish codec for h265', () => {
expect(publishVideo('h265')?.data.codec).toBe('h265');
});

it('leaves vp8 unseeded so byte detection still decides', () => {
const encode = publishVideo('vp8');
expect(encode).toBeDefined();
expect(encode.data.codec).toBeUndefined();
});
});
8 changes: 6 additions & 2 deletions src/e2ee/E2eeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,11 +620,15 @@ export class E2EEManager
if (!sender) this.log.warn('early return because sender is not ready');
return;
}
const isVideo = isVideoTrack(track);
// Safari exposes no per-frame codec info until trackInfo arrives. Seed only h264/h265:
// a wrong vp8 seed would skip the NALU path, a wrong NALU seed only costs a logged fallback.
const publishedCodec = isVideo ? track.publishOptions?.videoCodec : undefined;
this.handleSender(
sender,
track.mediaStreamID,
undefined,
isVideoTrack(track)
publishedCodec === 'h264' || publishedCodec === 'h265' ? publishedCodec : undefined,
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

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.

isVideo
? (track.publishOptions?.frameMetadata ?? track.publishOptions?.packetTrailer)
: undefined,
);
Expand Down
46 changes: 45 additions & 1 deletion src/e2ee/worker/FrameCryptor.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { afterEach, describe, expect, it, vitest } from 'vitest';
import { appendPacketTrailer, extractPacketTrailer } from '../../frameMetadata/frameMetadata';
import type { FrameMetadataPublishOptions } from '../../frameMetadata/types';
import { workerLogger } from '../../logger';
import type { VideoCodec } from '../../room/track/options';
import type { NonSharedUint8Array } from '../../type-polyfills/non-shared-typed-arrays';
import { IV_LENGTH, KEY_PROVIDER_DEFAULTS } from '../constants';
import { CryptorEvent } from '../events';
Expand Down Expand Up @@ -71,12 +73,14 @@ function prepareParticipantTestEncoder(
participantIdentity: string,
partialKeyProviderOptions: Partial<KeyProviderOptions>,
packetTrailer?: FrameMetadataPublishOptions,
codec?: VideoCodec,
) {
return prepareParticipantTest(
'encode',
participantIdentity,
partialKeyProviderOptions,
packetTrailer,
codec,
);
}

Expand All @@ -85,6 +89,7 @@ function prepareParticipantTest(
participantIdentity: string,
partialKeyProviderOptions: Partial<KeyProviderOptions>,
packetTrailer?: FrameMetadataPublishOptions,
codec?: VideoCodec,
): {
keys: ParticipantKeyHandler;
cryptor: FrameCryptor;
Expand All @@ -110,7 +115,7 @@ function prepareParticipantTest(
new ReadableStream(input),
new WritableStream(output),
'testTrack',
undefined,
codec,
packetTrailer,
);

Expand Down Expand Up @@ -233,6 +238,45 @@ describe('FrameCryptor', () => {
}
});

it('logs the frame bytes once when NALU processing fails', async () => {
vitest.useFakeTimers();
const warn = vitest.spyOn(workerLogger, 'warn');
try {
const { keys, input, output } = prepareParticipantTestEncoder(
participantIdentity,
{},
undefined,
'h264',
);

await keys.setKey(await createKeyMaterialFromString('key1'), 1);

// No start code at offset 0, so findNALUIndices throws 'byte stream contains
// leading data' and the cryptor falls back to VP8 handling.
input.write(mockRTCEncodedVideoFrame(new Uint8Array([9, 9, 9, 9, 1, 2, 3, 4, 5, 6, 7, 8])));
await vitest.waitFor(() => expect(output.chunks).toHaveLength(1));

expect(warn).toHaveBeenCalledWith(
'NALU processing failed, falling back to VP8 handling',
expect.objectContaining({
detectedCodec: 'h264',
frameType: 'key',
byteLength: 12,
firstBytes: expect.stringMatching(/^09 09 09 09 /),
}),
);

// same payload type, so the once-per-tuple guard must suppress the second log
input.write(mockRTCEncodedVideoFrame(new Uint8Array([8, 8, 8, 8, 1, 2, 3, 4, 5, 6, 7, 8])));
await vitest.waitFor(() => expect(output.chunks).toHaveLength(2));

expect(warn).toHaveBeenCalledTimes(1);
} finally {
warn.mockRestore();
vitest.useRealTimers();
}
});

it('appends packet trailer after encryption', async () => {
vitest.useFakeTimers();
const now = new Date('2025-04-10T12:00:00.123Z');
Expand Down
10 changes: 9 additions & 1 deletion src/e2ee/worker/FrameCryptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -906,7 +906,7 @@ export class FrameCryptor extends BaseFrameCryptor {
};
}
} catch (e) {
this.logNALUFallbackOnce(fallbackKey, payloadType, e);
this.logNALUFallbackOnce(fallbackKey, frame, payloadType, detectedCodec, e);
}

// Fallback to VP8 handling
Expand All @@ -919,7 +919,9 @@ export class FrameCryptor extends BaseFrameCryptor {
*/
private logNALUFallbackOnce(
fallbackKey: string,
frame: RTCEncodedVideoFrame,
payloadType: number | undefined,
detectedCodec: VideoCodec | undefined,
error: unknown,
) {
if (this.loggedNALUFallbacks.has(fallbackKey)) {
Expand All @@ -929,6 +931,12 @@ export class FrameCryptor extends BaseFrameCryptor {
workerLogger.warn('NALU processing failed, falling back to VP8 handling', {
error,
payloadType,
detectedCodec,
frameType: frame.type,
byteLength: frame.data.byteLength,
firstBytes: Array.from(new Uint8Array(frame.data, 0, Math.min(16, frame.data.byteLength)))
.map((b) => b.toString(16).padStart(2, '0'))
.join(' '),
...this.logContext,
});
}
Expand Down
55 changes: 55 additions & 0 deletions src/e2ee/worker/naluUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { processNALUsForEncryption } from './naluUtils';

/** Annex-B frame: a 4-byte start code before every NALU. */
function annexB(...nalus: number[][]) {
return Uint8Array.from(nalus.flatMap((nalu) => [0x00, 0x00, 0x00, 0x01, ...nalu]));
}

// h264: 0x06 SEI, 0x65 IDR slice, 0x67 SPS. h265: 0x26 IDR_W_RADL slice.
// An h264 SEI byte (0x06) reads as h265 TSA_R, which is why the SEI leads here.
const h264KeyFrame = annexB([0x06, 0x11, 0x22], [0x65, 0x33, 0x44, 0x55, 0x66]);
const h265KeyFrame = annexB([0x26, 0x11, 0x22], [0x02, 0x33, 0x44, 0x55, 0x66]);
const noSliceFrame = annexB([0x67, 0x11, 0x22]);

describe('processNALUsForEncryption', () => {
it('slices at the h264 slice NALU even when seeded with h265', () => {
// The whole point: the h265 seed must not win, or the offset lands at 6 (inside the
// SEI) and the slice header gets encrypted with no error raised.
expect(processNALUsForEncryption(h264KeyFrame, 'h265')).toEqual({
unencryptedBytes: 13,
detectedCodec: 'h264',
requiresNALUProcessing: true,
});
});

it('reads an h264 frame whose SEI precedes the slice as h264', () => {
expect(processNALUsForEncryption(h264KeyFrame)).toEqual({
unencryptedBytes: 13,
detectedCodec: 'h264',
requiresNALUProcessing: true,
});
});

it('still reads an h265 frame as h265', () => {
expect(processNALUsForEncryption(h265KeyFrame)).toEqual({
unencryptedBytes: 6,
detectedCodec: 'h265',
requiresNALUProcessing: true,
});
expect(processNALUsForEncryption(h265KeyFrame, 'h264')).toEqual({
unencryptedBytes: 6,
detectedCodec: 'h265',
requiresNALUProcessing: true,
});
});

it('throws with a seed, and returns quietly without one, when no slice NALU is present', () => {
expect(() => processNALUsForEncryption(noSliceFrame, 'h264')).toThrow('Could not find NALU');
expect(processNALUsForEncryption(noSliceFrame)).toEqual({
unencryptedBytes: 0,
detectedCodec: 'unknown',
requiresNALUProcessing: false,
});
});
});
16 changes: 14 additions & 2 deletions src/e2ee/worker/naluUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,14 @@ export interface NALUProcessingResult {
* @returns Detected codec type
*/
function detectCodecFromNALUs(data: NonSharedUint8Array, naluIndices: number[]): DetectedCodec {
// h264 runs to completion first. Several non-slice h264 NALUs read as h265 slice types
// (AUD 0x09 -> STSA_N, SEI 0x06 -> TSA_R), so interleaving the two checks lets one of
// those outrank the real h264 slice that follows it. The reverse cannot happen: h265
// header bytes are even at nuh_layer_id 0, so they never mask to an h264 slice type.
for (const naluIndex of naluIndices) {
if (isH264SliceNALU(parseH264NALUType(data[naluIndex]))) return 'h264';
}
for (const naluIndex of naluIndices) {
if (isH265SliceNALU(parseH265NALUType(data[naluIndex]))) return 'h265';
}
return 'unknown';
Expand Down Expand Up @@ -306,15 +312,21 @@ function findNALUIndices(stream: NonSharedUint8Array): number[] {
/**
* Process NALU data for frame encryption, detecting codec and finding unencrypted bytes
* @param data Frame data
* @param knownCodec Known codec from other sources (optional)
* @param knownCodec Codec from other sources, used only when the bytes are inconclusive (optional)
* @returns NALU processing result
*/
export function processNALUsForEncryption(
data: NonSharedUint8Array,
knownCodec?: 'h264' | 'h265',
): NALUProcessingResult {
const naluIndices = findNALUIndices(data);
const detectedCodec = knownCodec ?? detectCodecFromNALUs(data, naluIndices);
// The bytes outrank knownCodec, which can be the codec the client asked for rather than the
// one that was negotiated. Parsing the wrong one mis-slices without raising anything: the
// offset lands before the real slice header, so the header itself gets encrypted.
let detectedCodec = detectCodecFromNALUs(data, naluIndices);
if (detectedCodec === 'unknown') {
detectedCodec = knownCodec ?? 'unknown';
}

if (detectedCodec === 'unknown') {
return { unencryptedBytes: 0, detectedCodec, requiresNALUProcessing: false };
Expand Down
Loading