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: parse key attributes for Widevine HLS #88

Merged
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
53 changes: 53 additions & 0 deletions src/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is from the spec?

Copy link
Member

@misteroneill misteroneill Jun 20, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I had the same question. It's in the spec doc... 🤷‍♂

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, and the same string is used for mpds (see mpd-parser)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be useful to add a comment and link to the widevine hls extension spec

Copy link
Member

@misteroneill misteroneill Jun 20, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know nothing about this really, but is this a universally valid UUID?
Answered my own question.

// group segments into numbered timelines delineated by discontinuities
let currentTimeline = 0;

Expand Down Expand Up @@ -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') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This case is fine just deprecated?

Maybe we should group the ones that exit early?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just logging a warning should be fine. The spec didn't replace 'SAMPLE-AES-CENC' with 'SAMPLE-AES-CTR' all that long ago so there's likely still a good amount of content out there using the former. I could group the early exits together, but that would require separating the two METHOD validation blocks. I don't feel strongly either way.

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 = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we call this out specifically as widevine since it isn't standard hls?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the mpd parser calls it this as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean renaming contentProtection to widevine? We could, but that would require an additional change in VHS (and perhaps elsewhere), which already expects contentProtection. Calling it contentProtection maintains greater parity with mpd-parser.

'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'
Expand Down
11 changes: 11 additions & 0 deletions src/utils/decode.js
Original file line number Diff line number Diff line change
@@ -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;
}
111 changes: 111 additions & 0 deletions test/m3u8.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down