Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Parse multiple pssh from "encrypted" init data and fix Widevine key I… #6640

Merged
merged 3 commits into from
Aug 22, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
29 changes: 17 additions & 12 deletions src/controller/eme-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,14 @@ import {
keySystemFormatToKeySystemDomain,
KeySystemIds,
keySystemIdToKeySystemDomain,
} from '../utils/mediakeys-helper';
import {
KeySystems,
requestMediaKeySystemAccess,
} from '../utils/mediakeys-helper';
import { strToUtf8array } from '../utils/utf8-utils';
import { base64Decode } from '../utils/numeric-encoding-utils';
import { DecryptData, LevelKey } from '../loader/level-key';
import Hex from '../utils/hex';
import { bin2str, parsePssh, parseSinf } from '../utils/mp4-tools';
import { bin2str, parseMultiPssh, parseSinf } from '../utils/mp4-tools';
import { EventEmitter } from 'eventemitter3';
import type Hls from '../hls';
import type { ComponentAPI } from '../types/component-api';
Expand Down Expand Up @@ -542,20 +540,24 @@ class EMEController extends Logger implements ComponentAPI {
}
} else {
// Support clear-lead key-session creation (otherwise depend on playlist keys)
const psshInfo = parsePssh(initData);
if (psshInfo === null) {
const psshInfos = parseMultiPssh(initData);
const psshInfo = psshInfos.filter(
cjpillsbury marked this conversation as resolved.
Show resolved Hide resolved
(pssh) => pssh.systemId === KeySystemIds.WIDEVINE,
)[0];
if (!psshInfo) {
return;
}
keySystemDomain = keySystemIdToKeySystemDomain(
psshInfo.systemId as KeySystemIds,
);
if (
psshInfo.version === 0 &&
psshInfo.systemId === KeySystemIds.WIDEVINE &&
psshInfo.data
psshInfo.data &&
psshInfo.data.length >= 30
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Where did 30 bytes come from/why the change in keyId, below? Couldn't figure it out from various specs/"specs" (i.e. example code in various languages in the wild for creating and/or parsing widevine pssh data). Phrased differently, what was wrong with:

keyId = psshInfo.data.subarray(8, 24);

Copy link
Collaborator Author

@robwalch robwalch Aug 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Phrased differently, what was wrong with: Phrased differently, what was wrong with:

The key ID ends at an offset from the end of the data. Not 8 bytes from the start.

question: Where did 30 bytes come from

That was incorrect. The length check has been moved into the helper. The key bytes start 22 bytes from the end of the PSSH data matching how we extract the playlist key ID in level-key.ts.

) {
keyId = psshInfo.data.subarray(8, 24);
const offset = psshInfo.data.length - 22;
keyId = psshInfo.data.subarray(offset, offset + 16);
}
keySystemDomain = keySystemIdToKeySystemDomain(
psshInfo.systemId as KeySystemIds,
);
}

if (!keySystemDomain || !keyId) {
Expand All @@ -570,7 +572,7 @@ class EMEController extends Logger implements ComponentAPI {
// Match playlist key
const keyContext = mediaKeySessions[i];
const decryptdata = keyContext.decryptdata;
if (decryptdata.pssh || !decryptdata.keyId) {
if (!decryptdata.keyId) {
continue;
}
const oldKeyIdHex = Hex.hexDump(decryptdata.keyId);
Expand All @@ -579,6 +581,9 @@ class EMEController extends Logger implements ComponentAPI {
decryptdata.uri.replace(/-/g, '').indexOf(keyIdHex) !== -1
) {
keySessionContextPromise = keyIdToKeySessionPromise[oldKeyIdHex];
if (decryptdata.pssh) {
break;
}
delete keyIdToKeySessionPromise[oldKeyIdHex];
decryptdata.pssh = new Uint8Array(initData);
decryptdata.keyId = keyId;
Expand Down
19 changes: 11 additions & 8 deletions src/utils/mediakeys-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ export function keySystemFormatToKeySystemDomain(

// System IDs for which we can extract a key ID from "encrypted" event PSSH
export const enum KeySystemIds {
// CENC = '1077efecc0b24d02ace33c1e52e2fb4b'
// CLEARKEY = 'e2719d58a985b3c9781ab030af78d30e',
// FAIRPLAY = '94ce86fb07ff4f43adb893d2fa968ca2',
// PLAYREADY = '9a04f07998404286ab92e65be0885f95',
CENC = '1077efecc0b24d02ace33c1e52e2fb4b',
CLEARKEY = 'e2719d58a985b3c9781ab030af78d30e',
FAIRPLAY = '94ce86fb07ff4f43adb893d2fa968ca2',
PLAYREADY = '9a04f07998404286ab92e65be0885f95',
WIDEVINE = 'edef8ba979d64acea3c827dcd51d21ed',
}

Expand All @@ -48,10 +48,13 @@ export function keySystemIdToKeySystemDomain(
): KeySystems | undefined {
if (systemId === KeySystemIds.WIDEVINE) {
return KeySystems.WIDEVINE;
// } else if (systemId === KeySystemIds.PLAYREADY) {
// return KeySystems.PLAYREADY;
// } else if (systemId === KeySystemIds.CENC || systemId === KeySystemIds.CLEARKEY) {
// return KeySystems.CLEARKEY;
} else if (systemId === KeySystemIds.PLAYREADY) {
return KeySystems.PLAYREADY;
} else if (
systemId === KeySystemIds.CENC ||
systemId === KeySystemIds.CLEARKEY
) {
return KeySystems.CLEARKEY;
}
}

Expand Down
80 changes: 54 additions & 26 deletions src/utils/mp4-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { sliceUint8 } from './typed-array';
import { utf8ArrayToStr } from '@svta/common-media-library/utils/utf8ArrayToStr';
import { logger } from '../utils/logger';
import Hex from './hex';
import type { KeySystemIds } from './mediakeys-helper';
import type { PassthroughTrack, UserdataSample } from '../types/demuxer';
import type { DecryptData } from '../loader/level-key';

Expand Down Expand Up @@ -1351,41 +1352,68 @@ export function mp4pssh(
);
}

export function parsePssh(initData: ArrayBuffer) {
if (!(initData instanceof ArrayBuffer) || initData.byteLength < 32) {
return null;
type PsshData = {
version: 0 | 1;
systemId: KeySystemIds;
kids: null | Uint8Array[];
data: null | Uint8Array;
size: number;
};

export function parseMultiPssh(initData: ArrayBuffer): PsshData[] {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: 👌

const results: PsshData[] = [];
if (initData instanceof ArrayBuffer) {
const length = initData.byteLength;
let offset = 0;
while (offset + 32 < length) {
const view = new DataView(initData, offset);
const pssh = parsePssh(view);
if ('systemId' in pssh) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion(non-blocking): should we consider signaling somehow when a pssh box was invalid and thus dropped (would say a dum dum console.warn but that wouldn't get control from hls logger)? We could check this "from the outside" by doing a sum of psshInfo.sizes and comparing to byteLength

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dealing with this outside of helper functions would be preferred so that we keep logging in the controller instances for log messages to have access to player and component context (v1.6 will include logger enhancements including #6131, #6198, #6242).

What do you think about returning all results including the invalid ones and typing them PsshInvalidData. Adding offset to the results might help too.

I expect the most common issue to be incomplete data, but we might also run into bad boundaries/data without the type sync value (0x70737368). I'm not sure how granular we need to be here.

It would help if you could comment on the logging for the "encrypted" event and let me know what elements you'd like to see like selected key-system or pssh system ids found.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool about to hop on a wall of meetings but will try to circle back after. 👌

results.push(pssh);
}
offset += pssh.size;
}
}
const result = {
version: 0,
systemId: '',
kids: null as null | Uint8Array[],
data: null as null | Uint8Array,
};
const view = new DataView(initData);
const boxSize = view.getUint32(0);
if (initData.byteLength !== boxSize && boxSize > 44) {
return null;
return results;
}

function parsePssh(view: DataView): PsshData | { size: number } {
const size = view.getUint32(0);
const length = view.byteLength;
if (view.byteLength < size) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: 😎 shouldn't happen, but good cheap validation.

return { size: length };
}
const type = view.getUint32(4);
if (type !== 0x70737368) {
return null;
return { size };
}
result.version = view.getUint32(8) >>> 24;
if (result.version > 1) {
return null;
const version = view.getUint32(8) >>> 24;
if (version !== 0 && version !== 1) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: probably a better check than prior impl 👌

return { size };
}
result.systemId = Hex.hexDump(new Uint8Array(initData, 12, 16));
const buffer = view.buffer;
const systemId = Hex.hexDump(
new Uint8Array(buffer, view.byteOffset + 12, 16),
) as KeySystemIds;
const dataSizeOrKidCount = view.getUint32(28);
if (result.version === 0) {
if (boxSize - 32 < dataSizeOrKidCount) {
return null;
let kids: null | Uint8Array[] = null;
let data: null | Uint8Array = null;
if (version === 0) {
if (size - 32 < dataSizeOrKidCount) {
return { size };
}
result.data = new Uint8Array(initData, 32, dataSizeOrKidCount);
} else if (result.version === 1) {
result.kids = [];
data = new Uint8Array(buffer, view.byteOffset + 32, dataSizeOrKidCount);
} else if (version === 1) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question(non-blocking): Since this was already status quo before the changes, it doesn't need to be included in this PR, but is there any reason why we "leave the data on the floor" for the parsed pssh for version 1?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's all present in the split kids isn't it?

kids = [];
for (let i = 0; i < dataSizeOrKidCount; i++) {
result.kids.push(new Uint8Array(initData, 32 + i * 16, 16));
kids.push(new Uint8Array(buffer, view.byteOffset + 32 + i * 16, 16));
}
}
return result;
return {
version,
systemId,
kids,
data,
size,
};
}
13 changes: 1 addition & 12 deletions tests/unit/controller/eme-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,18 +295,7 @@ describe('EMEController', function () {

media.emit('encrypted', badData);

expect(emeController.keyIdToKeySessionPromise.f000ba00).to.be.a('Promise');
if (!emeController.keyIdToKeySessionPromise.f000ba00) {
return;
}
return emeController.keyIdToKeySessionPromise.f000ba00
.catch(() => {})
.finally(() => {
expect(emeController.hls.trigger).callCount(1);
expect(emeController.hls.trigger.args[0][1].details).to.equal(
ErrorDetails.KEY_SYSTEM_NO_SESSION,
);
});
expect(emeController.keyIdToKeySessionPromise).to.deep.equal({});
robwalch marked this conversation as resolved.
Show resolved Hide resolved
});

it('should fetch the server certificate and set it into the session', function () {
Expand Down
Loading