diff --git a/src/parser.js b/src/parser.js index 3aa8b40..019327b 100644 --- a/src/parser.js +++ b/src/parser.js @@ -4,6 +4,7 @@ import Stream from './stream'; import LineStream from './line-stream'; import ParseStream from './parse-stream'; +import decodeB64ToUint8Array from './utils/decode'; /** * A parser for M3U8 files. The current interpretation of the input is @@ -49,6 +50,9 @@ export default class Parser extends Stream { 'CLOSED-CAPTIONS': {}, 'SUBTITLES': {} }; + // This is the Widevine UUID from DASH IF IOP. The same exact string is + // used in MPDs with Widevine encrypted streams. + const widevineUuid = 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed'; // group segments into numbered timelines delineated by discontinuities let currentTimeline = 0; @@ -143,6 +147,55 @@ export default class Parser extends Stream { }); return; } + + // check if the content is encrypted for Widevine + // Widevine/HLS spec: https://storage.googleapis.com/wvdocs/Widevine_DRM_HLS.pdf + if (entry.attributes.KEYFORMAT === widevineUuid) { + const VALID_METHODS = ['SAMPLE-AES', 'SAMPLE-AES-CTR', 'SAMPLE-AES-CENC']; + + if (VALID_METHODS.indexOf(entry.attributes.METHOD) === -1) { + this.trigger('warn', { + message: 'invalid key method provided for Widevine' + }); + return; + } + + if (entry.attributes.METHOD === 'SAMPLE-AES-CENC') { + this.trigger('warn', { + message: 'SAMPLE-AES-CENC is deprecated, please use SAMPLE-AES-CTR instead' + }); + } + + if (entry.attributes.URI.substring(0, 23) !== 'data:text/plain;base64,') { + this.trigger('warn', { + message: 'invalid key URI provided for Widevine' + }); + return; + } + + if (!(entry.attributes.KEYID && entry.attributes.KEYID.substring(0, 2) === '0x')) { + this.trigger('warn', { + message: 'invalid key ID provided for Widevine' + }); + return; + } + + // if Widevine key attributes are valid, store them as `contentProtection` + // on the manifest to emulate Widevine tag structure in a DASH mpd + this.manifest.contentProtection = { + 'com.widevine.alpha': { + attributes: { + schemeIdUri: entry.attributes.KEYFORMAT, + // remove '0x' from the key id string + keyId: entry.attributes.KEYID.substring(2) + }, + // decode the base64-encoded PSSH box + pssh: decodeB64ToUint8Array(entry.attributes.URI.split(',')[1]) + } + }; + return; + } + if (!entry.attributes.METHOD) { this.trigger('warn', { message: 'defaulting key method to AES-128' diff --git a/src/utils/decode.js b/src/utils/decode.js new file mode 100644 index 0000000..c38666e --- /dev/null +++ b/src/utils/decode.js @@ -0,0 +1,11 @@ +import window from 'global/window'; + +export default function decodeB64ToUint8Array(b64Text) { + const decodedString = window.atob(b64Text || ''); + const array = new Uint8Array(decodedString.length); + + for (let i = 0; i < decodedString.length; i++) { + array[i] = decodedString.charCodeAt(i); + } + return array; +} diff --git a/test/m3u8.test.js b/test/m3u8.test.js index 183783e..e4d5263 100644 --- a/test/m3u8.test.js +++ b/test/m3u8.test.js @@ -896,6 +896,7 @@ QUnit.test('parses prefixed with 0x or 0X #EXT-X-KEY:IV tags', function(assert) 0x90abcdef ]), 'parsed an IV value with 0X'); }); + // #EXT-X-START QUnit.test('parses EXT-X-START tags', function(assert) { const manifest = '#EXT-X-START:TIME-OFFSET=1.1\n'; @@ -1173,6 +1174,116 @@ QUnit.test('parses FORCED attribute', function(assert) { 'parsed FORCED attribute'); }); +QUnit.test('parses Widevine #EXT-X-KEY attributes and attaches to manifest', function(assert) { + const parser = new Parser(); + + const manifest = [ + '#EXTM3U', + '#EXT-X-KEY:METHOD=SAMPLE-AES-CTR,' + + 'URI="data:text/plain;base64,AAAAPnBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAB4iFnN' + + 'oYWthX2NlYzJmNjRhYTc4OTBhMTFI49yVmwY=",KEYID=0x800AACAA522958AE888062B5695DB6BF,' + + 'KEYFORMATVERSIONS="1",KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"', + '#EXTINF:5,', + 'ex1.ts', + '#EXT-X-ENDLIST' + ].join('\n'); + + parser.push(manifest); + + assert.ok(parser.manifest.contentProtection, 'contentProtection property added'); + assert.equal( + parser.manifest.contentProtection['com.widevine.alpha'].attributes.schemeIdUri, + 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', + 'schemeIdUri set correctly' + ); + assert.equal( + parser.manifest.contentProtection['com.widevine.alpha'].attributes.keyId, + '800AACAA522958AE888062B5695DB6BF', + 'keyId set correctly' + ); + assert.equal( + parser.manifest.contentProtection['com.widevine.alpha'].pssh.byteLength, + 62, + 'base64 URI decoded to TypedArray' + ); +}); + +QUnit.test('Widevine #EXT-X-KEY attributes not attached to manifest if METHOD is invalid', function(assert) { + const parser = new Parser(); + + const manifest = [ + '#EXTM3U', + '#EXT-X-KEY:METHOD=NONE,' + + 'URI="data:text/plain;base64,AAAAPnBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAB4iFnN' + + 'oYWthX2NlYzJmNjRhYTc4OTBhMTFI49yVmwY=",KEYID=0x800AACAA522958AE888062B5695DB6BF,' + + 'KEYFORMATVERSIONS="1",KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"', + '#EXTINF:5,', + 'ex1.ts', + '#EXT-X-ENDLIST' + ].join('\n'); + + parser.push(manifest); + + assert.notOk(parser.manifest.contentProtection, 'contentProtection not added'); +}); + +QUnit.test('Widevine #EXT-X-KEY attributes not attached to manifest if URI is invalid', function(assert) { + const parser = new Parser(); + + const manifest = [ + '#EXTM3U', + '#EXT-X-KEY:METHOD=SAMPLE-AES-CTR,' + + 'URI="AAAAPnBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAB4iFnN' + + 'oYWthX2NlYzJmNjRhYTc4OTBhMTFI49yVmwY=",KEYID=0x800AACAA522958AE888062B5695DB6BF,' + + 'KEYFORMATVERSIONS="1",KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"', + '#EXTINF:5,', + 'ex1.ts', + '#EXT-X-ENDLIST' + ].join('\n'); + + parser.push(manifest); + + assert.notOk(parser.manifest.contentProtection, 'contentProtection not added'); +}); + +QUnit.test('Widevine #EXT-X-KEY attributes not attached to manifest if KEYID is invalid', function(assert) { + const parser = new Parser(); + + const manifest = [ + '#EXTM3U', + '#EXT-X-KEY:METHOD=SAMPLE-AES-CTR,' + + 'URI="data:text/plain;base64,AAAAPnBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAB4iFnN' + + 'oYWthX2NlYzJmNjRhYTc4OTBhMTFI49yVmwY=",KEYID=800AACAA522958AE888062B5695DB6BF,' + + 'KEYFORMATVERSIONS="1",KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"', + '#EXTINF:5,', + 'ex1.ts', + '#EXT-X-ENDLIST' + ].join('\n'); + + parser.push(manifest); + + assert.notOk(parser.manifest.contentProtection, 'contentProtection not added'); +}); + +QUnit.test('Widevine #EXT-X-KEY attributes not attached to manifest if KEYFORMAT is not Widevine UUID', function(assert) { + const parser = new Parser(); + + const manifest = [ + '#EXTM3U', + '#EXT-X-KEY:METHOD=SAMPLE-AES-CTR,' + + 'URI="data:text/plain;base64,AAAAPnBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAB4iFnN' + + 'oYWthX2NlYzJmNjRhYTc4OTBhMTFI49yVmwY=",KEYID=0x800AACAA522958AE888062B5695DB6BF,' + + 'KEYFORMATVERSIONS="1",KEYFORMAT="invalid-keyformat"', + '#EXTINF:5,', + 'ex1.ts', + '#EXT-X-ENDLIST' + ].join('\n'); + + parser.push(manifest); + + assert.notOk(parser.manifest.contentProtection, 'contentProtection not added'); +}); + QUnit.module('m3u8s'); QUnit.test('parses static manifests as expected', function(assert) {