mirror of
https://github.com/shaka-project/shaka-player.git
synced 2026-06-13 15:46:46 +03:00
feat: Support ClearKey playback in Safari through WebCrypto (#10180)
Co-authored-by: Wojciech Tyczyński <tykus160@gmail.com>
This commit is contained in:
committed by
GitHub
parent
697816d3d9
commit
f349d0a06e
@@ -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
|
||||
|
||||
@@ -49,7 +49,8 @@ shaka.extern.InitDataOverride;
|
||||
* sessionType: string,
|
||||
* initData: Array<!shaka.extern.InitDataOverride>,
|
||||
* keyIds: Set<string>,
|
||||
* mediaTypes: (!Array<string>|undefined)
|
||||
* mediaTypes: (!Array<string>|undefined),
|
||||
* clearKeys: (!Map<string, string>|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 <code>data:</code> scheme URI, separated by semicolon.
|
||||
* @property {(!Map<string, string>|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;
|
||||
|
||||
@@ -389,6 +389,13 @@ shaka.device.AbstractDevice = class {
|
||||
return typeof Worker !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
hasWorkingClearKeySupport() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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() {}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+34
-20
@@ -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(
|
||||
|
||||
@@ -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<string, !MediaKeySystemAccess>}
|
||||
*/
|
||||
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;
|
||||
|
||||
@@ -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<string, {cbc:!CryptoKey, ctr:!CryptoKey}>} */
|
||||
this.keyMap_ = new Map();
|
||||
|
||||
/** @private {?Promise<!Map<string, {cbc:!CryptoKey, ctr:!CryptoKey}>>} */
|
||||
this.keyMapPromise_ = null;
|
||||
|
||||
/** @private {?Uint8Array} */
|
||||
this.lastInit_ = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {!BufferSource} data
|
||||
* @param {boolean} isInit
|
||||
* @param {!shaka.extern.DrmInfo} drmInfo
|
||||
* @return {!Promise<!Uint8Array>}
|
||||
*/
|
||||
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<keyIdHex, {cbc, ctr}> from a ClearKey DrmInfo whose
|
||||
* licenseServerUri is a data:application/json;base64,<JWK-set> URI.
|
||||
*
|
||||
* @param {!shaka.extern.DrmInfo} drmInfo
|
||||
* @return {!Promise<!Map<string, {cbc:!CryptoKey, ctr:!CryptoKey}>>}
|
||||
* @private
|
||||
*/
|
||||
async buildKeyMap_(drmInfo) {
|
||||
/** @type {!Map<string, {cbc:!CryptoKey, ctr:!CryptoKey}>} */
|
||||
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<!Uint8Array>}
|
||||
* @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<number, !shaka.media.ClearKeyWebCryptoDecryptor.InitInfo>}
|
||||
* @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<number,
|
||||
* !shaka.media.ClearKeyWebCryptoDecryptor.InitInfo>} 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<!Uint8Array>}
|
||||
* @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<number>} 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<!Uint8Array>}
|
||||
* @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<!Uint8Array>}
|
||||
* @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<!Uint8Array>}
|
||||
* @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<!Uint8Array>} 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<!shaka.media.ClearKeyWebCryptoDecryptor.FragmentInfo>,
|
||||
* firstFragmentOffset: number,
|
||||
* }}
|
||||
*/
|
||||
shaka.media.ClearKeyWebCryptoDecryptor.SegmentParseResult;
|
||||
@@ -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<shaka.util.ManifestParserUtils.ContentType,
|
||||
* !shaka.media.ClearKeyWebCryptoDecryptor>}
|
||||
*/
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+134
-9
@@ -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;
|
||||
|
||||
@@ -76,6 +76,23 @@ shaka.util.Mp4Parser = class {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Declare multiple box types as Full Boxes.
|
||||
*
|
||||
* @param {!Array<string>} 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -99,6 +99,7 @@ describe('SimpleAbrManager (integration)', () => {
|
||||
onEmsg: () => {},
|
||||
onEvent: () => {},
|
||||
onManifestUpdate: () => {},
|
||||
getDrmInfo: () => null,
|
||||
},
|
||||
mediaSourceConfig);
|
||||
waiter.setMediaSourceEngine(mediaSourceEngine);
|
||||
|
||||
@@ -297,6 +297,7 @@ describe('CastUtils', () => {
|
||||
onEmsg: () => {},
|
||||
onEvent: () => {},
|
||||
onManifestUpdate: () => {},
|
||||
getDrmInfo: () => null,
|
||||
},
|
||||
config);
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
[{
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,6 +193,7 @@ describe('MediaSourceEngine', () => {
|
||||
onEmsg: Util.spyFunc(onEmsg),
|
||||
onEvent: Util.spyFunc(onEvent),
|
||||
onManifestUpdate: Util.spyFunc(onManifestUpdate),
|
||||
getDrmInfo: () => null,
|
||||
},
|
||||
config);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ describe('StreamingEngine', () => {
|
||||
onEmsg: () => {},
|
||||
onEvent: () => {},
|
||||
onManifestUpdate: () => {},
|
||||
getDrmInfo: () => null,
|
||||
},
|
||||
mediaSourceConfig);
|
||||
waiter.setMediaSourceEngine(mediaSourceEngine);
|
||||
|
||||
@@ -427,6 +427,8 @@ shaka.test.ManifestGenerator.DrmInfo = class {
|
||||
this.keySystemUris;
|
||||
/** @type {(!Array<string> | undefined)} */
|
||||
this.mediaTypes;
|
||||
/** @type {(!Map<string, string>|undefined)} */
|
||||
this.clearKeys;
|
||||
|
||||
/** @type {shaka.extern.DrmInfo} */
|
||||
const foo = this;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user