Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(hls): Support AES-128 in HLS #4386

Merged
merged 4 commits into from
Aug 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions demo/common/assets.js
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading