feat: Support ClearKey playback in Safari through WebCrypto (#10180)

Co-authored-by: Wojciech Tyczyński <tykus160@gmail.com>
This commit is contained in:
Álvaro Velad Galván
2026-06-12 11:47:43 +02:00
committed by GitHub
parent 697816d3d9
commit f349d0a06e
27 changed files with 1415 additions and 45 deletions
+1
View File
@@ -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
+12 -1
View File
@@ -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;
+7
View File
@@ -389,6 +389,13 @@ shaka.device.AbstractDevice = class {
return typeof Worker !== 'undefined';
}
/**
* @override
*/
hasWorkingClearKeySupport() {
return true;
}
/**
* @override
*/
+7
View File
@@ -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}
+8
View File
@@ -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
View File
@@ -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(
+31
View File
@@ -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;
+860
View File
@@ -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;
+42 -1
View File
@@ -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;
+10 -2
View File
@@ -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;
})
+1
View File
@@ -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);
+5 -12
View File
@@ -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
View File
@@ -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;
+17
View File
@@ -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.
+6
View File
@@ -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);
+1
View File
@@ -297,6 +297,7 @@ describe('CastUtils', () => {
onEmsg: () => {},
onEvent: () => {},
onManifestUpdate: () => {},
getDrmInfo: () => null,
},
config);
+31
View File
@@ -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 &&
+5
View File
@@ -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 = {
+32
View File
@@ -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');
});
});
});
+21
View File
@@ -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',
[{
+21
View File
@@ -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);
+3
View File
@@ -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);
+2
View File
@@ -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;
+121
View File
@@ -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);
});
});
});