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

Only use selected key-system in EME "mediaencrypted" event handler #6948

Closed
Closed
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
257 changes: 137 additions & 120 deletions src/controller/eme-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,147 +535,164 @@ class EMEController extends Logger implements ComponentAPI {
return;
}

let keyId: Uint8Array | null | undefined;
let keySystemDomain: KeySystems | undefined;

if (
initDataType === 'sinf' &&
this.getLicenseServerUrl(KeySystems.FAIRPLAY)
) {
// Match sinf keyId to playlist skd://keyId=
const json = bin2str(new Uint8Array(initData));
try {
const sinf = base64Decode(JSON.parse(json).sinf);
const tenc = parseSinf(sinf);
if (!tenc) {
throw new Error(
`'schm' box missing or not cbcs/cenc with schi > tenc`,
);
}
keyId = tenc.subarray(8, 24);
keySystemDomain = KeySystems.FAIRPLAY;
} catch (error) {
this.warn(`${logMessage} Failed to parse sinf: ${error}`);
return;
}
} else if (this.getLicenseServerUrl(KeySystems.WIDEVINE)) {
// Support Widevine clear-lead key-session creation (otherwise depend on playlist keys)
const psshResults = parseMultiPssh(initData);

// TODO: If using keySystemAccessPromises we might want to wait until one is resolved
if (!this.keyFormatPromise) {
let keySystems = Object.keys(
this.keySystemAccessPromises,
) as KeySystems[];
if (!keySystems.length) {
keySystems = getKeySystemsForConfig(this.config);
}
const keyFormats = keySystems
.map(keySystemToKeySystemFormat)
.filter((k) => !!k) as KeySystemFormats[];
this.keyFormatPromise = this.getKeyFormatPromise(keyFormats);
}

const psshInfo = psshResults.filter((pssh): pssh is PsshData => {
const keySystem = pssh.systemId
? keySystemIdToKeySystemDomain(pssh.systemId)
: null;
return keySystem ? keySystems.indexOf(keySystem) > -1 : false;
})[0];
this.keyFormatPromise.then((keySystemFormat) => {
Comment on lines +538 to +551
Copy link
Collaborator Author

@robwalch robwalch Jan 8, 2025

Choose a reason for hiding this comment

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

keyFormatPromise = this.getKeyFormatPromise now could happen in three places:

  1. onManifestLoaded for EXT-X-SESSION-KEY session keys
  2. selectKeySystemFormat(frag: Fragment) for HLS media playlist keys (assigned to a fragment being loaded)
  3. (new with this change) onMediaEncrypted at this point we would expect an encrypted fragment to signal its key format(s), but if it hasn't we can check the configured key-systems. It may be good to limit this to the intersection of configured key-systems and init data type and formats. IMO it would be incorrect to configure for a key system supported the device but not present in the media so I don't think that is important to cover.

Copy link
Collaborator Author

@robwalch robwalch Jan 8, 2025

Choose a reason for hiding this comment

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

If the key format promise returned all the results of it's subroutine getKeySystemSelectionPromise (keySystem (format) and mediaKey), the last part pf this method (clear-lead resolution) could be simplified by just returning the results of attemptSetMediaKeys and generateRequestWithPreferredKeySession.

const keySystem = keySystemFormatToKeySystemDomain(keySystemFormat);

if (!psshInfo) {
if (
psshResults.length === 0 ||
psshResults.some((pssh): pssh is PsshInvalidResult => !pssh.systemId)
) {
this.warn(`${logMessage} contains incomplete or invalid pssh data`);
} else {
this.log(
`ignoring ${logMessage} for ${(psshResults as PsshData[])
.map((pssh) => keySystemIdToKeySystemDomain(pssh.systemId))
.join(',')} pssh data in favor of playlist keys`,
);
}
return;
}
let keyId: Uint8Array | null | undefined;
let keySystemDomain: KeySystems | undefined;

keySystemDomain = keySystemIdToKeySystemDomain(psshInfo.systemId);
if (psshInfo.version === 0 && psshInfo.data) {
if (keySystemDomain === KeySystems.WIDEVINE) {
const offset = psshInfo.data.length - 22;
keyId = psshInfo.data.subarray(offset, offset + 16);
} else if (keySystemDomain === KeySystems.PLAYREADY) {
keyId = parsePlayReadyWRM(psshInfo.data);
if (initDataType === 'sinf') {
if (keySystem !== KeySystems.FAIRPLAY) {
this.log(`Ignoring "${event.type}" event with init data type: "${initDataType}" for selected key-system ${keySystem}`);
return;
}
}
}
// Match sinf keyId to playlist skd://keyId=
const json = bin2str(new Uint8Array(initData));
try {
const sinf = base64Decode(JSON.parse(json).sinf);
const tenc = parseSinf(sinf);
if (!tenc) {
throw new Error(
`'schm' box missing or not cbcs/cenc with schi > tenc`,
);
}
keyId = tenc.subarray(8, 24);
keySystemDomain = KeySystems.FAIRPLAY;
} catch (error) {
this.warn(`${logMessage} Failed to parse sinf: ${error}`);
return;
}
} else {
Copy link
Collaborator

Choose a reason for hiding this comment

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

we should probably still check if the key system is either widevine or playready here and early bail + log/warn like we do for the fairplay + sinf case, above.

// Support Widevine/PlayReady clear-lead key-session creation (otherwise depend on playlist keys)
const psshResults = parseMultiPssh(initData);

if (!keySystemDomain || !keyId) {
return;
}
const psshInfo = psshResults.filter(
(pssh): pssh is PsshData =>
!!pssh.systemId &&
keySystemIdToKeySystemDomain(pssh.systemId) === keySystem,
)[0];
Comment on lines +582 to +586
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

If you wanted to iterate through these to find the first key that matched any in mediaKeySessions that would work too. I don't expect we would have multiple pssh with the same key system.

Copy link
Collaborator

Choose a reason for hiding this comment

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

This is probably fine. Small improvement might be to save off the filter, always use the 0th psshInfo, but log/warn if psshInfos.length > 1


const keyIdHex = Hex.hexDump(keyId);
const { keyIdToKeySessionPromise, mediaKeySessions } = this;
if (!psshInfo) {
if (
psshResults.length === 0 ||
psshResults.some(
(pssh): pssh is PsshInvalidResult => !pssh.systemId,
)
) {
this.warn(`${logMessage} contains incomplete or invalid pssh data`);
} else {
this.log(
`ignoring ${logMessage} for ${(psshResults as PsshData[])
.map((pssh) => keySystemIdToKeySystemDomain(pssh.systemId))
.join(',')} pssh data in favor of playlist keys`,
);
}
return;
}

let keySessionContextPromise = keyIdToKeySessionPromise[keyIdHex];
for (let i = 0; i < mediaKeySessions.length; i++) {
// Match playlist key
const keyContext = mediaKeySessions[i];
const decryptdata = keyContext.decryptdata;
if (!decryptdata.keyId) {
continue;
}
const oldKeyIdHex = Hex.hexDump(decryptdata.keyId);
if (
keyIdHex === oldKeyIdHex ||
decryptdata.uri.replace(/-/g, '').indexOf(keyIdHex) !== -1
) {
keySessionContextPromise = keyIdToKeySessionPromise[oldKeyIdHex];
if (decryptdata.pssh) {
break;
keySystemDomain = keySystemIdToKeySystemDomain(psshInfo.systemId);
if (psshInfo.version === 0 && psshInfo.data) {
if (keySystemDomain === KeySystems.WIDEVINE) {
const offset = psshInfo.data.length - 22;
keyId = psshInfo.data.subarray(offset, offset + 16);
} else if (keySystemDomain === KeySystems.PLAYREADY) {
keyId = parsePlayReadyWRM(psshInfo.data);
}
}
delete keyIdToKeySessionPromise[oldKeyIdHex];
decryptdata.pssh = new Uint8Array(initData);
decryptdata.keyId = keyId;
keySessionContextPromise = keyIdToKeySessionPromise[keyIdHex] =
keySessionContextPromise.then(() => {
return this.generateRequestWithPreferredKeySession(
keyContext,
initDataType,
initData,
'encrypted-event-key-match',
);
});
keySessionContextPromise.catch((error) => this.handleError(error));
break;
}
}

if (!keySessionContextPromise) {
// Clear-lead key (not encountered in playlist)
keySessionContextPromise = keyIdToKeySessionPromise[keyIdHex] =
this.getKeySystemSelectionPromise([keySystemDomain]).then(
({ keySystem, mediaKeys }) => {
this.throwIfDestroyed();
const decryptdata = new LevelKey(
'ISO-23001-7',
keyIdHex,
keySystemToKeySystemFormat(keySystem) ?? '',
);
decryptdata.pssh = new Uint8Array(initData);
decryptdata.keyId = keyId as Uint8Array;
return this.attemptSetMediaKeys(keySystem, mediaKeys).then(() => {
this.throwIfDestroyed();
const keySessionContext = this.createMediaKeySessionContext({
decryptdata,
keySystem,
mediaKeys,
});
if (!keySystemDomain || !keyId) {
return;
}

const keyIdHex = Hex.hexDump(keyId);
const { keyIdToKeySessionPromise, mediaKeySessions } = this;

let keySessionContextPromise = keyIdToKeySessionPromise[keyIdHex];
for (let i = 0; i < mediaKeySessions.length; i++) {
// Match playlist key
const keyContext = mediaKeySessions[i];
const decryptdata = keyContext.decryptdata;
if (!decryptdata.keyId) {
continue;
}
const oldKeyIdHex = Hex.hexDump(decryptdata.keyId);
if (
keyIdHex === oldKeyIdHex ||
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;
keySessionContextPromise = keyIdToKeySessionPromise[keyIdHex] =
keySessionContextPromise.then(() => {
return this.generateRequestWithPreferredKeySession(
keySessionContext,
keyContext,
initDataType,
initData,
'encrypted-event-no-match',
'encrypted-event-key-match',
);
});
},
);
keySessionContextPromise.catch((error) => this.handleError(error));
}
keySessionContextPromise.catch((error) => this.handleError(error));
break;
}
}

if (!keySessionContextPromise) {
if (keySystemDomain !== keySystem) {
this.log(`Ignoring "${event.type}" event with ${keySystemDomain} init data for selected key-system ${keySystem}`);
return;
}
// "Clear-lead" (misc key not encountered in playlist)
keySessionContextPromise = keyIdToKeySessionPromise[keyIdHex] =
this.getKeySystemSelectionPromise([keySystemDomain]).then(
({ keySystem, mediaKeys }) => {
this.throwIfDestroyed();

const decryptdata = new LevelKey(
'ISO-23001-7',
keyIdHex,
keySystemToKeySystemFormat(keySystem) ?? '',
);
decryptdata.pssh = new Uint8Array(initData);
decryptdata.keyId = keyId as Uint8Array;
return this.attemptSetMediaKeys(keySystem, mediaKeys).then(() => {
this.throwIfDestroyed();
const keySessionContext = this.createMediaKeySessionContext({
decryptdata,
keySystem,
mediaKeys,
});
return this.generateRequestWithPreferredKeySession(
keySessionContext,
initDataType,
initData,
'encrypted-event-no-match',
);
});
},
);

keySessionContextPromise.catch((error) => this.handleError(error));
}
});
};

private onWaitingForKey = (event: Event) => {
Expand Down
7 changes: 6 additions & 1 deletion src/loader/key-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,12 @@ export default class KeyLoader implements ComponentAPI {
}

load(frag: Fragment): Promise<KeyLoadedData> {
if (!frag.decryptdata && frag.encrypted && this.emeController) {
if (
!frag.decryptdata &&
frag.encrypted &&
this.emeController &&
this.config.emeEnabled
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@cjpillsbury I remember you mentioning that you had issues with the behavior when loading encrypted content that requires EME with emeEnabled: false. This would be the ideal place to log an error since playlists must include keys for encrypted content. !frag.decryptdata && frag.encrypted means we encountered DRM protected content. Keep in mind, there is no error if the key isn't handled because the applications can implement EME on its own. Either way we shouldn't call out to the emeController here when emeEnabled: false which is the reason for the addition of && this.config.emeEnabled.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should treat that as a separate PR/effort, but this looks like a good candidate. There were a few hard to identify cases of errors and this is definitely one. Ideally we'd surface the error as early as possible for root cause analysis reasons. E.g. if we encounter a multi-variant playlist or media playlist with (session) keys but don't have sufficient config for said keys, dispatch an error then (near the playlist parsing stage).

The hardest one that I'm not sure belongs in hls.js at all is when folks think some media is DRM protected but actually isn't. We could make assumptions that if we encounter drm config that means folks think the content is DRM protected, but that's a bad assumption for folks who have mixed content with a shared config. Given the cases of clear lead or valid cases where lower quality renditions may be unencrypted, there may be too many permutations to safely assume this in an overly generic way. Food for thought, though.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Agree this is for another PR.

if we encounter a multi-variant playlist or media playlist with (session) keys

Session keys don't point to variants and may or may not be used. Encrypted fragments (media playlist keys) is the earliest place we can error with certainty.

when folks think some media is DRM protected but actually isn't

Is there an example where that errors? The eme-controller with emeEnabled and drmSystem configs have no effect outside of adding "encrypted" and "waitingforkey" event listeners to the media element (for unprotected content). Key-system access is only attempted when HLS KEY or SESSION-KEY tags are encountered or "encrypted" is emitted.

) {
// Multiple keys, but none selected, resolve in eme-controller
return this.emeController
.selectKeySystemFormat(frag)
Expand Down
26 changes: 22 additions & 4 deletions tests/unit/controller/eme-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import sinonChai from 'sinon-chai';
import EMEController from '../../../src/controller/eme-controller';
import { ErrorDetails } from '../../../src/errors';
import { Events } from '../../../src/events';
import { KeySystemFormats } from '../../../src/utils/mediakeys-helper';
import HlsMock from '../../mocks/hls.mock';
import type { MediaKeySessionContext } from '../../../src/controller/eme-controller';
import type { MediaAttachedData } from '../../../src/types/events';
Expand Down Expand Up @@ -266,6 +267,11 @@ describe('EMEController', function () {
setupEach({
emeEnabled: true,
requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy,
drmSystems: {
'com.apple.fps': {
licenseUrl: '.',
},
},
});

const badData = {
Expand Down Expand Up @@ -293,10 +299,22 @@ describe('EMEController', function () {

media.emit('encrypted', badData);

expect(emeController.keyIdToKeySessionPromise).to.deep.equal(
{},
'`keyIdToKeySessionPromise` should be an empty dictionary when no key IDs are found',
);
return emeController
.selectKeySystemFormat({
levelkeys: {
[KeySystemFormats.FAIRPLAY]: {},
[KeySystemFormats.WIDEVINE]: {},
[KeySystemFormats.PLAYREADY]: {},
},
sn: 0,
type: 'main',
} as any)
.then(() => {
expect(emeController.keyIdToKeySessionPromise).to.deep.equal(
{},
'`keyIdToKeySessionPromise` should be an empty dictionary when no key IDs are found',
);
});
});

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