diff --git a/poetry.lock b/poetry.lock index 4b5b0c9..f05531f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -572,7 +572,7 @@ name = "pyyaml" version = "6.0" description = "YAML parser and emitter for Python" category = "main" -optional = true +optional = false python-versions = ">=3.6" files = [ {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, @@ -789,4 +789,4 @@ serve = ["aiohttp", "PyYAML"] [metadata] lock-version = "2.0" python-versions = ">=3.7,<3.12" -content-hash = "4ac0b422601d094bfbda39c94bea40171e995b7e9bd4b97b4d55dd34f5af7360" +content-hash = "c392d2830d8a0614ebdaa8b16fce5ccd0f92020db948270cb57246fe4c7b1372" diff --git a/pyproject.toml b/pyproject.toml index d6c6029..e00e32c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,8 +35,8 @@ click = "^8.1.3" requests = "^2.28.1" lxml = ">=4.9.2" Unidecode = "^1.3.4" +PyYAML = "^6.0" aiohttp = {version = "^3.8.1", optional = true} -PyYAML = {version = "^6.0", optional = true} [tool.poetry.extras] serve = ["aiohttp", "PyYAML"] diff --git a/pywidevine/main.py b/pywidevine/main.py index ebad736..2d1d11b 100644 --- a/pywidevine/main.py +++ b/pywidevine/main.py @@ -8,6 +8,8 @@ import click import requests from construct import ConstructError from unidecode import unidecode, UnidecodeError +import yaml +from google.protobuf.json_format import MessageToDict from pywidevine import __version__ from pywidevine.cdm import Cdm @@ -247,6 +249,85 @@ def create_device( log.info(" + Saved to: %s", out_path.absolute()) +@main.command() +@click.argument("wvd_path", type=Path) +@click.option("-o", "--out_dir", type=Path, default=None, help="Output Directory") +@click.pass_context +def export_device(ctx: click.Context, wvd_path: Path, out_dir: Optional[Path] = None) -> None: + """ + Export a Widevine Device (.wvd) file to an RSA Private Key (PEM and DER) and Client ID Blob. + Optionally also a VMP (Verified Media Path) Blob, which will be stored in the Client ID. + + If an output directory is not specified, it will be stored in the current working directory. + """ + if not wvd_path.is_file(): + raise click.UsageError("wvd_path: Not a path to a file, or it doesn't exist.", ctx) + + log = logging.getLogger("export-device") + log.info("Exporting Widevine Device (.wvd) file, %s", wvd_path.stem) + + if not out_dir: + out_dir = Path.cwd() + + out_path = out_dir / wvd_path.stem + if out_path.exists(): + if any(out_path.iterdir()): + log.error("Output directory is not empty, cannot overwrite.") + return + else: + log.warning("Output directory already exists, but is empty.") + else: + out_path.mkdir(parents=True) + + device = Device.load(wvd_path) + + log.info(f"L{device.security_level} {device.system_id} {device.type.name}") + log.info(f"Saving to: {out_path}") + + device_meta = { + "wvd": { + "device_type": device.type.name, + "security_level": device.security_level, + **device.flags + }, + "client_info": {}, + "capabilities": MessageToDict(device.client_id, preserving_proto_field_name=True)["client_capabilities"] + } + for client_info in device.client_id.client_info: + device_meta["client_info"][client_info.name] = client_info.value + + device_meta_path = out_path / "metadata.yml" + device_meta_path.write_text(yaml.dump(device_meta), encoding="utf8") + log.info("Exported Device Metadata as metadata.yml") + + if device.private_key: + private_key_path = out_path / "private_key.pem" + private_key_path.write_text( + data=device.private_key.export_key().decode(), + encoding="utf8" + ) + private_key_path.with_suffix(".der").write_bytes( + device.private_key.export_key(format="DER") + ) + log.info("Exported Private Key as private_key.der and private_key.pem") + else: + log.warning("No Private Key available") + + if device.client_id: + client_id_path = out_path / "client_id.bin" + client_id_path.write_bytes(device.client_id.SerializeToString()) + log.info("Exported Client ID as client_id.bin") + else: + log.warning("No Client ID available") + + if device.client_id.vmp_data: + vmp_path = out_path / "vmp.bin" + vmp_path.write_bytes(device.client_id.vmp_data) + log.info("Exported VMP (File Hashes) as vmp.bin") + else: + log.info("No VMP (File Hashes) available") + + @main.command() @click.argument("path", type=Path) @click.pass_context