Skip to content

Commit

Permalink
feat: add cli, library, upgrade extension to manifest v3 and new design
Browse files Browse the repository at this point in the history
  • Loading branch information
vitalygashkov committed Oct 27, 2024
1 parent b46b674 commit ca6c9fd
Show file tree
Hide file tree
Showing 79 changed files with 31,118 additions and 340 deletions.
32 changes: 30 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,31 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
.output
stats.html
stats-*.json
.wxt
web-ext.config.ts
dist

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
streamyx-extension.crx
streamyx-extension.pem
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

device_client_id_blob
device_private_key
*.wvd
661 changes: 661 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

48 changes: 40 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,48 @@
# Streamyx Chrome Extension
# azot

Intercept [DRM](https://www.widevine.com/) requests, view logs from [EME](https://w3c.github.io/encrypted-media/) session and copy ready commands for [Streamyx CLI](https://github.com/vitalygashkov/streamyx)
Azot (Russian word for "nitrogen", pronounced `/azо́t/`) is a set of tools (JavaScript library, command-line utility and browser extension) for diagnosing, researching, and pentesting [Google's](https://about.google/) [Widevine](https://www.widevine.com/about) [DRM](https://www.urbandictionary.com/define.php?term=DRM).

## Features

- **Network-independent interception**: everything works even with one-time tokens and custom request body/response format.
- **Custom client support**: WVD v2, device_client_id_blob + device_private_key, client_id.bin + private_key.pem
- **Logging** details from EME events in Developer Tools console of current page
- **Manifest V3** compliant browser extension
- **Converting clients** between formats via CLI
- **Encrypted Media Extensions API** compatibility via `requestMediaKeySystemAccess()` method
- **Runtime agnostic** core: works in Node.js, Bun, Deno, browsers and more
- **Minimal** dependencies

## Installation

1. Enable **Developer Mode** in [Chrome Extensions](chrome://extensions/)
> JavaScript library and command-line tool installation requires pre-installed JavaScript runtime (e.g. Node.js).
### JavaScript library

```bash
npm install azot
```

### Command-line tool

```bash
npm install -g azot
```

### Browser extension

1. Enable **Developer Mode** in [Chrome Extensions](chrome://extensions/) page
2. Click on **Load Unpacked**
3. Select folder where you extracted zip downloaded from [Releases](https://github.com/vitalygashkov/streamyx-extension/releases) page
3. Select folder where you extracted zip downloaded from [Releases](https://github.com/vitalygashkov/azot/releases) page
4. Done!

## Features
## Usage

### Example with Encrypted Media Extensions API

Source code for example available [here](https://github.com/vitalygashkov/orlan/blob/main/examples/demo/eme.js). It's a minimal example of using `azot` to get a license for Bitmovin's Art of Motion Demo. This example similar to [example from EME](https://www.w3.org/TR/encrypted-media-2/#example-8).

There are some differences from the native EME implementation:

- **Notifications** about intercepted request with button to **copy Streamyx command** for your shell (run it using [Streamyx](https://github.com/vitalygashkov/streamyx) >= v4.0.0-beta.46 to extract content decryption keys)
- [Encrypted Media Extensions (EME)](https://w3c.github.io/encrypted-media/) logging in Developer Tools console (**session ID, key ID, PSSH**, etc.)
- Request **blocking** to catch license requests with disposable / one time tokens (you can add your own list of servers to block in `./src/worker.js`)
1. You must import your Widevine client to obtain a license.
2. In the `keyStatuses` field, Map's key is not just the key ID (like in EME), but pair with ID and value. For example, if key ID in HEX is `35e7eff366b24121a261832f3368146f` and content key itself is `f01471043a064e039a602346845b690f` then Map's key will be `35e7eff366b24121a261832f3368146f:f01471043a064e039a602346845b690f` (but as binary with `BufferSource` type) instead of just `35e7eff366b24121a261832f3368146f`.
22 changes: 22 additions & 0 deletions cli/commands/client/help.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { col } from '../../utils';

export const help = () => {
console.log(`azot client: Widevine client utilities\n`);
console.log(`Usage: azot client <subcommand> [...flags]\n`);
console.log(`Commands:`);
console.log(
col(`info <input>`) +
'print info about client that is on <input> path (*.wvd filepath or directory with id and private key)',
);
console.log(
col(`pack <input> <output>`) +
'pack client id and private key from <input> directory into single *.wvd file with <output> path',
);
console.log(
col(`unpack <input> <output>`) +
'unpack *.wvd from <input> path to separate client id and private key placed in <output> directory',
);
console.log('');
console.log(`Flags:`);
console.log(col(`-h, --help`) + 'Display this menu and exit');
};
6 changes: 6 additions & 0 deletions cli/commands/client/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { help } from './help';
import { info } from './info';
import { pack } from './pack';
import { unpack } from './unpack';

export const client = { info, pack, unpack, help };
8 changes: 8 additions & 0 deletions cli/commands/client/info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { importClient } from '../../utils';

export const info = async (input: string) => {
const client = await importClient(input);
for (const [key, value] of client.info.entries()) {
console.log(`${key}: ${value}`);
}
};
12 changes: 12 additions & 0 deletions cli/commands/client/pack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { importClient } from '../../utils';

export const pack = async (input = process.cwd(), output?: string) => {
const client = await importClient(input);
const wvd = await client.toWvd();
const wvdName = `${client.info.get('company_name')}_${client.info.get('model_name')}`;
const wvdOutput = output || join(process.cwd(), `${wvdName}.wvd`);
await writeFile(wvdOutput, wvd);
console.log(`Client packed: ${wvdOutput}`);
};
13 changes: 13 additions & 0 deletions cli/commands/client/unpack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { importClient } from '../../utils';

export const unpack = async (input = process.cwd(), output?: string) => {
const client = await importClient(input);
const [id, key] = await client.toPair();
const idOutput = join(output || process.cwd(), `device_client_id_blob`);
const keyOutput = join(output || process.cwd(), `device_private_key`);
await writeFile(idOutput, id);
await writeFile(keyOutput, key);
console.log(`Client unpacked: ${idOutput}, ${keyOutput}`);
};
24 changes: 24 additions & 0 deletions cli/commands/license/help.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { col } from '../../utils';

export const help = () => {
console.log(`azot license: Make license request\n`);
console.log(`Usage: azot license <url> [...flags]\n`);
console.log(`Commands:`);
console.log(
col(`<url>`) +
'URL of license server (e.g. https://cwip-shaka-proxy.appspot.com/no_auth)',
);
console.log('');
console.log(`Flags:`);
console.log(col(`-H, --header`) + 'headers to send with license request');
console.log(col(`-p, --pssh`) + 'widevine PSSH data in Base64');
console.log(
col(`-c, --client`) +
'path to client (directory with id and private key or path to *.wvd file)',
);
console.log(
col(`-e, --encrypt`) +
'enable client encryption with service certificate, disabled by default',
);
console.log(col(`-h, --help`) + 'display this menu and exit');
};
1 change: 1 addition & 0 deletions cli/commands/license/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './license';
25 changes: 25 additions & 0 deletions cli/commands/license/license.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { fetchDecryptionKeys } from '@azot/lib';
import { importClient } from '../../utils';
import { help } from './help';

type LicenseCommandParams = {
url: string;
pssh: string;
clientPath?: string;
encrypt?: boolean;
headers?: string[];
};

export const license = async (params: LicenseCommandParams) => {
const keys = await fetchDecryptionKeys({
server: params.url,
pssh: params.pssh,
client: await importClient(params.clientPath || process.cwd()),
});
for (const key of keys) {
console.log(`${key.id}:${key.value}`);
}
return keys;
};

license.help = help;
108 changes: 108 additions & 0 deletions cli/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/usr/bin/env node

import { parseArgs } from 'node:util';
import { client } from './commands/client';
import { license } from './commands/license';
import pkg from '../package.json' with { type: 'json' };
import { col } from './utils';

const args = parseArgs({
args: process.argv.slice(2),
options: {
pssh: { type: 'string', short: 'p' },
client: { type: 'string', short: 'c' },
encrypt: { type: 'boolean', short: 'e', default: false },
header: { type: 'string', short: 'H', multiple: true },
help: { type: 'boolean', short: 'h' },
version: { type: 'boolean', short: 'v' },
debug: { type: 'boolean', short: 'd' },
},
strict: false,
allowPositionals: true,
});

const help = () => {
console.log(
`Azot is a research & pentesting toolkit for Google's Widevine DRM. (${pkg.version})\n`,
);
console.log(`Usage: azot <command> [...flags]\n`);
console.log(`Commands:`);
console.log(col(`license <url>`) + 'Make a license request');
console.log(
col(`client <subcommand>`) + 'Additional Widevine client utilities',
);
console.log('');
console.log(`Flags:`);
console.log(col(`-d, --debug`) + 'Enable debug level logging');
console.log(col(`-v, --version`) + 'Print version and exit');
console.log(col(`-h, --help`) + 'Display this menu and exit');
console.log('');
console.log(`(more flags in azot license --help and azot client --help)`);
};

(async () => {
if (args.values.version) {
console.log(pkg.version);
process.exit(0);
}

const [command, ...positionals] = args.positionals;

switch (command) {
case 'client': {
const subcommand = positionals.shift();
const [input, output] = positionals;
switch (subcommand) {
case 'pack':
client.pack(input, output);
break;
case 'unpack':
client.unpack(input, output);
break;
case 'info':
client.info(input);
break;
default: {
if (args.values.help) {
client.help();
process.exit(0);
} else {
console.log('Client subcommand required: pack, unpack, info');
process.exit(1);
}
}
}
break;
}
case 'license': {
if (args.values.help) {
license.help();
process.exit(0);
}
const url = positionals.shift();
if (!url) throw new Error('License URL required');
const pssh = args.values.pssh as string;
if (!pssh) throw new Error('PSSH required');
const clientPath = args.values.client as string;
const encrypt = args.values.encrypt as boolean;
const headers = args.values.header as string[];
await license({ url, pssh, clientPath, encrypt, headers });
break;
}
case 'pssh':
break;
case 'serve':
break;
case 'test':
break;
default: {
if (args.values.help) {
help();
process.exit(0);
} else {
console.log('Command not found');
process.exit(1);
}
}
}
})();
39 changes: 39 additions & 0 deletions cli/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { Client } from '../lib';

export const importClient = async (input: string) => {
const inputStat = await stat(input);
const isDir = inputStat.isDirectory();
if (isDir) {
const entries = isDir ? await readdir(input) : [];
const idFilename = entries.find((entry) => entry.includes('client_id'));
const keyFilename = entries.find((entry) => entry.includes('private_key'));
const wvdFilename = entries.find((entry) => entry.endsWith('wvd'));
const isUnpacked = !!(idFilename && keyFilename);
const isPacked = !!wvdFilename;
if (isUnpacked) {
const idPath = join(input, idFilename);
const keyPath = join(input, keyFilename);
const id = await readFile(idPath);
const key = await readFile(keyPath);
return Client.fromPair(id, key);
} else if (isPacked) {
const wvdPath = join(input, wvdFilename);
const wvd = await readFile(wvdPath);
return await Client.fromWvd(wvd);
} else {
console.log(`Unable to find client files in ${input}`);
process.exit(1);
}
} else if (input.endsWith('.wvd')) {
const wvd = await readFile(input);
return Client.fromWvd(wvd);
} else {
console.log(`Unable to find client files in ${input}`);
process.exit(1);
}
};

export const col = (str: string, offset = 2, width = 30) =>
`${' '.repeat(offset)}${str.padEnd(width)}`;
20 changes: 20 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import globals from 'globals';
import pluginJs from '@eslint/js';
import tseslint from 'typescript-eslint';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';

export default [
{ ignores: ['lib/proto/*', 'dist/*'] },
{ files: ['**/*.{js,mjs,cjs,ts}'] },
{ files: ['**/*.js'], languageOptions: { sourceType: 'commonjs' } },
{ languageOptions: { globals: { ...globals.browser, ...globals.node } } },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
eslintPluginPrettierRecommended,
{
rules: {
'no-empty': 'off',
'@typescript-eslint/no-unused-vars': ['error', { caughtErrors: 'none' }],
},
},
];
Loading

0 comments on commit ca6c9fd

Please sign in to comment.