diff --git a/build/types/core b/build/types/core index 25a5a6d58..e369803dd 100644 --- a/build/types/core +++ b/build/types/core @@ -33,6 +33,7 @@ +../../lib/media/adaptation_set.js +../../lib/media/buffering_observer.js ++../../lib/media/clearkey_webcrypto_decryptor.js +../../lib/media/closed_caption_parser.js +../../lib/media/content_workarounds.js +../../lib/media/gap_jumping_controller.js diff --git a/externs/shaka/drm_info.js b/externs/shaka/drm_info.js index bcd54a3f4..ab6cd7a13 100644 --- a/externs/shaka/drm_info.js +++ b/externs/shaka/drm_info.js @@ -49,7 +49,8 @@ shaka.extern.InitDataOverride; * sessionType: string, * initData: Array, * keyIds: Set, - * mediaTypes: (!Array|undefined) + * mediaTypes: (!Array|undefined), + * clearKeys: (!Map|undefined), * }} * * @description @@ -108,6 +109,16 @@ shaka.extern.InitDataOverride; * An optional list specifying each component in a media type: * https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data#media-type * included in a data: scheme URI, separated by semicolon. + * @property {(!Map|undefined)} clearKeys + * An optional map of normalized ClearKey key pairs where: + * - key = key ID ('kid') encoded as base64url without padding + * - value = decryption key ('k') encoded as base64url without padding + * + * If values are provided in another format (e.g. hexadecimal), they must + * be normalized before being stored. + * + * This map is used only for ClearKey key systems and is ignored for other + * DRM systems. * @exportDoc */ shaka.extern.DrmInfo; diff --git a/lib/device/abstract_device.js b/lib/device/abstract_device.js index 613c27125..c5893707b 100644 --- a/lib/device/abstract_device.js +++ b/lib/device/abstract_device.js @@ -389,6 +389,13 @@ shaka.device.AbstractDevice = class { return typeof Worker !== 'undefined'; } + /** + * @override + */ + hasWorkingClearKeySupport() { + return true; + } + /** * @override */ diff --git a/lib/device/apple_browser.js b/lib/device/apple_browser.js index 8b81d3b93..46bc90add 100644 --- a/lib/device/apple_browser.js +++ b/lib/device/apple_browser.js @@ -132,6 +132,13 @@ shaka.device.AppleBrowser = class extends shaka.device.AbstractDevice { return false; } + /** + * @override + */ + hasWorkingClearKeySupport() { + return false; + } + /** * Apple has fixed the mixed clear/encrypted content issue in Safari 26.4 * @return {boolean} diff --git a/lib/device/i_device.js b/lib/device/i_device.js index 00a421706..da5f4e952 100644 --- a/lib/device/i_device.js +++ b/lib/device/i_device.js @@ -271,6 +271,14 @@ shaka.device.IDevice = class { * @return {boolean} */ supportsWorkerTransmux() {} + + /** + * Returns true if ClearKey DRM support is known to work reliably on the + * current platform. + * + * @return {boolean} + */ + hasWorkingClearKeySupport() {} }; /** diff --git a/lib/drm/drm_engine.js b/lib/drm/drm_engine.js index 993a0f718..ff2e630a1 100644 --- a/lib/drm/drm_engine.js +++ b/lib/drm/drm_engine.js @@ -1565,8 +1565,9 @@ shaka.drm.DrmEngine = class { } // NOTE: allowCrossSiteCredentials can be set in a request filter. - if (shaka.drm.DrmUtils.isClearKeySystem( - this.currentDrmInfo_.keySystem)) { + const isClearKey = shaka.drm.DrmUtils.isClearKeySystem( + this.currentDrmInfo_.keySystem); + if (isClearKey) { this.fixClearKeyRequest_(request, this.currentDrmInfo_); } @@ -1650,6 +1651,10 @@ shaka.drm.DrmEngine = class { shaka.log.info('EME license response', str); } + if (isClearKey) { + this.processClearKeyResponse_(response); + } + // Request succeeded, now pass the response to the CDM. try { shaka.log.v1('Updating session', session.sessionId); @@ -1687,6 +1692,33 @@ shaka.drm.DrmEngine = class { } } + /** + * @param {shaka.extern.Response} response + * @private + */ + processClearKeyResponse_(response) { + const text = shaka.util.StringUtils.fromUTF8(response.data); + try { + const responseJson = JSON.parse(text); + + const incoming = new Map( + responseJson['keys'].map(({kid, k}) => [ + shaka.drm.DrmUtils.normalizeClearKeyValue(kid), + shaka.drm.DrmUtils.normalizeClearKeyValue(k), + ])); + + if (!this.currentDrmInfo_.clearKeys) { + this.currentDrmInfo_.clearKeys = incoming; + } else { + for (const [kid, key] of incoming) { + this.currentDrmInfo_.clearKeys.set(kid, key); + } + } + } catch (e) { + // Ignore errors. + } + } + /** * Unpacks PlayReady license requests. Modifies the request object. * @param {shaka.extern.Request} request @@ -2131,30 +2163,12 @@ shaka.drm.DrmEngine = class { } catch (error) {} // Ignore errors. }; - const checkKeySystem = (keySystem) => { - // Our Polyfill will reject anything apart com.apple.fps key systems. - // It seems the Safari modern EME API will allow to request a - // MediaKeySystemAccess for the ClearKey CDM, create and update a key - // session but playback will never start - // Safari bug: https://bugs.webkit.org/show_bug.cgi?id=231006 - const device = shaka.device.DeviceFactory.getDevice(); - if (device.getBrowserEngine() === - shaka.device.IDevice.BrowserEngine.WEBKIT && - shaka.drm.DrmUtils.isClearKeySystem(keySystem)) { - return false; - } - return true; - }; - // Test each key system and encryption scheme. const tests = []; for (const keySystem of testKeySystems) { // Initialize the support structure for each key system. support.set(keySystem, null); - if (!checkKeySystem(keySystem)) { - continue; - } let testHdcp = true; for (const encryptionScheme of testEncryptionSchemes) { tests.push( diff --git a/lib/drm/drm_utils.js b/lib/drm/drm_utils.js index 9af45596e..ee44c9a85 100644 --- a/lib/drm/drm_utils.js +++ b/lib/drm/drm_utils.js @@ -8,6 +8,7 @@ goog.provide('shaka.drm.DrmUtils'); goog.require('shaka.util.BufferUtils'); goog.require('shaka.util.Lazy'); +goog.require('shaka.util.Uint8ArrayUtils'); shaka.drm.DrmUtils = class { @@ -318,6 +319,27 @@ shaka.drm.DrmUtils = class { return result; } + + /** + * Normalizes a ClearKey key or key ID to the base64url (no padding) format + * required by the W3C EME JWK spec. + * + * Accepts two formats as input: + * - Hex string (e.g. 32-char hex keyId or 32-char hex key): converted via + * fromHex to toBase64url. + * - Base64url string already at 22 chars (the canonical JWK length for a + * 16-byte value): returned unchanged. + * + * @param {string} value A ClearKey key or key ID in hex or base64url format. + * @return {string} The value in base64url format without padding. + */ + static normalizeClearKeyValue(value) { + if (value.length === shaka.drm.DrmUtils.CLEAR_KEY_BASE64URL_LENGTH_) { + return value; + } + return shaka.util.Uint8ArrayUtils.toBase64( + shaka.util.Uint8ArrayUtils.fromHex(value), false); + } }; @@ -338,3 +360,12 @@ shaka.drm.DrmUtils.DUMMY_KEY_ID = new shaka.util.Lazy( * @private {!Map} */ shaka.drm.DrmUtils.memoizedMediaKeySystemAccessRequests_ = new Map(); + + +/** + * Length of a 16-byte ClearKey key/key ID encoded as base64url without padding. + * + * @const {number} + * @private + */ +shaka.drm.DrmUtils.CLEAR_KEY_BASE64URL_LENGTH_ = 22; diff --git a/lib/media/clearkey_webcrypto_decryptor.js b/lib/media/clearkey_webcrypto_decryptor.js new file mode 100644 index 000000000..b6355187e --- /dev/null +++ b/lib/media/clearkey_webcrypto_decryptor.js @@ -0,0 +1,860 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.media.ClearKeyWebCryptoDecryptor'); + +goog.require('goog.asserts'); +goog.require('shaka.device.DeviceFactory'); +goog.require('shaka.drm.DrmUtils'); +goog.require('shaka.log'); +goog.require('shaka.util.BufferUtils'); +goog.require('shaka.util.IDestroyable'); +goog.require('shaka.util.Mp4BoxParsers'); +goog.require('shaka.util.Mp4Parser'); +goog.require('shaka.util.Uint8ArrayUtils'); + + +/** + * @implements {shaka.util.IDestroyable} + */ +shaka.media.ClearKeyWebCryptoDecryptor = class { + constructor() { + /** @private {?shaka.extern.DrmInfo} */ + this.drmInfo_ = null; + + /** @private {!Map} */ + this.keyMap_ = new Map(); + + /** @private {?Promise>} */ + this.keyMapPromise_ = null; + + /** @private {?Uint8Array} */ + this.lastInit_ = null; + } + + /** + * @param {!BufferSource} data + * @param {boolean} isInit + * @param {!shaka.extern.DrmInfo} drmInfo + * @return {!Promise} + */ + async decrypt(data, isInit, drmInfo) { + const uint8ArrayData = shaka.util.BufferUtils.toUint8(data); + + if (isInit) { + this.lastInit_ = uint8ArrayData; + return this.stripEncryptionFromInit_(uint8ArrayData); + } + + if (!this.lastInit_) { + return uint8ArrayData; + } + + if (this.drmInfo_ !== drmInfo) { + this.drmInfo_ = drmInfo; + this.keyMapPromise_ = this.buildKeyMap_(this.drmInfo_); + this.keyMap_ = await this.keyMapPromise_; + this.keyMapPromise_ = null; + } + + return this.decryptSegment_(uint8ArrayData, this.lastInit_); + } + + /** + * @override + */ + async destroy() { + if (this.keyMapPromise_) { + try { + await this.keyMapPromise_; + } catch (e) { + // Ignore errors. + } + } + + this.drmInfo_ = null; + this.keyMap_.clear(); + this.keyMapPromise_ = null; + this.lastInit_ = null; + } + + /** + * Extracts a Map from a ClearKey DrmInfo whose + * licenseServerUri is a data:application/json;base64, URI. + * + * @param {!shaka.extern.DrmInfo} drmInfo + * @return {!Promise>} + * @private + */ + async buildKeyMap_(drmInfo) { + /** @type {!Map} */ + const keyMap = new Map(); + + if (!drmInfo.clearKeys) { + return keyMap; + } + + const results = await Promise.all( + [...drmInfo.clearKeys.entries()].map(([kid, key]) => { + const keyBytes = shaka.util.Uint8ArrayUtils.fromBase64(key); + const kidBytes = shaka.util.Uint8ArrayUtils.fromBase64(kid); + return Promise.all([ + crypto.subtle.importKey( + 'raw', keyBytes, {name: 'AES-CBC'}, + /* extractable= */ false, ['decrypt', 'encrypt']), + crypto.subtle.importKey( + 'raw', keyBytes, {name: 'AES-CTR'}, + /* extractable= */ false, ['decrypt']), + ]).then(([cbc, ctr]) => ({ + kidHex: shaka.util.Uint8ArrayUtils.toHex(kidBytes), cbc, ctr, + })); + }), + ); + + for (const {kidHex, cbc, ctr} of results) { + keyMap.set(kidHex, {cbc, ctr}); + } + return keyMap; + } + + /** + * Full segment decryption pipeline. + * + * @param {!Uint8Array} segmentData + * @param {!Uint8Array} initData + * @return {!Promise} + * @private + */ + async decryptSegment_(segmentData, initData) { + const trackInfos = this.parseInitSegment_(initData); + const segInfo = this.parseMediaSegment_(segmentData, trackInfos); + + const fragmentPromises = segInfo.fragments.map((fragment) => { + const initInfo = trackInfos.get(fragment.trackId) || + trackInfos.values().next().value; + return this.decryptFragment_(fragment, segmentData, initInfo); + }); + + const decryptedFragments = await Promise.all(fragmentPromises); + const outputChunks = [ + segmentData.slice(0, segInfo.firstFragmentOffset), + ...decryptedFragments, + ]; + return shaka.util.Uint8ArrayUtils.concat(...outputChunks); + } + + /** + * Rewrites the init segment to strip encryption signalling: + * encv/enca -> original codec fourcc (from frma), sinf -> free. + * MSE will then accept the plain decrypted samples without complaint. + * + * @param {!Uint8Array} initData + * @return {!Uint8Array} + * @private + */ + stripEncryptionFromInit_(initData) { + const initSegment = shaka.util.BufferUtils.toUint8(initData).slice(); + const view = shaka.util.BufferUtils.toDataView(initSegment); + + const modifications = []; + let currentEncBoxStart = -1; + + const freeBox = (box) => { + modifications.push(() => { + view.setUint32(box.start + 4, + shaka.media.ClearKeyWebCryptoDecryptor.BOX_TYPE_FREE_, + /* littleEndian= */ false); + initSegment.fill(0, box.start + 8, box.start + box.size); + }); + }; + + new shaka.util.Mp4Parser() + .boxes([ + 'moov', + 'trak', + 'mdia', + 'minf', + 'stbl', + ], shaka.util.Mp4Parser.children) + .fullBox('stsd', shaka.util.Mp4Parser.sampleDescription) + .box('encv', (box) => { + currentEncBoxStart = box.start; + shaka.util.Mp4Parser.visualSampleEntry(box); + }) + .box('enca', (box) => { + currentEncBoxStart = box.start; + shaka.util.Mp4Parser.audioSampleEntry(box); + }) + .box('sinf', (box) => { + freeBox(box); + shaka.util.Mp4Parser.children(box); + }) + .box('frma', (box) => { + const {codec} = shaka.util.Mp4BoxParsers.parseFRMA(box.reader); + const targetEncStart = currentEncBoxStart; + if (targetEncStart !== -1 && codec) { + modifications.push(() => { + for (let i = 0; i < 4; ++i) { + view.setUint8(targetEncStart + 4 + i, codec.charCodeAt(i)); + } + }); + } + }) + .fullBox('sgpd', freeBox) + .box('pssh', freeBox) + .parse(initSegment, /* partialOkay= */ true); + + for (const mod of modifications) { + mod(); + } + + return initSegment; + } + + /** + * @param {!Uint8Array} initData + * @return {!Map} + * @private + */ + parseInitSegment_(initData) { + const trackInfos = new Map(); + let currentTrackId = 0; + + const Mp4Parser = shaka.util.Mp4Parser; + const Mp4BoxParsers = shaka.util.Mp4BoxParsers; + + new Mp4Parser() + .boxes([ + 'moov', + 'mdia', + 'minf', + 'stbl', + ], Mp4Parser.children) + .box('trak', (box) => { + currentTrackId = 0; + Mp4Parser.children(box); + }) + .fullBox('tkhd', (box) => { + goog.asserts.assert( + box.version != null, + 'TKHD is a full box and should have a valid version.'); + const parsed = Mp4BoxParsers.parseTKHD(box.reader, box.version); + currentTrackId = parsed.trackId; + trackInfos.getOrInsert(currentTrackId, { + defaultKID: '', + encryptionScheme: 'cenc', + defaultIVSize: 8, + defaultConstantIV: null, + defaultCryptByteBlock: 0, + defaultSkipByteBlock: 0, + }); + }) + .fullBox('stsd', Mp4Parser.sampleDescription) + .box('encv', Mp4Parser.visualSampleEntry) + .box('enca', Mp4Parser.audioSampleEntry) + .box('sinf', Mp4Parser.children) + .fullBox('schm', (box) => { + const parsed = Mp4BoxParsers.parseSCHM(box.reader); + const info = trackInfos.get(currentTrackId); + if (info) { + info.encryptionScheme = parsed.encryptionScheme.toLowerCase(); + } + }) + .box('schi', Mp4Parser.children) + .fullBox('tenc', (box) => { + goog.asserts.assert( + box.version != null, + 'TENC is a full box and should have a valid version.'); + const parsed = Mp4BoxParsers.parseTENC(box.reader, box.version); + const info = trackInfos.get(currentTrackId); + if (info) { + info.defaultKID = parsed.defaultKID; + info.defaultIVSize = parsed.defaultPerSampleIVSize; + info.defaultConstantIV = parsed.defaultConstantIV; + info.defaultCryptByteBlock = parsed.defaultCryptByteBlock; + info.defaultSkipByteBlock = parsed.defaultSkipByteBlock; + } + }) + .parse(initData, /* partialOkay= */ true); + + return trackInfos; + } + + /** + * @param {!Uint8Array} segData + * @param {!Map} trackInfos + * @return {!shaka.media.ClearKeyWebCryptoDecryptor.SegmentParseResult} + * @private + */ + parseMediaSegment_(segData, trackInfos) { + const Mp4Parser = shaka.util.Mp4Parser; + const Mp4BoxParsers = shaka.util.Mp4BoxParsers; + const fragments = []; + let firstFragmentOffset = 0; + + /** @type {?shaka.media.ClearKeyWebCryptoDecryptor.FragmentInfo} */ + let currentFragment = null; + + const markFree = (box) => { + if (currentFragment) { + currentFragment.boxesToFree.push({start: box.start, size: box.size}); + } + }; + + new Mp4Parser() + .box('moof', (box) => { + if (!fragments.length) { + firstFragmentOffset = box.start; + } + currentFragment = { + moofStart: box.start, + moofSize: box.size, + mdatStart: -1, + mdatSize: -1, + sencInfo: null, + tfhdDefaultSize: 0, + trackId: 0, + trunSamples: [], + boxesToFree: [], + }; + Mp4Parser.children(box); + }) + .box('traf', Mp4Parser.children) + .fullBox('tfhd', (box) => { + if (!currentFragment) { + return; + } + goog.asserts.assert( + box.flags != null, + 'TFHD is a full box and should have valid flags.'); + const parsed = Mp4BoxParsers.parseTFHD(box.reader, box.flags); + currentFragment.trackId = parsed.trackId; + currentFragment.tfhdDefaultSize = parsed.defaultSampleSize || 0; + }) + .fullBox('trun', (box) => { + if (!currentFragment) { + return; + } + goog.asserts.assert( + box.version != null && box.flags != null, + 'TRUN is a full box and should have a valid version & flags.'); + const parsed = Mp4BoxParsers.parseTRUN( + box.reader, box.version, box.flags); + for (const sample of parsed.sampleData) { + currentFragment.trunSamples.push({ + size: sample.sampleSize || currentFragment.tfhdDefaultSize, + }); + } + }) + .fullBox('senc', (box) => { + if (!currentFragment) { + return; + } + goog.asserts.assert( + box.flags != null, + 'SENC is a full box and should have valid flags.'); + const info = trackInfos.get(currentFragment.trackId); + if (info) { + currentFragment.sencInfo = shaka.util.Mp4BoxParsers.parseSENC( + box.reader, box.flags, info.defaultIVSize, + info.defaultConstantIV); + } + markFree(box); + }) + .fullBoxes([ + 'saiz', + 'saio', + 'sgpd', + 'sbgp', + ], markFree) + .box('pssh', markFree) + .box('mdat', (mdatBox) => { + if (currentFragment) { + currentFragment.mdatStart = mdatBox.start; + currentFragment.mdatSize = mdatBox.size; + fragments.push(currentFragment); + currentFragment = null; + } + }) + .parse(segData); + + return {fragments, firstFragmentOffset}; + } + + /** + * @param {!shaka.media.ClearKeyWebCryptoDecryptor.FragmentInfo} fragment + * @param {!Uint8Array} segData + * @param {!shaka.media.ClearKeyWebCryptoDecryptor.InitInfo} initInfo + * @return {!Promise} + * @private + */ + async decryptFragment_(fragment, segData, initInfo) { + const keyId = initInfo.defaultKID; + const keyEntry = this.keyMap_.get(keyId); + + if (!keyEntry) { + shaka.log.warning('[ClearKeyDecryptor] No key found for KID:', keyId); + return segData.slice( + fragment.moofStart, fragment.mdatStart + fragment.mdatSize); + } + + const scheme = initInfo.encryptionScheme; + let sencInfo = fragment.sencInfo; + if (sencInfo && initInfo.defaultIVSize !== 8) { + const moofSlice = segData.subarray( + fragment.moofStart, fragment.moofStart + fragment.moofSize); + const Mp4Parser = shaka.util.Mp4Parser; + new Mp4Parser() + .box('moof', Mp4Parser.children) + .box('traf', Mp4Parser.children) + .fullBox('senc', (box) => { + goog.asserts.assert( + box.flags != null, + 'SENC is a full box and should have valid flags.'); + sencInfo = shaka.util.Mp4BoxParsers.parseSENC( + box.reader, box.flags, initInfo.defaultIVSize, + initInfo.defaultConstantIV); + }) + .parse(moofSlice, /* partialOkay= */ false); + } + + const mdatPayloadStart = fragment.mdatStart + 8; + const mdatPayload = segData.subarray( + mdatPayloadStart, mdatPayloadStart + fragment.mdatSize - 8); + + const decryptedMdat = await this.decryptMdat_( + mdatPayload, fragment.trunSamples.map((s) => s.size), + sencInfo, initInfo, keyEntry, scheme); + + const moof = segData.slice( + fragment.moofStart, fragment.moofStart + fragment.moofSize); + const moofView = shaka.util.BufferUtils.toDataView(moof); + + if (fragment.boxesToFree) { + for (const boxToFree of fragment.boxesToFree) { + const relStart = boxToFree.start - fragment.moofStart; + if (relStart >= 0 && (relStart + boxToFree.size) <= moof.byteLength) { + moofView.setUint32(relStart + 4, + shaka.media.ClearKeyWebCryptoDecryptor.BOX_TYPE_FREE_, + /* littleEndian= */ false); + // Zero payload + moof.fill(0, relStart + 8, relStart + boxToFree.size); + } + } + } + + const newMdatSize = 8 + decryptedMdat.byteLength; + const newMdat = new Uint8Array(newMdatSize); + shaka.util.BufferUtils.toDataView(newMdat).setUint32( + 0, newMdatSize, /* LE= */ false); + newMdat.set([0x6d, 0x64, 0x61, 0x74], 4); // 'mdat' + newMdat.set(decryptedMdat, 8); + + return shaka.util.Uint8ArrayUtils.concat(moof, newMdat); + } + + /** + * Decrypt the raw mdat payload sample by sample. + * + * @param {!Uint8Array} mdatPayload + * @param {!Array} sampleSizes + * @param {?shaka.media.ClearKeyWebCryptoDecryptor.SencInfo} senc + * @param {!shaka.media.ClearKeyWebCryptoDecryptor.InitInfo} initInfo + * @param {{cbc:!CryptoKey, ctr:!CryptoKey}} keyEntry + * @param {string} scheme 'cenc' | 'cbcs' + * @return {!Promise} + * @private + */ + async decryptMdat_( + mdatPayload, sampleSizes, senc, initInfo, keyEntry, scheme) { + const out = new Uint8Array(mdatPayload.byteLength); + + // Build per-sample decrypt promises; samples are independent of each + // other so we can run them all in parallel with Promise.all. + let sampleOffset = 0; + const samplePromises = sampleSizes.map((sampleSize, i) => { + const offset = sampleOffset; + sampleOffset += sampleSize; + const sampleData = mdatPayload.subarray(offset, offset + sampleSize); + + if (senc && senc.samples[i]) { + const sencSample = senc.samples[i]; + + // Zero-pad 8-byte IVs into the high bytes of a 16-byte block. + const ivLen = initInfo.defaultIVSize === 16 ? 16 : 8; + const iv = new Uint8Array(16); + iv.set(sencSample.iv.slice(0, ivLen), 0); + + if (scheme === 'cenc') { + return this.decryptSampleCenc_( + sampleData, iv, sencSample.subsamples, keyEntry) + .then((dec) => ({offset, dec})); + } + // cbcs: use constant IV if signalled in tenc, else per-sample IV. + const cbcsIV = initInfo.defaultConstantIV || iv; + return this.decryptSampleCbcs_( + sampleData, cbcsIV, sencSample.subsamples, initInfo, keyEntry) + .then((dec) => ({offset, dec})); + } + + if (scheme === 'cbcs' && initInfo.defaultConstantIV) { + // No per-sample senc entry — whole sample uses constant IV. + return this.decryptSampleCbcs_( + sampleData, initInfo.defaultConstantIV, null, initInfo, keyEntry) + .then((dec) => ({offset, dec})); + } + + // Clear sample — pass through unchanged. + return Promise.resolve({offset, dec: sampleData}); + }); + + const results = await Promise.all(samplePromises); + for (const {offset, dec} of results) { + out.set(dec, offset); + } + return out; + } + + /** + * Decrypt one sample under CENC (AES-128-CTR, full or subsample). + * + * The IV is the initial 128-bit counter block (big-endian). For + * subsample encryption the counter is NOT reset between subsample + * regions — it advances by the number of whole 16-byte blocks + * consumed in prior regions. + * + * @param {!Uint8Array} sampleData + * @param {!Uint8Array} iv 16 bytes + * @param {?Array<{clearBytes: number, encryptedBytes: number}>} subsamples + * @param {{cbc:!CryptoKey, ctr:!CryptoKey}} keyEntry + * @return {!Promise} + * @private + */ + async decryptSampleCenc_(sampleData, iv, subsamples, keyEntry) { + if (!subsamples || !subsamples.length) { + return shaka.util.BufferUtils.toUint8(await crypto.subtle.decrypt( + {name: 'AES-CTR', counter: iv, length: 64}, + keyEntry.ctr, sampleData)); + } + + // Pre-compute per-subsample counters (counter state is cumulative), + // then decrypt all encrypted ranges in parallel. + const out = new Uint8Array(sampleData.byteLength); + let pos = 0; + let totalEncryptedBlocks = 0; + + const decryptJobs = subsamples.map((sub) => { + // Copy clear bytes synchronously; record their range. + const clearStart = pos; + pos += sub.clearBytes; + + if (sub.encryptedBytes === 0) { + return Promise.resolve({ + clearStart, + clearLen: sub.clearBytes, + encStart: pos, + encLen: 0, + decrypted: null, + }); + } + + // Snapshot counter for this subsample before advancing. + const counter = iv.slice(); + this.addCounterOffset_(counter, totalEncryptedBlocks); + + const encStart = pos; + pos += sub.encryptedBytes; + totalEncryptedBlocks += Math.ceil(sub.encryptedBytes / 16); + + const encData = sampleData.subarray( + encStart, encStart + sub.encryptedBytes); + return crypto.subtle.decrypt( + {name: 'AES-CTR', counter, length: 64}, + keyEntry.ctr, encData) + .then((buf) => ({ + clearStart, + clearLen: sub.clearBytes, + encStart, + encLen: sub.encryptedBytes, + decrypted: shaka.util.BufferUtils.toUint8(buf), + })); + }); + + const results = await Promise.all(decryptJobs); + for (const r of results) { + out.set( + sampleData.subarray(r.clearStart, r.clearStart + r.clearLen), + r.clearStart); + if (r.decrypted) { + out.set(r.decrypted, r.encStart); + } + } + return out; + } + + /** + * Decrypt one sample under CBCS (AES-128-CBC pattern encryption). + * + * Within each encrypted range, blocks alternate between encrypted + * (cryptByteBlock x 16 bytes) and clear (skipByteBlock x 16 bytes). + * Partial trailing blocks are always clear. + * + * @param {!Uint8Array} sampleData + * @param {!Uint8Array} iv 16 bytes + * @param {?Array<{clearBytes: number, encryptedBytes: number}>} subsamples + * @param {!shaka.media.ClearKeyWebCryptoDecryptor.InitInfo} initInfo + * @param {{cbc:!CryptoKey, ctr:!CryptoKey}} keyEntry + * @return {!Promise} + * @private + */ + async decryptSampleCbcs_( + sampleData, iv, subsamples, initInfo, keyEntry) { + let cryptBlocks = initInfo.defaultCryptByteBlock; + let skipBlocks = initInfo.defaultSkipByteBlock; + + // In cbcs, a 0:0 pattern means 100% encrypted, equivalent to 1:0. + if (cryptBlocks === 0) { + cryptBlocks = 1; + skipBlocks = 0; + } + + const out = new Uint8Array(sampleData.byteLength); + const jobs = []; + + // State variable to maintain CBC IV chaining across the entire sample. + let currentIv = iv; + + const processRange = (rangeStart, rangeLen) => { + let offset = rangeStart; + const end = rangeStart + rangeLen; + + while (offset < end) { + const remaining = end - offset; + const encLen = Math.min(cryptBlocks * 16, remaining); + + if (encLen >= 16) { + const alignedLen = Math.floor(encLen / 16) * 16; + const encStart = offset; + // Partial trailing block of the crypt group is always clear. + const partialLen = encLen - alignedLen; + const clearAfterStart = offset + alignedLen; + + // Capture the correct IV for this specific asynchronous block. + const chunkIv = currentIv; + + jobs.push(this.rawCBCDecrypt_( + sampleData.subarray(encStart, encStart + alignedLen), + chunkIv, keyEntry.cbc) + .then((dec) => { + out.set(dec, encStart); + if (partialLen > 0) { + out.set( + sampleData.subarray( + clearAfterStart, + clearAfterStart + partialLen), + clearAfterStart); + } + }), + ); + + // Update the IV for the next block: + // CBC chaining requires the last 16 bytes of the current ciphertext. + currentIv = sampleData.slice( + encStart + alignedLen - 16, encStart + alignedLen); + + offset += alignedLen + partialLen; + } else { + // Less than one full block remaining in crypt group — clear. + out.set(sampleData.subarray(offset, offset + encLen), offset); + offset += encLen; + } + + // Skip group — always copied clear. + const skipLen = Math.min(skipBlocks * 16, end - offset); + if (skipLen > 0) { + out.set(sampleData.subarray(offset, offset + skipLen), offset); + offset += skipLen; + } + } + }; + + // Process subsamples synchronously to ensure correct IV chaining order + // before resolving promises. + if (!subsamples || !subsamples.length) { + processRange(0, sampleData.byteLength); + } else { + let pos = 0; + for (const sub of subsamples) { + const clearStart = pos; + pos += sub.clearBytes; + out.set( + sampleData.subarray(clearStart, clearStart + sub.clearBytes), + clearStart); + + if (sub.encryptedBytes > 0) { + const encStart = pos; + pos += sub.encryptedBytes; + processRange(encStart, sub.encryptedBytes); + } + } + } + + // Await all decryption jobs concurrently. + await Promise.all(jobs); + return out; + } + + /** + * AES-128-CBC decryption without PKCS7 unpadding. + * + * WebCrypto AES-CBC always applies PKCS7. CBCS stream data is NOT + * padded — partial final blocks are clear rather than padded. We work + * around this by appending a synthetic full PKCS7 padding block + * (16 x 0x10) so WebCrypto's automatic unpadding removes only that + * dummy block, leaving our real data (always 16-byte aligned here) + * intact. + * + * @param {!Uint8Array} data Must be 16-byte aligned + * @param {!Uint8Array} iv 16 bytes + * @param {!CryptoKey} cbcKey AES-CBC key + * @return {!Promise} Exactly data.byteLength bytes + * @private + */ + async rawCBCDecrypt_(data, iv, cbcKey) { + if (!data || !data.byteLength || data.byteLength % 16 !== 0) { + return new Uint8Array(0); + } + + const numBlocks = data.byteLength / 16; + const lastCiphertextBlock = + data.subarray((numBlocks - 1) * 16, numBlocks * 16); + + // Mathematical requirement to force a PKCS#7 padding block + const paddingBlock = lastCiphertextBlock.map((b) => 0x10 ^ b); + + // Artificial padding encryption using zero IV (AES-ECB simulation) + const zeroIv = new Uint8Array(16); + const encryptedPadding = await crypto.subtle.encrypt( + {name: 'AES-CBC', iv: zeroIv}, + cbcKey, + paddingBlock, + ); + + // Take only the first 16 bytes + const extraCiphertextBlock = new Uint8Array(encryptedPadding, 0, 16); + + // Concatenate synthetic block at the end + const extendedCiphertext = new Uint8Array(data.byteLength + 16); + extendedCiphertext.set(data, 0); + extendedCiphertext.set(extraCiphertextBlock, data.byteLength); + + // WebCrypto will strip the extra block cleanly + const decrypted = await crypto.subtle.decrypt( + {name: 'AES-CBC', iv: iv}, + cbcKey, + extendedCiphertext, + ); + + return shaka.util.BufferUtils.toUint8(decrypted); + } + + /** + * Add a block-count offset to a 16-byte big-endian AES-CTR counter. + * Only the lower 8 bytes (bytes 8-15) act as the incrementing counter + * per the CENC spec (section 9.1); the upper 8 bytes are the IV nonce. + * + * @param {!Uint8Array} counter modified in place, 16 bytes + * @param {number} offset number of 16-byte blocks to add + * @private + */ + addCounterOffset_(counter, offset) { + let carry = offset; + for (let i = 15; i >= 8 && carry > 0; i--) { + carry += counter[i]; + counter[i] = carry & 0xff; + carry >>>= 8; + } + } + + /** + * Returns true if the ClearKey WebCrypto path should be used. + * + * @param {?shaka.extern.DrmInfo} drmInfo + * @return {boolean} + */ + static shouldUse(drmInfo) { + if (!drmInfo) { + return false; + } + if (!window.crypto?.subtle) { + return false; + } + if (!shaka.drm.DrmUtils.isClearKeySystem(drmInfo.keySystem)) { + return false; + } + return !shaka.device.DeviceFactory.getDevice().hasWorkingClearKeySupport(); + } +}; + +/** + * Box type for "free". + * + * @const {number} + * @private + */ +shaka.media.ClearKeyWebCryptoDecryptor.BOX_TYPE_FREE_ = 0x66726565; + + +/** + * @typedef {{ + * defaultKID: string, + * encryptionScheme: string, + * defaultIVSize: number, + * defaultConstantIV: ?Uint8Array, + * defaultCryptByteBlock: number, + * defaultSkipByteBlock: number, + * }} + */ +shaka.media.ClearKeyWebCryptoDecryptor.InitInfo; + + +/** + * @typedef {{ + * moofStart: number, + * moofSize: number, + * mdatStart: number, + * mdatSize: number, + * sencInfo: ?shaka.media.ClearKeyWebCryptoDecryptor.SencInfo, + * tfhdDefaultSize: number, + * trackId: number, + * trunSamples: !Array<{size: number}>, + * boxesToFree: !Array<{start: number, size: number}>, + * }} + */ +shaka.media.ClearKeyWebCryptoDecryptor.FragmentInfo; + + +/** + * @typedef {{ + * samples: !Array<{ + * iv: !Uint8Array, + * subsamples: + * ?Array<{clearBytes: number, encryptedBytes: number}> + * }> + * }} + */ +shaka.media.ClearKeyWebCryptoDecryptor.SencInfo; + + +/** + * @typedef {{ + * fragments: + * !Array, + * firstFragmentOffset: number, + * }} + */ +shaka.media.ClearKeyWebCryptoDecryptor.SegmentParseResult; diff --git a/lib/media/media_source_engine.js b/lib/media/media_source_engine.js index 25911a2ec..031656396 100644 --- a/lib/media/media_source_engine.js +++ b/lib/media/media_source_engine.js @@ -10,6 +10,7 @@ goog.require('goog.asserts'); goog.require('shaka.log'); goog.require('shaka.config.CodecSwitchingStrategy'); goog.require('shaka.device.DeviceFactory'); +goog.require('shaka.media.ClearKeyWebCryptoDecryptor'); goog.require('shaka.media.Capabilities'); goog.require('shaka.media.ContentWorkarounds'); goog.require('shaka.media.ClosedCaptionParser'); @@ -210,6 +211,12 @@ shaka.media.MediaSourceEngine = class { /** @private {boolean} */ this.usesLiveSeekableRange_ = false; + + /** + * @private {!Map} + */ + this.clearKeyDecryptors_ = new Map(); } /** @@ -411,7 +418,7 @@ shaka.media.MediaSourceEngine = class { /** @private */ async doDestroy_() { - const cleanup = []; + let cleanup = []; for (const [key, q] of this.queues_) { // Make a local copy of the queue and the first item. @@ -438,11 +445,16 @@ shaka.media.MediaSourceEngine = class { } await Promise.all(cleanup); + cleanup = []; for (const transmuxer of this.transmuxers_.values()) { transmuxer.destroy(); } + for (const clearKeyDecryptor of this.clearKeyDecryptors_.values()) { + cleanup.push(clearKeyDecryptor.destroy()); + } + if (this.eventManager_) { this.eventManager_.release(); this.eventManager_ = null; @@ -467,6 +479,7 @@ shaka.media.MediaSourceEngine = class { this.textDisplayer_ = null; this.sourceBuffers_.clear(); this.expectedEncryption_.clear(); + this.clearKeyDecryptors_.clear(); this.transmuxers_.clear(); this.captionParser_ = null; if (goog.DEBUG) { @@ -483,6 +496,8 @@ shaka.media.MediaSourceEngine = class { this.tsParsers_.clear(); this.playerInterface_ = null; + + await Promise.all(cleanup); } /** @@ -651,6 +666,14 @@ shaka.media.MediaSourceEngine = class { this.sourceBuffers_.set(contentType, sourceBuffer); this.sourceBufferTypes_.set(contentType, mimeType); this.expectedEncryption_.set(contentType, !!stream.drmInfos.length); + const drmInfo = this.playerInterface_.getDrmInfo(); + if (shaka.media.ClearKeyWebCryptoDecryptor.shouldUse(drmInfo)) { + const decryptor = new shaka.media.ClearKeyWebCryptoDecryptor(); + this.clearKeyDecryptors_.set(contentType, decryptor); + } else { + this.clearKeyDecryptors_.get(contentType)?.destroy(); + this.clearKeyDecryptors_.delete(contentType); + } } } @@ -1343,6 +1366,17 @@ shaka.media.MediaSourceEngine = class { data = this.workAroundBrokenPlatforms_( stream, data, reference, contentType); + if (this.clearKeyDecryptors_.has(contentType)) { + const isMp4 = shaka.util.MimeUtils.getContainerType( + this.sourceBufferTypes_.get(contentType)) == 'mp4'; + const drmInfo = this.playerInterface_.getDrmInfo(); + if (isMp4 && drmInfo) { + const isInitSegment = reference === null; + const decryptor = this.clearKeyDecryptors_.get(contentType); + data = await decryptor.decrypt(data, isInitSegment, drmInfo); + } + } + if (reference && this.sequenceMode_ && contentType != ContentType.TEXT) { // In sequence mode, for non-text streams, if we just cleared the buffer // and are either performing an unbuffered seek or handling an automatic @@ -2359,6 +2393,9 @@ shaka.media.MediaSourceEngine = class { for (const transmuxer of this.transmuxers_.values()) { transmuxer.destroy(); } + for (const clearKeyDecryptor of this.clearKeyDecryptors_.values()) { + clearKeyDecryptor.destroy(); + } for (const sourceBuffer of this.sourceBuffers_.values()) { try { this.mediaSource_.removeSourceBuffer(sourceBuffer); @@ -2368,6 +2405,7 @@ shaka.media.MediaSourceEngine = class { } this.transmuxers_.clear(); this.sourceBuffers_.clear(); + this.clearKeyDecryptors_.clear(); const previousDuration = this.mediaSource_.duration; this.mediaSourceOpen_ = Promise.withResolvers(); @@ -2787,6 +2825,7 @@ shaka.media.MediaSourceEngine.ResetMode_ = { * onEmsg: function(!shaka.extern.EmsgInfo), * onEvent: function(!Event), * onManifestUpdate: function(), + * getDrmInfo: function():?shaka.extern.DrmInfo, * }} * * @summary Player interface @@ -2801,5 +2840,7 @@ shaka.media.MediaSourceEngine.ResetMode_ = { * Called when an event occurs that should be sent to the app. * @property {function()} onManifestUpdate * Called when an embedded 'emsg' box should trigger a manifest update. + * @property {function():?shaka.extern.DrmInfo} getDrmInfo + * Gets currently used drmInfo or null if not used. */ shaka.media.MediaSourceEngine.PlayerInterface; diff --git a/lib/media/segment_utils.js b/lib/media/segment_utils.js index 533b2a68d..a411e9a95 100644 --- a/lib/media/segment_utils.js +++ b/lib/media/segment_utils.js @@ -551,7 +551,11 @@ shaka.media.SegmentUtils = class { }) .box('schi', Mp4Parser.children) .fullBox('tenc', (box) => { - const parsedTENCBox = shaka.util.Mp4BoxParsers.parseTENC(box.reader); + goog.asserts.assert( + box.version != null, + 'TENC is a full box and should have a valid version.'); + const parsedTENCBox = + shaka.util.Mp4BoxParsers.parseTENC(box.reader, box.version); defaultKID = parsedTENCBox.defaultKID; }) .fullBox('pssh', (box) => { @@ -841,7 +845,11 @@ shaka.media.SegmentUtils = class { .box('sinf', Mp4Parser.children) .box('schi', Mp4Parser.children) .fullBox('tenc', (box) => { - const parsedTENCBox = shaka.util.Mp4BoxParsers.parseTENC(box.reader); + goog.asserts.assert( + box.version != null, + 'TENC is a full box and should have a valid version.'); + const parsedTENCBox = + shaka.util.Mp4BoxParsers.parseTENC(box.reader, box.version); defaultKID = parsedTENCBox.defaultKID; }) diff --git a/lib/player.js b/lib/player.js index f656c0a10..4da63ed7c 100644 --- a/lib/player.js +++ b/lib/player.js @@ -2769,6 +2769,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { }, onEvent: (event) => this.dispatchEvent(event), onManifestUpdate: () => this.onManifestUpdate_(), + getDrmInfo: () => this.drmInfo(), }, this.lcevcDec_, this.config_.mediaSource); diff --git a/lib/util/manifest_parser_utils.js b/lib/util/manifest_parser_utils.js index feedfe9ac..211e2de12 100644 --- a/lib/util/manifest_parser_utils.js +++ b/lib/util/manifest_parser_utils.js @@ -12,7 +12,6 @@ goog.require('shaka.util.BufferUtils'); goog.require('shaka.util.Error'); goog.require('shaka.util.LanguageUtils'); goog.require('shaka.util.StringUtils'); -goog.require('shaka.util.Uint8ArrayUtils'); /** @@ -122,22 +121,14 @@ shaka.util.ManifestParserUtils = class { */ static createDrmInfoFromClearKeys(clearKeys, encryptionScheme = 'cenc') { const StringUtils = shaka.util.StringUtils; - const Uint8ArrayUtils = shaka.util.Uint8ArrayUtils; const keys = []; const keyIds = []; const originalKeyIds = []; + const normalizedClearKeys = new Map(); clearKeys.forEach((key, keyId) => { - let kid = keyId; - if (kid.length != 22) { - kid = Uint8ArrayUtils.toBase64( - Uint8ArrayUtils.fromHex(keyId), false); - } - let k = key; - if (k.length != 22) { - k = Uint8ArrayUtils.toBase64( - Uint8ArrayUtils.fromHex(key), false); - } + const kid = shaka.drm.DrmUtils.normalizeClearKeyValue(keyId); + const k = shaka.drm.DrmUtils.normalizeClearKeyValue(key); const keyObj = { kty: 'oct', kid: kid, @@ -147,6 +138,7 @@ shaka.util.ManifestParserUtils = class { keys.push(keyObj); keyIds.push(keyObj.kid); originalKeyIds.push(keyId); + normalizedClearKeys.set(kid, k); }); const jwkSet = {keys: keys}; @@ -173,6 +165,7 @@ shaka.util.ManifestParserUtils = class { sessionType: '', initData: initDatas, keyIds: new Set(originalKeyIds), + clearKeys: normalizedClearKeys, }; } diff --git a/lib/util/mp4_box_parsers.js b/lib/util/mp4_box_parsers.js index 314b21da8..38ebf57b6 100644 --- a/lib/util/mp4_box_parsers.js +++ b/lib/util/mp4_box_parsers.js @@ -548,20 +548,42 @@ shaka.util.Mp4BoxParsers = class { /** * Parses a TENC box. * @param {!shaka.util.DataViewReader} reader + * @param {number} version * @return {!shaka.util.ParsedTENCBox} */ - static parseTENC(reader) { + static parseTENC(reader, version) { const Uint8ArrayUtils = shaka.util.Uint8ArrayUtils; - reader.readUint8(); // reserved + + // reserved (1 byte) reader.readUint8(); - reader.readUint8(); // default_isProtected - reader.readUint8(); // default_Per_Sample_IV_Size - const defaultKIDData = reader.readBytes(16, - // Don't clone. - // The payload is temporary, and is parsed immediately. - /* clone= */ false); + + // Version 1+ carries crypt/skip pattern in the second byte. + // Version 0 treats it as reserved and it must be 0. + const patternByte = reader.readUint8(); + const defaultCryptByteBlock = version >= 1 ? (patternByte >> 4) & 0x0f : 0; + const defaultSkipByteBlock = version >= 1 ? patternByte & 0x0f : 0; + + const defaultIsProtected = reader.readUint8(); // 0 = clear, 1 = encrypted + const defaultPerSampleIVSize = reader.readUint8(); + + const defaultKIDData = reader.readBytes(16, /* clone= */ false); const defaultKID = Uint8ArrayUtils.toHex(defaultKIDData); - return {defaultKID}; + + // When isProtected == 1 and perSampleIVSize == 0 a constant IV is present. + let defaultConstantIV = null; + if (defaultIsProtected === 1 && defaultPerSampleIVSize === 0) { + const ivSize = reader.readUint8(); + defaultConstantIV = reader.readBytes(ivSize, /* clone= */ true); + } + + return { + defaultKID, + defaultIsProtected, + defaultPerSampleIVSize, + defaultCryptByteBlock, + defaultSkipByteBlock, + defaultConstantIV, + }; } /** @@ -754,6 +776,71 @@ shaka.util.Mp4BoxParsers = class { static addLeadingZero_(x) { return (x < 10 ? '0' : '') + x; } + + /** + * Parses a SENC (Sample Encryption) full box. + * + * @param {!shaka.util.DataViewReader} reader + * @param {number} flags Box flags from the full-box header. + * @param {number} perSampleIVSize IV size from tenc (0, 8 or 16). + * @param {?Uint8Array} defaultConstantIV Constant IV from tenc when + * perSampleIVSize == 0. + * @return {!shaka.util.ParsedSENCBox} + */ + static parseSENC(reader, flags, perSampleIVSize, defaultConstantIV) { + const overrideTrackEncryptionBoxParameters = !!(flags & 0x000001); + const hasSubsamples = !!(flags & 0x000002); + + let ivSize = perSampleIVSize; + let constantIV = defaultConstantIV; + + if (overrideTrackEncryptionBoxParameters) { + // Skip AlgorithmID (24 bits) + IV_size (8 bits) + KID (16 bytes) + // as defined by ISO/IEC 23001-7. + reader.skip(3); // AlgorithmID + ivSize = reader.readUint8(); + reader.skip(16); // KID + + if (ivSize === 0) { + const constantIVSize = reader.readUint8(); + constantIV = reader.readBytes( + constantIVSize, /* clone= */ true); + } + } + + const sampleCount = reader.readUint32(); + const samples = []; + + for (let i = 0; i < sampleCount; i++) { + let ivToUse = constantIV; + if (ivSize > 0) { + ivToUse = reader.readBytes(ivSize, /* clone= */ true); + } else if (!ivToUse) { + throw new Error( + 'SENC specifies IV size 0 but no constant IV is available.'); + } + + const iv = new Uint8Array(16); + iv.set(ivToUse.subarray(0, Math.min(ivToUse.length, 16)), 0); + + let subsamples = null; + if (hasSubsamples) { + const count = reader.readUint16(); + subsamples = []; + for (let j = 0; j < count; j++) { + subsamples.push({ + clearBytes: reader.readUint16(), + encryptedBytes: reader.readUint32(), + }); + } + } + samples.push({iv, subsamples}); + } + + return { + samples, + }; + } }; @@ -909,9 +996,29 @@ shaka.util.ParsedFRMABox; /** * @typedef {{ * defaultKID: string, + * defaultIsProtected: number, + * defaultPerSampleIVSize: number, + * defaultCryptByteBlock: number, + * defaultSkipByteBlock: number, + * defaultConstantIV: ?Uint8Array, * }} * * @property {string} defaultKID + * Hex string of the 16-byte default Key ID. + * @property {number} defaultIsProtected + * 0 = clear track, 1 = encrypted track. + * @property {number} defaultPerSampleIVSize + * Size in bytes of the per-sample IV (8 or 16). 0 means a constant IV + * is used instead (present only when defaultIsProtected == 1). + * @property {number} defaultCryptByteBlock + * Number of encrypted 16-byte blocks in the pattern (CBCS, version >= 1). + * 0 for version-0 boxes or non-pattern encryption (CENC). + * @property {number} defaultSkipByteBlock + * Number of unencrypted 16-byte blocks in the pattern (CBCS, version >= 1). + * 0 for version-0 boxes or non-pattern encryption (CENC). + * @property {?Uint8Array} defaultConstantIV + * Constant IV bytes, present only when defaultIsProtected == 1 and + * defaultPerSampleIVSize == 0 (CBCS pattern mode). Null otherwise. * * @exportDoc */ @@ -1085,3 +1192,21 @@ shaka.util.ParsedCOLRBox; * @exportDoc */ shaka.util.ParsedSCHMBox; + + +/** + * @typedef {{ + * samples: !Array<{ + * iv: !Uint8Array, + * subsamples: ?Array<{clearBytes: number, encryptedBytes: number}> + * }> + * }} + * + * @property {!Array} samples + * One entry per sample. Each entry carries the 16-byte IV (zero-padded from + * the raw perSampleIVSize bytes) and an optional subsample map. + * subsamples is null when the whole sample is encrypted (no subsample table). + * + * @exportDoc + */ +shaka.util.ParsedSENCBox; diff --git a/lib/util/mp4_parser.js b/lib/util/mp4_parser.js index c4c84cd44..c6136c282 100644 --- a/lib/util/mp4_parser.js +++ b/lib/util/mp4_parser.js @@ -76,6 +76,23 @@ shaka.util.Mp4Parser = class { } + /** + * Declare multiple box types as Full Boxes. + * + * @param {!Array} types + * @param {!shaka.util.Mp4Parser.CallbackType} definition + * @return {!shaka.util.Mp4Parser} + * @export + */ + fullBoxes(types, definition) { + for (const type of types) { + this.fullBox(type, definition); + } + + return this; + } + + /** * Stop parsing. Useful for extracting information from partial segments and * avoiding an out-of-bounds error once you find what you are looking for. diff --git a/project-words.txt b/project-words.txt index af8824074..61fcf0994 100644 --- a/project-words.txt +++ b/project-words.txt @@ -119,6 +119,8 @@ csslintrc csslintstamp cygpath datas +Decryptor +Decryptors degrad discardable displayer @@ -172,6 +174,7 @@ uints uncompiled unconfigured unfuzzed +unpadding unregisters unstore unviewed @@ -383,12 +386,14 @@ rltb samplerate saio saiz +sbgp schi schm scte sdtp senc sepiff +sgpd sidx sinf skipoffset @@ -407,6 +412,7 @@ stsd stsz sttg stts +styp subcues subframes subt diff --git a/test/abr/simple_abr_manager_integration.js b/test/abr/simple_abr_manager_integration.js index 3e54ababf..41af615bb 100644 --- a/test/abr/simple_abr_manager_integration.js +++ b/test/abr/simple_abr_manager_integration.js @@ -99,6 +99,7 @@ describe('SimpleAbrManager (integration)', () => { onEmsg: () => {}, onEvent: () => {}, onManifestUpdate: () => {}, + getDrmInfo: () => null, }, mediaSourceConfig); waiter.setMediaSourceEngine(mediaSourceEngine); diff --git a/test/cast/cast_utils_unit.js b/test/cast/cast_utils_unit.js index dcaddf847..f1da01641 100644 --- a/test/cast/cast_utils_unit.js +++ b/test/cast/cast_utils_unit.js @@ -297,6 +297,7 @@ describe('CastUtils', () => { onEmsg: () => {}, onEvent: () => {}, onManifestUpdate: () => {}, + getDrmInfo: () => null, }, config); diff --git a/test/dash/dash_parser_integration.js b/test/dash/dash_parser_integration.js index d384a3758..a96837dc8 100644 --- a/test/dash/dash_parser_integration.js +++ b/test/dash/dash_parser_integration.js @@ -102,6 +102,37 @@ describe('DashParser', () => { await player.unload(); }); + it('supports ClearKey with raw single key with Web Crypto', async () => { + if (!checkClearKeySupport()) { + pending('ClearKey is not supported'); + } + spyOn(deviceDetected, 'hasWorkingClearKeySupport').and.returnValue(false); + + player.configure({ + drm: { + clearKeys: { + // cspell: disable + // eslint-disable-next-line @stylistic/max-len + '4060a865887842679cbf91ae5bae1e72': 'fc35340837310cc0fb53de97e22a69e0', + // cspell: enable + }, + }, + }); + await player.load('/base/test/test/assets/dash-clearkey/dash.mpd'); + await video.play(); + expect(player.isLive()).toBe(false); + + // Wait for the video to start playback. If it takes longer than 10 + // seconds, fail the test. + await waiter.waitForMovementOrFailOnTimeout(video, 10); + + // Play for 8 seconds, but stop early if the video ends. If it takes + // longer than 30 seconds, fail the test. + await waiter.waitUntilPlayheadReachesOrFailOnTimeout(video, 8, 30); + + await player.unload(); + }); + it('support multi type variants', async () => { const DeviceType = shaka.device.IDevice.DeviceType; if (deviceDetected.getDeviceType() === DeviceType.TV && diff --git a/test/drm/drm_engine_integration.js b/test/drm/drm_engine_integration.js index 76c731aed..8d8d8a308 100644 --- a/test/drm/drm_engine_integration.js +++ b/test/drm/drm_engine_integration.js @@ -126,6 +126,7 @@ describe('DrmEngine', () => { onEmsg: () => {}, onEvent: () => {}, onManifestUpdate: () => {}, + getDrmInfo: () => null, }, mediaSourceConfig); @@ -257,6 +258,10 @@ describe('DrmEngine', () => { filterDescribe('ClearKey', checkClearKeySupport, () => { drmIt('plays encrypted content with the ClearKey CDM', async () => { + const BrowserEngine = shaka.device.IDevice.BrowserEngine; + if (deviceDetected.getBrowserEngine() === BrowserEngine.WEBKIT) { + pending('Disabled on Safari.'); + } // Configure DrmEngine for ClearKey playback. const config = shaka.util.PlayerConfiguration.createDefault().drm; config.clearKeys = { diff --git a/test/drm/drm_utils_unit.js b/test/drm/drm_utils_unit.js index a57ff29c5..1c6bc85ff 100644 --- a/test/drm/drm_utils_unit.js +++ b/test/drm/drm_utils_unit.js @@ -366,4 +366,36 @@ describe('DrmUtils', () => { .toBeUndefined(); }); }); + + describe('normalizeClearKeyValue', () => { + it('returns base64url values unchanged', () => { + // cspell: disable-next-line + const value = 'EjRWeJCrze8SNFZ4kKvN7w'; + + expect(shaka.drm.DrmUtils.normalizeClearKeyValue(value)) + .toBe(value); + }); + + it('converts a hex value to base64url', () => { + const hex = '1234567890abcdef1234567890abcdef'; + + expect(shaka.drm.DrmUtils.normalizeClearKeyValue(hex)) + // cspell: disable-next-line + .toBe('EjRWeJCrze8SNFZ4kKvN7w'); + }); + + it('converts a key id consisting of all zeros', () => { + const hex = '00000000000000000000000000000000'; + + expect(shaka.drm.DrmUtils.normalizeClearKeyValue(hex)) + .toBe('AAAAAAAAAAAAAAAAAAAAAA'); + }); + + it('converts a key id consisting of all ff bytes', () => { + const hex = 'ffffffffffffffffffffffffffffffff'; + + expect(shaka.drm.DrmUtils.normalizeClearKeyValue(hex)) + .toBe('_____________________w'); + }); + }); }); diff --git a/test/hls/hls_parser_integration.js b/test/hls/hls_parser_integration.js index dae35b508..5ef58f545 100644 --- a/test/hls/hls_parser_integration.js +++ b/test/hls/hls_parser_integration.js @@ -104,6 +104,27 @@ describe('HlsParser', () => { await player.unload(); }); + drmIt('supports SAMPLE-AES identity streaming with Web Crypto', async () => { + if (!checkClearKeySupport()) { + pending('ClearKey is not supported'); + } + spyOn(deviceDetected, 'hasWorkingClearKeySupport').and.returnValue(false); + + await player.load('/base/test/test/assets/hls-sample-aes/index.m3u8'); + await video.play(); + expect(player.isLive()).toBe(false); + + // Wait for the video to start playback. If it takes longer than 10 + // seconds, fail the test. + await waiter.waitForMovementOrFailOnTimeout(video, 10); + + // Play for 8 seconds, but stop early if the video ends. If it takes + // longer than 30 seconds, fail the test. + await waiter.waitUntilPlayheadReachesOrFailOnTimeout(video, 8, 30); + + await player.unload(); + }); + it('supports text discontinuity', async () => { player.configure('preferredText', [{ diff --git a/test/hls/hls_parser_unit.js b/test/hls/hls_parser_unit.js index c1dda9ced..4757b47ff 100644 --- a/test/hls/hls_parser_unit.js +++ b/test/hls/hls_parser_unit.js @@ -4568,6 +4568,9 @@ describe('HlsParser', () => { drmInfo.keyIds.add(keyId); drmInfo.addKeyIdsData(initDataBase64); drmInfo.encryptionScheme = 'cenc'; + drmInfo.clearKeys = new Map([ + ['AAAAAAAAAAAAAAAAAAAAAA', 'UGo2aEZndDVpRlp0ZkJMTjZvcThFZz09'], + ]); }); }); }); @@ -4615,6 +4618,9 @@ describe('HlsParser', () => { drmInfo.keyIds.add(keyId); drmInfo.addKeyIdsData(initDataBase64); drmInfo.encryptionScheme = 'cenc'; + drmInfo.clearKeys = new Map([ + ['AAAAAAAAAAAAAAAAAAAAAA', 'UGo2aEZndDVpRlp0ZkJMTjZvcThFZz09'], + ]); }); }); }); @@ -4663,6 +4669,9 @@ describe('HlsParser', () => { drmInfo.keyIds.add(keyId); drmInfo.addKeyIdsData(initDataBase64); drmInfo.encryptionScheme = 'cenc'; + drmInfo.clearKeys = new Map([ + ['AAAAAAAAAAAAAAAAAAAAAA', 'Pj6hFgt5iFZtfBLN6oq8Eg'], + ]); }); }); }); @@ -4908,6 +4917,9 @@ describe('HlsParser', () => { drmInfo.keyIds.add(keyId); drmInfo.addKeyIdsData(initDataBase64); drmInfo.encryptionScheme = 'cenc'; + drmInfo.clearKeys = new Map([ + ['AAAAAAAAAAAAAAAAAAAAAA', 'UGo2aEZndDVpRlp0ZkJMTjZvcThFZz09'], + ]); }); }); }); @@ -4918,6 +4930,9 @@ describe('HlsParser', () => { drmInfo.keyIds.add(keyId); drmInfo.addKeyIdsData(initDataBase64); drmInfo.encryptionScheme = 'cenc'; + drmInfo.clearKeys = new Map([ + ['AAAAAAAAAAAAAAAAAAAAAA', 'UGo2aEZndDVpRlp0ZkJMTjZvcThFZz09'], + ]); }); }); }); @@ -4962,6 +4977,9 @@ describe('HlsParser', () => { drmInfo.keyIds.add(keyId); drmInfo.addKeyIdsData(initDataBase64); drmInfo.encryptionScheme = 'cenc'; + drmInfo.clearKeys = new Map([ + ['AAAAAAAAAAAAAAAAAAAAAA', 'UGo2aEZndDVpRlp0ZkJMTjZvcThFZz09'], + ]); }); }); }); @@ -4972,6 +4990,9 @@ describe('HlsParser', () => { drmInfo.keyIds.add(keyId); drmInfo.addKeyIdsData(initDataBase64); drmInfo.encryptionScheme = 'cenc'; + drmInfo.clearKeys = new Map([ + ['AAAAAAAAAAAAAAAAAAAAAA', 'UGo2aEZndDVpRlp0ZkJMTjZvcThFZz09'], + ]); }); }); }); diff --git a/test/media/media_source_engine_integration.js b/test/media/media_source_engine_integration.js index a31e09b5d..a9cf3d30d 100644 --- a/test/media/media_source_engine_integration.js +++ b/test/media/media_source_engine_integration.js @@ -193,6 +193,7 @@ describe('MediaSourceEngine', () => { onEmsg: Util.spyFunc(onEmsg), onEvent: Util.spyFunc(onEvent), onManifestUpdate: Util.spyFunc(onManifestUpdate), + getDrmInfo: () => null, }, config); diff --git a/test/media/media_source_engine_unit.js b/test/media/media_source_engine_unit.js index 7aba0e589..6d86a13b1 100644 --- a/test/media/media_source_engine_unit.js +++ b/test/media/media_source_engine_unit.js @@ -264,6 +264,7 @@ describe('MediaSourceEngine', () => { onEmsg: () => {}, onEvent: () => {}, onManifestUpdate: () => {}, + getDrmInfo: () => null, }, config); mediaSourceEngine.getCaptionParser = () => { @@ -342,6 +343,7 @@ describe('MediaSourceEngine', () => { onEmsg: () => {}, onEvent: () => {}, onManifestUpdate: () => {}, + getDrmInfo: () => null, }, config); @@ -369,6 +371,7 @@ describe('MediaSourceEngine', () => { onEmsg: () => {}, onEvent: () => {}, onManifestUpdate: () => {}, + getDrmInfo: () => null, }, config); diff --git a/test/media/streaming_engine_integration.js b/test/media/streaming_engine_integration.js index 2d4271153..e59f42d1f 100644 --- a/test/media/streaming_engine_integration.js +++ b/test/media/streaming_engine_integration.js @@ -78,6 +78,7 @@ describe('StreamingEngine', () => { onEmsg: () => {}, onEvent: () => {}, onManifestUpdate: () => {}, + getDrmInfo: () => null, }, mediaSourceConfig); waiter.setMediaSourceEngine(mediaSourceEngine); diff --git a/test/test/util/manifest_generator.js b/test/test/util/manifest_generator.js index 87027e79f..1fcea9c27 100644 --- a/test/test/util/manifest_generator.js +++ b/test/test/util/manifest_generator.js @@ -427,6 +427,8 @@ shaka.test.ManifestGenerator.DrmInfo = class { this.keySystemUris; /** @type {(!Array | undefined)} */ this.mediaTypes; + /** @type {(!Map|undefined)} */ + this.clearKeys; /** @type {shaka.extern.DrmInfo} */ const foo = this; diff --git a/test/util/mp4_box_parsers_unit.js b/test/util/mp4_box_parsers_unit.js index 68dd7eeac..754cc0953 100644 --- a/test/util/mp4_box_parsers_unit.js +++ b/test/util/mp4_box_parsers_unit.js @@ -371,4 +371,125 @@ describe('Mp4BoxParsers', () => { expect(parsedHdlr.handlerType).toBe('vide'); }); }); + + describe('parseTENC', () => { + it('parses version 0 tenc box with per-sample IV', () => { + const tencBox = new Uint8Array([ + 0x00, // reserved + 0x00, // patternByte + 0x01, // defaultIsProtected + 0x08, // defaultPerSampleIVSize + // defaultKID (16 bytes) + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + ]); + const reader = new shaka.util.DataViewReader( + tencBox, shaka.util.DataViewReader.Endianness.BIG_ENDIAN); + const parsed = + shaka.util.Mp4BoxParsers.parseTENC(reader, /* version= */ 0); + + expect(parsed.defaultCryptByteBlock).toBe(0); + expect(parsed.defaultSkipByteBlock).toBe(0); + expect(parsed.defaultIsProtected).toBe(1); + expect(parsed.defaultPerSampleIVSize).toBe(8); + expect(parsed.defaultKID).toBe('0102030405060708090a0b0c0d0e0f10'); + expect(parsed.defaultConstantIV).toBeNull(); + }); + + // eslint-disable-next-line @stylistic/max-len + it('parses version 1 tenc box with pattern encryption and constant IV', () => { + const tencBox = new Uint8Array([ + 0x00, // reserved + 0x12, // patternByte (crypt = 1 [0x1-], skip = 2 [-0x2]) + 0x01, // defaultIsProtected + 0x00, // defaultPerSampleIVSize + // defaultKID (16 bytes) + 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, + 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, + 0x04, // ivSize + 0x55, 0x66, 0x77, 0x88, // defaultConstantIV + ]); + const reader = new shaka.util.DataViewReader( + tencBox, shaka.util.DataViewReader.Endianness.BIG_ENDIAN); + const parsed = + shaka.util.Mp4BoxParsers.parseTENC(reader, /* version= */ 1); + + expect(parsed.defaultCryptByteBlock).toBe(1); + expect(parsed.defaultSkipByteBlock).toBe(2); + expect(parsed.defaultIsProtected).toBe(1); + expect(parsed.defaultPerSampleIVSize).toBe(0); + expect(parsed.defaultKID).toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + expect(parsed.defaultConstantIV) + .toEqual(new Uint8Array([0x55, 0x66, 0x77, 0x88])); + }); + }); + + describe('parseSENC', () => { + it('parses senc box without subsamples or parameter overrides', () => { + const sencBox = new Uint8Array([ + 0x00, 0x00, 0x00, 0x01, // sampleCount + // IV del sample (8 bytes) + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + ]); + const reader = new shaka.util.DataViewReader( + sencBox, shaka.util.DataViewReader.Endianness.BIG_ENDIAN); + + const parsed = shaka.util.Mp4BoxParsers.parseSENC( + reader, /* flags= */ 0, /* perSampleIVSize= */ 8, + /* defaultConstantIV= */ null); + + expect(parsed.samples.length).toBe(1); + expect(parsed.samples[0].subsamples).toBeNull(); + + const expectedIv = new Uint8Array(16); + expectedIv.set([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08], 0); + expect(parsed.samples[0].iv).toEqual(expectedIv); + }); + + it('parses senc box with subsamples', () => { + const sencBox = new Uint8Array([ + 0x00, 0x00, 0x00, 0x01, // sampleCount + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x00, 0x01, // subsample count + 0x00, 0x05, // clearBytes + 0x00, 0x00, 0x00, 0x0a, // encryptedBytes + ]); + const reader = new shaka.util.DataViewReader( + sencBox, shaka.util.DataViewReader.Endianness.BIG_ENDIAN); + + const parsed = shaka.util.Mp4BoxParsers.parseSENC( + reader, /* flags= */ 0x000002, /* perSampleIVSize= */ 8, + /* defaultConstantIV= */ null); + + expect(parsed.samples.length).toBe(1); + expect(parsed.samples[0].subsamples).toEqual([ + {clearBytes: 5, encryptedBytes: 10}, + ]); + }); + + it('parses senc box and overrides track encryption parameters', () => { + const sencBox = new Uint8Array([ + 0x00, 0x00, 0x00, // AlgorithmID + 0x00, // ivSize + // KID + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x04, // constantIVSize + 0x11, 0x22, 0x33, 0x44, // constantIV + 0x00, 0x00, 0x00, 0x01, // sampleCount + ]); + const reader = new shaka.util.DataViewReader( + sencBox, shaka.util.DataViewReader.Endianness.BIG_ENDIAN); + + const parsed = shaka.util.Mp4BoxParsers.parseSENC( + reader, /* flags= */ 0x000001, /* perSampleIVSize= */ 8, + /* defaultConstantIV= */ null); + + expect(parsed.samples.length).toBe(1); + + const expectedIv = new Uint8Array(16); + expectedIv.set([0x11, 0x22, 0x33, 0x44], 0); + expect(parsed.samples[0].iv).toEqual(expectedIv); + }); + }); });