Skip to content
Merged
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
---

When an SEI or AUD NALU precedes the slice, encrypt h264 frames from the correct offset. Those NALUs read as h265 slice types, so detection returned h265 and the unencrypted prefix ended inside the leading NALU. The slice header was encrypted and no error was raised. The frame bytes now take precedence over a caller-supplied codec. The sender cryptor is also seeded with the publish codec for h264 and h265. A fallback to VP8 handling now logs 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
251 changes: 149 additions & 102 deletions 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 @@ -306,83 +311,151 @@ describe('FrameCryptor', () => {
);
expect(Array.from(decryptedData)).toEqual(plainTextData);
});
});

it('appends packet trailer after encryption', async () => {
vitest.useFakeTimers();
const now = new Date('2025-04-10T12:00:00.123Z');
vitest.setSystemTime(now);
try {
const { keys, input, output } = prepareParticipantTestEncoder(
participantIdentity,
{},
{ timestamp: true, frameId: true },
);

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

const frame = mockRTCEncodedVideoFrame(
new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
);

input.write(frame);
await vitest.waitFor(() => expect(output.chunks).toHaveLength(1));

const extracted = extractPacketTrailer(frame.data);
expect(extracted.metadata?.userTimestamp).toBeGreaterThanOrEqual(
BigInt(now.getTime()) * BigInt(1000),
);
expect(extracted.metadata?.frameId).toBe(1);

const frameTrailer = new Uint8Array(
extracted.data.buffer.slice(extracted.data.byteLength - 2),
);
expect(frameTrailer[0]).toEqual(IV_LENGTH);
expect(frameTrailer[1]).toEqual(1);
} finally {
vitest.useRealTimers();
}
});

it('does not append packet trailer after encryption when no trailer features are enabled', async () => {
vitest.useFakeTimers();
try {
const { keys, input, output } = prepareParticipantTestEncoder(
participantIdentity,
{},
{ timestamp: false, frameId: false },
);

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

const frame = mockRTCEncodedVideoFrame(
new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
);
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();
}
});

input.write(frame);
await vitest.waitFor(() => expect(output.chunks).toHaveLength(1));
it('appends packet trailer after encryption', async () => {
vitest.useFakeTimers();
const now = new Date('2025-04-10T12:00:00.123Z');
vitest.setSystemTime(now);
try {
const { keys, input, output } = prepareParticipantTestEncoder(
participantIdentity,
{},
{ timestamp: true, frameId: true },
);

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

const frame = mockRTCEncodedVideoFrame(
new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
);

input.write(frame);
await vitest.waitFor(() => expect(output.chunks).toHaveLength(1));

const extracted = extractPacketTrailer(frame.data);
expect(extracted.metadata?.userTimestamp).toBeGreaterThanOrEqual(
BigInt(now.getTime()) * BigInt(1000),
);
expect(extracted.metadata?.frameId).toBe(1);

const frameTrailer = new Uint8Array(
extracted.data.buffer.slice(extracted.data.byteLength - 2),
);
expect(frameTrailer[0]).toEqual(IV_LENGTH);
expect(frameTrailer[1]).toEqual(1);
} finally {
vitest.useRealTimers();
}
});

const extracted = extractPacketTrailer(frame.data);
expect(extracted.metadata).toBeUndefined();
expect(extracted.data.byteLength).toBe(frame.data.byteLength);
it('does not append packet trailer after encryption when no trailer features are enabled', async () => {
vitest.useFakeTimers();
try {
const { keys, input, output } = prepareParticipantTestEncoder(
participantIdentity,
{},
{ timestamp: false, frameId: false },
);

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

const frame = mockRTCEncodedVideoFrame(
new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
);

input.write(frame);
await vitest.waitFor(() => expect(output.chunks).toHaveLength(1));

const extracted = extractPacketTrailer(frame.data);
expect(extracted.metadata).toBeUndefined();
expect(extracted.data.byteLength).toBe(frame.data.byteLength);

const frameTrailer = new Uint8Array(frame.data.slice(frame.data.byteLength - 2));
expect(frameTrailer[0]).toEqual(IV_LENGTH);
expect(frameTrailer[1]).toEqual(1);
} finally {
vitest.useRealTimers();
}
});

const frameTrailer = new Uint8Array(frame.data.slice(frame.data.byteLength - 2));
expect(frameTrailer[0]).toEqual(IV_LENGTH);
expect(frameTrailer[1]).toEqual(1);
} finally {
vitest.useRealTimers();
}
});
it('appends only timestamp packet trailer metadata after encryption', async () => {
vitest.useFakeTimers();
const now = new Date('2025-04-10T12:00:00.123Z');
vitest.setSystemTime(now);
try {
const { keys, input, output } = prepareParticipantTestEncoder(
participantIdentity,
{},
{ timestamp: true },
);

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

const frame = mockRTCEncodedVideoFrame(
new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
);

input.write(frame);
await vitest.waitFor(() => expect(output.chunks).toHaveLength(1));

const extracted = extractPacketTrailer(frame.data);
expect(frame.data.byteLength - extracted.data.byteLength).toBe(15);
expect(extracted.metadata?.userTimestamp).toBeGreaterThanOrEqual(
BigInt(now.getTime()) * BigInt(1000),
);
expect(extracted.metadata?.frameId).toBe(0);
} finally {
vitest.useRealTimers();
}
});

it('appends only timestamp packet trailer metadata after encryption', async () => {
vitest.useFakeTimers();
const now = new Date('2025-04-10T12:00:00.123Z');
vitest.setSystemTime(now);
try {
it('appends only frame id packet trailer metadata after encryption', async () => {
const { keys, input, output } = prepareParticipantTestEncoder(
participantIdentity,
{},
{ timestamp: true },
{ frameId: true },
);

await keys.setKey(await createKeyMaterialFromString('key1'), 1);
Expand All @@ -395,37 +468,11 @@ describe('FrameCryptor', () => {
await vitest.waitFor(() => expect(output.chunks).toHaveLength(1));

const extracted = extractPacketTrailer(frame.data);
expect(frame.data.byteLength - extracted.data.byteLength).toBe(15);
expect(extracted.metadata?.userTimestamp).toBeGreaterThanOrEqual(
BigInt(now.getTime()) * BigInt(1000),
);
expect(extracted.metadata?.frameId).toBe(0);
} finally {
vitest.useRealTimers();
}
});

it('appends only frame id packet trailer metadata after encryption', async () => {
const { keys, input, output } = prepareParticipantTestEncoder(
participantIdentity,
{},
{ frameId: true },
);

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

const frame = mockRTCEncodedVideoFrame(
new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
);

input.write(frame);
await vitest.waitFor(() => expect(output.chunks).toHaveLength(1));

const extracted = extractPacketTrailer(frame.data);
expect(frame.data.byteLength - extracted.data.byteLength).toBe(11);
expect(extracted.metadata).toEqual({
userTimestamp: BigInt(0),
frameId: 1,
expect(frame.data.byteLength - extracted.data.byteLength).toBe(11);
expect(extracted.metadata).toEqual({
userTimestamp: BigInt(0),
frameId: 1,
});
});
});
});
Expand Down
Loading
Loading