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

Feature: add EME ClearKey support #2934

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/config.ts
Expand Up @@ -44,10 +44,16 @@ export type DRMSystemOptions = {
videoRobustness?: string,
}

export interface KeyidValue {
[keyid: string] : string;
}

export type EMEControllerConfig = {
licenseXhrSetup?: (xhr: XMLHttpRequest, url: string) => void,
emeEnabled: boolean,
widevineLicenseUrl?: string,
clearkeyServerUrl?: string,
clearkeyPair: KeyidValue | null,
drmSystemOptions: DRMSystemOptions,
requestMediaKeySystemAccessFunc: MediaKeyFunc | null,
};
Expand Down Expand Up @@ -244,6 +250,8 @@ export const hlsDefaultConfig: HlsConfig = {
maxLoadingDelay: 4, // used by abr-controller
minAutoBitrate: 0, // used by hls
emeEnabled: false, // used by eme-controller
clearkeyServerUrl: void 0,
clearkeyPair: null,
widevineLicenseUrl: void 0, // used by eme-controller
drmSystemOptions: {}, // used by eme-controller
requestMediaKeySystemAccessFunc: requestMediaKeySystemAccess, // used by eme-controller
Expand Down
158 changes: 150 additions & 8 deletions src/controller/eme-controller.ts
Expand Up @@ -7,7 +7,7 @@ import { Events } from '../events';
import { ErrorTypes, ErrorDetails } from '../errors';

import { logger } from '../utils/logger';
import { DRMSystemOptions, EMEControllerConfig } from '../config';
import { DRMSystemOptions, EMEControllerConfig, KeyidValue } from '../config';
import { KeySystems, MediaKeyFunc } from '../utils/mediakeys-helper';
import Hls from '../hls';
import { ComponentAPI } from '../types/component-api';
Expand Down Expand Up @@ -56,6 +56,36 @@ const createWidevineMediaKeySystemConfigurations = function (
];
};

const createClearkeyMediaKeySystemConfigurations = function (
audioCodecs: string[],
videoCodecs: string[]
): MediaKeySystemConfiguration[] { /* jshint ignore:line */
const baseConfig: MediaKeySystemConfiguration = {
initDataTypes: ['keyids', 'mp4'],
Copy link
Collaborator

Choose a reason for hiding this comment

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

In some browsers; "cenc" is the init data type that will be selected when a org.w3.clearkey keysystem is requested.

Additionally, encryptionScheme: 'cbcs' can be added to further clarify the request

Copy link
Collaborator

Choose a reason for hiding this comment

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

initDataTypes for all keysystems can be found in utils/mediakeys-helper.ts:

https://github.com/video-dev/hls.js/blob/v1.3.0-beta.2/src/utils/mediakeys-helper.ts#L125-L126

// label: "",
// persistentState: "not-allowed", // or "required" ?
// distinctiveIdentifier: "not-allowed", // or "required" ?
// sessionTypes: ['temporary'],
audioCapabilities: [], // { contentType: 'audio/mp4; codecs="mp4a.40.2"' }
videoCapabilities: [] // { contentType: 'video/mp4; codecs="avc1.42E01E"' }
};

audioCodecs.forEach((codec) => {
baseConfig.audioCapabilities!.push({
contentType: `audio/mp4; codecs="${codec}"`
});
});
videoCodecs.forEach((codec) => {
// logger.log(codec);
baseConfig.videoCapabilities!.push({
contentType: `video/mp4; codecs="${codec}"`
});
});
return [
baseConfig
];
};

/**
* The idea here is to handle key-system (and their respective platforms) specific configuration differences
* in order to work with the local requestMediaKeySystemAccess method.
Expand All @@ -77,6 +107,8 @@ const getSupportedMediaKeySystemConfigurations = function (
switch (keySystem) {
case KeySystems.WIDEVINE:
return createWidevineMediaKeySystemConfigurations(audioCodecs, videoCodecs, drmSystemOptions);
case KeySystems.CLEARKEY:
return createClearkeyMediaKeySystemConfigurations(audioCodecs, videoCodecs);
default:
throw new Error(`Unknown key-system: ${keySystem}`);
}
Expand All @@ -102,6 +134,8 @@ class EMEController implements ComponentAPI {
private _widevineLicenseUrl?: string;
private _licenseXhrSetup?: (xhr: XMLHttpRequest, url: string) => void;
private _emeEnabled: boolean;
private _clearkeyServerUrl?: string;
private _clearkeyPair: KeyidValue | null;
private _requestMediaKeySystemAccess: MediaKeyFunc | null;
private _drmSystemOptions: DRMSystemOptions;

Expand All @@ -124,6 +158,8 @@ class EMEController implements ComponentAPI {
this._widevineLicenseUrl = this._config.widevineLicenseUrl;
this._licenseXhrSetup = this._config.licenseXhrSetup;
this._emeEnabled = this._config.emeEnabled;
this._clearkeyServerUrl = this._config.clearkeyServerUrl;
this._clearkeyPair = this._config.clearkeyPair;
this._requestMediaKeySystemAccess = this._config.requestMediaKeySystemAccessFunc;
this._drmSystemOptions = this._config.drmSystemOptions;

Expand Down Expand Up @@ -158,6 +194,11 @@ class EMEController implements ComponentAPI {
break;
}
return this._widevineLicenseUrl;
case KeySystems.CLEARKEY:
if (!this._clearkeyServerUrl) {
break;
}
return this._clearkeyServerUrl;
}

throw new Error(`no license server URL configured for key-system "${keySystem}"`);
Expand Down Expand Up @@ -248,6 +289,89 @@ class EMEController implements ComponentAPI {
});
}

private _handleMessage (keySession: MediaKeySession, message: ArrayBuffer) {
// If you had a license server, you would make an asynchronous XMLHttpRequest
// with event.message as the body. The response from the server, as a
// Uint8Array, would then be passed to session.update().
// Instead, we will generate the license synchronously on the client, using
// the hard-coded KEY.
if (!this._clearkeyPair) {
logger.error('Failed to load the keys');
}

const license = this._generateLicense(message);

keySession.update(license).catch(
function (error) {
logger.error('Failed to update the session', error);
}
);
logger.log(`Received license data (length: ${license ? license.byteLength : license}), updating key-session`);
}

private _generateLicense (message) {
// Parse the clearkey license request.
const request = JSON.parse(new TextDecoder().decode(message));
type responseFormat = {
kty?: string,
alg?: string,
kid?: string,
k?: string
};

const keyarray: responseFormat[] = [];
for (const id of request.kids) {
const decodedBase64 = this._base64ToHex(id);
// logger.log(`decodedBase64: ${decodedBase64}`);
if (!(this._clearkeyPair as KeyidValue).hasOwnProperty(decodedBase64)) {
logger.error('No pair key, please use lower case');
}
keyarray.push(
{
kty: 'oct',
alg: 'A128KW',
Copy link
Collaborator

Choose a reason for hiding this comment

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

The alg property here can be removed.

w3c/encrypted-media#48

kid: id,
k: this._hexToBase64((this._clearkeyPair as KeyidValue)[decodedBase64])
// k: "aeqoAqZ2Ovl56NGUD7iDkg"
}
);
}

logger.log(JSON.stringify({
keys: keyarray,
type: 'temporary'
}));

return new TextEncoder().encode(JSON.stringify({
keys: keyarray,
type: 'temporary'
}));
}

private _hexToBase64 (hexstring) {
var encodedBase64 = btoa(hexstring.match(/\w{2}/g).map(function (a) {
return String.fromCharCode(parseInt(a, 16));
}).join(''));

var start = 0;
var end = encodedBase64.length;
while (end > start && encodedBase64[end - 1] === '=') {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Need to also handle converting + -> -. and / -> _.

I had come up with these helpers when I was doing my validation.

// Helper function to do the Base64 UrlDecoding as described in the ClearKey spec
    function base64urldecode(str) {
      return window.atob(str.replace(/-/g, "+").replace(/_/g, "/"));
    }
    
// Helper function to do the Base64 UrlEncoding as described in the ClearKey spec
    function base64urlencode(str) {
      return window.btoa(str).split('=')[0].replace(/\+/g, "-").replace(/\//g, "_")
    }

--end;
}
return (start > 0 || end < encodedBase64.length) ? encodedBase64.substring(start, end) : encodedBase64;
}

private _base64ToHex (str) {
const raw = atob(str);
logger.log(raw);
let result = '';
for (let i = 0; i < raw.length; i++) {
const hex = raw.charCodeAt(i).toString(16);
result += (hex.length === 2 ? hex : '0' + hex);
}
return result.toLowerCase();
}

/**
* @private
* @param {*} keySession
Expand All @@ -268,10 +392,14 @@ class EMEController implements ComponentAPI {
private _onKeySessionMessage (keySession: MediaKeySession, message: ArrayBuffer) {
logger.log('Got EME message event, creating license request');

this._requestLicense(message, (data: ArrayBuffer) => {
logger.log(`Received license data (length: ${data ? data.byteLength : data}), updating key-session`);
keySession.update(data);
});
if (this._clearkeyPair && !this._clearkeyServerUrl) {
this._handleMessage(keySession, message);
} else {
this._requestLicense(message, (data: ArrayBuffer) => {
logger.log(`Received license data (length: ${data ? data.byteLength : data}), updating key-session`);
keySession.update(data);
});
}
}

/**
Expand Down Expand Up @@ -501,6 +629,10 @@ class EMEController implements ComponentAPI {
case KeySystems.WIDEVINE:
// For Widevine CDMs, the challenge is the keyMessage.
return keyMessage;
case KeySystems.CLEARKEY:
// For CLEARKEY, the challenge is the keyMessage.
Copy link
Collaborator

Choose a reason for hiding this comment

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

It's a PSSH box. If we needed to for whatever reason provide the end-user with the contents of the key message, we could parse like this.

// precompute hex mapping from Uint8
const byteToHex = [];
for (let n = 0; n <= 0xff; ++n) {
    const hexOctet = n.toString(16).padStart(2, "0");
    byteToHex.push(hexOctet);
}

function hexFromAb(arrayBuffer) {
    const buff = new Uint8Array(arrayBuffer);
    const hexOctets = new Array(buff.length);
    for (let i = 0; i < buff.length; ++i) {
        hexOctets[i] = byteToHex[buff[i]];
    }
    return hexOctets.join("");
}

function parsePssh(buffer) {
    const view = new DataView(buffer)
    const boxSize = view.getUint32(0)
    if (buffer.byteLength !== boxSize && boxSize > 44) {
        console.assert('yikes not right')
    }
    const type = view.getUint32(4)
    if (type != 1886614376) { // pssh
        console.assert('yikes not right')
    }
    const fbHeader = view.getUint32(8)
    const version = fbHeader >>> 24
    const flags = fbHeader & 0x00FFFFFF;
    if (version == 1) {
        const systemId = hexFromAb(buffer.slice(12, 28))
        const kidCount = view.getUint32(28)
        const keyIds = []
        for (let i = 0; i < kidCount; i++) {
            keyIds.push(hexFromAb(buffer.slice(32 + (i * 16), 48 + (i * 16))))
        }
        return {
            systemId,
            keyIds
        }
    } else {
        console.assert('unknown version')
    }
}

Copy link
Collaborator

@robwalch robwalch Jan 11, 2023

Choose a reason for hiding this comment

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

mp4pssh parsing has been added to utils/mp4-tools.ts:

https://github.com/video-dev/hls.js/blob/v1.3.0-beta.2/src/utils/mp4-tools.ts#L1108

return keyMessage;
// return JSON.parse(new TextDecoder().decode(keyMessage));
}

throw new Error(`unsupported key-system: ${keysListItem.mediaKeySystemDomain}`);
Expand Down Expand Up @@ -530,7 +662,14 @@ class EMEController implements ComponentAPI {
const xhr = this._createLicenseXhr(url, keyMessage, callback);
logger.log(`Sending license request to URL: ${url}`);
const challenge = this._generateLicenseRequestChallenge(keysListItem, keyMessage);
xhr.send(challenge);
switch (keysListItem.mediaKeySystemDomain) {
case KeySystems.WIDEVINE:
xhr.send(challenge);
case KeySystems.CLEARKEY:
// xhr.setRequestHeader('content-type', 'application/json')
// xhr.send(JSON.stringify(challenge));
xhr.send(challenge);
}
} catch (e) {
logger.error(`Failure requesting DRM license: ${e}`);
this.hls.trigger(Events.ERROR, {
Expand Down Expand Up @@ -589,8 +728,11 @@ class EMEController implements ComponentAPI {
const videoCodecs = data.levels.map((level) => level.videoCodec).filter(
(videoCodec: string | undefined): videoCodec is string => !!videoCodec
);

this._attemptKeySystemAccess(KeySystems.WIDEVINE, audioCodecs, videoCodecs);
if (this._clearkeyPair || this._clearkeyServerUrl) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

We definitely don't want to always prefer setting up a ClearKey session over any real DRM protection if we get an encrypted event from the browser.

I think this is potentially why we want to hook the HLS manifest parsing and preemptively setup a ClearKey MediaKeySession when we detect SAMPLE-AES based content. We can request from the user if they want to decrypt using ClearKey. And thats when we are provided with the ClearKey information the user thinks we will need to do playback.

this._attemptKeySystemAccess(KeySystems.CLEARKEY, audioCodecs, videoCodecs);
} else {
this._attemptKeySystemAccess(KeySystems.WIDEVINE, audioCodecs, videoCodecs);
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/demux/mp4demuxer.ts
Expand Up @@ -60,7 +60,7 @@ class MP4Demuxer implements Demuxer {
}

demuxSampleAes (data: Uint8Array, decryptData: Uint8Array, timeOffset: number): Promise<DemuxerResult> {
return Promise.reject(new Error('The MP4 demuxer does not support SAMPLE-AES decryption'));
return Promise.reject(new Error('The MP4 demuxer does not support SAMPLE-AES decryption, Please specify DRM'));
}

destroy () {}
Expand Down
16 changes: 14 additions & 2 deletions src/demux/transmuxer.ts
Expand Up @@ -14,7 +14,7 @@ import ChunkCache from './chunk-cache';
import { appendUint8Array } from '../utils/mp4-tools';

import { logger } from '../utils/logger';
import { HlsConfig } from '../config';
import { HlsConfig, KeyidValue } from '../config';

let now;
// performance.now() not available on WebWorker, at least on Safari Desktop
Expand Down Expand Up @@ -56,12 +56,18 @@ export default class Transmuxer {
private transmuxConfig!: TransmuxConfig;
private currentTransmuxState!: TransmuxState;
private cache: ChunkCache = new ChunkCache();
private emeEnabled: boolean;
private clearkeyServerUrl?: string;
private clearkeyPair: KeyidValue | null;

constructor (observer: HlsEventEmitter, typeSupported, config: HlsConfig, vendor) {
this.observer = observer;
this.typeSupported = typeSupported;
this.config = config;
this.vendor = vendor;
this.emeEnabled = this.config.emeEnabled;
this.clearkeyServerUrl = this.config.clearkeyServerUrl;
this.clearkeyPair = this.config.clearkeyPair;
}

configure (transmuxConfig: TransmuxConfig, state: TransmuxState) {
Expand Down Expand Up @@ -246,7 +252,13 @@ export default class Transmuxer {
private transmux (data: Uint8Array, decryptData: Uint8Array, encryptionType: string | null, timeOffset: number, accurateTimeOffset: boolean, chunkMeta: ChunkMetadata): TransmuxerResult | Promise<TransmuxerResult> {
let result: TransmuxerResult | Promise<TransmuxerResult>;
if (encryptionType === 'SAMPLE-AES') {
result = this.transmuxSampleAes(data, decryptData, timeOffset, accurateTimeOffset, chunkMeta);
// May Get the URI from the manifest
logger.log('Check if the clearkeyPair or clearkeyServerUrl is specified to play SAMPLE-AES cbcs fMP4');
if (this.emeEnabled && (this.clearkeyPair || this.clearkeyServerUrl)) {
result = this.transmuxUnencrypted(data, timeOffset, accurateTimeOffset, chunkMeta);
} else {
result = this.transmuxSampleAes(data, decryptData, timeOffset, accurateTimeOffset, chunkMeta);
}
} else {
result = this.transmuxUnencrypted(data, timeOffset, accurateTimeOffset, chunkMeta);
}
Expand Down
1 change: 1 addition & 0 deletions src/utils/mediakeys-helper.ts
Expand Up @@ -4,6 +4,7 @@
export enum KeySystems {
WIDEVINE = 'com.widevine.alpha',
PLAYREADY = 'com.microsoft.playready',
CLEARKEY = 'org.w3.clearkey'
}

export type MediaKeyFunc = (keySystem: KeySystems, supportedConfigurations: MediaKeySystemConfiguration[]) => Promise<MediaKeySystemAccess>;
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/controller/eme-controller.js
Expand Up @@ -90,6 +90,8 @@ describe('EMEController', function () {
audioRobustness: 'HW_SECURE_ALL',
videoRobustness: 'HW_SECURE_ALL'
},
clearkeyPair: null,
clearkeyServerUrl: void 0,
requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy
});

Expand Down