Skip to content

Commit

Permalink
Support AES-128 in HLS TS segments.
Browse files Browse the repository at this point in the history
  • Loading branch information
wjywbs authored and sunb-inflab committed Jul 12, 2022
1 parent b5da41e commit 921fc06
Show file tree
Hide file tree
Showing 8 changed files with 190 additions and 17 deletions.
9 changes: 9 additions & 0 deletions demo/common/assets.js
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,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
31 changes: 31 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 All @@ -263,6 +290,7 @@ shaka.extern.CreateSegmentIndexFunction;
* encrypted: boolean,
* drmInfos: !Array.<shaka.extern.DrmInfo>,
* keyIds: !Set.<string>,
* hlsAes128Key: (shaka.extern.HlsAes128Key|undefined),
* language: string,
* label: ?string,
* type: string,
Expand Down Expand Up @@ -343,6 +371,9 @@ shaka.extern.CreateSegmentIndexFunction;
* The stream's key IDs as lowercase hex strings. These key IDs identify the
* encryption keys that the browser (key system) can use to decrypt the
* stream.
* @property {(shaka.extern.HlsAes128Key|undefined)} hlsAes128Key
* <i>Defaults to undefined (i.e., no HLS AES-128 encryption).</i> <br>
* The HLS stream's AES-128-CBC full segment encryption key and iv.
* @property {string} language
* The Stream's language, specified as a language code. <br>
* Audio stream's language must be identical to the language of the containing
Expand Down
82 changes: 73 additions & 9 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 @@ -1628,25 +1629,87 @@ shaka.hls.HlsParser = class {
}

let encrypted = false;
let hlsAes128Key;
/** @type {!Array.<shaka.extern.DrmInfo>}*/
const drmInfos = [];
const keyIds = new Set();

// TODO: May still need changes to support key rotation.
// TODO: May still need changes to support key rotation and discontinuity.
for (const drmTag of drmTags) {
const method = drmTag.getRequiredAttrValue('METHOD');
if (method != 'NONE') {
if (method == 'AES-128') {
encrypted = true;
this.aesEncrypted_ = 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);
this.aesEncrypted_ = true;
if (mimeType != 'video/mp2t') {
shaka.log.alwaysWarn(
'AES-128 in MP4 HLS is not yet supported. Skipping ' + mimeType);
return null;
}

// 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(
this.masterPlaylistUri_, drmTag.getRequiredAttrValue('URI'));

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

// eslint-disable-next-line no-await-in-loop
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');
}
// eslint-disable-next-line no-await-in-loop
const cryptoKey = await window.crypto.subtle.importKey(
'raw', keyResponse.data, 'AES-CBC', true, ['decrypt']);

hlsAes128Key = {method, cryptoKey, iv, firstMediaSequenceNumber};
} else if (method != 'NONE') {
encrypted = true;

const keyFormat = drmTag.getRequiredAttrValue('KEYFORMAT');
const drmParser =
shaka.hls.HlsParser.KEYFORMATS_TO_DRM_PARSERS_[keyFormat];
Expand All @@ -1665,7 +1728,7 @@ shaka.hls.HlsParser = class {
}
}

if (encrypted && !drmInfos.length) {
if (encrypted && !drmInfos.length && !hlsAes128Key) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
Expand Down Expand Up @@ -1726,6 +1789,7 @@ shaka.hls.HlsParser = class {
encrypted,
drmInfos,
keyIds,
hlsAes128Key,
language,
label: name, // For historical reasons, since before "originalId".
type,
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
16 changes: 15 additions & 1 deletion lib/media/streaming_engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -1273,7 +1273,21 @@ 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;
if (stream.hlsAes128Key) {
let iv = stream.hlsAes128Key.iv;
if (!iv) {
iv = shaka.util.BufferUtils.toUint8(new ArrayBuffer(16));
let sequence = stream.hlsAes128Key.firstMediaSequenceNumber +
iter.currentPosition();
for (let i = iv.byteLength - 1; i >= 0; i--) {
iv[i] = sequence & 0xff;
sequence >>= 8;
}
}
result = await window.crypto.subtle.decrypt(
{name: 'AES-CBC', iv}, stream.hlsAes128Key.cryptoKey, result);
}
this.destroyer_.ensureNotDestroyed();
if (this.fatalError_) {
return;
Expand Down
1 change: 1 addition & 0 deletions lib/net/networking_engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,7 @@ shaka.net.NetworkingEngine.RequestType = {
'APP': 3,
'TIMING': 4,
'SERVER_CERTIFICATE': 5,
'KEY': 6,
};


Expand Down
11 changes: 11 additions & 0 deletions lib/util/error.js
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,17 @@ shaka.util.Error.Code = {
*/
'HLS_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED': 4041,

/**
* Web Crypto API is not available (to decrypt AES-128 streams). Web Crypto
* only exists in secure origins like https.
*/
'NO_WEB_CRYPTO_API': 4042,

/**
* AES-128 encryption key or iv length should be 16 bytes.
*/
'HLS_AES_128_INVALID_KEY_OR_IV_LENGTH': 4043,


// RETIRED: 'INCONSISTENT_BUFFER_STATE': 5000,
// RETIRED: 'INVALID_SEGMENT_INDEX': 5001,
Expand Down
49 changes: 42 additions & 7 deletions test/hls/hls_parser_unit.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ describe('HlsParser', () => {
let segmentData;
/** @type {!Uint8Array} */
let selfInitializingSegmentData;
/** @type {!Uint8Array} */
let aes128Key;

afterEach(() => {
shaka.log.alwaysWarn = originalAlwaysWarn;
Expand Down Expand Up @@ -75,6 +77,11 @@ describe('HlsParser', () => {
selfInitializingSegmentData =
shaka.util.Uint8ArrayUtils.concat(initSegmentData, segmentData);

aes128Key = new Uint8Array([
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
]);

fakeNetEngine = new shaka.test.FakeNetworkingEngine();

config = shaka.util.PlayerConfiguration.createDefault().manifest;
Expand Down Expand Up @@ -2432,7 +2439,7 @@ describe('HlsParser', () => {
expect(initSegments[1].getUris()[0]).toBe('test:/init2.mp4');
});

it('drops variants encrypted with AES-128', async () => {
it('parses variants encrypted with AES-128', async () => {
const master = [
'#EXTM3U\n',
'#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1,mp4a",',
Expand All @@ -2441,10 +2448,15 @@ describe('HlsParser', () => {
'#EXT-X-STREAM-INF:BANDWIDTH=300,CODECS="avc1,mp4a",',
'RESOLUTION=960x540,FRAME-RATE=120,AUDIO="aud2"\n',
'video2\n',
'#EXT-X-STREAM-INF:BANDWIDTH=300,CODECS="avc1,mp4a",',
'RESOLUTION=960x540,FRAME-RATE=120,AUDIO="aud3"\n',
'video3\n',
'#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud1",LANGUAGE="eng",',
'URI="audio"\n',
'#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud2",LANGUAGE="fr",',
'URI="audio2"\n',
'#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud3",LANGUAGE="de",',
'URI="audio3"\n',
].join('');

const media = [
Expand All @@ -2456,17 +2468,26 @@ describe('HlsParser', () => {
'main.mp4',
].join('');

const mediaWithAesEncryption = [
const mediaWithMp4AesEncryption = [
'#EXTM3U\n',
'#EXT-X-PLAYLIST-TYPE:VOD\n',
'#EXT-X-KEY:METHOD=AES-128,',
'URI="800k.key\n',
'URI="800k.key"\n',
'#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n',
'#EXTINF:5,\n',
'#EXT-X-BYTERANGE:121090@616\n',
'main.mp4',
].join('');

const mediaWithTSAesEncryption = [
'#EXTM3U\n',
'#EXT-X-PLAYLIST-TYPE:VOD\n',
'#EXT-X-KEY:METHOD=AES-128,',
'URI="800k.key"\n',
'#EXTINF:5,\n',
'main.ts',
].join('');

const manifest = shaka.test.ManifestGenerator.generate((manifest) => {
manifest.anyTimeline();
manifest.addPartialVariant((variant) => {
Expand All @@ -2478,19 +2499,31 @@ describe('HlsParser', () => {
stream.language = 'en';
});
});
manifest.addPartialVariant((variant) => {
variant.bandwidth = 300;
variant.addPartialStream(ContentType.VIDEO, (stream) => {
stream.size(960, 540);
});
variant.addPartialStream(ContentType.AUDIO, (stream) => {
stream.language = 'de';
});
});
manifest.sequenceMode = true;
});

fakeNetEngine
.setResponseText('test:/master', master)
.setResponseText('test:/audio', media)
.setResponseText('test:/audio2', media)
.setResponseText('test:/audio3', media)
.setResponseText('test:/video', media)
.setResponseText('test:/video2', mediaWithAesEncryption)
.setResponseText('test:/video2', mediaWithMp4AesEncryption)
.setResponseText('test:/video3', mediaWithTSAesEncryption)
.setResponseText('test:/main.vtt', vttText)
.setResponseValue('test:/init.mp4', initSegmentData)
.setResponseValue('test:/main.mp4', segmentData)
.setResponseValue('test:/main.test', segmentData)
.setResponseValue('test:/800k.key', aes128Key)
.setResponseValue('test:/selfInit.mp4', selfInitializingSegmentData);

const actual = await parser.start('test:/master', playerInterface);
Expand Down Expand Up @@ -2674,7 +2707,9 @@ describe('HlsParser', () => {
.setResponseText('test:/video', media)
.setResponseValue('test:/main.exe', segmentData)
.setResponseValue('test:/init.mp4', initSegmentData)
.setResponseValue('test:/main.mp4', segmentData);
.setResponseValue('test:/main.mp4', segmentData)
.setResponseValue('data:text/plain;base64,AAECAwQFBgcICQoLDA0ODw==',
aes128Key);

await expectAsync(parser.start('test:/master', playerInterface))
.toBeRejectedWith(Util.jasmineError(error));
Expand Down Expand Up @@ -2708,7 +2743,7 @@ describe('HlsParser', () => {
await verifyError(master, media, error);
});

it('if all variants are encrypted with AES-128', async () => {
it('if all variants are encrypted with AES-128 and used MP4', async () => {
const master = [
'#EXTM3U\n',
'#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1",',
Expand All @@ -2721,7 +2756,7 @@ describe('HlsParser', () => {
'#EXT-X-TARGETDURATION:6\n',
'#EXT-X-PLAYLIST-TYPE:VOD\n',
'#EXT-X-KEY:METHOD=AES-128,',
'URI="data:text/plain;base64\n',
'URI="data:text/plain;base64,AAECAwQFBgcICQoLDA0ODw=="\n',
'#EXT-X-MAP:URI="init.mp4"\n',
'#EXTINF:5,\n',
'#EXT-X-BYTERANGE:121090@616\n',
Expand Down

0 comments on commit 921fc06

Please sign in to comment.