Skip to content

Commit

Permalink
feat(hls): Support AES-128 in HLS
Browse files Browse the repository at this point in the history
Also adds support for key rotation in HLS, but only for AES-128.

Based on shaka-project#3880
Issue shaka-project#850
  • Loading branch information
theodab committed Jul 29, 2022
1 parent 8b99490 commit 68a5413
Show file tree
Hide file tree
Showing 8 changed files with 211 additions and 28 deletions.
17 changes: 17 additions & 0 deletions demo/common/assets.js
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,14 @@ 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= */ 'Angel One (HLS, TS, AES-128 key rotation, Video Only)',
/* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/angel_one.png',
/* manifestUri= */ 'https://storage.googleapis.com/shaka-demo-assets/angel-one-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 4k (multicodec)',
/* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/sintel.png',
Expand Down Expand Up @@ -904,6 +912,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
27 changes: 27 additions & 0 deletions externs/shaka/manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,33 @@ shaka.extern.Variant;
shaka.extern.CreateSegmentIndexFunction;


/**
* @typedef {{
* method: string,
* cryptoKey: !webCrypto.CryptoKey,
* 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} cryptoKey
* Web crypto key object of the AES-128 CBC key.
* @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;


/**
* @typedef {{
* id: number,
Expand Down
137 changes: 111 additions & 26 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 @@ -326,7 +327,7 @@ shaka.hls.HlsParser = class {

const stream = streamInfo.stream;

const segments = this.createSegments_(
const segments = await this.createSegments_(
streamInfo.verbatimMediaPlaylistUri, playlist, stream.type,
stream.mimeType, streamInfo.mediaSequenceToStartTime, mediaVariables,
stream.codecs);
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,7 +1685,7 @@ shaka.hls.HlsParser = class {

let segments;
try {
segments = this.createSegments_(verbatimMediaPlaylistUri,
segments = await this.createSegments_(verbatimMediaPlaylistUri,
playlist, type, mimeType, mediaSequenceToStartTime, mediaVariables,
codecs);
} catch (error) {
Expand Down Expand Up @@ -1765,6 +1762,78 @@ shaka.hls.HlsParser = class {
}


/**
* @param {!shaka.hls.Tag} drmTag
* @param {!shaka.hls.Playlist} playlist
* @return {!Promise.<!shaka.extern.HlsAes128Key>}
* @private
*/
async 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_KEY_OR_IV_LENGTH,
'IV');
}
}

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 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_OR_IV_LENGTH,
'KEY');
}

const cryptoKey = await window.crypto.subtle.importKey(
'raw', keyResponse.data, 'AES-CBC', true, ['decrypt']);

// TODO: TO DO:
// 4. make tests, including tests for multi-key and such
// 6. disable isReadableStreamSupported when AES-128 is present? maybe
// 8. once this is accepted, make this into a rebase on top of their PR
// Joey suggests that an interactive rebase could be easier (rebase -i)
// TODO: make an AES-128 in mp4 asset, and put it in pantheon
return {method: 'AES-128', cryptoKey, iv, firstMediaSequenceNumber};
}


/**
* @param {!shaka.hls.Playlist} playlist
* @private
Expand Down Expand Up @@ -1939,12 +2008,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 +2109,7 @@ shaka.hls.HlsParser = class {
partialStatus = shaka.media.SegmentReference.Status.MISSING;
}

// TODO: can there can be partial AES segments?
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 @@ -2174,10 +2246,10 @@ shaka.hls.HlsParser = class {
* @param {!Map.<number, number>} mediaSequenceToStartTime
* @param {!Map.<string, string>} variables
* @param {string} codecs
* @return {!Array.<!shaka.media.SegmentReference>}
* @return {!Promise.<!Array.<!shaka.media.SegmentReference>>}
* @private
*/
createSegments_(verbatimMediaPlaylistUri, playlist, type, mimeType,
async createSegments_(verbatimMediaPlaylistUri, playlist, type, mimeType,
mediaSequenceToStartTime, variables, codecs) {
/** @type {Array.<!shaka.hls.Segment>} */
const hlsSegments = playlist.segments;
Expand All @@ -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,15 @@ 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') {
// eslint-disable-next-line no-await-in-loop
hlsAes128Key = await this.parseAES128DrmTag_(drmTag, playlist);
}
}

mediaSequenceToStartTime.set(position, startTime);

initSegmentRef = this.getInitSegmentReference_(playlist.absoluteUri,
Expand All @@ -2238,7 +2322,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
10 changes: 9 additions & 1 deletion lib/media/segment_reference.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ shaka.media.InitSegmentReference = class {

/** @const {shaka.extern.MediaQualityInfo|null} */
this.mediaQuality = mediaQuality;

// TODO: does this need AES info too?
}

/**
Expand Down Expand Up @@ -166,12 +168,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) {
// 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 +238,9 @@ shaka.media.SegmentReference = class {

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

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

/**
Expand Down
28 changes: 27 additions & 1 deletion lib/media/streaming_engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -1273,11 +1273,15 @@ shaka.media.StreamingEngine = class {
'ReadableStream is not supported by the browser.');
}
const fetchSegment = this.fetch_(mediaState, reference);
const result = await fetchSegment;
let result = await fetchSegment;
this.destroyer_.ensureNotDestroyed();
if (this.fatalError_) {
return;
}
if (reference.hlsAes128Key && iter) {
result = await this.aes128Decrypt_(result, reference, iter);
}
this.destroyer_.ensureNotDestroyed();

// If the text stream gets switched between fetch_() and append_(), the
// new text parser is initialized, but the new init segment is not
Expand Down Expand Up @@ -1368,6 +1372,28 @@ shaka.media.StreamingEngine = class {
}
}

/**
* @param {!BufferSource} rawResult
* @param {!shaka.media.SegmentReference} reference
* @param {!shaka.media.SegmentIterator} iter
* @return {!Promise.<!BufferSource>} finalResult
* @private
*/
aes128Decrypt_(rawResult, reference, iter) {
let iv = reference.hlsAes128Key.iv;
if (!iv) {
iv = shaka.util.BufferUtils.toUint8(new ArrayBuffer(16));
let sequence = reference.hlsAes128Key.firstMediaSequenceNumber +
iter.currentPosition();
for (let i = iv.byteLength - 1; i >= 0; i--) {
iv[i] = sequence & 0xff;
sequence >>= 8;
}
}
return window.crypto.subtle.decrypt(
{name: 'AES-CBC', iv}, reference.hlsAes128Key.cryptoKey, rawResult);
}


/**
* Clear per-stream error states and retry any failed streams.
Expand Down
Loading

0 comments on commit 68a5413

Please sign in to comment.