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(HLS): Add automatically keyId-key for identity format #6308

Merged
merged 15 commits into from Feb 29, 2024
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,
avelad marked this conversation as resolved.
Show resolved Hide resolved
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
avelad marked this conversation as resolved.
Show resolved Hide resolved
// 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