Skip to content

Commit

Permalink
feat(HLS): Add automatically keyId-key for identity format (#6308)
Browse files Browse the repository at this point in the history
  • Loading branch information
avelad committed Feb 29, 2024
1 parent 5badb6a commit d251649
Show file tree
Hide file tree
Showing 12 changed files with 327 additions and 60 deletions.
2 changes: 2 additions & 0 deletions README.md
Expand Up @@ -132,6 +132,8 @@ HLS features supported:
- CEA-608/708 captions
- Encrypted content with PlayReady and Widevine
- Encrypted content with FairPlay (Safari on macOS and iOS 9+ only)
- AES-128, AES-256 and AES-256-CTR support on browsers with Web Crypto API support
- SAMPLE-AES and SAMPLE-AES-CTR (identity) support on browsers with ClearKey support
- Key rotation
- Raw AAC, MP3, AC-3 and EC-3 (without an MP4 container)
- I-frame-only playlists with mjpg codec for thumbnails
Expand Down
1 change: 1 addition & 0 deletions karma.conf.js
Expand Up @@ -241,6 +241,7 @@ module.exports = (config) => {
{pattern: 'test/test/assets/hls-raw-ac3/*', included: false},
{pattern: 'test/test/assets/hls-raw-ec3/*', included: false},
{pattern: 'test/test/assets/hls-raw-mp3/*', included: false},
{pattern: 'test/test/assets/hls-sample-aes/*', included: false},
{pattern: 'test/test/assets/hls-ts-aac/*', included: false},
{pattern: 'test/test/assets/hls-ts-ac3/*', included: false},
{pattern: 'test/test/assets/hls-ts-ec3/*', included: false},
Expand Down
200 changes: 145 additions & 55 deletions lib/hls/hls_parser.js
Expand Up @@ -219,6 +219,12 @@ shaka.hls.HlsParser = class {
/** @private {Map.<string, !shaka.extern.aesKey>} */
this.aesKeyInfoMap_ = new Map();

/** @private {Map.<string, string>} */
this.identityKeyMap_ = new Map();

/** @private {Map.<!shaka.media.InitSegmentReference, ?string>} */
this.identityKidMap_ = new Map();

/** @private {boolean} */
this.lowLatencyMode_ = false;

Expand Down Expand Up @@ -300,6 +306,8 @@ shaka.hls.HlsParser = class {
this.globalVariables_.clear();
this.mapTagToInitSegmentRefMap_.clear();
this.aesKeyInfoMap_.clear();
this.identityKeyMap_.clear();
this.identityKidMap_.clear();

if (this.contentSteeringManager_) {
this.contentSteeringManager_.destroy();
Expand Down Expand Up @@ -437,7 +445,8 @@ shaka.hls.HlsParser = class {

const mediaSequenceToStartTime =
this.getMediaSequenceToStartTimeFor_(streamInfo);
const {keyIds, drmInfos} = this.parseDrmInfo_(playlist, stream.mimeType);
const {keyIds, drmInfos} = await this.parseDrmInfo_(
playlist, stream.mimeType, streamInfo.getUris, mediaVariables);

const keysAreEqual =
(a, b) => a.size === b.size && [...a].every((value) => b.has(value));
Expand Down Expand Up @@ -719,12 +728,12 @@ shaka.hls.HlsParser = class {
/** @type {!Array.<!shaka.extern.Stream>} */
let imageStreams = [];

const getUris = () => {
return [uri];
};

// Parsing a media playlist results in a single-variant stream.
if (playlist.type == shaka.hls.PlaylistType.MEDIA) {
const getUris = () => {
return [uri];
};

// Get necessary info for this stream. These are things we would normally
// find from the master playlist (e.g. from values on EXT-X-MEDIA tags).
const basicInfo =
Expand Down Expand Up @@ -806,8 +815,9 @@ shaka.hls.HlsParser = class {
this.parseCodecs_(variantTags);

this.parseClosedCaptions_(mediaTags);
variants =
this.createVariantsForTags_(variantTags, sessionKeyTags, mediaTags);
variants = await this.createVariantsForTags_(
variantTags, sessionKeyTags, mediaTags, getUris,
this.globalVariables_);
textStreams = this.parseTexts_(mediaTags);
imageStreams = await this.parseImages_(imageTags, iFrameTags);
}
Expand Down Expand Up @@ -1314,37 +1324,48 @@ shaka.hls.HlsParser = class {
* from the playlist.
* @param {!Array.<!shaka.hls.Tag>} mediaTags EXT-X-MEDIA tags from the
* playlist.
* @return {!Array.<!shaka.extern.Variant>}
* @param {function():!Array.<string>} getUris
* @param {?Map.<string, string>=} variables
* @return {!Promise.<!Array.<!shaka.extern.Variant>>}
* @private
*/
createVariantsForTags_(tags, sessionKeyTags, mediaTags) {
async createVariantsForTags_(tags, sessionKeyTags, mediaTags, getUris,
variables) {
// EXT-X-SESSION-KEY processing
const drmInfos = [];
const keyIds = new Set();
if (sessionKeyTags.length > 0) {
for (const drmTag of sessionKeyTags) {
const method = drmTag.getRequiredAttrValue('METHOD');
if (method != 'NONE' && !this.isAesMethod_(method)) {
// According to the HLS spec, KEYFORMAT is optional and implicitly
// defaults to "identity".
// https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-11#section-4.4.4.4
const keyFormat =
drmTag.getAttributeValue('KEYFORMAT') || 'identity';
// According to the HLS spec, KEYFORMAT is optional and implicitly
// defaults to "identity".
// https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-11#section-4.4.4.4
const keyFormat =
drmTag.getAttributeValue('KEYFORMAT') || 'identity';
let drmInfo = null;
if (method == 'NONE' || this.isAesMethod_(method)) {
continue;
} else if (keyFormat == 'identity') {
// eslint-disable-next-line no-await-in-loop
drmInfo = await this.identityDrmParser_(
drmTag, /* mimeType= */ '', getUris,
/* initSegmentRef= */ null, variables);
} else {
const drmParser =
shaka.hls.HlsParser.KEYFORMATS_TO_DRM_PARSERS_[keyFormat];

const drmInfo = drmParser ?
drmInfo = drmParser ?
drmParser(drmTag, /* mimeType= */ '') : null;
if (drmInfo) {
if (drmInfo.keyIds) {
for (const keyId of drmInfo.keyIds) {
keyIds.add(keyId);
}
}
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);
}
}
}
Expand Down Expand Up @@ -2506,7 +2527,7 @@ shaka.hls.HlsParser = class {
}

const {drmInfos, keyIds, encrypted, aesEncrypted} =
this.parseDrmInfo_(playlist, mimeType);
await this.parseDrmInfo_(playlist, mimeType, getUris, mediaVariables);

if (encrypted && !drmInfos.length && !aesEncrypted) {
throw new shaka.util.Error(
Expand Down Expand Up @@ -2721,22 +2742,31 @@ shaka.hls.HlsParser = class {
/**
* @param {!shaka.hls.Playlist} playlist
* @param {string} mimeType
* @return {{
* @param {function():!Array.<string>} getUris
* @param {?Map.<string, string>=} variables
* @return {Promise.<{
* drmInfos: !Array.<shaka.extern.DrmInfo>,
* keyIds: !Set.<string>,
* encrypted: boolean,
* aesEncrypted: boolean
* }}
* }>}
* @private
*/
parseDrmInfo_(playlist, mimeType) {
/** @type {!Array.<!shaka.hls.Tag>} */
const drmTags = [];
async parseDrmInfo_(playlist, mimeType, getUris, variables) {
/** @type {!Map<!shaka.hls.Tag, ?shaka.media.InitSegmentReference>} */
const drmTagsMap = new Map();
if (playlist.segments) {
for (const segment of playlist.segments) {
const segmentKeyTags = shaka.hls.Utils.filterTagsByName(segment.tags,
'EXT-X-KEY');
drmTags.push(...segmentKeyTags);
let initSegmentRef = null;
if (segmentKeyTags.length) {
initSegmentRef = this.getInitSegmentReference_(playlist,
segment.tags, getUris, variables);
for (const segmentKeyTag of segmentKeyTags) {
drmTagsMap.set(segmentKeyTag, initSegmentRef);
}
}
}
}

Expand All @@ -2747,34 +2777,42 @@ shaka.hls.HlsParser = class {
const drmInfos = [];
const keyIds = new Set();

for (const drmTag of drmTags) {
for (const [key, value] of drmTagsMap) {
const drmTag = /** @type {!shaka.hls.Tag} */ (key);
const initSegmentRef =
/** @type {?shaka.media.InitSegmentReference} */ (value);
const method = drmTag.getRequiredAttrValue('METHOD');
if (method != 'NONE') {
encrypted = true;

// According to the HLS spec, KEYFORMAT is optional and implicitly
// defaults to "identity".
// https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-11#section-4.4.4.4
const keyFormat =
drmTag.getAttributeValue('KEYFORMAT') || 'identity';
let drmInfo = null;

if (this.isAesMethod_(method)) {
// These keys are handled separately.
aesEncrypted = true;
} else if (keyFormat == 'identity') {
// eslint-disable-next-line no-await-in-loop
drmInfo = await this.identityDrmParser_(
drmTag, mimeType, getUris, initSegmentRef, variables);
} else {
// According to the HLS spec, KEYFORMAT is optional and implicitly
// defaults to "identity".
// https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-11#section-4.4.4.4
const keyFormat =
drmTag.getAttributeValue('KEYFORMAT') || 'identity';
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);
}
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);
}
}
}
Expand Down Expand Up @@ -4152,10 +4190,15 @@ shaka.hls.HlsParser = class {
* See: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-11#section-5.1
*
* @param {!shaka.hls.Tag} drmTag
* @return {?shaka.extern.DrmInfo}
* @param {string} mimeType
* @param {function():!Array.<string>} getUris
* @param {?shaka.media.InitSegmentReference} initSegmentRef
* @param {?Map.<string, string>=} variables
* @return {!Promise.<?shaka.extern.DrmInfo>}
* @private
*/
static identityDrmParser_(drmTag, mimeType) {
async identityDrmParser_(drmTag, mimeType, getUris, initSegmentRef,
variables) {
if (mimeType == 'video/mp2t') {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
Expand All @@ -4179,15 +4222,64 @@ shaka.hls.HlsParser = class {
return null;
}

const keyUris = shaka.hls.Utils.constructSegmentUris(
getUris(), drmTag.getRequiredAttrValue('URI'), variables);

let key;
const keyMapKey = keyUris.sort().join('');
if (this.identityKeyMap_.has(keyMapKey)) {
key = this.identityKeyMap_.get(keyMapKey);
} else {
const requestType = shaka.net.NetworkingEngine.RequestType.KEY;
const request = shaka.net.NetworkingEngine.makeRequest(
keyUris, this.config_.retryParameters);
const keyResponse =
await this.makeNetworkRequest_(request, requestType);

key = shaka.util.Uint8ArrayUtils.toHex(keyResponse.data);
this.identityKeyMap_.set(keyMapKey, key);
}

// NOTE: The ClearKey CDM requires a key-id to key mapping. HLS doesn't
// provide a key ID anywhere. So although we could use the 'URI' attribute
// to fetch the actual 16-byte key, without a key ID, we can't provide this
// automatically to the ClearKey CDM. Instead, the application will have
// to use player.configure('drm.clearKeys', { ... }) to provide the key IDs
// automatically to the ClearKey CDM. By default we assume that keyId is 0,
// but we will try to get key ID from Init Segment.
// If the application want override this behavior will have to use
// player.configure('drm.clearKeys', { ... }) to provide the key IDs
// and keys or player.configure('drm.servers.org\.w3\.clearkey', ...) to
// provide a ClearKey license server URI.
return shaka.util.ManifestParserUtils.createDrmInfo(
'org.w3.clearkey', /* initDatas= */ null);
let keyId = '00000000000000000000000000000000';

if (initSegmentRef) {
let defaultKID;
if (this.identityKidMap_.has(initSegmentRef)) {
defaultKID = this.identityKidMap_.get(initSegmentRef);
} else {
const initSegmentRequest = shaka.util.Networking.createSegmentRequest(
initSegmentRef.getUris(),
initSegmentRef.getStartByte(),
initSegmentRef.getEndByte(),
this.config_.retryParameters);
const requestType = shaka.net.NetworkingEngine.RequestType.SEGMENT;
const initType =
shaka.net.NetworkingEngine.AdvancedRequestType.INIT_SEGMENT;
const initResponse = await this.makeNetworkRequest_(
initSegmentRequest, requestType, {type: initType});

defaultKID = shaka.media.SegmentUtils.getDefaultKID(
initResponse.data);
this.identityKidMap_.set(initSegmentRef, defaultKID);
}
if (defaultKID) {
keyId = defaultKID;
}
}

const clearkeys = new Map();
clearkeys.set(keyId, key);

return shaka.util.ManifestParserUtils.createDrmInfoFromClearKeys(clearkeys);
}
};

Expand Down Expand Up @@ -4368,8 +4460,6 @@ shaka.hls.HlsParser.KEYFORMATS_TO_DRM_PARSERS_ = {
shaka.hls.HlsParser.widevineDrmParser_,
'com.microsoft.playready':
shaka.hls.HlsParser.playreadyDrmParser_,
'identity':
shaka.hls.HlsParser.identityDrmParser_,
};


Expand Down
28 changes: 28 additions & 0 deletions lib/media/segment_utils.js
Expand Up @@ -457,6 +457,34 @@ shaka.media.SegmentUtils = class {
}
return codecs.filter((codec) => codec != dolbyVision);
}

/**
* @param {!BufferSource} data
* @return {?string}
*/
static getDefaultKID(data) {
const Mp4Parser = shaka.util.Mp4Parser;

let defaultKID = null;
new Mp4Parser()
.box('moov', Mp4Parser.children)
.box('trak', Mp4Parser.children)
.box('mdia', Mp4Parser.children)
.box('minf', Mp4Parser.children)
.box('stbl', Mp4Parser.children)
.fullBox('stsd', Mp4Parser.sampleDescription)
.box('encv', Mp4Parser.visualSampleEntry)
.box('enca', Mp4Parser.audioSampleEntry)
.box('sinf', Mp4Parser.children)
.box('schi', Mp4Parser.children)
.fullBox('tenc', (box) => {
const parsedTENCBox = shaka.util.Mp4BoxParsers.parseTENC(box.reader);
defaultKID = parsedTENCBox.defaultKID;
})

.parse(data, /* partialOkay= */ true);
return defaultKID;
}
};


Expand Down

0 comments on commit d251649

Please sign in to comment.