mirror of
https://github.com/anidl/multi-downloader-nx.git
synced 2026-01-11 20:10:20 +00:00
added playready support for crunchyroll
This commit is contained in:
parent
64c927c761
commit
fef39af547
21 changed files with 9927 additions and 6381 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -42,4 +42,6 @@ crunchyendpoints
|
||||||
/tmp/*.*
|
/tmp/*.*
|
||||||
bin
|
bin
|
||||||
widevine/*
|
widevine/*
|
||||||
!widevine/.gitkeep
|
!widevine/.gitkeep
|
||||||
|
playready/*
|
||||||
|
!playready/.gitkeep
|
||||||
14
ao.ts
14
ao.ts
|
|
@ -15,7 +15,7 @@ import * as yamlCfg from './modules/module.cfg-loader';
|
||||||
import * as yargs from './modules/module.app-args';
|
import * as yargs from './modules/module.app-args';
|
||||||
import * as reqModule from './modules/module.fetch';
|
import * as reqModule from './modules/module.fetch';
|
||||||
import Merger, { Font, MergerInput, SubtitleInput } from './modules/module.merger';
|
import Merger, { Font, MergerInput, SubtitleInput } from './modules/module.merger';
|
||||||
import getKeys, { canDecrypt } from './modules/widevine';
|
import { canDecrypt, getKeysWVD, cdm } from './modules/cdm';
|
||||||
import streamdl, { M3U8Json } from './modules/hls-download';
|
import streamdl, { M3U8Json } from './modules/hls-download';
|
||||||
import { exec } from './modules/sei-helper-fixes';
|
import { exec } from './modules/sei-helper-fixes';
|
||||||
import { console } from './modules/log';
|
import { console } from './modules/log';
|
||||||
|
|
@ -479,6 +479,10 @@ export default class AnimeOnegai implements ServiceClass {
|
||||||
console.warn('Decryption not enabled!');
|
console.warn('Decryption not enabled!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (canDecrypt && cdm === 'playready') {
|
||||||
|
console.warn("AO doesn't support Playready CDM!");
|
||||||
|
}
|
||||||
|
|
||||||
const lang = langsData.languages.find(a=>a.ao_locale == media.lang) as langsData.LanguageItem;
|
const lang = langsData.languages.find(a=>a.ao_locale == media.lang) as langsData.LanguageItem;
|
||||||
if (!lang) {
|
if (!lang) {
|
||||||
console.error(`Unable to find language for code ${media.lang}`);
|
console.error(`Unable to find language for code ${media.lang}`);
|
||||||
|
|
@ -597,7 +601,7 @@ export default class AnimeOnegai implements ServiceClass {
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const videoDownload = await new streamdl({
|
const videoDownload = await new streamdl({
|
||||||
output: chosenVideoSegments.pssh ? `${tempTsFile}.video.enc.mp4` : `${tsFile}.video.mp4`,
|
output: chosenVideoSegments.pssh_wvd ? `${tempTsFile}.video.enc.mp4` : `${tsFile}.video.mp4`,
|
||||||
timeout: options.timeout,
|
timeout: options.timeout,
|
||||||
m3u8json: videoJson,
|
m3u8json: videoJson,
|
||||||
// baseurl: chunkPlaylist.baseUrl,
|
// baseurl: chunkPlaylist.baseUrl,
|
||||||
|
|
@ -645,7 +649,7 @@ export default class AnimeOnegai implements ServiceClass {
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const audioDownload = await new streamdl({
|
const audioDownload = await new streamdl({
|
||||||
output: chosenAudioSegments.pssh ? `${tempTsFile}.audio.enc.mp4` : `${tsFile}.audio.mp4`,
|
output: chosenAudioSegments.pssh_wvd ? `${tempTsFile}.audio.enc.mp4` : `${tsFile}.audio.mp4`,
|
||||||
timeout: options.timeout,
|
timeout: options.timeout,
|
||||||
m3u8json: audioJson,
|
m3u8json: audioJson,
|
||||||
// baseurl: chunkPlaylist.baseUrl,
|
// baseurl: chunkPlaylist.baseUrl,
|
||||||
|
|
@ -677,9 +681,9 @@ export default class AnimeOnegai implements ServiceClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
//Handle Decryption if needed
|
//Handle Decryption if needed
|
||||||
if ((chosenVideoSegments.pssh || chosenAudioSegments.pssh) && (videoDownloaded || audioDownloaded)) {
|
if ((chosenVideoSegments.pssh_wvd || chosenAudioSegments.pssh_wvd) && (videoDownloaded || audioDownloaded)) {
|
||||||
console.info('Decryption Needed, attempting to decrypt');
|
console.info('Decryption Needed, attempting to decrypt');
|
||||||
const encryptionKeys = await getKeys(chosenVideoSegments.pssh, streamData.widevine_proxy, {});
|
const encryptionKeys = await getKeysWVD(chosenVideoSegments.pssh_wvd, streamData.widevine_proxy, {});
|
||||||
if (encryptionKeys.length == 0) {
|
if (encryptionKeys.length == 0) {
|
||||||
console.error('Failed to get encryption keys');
|
console.error('Failed to get encryption keys');
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
||||||
33
crunchy.ts
33
crunchy.ts
|
|
@ -18,7 +18,7 @@ import * as langsData from './modules/module.langsData';
|
||||||
import * as yamlCfg from './modules/module.cfg-loader';
|
import * as yamlCfg from './modules/module.cfg-loader';
|
||||||
import * as yargs from './modules/module.app-args';
|
import * as yargs from './modules/module.app-args';
|
||||||
import Merger, { Font, MergerInput, SubtitleInput } from './modules/module.merger';
|
import Merger, { Font, MergerInput, SubtitleInput } from './modules/module.merger';
|
||||||
import getKeys, { canDecrypt } from './modules/widevine';
|
import { canDecrypt, getKeysPRD, getKeysWVD, cdm } from './modules/cdm';
|
||||||
//import vttConvert from './modules/module.vttconvert';
|
//import vttConvert from './modules/module.vttconvert';
|
||||||
|
|
||||||
// args
|
// args
|
||||||
|
|
@ -1652,7 +1652,7 @@ export default class Crunchy implements ServiceClass {
|
||||||
segments: chosenVideoSegments.segments
|
segments: chosenVideoSegments.segments
|
||||||
};
|
};
|
||||||
const videoDownload = await new streamdl({
|
const videoDownload = await new streamdl({
|
||||||
output: chosenVideoSegments.pssh ? `${tempTsFile}.video.enc.m4s` : `${tsFile}.video.m4s`,
|
output: chosenVideoSegments.pssh_wvd || chosenVideoSegments.pssh_prd ? `${tempTsFile}.video.enc.m4s` : `${tsFile}.video.m4s`,
|
||||||
timeout: options.timeout,
|
timeout: options.timeout,
|
||||||
m3u8json: videoJson,
|
m3u8json: videoJson,
|
||||||
// baseurl: chunkPlaylist.baseUrl,
|
// baseurl: chunkPlaylist.baseUrl,
|
||||||
|
|
@ -1694,7 +1694,7 @@ export default class Crunchy implements ServiceClass {
|
||||||
segments: chosenAudioSegments.segments
|
segments: chosenAudioSegments.segments
|
||||||
};
|
};
|
||||||
const audioDownload = await new streamdl({
|
const audioDownload = await new streamdl({
|
||||||
output: chosenAudioSegments.pssh ? `${tempTsFile}.audio.enc.m4s` : `${tsFile}.audio.m4s`,
|
output: chosenVideoSegments.pssh_wvd || chosenVideoSegments.pssh_prd ? `${tempTsFile}.audio.enc.m4s` : `${tsFile}.audio.m4s`,
|
||||||
timeout: options.timeout,
|
timeout: options.timeout,
|
||||||
m3u8json: audioJson,
|
m3u8json: audioJson,
|
||||||
// baseurl: chunkPlaylist.baseUrl,
|
// baseurl: chunkPlaylist.baseUrl,
|
||||||
|
|
@ -1721,7 +1721,7 @@ export default class Crunchy implements ServiceClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
//Handle Decryption if needed
|
//Handle Decryption if needed
|
||||||
if ((chosenVideoSegments.pssh || chosenAudioSegments.pssh) && (videoDownloaded || audioDownloaded)) {
|
if ((chosenVideoSegments.pssh_wvd ||chosenVideoSegments.pssh_prd || chosenAudioSegments.pssh_wvd || chosenAudioSegments.pssh_prd) && (videoDownloaded || audioDownloaded)) {
|
||||||
const assetIdRegex = chosenVideoSegments.segments[0].uri.match(/\/assets\/(?:p\/)?([^_,]+)/);
|
const assetIdRegex = chosenVideoSegments.segments[0].uri.match(/\/assets\/(?:p\/)?([^_,]+)/);
|
||||||
const assetId = assetIdRegex ? assetIdRegex[1] : null;
|
const assetId = assetIdRegex ? assetIdRegex[1] : null;
|
||||||
const sessionId = new Date().getUTCMilliseconds().toString().padStart(3, '0') + process.hrtime.bigint().toString().slice(0, 13);
|
const sessionId = new Date().getUTCMilliseconds().toString().padStart(3, '0') + process.hrtime.bigint().toString().slice(0, 13);
|
||||||
|
|
@ -1741,11 +1741,24 @@ export default class Crunchy implements ServiceClass {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const authData = await decReq.res.json() as {'custom_data': string, 'token': string};
|
const authData = await decReq.res.json() as {'custom_data': string, 'token': string};
|
||||||
const encryptionKeys = await getKeys(chosenVideoSegments.pssh, 'https://lic.drmtoday.com/license-proxy-widevine/cenc/', {
|
|
||||||
'dt-custom-data': authData.custom_data,
|
var encryptionKeys;
|
||||||
'x-dt-auth-token': authData.token
|
|
||||||
});
|
if (cdm === 'widevine') {
|
||||||
if (encryptionKeys.length == 0) {
|
encryptionKeys = await getKeysWVD(chosenVideoSegments.pssh_wvd, 'https://lic.drmtoday.com/license-proxy-widevine/cenc/', {
|
||||||
|
'dt-custom-data': authData.custom_data,
|
||||||
|
'x-dt-auth-token': authData.token
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cdm === 'playready') {
|
||||||
|
encryptionKeys = await getKeysPRD(chosenVideoSegments.pssh_prd, 'https://lic.drmtoday.com/license-proxy-headerauth/drmtoday/RightsManager.asmx', {
|
||||||
|
'dt-custom-data': authData.custom_data,
|
||||||
|
'x-dt-auth-token': authData.token
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!encryptionKeys || encryptionKeys.length == 0) {
|
||||||
console.error('Failed to get encryption keys');
|
console.error('Failed to get encryption keys');
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -1755,7 +1768,7 @@ export default class Crunchy implements ServiceClass {
|
||||||
});*/
|
});*/
|
||||||
|
|
||||||
if (this.cfg.bin.mp4decrypt) {
|
if (this.cfg.bin.mp4decrypt) {
|
||||||
const commandBase = `--show-progress --key ${encryptionKeys[1].kid}:${encryptionKeys[1].key} `;
|
const commandBase = `--show-progress --key ${encryptionKeys[cdm === 'playready' ? 0 : 1].kid}:${encryptionKeys[cdm === 'playready' ? 0 : 1].key} `;
|
||||||
const commandVideo = commandBase+`"${tempTsFile}.video.enc.m4s" "${tempTsFile}.video.m4s"`;
|
const commandVideo = commandBase+`"${tempTsFile}.video.enc.m4s" "${tempTsFile}.video.m4s"`;
|
||||||
const commandAudio = commandBase+`"${tempTsFile}.audio.enc.m4s" "${tempTsFile}.audio.m4s"`;
|
const commandAudio = commandBase+`"${tempTsFile}.audio.enc.m4s" "${tempTsFile}.audio.m4s"`;
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
11
hidive.ts
11
hidive.ts
|
|
@ -35,7 +35,7 @@ import { Episode, NewHidiveEpisodeExtra, NewHidiveSeason, NewHidiveSeriesExtra }
|
||||||
import { NewHidiveEpisode } from './@types/newHidiveEpisode';
|
import { NewHidiveEpisode } from './@types/newHidiveEpisode';
|
||||||
import { NewHidivePlayback, Subtitle } from './@types/newHidivePlayback';
|
import { NewHidivePlayback, Subtitle } from './@types/newHidivePlayback';
|
||||||
import { MPDParsed, parse } from './modules/module.transform-mpd';
|
import { MPDParsed, parse } from './modules/module.transform-mpd';
|
||||||
import getKeys, { canDecrypt } from './modules/widevine';
|
import { canDecrypt, getKeysWVD, cdm } from './modules/cdm';
|
||||||
import { exec } from './modules/sei-helper-fixes';
|
import { exec } from './modules/sei-helper-fixes';
|
||||||
import { KeyContainer } from './modules/license';
|
import { KeyContainer } from './modules/license';
|
||||||
|
|
||||||
|
|
@ -657,6 +657,7 @@ export default class Hidive implements ServiceClass {
|
||||||
const chosenFontSize = options.originalFontSize ? undefined : options.fontSize;
|
const chosenFontSize = options.originalFontSize ? undefined : options.fontSize;
|
||||||
let encryptionKeys: KeyContainer[] = [];
|
let encryptionKeys: KeyContainer[] = [];
|
||||||
if (!canDecrypt) console.warn('Decryption not enabled!');
|
if (!canDecrypt) console.warn('Decryption not enabled!');
|
||||||
|
if (canDecrypt && cdm === 'playready') console.warn("Hidive doesn't support Playready CDM!");
|
||||||
|
|
||||||
if (!this.cfg.bin.ffmpeg)
|
if (!this.cfg.bin.ffmpeg)
|
||||||
this.cfg.bin = await yamlCfg.loadBinCfg();
|
this.cfg.bin = await yamlCfg.loadBinCfg();
|
||||||
|
|
@ -763,8 +764,8 @@ export default class Hidive implements ServiceClass {
|
||||||
console.info(`Selected (Available) Audio Languages: ${chosenAudios.map(a => a.language.name).join(', ')}`);
|
console.info(`Selected (Available) Audio Languages: ${chosenAudios.map(a => a.language.name).join(', ')}`);
|
||||||
console.info('Stream URL:', chosenVideoSegments.segments[0].map.uri.split('/init.mp4')[0]);
|
console.info('Stream URL:', chosenVideoSegments.segments[0].map.uri.split('/init.mp4')[0]);
|
||||||
|
|
||||||
if (chosenAudios[0].pssh || chosenVideoSegments.pssh) {
|
if (chosenAudios[0].pssh_wvd || chosenVideoSegments.pssh_wvd) {
|
||||||
encryptionKeys = await getKeys(chosenVideoSegments.pssh, 'https://shield-drm.imggaming.com/api/v2/license', {
|
encryptionKeys = await getKeysWVD(chosenVideoSegments.pssh_wvd, 'https://shield-drm.imggaming.com/api/v2/license', {
|
||||||
'Authorization': `Bearer ${selectedEpisode.jwtToken}`,
|
'Authorization': `Bearer ${selectedEpisode.jwtToken}`,
|
||||||
'X-Drm-Info': 'eyJzeXN0ZW0iOiJjb20ud2lkZXZpbmUuYWxwaGEifQ==',
|
'X-Drm-Info': 'eyJzeXN0ZW0iOiJjb20ud2lkZXZpbmUuYWxwaGEifQ==',
|
||||||
});
|
});
|
||||||
|
|
@ -810,7 +811,7 @@ export default class Hidive implements ServiceClass {
|
||||||
console.error(`DL Stats: ${JSON.stringify(videoDownload.parts)}\n`);
|
console.error(`DL Stats: ${JSON.stringify(videoDownload.parts)}\n`);
|
||||||
dlFailed = true;
|
dlFailed = true;
|
||||||
} else {
|
} else {
|
||||||
if (chosenVideoSegments.pssh) {
|
if (chosenVideoSegments.pssh_wvd) {
|
||||||
console.info('Decryption Needed, attempting to decrypt');
|
console.info('Decryption Needed, attempting to decrypt');
|
||||||
if (encryptionKeys.length == 0) {
|
if (encryptionKeys.length == 0) {
|
||||||
console.error('Failed to get encryption keys');
|
console.error('Failed to get encryption keys');
|
||||||
|
|
@ -892,7 +893,7 @@ export default class Hidive implements ServiceClass {
|
||||||
console.error(`DL Stats: ${JSON.stringify(audioDownload.parts)}\n`);
|
console.error(`DL Stats: ${JSON.stringify(audioDownload.parts)}\n`);
|
||||||
dlFailed = true;
|
dlFailed = true;
|
||||||
}
|
}
|
||||||
if (chosenAudioSegments.pssh) {
|
if (chosenAudioSegments.pssh_wvd) {
|
||||||
console.info('Decryption Needed, attempting to decrypt');
|
console.info('Decryption Needed, attempting to decrypt');
|
||||||
if (encryptionKeys.length == 0) {
|
if (encryptionKeys.length == 0) {
|
||||||
console.error('Failed to get encryption keys');
|
console.error('Failed to get encryption keys');
|
||||||
|
|
|
||||||
235
modules/cdm.ts
Normal file
235
modules/cdm.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
import { KeyContainer, Session } from "./license";
|
||||||
|
import fs from "fs";
|
||||||
|
import { console } from "./log";
|
||||||
|
import got from "got";
|
||||||
|
import { workingDir } from "./module.cfg-loader";
|
||||||
|
import path from "path";
|
||||||
|
import { ReadError, Response } from "got";
|
||||||
|
import { Device } from "./playready/device";
|
||||||
|
import Cdm from "./playready/cdm";
|
||||||
|
import { PSSH } from "./playready/pssh";
|
||||||
|
|
||||||
|
//read cdm files located in the same directory
|
||||||
|
let privateKey: Buffer = Buffer.from([]),
|
||||||
|
identifierBlob: Buffer = Buffer.from([]),
|
||||||
|
prd: Buffer = Buffer.from([]),
|
||||||
|
prd_cdm: Cdm | undefined;
|
||||||
|
export let cdm: "widevine" | "playready";
|
||||||
|
export let canDecrypt: boolean;
|
||||||
|
try {
|
||||||
|
const files_prd = fs.readdirSync(path.join(workingDir, "playready"));
|
||||||
|
const prd_file_found = files_prd.find(f => f.includes('.prd'))
|
||||||
|
if (prd_file_found) {
|
||||||
|
const file_prd = path.join(workingDir, "playready", prd_file_found);
|
||||||
|
const stats = fs.statSync(file_prd);
|
||||||
|
if (stats.size < 1024 * 8 && stats.isFile()) {
|
||||||
|
const fileContents = fs.readFileSync(file_prd, {
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
if (fileContents.includes("CERT")) {
|
||||||
|
prd = fs.readFileSync(file_prd);
|
||||||
|
const device = Device.loads(prd);
|
||||||
|
prd_cdm = Cdm.fromDevice(device);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const files_wvd = fs.readdirSync(path.join(workingDir, "widevine"));
|
||||||
|
files_wvd.forEach(function (file) {
|
||||||
|
file = path.join(workingDir, "widevine", file);
|
||||||
|
const stats = fs.statSync(file);
|
||||||
|
if (stats.size < 1024 * 8 && stats.isFile()) {
|
||||||
|
const fileContents = fs.readFileSync(file, { encoding: "utf8" });
|
||||||
|
if (
|
||||||
|
fileContents.includes("-BEGIN PRIVATE KEY-") ||
|
||||||
|
fileContents.includes("-BEGIN RSA PRIVATE KEY-")
|
||||||
|
) {
|
||||||
|
privateKey = fs.readFileSync(file);
|
||||||
|
}
|
||||||
|
if (fileContents.includes("widevine_cdm_version")) {
|
||||||
|
identifierBlob = fs.readFileSync(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (privateKey.length !== 0 && identifierBlob.length !== 0) {
|
||||||
|
cdm = "widevine";
|
||||||
|
canDecrypt = true;
|
||||||
|
} else if (prd.length !== 0) {
|
||||||
|
cdm = "playready";
|
||||||
|
canDecrypt = true;
|
||||||
|
} else if (privateKey.length == 0) {
|
||||||
|
console.warn("Private key missing");
|
||||||
|
canDecrypt = false;
|
||||||
|
} else if (identifierBlob.length == 0) {
|
||||||
|
console.warn("Identifier blob missing");
|
||||||
|
canDecrypt = false;
|
||||||
|
} else if (prd.length == 0) {
|
||||||
|
console.warn("PRD is missing");
|
||||||
|
canDecrypt = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
canDecrypt = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getKeysWVD(
|
||||||
|
pssh: string | undefined,
|
||||||
|
licenseServer: string,
|
||||||
|
authData: Record<string, string>
|
||||||
|
): Promise<KeyContainer[]> {
|
||||||
|
if (!pssh || !canDecrypt) return [];
|
||||||
|
//pssh found in the mpd manifest
|
||||||
|
const psshBuffer = Buffer.from(pssh, "base64");
|
||||||
|
|
||||||
|
//Create a new widevine session
|
||||||
|
const session = new Session({ privateKey, identifierBlob }, psshBuffer);
|
||||||
|
|
||||||
|
//Generate license
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await got(licenseServer, {
|
||||||
|
method: "POST",
|
||||||
|
body: session.createLicenseRequest(),
|
||||||
|
headers: authData,
|
||||||
|
responseType: "text",
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
const error = _error as {
|
||||||
|
name: string;
|
||||||
|
} & ReadError & {
|
||||||
|
res: Response<unknown>;
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
error.response &&
|
||||||
|
error.response.statusCode &&
|
||||||
|
error.response.statusMessage
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
`${error.name} ${error.response.statusCode}: ${error.response.statusMessage}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(`${error.name}: ${error.code || error.message}`);
|
||||||
|
}
|
||||||
|
if (error.response && !error.res) {
|
||||||
|
error.res = error.response;
|
||||||
|
const docTitle = (error.res.body as string).match(
|
||||||
|
/<title>(.*)<\/title>/
|
||||||
|
);
|
||||||
|
if (error.res.body && docTitle) {
|
||||||
|
console.error(docTitle[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
error.res &&
|
||||||
|
error.res.body &&
|
||||||
|
error.response.statusCode &&
|
||||||
|
error.response.statusCode != 404 &&
|
||||||
|
error.response.statusCode != 403
|
||||||
|
) {
|
||||||
|
console.error("Body:", error.res.body);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode === 200) {
|
||||||
|
//Parse License and return keys
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(response.body);
|
||||||
|
return session.parseLicense(Buffer.from(json["license"], "base64"));
|
||||||
|
} catch {
|
||||||
|
return session.parseLicense(response.rawBody);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.info(
|
||||||
|
"License request failed:",
|
||||||
|
response.statusMessage,
|
||||||
|
response.body
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getKeysPRD(
|
||||||
|
pssh: string | undefined,
|
||||||
|
licenseServer: string,
|
||||||
|
authData: Record<string, string>
|
||||||
|
): Promise<KeyContainer[] | undefined> {
|
||||||
|
if (!pssh || !canDecrypt || !prd_cdm) return [];
|
||||||
|
const pssh_parsed = new PSSH(pssh);
|
||||||
|
|
||||||
|
//Create a new playready session
|
||||||
|
const session = prd_cdm.getLicenseChallenge(
|
||||||
|
pssh_parsed.get_wrm_headers(true)[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
//Generate license
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await got(licenseServer, {
|
||||||
|
method: "POST",
|
||||||
|
body: session,
|
||||||
|
headers: authData,
|
||||||
|
responseType: "text",
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
const error = _error as {
|
||||||
|
name: string;
|
||||||
|
} & ReadError & {
|
||||||
|
res: Response<unknown>;
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
error.response &&
|
||||||
|
error.response.statusCode &&
|
||||||
|
error.response.statusMessage
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
`${error.name} ${error.response.statusCode}: ${error.response.statusMessage}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(`${error.name}: ${error.code || error.message}`);
|
||||||
|
}
|
||||||
|
if (error.response && !error.res) {
|
||||||
|
error.res = error.response;
|
||||||
|
const docTitle = (error.res.body as string).match(
|
||||||
|
/<title>(.*)<\/title>/
|
||||||
|
);
|
||||||
|
if (error.res.body && docTitle) {
|
||||||
|
console.error(docTitle[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
error.res &&
|
||||||
|
error.res.body &&
|
||||||
|
error.response.statusCode &&
|
||||||
|
error.response.statusCode != 404 &&
|
||||||
|
error.response.statusCode != 403
|
||||||
|
) {
|
||||||
|
console.error("Body:", error.res.body);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode === 200) {
|
||||||
|
//Parse License and return keys
|
||||||
|
try {
|
||||||
|
const keys = prd_cdm.parseLicense(response.body);
|
||||||
|
|
||||||
|
return keys.map((k) => {
|
||||||
|
return {
|
||||||
|
kid: k.key_id,
|
||||||
|
key: k.key,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.info(
|
||||||
|
"License request failed:",
|
||||||
|
response.statusMessage,
|
||||||
|
response.body
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,8 @@ type Segment = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PlaylistItem = {
|
export type PlaylistItem = {
|
||||||
pssh?: string,
|
pssh_wvd?: string,
|
||||||
|
pssh_prd?: string,
|
||||||
bandwidth: number,
|
bandwidth: number,
|
||||||
segments: Segment[]
|
segments: Segment[]
|
||||||
}
|
}
|
||||||
|
|
@ -47,6 +48,29 @@ export type MPDParsed = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractPSSH(
|
||||||
|
manifest: string,
|
||||||
|
schemeIdUri: string,
|
||||||
|
psshTagNames: string[]
|
||||||
|
): string | null {
|
||||||
|
const regex = new RegExp(
|
||||||
|
`<ContentProtection[^>]*schemeIdUri=["']${schemeIdUri}["'][^>]*>([\\s\\S]*?)</ContentProtection>`,
|
||||||
|
'i'
|
||||||
|
);
|
||||||
|
const match = regex.exec(manifest);
|
||||||
|
if (match && match[1]) {
|
||||||
|
const innerContent = match[1];
|
||||||
|
for (const tagName of psshTagNames) {
|
||||||
|
const psshRegex = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)</${tagName}>`, 'i');
|
||||||
|
const psshMatch = psshRegex.exec(innerContent);
|
||||||
|
if (psshMatch && psshMatch[1]) {
|
||||||
|
return psshMatch[1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function parse(manifest: string, language?: LanguageItem, url?: string) {
|
export async function parse(manifest: string, language?: LanguageItem, url?: string) {
|
||||||
if (!manifest.includes('BaseURL') && url) {
|
if (!manifest.includes('BaseURL') && url) {
|
||||||
manifest = manifest.replace(/(<MPD*\b[^>]*>)/gm, `$1<BaseURL>${url}</BaseURL>`);
|
manifest = manifest.replace(/(<MPD*\b[^>]*>)/gm, `$1<BaseURL>${url}</BaseURL>`);
|
||||||
|
|
@ -123,9 +147,18 @@ export async function parse(manifest: string, language?: LanguageItem, url?: str
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const playreadyPssh = extractPSSH(
|
||||||
|
manifest,
|
||||||
|
'urn:uuid:9A04F079-9840-4286-AB92-E65BE0885F95',
|
||||||
|
['cenc:pssh', 'mspr:pro']
|
||||||
|
);
|
||||||
|
|
||||||
if (playlist.contentProtection &&
|
if (playlist.contentProtection &&
|
||||||
playlist.contentProtection?.['com.widevine.alpha'].pssh)
|
playlist.contentProtection?.['com.widevine.alpha'].pssh)
|
||||||
pItem.pssh = arrayBufferToBase64(playlist.contentProtection['com.widevine.alpha'].pssh);
|
pItem.pssh_wvd = arrayBufferToBase64(playlist.contentProtection['com.widevine.alpha'].pssh);
|
||||||
|
|
||||||
|
if (playreadyPssh)
|
||||||
|
pItem.pssh_prd = playreadyPssh;
|
||||||
|
|
||||||
ret[host].audio.push(pItem);
|
ret[host].audio.push(pItem);
|
||||||
}
|
}
|
||||||
|
|
@ -189,9 +222,18 @@ export async function parse(manifest: string, language?: LanguageItem, url?: str
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const playreadyPssh = extractPSSH(
|
||||||
|
manifest,
|
||||||
|
'urn:uuid:9A04F079-9840-4286-AB92-E65BE0885F95',
|
||||||
|
['cenc:pssh', 'mspr:pro']
|
||||||
|
);
|
||||||
|
|
||||||
if (playlist.contentProtection &&
|
if (playlist.contentProtection &&
|
||||||
playlist.contentProtection?.['com.widevine.alpha'].pssh)
|
playlist.contentProtection?.['com.widevine.alpha'].pssh)
|
||||||
pItem.pssh = arrayBufferToBase64(playlist.contentProtection['com.widevine.alpha'].pssh);
|
pItem.pssh_wvd = arrayBufferToBase64(playlist.contentProtection['com.widevine.alpha'].pssh);
|
||||||
|
|
||||||
|
if (playreadyPssh)
|
||||||
|
pItem.pssh_prd = playreadyPssh;
|
||||||
|
|
||||||
ret[host].video.push(pItem);
|
ret[host].video.push(pItem);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
489
modules/playready/bcert.ts
Normal file
489
modules/playready/bcert.ts
Normal file
|
|
@ -0,0 +1,489 @@
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import { Parser } from 'binary-parser-encoder'
|
||||||
|
import ECCKey from './ecc_key'
|
||||||
|
|
||||||
|
function alignUp(length: number, alignment: number): number {
|
||||||
|
return Math.ceil(length / alignment) * alignment
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BCertStructs {
|
||||||
|
static DrmBCertBasicInfo = new Parser()
|
||||||
|
.buffer('cert_id', { length: 16 })
|
||||||
|
.uint32be('security_level')
|
||||||
|
.uint32be('flags')
|
||||||
|
.uint32be('cert_type')
|
||||||
|
.buffer('public_key_digest', { length: 32 })
|
||||||
|
.uint32be('expiration_date')
|
||||||
|
.buffer('client_id', { length: 16 })
|
||||||
|
|
||||||
|
static DrmBCertDomainInfo = new Parser()
|
||||||
|
.buffer('service_id', { length: 16 })
|
||||||
|
.buffer('account_id', { length: 16 })
|
||||||
|
.uint32be('revision_timestamp')
|
||||||
|
.uint32be('domain_url_length')
|
||||||
|
.buffer('domain_url', {
|
||||||
|
length: function () {
|
||||||
|
return alignUp((this as any).domain_url_length, 4)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
static DrmBCertPCInfo = new Parser().uint32be('security_version')
|
||||||
|
|
||||||
|
static DrmBCertDeviceInfo = new Parser()
|
||||||
|
.uint32be('max_license')
|
||||||
|
.uint32be('max_header')
|
||||||
|
.uint32be('max_chain_depth')
|
||||||
|
|
||||||
|
static DrmBCertFeatureInfo = new Parser()
|
||||||
|
.uint32be('feature_count')
|
||||||
|
.array('features', {
|
||||||
|
type: 'uint32be',
|
||||||
|
length: 'feature_count'
|
||||||
|
})
|
||||||
|
|
||||||
|
static CertKey = new Parser()
|
||||||
|
.uint16be('type')
|
||||||
|
.uint16be('length')
|
||||||
|
.uint32be('flags')
|
||||||
|
.buffer('key', {
|
||||||
|
length: function () {
|
||||||
|
return (this as any).length / 8
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.uint32be('usages_count')
|
||||||
|
.array('usages', {
|
||||||
|
type: 'uint32be',
|
||||||
|
length: 'usages_count'
|
||||||
|
})
|
||||||
|
|
||||||
|
static DrmBCertKeyInfo = new Parser()
|
||||||
|
.uint32be('key_count')
|
||||||
|
.array('cert_keys', {
|
||||||
|
type: BCertStructs.CertKey,
|
||||||
|
length: 'key_count'
|
||||||
|
})
|
||||||
|
|
||||||
|
static DrmBCertManufacturerInfo = new Parser()
|
||||||
|
.uint32be('flags')
|
||||||
|
.uint32be('manufacturer_name_length')
|
||||||
|
.buffer('manufacturer_name', {
|
||||||
|
length: function () {
|
||||||
|
return alignUp((this as any).manufacturer_name_length, 4)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.uint32be('model_name_length')
|
||||||
|
.buffer('model_name', {
|
||||||
|
length: function () {
|
||||||
|
return alignUp((this as any).model_name_length, 4)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.uint32be('model_number_length')
|
||||||
|
.buffer('model_number', {
|
||||||
|
length: function () {
|
||||||
|
return alignUp((this as any).model_number_length, 4)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
static DrmBCertSignatureInfo = new Parser()
|
||||||
|
.uint16be('signature_type')
|
||||||
|
.uint16be('signature_size')
|
||||||
|
.buffer('signature', { length: 'signature_size' })
|
||||||
|
.uint32be('signature_key_size')
|
||||||
|
.buffer('signature_key', {
|
||||||
|
length: function () {
|
||||||
|
return (this as any).signature_key_size / 8
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
static DrmBCertSilverlightInfo = new Parser()
|
||||||
|
.uint32be('security_version')
|
||||||
|
.uint32be('platform_identifier')
|
||||||
|
|
||||||
|
static DrmBCertMeteringInfo = new Parser()
|
||||||
|
.buffer('metering_id', { length: 16 })
|
||||||
|
.uint32be('metering_url_length')
|
||||||
|
.buffer('metering_url', {
|
||||||
|
length: function () {
|
||||||
|
return alignUp((this as any).metering_url_length, 4)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
static DrmBCertExtDataSignKeyInfo = new Parser()
|
||||||
|
.uint16be('type')
|
||||||
|
.uint16be('length')
|
||||||
|
.uint32be('flags')
|
||||||
|
.buffer('key', {
|
||||||
|
length: function () {
|
||||||
|
return (this as any).length / 8
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
static BCertExtDataRecord = new Parser()
|
||||||
|
.uint32be('data_size')
|
||||||
|
.buffer('data', {
|
||||||
|
length: 'data_size'
|
||||||
|
})
|
||||||
|
|
||||||
|
static DrmBCertExtDataSignature = new Parser()
|
||||||
|
.uint16be('signature_type')
|
||||||
|
.uint16be('signature_size')
|
||||||
|
.buffer('signature', {
|
||||||
|
length: 'signature_size'
|
||||||
|
})
|
||||||
|
|
||||||
|
static BCertExtDataContainer = new Parser()
|
||||||
|
.uint32be('record_count')
|
||||||
|
.array('records', {
|
||||||
|
length: 'record_count',
|
||||||
|
type: BCertStructs.BCertExtDataRecord
|
||||||
|
})
|
||||||
|
.nest('signature', {
|
||||||
|
type: BCertStructs.DrmBCertExtDataSignature
|
||||||
|
})
|
||||||
|
|
||||||
|
static DrmBCertServerInfo = new Parser().uint32be('warning_days')
|
||||||
|
|
||||||
|
static DrmBcertSecurityVersion = new Parser()
|
||||||
|
.uint32be('security_version')
|
||||||
|
.uint32be('platform_identifier')
|
||||||
|
|
||||||
|
static Attribute = new Parser()
|
||||||
|
.uint16be('flags')
|
||||||
|
.uint16be('tag')
|
||||||
|
.uint32be('length')
|
||||||
|
.choice('attribute', {
|
||||||
|
tag: 'tag',
|
||||||
|
choices: {
|
||||||
|
1: BCertStructs.DrmBCertBasicInfo,
|
||||||
|
2: BCertStructs.DrmBCertDomainInfo,
|
||||||
|
3: BCertStructs.DrmBCertPCInfo,
|
||||||
|
4: BCertStructs.DrmBCertDeviceInfo,
|
||||||
|
5: BCertStructs.DrmBCertFeatureInfo,
|
||||||
|
6: BCertStructs.DrmBCertKeyInfo,
|
||||||
|
7: BCertStructs.DrmBCertManufacturerInfo,
|
||||||
|
8: BCertStructs.DrmBCertSignatureInfo,
|
||||||
|
9: BCertStructs.DrmBCertSilverlightInfo,
|
||||||
|
10: BCertStructs.DrmBCertMeteringInfo,
|
||||||
|
11: BCertStructs.DrmBCertExtDataSignKeyInfo,
|
||||||
|
12: BCertStructs.BCertExtDataContainer,
|
||||||
|
13: BCertStructs.DrmBCertExtDataSignature,
|
||||||
|
14: new Parser().buffer('data', {
|
||||||
|
length: function () {
|
||||||
|
return (this as any).length - 8
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
15: BCertStructs.DrmBCertServerInfo,
|
||||||
|
16: BCertStructs.DrmBcertSecurityVersion,
|
||||||
|
17: BCertStructs.DrmBcertSecurityVersion
|
||||||
|
},
|
||||||
|
defaultChoice: new Parser().buffer('data', {
|
||||||
|
length: function () {
|
||||||
|
return (this as any).length - 8
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
static BCert = new Parser()
|
||||||
|
.string('signature', { length: 4, assert: 'CERT' })
|
||||||
|
.int32be('version')
|
||||||
|
.int32be('total_length')
|
||||||
|
.int32be('certificate_length')
|
||||||
|
.array('attributes', {
|
||||||
|
type: BCertStructs.Attribute,
|
||||||
|
lengthInBytes: function () {
|
||||||
|
return (this as any).total_length - 16
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
static BCertChain = new Parser()
|
||||||
|
.string('signature', { length: 4, assert: 'CHAI' })
|
||||||
|
.int32be('version')
|
||||||
|
.int32be('total_length')
|
||||||
|
.int32be('flags')
|
||||||
|
.int32be('certificate_count')
|
||||||
|
.array('certificates', {
|
||||||
|
type: BCertStructs.BCert,
|
||||||
|
length: 'certificate_count'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Certificate {
|
||||||
|
parsed: any
|
||||||
|
_BCERT: Parser
|
||||||
|
|
||||||
|
constructor(parsed_bcert: any, bcert_obj: Parser = BCertStructs.BCert) {
|
||||||
|
this.parsed = parsed_bcert
|
||||||
|
this._BCERT = bcert_obj
|
||||||
|
}
|
||||||
|
|
||||||
|
// UNSTABLE
|
||||||
|
static new_key_cert(
|
||||||
|
cert_id: Buffer,
|
||||||
|
security_level: number,
|
||||||
|
client_id: Buffer,
|
||||||
|
signing_key: ECCKey,
|
||||||
|
encryption_key: ECCKey,
|
||||||
|
group_key: ECCKey,
|
||||||
|
parent: CertificateChain,
|
||||||
|
expiry: number = 0xffffffff,
|
||||||
|
max_license: number = 10240,
|
||||||
|
max_header: number = 15360,
|
||||||
|
max_chain_depth: number = 2
|
||||||
|
): Certificate {
|
||||||
|
if (!cert_id) {
|
||||||
|
throw new Error('Certificate ID is required')
|
||||||
|
}
|
||||||
|
if (!client_id) {
|
||||||
|
throw new Error('Client ID is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
const basic_info = {
|
||||||
|
cert_id: cert_id,
|
||||||
|
security_level: security_level,
|
||||||
|
flags: 0,
|
||||||
|
cert_type: 2,
|
||||||
|
public_key_digest: signing_key.publicSha256Digest(),
|
||||||
|
expiration_date: expiry,
|
||||||
|
client_id: client_id
|
||||||
|
}
|
||||||
|
const basic_info_attribute = {
|
||||||
|
flags: 1,
|
||||||
|
tag: 1,
|
||||||
|
length:
|
||||||
|
BCertStructs.DrmBCertBasicInfo.encode(basic_info).length + 8,
|
||||||
|
attribute: basic_info
|
||||||
|
}
|
||||||
|
|
||||||
|
const device_info = {
|
||||||
|
max_license: max_license,
|
||||||
|
max_header: max_header,
|
||||||
|
max_chain_depth: max_chain_depth
|
||||||
|
}
|
||||||
|
|
||||||
|
const device_info_attribute = {
|
||||||
|
flags: 1,
|
||||||
|
tag: 4,
|
||||||
|
length:
|
||||||
|
BCertStructs.DrmBCertDeviceInfo.encode(device_info).length + 8,
|
||||||
|
attribute: device_info
|
||||||
|
}
|
||||||
|
|
||||||
|
const feature = {
|
||||||
|
feature_count: 1,
|
||||||
|
features: [4, 13]
|
||||||
|
}
|
||||||
|
const feature_attribute = {
|
||||||
|
flags: 1,
|
||||||
|
tag: 5,
|
||||||
|
length: BCertStructs.DrmBCertFeatureInfo.encode(feature).length + 8,
|
||||||
|
attribute: feature
|
||||||
|
}
|
||||||
|
|
||||||
|
const cert_key_sign = {
|
||||||
|
type: 1,
|
||||||
|
length: 512, // bits
|
||||||
|
flags: 0,
|
||||||
|
key: signing_key.privateBytes(),
|
||||||
|
usages_count: 1,
|
||||||
|
usages: [1]
|
||||||
|
}
|
||||||
|
const cert_key_encrypt = {
|
||||||
|
type: 1,
|
||||||
|
length: 512, // bits
|
||||||
|
flags: 0,
|
||||||
|
key: encryption_key.privateBytes(),
|
||||||
|
usages_count: 1,
|
||||||
|
usages: [2]
|
||||||
|
}
|
||||||
|
const key_info = {
|
||||||
|
key_count: 2,
|
||||||
|
cert_keys: [cert_key_sign, cert_key_encrypt]
|
||||||
|
}
|
||||||
|
const key_info_attribute = {
|
||||||
|
flags: 1,
|
||||||
|
tag: 6,
|
||||||
|
length: BCertStructs.DrmBCertKeyInfo.encode(key_info).length + 8,
|
||||||
|
attribute: key_info
|
||||||
|
}
|
||||||
|
|
||||||
|
const manufacturer_info = parent.get_certificate(0).get_attribute(7)
|
||||||
|
|
||||||
|
const new_bcert_container = {
|
||||||
|
signature: 'CERT',
|
||||||
|
version: 1,
|
||||||
|
total_length: 0,
|
||||||
|
certificate_length: 0,
|
||||||
|
attributes: [
|
||||||
|
basic_info_attribute,
|
||||||
|
device_info_attribute,
|
||||||
|
feature_attribute,
|
||||||
|
key_info_attribute,
|
||||||
|
manufacturer_info
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = BCertStructs.BCert.encode(new_bcert_container)
|
||||||
|
new_bcert_container.certificate_length = payload.length
|
||||||
|
new_bcert_container.total_length = payload.length + 144
|
||||||
|
payload = BCertStructs.BCert.encode(new_bcert_container)
|
||||||
|
|
||||||
|
const hash = createHash('sha256')
|
||||||
|
hash.update(payload)
|
||||||
|
const digest = hash.digest()
|
||||||
|
|
||||||
|
const signatureObj = group_key.keyPair.sign(digest)
|
||||||
|
const r = Buffer.from(signatureObj.r.toArray('be', 32))
|
||||||
|
const s = Buffer.from(signatureObj.s.toArray('be', 32))
|
||||||
|
const signature = Buffer.concat([r, s])
|
||||||
|
|
||||||
|
const signature_info = {
|
||||||
|
signature_type: 1,
|
||||||
|
signature_size: 64,
|
||||||
|
signature: signature,
|
||||||
|
signature_key_size: 512, // bits
|
||||||
|
signature_key: group_key.publicBytes()
|
||||||
|
}
|
||||||
|
const signature_info_attribute = {
|
||||||
|
flags: 1,
|
||||||
|
tag: 8,
|
||||||
|
length:
|
||||||
|
BCertStructs.DrmBCertSignatureInfo.encode(signature_info)
|
||||||
|
.length + 8,
|
||||||
|
attribute: signature_info
|
||||||
|
}
|
||||||
|
new_bcert_container.attributes.push(signature_info_attribute)
|
||||||
|
|
||||||
|
return new Certificate(new_bcert_container)
|
||||||
|
}
|
||||||
|
|
||||||
|
static loads(data: string | Buffer): Certificate {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
data = Buffer.from(data, 'base64')
|
||||||
|
}
|
||||||
|
if (!Buffer.isBuffer(data)) {
|
||||||
|
throw new Error(`Expecting Bytes or Base64 input, got ${data}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cert = BCertStructs.BCert
|
||||||
|
const parsed_bcert = cert.parse(data)
|
||||||
|
return new Certificate(parsed_bcert, cert)
|
||||||
|
}
|
||||||
|
|
||||||
|
static load(filePath: string): Certificate {
|
||||||
|
const data = fs.readFileSync(filePath)
|
||||||
|
return Certificate.loads(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
get_attribute(type_: number) {
|
||||||
|
for (const attribute of this.parsed.attributes) {
|
||||||
|
if (attribute.tag === type_) {
|
||||||
|
return attribute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get_security_level(): number {
|
||||||
|
const basic_info_attribute = this.get_attribute(1)
|
||||||
|
if (basic_info_attribute) {
|
||||||
|
return basic_info_attribute.attribute.security_level
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static _unpad(name: Buffer): string {
|
||||||
|
return name.toString('utf8').replace(/\0+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
get_name(): string {
|
||||||
|
const manufacturer_info_attribute = this.get_attribute(7)
|
||||||
|
if (manufacturer_info_attribute) {
|
||||||
|
const manufacturer_info = manufacturer_info_attribute.attribute
|
||||||
|
const manufacturer_name = Certificate._unpad(
|
||||||
|
manufacturer_info.manufacturer_name
|
||||||
|
)
|
||||||
|
const model_name = Certificate._unpad(manufacturer_info.model_name)
|
||||||
|
const model_number = Certificate._unpad(
|
||||||
|
manufacturer_info.model_number
|
||||||
|
)
|
||||||
|
return `${manufacturer_name} ${model_name} ${model_number}`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
dumps(): Buffer {
|
||||||
|
return this._BCERT.encode(this.parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct(): Parser {
|
||||||
|
return this._BCERT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CertificateChain {
|
||||||
|
parsed: any
|
||||||
|
_BCERT_CHAIN: Parser
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
parsed_bcert_chain: any,
|
||||||
|
bcert_chain_obj: Parser = BCertStructs.BCertChain
|
||||||
|
) {
|
||||||
|
this.parsed = parsed_bcert_chain
|
||||||
|
this._BCERT_CHAIN = bcert_chain_obj
|
||||||
|
}
|
||||||
|
|
||||||
|
static loads(data: string | Buffer): CertificateChain {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
data = Buffer.from(data, 'base64')
|
||||||
|
}
|
||||||
|
if (!Buffer.isBuffer(data)) {
|
||||||
|
throw new Error(`Expecting Bytes or Base64 input, got ${data}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cert_chain = BCertStructs.BCertChain
|
||||||
|
try {
|
||||||
|
const parsed_bcert_chain = cert_chain.parse(data)
|
||||||
|
return new CertificateChain(parsed_bcert_chain, cert_chain)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during parsing:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static load(filePath: string): CertificateChain {
|
||||||
|
const data = fs.readFileSync(filePath)
|
||||||
|
return CertificateChain.loads(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
dumps(): Buffer {
|
||||||
|
return this._BCERT_CHAIN.encode(this.parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct(): Parser {
|
||||||
|
return this._BCERT_CHAIN
|
||||||
|
}
|
||||||
|
|
||||||
|
get_certificate(index: number): Certificate {
|
||||||
|
return new Certificate(this.parsed.certificates[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
get_security_level(): number {
|
||||||
|
return this.get_certificate(0).get_security_level()
|
||||||
|
}
|
||||||
|
|
||||||
|
get_name(): string {
|
||||||
|
return this.get_certificate(0).get_name()
|
||||||
|
}
|
||||||
|
|
||||||
|
append(bcert: Certificate): void {
|
||||||
|
this.parsed.certificate_count += 1
|
||||||
|
this.parsed.certificates.push(bcert.parsed)
|
||||||
|
this.parsed.total_length += bcert.dumps().length
|
||||||
|
}
|
||||||
|
|
||||||
|
prepend(bcert: Certificate): void {
|
||||||
|
this.parsed.certificate_count += 1
|
||||||
|
this.parsed.certificates.unshift(bcert.parsed)
|
||||||
|
this.parsed.total_length += bcert.dumps().length
|
||||||
|
}
|
||||||
|
}
|
||||||
289
modules/playready/cdm.ts
Normal file
289
modules/playready/cdm.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
import { CertificateChain } from './bcert'
|
||||||
|
import ECCKey from './ecc_key'
|
||||||
|
import ElGamal, { Point } from './elgamal'
|
||||||
|
import XmlKey from './xml_key'
|
||||||
|
import { CipherType, getCipherType, Key } from './key'
|
||||||
|
import { XMRLicense } from './xmrlicense'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import elliptic from 'elliptic'
|
||||||
|
import { Device } from './device'
|
||||||
|
import { XMLParser } from 'fast-xml-parser'
|
||||||
|
|
||||||
|
export default class Cdm {
|
||||||
|
security_level: number
|
||||||
|
certificate_chain: CertificateChain
|
||||||
|
encryption_key: ECCKey
|
||||||
|
signing_key: ECCKey
|
||||||
|
client_version: string
|
||||||
|
la_version: number
|
||||||
|
|
||||||
|
curve: elliptic.ec
|
||||||
|
elgamal: ElGamal
|
||||||
|
|
||||||
|
private wmrm_key: elliptic.ec.KeyPair
|
||||||
|
private xml_key: XmlKey
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
security_level: number,
|
||||||
|
certificate_chain: CertificateChain,
|
||||||
|
encryption_key: ECCKey,
|
||||||
|
signing_key: ECCKey,
|
||||||
|
client_version: string = '2.4.117.27',
|
||||||
|
la_version: number = 1
|
||||||
|
) {
|
||||||
|
this.security_level = security_level
|
||||||
|
this.certificate_chain = certificate_chain
|
||||||
|
this.encryption_key = encryption_key
|
||||||
|
this.signing_key = signing_key
|
||||||
|
this.client_version = client_version
|
||||||
|
this.la_version = la_version
|
||||||
|
|
||||||
|
this.curve = new elliptic.ec('p256')
|
||||||
|
this.elgamal = new ElGamal(this.curve)
|
||||||
|
|
||||||
|
const x =
|
||||||
|
'c8b6af16ee941aadaa5389b4af2c10e356be42af175ef3face93254e7b0b3d9b'
|
||||||
|
const y =
|
||||||
|
'982b27b5cb2341326e56aa857dbfd5c634ce2cf9ea74fca8f2af5957efeea562'
|
||||||
|
this.wmrm_key = this.curve.keyFromPublic({ x, y }, 'hex')
|
||||||
|
this.xml_key = new XmlKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromDevice(device: Device): Cdm {
|
||||||
|
return new Cdm(
|
||||||
|
device.security_level,
|
||||||
|
device.group_certificate,
|
||||||
|
device.encryption_key,
|
||||||
|
device.signing_key
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getKeyData(): Buffer {
|
||||||
|
const messagePoint = this.xml_key.getPoint(this.elgamal.curve)
|
||||||
|
const [point1, point2] = this.elgamal.encrypt(
|
||||||
|
messagePoint,
|
||||||
|
this.wmrm_key.getPublic() as Point
|
||||||
|
)
|
||||||
|
|
||||||
|
const bufferArray = Buffer.concat([
|
||||||
|
ElGamal.toBytes(point1.getX()),
|
||||||
|
ElGamal.toBytes(point1.getY()),
|
||||||
|
ElGamal.toBytes(point2.getX()),
|
||||||
|
ElGamal.toBytes(point2.getY())
|
||||||
|
])
|
||||||
|
|
||||||
|
return bufferArray
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCipherData(): Buffer {
|
||||||
|
const b64_chain = this.certificate_chain.dumps().toString('base64')
|
||||||
|
const body = `<Data><CertificateChains><CertificateChain>${b64_chain}</CertificateChain></CertificateChains></Data>`
|
||||||
|
|
||||||
|
const cipher = crypto.createCipheriv(
|
||||||
|
'aes-128-cbc',
|
||||||
|
this.xml_key.aesKey,
|
||||||
|
this.xml_key.aesIv
|
||||||
|
)
|
||||||
|
|
||||||
|
const ciphertext = Buffer.concat([
|
||||||
|
cipher.update(Buffer.from(body, 'utf-8')),
|
||||||
|
cipher.final()
|
||||||
|
])
|
||||||
|
|
||||||
|
return Buffer.concat([this.xml_key.aesIv, ciphertext])
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDigestContent(
|
||||||
|
content_header: string,
|
||||||
|
nonce: string,
|
||||||
|
wmrm_cipher: string,
|
||||||
|
cert_cipher: string
|
||||||
|
): string {
|
||||||
|
const clientTime = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<LA xmlns="http://schemas.microsoft.com/DRM/2007/03/protocols" Id="SignedData" xml:space="preserve">` +
|
||||||
|
`<Version>${this.la_version}</Version>` +
|
||||||
|
`<ContentHeader>${content_header}</ContentHeader>` +
|
||||||
|
`<CLIENTINFO>` +
|
||||||
|
`<CLIENTVERSION>${this.client_version}</CLIENTVERSION>` +
|
||||||
|
`</CLIENTINFO>` +
|
||||||
|
`<LicenseNonce>${nonce}</LicenseNonce>` +
|
||||||
|
`<ClientTime>${clientTime}</ClientTime>` +
|
||||||
|
`<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" Type="http://www.w3.org/2001/04/xmlenc#Element">` +
|
||||||
|
`<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"></EncryptionMethod>` +
|
||||||
|
`<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
|
||||||
|
`<EncryptedKey xmlns="http://www.w3.org/2001/04/xmlenc#">` +
|
||||||
|
`<EncryptionMethod Algorithm="http://schemas.microsoft.com/DRM/2007/03/protocols#ecc256"></EncryptionMethod>` +
|
||||||
|
`<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
|
||||||
|
`<KeyName>WMRMServer</KeyName>` +
|
||||||
|
`</KeyInfo>` +
|
||||||
|
`<CipherData>` +
|
||||||
|
`<CipherValue>${wmrm_cipher}</CipherValue>` +
|
||||||
|
`</CipherData>` +
|
||||||
|
`</EncryptedKey>` +
|
||||||
|
`</KeyInfo>` +
|
||||||
|
`<CipherData>` +
|
||||||
|
`<CipherValue>${cert_cipher}</CipherValue>` +
|
||||||
|
`</CipherData>` +
|
||||||
|
`</EncryptedData>` +
|
||||||
|
`</LA>`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static buildSignedInfo(digest_value: string): string {
|
||||||
|
return (
|
||||||
|
`<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
|
||||||
|
`<CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></CanonicalizationMethod>` +
|
||||||
|
`<SignatureMethod Algorithm="http://schemas.microsoft.com/DRM/2007/03/protocols#ecdsa-sha256"></SignatureMethod>` +
|
||||||
|
`<Reference URI="#SignedData">` +
|
||||||
|
`<DigestMethod Algorithm="http://schemas.microsoft.com/DRM/2007/03/protocols#sha256"></DigestMethod>` +
|
||||||
|
`<DigestValue>${digest_value}</DigestValue>` +
|
||||||
|
`</Reference>` +
|
||||||
|
`</SignedInfo>`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getLicenseChallenge(content_header: string): string {
|
||||||
|
const nonce = randomBytes(16).toString('base64')
|
||||||
|
const wmrm_cipher = this.getKeyData().toString('base64')
|
||||||
|
const cert_cipher = this.getCipherData().toString('base64')
|
||||||
|
|
||||||
|
const la_content = this.buildDigestContent(
|
||||||
|
content_header,
|
||||||
|
nonce,
|
||||||
|
wmrm_cipher,
|
||||||
|
cert_cipher
|
||||||
|
)
|
||||||
|
|
||||||
|
const la_hash = createHash('sha256')
|
||||||
|
.update(la_content, 'utf-8')
|
||||||
|
.digest()
|
||||||
|
|
||||||
|
const signed_info = Cdm.buildSignedInfo(la_hash.toString('base64'))
|
||||||
|
const signed_info_digest = createHash('sha256')
|
||||||
|
.update(signed_info, 'utf-8')
|
||||||
|
.digest()
|
||||||
|
|
||||||
|
const signatureObj = this.signing_key.keyPair.sign(signed_info_digest)
|
||||||
|
|
||||||
|
const r = signatureObj.r.toArrayLike(Buffer, 'be', 32)
|
||||||
|
const s = signatureObj.s.toArrayLike(Buffer, 'be', 32)
|
||||||
|
|
||||||
|
const rawSignature = Buffer.concat([r, s])
|
||||||
|
const signatureValue = rawSignature.toString('base64')
|
||||||
|
|
||||||
|
const publicKeyBytes = this.signing_key.keyPair
|
||||||
|
.getPublic()
|
||||||
|
.encode('array', false)
|
||||||
|
const publicKeyBuffer = Buffer.from(publicKeyBytes)
|
||||||
|
const publicKeyBase64 = publicKeyBuffer.toString('base64')
|
||||||
|
|
||||||
|
const main_body =
|
||||||
|
'<?xml version="1.0" encoding="utf-8"?>' +
|
||||||
|
'<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ' +
|
||||||
|
'xmlns:xsd="http://www.w3.org/2001/XMLSchema" ' +
|
||||||
|
'xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">' +
|
||||||
|
'<soap:Body>' +
|
||||||
|
'<AcquireLicense xmlns="http://schemas.microsoft.com/DRM/2007/03/protocols">' +
|
||||||
|
'<challenge>' +
|
||||||
|
'<Challenge xmlns="http://schemas.microsoft.com/DRM/2007/03/protocols/messages">' +
|
||||||
|
la_content +
|
||||||
|
'<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">' +
|
||||||
|
signed_info +
|
||||||
|
`<SignatureValue>${signatureValue}</SignatureValue>` +
|
||||||
|
'<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">' +
|
||||||
|
'<KeyValue>' +
|
||||||
|
'<ECCKeyValue>' +
|
||||||
|
`<PublicKey>${publicKeyBase64}</PublicKey>` +
|
||||||
|
'</ECCKeyValue>' +
|
||||||
|
'</KeyValue>' +
|
||||||
|
'</KeyInfo>' +
|
||||||
|
'</Signature>' +
|
||||||
|
'</Challenge>' +
|
||||||
|
'</challenge>' +
|
||||||
|
'</AcquireLicense>' +
|
||||||
|
'</soap:Body>' +
|
||||||
|
'</soap:Envelope>'
|
||||||
|
|
||||||
|
return main_body
|
||||||
|
}
|
||||||
|
|
||||||
|
private _decryptEcc256Key(encrypted_key: Buffer): Buffer {
|
||||||
|
const point1 = this.curve.curve.point(
|
||||||
|
encrypted_key.subarray(0, 32).toString('hex'),
|
||||||
|
encrypted_key.subarray(32, 64).toString('hex')
|
||||||
|
)
|
||||||
|
const point2 = this.curve.curve.point(
|
||||||
|
encrypted_key.subarray(64, 96).toString('hex'),
|
||||||
|
encrypted_key.subarray(96, 128).toString('hex')
|
||||||
|
)
|
||||||
|
|
||||||
|
const decrypted = ElGamal.decrypt(
|
||||||
|
[point1, point2],
|
||||||
|
this.encryption_key.keyPair.getPrivate()
|
||||||
|
)
|
||||||
|
const decryptedBytes = decrypted.getX().toArray('be', 32).slice(16, 32)
|
||||||
|
|
||||||
|
return Buffer.from(decryptedBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
parseLicense(license: string | Buffer): {
|
||||||
|
key_id: string
|
||||||
|
key_type: number
|
||||||
|
cipher_type: number
|
||||||
|
key_length: number
|
||||||
|
key: string
|
||||||
|
}[] {
|
||||||
|
try {
|
||||||
|
const parser = new XMLParser({
|
||||||
|
removeNSPrefix: true
|
||||||
|
})
|
||||||
|
const result = parser.parse(license)
|
||||||
|
|
||||||
|
let licenses =
|
||||||
|
result['Envelope']['Body']['AcquireLicenseResponse'][
|
||||||
|
'AcquireLicenseResult'
|
||||||
|
]['Response']['LicenseResponse']['Licenses']['License']
|
||||||
|
|
||||||
|
if (!Array.isArray(licenses)) {
|
||||||
|
licenses = [licenses]
|
||||||
|
}
|
||||||
|
|
||||||
|
var keys = []
|
||||||
|
|
||||||
|
for (const licenseElement of licenses) {
|
||||||
|
for (const key of XMRLicense.loads(
|
||||||
|
licenseElement
|
||||||
|
).get_content_keys()) {
|
||||||
|
if (getCipherType(key.cipher_type) === CipherType.ECC256) {
|
||||||
|
keys.push(
|
||||||
|
new Key(
|
||||||
|
this.fixUUID(key.key_id),
|
||||||
|
key.key_type,
|
||||||
|
key.cipher_type,
|
||||||
|
key.key_length,
|
||||||
|
this._decryptEcc256Key(key.encrypted_key)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Unable to parse license, ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fixUUID(data: Buffer): Buffer {
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from(data.subarray(0, 4).reverse()),
|
||||||
|
Buffer.from(data.subarray(4, 6).reverse()),
|
||||||
|
Buffer.from(data.subarray(6, 8).reverse()),
|
||||||
|
data.subarray(8, 16)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
117
modules/playready/device.ts
Normal file
117
modules/playready/device.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { Parser } from 'binary-parser-encoder'
|
||||||
|
import { CertificateChain } from './bcert'
|
||||||
|
import ECCKey from './ecc_key'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
|
||||||
|
type RawDeviceV2 = {
|
||||||
|
signature: string
|
||||||
|
version: number
|
||||||
|
group_certificate_length: number
|
||||||
|
group_certificate: Buffer
|
||||||
|
encryption_key: Buffer
|
||||||
|
signing_key: Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeviceStructs {
|
||||||
|
static magic = 'PRD'
|
||||||
|
|
||||||
|
static v1 = new Parser()
|
||||||
|
.string('signature', { length: 3, assert: DeviceStructs.magic })
|
||||||
|
.uint8('version')
|
||||||
|
.uint32('group_key_length')
|
||||||
|
.buffer('group_key', { length: 'group_key_length' })
|
||||||
|
.uint32('group_certificate_length')
|
||||||
|
.buffer('group_certificate', { length: 'group_certificate_length' })
|
||||||
|
|
||||||
|
static v2 = new Parser()
|
||||||
|
.string('signature', { length: 3, assert: DeviceStructs.magic })
|
||||||
|
.uint8('version')
|
||||||
|
.uint32('group_certificate_length')
|
||||||
|
.buffer('group_certificate', { length: 'group_certificate_length' })
|
||||||
|
.buffer('encryption_key', { length: 96 })
|
||||||
|
.buffer('signing_key', { length: 96 })
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Device {
|
||||||
|
static CURRENT_STRUCT = DeviceStructs.v2
|
||||||
|
|
||||||
|
group_certificate: CertificateChain
|
||||||
|
encryption_key: ECCKey
|
||||||
|
signing_key: ECCKey
|
||||||
|
security_level: number
|
||||||
|
|
||||||
|
constructor(parsedData: RawDeviceV2) {
|
||||||
|
this.group_certificate = CertificateChain.loads(
|
||||||
|
parsedData.group_certificate
|
||||||
|
)
|
||||||
|
this.encryption_key = ECCKey.loads(parsedData.encryption_key)
|
||||||
|
this.signing_key = ECCKey.loads(parsedData.signing_key)
|
||||||
|
this.security_level = this.group_certificate.get_security_level()
|
||||||
|
}
|
||||||
|
|
||||||
|
static loads(data: Buffer): Device {
|
||||||
|
const parsedData = Device.CURRENT_STRUCT.parse(data)
|
||||||
|
return new Device(parsedData)
|
||||||
|
}
|
||||||
|
|
||||||
|
static load(filePath: string): Device {
|
||||||
|
const data = fs.readFileSync(filePath)
|
||||||
|
return Device.loads(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
dumps(): Buffer {
|
||||||
|
const groupCertBytes = this.group_certificate.dumps()
|
||||||
|
const encryptionKeyBytes = this.encryption_key.dumps()
|
||||||
|
const signingKeyBytes = this.signing_key.dumps()
|
||||||
|
|
||||||
|
const buildData = {
|
||||||
|
signature: DeviceStructs.magic,
|
||||||
|
version: 2,
|
||||||
|
group_certificate_length: groupCertBytes.length,
|
||||||
|
group_certificate: groupCertBytes,
|
||||||
|
encryption_key: encryptionKeyBytes,
|
||||||
|
signing_key: signingKeyBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
return Device.CURRENT_STRUCT.encode(buildData)
|
||||||
|
}
|
||||||
|
|
||||||
|
dump(filePath: string): void {
|
||||||
|
const data = this.dumps()
|
||||||
|
fs.writeFileSync(filePath, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
get_name(): string {
|
||||||
|
const name = `${this.group_certificate.get_name()}_sl${this.security_level}`
|
||||||
|
return name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device V2 disabled because unstable provisioning
|
||||||
|
// export class Device {
|
||||||
|
// group_certificate: CertificateChain
|
||||||
|
// encryption_key: ECCKey
|
||||||
|
// signing_key: ECCKey
|
||||||
|
// security_level: number
|
||||||
|
|
||||||
|
// constructor(group_certificate: Buffer, group_key: Buffer) {
|
||||||
|
// this.group_certificate = CertificateChain.loads(group_certificate)
|
||||||
|
|
||||||
|
// this.encryption_key = ECCKey.generate()
|
||||||
|
// this.signing_key = ECCKey.generate()
|
||||||
|
|
||||||
|
// this.security_level = this.group_certificate.get_security_level()
|
||||||
|
|
||||||
|
// const new_certificate = Certificate.new_key_cert(
|
||||||
|
// randomBytes(16),
|
||||||
|
// this.group_certificate.get_security_level(),
|
||||||
|
// randomBytes(16),
|
||||||
|
// this.signing_key,
|
||||||
|
// this.encryption_key,
|
||||||
|
// ECCKey.loads(group_key),
|
||||||
|
// this.group_certificate
|
||||||
|
// )
|
||||||
|
|
||||||
|
// this.group_certificate.prepend(new_certificate)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
93
modules/playready/ecc_key.ts
Normal file
93
modules/playready/ecc_key.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import elliptic from 'elliptic'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
|
||||||
|
export default class ECCKey {
|
||||||
|
keyPair: elliptic.ec.KeyPair
|
||||||
|
|
||||||
|
constructor(keyPair: elliptic.ec.KeyPair) {
|
||||||
|
this.keyPair = keyPair
|
||||||
|
}
|
||||||
|
|
||||||
|
static generate(): ECCKey {
|
||||||
|
const EC = new elliptic.ec('p256')
|
||||||
|
const keyPair = EC.genKeyPair()
|
||||||
|
return new ECCKey(keyPair)
|
||||||
|
}
|
||||||
|
|
||||||
|
static construct(privateKey: Buffer | string | number): ECCKey {
|
||||||
|
if (Buffer.isBuffer(privateKey)) {
|
||||||
|
privateKey = privateKey.toString('hex')
|
||||||
|
} else if (typeof privateKey === 'number') {
|
||||||
|
privateKey = privateKey.toString(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
const EC = new elliptic.ec('p256')
|
||||||
|
const keyPair = EC.keyFromPrivate(privateKey, 'hex')
|
||||||
|
|
||||||
|
return new ECCKey(keyPair)
|
||||||
|
}
|
||||||
|
|
||||||
|
static loads(data: string | Buffer): ECCKey {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
data = Buffer.from(data, 'base64')
|
||||||
|
}
|
||||||
|
if (!Buffer.isBuffer(data)) {
|
||||||
|
throw new Error(`Expecting Bytes or Base64 input, got ${data}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length !== 96 && data.length !== 32) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid data length. Expecting 96 or 32 bytes, got ${data.length}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateKey = data.subarray(0, 32)
|
||||||
|
return ECCKey.construct(privateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static load(filePath: string): ECCKey {
|
||||||
|
const data = fs.readFileSync(filePath)
|
||||||
|
return ECCKey.loads(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
dumps(): Buffer {
|
||||||
|
return Buffer.concat([this.privateBytes(), this.publicBytes()])
|
||||||
|
}
|
||||||
|
|
||||||
|
dump(filePath: string): void {
|
||||||
|
fs.writeFileSync(filePath, this.dumps())
|
||||||
|
}
|
||||||
|
|
||||||
|
getPoint(): { x: string; y: string } {
|
||||||
|
const publicKey = this.keyPair.getPublic()
|
||||||
|
return {
|
||||||
|
x: publicKey.getX().toString('hex'),
|
||||||
|
y: publicKey.getY().toString('hex')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
privateBytes(): Buffer {
|
||||||
|
const privateKey = this.keyPair.getPrivate()
|
||||||
|
return Buffer.from(privateKey.toArray('be', 32))
|
||||||
|
}
|
||||||
|
|
||||||
|
privateSha256Digest(): Buffer {
|
||||||
|
const hash = createHash('sha256')
|
||||||
|
hash.update(this.privateBytes())
|
||||||
|
return hash.digest()
|
||||||
|
}
|
||||||
|
|
||||||
|
publicBytes(): Buffer {
|
||||||
|
const publicKey = this.keyPair.getPublic()
|
||||||
|
const x = publicKey.getX().toArray('be', 32)
|
||||||
|
const y = publicKey.getY().toArray('be', 32)
|
||||||
|
return Buffer.concat([Buffer.from(x), Buffer.from(y)])
|
||||||
|
}
|
||||||
|
|
||||||
|
publicSha256Digest(): Buffer {
|
||||||
|
const hash = createHash('sha256')
|
||||||
|
hash.update(this.publicBytes())
|
||||||
|
return hash.digest()
|
||||||
|
}
|
||||||
|
}
|
||||||
45
modules/playready/elgamal.ts
Normal file
45
modules/playready/elgamal.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { ec as EC } from 'elliptic'
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
|
import BN from 'bn.js'
|
||||||
|
|
||||||
|
export interface Point {
|
||||||
|
getY(): BN
|
||||||
|
getX(): BN
|
||||||
|
add(point: Point): Point
|
||||||
|
mul(n: BN | bigint | number): Point
|
||||||
|
neg(): Point
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ElGamal {
|
||||||
|
curve: EC
|
||||||
|
|
||||||
|
constructor(curve: EC) {
|
||||||
|
this.curve = curve
|
||||||
|
}
|
||||||
|
|
||||||
|
static toBytes(n: BN): Uint8Array {
|
||||||
|
const byteArray = n.toString(16).padStart(2, '0')
|
||||||
|
if (byteArray.length % 2 !== 0) {
|
||||||
|
return Uint8Array.from(Buffer.from('0' + byteArray, 'hex'))
|
||||||
|
}
|
||||||
|
return Uint8Array.from(Buffer.from(byteArray, 'hex'))
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypt(messagePoint: Point, publicKey: Point): [Point, Point] {
|
||||||
|
const ephemeralKey = new BN(randomBytes(32).toString('hex'), 16).mod(
|
||||||
|
this.curve.n!
|
||||||
|
)
|
||||||
|
const ephemeralKeyBigInt = BigInt(ephemeralKey.toString(10))
|
||||||
|
const point1 = this.curve.g.mul(ephemeralKeyBigInt)
|
||||||
|
const point2 = messagePoint.add(publicKey.mul(ephemeralKeyBigInt))
|
||||||
|
|
||||||
|
return [point1, point2]
|
||||||
|
}
|
||||||
|
|
||||||
|
static decrypt(encrypted: [Point, Point], privateKey: BN): Point {
|
||||||
|
const [point1, point2] = encrypted
|
||||||
|
const sharedSecret = point1.mul(privateKey)
|
||||||
|
const decryptedMessage = point2.add(sharedSecret.neg())
|
||||||
|
return decryptedMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
70
modules/playready/key.ts
Normal file
70
modules/playready/key.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
export enum KeyType {
|
||||||
|
Invalid = 0x0000,
|
||||||
|
AES128CTR = 0x0001,
|
||||||
|
RC4 = 0x0002,
|
||||||
|
AES128ECB = 0x0003,
|
||||||
|
Cocktail = 0x0004,
|
||||||
|
AESCBC = 0x0005,
|
||||||
|
UNKNOWN = 0xffff
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKeyType(value: number): KeyType {
|
||||||
|
switch (value) {
|
||||||
|
case KeyType.Invalid:
|
||||||
|
case KeyType.AES128CTR:
|
||||||
|
case KeyType.RC4:
|
||||||
|
case KeyType.AES128ECB:
|
||||||
|
case KeyType.Cocktail:
|
||||||
|
case KeyType.AESCBC:
|
||||||
|
return value
|
||||||
|
default:
|
||||||
|
return KeyType.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CipherType {
|
||||||
|
Invalid = 0x0000,
|
||||||
|
RSA128 = 0x0001,
|
||||||
|
ChainedLicense = 0x0002,
|
||||||
|
ECC256 = 0x0003,
|
||||||
|
ECCforScalableLicenses = 0x0004,
|
||||||
|
Scalable = 0x0005,
|
||||||
|
UNKNOWN = 0xffff
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCipherType(value: number): CipherType {
|
||||||
|
switch (value) {
|
||||||
|
case CipherType.Invalid:
|
||||||
|
case CipherType.RSA128:
|
||||||
|
case CipherType.ChainedLicense:
|
||||||
|
case CipherType.ECC256:
|
||||||
|
case CipherType.ECCforScalableLicenses:
|
||||||
|
case CipherType.Scalable:
|
||||||
|
return value
|
||||||
|
default:
|
||||||
|
return CipherType.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Key {
|
||||||
|
key_id: string
|
||||||
|
key_type: KeyType
|
||||||
|
cipher_type: CipherType
|
||||||
|
key_length: number
|
||||||
|
key: string
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
key_id: Buffer | string,
|
||||||
|
key_type: number,
|
||||||
|
cipher_type: number,
|
||||||
|
key_length: number,
|
||||||
|
key: Buffer | string
|
||||||
|
) {
|
||||||
|
this.key_id = Buffer.isBuffer(key_id) ? key_id.toString('hex') : key_id
|
||||||
|
|
||||||
|
this.key_type = getKeyType(key_type)
|
||||||
|
this.cipher_type = getCipherType(cipher_type)
|
||||||
|
this.key_length = key_length
|
||||||
|
this.key = Buffer.isBuffer(key) ? key.toString('hex') : key
|
||||||
|
}
|
||||||
|
}
|
||||||
131
modules/playready/pssh.ts
Normal file
131
modules/playready/pssh.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { Parser } from 'binary-parser'
|
||||||
|
import { Buffer } from 'buffer'
|
||||||
|
import WRMHeader from './wrmheader'
|
||||||
|
|
||||||
|
const SYSTEM_ID = Buffer.from('9a04f07998404286ab92e65be0885f95', 'hex')
|
||||||
|
|
||||||
|
const PSSHBox = new Parser()
|
||||||
|
|
||||||
|
.uint32('length')
|
||||||
|
.string('pssh', { length: 4, assert: 'pssh' })
|
||||||
|
.uint32('fullbox')
|
||||||
|
.buffer('system_id', { length: 16 })
|
||||||
|
.uint32('data_length')
|
||||||
|
.buffer('data', { length: 'data_length' })
|
||||||
|
|
||||||
|
const PlayreadyObject = new Parser()
|
||||||
|
.endianess('little')
|
||||||
|
.uint16('type')
|
||||||
|
.uint16('length')
|
||||||
|
.choice('data', {
|
||||||
|
tag: 'type',
|
||||||
|
choices: {
|
||||||
|
1: new Parser().string('data', {
|
||||||
|
length: 'length',
|
||||||
|
encoding: 'utf16le'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
defaultChoice: new Parser().buffer('data', { length: 'length' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const PlayreadyHeader = new Parser()
|
||||||
|
.endianess('little')
|
||||||
|
.uint32('length')
|
||||||
|
.uint16('record_count')
|
||||||
|
.array('records', {
|
||||||
|
length: 'record_count',
|
||||||
|
type: PlayreadyObject
|
||||||
|
})
|
||||||
|
|
||||||
|
function isPlayreadyPsshBox(data: Buffer): boolean {
|
||||||
|
if (data.length < 28) return false // Ensure enough length
|
||||||
|
return data.subarray(12, 28).equals(SYSTEM_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUtf16(data: Buffer): boolean {
|
||||||
|
for (let i = 1; i < data.length; i += 2) {
|
||||||
|
if (data[i] !== 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function* getWrmHeaders(wrm_header: any): IterableIterator<string> {
|
||||||
|
for (const record of wrm_header.records) {
|
||||||
|
if (record.type === 1 && typeof record.data === 'string') {
|
||||||
|
yield record.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PSSH {
|
||||||
|
public wrm_headers: string[]
|
||||||
|
|
||||||
|
constructor(data: string | Buffer) {
|
||||||
|
if (!data) {
|
||||||
|
throw new Error('Data must not be empty')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
try {
|
||||||
|
data = Buffer.from(data, 'base64')
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Could not decode data as Base64: ${e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isPlayreadyPsshBox(data)) {
|
||||||
|
const pssh_box = PSSHBox.parse(data)
|
||||||
|
const psshData = pssh_box.data
|
||||||
|
|
||||||
|
if (isUtf16(psshData)) {
|
||||||
|
this.wrm_headers = [psshData.toString('utf16le')]
|
||||||
|
} else if (isUtf16(psshData.subarray(6))) {
|
||||||
|
this.wrm_headers = [
|
||||||
|
psshData.subarray(6).toString('utf16le')
|
||||||
|
]
|
||||||
|
} else if (isUtf16(psshData.subarray(10))) {
|
||||||
|
this.wrm_headers = [
|
||||||
|
psshData.subarray(10).toString('utf16le')
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
const playready_header = PlayreadyHeader.parse(psshData)
|
||||||
|
this.wrm_headers = Array.from(
|
||||||
|
getWrmHeaders(playready_header)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isUtf16(data)) {
|
||||||
|
this.wrm_headers = [data.toString('utf16le')]
|
||||||
|
} else if (isUtf16(data.subarray(6))) {
|
||||||
|
this.wrm_headers = [data.subarray(6).toString('utf16le')]
|
||||||
|
} else if (isUtf16(data.subarray(10))) {
|
||||||
|
this.wrm_headers = [data.subarray(10).toString('utf16le')]
|
||||||
|
} else {
|
||||||
|
const playready_header = PlayreadyHeader.parse(data)
|
||||||
|
this.wrm_headers = Array.from(
|
||||||
|
getWrmHeaders(playready_header)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
'Could not parse data as a PSSH Box nor a PlayReadyHeader'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header downgrade
|
||||||
|
public get_wrm_headers(downgrade_to_v4: boolean = false): string[] {
|
||||||
|
return this.wrm_headers.map(
|
||||||
|
downgrade_to_v4 ? this._downgrade : (_) => _
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private _downgrade(wrm_header: string): string {
|
||||||
|
const header = new WRMHeader(wrm_header)
|
||||||
|
return header.to_v4_0_0_0()
|
||||||
|
}
|
||||||
|
}
|
||||||
112
modules/playready/wrmheader.ts
Normal file
112
modules/playready/wrmheader.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { XMLParser } from 'fast-xml-parser'
|
||||||
|
|
||||||
|
export class SignedKeyID {
|
||||||
|
constructor(
|
||||||
|
public alg_id: string,
|
||||||
|
public value: string,
|
||||||
|
public checksum?: string
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Version = '4.0.0.0' | '4.1.0.0' | '4.2.0.0' | '4.3.0.0' | 'UNKNOWN'
|
||||||
|
|
||||||
|
export type ReturnStructure = [
|
||||||
|
SignedKeyID[],
|
||||||
|
string | null,
|
||||||
|
string | null,
|
||||||
|
string | null
|
||||||
|
]
|
||||||
|
|
||||||
|
interface ParsedWRMHeader {
|
||||||
|
WRMHEADER: {
|
||||||
|
'@_version': string
|
||||||
|
DATA?: any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class WRMHeader {
|
||||||
|
private header: ParsedWRMHeader['WRMHEADER']
|
||||||
|
version: Version
|
||||||
|
|
||||||
|
constructor(data: string) {
|
||||||
|
if (!data) throw new Error('Data must not be empty')
|
||||||
|
|
||||||
|
const parser = new XMLParser({
|
||||||
|
ignoreAttributes: false,
|
||||||
|
removeNSPrefix: true,
|
||||||
|
attributeNamePrefix: '@_'
|
||||||
|
})
|
||||||
|
const parsed = parser.parse(data) as ParsedWRMHeader
|
||||||
|
|
||||||
|
if (!parsed.WRMHEADER) throw new Error('Data is not a valid WRMHEADER')
|
||||||
|
|
||||||
|
this.header = parsed.WRMHEADER
|
||||||
|
this.version = WRMHeader.fromString(this.header['@_version'])
|
||||||
|
}
|
||||||
|
|
||||||
|
private static fromString(value: string): Version {
|
||||||
|
if (['4.0.0.0', '4.1.0.0', '4.2.0.0', '4.3.0.0'].includes(value)) {
|
||||||
|
return value as Version
|
||||||
|
}
|
||||||
|
return 'UNKNOWN'
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ensureList(element: any): any[] {
|
||||||
|
return Array.isArray(element) ? element : [element]
|
||||||
|
}
|
||||||
|
|
||||||
|
to_v4_0_0_0(): string {
|
||||||
|
const [key_ids, la_url, lui_url, ds_id] = this.readAttributes()
|
||||||
|
if (key_ids.length === 0) throw new Error('No Key IDs available')
|
||||||
|
const key_id = key_ids[0]
|
||||||
|
return `<WRMHEADER xmlns="http://schemas.microsoft.com/DRM/2007/03/PlayReadyHeader" version="4.0.0.0"><DATA><PROTECTINFO><KEYLEN>16</KEYLEN><ALGID>AESCTR</ALGID></PROTECTINFO><KID>${key_id.value}</KID>${la_url ? `<LA_URL>${la_url}</LA_URL>` : ''}${lui_url ? `<LUI_URL>${lui_url}</LUI_URL>` : ''}${ds_id ? `<DS_ID>${ds_id}</DS_ID>` : ''}${key_id.checksum ? `<CHECKSUM>${key_id.checksum}</CHECKSUM>` : ''}</DATA></WRMHEADER>`
|
||||||
|
}
|
||||||
|
|
||||||
|
readAttributes(): ReturnStructure {
|
||||||
|
const data = this.header.DATA
|
||||||
|
if (!data)
|
||||||
|
throw new Error(
|
||||||
|
'Not a valid PlayReady Header Record, WRMHEADER/DATA required'
|
||||||
|
)
|
||||||
|
switch (this.version) {
|
||||||
|
case '4.0.0.0':
|
||||||
|
return WRMHeader.read_v4(data)
|
||||||
|
case '4.1.0.0':
|
||||||
|
case '4.2.0.0':
|
||||||
|
case '4.3.0.0':
|
||||||
|
return WRMHeader.read_vX(data)
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported version: ${this.version}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static read_v4(data: any): ReturnStructure {
|
||||||
|
const protectInfo = data.PROTECTINFO
|
||||||
|
return [
|
||||||
|
[new SignedKeyID(protectInfo.ALGID, data.KID, data.CHECKSUM)],
|
||||||
|
data.LA_URL || null,
|
||||||
|
data.LUI_URL || null,
|
||||||
|
data.DS_ID || null
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static read_vX(data: any): ReturnStructure {
|
||||||
|
const protectInfo = data.PROTECTINFO
|
||||||
|
const signedKeyIDs: SignedKeyID[] = protectInfo?.KID
|
||||||
|
? WRMHeader.ensureList(protectInfo.KID).map(
|
||||||
|
(kid: any) =>
|
||||||
|
new SignedKeyID(
|
||||||
|
kid['@_ALGID'] || '',
|
||||||
|
kid['@_VALUE'],
|
||||||
|
kid['@_CHECKSUM']
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
return [
|
||||||
|
signedKeyIDs,
|
||||||
|
data.LA_URL || null,
|
||||||
|
data.LUI_URL || null,
|
||||||
|
data.DS_ID || null
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
45
modules/playready/xml_key.ts
Normal file
45
modules/playready/xml_key.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import BN from 'bn.js'
|
||||||
|
import { ec as EC } from 'elliptic'
|
||||||
|
import ECCKey from './ecc_key'
|
||||||
|
import ElGamal, { Point } from './elgamal'
|
||||||
|
|
||||||
|
export default class XmlKey {
|
||||||
|
private _sharedPoint: ECCKey
|
||||||
|
public sharedKeyX: BN
|
||||||
|
public sharedKeyY: BN
|
||||||
|
public _shared_key_x_bytes: Uint8Array
|
||||||
|
public aesIv: Uint8Array
|
||||||
|
public aesKey: Uint8Array
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._sharedPoint = ECCKey.generate()
|
||||||
|
this.sharedKeyX = this._sharedPoint.keyPair.getPublic().getX()
|
||||||
|
this.sharedKeyY = this._sharedPoint.keyPair.getPublic().getY()
|
||||||
|
this._shared_key_x_bytes = ElGamal.toBytes(this.sharedKeyX)
|
||||||
|
this.aesIv = this._shared_key_x_bytes.subarray(0, 16)
|
||||||
|
this.aesKey = this._shared_key_x_bytes.subarray(16, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
getPoint(curve: EC): Point {
|
||||||
|
return curve.curve.point(this.sharedKeyX, this.sharedKeyY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make it more undetectable (not working right now)
|
||||||
|
// import { ec as EC } from 'elliptic'
|
||||||
|
// import { Point } from './elgamal.js'
|
||||||
|
// import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
// export default class XmlKey {
|
||||||
|
// public aesIv: Uint8Array;
|
||||||
|
// public aesKey: Uint8Array;
|
||||||
|
|
||||||
|
// constructor() {
|
||||||
|
// this.aesIv = randomBytes(16);
|
||||||
|
// this.aesKey = randomBytes(16);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// getPoint(curve: EC): Point {
|
||||||
|
// return curve.curve.point(this.aesIv, this.aesKey)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
270
modules/playready/xmrlicense.ts
Normal file
270
modules/playready/xmrlicense.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
import { Parser } from 'binary-parser'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
|
||||||
|
export class XMRLicenseStructs {
|
||||||
|
static PlayEnablerType = new Parser().buffer('player_enabler_type', {
|
||||||
|
length: 16
|
||||||
|
})
|
||||||
|
|
||||||
|
static DomainRestrictionObject = new Parser()
|
||||||
|
.buffer('account_id', { length: 16 })
|
||||||
|
.uint32('revision')
|
||||||
|
|
||||||
|
static IssueDateObject = new Parser().uint32('issue_date')
|
||||||
|
|
||||||
|
static RevInfoVersionObject = new Parser().uint32('sequence')
|
||||||
|
|
||||||
|
static SecurityLevelObject = new Parser().uint16('minimum_security_level')
|
||||||
|
|
||||||
|
static EmbeddedLicenseSettingsObject = new Parser().uint16('indicator')
|
||||||
|
|
||||||
|
static ECCKeyObject = new Parser()
|
||||||
|
.uint16('curve_type')
|
||||||
|
.uint16('key_length')
|
||||||
|
.buffer('key', {
|
||||||
|
length: function () {
|
||||||
|
return (this as any).key_length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
static SignatureObject = new Parser()
|
||||||
|
.uint16('signature_type')
|
||||||
|
.uint16('signature_data_length')
|
||||||
|
.buffer('signature_data', {
|
||||||
|
length: function () {
|
||||||
|
return (this as any).signature_data_length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
static ContentKeyObject = new Parser()
|
||||||
|
.buffer('key_id', { length: 16 })
|
||||||
|
.uint16('key_type')
|
||||||
|
.uint16('cipher_type')
|
||||||
|
.uint16('key_length')
|
||||||
|
.buffer('encrypted_key', {
|
||||||
|
length: function () {
|
||||||
|
return (this as any).key_length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
static RightsSettingsObject = new Parser().uint16('rights')
|
||||||
|
|
||||||
|
static OutputProtectionLevelRestrictionObject = new Parser()
|
||||||
|
.uint16('minimum_compressed_digital_video_opl')
|
||||||
|
.uint16('minimum_uncompressed_digital_video_opl')
|
||||||
|
.uint16('minimum_analog_video_opl')
|
||||||
|
.uint16('minimum_digital_compressed_audio_opl')
|
||||||
|
.uint16('minimum_digital_uncompressed_audio_opl')
|
||||||
|
|
||||||
|
static ExpirationRestrictionObject = new Parser()
|
||||||
|
.uint32('begin_date')
|
||||||
|
.uint32('end_date')
|
||||||
|
|
||||||
|
static RemovalDateObject = new Parser().uint32('removal_date')
|
||||||
|
|
||||||
|
static UplinkKIDObject = new Parser()
|
||||||
|
.buffer('uplink_kid', { length: 16 })
|
||||||
|
.uint16('chained_checksum_type')
|
||||||
|
.uint16('chained_checksum_length')
|
||||||
|
.buffer('chained_checksum', {
|
||||||
|
length: function () {
|
||||||
|
return (this as any).chained_checksum_length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
static AnalogVideoOutputConfigurationRestriction = new Parser()
|
||||||
|
.buffer('video_output_protection_id', { length: 16 })
|
||||||
|
.buffer('binary_configuration_data', {
|
||||||
|
length: function () {
|
||||||
|
return (this as any).$parent.length - 16
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
static DigitalVideoOutputRestrictionObject = new Parser()
|
||||||
|
.buffer('video_output_protection_id', { length: 16 })
|
||||||
|
.buffer('binary_configuration_data', {
|
||||||
|
length: function () {
|
||||||
|
return (this as any).$parent.length - 16
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
static DigitalAudioOutputRestrictionObject = new Parser()
|
||||||
|
.buffer('audio_output_protection_id', { length: 16 })
|
||||||
|
.buffer('binary_configuration_data', {
|
||||||
|
length: function () {
|
||||||
|
return (this as any).$parent.length - 16
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
static PolicyMetadataObject = new Parser()
|
||||||
|
.buffer('metadata_type', { length: 16 })
|
||||||
|
.buffer('policy_data', {
|
||||||
|
length: function () {
|
||||||
|
return (this as any).$parent.length - 16
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
static SecureStopRestrictionObject = new Parser().buffer('metering_id', {
|
||||||
|
length: 16
|
||||||
|
})
|
||||||
|
|
||||||
|
static MeteringRestrictionObject = new Parser().buffer('metering_id', {
|
||||||
|
length: 16
|
||||||
|
})
|
||||||
|
|
||||||
|
static ExpirationAfterFirstPlayRestrictionObject = new Parser().uint32(
|
||||||
|
'seconds'
|
||||||
|
)
|
||||||
|
|
||||||
|
static GracePeriodObject = new Parser().uint32('grace_period')
|
||||||
|
|
||||||
|
static SourceIdObject = new Parser().uint32('source_id')
|
||||||
|
|
||||||
|
static AuxiliaryKey = new Parser()
|
||||||
|
.uint32('location')
|
||||||
|
.buffer('key', { length: 16 })
|
||||||
|
|
||||||
|
static AuxiliaryKeysObject = new Parser()
|
||||||
|
.uint16('count')
|
||||||
|
.array('auxiliary_keys', {
|
||||||
|
length: 'count',
|
||||||
|
type: XMRLicenseStructs.AuxiliaryKey
|
||||||
|
})
|
||||||
|
|
||||||
|
static UplinkKeyObject3 = new Parser()
|
||||||
|
.buffer('uplink_key_id', { length: 16 })
|
||||||
|
.uint16('chained_length')
|
||||||
|
.buffer('checksum', {
|
||||||
|
length: function () {
|
||||||
|
return (this as any).chained_length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.uint16('count')
|
||||||
|
.array('entries', {
|
||||||
|
length: 'count',
|
||||||
|
type: new Parser().uint32('entry')
|
||||||
|
})
|
||||||
|
|
||||||
|
static CopyEnablerObject = new Parser().buffer('copy_enabler_type', {
|
||||||
|
length: 16
|
||||||
|
})
|
||||||
|
|
||||||
|
static CopyCountRestrictionObject = new Parser().uint32('count')
|
||||||
|
|
||||||
|
static MoveObject = new Parser().uint32('minimum_move_protection_level')
|
||||||
|
|
||||||
|
static XMRObject = (): Parser =>
|
||||||
|
new Parser()
|
||||||
|
.namely('self')
|
||||||
|
.int16('flags')
|
||||||
|
.int16('type')
|
||||||
|
.int32('length')
|
||||||
|
.choice('data', {
|
||||||
|
tag: 'type',
|
||||||
|
choices: {
|
||||||
|
0x0005: XMRLicenseStructs.OutputProtectionLevelRestrictionObject,
|
||||||
|
0x0008: XMRLicenseStructs.AnalogVideoOutputConfigurationRestriction,
|
||||||
|
0x000a: XMRLicenseStructs.ContentKeyObject,
|
||||||
|
0x000b: XMRLicenseStructs.SignatureObject,
|
||||||
|
0x000d: XMRLicenseStructs.RightsSettingsObject,
|
||||||
|
0x0012: XMRLicenseStructs.ExpirationRestrictionObject,
|
||||||
|
0x0013: XMRLicenseStructs.IssueDateObject,
|
||||||
|
0x0016: XMRLicenseStructs.MeteringRestrictionObject,
|
||||||
|
0x001a: XMRLicenseStructs.GracePeriodObject,
|
||||||
|
0x0022: XMRLicenseStructs.SourceIdObject,
|
||||||
|
0x002a: XMRLicenseStructs.ECCKeyObject,
|
||||||
|
0x002c: XMRLicenseStructs.PolicyMetadataObject,
|
||||||
|
0x0029: XMRLicenseStructs.DomainRestrictionObject,
|
||||||
|
0x0030: XMRLicenseStructs.ExpirationAfterFirstPlayRestrictionObject,
|
||||||
|
0x0031: XMRLicenseStructs.DigitalAudioOutputRestrictionObject,
|
||||||
|
0x0032: XMRLicenseStructs.RevInfoVersionObject,
|
||||||
|
0x0033: XMRLicenseStructs.EmbeddedLicenseSettingsObject,
|
||||||
|
0x0034: XMRLicenseStructs.SecurityLevelObject,
|
||||||
|
0x0037: XMRLicenseStructs.MoveObject,
|
||||||
|
0x0039: XMRLicenseStructs.PlayEnablerType,
|
||||||
|
0x003a: XMRLicenseStructs.CopyEnablerObject,
|
||||||
|
0x003b: XMRLicenseStructs.UplinkKIDObject,
|
||||||
|
0x003d: XMRLicenseStructs.CopyCountRestrictionObject,
|
||||||
|
0x0050: XMRLicenseStructs.RemovalDateObject,
|
||||||
|
0x0051: XMRLicenseStructs.AuxiliaryKeysObject,
|
||||||
|
0x0052: XMRLicenseStructs.UplinkKeyObject3,
|
||||||
|
0x005a: XMRLicenseStructs.SecureStopRestrictionObject,
|
||||||
|
0x0059: XMRLicenseStructs.DigitalVideoOutputRestrictionObject
|
||||||
|
},
|
||||||
|
defaultChoice: 'self'
|
||||||
|
})
|
||||||
|
|
||||||
|
static XmrLicense = new Parser()
|
||||||
|
.useContextVars()
|
||||||
|
.buffer('signature', { length: 4 })
|
||||||
|
.int32('xmr_version')
|
||||||
|
.buffer('rights_id', { length: 16 })
|
||||||
|
.array('containers', {
|
||||||
|
type: XMRLicenseStructs.XMRObject(),
|
||||||
|
readUntil: 'eof'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export class XMRLicense extends XMRLicenseStructs {
|
||||||
|
parsed: any
|
||||||
|
_LICENSE: Parser
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
parsed_license: any,
|
||||||
|
license_obj: Parser = XMRLicenseStructs.XmrLicense
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
this.parsed = parsed_license
|
||||||
|
this._LICENSE = license_obj
|
||||||
|
}
|
||||||
|
|
||||||
|
static loads(data: string | Buffer): XMRLicense {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
data = Buffer.from(data, 'base64')
|
||||||
|
}
|
||||||
|
if (!Buffer.isBuffer(data)) {
|
||||||
|
throw new Error(`Expecting Bytes or Base64 input, got ${data}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const licence = XMRLicenseStructs.XmrLicense
|
||||||
|
const parsed_license = licence.parse(data)
|
||||||
|
return new XMRLicense(parsed_license, licence)
|
||||||
|
}
|
||||||
|
|
||||||
|
static load(filePath: string): XMRLicense {
|
||||||
|
if (typeof filePath !== 'string') {
|
||||||
|
throw new Error(`Expecting path string, got ${filePath}`)
|
||||||
|
}
|
||||||
|
const data = fs.readFileSync(filePath)
|
||||||
|
return XMRLicense.loads(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
dumps(): Buffer {
|
||||||
|
return this._LICENSE.parse(this.parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct(): Parser {
|
||||||
|
return this._LICENSE
|
||||||
|
}
|
||||||
|
|
||||||
|
private _locate(container: any): any {
|
||||||
|
if (container.flags === 2 || container.flags === 3) {
|
||||||
|
return this._locate(container.data)
|
||||||
|
} else {
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*get_object(type_: number): Generator<any> {
|
||||||
|
for (const obj of this.parsed.containers) {
|
||||||
|
const container = this._locate(obj)
|
||||||
|
if (container.type === type_) {
|
||||||
|
yield container.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get_content_keys(): Generator<any> {
|
||||||
|
return this.get_object(0x000a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
import { KeyContainer, Session } from './license';
|
|
||||||
import fs from 'fs';
|
|
||||||
import { console } from './log';
|
|
||||||
import got from 'got';
|
|
||||||
import { workingDir } from './module.cfg-loader';
|
|
||||||
import path from 'path';
|
|
||||||
import { ReadError, Response } from 'got';
|
|
||||||
|
|
||||||
//read cdm files located in the same directory
|
|
||||||
let privateKey: Buffer = Buffer.from([]), identifierBlob: Buffer = Buffer.from([]);
|
|
||||||
export let canDecrypt: boolean;
|
|
||||||
try {
|
|
||||||
const files = fs.readdirSync(path.join(workingDir, 'widevine'));
|
|
||||||
files.forEach(function(file) {
|
|
||||||
file = path.join(workingDir, 'widevine', file);
|
|
||||||
const stats = fs.statSync(file);
|
|
||||||
if (stats.size < 1024*8 && stats.isFile()) {
|
|
||||||
const fileContents = fs.readFileSync(file, {'encoding': 'utf8'});
|
|
||||||
if (fileContents.includes('-BEGIN PRIVATE KEY-') || fileContents.includes('-BEGIN RSA PRIVATE KEY-')) {
|
|
||||||
privateKey = fs.readFileSync(file);
|
|
||||||
}
|
|
||||||
if (fileContents.includes('widevine_cdm_version')) {
|
|
||||||
identifierBlob = fs.readFileSync(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (privateKey.length !== 0 && identifierBlob.length !== 0) {
|
|
||||||
canDecrypt = true;
|
|
||||||
} else if (privateKey.length == 0) {
|
|
||||||
console.warn('Private key missing');
|
|
||||||
canDecrypt = false;
|
|
||||||
} else if (identifierBlob.length == 0) {
|
|
||||||
console.warn('Identifier blob missing');
|
|
||||||
canDecrypt = false;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
canDecrypt = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function getKeys(pssh: string | undefined, licenseServer: string, authData: Record<string, string>): Promise<KeyContainer[]> {
|
|
||||||
if (!pssh || !canDecrypt) return [];
|
|
||||||
//pssh found in the mpd manifest
|
|
||||||
const psshBuffer = Buffer.from(
|
|
||||||
pssh,
|
|
||||||
'base64'
|
|
||||||
);
|
|
||||||
|
|
||||||
//Create a new widevine session
|
|
||||||
const session = new Session({ privateKey, identifierBlob }, psshBuffer);
|
|
||||||
|
|
||||||
//Generate license
|
|
||||||
let response;
|
|
||||||
try {
|
|
||||||
response = await got(licenseServer, {
|
|
||||||
method: 'POST',
|
|
||||||
body: session.createLicenseRequest(),
|
|
||||||
headers: authData,
|
|
||||||
responseType: 'text'
|
|
||||||
});
|
|
||||||
} catch(_error){
|
|
||||||
const error = _error as {
|
|
||||||
name: string
|
|
||||||
} & ReadError & {
|
|
||||||
res: Response<unknown>
|
|
||||||
};
|
|
||||||
if(error.response && error.response.statusCode && error.response.statusMessage){
|
|
||||||
console.error(`${error.name} ${error.response.statusCode}: ${error.response.statusMessage}`);
|
|
||||||
} else{
|
|
||||||
console.error(`${error.name}: ${error.code || error.message}`);
|
|
||||||
}
|
|
||||||
if(error.response && !error.res){
|
|
||||||
error.res = error.response;
|
|
||||||
const docTitle = (error.res.body as string).match(/<title>(.*)<\/title>/);
|
|
||||||
if(error.res.body && docTitle){
|
|
||||||
console.error(docTitle[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(error.res && error.res.body && error.response.statusCode
|
|
||||||
&& error.response.statusCode != 404 && error.response.statusCode != 403){
|
|
||||||
console.error('Body:', error.res.body);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.statusCode === 200) {
|
|
||||||
//Parse License and return keys
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(response.body);
|
|
||||||
return session.parseLicense(Buffer.from(json['license'], 'base64'));
|
|
||||||
} catch {
|
|
||||||
return session.parseLicense(response.rawBody);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.info('License request failed:', response.statusMessage, response.body);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -42,9 +42,14 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/xmldom": "^0.1.34",
|
"@types/xmldom": "^0.1.34",
|
||||||
"@yao-pkg/pkg": "^5.12.0",
|
"@yao-pkg/pkg": "^5.12.0",
|
||||||
|
"binary-parser": "^2.2.1",
|
||||||
|
"binary-parser-encoder": "^1.5.3",
|
||||||
|
"bn.js": "^5.2.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"elliptic": "^6.6.1",
|
||||||
"esbuild": "^0.21.5",
|
"esbuild": "^0.21.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
|
"fast-xml-parser": "^4.5.0",
|
||||||
"ffprobe": "^1.1.2",
|
"ffprobe": "^1.1.2",
|
||||||
"fs-extra": "^11.2.0",
|
"fs-extra": "^11.2.0",
|
||||||
"got": "^11.8.6",
|
"got": "^11.8.6",
|
||||||
|
|
@ -63,7 +68,9 @@
|
||||||
"yargs": "^17.7.2"
|
"yargs": "^17.7.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bn.js": "^5.1.6",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/elliptic": "^6.4.18",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/ffprobe": "^1.1.8",
|
"@types/ffprobe": "^1.1.8",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
|
|
|
||||||
0
playready/.gitkeep
Normal file
0
playready/.gitkeep
Normal file
7539
pnpm-lock.yaml
7539
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue