From 99aef633545d6b9a10b0d10ee1484cc8da64b482 Mon Sep 17 00:00:00 2001
From: rlaphoenix <rlaphoenix@pm.me>
Date: Fri, 3 Feb 2023 06:53:55 +0000
Subject: [PATCH] Add export-device command to export WVDs back as files

In reality you wouldn't need this for use with pywidevine, but a lot have asked me for this feature so they can use WVDs in other ways or with other software that does not support WVDs.
---
 poetry.lock        |  4 +--
 pyproject.toml     |  2 +-
 pywidevine/main.py | 81 ++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 84 insertions(+), 3 deletions(-)

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