Skip to content

Commit

Permalink
fix: Fix in-band key rotation on Xbox One (#4478)
Browse files Browse the repository at this point in the history
In-band key rotation, in which new keys are signaled by a change in the
pssh in the media segments, is not working on the Xbox one, but is
working for other browsers and devices. It appear that the Xbox does not
continue watching for pssh changes after the initial DRM session has
been setup.

The problem is fixed by parsing the pssh from media segments prior to
appending to the source buffer. This behavior is controlled with the new
boolean config field drm.parseInbandPsshEnabled.

Fixes #4401
  • Loading branch information
caridley authored and JulianDomingo committed Oct 6, 2022
1 parent 72a119d commit 5a8f09c
Show file tree
Hide file tree
Showing 13 changed files with 177 additions and 5 deletions.
1 change: 1 addition & 0 deletions demo/common/message_ids.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ shakaDemo.MessageIds = {
NUMBER_NONZERO_INTEGER_WARNING: 'DEMO_NUMBER_NONZERO_INTEGER_WARNING',
NUMBER_OF_PARALLEL_DOWNLOADS: 'DEMO_NUMBER_OF_PARALLEL_DOWNLOADS',
OFFLINE_SECTION_HEADER: 'DEMO_OFFLINE_SECTION_HEADER',
PARSE_INBAND_PSSH_ENABLED: 'DEMO_PARSE_INBAND_PSSH_ENABLED',
PREFER_FORCED_SUBS: 'DEMO_PREFER_FORCED_SUBS',
PREFER_NATIVE_HLS: 'DEMO_PREFER_NATIVE_HLS',
REBUFFERING_GOAL: 'DEMO_REBUFFERING_GOAL',
Expand Down
4 changes: 3 additions & 1 deletion demo/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ shakaDemo.Config = class {
'drm.updateExpirationTime',
/* canBeDecimal= */ true,
/* canBeZero= */ false,
/* canBeUnset= */ true);
/* canBeUnset= */ true)
.addBoolInput_(MessageIds.PARSE_INBAND_PSSH_ENABLED,
'drm.parseInbandPsshEnabled');
const advanced = shakaDemoMain.getConfiguration().drm.advanced || {};
const addDRMAdvancedField = (name, valueName, suggestions) => {
// All advanced fields of a given type are set at once.
Expand Down
1 change: 1 addition & 0 deletions demo/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@
"DEMO_NUMBER_NONZERO_INTEGER_WARNING": "Must be a positive, nonzero integer.",
"DEMO_NUMBER_OF_PARALLEL_DOWNLOADS": "Number of parallel downloads",
"DEMO_OBSERVE_QUALITY_CHANGES": "Observe media quality changes",
"DEMO_PARSE_INBAND_PSSH_ENABLED": "Parse inband 'pssh' from media segments",
"DEMO_OFFLINE": "Downloadable",
"DEMO_OFFLINE_SEARCH": "Filters for assets that can be stored offline.",
"DEMO_OFFLINE_SECTION_HEADER": "Offline",
Expand Down
4 changes: 4 additions & 0 deletions demo/locales/source.json
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,10 @@
"description": "The label on a field that allows users to provide a full mime type for a custom asset.",
"message": "Full Mime Type for Playing Media Playlists Directly"
},
"DEMO_PARSE_INBAND_PSSH_ENABLED": {
"description": "The name of a configuration value.",
"message": "Parse inband 'pssh' from media segments"
},
"DEMO_PLAY": {
"description": "A button to play the attached asset.",
"message": "Play"
Expand Down
8 changes: 7 additions & 1 deletion externs/shaka/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -632,7 +632,8 @@ shaka.extern.AdvancedDrmConfiguration;
* logLicenseExchange: boolean,
* updateExpirationTime: number,
* preferredKeySystems: !Array.<string>,
* keySystemsMapping: !Object.<string, string>
* keySystemsMapping: !Object.<string, string>,
* parseInbandPsshEnabled: boolean
* }}
*
* @property {shaka.extern.RetryParameters} retryParameters
Expand Down Expand Up @@ -675,6 +676,11 @@ shaka.extern.AdvancedDrmConfiguration;
* Specifies the priorties of available DRM key systems.
* @property {Object.<string, string>} keySystemsMapping
* A map of key system name to key system name.
* @property {boolean} parseInbandPsshEnabled
* <i>Defaults to true on Xbox One, and false for all other browsers.</i><br>
* When true parse DRM init data from pssh boxes in media and init segments
* and ignore 'encrypted' events.
* This is required when using in-band key rotation on Xbox One.
*
* @exportDoc
*/
Expand Down
53 changes: 52 additions & 1 deletion lib/media/drm_engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.IDestroyable');
goog.require('shaka.util.Iterables');
goog.require('shaka.util.Lazy');
goog.require('shaka.util.ManifestParserUtils');
goog.require('shaka.util.MapUtils');
goog.require('shaka.util.MimeUtils');
goog.require('shaka.util.Platform');
goog.require('shaka.util.Pssh');
goog.require('shaka.util.PublicPromise');
goog.require('shaka.util.StreamUtils');
goog.require('shaka.util.StringUtils');
Expand Down Expand Up @@ -497,7 +499,11 @@ shaka.media.DrmEngine = class {

// Explicit init data for any one stream or an offline session is
// sufficient to suppress 'encrypted' events for all streams.
if (!manifestInitData && !this.offlineSessionIds_.length) {
// Also suppress 'encrypted' events when parsing in-band ppsh
// from media segments because that serves the same purpose as the
// 'encrypted' events.
if (!manifestInitData && !this.offlineSessionIds_.length &&
!this.config_.parseInbandPsshEnabled) {
this.eventManager_.listen(
this.video_, 'encrypted', (e) => this.onEncryptedEvent_(e));
}
Expand Down Expand Up @@ -668,6 +674,14 @@ shaka.media.DrmEngine = class {
}
}

// If there are pre-existing sessions that have all been loaded
// then reset the allSessionsLoaded_ promise, which can now be
// used to wait for new sesssions to be loaded
if (this.activeSessions_.size > 0 && this.areAllSessionsLoaded_()) {
this.allSessionsLoaded_.resolve();
this.allSessionsLoaded_ = new shaka.util.PublicPromise();
this.allSessionsLoaded_.catch(() => {});
}
this.createSession(initDataType, initData,
this.currentDrmInfo_.sessionType);
}
Expand Down Expand Up @@ -2329,6 +2343,43 @@ shaka.media.DrmEngine = class {
}
}
}

/**
* Parse pssh from a media segment and announce new initData
*
* @param {shaka.util.ManifestParserUtils.ContentType} contentType
* @param {!BufferSource} mediaSegment
* @return {!Promise<void>}
*/
parseInbandPssh(contentType, mediaSegment) {
if (!this.config_.parseInbandPsshEnabled) {
return Promise.resolve();
}

const ContentType = shaka.util.ManifestParserUtils.ContentType;
if (![ContentType.AUDIO, ContentType.VIDEO].includes(contentType)) {
return Promise.resolve();
}

const pssh = new shaka.util.Pssh(
shaka.util.BufferUtils.toUint8(mediaSegment));

let totalLength = 0;
for (const data of pssh.data) {
totalLength += data.length;
}
if (totalLength == 0) {
return Promise.resolve();
}
const combinedData = new Uint8Array(totalLength);
let pos = 0;
for (const data of pssh.data) {
combinedData.set(data, pos);
pos += data.length;
}
this.newInitData('cenc', combinedData);
return this.allSessionsLoaded_;
}
};


Expand Down
10 changes: 9 additions & 1 deletion lib/media/streaming_engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -1602,6 +1602,8 @@ shaka.media.StreamingEngine = class {
shaka.log.v1(logPrefix, 'appending init segment');
const hasClosedCaptions = mediaState.stream.closedCaptions &&
mediaState.stream.closedCaptions.size > 0;
await this.playerInterface_.beforeAppendSegment(
mediaState.type, initSegment);
await this.playerInterface_.mediaSourceEngine.appendBuffer(
mediaState.type, initSegment, /* reference= */ null,
hasClosedCaptions);
Expand Down Expand Up @@ -1659,6 +1661,7 @@ shaka.media.StreamingEngine = class {
const adaptation = mediaState.adaptation;
mediaState.adaptation = false;

await this.playerInterface_.beforeAppendSegment(mediaState.type, segment);
await this.playerInterface_.mediaSourceEngine.appendBuffer(
mediaState.type,
segment,
Expand Down Expand Up @@ -2042,7 +2045,9 @@ shaka.media.StreamingEngine = class {
* onEvent: function(!Event),
* onManifestUpdate: function(),
* onSegmentAppended: function(),
* onInitSegmentAppended: function(!number,!shaka.media.InitSegmentReference)
* onInitSegmentAppended: function(!number,!shaka.media.InitSegmentReference),
* beforeAppendSegment: function(
* shaka.util.ManifestParserUtils.ContentType,!BufferSource):Promise
* }}
*
* @property {function():number} getPresentationTime
Expand Down Expand Up @@ -2070,6 +2075,9 @@ shaka.media.StreamingEngine = class {
* @property
* {function(!number, !shaka.media.InitSegmentReference)} onInitSegmentAppended
* Called when an init segment is appended to a MediaSource.
* @property {!function(shaka.util.ManifestParserUtils.ContentType,
* !BufferSource):Promise} beforeAppendSegment
* A function called just before appending to the source buffer.
*/
shaka.media.StreamingEngine.PlayerInterface;

Expand Down
3 changes: 3 additions & 0 deletions lib/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -2950,6 +2950,9 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
this.qualityObserver_.addMediaQualityChange(mediaQuality, position);
}
},
beforeAppendSegment: (contentType, segment) => {
return this.drmEngine_.parseInbandPssh(contentType, segment);
},
};

return new shaka.media.StreamingEngine(this.manifest_, playerInterface);
Expand Down
4 changes: 4 additions & 0 deletions lib/util/player_configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ shaka.util.PlayerConfiguration = class {
updateExpirationTime: 1,
preferredKeySystems: [],
keySystemsMapping: {},
// The Xbox One browser does not detect DRM key changes signalled by a
// change in the PSSH in media segments. We need to parse PSSH from media
// segments to detect key changes.
parseInbandPsshEnabled: shaka.util.Platform.isXboxOne(),
};

const manifest = {
Expand Down
3 changes: 2 additions & 1 deletion lib/util/pssh.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,12 @@ shaka.util.Pssh = class {

new shaka.util.Mp4Parser()
.box('moov', shaka.util.Mp4Parser.children)
.box('moof', shaka.util.Mp4Parser.children)
.fullBox('pssh', (box) => this.parsePsshBox_(box))
.parse(psshBox);

if (this.data.length == 0) {
shaka.log.warning('No pssh box found!');
shaka.log.v2('No pssh box found!');
}
}

Expand Down
61 changes: 61 additions & 0 deletions test/media/drm_engine_unit.js
Original file line number Diff line number Diff line change
Expand Up @@ -1097,6 +1097,14 @@ describe('DrmEngine', () => {
'encrypted', jasmine.any(Function), jasmine.anything());
});

it('is not listened for if parseInbandPsshEnabled is true', async () => {
config.parseInbandPsshEnabled = true;
drmEngine.configure(config);
await initAndAttach();
expect(mockVideo.addEventListener).not.toHaveBeenCalledWith(
'encrypted', jasmine.any(Function), jasmine.anything());
});

it('triggers the creation of a session', async () => {
await initAndAttach();
const initData1 = new Uint8Array(1);
Expand Down Expand Up @@ -2355,6 +2363,59 @@ describe('DrmEngine', () => {
}
});

describe('parseInbandPssh', () => {
const WIDEVINE_PSSH =
'00000028' + // atom size
'70737368' + // atom type='pssh'
'00000000' + // v0, flags=0
'edef8ba979d64acea3c827dcd51d21ed' + // system id (Widevine)
'00000008' + // data size
'0102030405060708'; // data

const PLAYREADY_PSSH =
'00000028' + // atom size
'70737368' + // atom type 'pssh'
'00000000' + // v0, flags=0
'9a04f07998404286ab92e65be0885f95' + // system id (PlayReady)
'00000008' + // data size
'0102030405060708'; // data

const SEGMENT =
'00000058' + // atom size = 28x + 28x + 8x
'6d6f6f66' + // atom type 'moof'
WIDEVINE_PSSH +
PLAYREADY_PSSH;

const binarySegment = shaka.util.Uint8ArrayUtils.fromHex(SEGMENT);

it('calls newInitData when enabled', async () => {
config.parseInbandPsshEnabled = true;
await initAndAttach();

/** @type {!jasmine.Spy} */
const newInitDataSpy = jasmine.createSpy('newInitData');
drmEngine.newInitData = shaka.test.Util.spyFunc(newInitDataSpy);

await drmEngine.parseInbandPssh(
shaka.util.ManifestParserUtils.ContentType.VIDEO, binarySegment);
const expectedInitData = shaka.util.Uint8ArrayUtils.fromHex(
WIDEVINE_PSSH + PLAYREADY_PSSH);
expect(newInitDataSpy).toHaveBeenCalledWith('cenc', expectedInitData);
});

it('does not call newInitData when disabled', async () => {
config.parseInbandPsshEnabled = false;
await initAndAttach();

/** @type {!jasmine.Spy} */
const newInitDataSpy = jasmine.createSpy('newInitData');
drmEngine.newInitData = shaka.test.Util.spyFunc(newInitDataSpy);
await drmEngine.parseInbandPssh(
shaka.util.ManifestParserUtils.ContentType.VIDEO, binarySegment);
expect(newInitDataSpy).not.toHaveBeenCalled();
});
});

async function initAndAttach() {
const variants = manifest.variants;
await drmEngine.initForPlayback(variants, manifest.offlineSessionIds);
Expand Down
1 change: 1 addition & 0 deletions test/media/streaming_engine_integration.js
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ describe('StreamingEngine', () => {
onManifestUpdate: () => {},
onSegmentAppended: () => playhead.notifyOfBufferingChange(),
onInitSegmentAppended: () => {},
beforeAppendSegment: () => Promise.resolve(),
};
streamingEngine = new shaka.media.StreamingEngine(
/** @type {shaka.extern.Manifest} */(manifest), playerInterface);
Expand Down
29 changes: 29 additions & 0 deletions test/media/streaming_engine_unit.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ describe('StreamingEngine', () => {
let getBandwidthEstimate;
/** @type {!shaka.media.StreamingEngine} */
let streamingEngine;
/** @type {!jasmine.Spy} */
let beforeAppendSegment;

/** @type {function(function(), number)} */
let realSetTimeout;
Expand Down Expand Up @@ -418,9 +420,14 @@ describe('StreamingEngine', () => {
onEvent = jasmine.createSpy('onEvent');
onManifestUpdate = jasmine.createSpy('onManifestUpdate');
onSegmentAppended = jasmine.createSpy('onSegmentAppended');
beforeAppendSegment = jasmine.createSpy('beforeAppendSegment');
getBandwidthEstimate = jasmine.createSpy('getBandwidthEstimate');
getBandwidthEstimate.and.returnValue(1e3);

beforeAppendSegment.and.callFake((segment) => {
return Promise.resolve();
});

if (!config) {
config = shaka.util.PlayerConfiguration.createDefault().streaming;
config.rebufferingGoal = 2;
Expand All @@ -442,6 +449,7 @@ describe('StreamingEngine', () => {
onManifestUpdate: Util.spyFunc(onManifestUpdate),
onSegmentAppended: Util.spyFunc(onSegmentAppended),
onInitSegmentAppended: () => {},
beforeAppendSegment: Util.spyFunc(beforeAppendSegment),
};
streamingEngine = new shaka.media.StreamingEngine(
/** @type {shaka.extern.Manifest} */(manifest), playerInterface);
Expand Down Expand Up @@ -3472,6 +3480,27 @@ describe('StreamingEngine', () => {
});
});

describe('beforeAppendSegment', () => {
it('is called before appending media segment', async () => {
setupVod();
mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData);
createStreamingEngine();
beforeAppendSegment.and.callFake((segment) => {
return shaka.test.Util.shortDelay();
});
streamingEngine.switchVariant(variant);
streamingEngine.switchTextStream(textStream);
await streamingEngine.start();
// Simulate time passing.
playing = true;
await Util.fakeEventLoop(10);
expect(beforeAppendSegment).toHaveBeenCalledWith(
ContentType.AUDIO, segmentData[ContentType.AUDIO].initSegments[0]);
expect(beforeAppendSegment).toHaveBeenCalledWith(
ContentType.AUDIO, segmentData[ContentType.AUDIO].segments[0]);
});
});

/**
* Slides the segment availability window forward by 1 second.
*/
Expand Down

0 comments on commit 5a8f09c

Please sign in to comment.