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
7 changes: 4 additions & 3 deletions packages/client/src/rtc/e2ee/EncryptionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ import type { E2EEManager } from './E2EEManager';

export type {
E2EEEventMap,
E2EEBrokenEvent,
DecryptionFailedEvent,
DecryptionResumedEvent,
DecryptionStalledEvent,
EncryptionFailedEvent,
KeyStateReport,
MissingKeyEvent,
PerfReport,
TrackPerf,
UnencryptedFrameEvent,
UnsupportedVersionEvent,
} from './events';

/**
Expand Down Expand Up @@ -275,9 +276,9 @@ export class EncryptionManager
* Request a snapshot of the worker's keys. It arrives later as the
* `e2ee.key_state` event, listing fingerprints only, never key material.
*/
requestKeyDump = (): void => {
requestKeyState = (): void => {
this.assertUsable();
this.worker.postMessage({ type: 'cmd.dump_key_state' });
this.worker.postMessage({ type: 'cmd.request_key_state' });
};

private handleWorkerMessage = (e: MessageEvent) => {
Expand Down
182 changes: 107 additions & 75 deletions packages/client/src/rtc/e2ee/SPEC.md

Large diffs are not rendered by default.

17 changes: 12 additions & 5 deletions packages/client/src/rtc/e2ee/__tests__/EncryptionManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ describe('EncryptionManager', () => {
{ type: 'cmd.remove_keys', userId: 'remote-user' },
],
[
'requestKeyDump',
() => manager.requestKeyDump(),
{ type: 'cmd.dump_key_state' },
'requestKeyState',
() => manager.requestKeyState(),
{ type: 'cmd.request_key_state' },
],
[
'enablePerformanceReporting',
Expand Down Expand Up @@ -330,8 +330,15 @@ describe('EncryptionManager', () => {
['e2ee.decryption_resumed', { userId: 'bob', trackType: 'VIDEO' }],
['e2ee.encryption_failed', { userId: 'bob', reason: 'clear-bytes' }],
['e2ee.missing_key', { userId: 'local-user', keyIndex: 2 }],
['e2ee.broken', { userId: 'bob', keyIndex: 3, trackType: 'AUDIO' }],
[
'e2ee.decryption_stalled',
{ userId: 'bob', keyIndex: 3, trackType: 'AUDIO' },
],
['e2ee.unencrypted_frame', { userId: 'bob', trackType: 'VIDEO' }],
[
'e2ee.unsupported_version',
{ userId: 'bob', version: 2, trackType: 'VIDEO' },
],
[
'e2ee.perf_report',
{
Expand Down Expand Up @@ -440,7 +447,7 @@ describe('EncryptionManager', () => {
);
expect(() => manager.removeSharedKey(0)).toThrow(/is disposed/);
expect(() => manager.removeKeys('user')).toThrow(/is disposed/);
expect(() => manager.requestKeyDump()).toThrow(/is disposed/);
expect(() => manager.requestKeyState()).toThrow(/is disposed/);
expect(() => manager.enablePerformanceReporting(true)).toThrow(
/is disposed/,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ describe('FailureTracker', () => {
}
// The next one crosses it - the break transition fires exactly once.
expect(tracker.recordFailure(1)).toBe(true);
expect(tracker.recordFailure(1)).toBe(false); // already broken, no re-fire
expect(tracker.recordFailure(1)).toBe(false); // already stalled, no re-fire
});

it('recordSuccess clears the count and reports whether there were failures', () => {
Expand Down
8 changes: 4 additions & 4 deletions packages/client/src/rtc/e2ee/__tests__/keyStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe('dumpKeyState', () => {
await keyStore.importKey('alice', 1, rawKey(0x01));
await keyStore.importSharedKey(0, rawKey(0x02));

const dump = keyStore.dump();
const dump = keyStore.keyState();
expect(dump.perUserKeys).toHaveLength(1);
expect(dump.perUserKeys[0]).toMatchObject({
userId: 'alice',
Expand All @@ -89,12 +89,12 @@ describe('dumpKeyState', () => {
// What makes the dump useful: two peers can compare prints to confirm they
// hold the same key, under any user id or key index.
await keyStore.importKey('alice', 1, rawKey(0xaa));
const alice = keyStore.dump().perUserKeys[0].fingerprint;
const alice = keyStore.keyState().perUserKeys[0].fingerprint;

keyStore.clear();
await keyStore.importKey('bob', 99, rawKey(0xaa));
await keyStore.importKey('bob', 100, rawKey(0x02));
const [same, different] = keyStore.dump().perUserKeys;
const [same, different] = keyStore.keyState().perUserKeys;

expect(same.fingerprint).toBe(alice);
expect(different.fingerprint).not.toBe(alice);
Expand Down Expand Up @@ -154,7 +154,7 @@ describe('shared-key rotation', () => {
expect(keyStore.getKey('alice', 1)).toBeDefined();
expect(keyStore.getKey('alice', 2)).toBeUndefined();
expect(keyStore.getLatestKey('alice')).toBeNull();
expect(keyStore.dump()).toMatchObject({
expect(keyStore.keyState()).toMatchObject({
sharedKeys: [{ keyIndex: 1, isActive: false }],
});
});
Expand Down
24 changes: 20 additions & 4 deletions packages/client/src/rtc/e2ee/__tests__/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ describe('DecodeNotifier throttling', () => {
it.each([
['decryption_failed', (n: DecodeNotifier) => n.failed()],
['unencrypted_frame', (n: DecodeNotifier) => n.unencrypted()],
['unsupported_version', (n: DecodeNotifier) => n.unsupportedVersion(2)],
])('delivers at most one %s per second', (type, raise) => {
const notify = new DecodeNotifier('bob', 'VIDEO');
raise(notify);
Expand All @@ -76,6 +77,18 @@ describe('DecodeNotifier throttling', () => {
expect(types()).toEqual([`e2ee.${type}`, `e2ee.${type}`]);
});

// The version byte is plaintext, so a relay can rewrite it per frame. Keying
// the throttle by version would hand it one event per distinct value, 255x
// the intended rate, for free.
it('throttles unsupported_version per track, not per version', () => {
const notify = new DecodeNotifier('bob', 'VIDEO');
notify.unsupportedVersion(2);
notify.unsupportedVersion(3);
notify.unsupportedVersion(99);
expect(types()).toEqual(['e2ee.unsupported_version']);
expect(postMessage.mock.calls[0][0].version).toBe(2);
});

it('throttles missing_key per keyIndex, so a rotation still reports', () => {
const notify = new DecodeNotifier('bob', 'VIDEO');
notify.missingKey(1);
Expand All @@ -86,11 +99,14 @@ describe('DecodeNotifier throttling', () => {
expect(postMessage.mock.calls.map(([m]) => m.keyIndex)).toEqual([1, 2]);
});

it('does not throttle broken: it is already once per failure run', () => {
it('does not throttle decryption_stalled: it is already once per failure run', () => {
const notify = new DecodeNotifier('bob', 'VIDEO');
notify.broken(0);
notify.broken(1);
expect(types()).toEqual(['e2ee.broken', 'e2ee.broken']);
notify.stalled(0);
notify.stalled(1);
expect(types()).toEqual([
'e2ee.decryption_stalled',
'e2ee.decryption_stalled',
]);
});

it('scopes throttles per notifier, so one track cannot mute another', () => {
Expand Down
62 changes: 61 additions & 1 deletion packages/client/src/rtc/e2ee/__tests__/trailer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import {
MAX_CLEAR_BYTES,
TRAILER_LEN,
} from '../e2ee-worker/constants';
import { readTrailer, writeTrailer } from '../e2ee-worker/trailer';
import {
readFramingVersion,
readTrailer,
writeTrailer,
} from '../e2ee-worker/trailer';

const makeFrame = (bodyLen: number): Uint8Array =>
new Uint8Array(bodyLen + TRAILER_LEN);
Expand Down Expand Up @@ -81,3 +85,59 @@ describe('writeTrailer + readTrailer', () => {
expect(readTrailer(corrupt(dst))).toBeNull();
});
});

// The identification suffix is frozen across versions, so this must keep
// working against a frame written by a version this build knows nothing about.
describe('readFramingVersion', () => {
const framed = (): Uint8Array => {
const body = 5;
const dst = makeFrame(body);
writeTrailer(dst, body, 1, randomPrefix(), 0, 0, false);
return dst;
};

// Frozen across every version (SPEC 5.2): an older receiver identifies our
// frames from these 5 bytes alone, so a layout change that moves them has to
// fail here rather than silently strand those receivers. Literal values on
// purpose - reading them from the constants would move with the break.
it('pins the identification suffix to the last 5 bytes written', () => {
const dst = framed();
const view = new DataView(dst.buffer);
expect(dst[dst.length - 5]).toBe(1);
expect(view.getUint32(dst.length - 4)).toBe(0xe2eefeed);
});

it('reports the version of a frame carrying our framing', () => {
expect(readFramingVersion(framed())).toBe(1);
});

it('accepts a caller-supplied view over the same bytes', () => {
const dst = framed();
const view = new DataView(dst.buffer, dst.byteOffset, dst.byteLength);
expect(readFramingVersion(dst, view)).toBe(readFramingVersion(dst));
});

it('reports a version this build cannot read, where readTrailer only says null', () => {
const future = framed();
future[future.length - 5] = 99;
expect(readFramingVersion(future)).toBe(99);
expect(readTrailer(future)).toBeNull();
});

it('reads the suffix from the end, so a longer future trailer still resolves', () => {
const grown = new Uint8Array(framed().length + 4);
const src = framed();
// Simulate a v2 trailer with 4 extra bytes ahead of the frozen suffix.
grown.set(src.subarray(0, src.length - 5), 0);
grown.set(src.subarray(src.length - 5), grown.length - 5);
grown[grown.length - 5] = 2;
expect(readFramingVersion(grown)).toBe(2);
});

it('returns null when the frame is not ours', () => {
const notOurs = framed();
notOurs[notOurs.length - 1] ^= 0x01;
expect(readFramingVersion(notOurs)).toBeNull();
expect(readFramingVersion(new Uint8Array(4))).toBeNull();
});
});
46 changes: 40 additions & 6 deletions packages/client/src/rtc/e2ee/__tests__/transform-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,11 @@ afterEach(async () => {
// covered by the module that owns it (perf.test.ts, crypto.test.ts); these
// cover the dispatch reaching it.
describe('worker command interface', () => {
it('answers cmd.dump_key_state with fingerprints, never key material', async () => {
it('answers cmd.request_key_state with fingerprints, never key material', async () => {
const user = freshUser();
await setKey(user, 4);
posted.length = 0;
message({ type: 'cmd.dump_key_state' });
message({ type: 'cmd.request_key_state' });
await flush();
const dump = posted.find((m) => m.type === 'e2ee.key_state') as
{ perUserKeys: Array<{ userId: string; keyIndex: number }> } | undefined;
Expand Down Expand Up @@ -333,6 +333,33 @@ describe('decode pipeline edge behaviors', () => {
]);
});

it('drops a frame from a newer framing version instead of forwarding it', async () => {
const user = freshUser();
await setKey(user);
const [encrypted] = await drive('encode', user, 'vp8', [
frame([1, 2, 3, 4, 5, 6, 7, 8], 'delta'),
]);
const future = new Uint8Array(encrypted.data.slice(0));
future[future.length - 5] = E2EE_VERSION + 1;
posted.length = 0;

const out = await drive('decode', user, undefined, [
{ ...encrypted, data: future.buffer },
]);

// Forwarding would hand ciphertext to the decoder: corrupt media, reported
// as a downgrade. The peer is simply newer, so drop and say which version.
expect(out).toEqual([]);
expect(posted).toEqual([
{
type: 'e2ee.unsupported_version',
userId: user,
version: E2EE_VERSION + 1,
trackType: undefined,
},
]);
});

it('drops and signals missing_key when the key is gone', async () => {
const user = freshUser();
await setKey(user);
Expand Down Expand Up @@ -569,8 +596,13 @@ describe('decode pipeline edge behaviors', () => {
// The break is surfaced once (on the tolerance crossing) and recovery once.
// Both name the track: a peer's audio and video are separate transforms
// reported under one userId, so a host cannot pair them up without this.
expect(posted.filter((m) => m.type === 'e2ee.broken')).toEqual([
{ type: 'e2ee.broken', userId: user, keyIndex: 0, trackType: 'VIDEO' },
expect(posted.filter((m) => m.type === 'e2ee.decryption_stalled')).toEqual([
{
type: 'e2ee.decryption_stalled',
userId: user,
keyIndex: 0,
trackType: 'VIDEO',
},
]);
expect(posted.filter((m) => m.type === 'e2ee.decryption_resumed')).toEqual([
{ type: 'e2ee.decryption_resumed', userId: user, trackType: 'VIDEO' },
Expand Down Expand Up @@ -617,12 +649,14 @@ describe('decode pipeline edge behaviors', () => {
posted.length = 0;
const vOut = await drive('decode', user, undefined, tamperedVideo);
expect(vOut).toHaveLength(0);
expect(posted.filter((m) => m.type === 'e2ee.broken')).toHaveLength(1);
expect(
posted.filter((m) => m.type === 'e2ee.decryption_stalled'),
).toHaveLength(1);

// Audio decode transform (a SEPARATE track): the genuine frame decrypts and
// must NOT emit decryption_resumed - this track never failed. With the old
// per-(user, keyIndex) counter shared across tracks, the audio success reset
// the video failures and spuriously "resumed" (and kept e2ee.broken from
// the video failures and spuriously "resumed" (and kept e2ee.decryption_stalled from
// ever firing).
posted.length = 0;
const aOut = await drive('decode', user, undefined, [audioEnc]);
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/rtc/e2ee/e2ee-worker/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const MAX_CLEAR_BYTES = 0x7fff;

export const EMPTY_AAD = new Uint8Array(0);

/** Consecutive decrypt failures on one track before `e2ee.broken` fires. */
/** Consecutive decrypt failures on one track before `e2ee.decryption_stalled` fires. */
export const FAILURE_TOLERANCE = 10;

/** Replay window in frames. A counter <= highestSeen - this is rejected. */
Expand Down
30 changes: 22 additions & 8 deletions packages/client/src/rtc/e2ee/e2ee-worker/decode.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { EMPTY_AAD, IV_LEN, TRAILER_LEN } from './constants';
import { E2EE_VERSION, EMPTY_AAD, IV_LEN, TRAILER_LEN } from './constants';
import { boundarySeedZeros, rbspUnescape } from './codec';
import { fillIV, readTrailer, readTrailerIv } from './trailer';
import {
fillIV,
readFramingVersion,
readTrailer,
readTrailerIv,
} from './trailer';
import { FailureTracker } from './failureTracker';
import { ReplayWindow } from './replayWindow';
import { keyStore } from './keyStore';
Expand All @@ -23,7 +28,8 @@ export const decodeTransform = (
const ivView = new DataView(iv.buffer);

// Per track, so a user's audio, video and screen share never share a window
// or a failure count. The separate count is what lets e2ee.broken fire.
// or a failure count. The separate count is what lets
// e2ee.decryption_stalled fire.
const replay = new ReplayWindow();
const failures = new FailureTracker();

Expand All @@ -35,7 +41,7 @@ export const decodeTransform = (
* Trust ordering (the SFrame/SRTP rule): a relay can forge `frameCounter`,
* `ivPrefix` and `keyIndex`, which are plaintext in the trailer, so nothing
* changes trust state until GCM authenticates. Hence peek before, commit
* after. The failure counter is diagnostic only - it gates `e2ee.broken`,
* after. The failure counter is diagnostic only - it gates `e2ee.decryption_stalled`,
* never the decrypt attempt - so forged frames cannot mark a key invalid.
*/
const finishDecode = async (
Expand Down Expand Up @@ -70,11 +76,11 @@ export const decodeTransform = (
controller.enqueue(frame);
stats.bump();
} catch {
// True only on the failure crossing the tolerance, so `e2ee.broken` fires
// once per run, not once per frame.
const becameInvalid = failures.recordFailure(keyIndex);
// True only on the failure crossing the tolerance, so
// `e2ee.decryption_stalled` fires once per run, not once per frame.
const stalled = failures.recordFailure(keyIndex);
notify.failed();
if (becameInvalid) notify.broken(keyIndex);
if (stalled) notify.stalled(keyIndex);
}
};

Expand All @@ -90,6 +96,14 @@ export const decodeTransform = (
const trailer = readTrailer(src);

if (!trailer) {
// Ours but unreadable: drop rather than forward. Handing ciphertext to
// the decoder renders corruption and reads to the host as a downgrade,
// when the actual condition is that this build is the older one.
const version = readFramingVersion(src);
if (version !== null && version !== E2EE_VERSION) {
notify.unsupportedVersion(version);
return;
}
notify.unencrypted();
controller.enqueue(frame);
stats.bump();
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ addEventListener('message', ({ data }) => {
if (data.enabled) startPerfReport();
else stopPerfReport();
break;
case 'cmd.dump_key_state':
self.postMessage({ type: 'e2ee.key_state', ...keyStore.dump() });
case 'cmd.request_key_state':
self.postMessage({ type: 'e2ee.key_state', ...keyStore.keyState() });
break;
case 'cmd.setup_transform':
setupTransform(data);
Expand Down
Loading
Loading