src/loader/level-key.ts
import {
changeEndianness,
convertDataUriToArrayBytes,
strToUtf8array,
} from '../utils/keysystem-util';
import { KeySystemFormats } from '../utils/mediakeys-helper';
import { mp4Box, mp4pssh, writeUint32 } from '../utils/mp4-tools';
import { logger } from '../utils/logger';
import { base64Decode } from '../utils/numeric-encoding-utils';
let keyUriToKeyIdMap: { [uri: string]: Uint8Array } = {};
export interface DecryptData {
uri: string;
method: string;
keyFormat: string;
keyFormatVersions: number[];
iv: Uint8Array | null;
key: Uint8Array | null;
keyId: Uint8Array | null;
pssh: Uint8Array | null;
encrypted: boolean;
isCommonEncryption: boolean;
}
export class LevelKey implements DecryptData {
public readonly uri: string;
public readonly method: string;
public readonly keyFormat: string;
public readonly keyFormatVersions: number[];
public readonly encrypted: boolean;
public readonly isCommonEncryption: boolean;
public iv: Uint8Array | null = null;
public key: Uint8Array | null = null;
public keyId: Uint8Array | null = null;
public pssh: Uint8Array | null = null;
static clearKeyUriToKeyIdMap() {
keyUriToKeyIdMap = {};
}
constructor(
method: string,
uri: string,
format: string,
formatversions: number[] = [1],
iv: Uint8Array | null = null
) {
this.method = method;
this.uri = uri;
this.keyFormat = format;
this.keyFormatVersions = formatversions;
this.iv = iv;
this.encrypted = method ? method !== 'NONE' : false;
this.isCommonEncryption = this.encrypted && method !== 'AES-128';
}
public isSupported(): boolean {
// If it's Segment encryption or No encryption, just select that key system
if (this.method) {
if (this.method === 'AES-128' || this.method === 'NONE') {
return true;
}
switch (this.keyFormat) {
case 'identity':
// Maintain support for clear SAMPLE-AES with MPEG-3 TS
return this.method === 'SAMPLE-AES';
case KeySystemFormats.FAIRPLAY:
case KeySystemFormats.WIDEVINE:
case KeySystemFormats.PLAYREADY:
case KeySystemFormats.CLEARKEY:
return (
[
'ISO-23001-7',
'SAMPLE-AES',
'SAMPLE-AES-CENC',
'SAMPLE-AES-CTR',
].indexOf(this.method) !== -1
);
}
}
return false;
}
public getDecryptData(sn: number | 'initSegment'): LevelKey | null {
if (!this.encrypted || !this.uri) {
return null;
}
if (this.method === 'AES-128' && this.uri && !this.iv) {
if (typeof sn !== 'number') {
// We are fetching decryption data for a initialization segment
// If the segment was encrypted with AES-128
// It must have an IV defined. We cannot substitute the Segment Number in.
if (this.method === 'AES-128' && !this.iv) {
logger.warn(
`missing IV for initialization segment with method="${this.method}" - compliance issue`
);
}
// Explicitly set sn to resulting value from implicit conversions 'initSegment' values for IV generation.
sn = 0;
}
const iv = createInitializationVector(sn);
const decryptdata = new LevelKey(
this.method,
this.uri,
'identity',
this.keyFormatVersions,
iv
);
return decryptdata;
}
// Initialize keyId if possible
const keyBytes = convertDataUriToArrayBytes(this.uri);
if (keyBytes) {
switch (this.keyFormat) {
case KeySystemFormats.WIDEVINE:
this.pssh = keyBytes;
// In case of widevine keyID is embedded in PSSH box. Read Key ID.
if (keyBytes.length >= 22) {
this.keyId = keyBytes.subarray(
keyBytes.length - 22,
keyBytes.length - 6
);
}
break;
case KeySystemFormats.FAIRPLAY: {
let keydata = keyBytes.subarray(0, 16);
if (keydata.length !== 16) {
const padded = new Uint8Array(16);
padded.set(keydata, 16 - keydata.length);
keydata = padded;
}
this.keyId = keydata;
break;
}
case KeySystemFormats.PLAYREADY: {
const PlayReadyKeySystemUUID = new Uint8Array([
0x9a, 0x04, 0xf0, 0x79, 0x98, 0x40, 0x42, 0x86, 0xab, 0x92, 0xe6,
0x5b, 0xe0, 0x88, 0x5f, 0x95,
]);
this.pssh = mp4pssh(PlayReadyKeySystemUUID, null, keyBytes);
const keyBytesUtf16 = new Uint16Array(
keyBytes.buffer,
keyBytes.byteOffset,
keyBytes.byteLength / 2
);
const keyByteStr = String.fromCharCode.apply(
null,
Array.from(keyBytesUtf16)
);
// Parse Playready WRMHeader XML
const xmlKeyBytes = keyByteStr.substring(
keyByteStr.indexOf('<'),
keyByteStr.length
);
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlKeyBytes, 'text/xml');
const keyData = xmlDoc.getElementsByTagName('KID')[0];
if (keyData) {
const keyId = keyData.childNodes[0]
? keyData.childNodes[0].nodeValue
: keyData.getAttribute('VALUE');
if (keyId) {
const keyIdArray = base64Decode(keyId).subarray(0, 16);
// KID value in PRO is a base64-encoded little endian GUID interpretation of UUID
// KID value in ‘tenc’ is a big endian UUID GUID interpretation of UUID
changeEndianness(keyIdArray);
this.keyId = keyIdArray;
}
}
break;
}
default: {
let keydata = keyBytes.subarray(0, 16);
if (keydata.length !== 16) {
const padded = new Uint8Array(16);
padded.set(keydata, 16 - keydata.length);
keydata = padded;
}
this.keyId = keydata;
break;
}
}
}
// Default behavior: assign a new keyId for each uri
if (!this.keyId || this.keyId.byteLength !== 16) {
let keyId = keyUriToKeyIdMap[this.uri];
if (!keyId) {
const val =
Object.keys(keyUriToKeyIdMap).length % Number.MAX_SAFE_INTEGER;
keyId = new Uint8Array(16);
const dv = new DataView(keyId.buffer, 12, 4); // Just set the last 4 bytes
dv.setUint32(0, val);
keyUriToKeyIdMap[this.uri] = keyId;
}
this.keyId = keyId;
}
if (this.keyFormat === KeySystemFormats.FAIRPLAY) {
this.pssh = getFairPlayV3Pssh(
this.keyId,
this.method,
this.keyFormatVersions
);
}
return this;
}
}
function createInitializationVector(segmentNumber: number): Uint8Array {
const uint8View = new Uint8Array(16);
for (let i = 12; i < 16; i++) {
uint8View[i] = (segmentNumber >> (8 * (15 - i))) & 0xff;
}
return uint8View;
}
function getFairPlayV3Pssh(
keyId: Uint8Array,
method: string,
keyFormatVersions: number[]
): Uint8Array {
enum SchemeFourCC {
CENC = 0x63656e63,
CBCS = 0x63626373,
}
const scheme =
method === 'ISO-23001-7' ? SchemeFourCC.CENC : SchemeFourCC.CBCS;
const FpsBoxTypes = {
fpsd: strToUtf8array('fpsd'), // Parent box containing all info
fpsi: strToUtf8array('fpsi'), // Common info
fpsk: strToUtf8array('fpsk'), // key request
fkri: strToUtf8array('fkri'), // key request info
fkvl: strToUtf8array('fkvl'), // version list
};
const makeFpsKeySystemInfoBox = (scheme: SchemeFourCC): Uint8Array => {
const schemeArray = new Uint8Array(4);
writeUint32(schemeArray, 0, scheme);
return mp4Box(FpsBoxTypes.fpsi, new Uint8Array([0, 0, 0, 0]), schemeArray);
};
const makeFpsKeyRequestBox = (
keyId: Uint8Array,
versionList: Array<number>
): Uint8Array => {
const args = [
FpsBoxTypes.fpsk,
mp4Box(FpsBoxTypes.fkri, new Uint8Array([0x00, 0x00, 0x00, 0x00]), keyId),
];
if (versionList.length) {
// List of integers
const versionListBuffer = new Uint8Array(4 * versionList.length);
let pos = 0;
for (const version of versionList) {
writeUint32(versionListBuffer, pos, version);
pos += 4;
}
args.push(mp4Box(FpsBoxTypes.fkvl, versionListBuffer));
}
const fpsk = mp4Box.apply(null, args as [ArrayLike<number>, Uint8Array]);
return fpsk;
};
const kFairPlayStreamingKeySystemUUID = new Uint8Array([
0x94, 0xce, 0x86, 0xfb, 0x07, 0xff, 0x4f, 0x43, 0xad, 0xb8, 0x93, 0xd2,
0xfa, 0x96, 0x8c, 0xa2,
]);
const data = mp4Box(
FpsBoxTypes.fpsd,
makeFpsKeySystemInfoBox(scheme),
makeFpsKeyRequestBox(keyId, keyFormatVersions)
);
return mp4pssh(kFairPlayStreamingKeySystemUUID, null, data);
}