Skip to content

Commit

Permalink
feat(hls): Support AES-128 in HLS (shaka-project#4386)
Browse files Browse the repository at this point in the history
Expands on the original PR (shaka-project#3880) by adding support for MP4 and key rotation.

Close shaka-project#850

Co-authored-by: wjywbs <wjywbs@users.noreply.github.com>
  • Loading branch information
theodab and wjywbs committed Aug 12, 2022
1 parent 04fc0d4 commit 6194021
Show file tree
Hide file tree
Showing 12 changed files with 389 additions and 68 deletions.
25 changes: 25 additions & 0 deletions demo/common/assets.js
Expand Up @@ -346,6 +346,22 @@ shakaAssets.testAssets = [
.addFeature(shakaAssets.Feature.SURROUND)
.addFeature(shakaAssets.Feature.OFFLINE)
.addLicenseServer('com.widevine.alpha', 'https://cwip-shaka-proxy.appspot.com/no_auth'),
new ShakaDemoAssetInfo(
/* name= */ 'Sintel (HLS, TS, AES-128 key rotation)',
/* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/sintel.png',
/* manifestUri= */ 'https://storage.googleapis.com/shaka-demo-assets/sintel-ts-aes-key-rotation/master.m3u8',
/* source= */ shakaAssets.Source.SHAKA)
.addFeature(shakaAssets.Feature.HLS)
.addFeature(shakaAssets.Feature.MP2TS)
.addFeature(shakaAssets.Feature.OFFLINE),
new ShakaDemoAssetInfo(
/* name= */ 'Sintel (HLS, FMP4, AES-128)',
/* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/sintel.png',
/* manifestUri= */ 'https://storage.googleapis.com/shaka-demo-assets/sintel-fmp4-aes/master.m3u8',
/* source= */ shakaAssets.Source.SHAKA)
.addFeature(shakaAssets.Feature.HLS)
.addFeature(shakaAssets.Feature.MP4)
.addFeature(shakaAssets.Feature.OFFLINE),
new ShakaDemoAssetInfo(
/* name= */ 'Sintel 4k (multicodec)',
/* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/sintel.png',
Expand Down Expand Up @@ -904,6 +920,15 @@ shakaAssets.testAssets = [
.addFeature(shakaAssets.Feature.HLS)
.addFeature(shakaAssets.Feature.MP2TS)
.addFeature(shakaAssets.Feature.OFFLINE),
new ShakaDemoAssetInfo(
/* name= */ 'Art of Motion (HLS, TS, AES-128)',
/* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/art_of_motion.png',
/* manifestUri= */ 'https://bitmovin-a.akamaihd.net/content/art-of-motion_drm/m3u8s/11331.m3u8',
/* source= */ shakaAssets.Source.BITCODIN)
.addFeature(shakaAssets.Feature.HIGH_DEFINITION)
.addFeature(shakaAssets.Feature.HLS)
.addFeature(shakaAssets.Feature.MP2TS)
.addFeature(shakaAssets.Feature.OFFLINE),
new ShakaDemoAssetInfo(
/* name= */ 'Sintel (HLS, TS, 4k)',
/* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/sintel.png',
Expand Down
43 changes: 43 additions & 0 deletions externs/shaka/manifest.js
Expand Up @@ -244,6 +244,49 @@ shaka.extern.Variant;
shaka.extern.CreateSegmentIndexFunction;


/**
* @typedef {{
* method: string,
* cryptoKey: (webCrypto.CryptoKey|undefined),
* fetchKey: (shaka.extern.CreateSegmentIndexFunction|undefined),
* iv: (!Uint8Array|undefined),
* firstMediaSequenceNumber: number
* }}
*
* @description
* AES-128 key and iv info from the HLS manifest.
*
* @property {string} method
* The key method defined in the HLS manifest.
* @property {webCrypto.CryptoKey|undefined} cryptoKey
* Web crypto key object of the AES-128 CBC key. If unset, the "fetchKey"
* property should be provided.
* @property {shaka.extern.FetchCryptoKeysFunction|undefined} fetchKey
* A function that fetches the key.
* Should be provided if the "cryptoKey" property is unset.
* Should update this object in-place, to set "cryptoKey".
* @property {(!Uint8Array|undefined)} iv
* The IV in the HLS manifest, if defined. See HLS RFC 8216 Section 5.2 for
* handling undefined IV.
* @property {number} firstMediaSequenceNumber
* The starting Media Sequence Number of the playlist, used when IV is
* undefined.
*
* @exportDoc
*/
shaka.extern.HlsAes128Key;


/**
* A function that fetches the crypto keys for AES-128.
* Returns a promise that resolves when the keys have been fetched.
*
* @typedef {function(): !Promise}
* @exportDoc
*/
shaka.extern.FetchCryptoKeysFunction;


/**
* @typedef {{
* id: number,
Expand Down
134 changes: 109 additions & 25 deletions lib/hls/hls_parser.js
Expand Up @@ -37,6 +37,7 @@ goog.require('shaka.util.OperationManager');
goog.require('shaka.util.Pssh');
goog.require('shaka.util.Timer');
goog.require('shaka.util.Platform');
goog.require('shaka.util.Uint8ArrayUtils');
goog.require('shaka.util.XmlUtils');
goog.requireType('shaka.hls.Segment');

Expand Down Expand Up @@ -1644,34 +1645,30 @@ shaka.hls.HlsParser = class {
if (method != 'NONE') {
encrypted = true;

// We do not support AES-128 encryption with HLS yet. So, do not create
// StreamInfo for the playlist encrypted with AES-128.
// TODO: Remove the error message once we add support for AES-128.
if (method == 'AES-128') {
shaka.log.warning('Unsupported HLS Encryption', method);
// These keys are handled separately.
this.aesEncrypted_ = true;
return null;
}

const keyFormat = drmTag.getRequiredAttrValue('KEYFORMAT');
const drmParser =
shaka.hls.HlsParser.KEYFORMATS_TO_DRM_PARSERS_[keyFormat];

const drmInfo = drmParser ? drmParser(drmTag, mimeType) : null;
if (drmInfo) {
if (drmInfo.keyIds) {
for (const keyId of drmInfo.keyIds) {
keyIds.add(keyId);
} else {
const keyFormat = drmTag.getRequiredAttrValue('KEYFORMAT');
const drmParser =
shaka.hls.HlsParser.KEYFORMATS_TO_DRM_PARSERS_[keyFormat];

const drmInfo = drmParser ? drmParser(drmTag, mimeType) : null;
if (drmInfo) {
if (drmInfo.keyIds) {
for (const keyId of drmInfo.keyIds) {
keyIds.add(keyId);
}
}
drmInfos.push(drmInfo);
} else {
shaka.log.warning('Unsupported HLS KEYFORMAT', keyFormat);
}
drmInfos.push(drmInfo);
} else {
shaka.log.warning('Unsupported HLS KEYFORMAT', keyFormat);
}
}
}

if (encrypted && !drmInfos.length) {
if (encrypted && !drmInfos.length && !this.aesEncrypted_) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
Expand All @@ -1688,9 +1685,8 @@ shaka.hls.HlsParser = class {

let segments;
try {
segments = this.createSegments_(verbatimMediaPlaylistUri,
playlist, type, mimeType, mediaSequenceToStartTime, mediaVariables,
codecs);
segments = this.createSegments_(verbatimMediaPlaylistUri, playlist, type,
mimeType, mediaSequenceToStartTime, mediaVariables, codecs);
} catch (error) {
if (error.code == shaka.util.Error.Code.HLS_INTERNAL_SKIP_STREAM) {
shaka.log.alwaysWarn('Skipping unsupported HLS stream',
Expand Down Expand Up @@ -1765,6 +1761,78 @@ shaka.hls.HlsParser = class {
}


/**
* @param {!shaka.hls.Tag} drmTag
* @param {!shaka.hls.Playlist} playlist
* @return {!shaka.extern.HlsAes128Key}
* @private
*/
parseAES128DrmTag_(drmTag, playlist) {
// Check if the Web Crypto API is available.
if (!window.crypto || !window.crypto.subtle) {
shaka.log.alwaysWarn('Web Crypto API is not available to decrypt ' +
'AES-128. (Web Crypto only exists in secure origins like https)');
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.NO_WEB_CRYPTO_API);
}

// HLS RFC 8216 Section 5.2:
// An EXT-X-KEY tag with a KEYFORMAT of "identity" that does not have an IV
// attribute indicates that the Media Sequence Number is to be used as the
// IV when decrypting a Media Segment, by putting its big-endian binary
// representation into a 16-octet (128-bit) buffer and padding (on the left)
// with zeros.
let firstMediaSequenceNumber = 0;
let iv;
const ivHex = drmTag.getAttributeValue('IV', '');
if (!ivHex) {
// Media Sequence Number will be used as IV.
firstMediaSequenceNumber = shaka.hls.Utils.getFirstTagWithNameAsNumber(
playlist.tags, 'EXT-X-MEDIA-SEQUENCE', 0);
} else {
// Exclude 0x at the start of string.
iv = shaka.util.Uint8ArrayUtils.fromHex(ivHex.substr(2));
if (iv.byteLength != 16) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.HLS_AES_128_INVALID_IV_LENGTH);
}
}

const keyUri = shaka.hls.Utils.constructAbsoluteUri(
playlist.absoluteUri, drmTag.getRequiredAttrValue('URI'));

const requestType = shaka.net.NetworkingEngine.RequestType.KEY;
const request = shaka.net.NetworkingEngine.makeRequest(
[keyUri], this.config_.retryParameters);

const keyInfo = {method: 'AES-128', iv, firstMediaSequenceNumber};

// Don't download the key object until the segment is parsed, to avoid a
// startup delay for long manifests with lots of keys.
keyInfo.fetchKey = async () => {
const keyResponse = await this.makeNetworkRequest_(request, requestType);

// keyResponse.status is undefined when URI is "data:text/plain;base64,"
if (!keyResponse.data || keyResponse.data.byteLength != 16) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.HLS_AES_128_INVALID_KEY_LENGTH);
}

keyInfo.cryptoKey = await window.crypto.subtle.importKey(
'raw', keyResponse.data, 'AES-CBC', true, ['decrypt']);
keyInfo.fetchKey = undefined; // No longer needed.
};

return keyInfo;
}


/**
* @param {!shaka.hls.Playlist} playlist
* @private
Expand Down Expand Up @@ -1939,12 +2007,13 @@ shaka.hls.HlsParser = class {
* @param {!Map.<string, string>} variables
* @param {string} absoluteMediaPlaylistUri
* @param {string} type
* @param {shaka.extern.HlsAes128Key=} hlsAes128Key
* @return {shaka.media.SegmentReference}
* @private
*/
createSegmentReference_(
initSegmentReference, previousReference, hlsSegment, startTime,
variables, absoluteMediaPlaylistUri, type) {
variables, absoluteMediaPlaylistUri, type, hlsAes128Key) {
const tags = hlsSegment.tags;
const absoluteSegmentUri = this.variableSubstitution_(
hlsSegment.absoluteUri, variables);
Expand Down Expand Up @@ -2039,6 +2108,8 @@ shaka.hls.HlsParser = class {
partialStatus = shaka.media.SegmentReference.Status.MISSING;
}

// We do not set the AES-128 key information for partial segments, as we
// do not support AES-128 and low-latency at the same time.
const partial = new shaka.media.SegmentReference(
pStartTime,
pEndTime,
Expand Down Expand Up @@ -2120,6 +2191,7 @@ shaka.hls.HlsParser = class {
tileDuration,
syncTime,
status,
hlsAes128Key,
);
}

Expand Down Expand Up @@ -2186,6 +2258,9 @@ shaka.hls.HlsParser = class {
/** @type {shaka.media.InitSegmentReference} */
let initSegmentRef;

/** @type {shaka.extern.HlsAes128Key|undefined} */
let hlsAes128Key = undefined;

// We may need to look at the media itself to determine a segment start
// time.
const mediaSequenceNumber = shaka.hls.Utils.getFirstTagWithNameAsNumber(
Expand Down Expand Up @@ -2214,6 +2289,14 @@ shaka.hls.HlsParser = class {
(i == 0) ? firstStartTime : previousReference.endTime;
position = mediaSequenceNumber + skippedSegments + i;

// Apply new AES-128 tags as you see them, keeping a running total.
for (const drmTag of item.tags) {
if (drmTag.name == 'EXT-X-KEY' &&
drmTag.getRequiredAttrValue('METHOD') == 'AES-128') {
hlsAes128Key = this.parseAES128DrmTag_(drmTag, playlist);
}
}

mediaSequenceToStartTime.set(position, startTime);

initSegmentRef = this.getInitSegmentReference_(playlist.absoluteUri,
Expand All @@ -2238,7 +2321,8 @@ shaka.hls.HlsParser = class {
startTime,
variables,
playlist.absoluteUri,
type);
type,
hlsAes128Key);
previousReference = reference;

if (reference) {
Expand Down
8 changes: 8 additions & 0 deletions lib/media/segment_index.js
Expand Up @@ -529,6 +529,14 @@ shaka.media.SegmentIterator = class {
this.currentPartialPosition_ = partialSegmentIndex;
}

/**
* @return {number}
* @export
*/
currentPosition() {
return this.currentPosition_;
}

/**
* @return {shaka.media.SegmentReference}
* @export
Expand Down
8 changes: 7 additions & 1 deletion lib/media/segment_reference.js
Expand Up @@ -166,12 +166,15 @@ shaka.media.SegmentReference = class {
* @param {shaka.media.SegmentReference.Status=} status
* The segment status is used to indicate that a segment does not exist or is
* not available.
* @param {?shaka.extern.HlsAes128Key=} hlsAes128Key
* The segment's AES-128-CBC full segment encryption key and iv.
*/
constructor(
startTime, endTime, uris, startByte, endByte, initSegmentReference,
timestampOffset, appendWindowStart, appendWindowEnd,
partialReferences = [], tilesLayout = '', tileDuration = null,
syncTime = null, status = shaka.media.SegmentReference.Status.AVAILABLE) {
syncTime = null, status = shaka.media.SegmentReference.Status.AVAILABLE,
hlsAes128Key = null) {
// A preload hinted Partial Segment has the same startTime and endTime.
goog.asserts.assert(startTime <= endTime,
'startTime must be less than or equal to endTime');
Expand Down Expand Up @@ -233,6 +236,9 @@ shaka.media.SegmentReference = class {

/** @type {shaka.media.SegmentReference.Status} */
this.status = status;

/** @type {?shaka.extern.HlsAes128Key} */
this.hlsAes128Key = hlsAes128Key;
}

/**
Expand Down

0 comments on commit 6194021

Please sign in to comment.