diff --git a/pywv.py b/pywv.py
new file mode 100644
index 0000000..66bbbf7
--- /dev/null
+++ b/pywv.py
@@ -0,0 +1,1703 @@
+
+from __future__ import annotations
+import argparse
+import base64
+import binascii
+import json
+import logging
+import os
+import random
+import re
+import shutil
+import string
+import subprocess
+import sys
+import time
+from datetime import datetime
+from enum import Enum
+from io import BytesIO
+from pathlib import Path
+from typing import Any, Optional, Union
+from uuid import UUID
+from zlib import crc32
+from xml.etree.ElementTree import XML
+import requests
+from construct import BitStruct, Bytes, Const, ConstructError, Container
+from construct import Enum as CEnum
+from construct import Int8ub, Int16ub
+from construct import Optional as COptional
+from construct import Padded, Padding, Struct, this
+import construct
+from Crypto.Cipher import AES, PKCS1_OAEP
+from Crypto.Hash import CMAC, HMAC, SHA1, SHA256
+from Crypto.PublicKey import RSA
+from Crypto.Random import get_random_bytes
+from Crypto.Signature import pss
+from Crypto.Util import Padding as CryptoPadding
+from google.protobuf.message import DecodeError
+from google.protobuf.json_format import MessageToDict
+from pymp4.parser import Box
+try:
+ from unidecode import unidecode
+ from unidecode import UnidecodeError
+except Exception:
+ class UnidecodeError(Exception):
+ pass
+ def unidecode(value: str) -> str:
+ return value
+
+__version__ = "1.9.0"
+
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import descriptor_pool as _descriptor_pool
+from google.protobuf import symbol_database as _symbol_database
+from google.protobuf.internal import builder as _builder
+
+_sym_db = _symbol_database.Default()
+
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x16license_protocol.proto\x12\x10license_protocol\"\xb2\x01\n\x15LicenseIdentification\x12\x12\n\nrequest_id\x18\x01 \x01(\x0c\x12\x12\n\nsession_id\x18\x02 \x01(\x0c\x12\x13\n\x0bpurchase_id\x18\x03 \x01(\x0c\x12+\n\x04type\x18\x04 \x01(\x0e\x32\x1d.license_protocol.LicenseType\x12\x0f\n\x07version\x18\x05 \x01(\x05\x12\x1e\n\x16provider_session_token\x18\x06 \x01(\x0c\"\xcc\x17\n\x07License\x12\x33\n\x02id\x18\x01 \x01(\x0b\x32\'.license_protocol.LicenseIdentification\x12\x30\n\x06policy\x18\x02 \x01(\x0b\x32 .license_protocol.License.Policy\x12\x33\n\x03key\x18\x03 \x03(\x0b\x32&.license_protocol.License.KeyContainer\x12\x1a\n\x12license_start_time\x18\x04 \x01(\x03\x12*\n\x1bremote_attestation_verified\x18\x05 \x01(\x08:\x05\x66\x61lse\x12\x1d\n\x15provider_client_token\x18\x06 \x01(\x0c\x12\x19\n\x11protection_scheme\x18\x07 \x01(\r\x12\x17\n\x0fsrm_requirement\x18\x08 \x01(\x0c\x12\x12\n\nsrm_update\x18\t \x01(\x0c\x12l\n\x1cplatform_verification_status\x18\n \x01(\x0e\x32,.license_protocol.PlatformVerificationStatus:\x18PLATFORM_NO_VERIFICATION\x12\x11\n\tgroup_ids\x18\x0b \x03(\x0c\x1a\xae\x04\n\x06Policy\x12\x17\n\x08\x63\x61n_play\x18\x01 \x01(\x08:\x05\x66\x61lse\x12\x1a\n\x0b\x63\x61n_persist\x18\x02 \x01(\x08:\x05\x66\x61lse\x12\x18\n\tcan_renew\x18\x03 \x01(\x08:\x05\x66\x61lse\x12\"\n\x17rental_duration_seconds\x18\x04 \x01(\x03:\x01\x30\x12$\n\x19playback_duration_seconds\x18\x05 \x01(\x03:\x01\x30\x12#\n\x18license_duration_seconds\x18\x06 \x01(\x03:\x01\x30\x12,\n!renewal_recovery_duration_seconds\x18\x07 \x01(\x03:\x01\x30\x12\x1a\n\x12renewal_server_url\x18\x08 \x01(\t\x12 \n\x15renewal_delay_seconds\x18\t \x01(\x03:\x01\x30\x12)\n\x1erenewal_retry_interval_seconds\x18\n \x01(\x03:\x01\x30\x12\x1f\n\x10renew_with_usage\x18\x0b \x01(\x08:\x05\x66\x61lse\x12\'\n\x18\x61lways_include_client_id\x18\x0c \x01(\x08:\x05\x66\x61lse\x12*\n\x1fplay_start_grace_period_seconds\x18\r \x01(\x03:\x01\x30\x12-\n\x1esoft_enforce_playback_duration\x18\x0e \x01(\x08:\x05\x66\x61lse\x12*\n\x1csoft_enforce_rental_duration\x18\x0f \x01(\x08:\x04true\x1a\xc3\x0f\n\x0cKeyContainer\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\n\n\x02iv\x18\x02 \x01(\x0c\x12\x0b\n\x03key\x18\x03 \x01(\x0c\x12<\n\x04type\x18\x04 \x01(\x0e\x32..license_protocol.License.KeyContainer.KeyType\x12U\n\x05level\x18\x05 \x01(\x0e\x32\x34.license_protocol.License.KeyContainer.SecurityLevel:\x10SW_SECURE_CRYPTO\x12T\n\x13required_protection\x18\x06 \x01(\x0b\x32\x37.license_protocol.License.KeyContainer.OutputProtection\x12U\n\x14requested_protection\x18\x07 \x01(\x0b\x32\x37.license_protocol.License.KeyContainer.OutputProtection\x12\x46\n\x0bkey_control\x18\x08 \x01(\x0b\x32\x31.license_protocol.License.KeyContainer.KeyControl\x12n\n operator_session_key_permissions\x18\t \x01(\x0b\x32\x44.license_protocol.License.KeyContainer.OperatorSessionKeyPermissions\x12\x66\n\x1cvideo_resolution_constraints\x18\n \x03(\x0b\x32@.license_protocol.License.KeyContainer.VideoResolutionConstraint\x12(\n\x19\x61nti_rollback_usage_table\x18\x0b \x01(\x08:\x05\x66\x61lse\x12\x13\n\x0btrack_label\x18\x0c \x01(\t\x1a\x33\n\nKeyControl\x12\x19\n\x11key_control_block\x18\x01 \x01(\x0c\x12\n\n\x02iv\x18\x02 \x01(\x0c\x1a\xfb\x04\n\x10OutputProtection\x12U\n\x04hdcp\x18\x01 \x01(\x0e\x32<.license_protocol.License.KeyContainer.OutputProtection.HDCP:\tHDCP_NONE\x12[\n\ncgms_flags\x18\x02 \x01(\x0e\x32<.license_protocol.License.KeyContainer.OutputProtection.CGMS:\tCGMS_NONE\x12n\n\rhdcp_srm_rule\x18\x03 \x01(\x0e\x32\x43.license_protocol.License.KeyContainer.OutputProtection.HdcpSrmRule:\x12HDCP_SRM_RULE_NONE\x12$\n\x15\x64isable_analog_output\x18\x04 \x01(\x08:\x05\x66\x61lse\x12%\n\x16\x64isable_digital_output\x18\x05 \x01(\x08:\x05\x66\x61lse\"y\n\x04HDCP\x12\r\n\tHDCP_NONE\x10\x00\x12\x0b\n\x07HDCP_V1\x10\x01\x12\x0b\n\x07HDCP_V2\x10\x02\x12\r\n\tHDCP_V2_1\x10\x03\x12\r\n\tHDCP_V2_2\x10\x04\x12\r\n\tHDCP_V2_3\x10\x05\x12\x1b\n\x16HDCP_NO_DIGITAL_OUTPUT\x10\xff\x01\"C\n\x04\x43GMS\x12\r\n\tCGMS_NONE\x10*\x12\r\n\tCOPY_FREE\x10\x00\x12\r\n\tCOPY_ONCE\x10\x02\x12\x0e\n\nCOPY_NEVER\x10\x03\"6\n\x0bHdcpSrmRule\x12\x16\n\x12HDCP_SRM_RULE_NONE\x10\x00\x12\x0f\n\x0b\x43URRENT_SRM\x10\x01\x1a\xaf\x01\n\x19VideoResolutionConstraint\x12\x1d\n\x15min_resolution_pixels\x18\x01 \x01(\r\x12\x1d\n\x15max_resolution_pixels\x18\x02 \x01(\r\x12T\n\x13required_protection\x18\x03 \x01(\x0b\x32\x37.license_protocol.License.KeyContainer.OutputProtection\x1a\x9d\x01\n\x1dOperatorSessionKeyPermissions\x12\x1c\n\rallow_encrypt\x18\x01 \x01(\x08:\x05\x66\x61lse\x12\x1c\n\rallow_decrypt\x18\x02 \x01(\x08:\x05\x66\x61lse\x12\x19\n\nallow_sign\x18\x03 \x01(\x08:\x05\x66\x61lse\x12%\n\x16\x61llow_signature_verify\x18\x04 \x01(\x08:\x05\x66\x61lse\"l\n\x07KeyType\x12\x0b\n\x07SIGNING\x10\x01\x12\x0b\n\x07\x43ONTENT\x10\x02\x12\x0f\n\x0bKEY_CONTROL\x10\x03\x12\x14\n\x10OPERATOR_SESSION\x10\x04\x12\x0f\n\x0b\x45NTITLEMENT\x10\x05\x12\x0f\n\x0bOEM_CONTENT\x10\x06\"z\n\rSecurityLevel\x12\x14\n\x10SW_SECURE_CRYPTO\x10\x01\x12\x14\n\x10SW_SECURE_DECODE\x10\x02\x12\x14\n\x10HW_SECURE_CRYPTO\x10\x03\x12\x14\n\x10HW_SECURE_DECODE\x10\x04\x12\x11\n\rHW_SECURE_ALL\x10\x05\"\xa3\x0c\n\x0eLicenseRequest\x12\x39\n\tclient_id\x18\x01 \x01(\x0b\x32&.license_protocol.ClientIdentification\x12J\n\ncontent_id\x18\x02 \x01(\x0b\x32\x36.license_protocol.LicenseRequest.ContentIdentification\x12:\n\x04type\x18\x03 \x01(\x0e\x32,.license_protocol.LicenseRequest.RequestType\x12\x14\n\x0crequest_time\x18\x04 \x01(\x03\x12$\n\x1ckey_control_nonce_deprecated\x18\x05 \x01(\x0c\x12H\n\x10protocol_version\x18\x06 \x01(\x0e\x32!.license_protocol.ProtocolVersion:\x0bVERSION_2_0\x12\x19\n\x11key_control_nonce\x18\x07 \x01(\r\x12L\n\x13\x65ncrypted_client_id\x18\x08 \x01(\x0b\x32/.license_protocol.EncryptedClientIdentification\x1a\xac\x08\n\x15\x43ontentIdentification\x12\x65\n\x12widevine_pssh_data\x18\x01 \x01(\x0b\x32G.license_protocol.LicenseRequest.ContentIdentification.WidevinePsshDataH\x00\x12W\n\x0bwebm_key_id\x18\x02 \x01(\x0b\x32@.license_protocol.LicenseRequest.ContentIdentification.WebmKeyIdH\x00\x12\x62\n\x10\x65xisting_license\x18\x03 \x01(\x0b\x32\x46.license_protocol.LicenseRequest.ContentIdentification.ExistingLicenseH\x00\x12T\n\tinit_data\x18\x04 \x01(\x0b\x32?.license_protocol.LicenseRequest.ContentIdentification.InitDataH\x00\x1an\n\x10WidevinePsshData\x12\x11\n\tpssh_data\x18\x01 \x03(\x0c\x12\x33\n\x0clicense_type\x18\x02 \x01(\x0e\x32\x1d.license_protocol.LicenseType\x12\x12\n\nrequest_id\x18\x03 \x01(\x0c\x1a\x64\n\tWebmKeyId\x12\x0e\n\x06header\x18\x01 \x01(\x0c\x12\x33\n\x0clicense_type\x18\x02 \x01(\x0e\x32\x1d.license_protocol.LicenseType\x12\x12\n\nrequest_id\x18\x03 \x01(\x0c\x1a\xb3\x01\n\x0f\x45xistingLicense\x12;\n\nlicense_id\x18\x01 \x01(\x0b\x32\'.license_protocol.LicenseIdentification\x12\x1d\n\x15seconds_since_started\x18\x02 \x01(\x03\x12!\n\x19seconds_since_last_played\x18\x03 \x01(\x03\x12!\n\x19session_usage_table_entry\x18\x04 \x01(\x0c\x1a\xf6\x01\n\x08InitData\x12j\n\x0einit_data_type\x18\x01 \x01(\x0e\x32L.license_protocol.LicenseRequest.ContentIdentification.InitData.InitDataType:\x04\x43\x45NC\x12\x11\n\tinit_data\x18\x02 \x01(\x0c\x12\x33\n\x0clicense_type\x18\x03 \x01(\x0e\x32\x1d.license_protocol.LicenseType\x12\x12\n\nrequest_id\x18\x04 \x01(\x0c\"\"\n\x0cInitDataType\x12\x08\n\x04\x43\x45NC\x10\x01\x12\x08\n\x04WEBM\x10\x02\x42\x14\n\x12\x63ontent_id_variant\"0\n\x0bRequestType\x12\x07\n\x03NEW\x10\x01\x12\x0b\n\x07RENEWAL\x10\x02\x12\x0b\n\x07RELEASE\x10\x03\"\xdd\x01\n\nMetricData\x12\x12\n\nstage_name\x18\x01 \x01(\t\x12;\n\x0bmetric_data\x18\x02 \x03(\x0b\x32&.license_protocol.MetricData.TypeValue\x1aT\n\tTypeValue\x12\x35\n\x04type\x18\x01 \x01(\x0e\x32\'.license_protocol.MetricData.MetricType\x12\x10\n\x05value\x18\x02 \x01(\x03:\x01\x30\"(\n\nMetricType\x12\x0b\n\x07LATENCY\x10\x01\x12\r\n\tTIMESTAMP\x10\x02\"K\n\x0bVersionInfo\x12\x1b\n\x13license_sdk_version\x18\x01 \x01(\t\x12\x1f\n\x17license_service_version\x18\x02 \x01(\t\"\xca\x05\n\rSignedMessage\x12\x39\n\x04type\x18\x01 \x01(\x0e\x32+.license_protocol.SignedMessage.MessageType\x12\x0b\n\x03msg\x18\x02 \x01(\x0c\x12\x11\n\tsignature\x18\x03 \x01(\x0c\x12\x13\n\x0bsession_key\x18\x04 \x01(\x0c\x12\x1a\n\x12remote_attestation\x18\x05 \x01(\x0c\x12\x31\n\x0bmetric_data\x18\x06 \x03(\x0b\x32\x1c.license_protocol.MetricData\x12;\n\x14service_version_info\x18\x07 \x01(\x0b\x32\x1d.license_protocol.VersionInfo\x12Y\n\x10session_key_type\x18\x08 \x01(\x0e\x32..license_protocol.SignedMessage.SessionKeyType:\x0fWRAPPED_AES_KEY\x12\x1e\n\x16oemcrypto_core_message\x18\t \x01(\x0c\"\xec\x01\n\x0bMessageType\x12\x13\n\x0fLICENSE_REQUEST\x10\x01\x12\x0b\n\x07LICENSE\x10\x02\x12\x12\n\x0e\x45RROR_RESPONSE\x10\x03\x12\x1f\n\x1bSERVICE_CERTIFICATE_REQUEST\x10\x04\x12\x17\n\x13SERVICE_CERTIFICATE\x10\x05\x12\x0f\n\x0bSUB_LICENSE\x10\x06\x12\x17\n\x13\x43\x41S_LICENSE_REQUEST\x10\x07\x12\x0f\n\x0b\x43\x41S_LICENSE\x10\x08\x12\x1c\n\x18\x45XTERNAL_LICENSE_REQUEST\x10\t\x12\x14\n\x10\x45XTERNAL_LICENSE\x10\n\"S\n\x0eSessionKeyType\x12\r\n\tUNDEFINED\x10\x00\x12\x13\n\x0fWRAPPED_AES_KEY\x10\x01\x12\x1d\n\x19\x45PHERMERAL_ECC_PUBLIC_KEY\x10\x02\"\xef\r\n\x14\x43lientIdentification\x12\x46\n\x04type\x18\x01 \x01(\x0e\x32\x30.license_protocol.ClientIdentification.TokenType:\x06KEYBOX\x12\r\n\x05token\x18\x02 \x01(\x0c\x12\x45\n\x0b\x63lient_info\x18\x03 \x03(\x0b\x32\x30.license_protocol.ClientIdentification.NameValue\x12\x1d\n\x15provider_client_token\x18\x04 \x01(\x0c\x12\x17\n\x0flicense_counter\x18\x05 \x01(\r\x12V\n\x13\x63lient_capabilities\x18\x06 \x01(\x0b\x32\x39.license_protocol.ClientIdentification.ClientCapabilities\x12\x10\n\x08vmp_data\x18\x07 \x01(\x0c\x12T\n\x12\x64\x65vice_credentials\x18\x08 \x03(\x0b\x32\x38.license_protocol.ClientIdentification.ClientCredentials\x1a(\n\tNameValue\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x1a\xb5\x08\n\x12\x43lientCapabilities\x12\x1b\n\x0c\x63lient_token\x18\x01 \x01(\x08:\x05\x66\x61lse\x12\x1c\n\rsession_token\x18\x02 \x01(\x08:\x05\x66\x61lse\x12+\n\x1cvideo_resolution_constraints\x18\x03 \x01(\x08:\x05\x66\x61lse\x12j\n\x10max_hdcp_version\x18\x04 \x01(\x0e\x32\x45.license_protocol.ClientIdentification.ClientCapabilities.HdcpVersion:\tHDCP_NONE\x12\x1e\n\x16oem_crypto_api_version\x18\x05 \x01(\r\x12(\n\x19\x61nti_rollback_usage_table\x18\x06 \x01(\x08:\x05\x66\x61lse\x12\x13\n\x0bsrm_version\x18\x07 \x01(\r\x12\x1d\n\x0e\x63\x61n_update_srm\x18\x08 \x01(\x08:\x05\x66\x61lse\x12t\n\x1esupported_certificate_key_type\x18\t \x03(\x0e\x32L.license_protocol.ClientIdentification.ClientCapabilities.CertificateKeyType\x12\x8d\x01\n\x1a\x61nalog_output_capabilities\x18\n \x01(\x0e\x32R.license_protocol.ClientIdentification.ClientCapabilities.AnalogOutputCapabilities:\x15\x41NALOG_OUTPUT_UNKNOWN\x12(\n\x19\x63\x61n_disable_analog_output\x18\x0b \x01(\x08:\x05\x66\x61lse\x12\x1f\n\x14resource_rating_tier\x18\x0c \x01(\r:\x01\x30\"\x80\x01\n\x0bHdcpVersion\x12\r\n\tHDCP_NONE\x10\x00\x12\x0b\n\x07HDCP_V1\x10\x01\x12\x0b\n\x07HDCP_V2\x10\x02\x12\r\n\tHDCP_V2_1\x10\x03\x12\r\n\tHDCP_V2_2\x10\x04\x12\r\n\tHDCP_V2_3\x10\x05\x12\x1b\n\x16HDCP_NO_DIGITAL_OUTPUT\x10\xff\x01\"i\n\x12\x43\x65rtificateKeyType\x12\x0c\n\x08RSA_2048\x10\x00\x12\x0c\n\x08RSA_3072\x10\x01\x12\x11\n\rECC_SECP256R1\x10\x02\x12\x11\n\rECC_SECP384R1\x10\x03\x12\x11\n\rECC_SECP521R1\x10\x04\"\x8d\x01\n\x18\x41nalogOutputCapabilities\x12\x19\n\x15\x41NALOG_OUTPUT_UNKNOWN\x10\x00\x12\x16\n\x12\x41NALOG_OUTPUT_NONE\x10\x01\x12\x1b\n\x17\x41NALOG_OUTPUT_SUPPORTED\x10\x02\x12!\n\x1d\x41NALOG_OUTPUT_SUPPORTS_CGMS_A\x10\x03\x1aj\n\x11\x43lientCredentials\x12\x46\n\x04type\x18\x01 \x01(\x0e\x32\x30.license_protocol.ClientIdentification.TokenType:\x06KEYBOX\x12\r\n\x05token\x18\x02 \x01(\x0c\"s\n\tTokenType\x12\n\n\x06KEYBOX\x10\x00\x12\x1a\n\x16\x44RM_DEVICE_CERTIFICATE\x10\x01\x12\"\n\x1eREMOTE_ATTESTATION_CERTIFICATE\x10\x02\x12\x1a\n\x16OEM_DEVICE_CERTIFICATE\x10\x03\"\xbb\x01\n\x1d\x45ncryptedClientIdentification\x12\x13\n\x0bprovider_id\x18\x01 \x01(\t\x12)\n!service_certificate_serial_number\x18\x02 \x01(\x0c\x12\x1b\n\x13\x65ncrypted_client_id\x18\x03 \x01(\x0c\x12\x1e\n\x16\x65ncrypted_client_id_iv\x18\x04 \x01(\x0c\x12\x1d\n\x15\x65ncrypted_privacy_key\x18\x05 \x01(\x0c\"\x83\x07\n\x0e\x44rmCertificate\x12\x33\n\x04type\x18\x01 \x01(\x0e\x32%.license_protocol.DrmCertificate.Type\x12\x15\n\rserial_number\x18\x02 \x01(\x0c\x12\x1d\n\x15\x63reation_time_seconds\x18\x03 \x01(\r\x12\x1f\n\x17\x65xpiration_time_seconds\x18\x0c \x01(\r\x12\x12\n\npublic_key\x18\x04 \x01(\x0c\x12\x11\n\tsystem_id\x18\x05 \x01(\r\x12\"\n\x16test_device_deprecated\x18\x06 \x01(\x08\x42\x02\x18\x01\x12\x13\n\x0bprovider_id\x18\x07 \x01(\t\x12\x43\n\rservice_types\x18\x08 \x03(\x0e\x32,.license_protocol.DrmCertificate.ServiceType\x12\x42\n\talgorithm\x18\t \x01(\x0e\x32*.license_protocol.DrmCertificate.Algorithm:\x03RSA\x12\x0e\n\x06rot_id\x18\n \x01(\x0c\x12\x46\n\x0e\x65ncryption_key\x18\x0b \x01(\x0b\x32..license_protocol.DrmCertificate.EncryptionKey\x1ag\n\rEncryptionKey\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x42\n\talgorithm\x18\x02 \x01(\x0e\x32*.license_protocol.DrmCertificate.Algorithm:\x03RSA\"L\n\x04Type\x12\x08\n\x04ROOT\x10\x00\x12\x10\n\x0c\x44\x45VICE_MODEL\x10\x01\x12\n\n\x06\x44\x45VICE\x10\x02\x12\x0b\n\x07SERVICE\x10\x03\x12\x0f\n\x0bPROVISIONER\x10\x04\"\x86\x01\n\x0bServiceType\x12\x18\n\x14UNKNOWN_SERVICE_TYPE\x10\x00\x12\x16\n\x12LICENSE_SERVER_SDK\x10\x01\x12\x1c\n\x18LICENSE_SERVER_PROXY_SDK\x10\x02\x12\x14\n\x10PROVISIONING_SDK\x10\x03\x12\x11\n\rCAS_PROXY_SDK\x10\x04\"d\n\tAlgorithm\x12\x15\n\x11UNKNOWN_ALGORITHM\x10\x00\x12\x07\n\x03RSA\x10\x01\x12\x11\n\rECC_SECP256R1\x10\x02\x12\x11\n\rECC_SECP384R1\x10\x03\x12\x11\n\rECC_SECP521R1\x10\x04\"\xb8\x01\n\x14SignedDrmCertificate\x12\x17\n\x0f\x64rm_certificate\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x12\x36\n\x06signer\x18\x03 \x01(\x0b\x32&.license_protocol.SignedDrmCertificate\x12<\n\x0ehash_algorithm\x18\x04 \x01(\x0e\x32$.license_protocol.HashAlgorithmProto\"\xd5\x05\n\x10WidevinePsshData\x12\x0f\n\x07key_ids\x18\x02 \x03(\x0c\x12\x12\n\ncontent_id\x18\x04 \x01(\x0c\x12\x1b\n\x13\x63rypto_period_index\x18\x07 \x01(\r\x12\x19\n\x11protection_scheme\x18\t \x01(\r\x12\x1d\n\x15\x63rypto_period_seconds\x18\n \x01(\r\x12=\n\x04type\x18\x0b \x01(\x0e\x32\'.license_protocol.WidevinePsshData.Type:\x06SINGLE\x12\x14\n\x0ckey_sequence\x18\x0c \x01(\r\x12\x11\n\tgroup_ids\x18\r \x03(\x0c\x12\x45\n\rentitled_keys\x18\x0e \x03(\x0b\x32..license_protocol.WidevinePsshData.EntitledKey\x12\x15\n\rvideo_feature\x18\x0f \x01(\t\x12\x43\n\talgorithm\x18\x01 \x01(\x0e\x32,.license_protocol.WidevinePsshData.AlgorithmB\x02\x18\x01\x12\x14\n\x08provider\x18\x03 \x01(\tB\x02\x18\x01\x12\x16\n\ntrack_type\x18\x05 \x01(\tB\x02\x18\x01\x12\x12\n\x06policy\x18\x06 \x01(\tB\x02\x18\x01\x12\x1b\n\x0fgrouped_license\x18\x08 \x01(\x0c\x42\x02\x18\x01\x1az\n\x0b\x45ntitledKey\x12\x1a\n\x12\x65ntitlement_key_id\x18\x01 \x01(\x0c\x12\x0e\n\x06key_id\x18\x02 \x01(\x0c\x12\x0b\n\x03key\x18\x03 \x01(\x0c\x12\n\n\x02iv\x18\x04 \x01(\x0c\x12&\n\x1a\x65ntitlement_key_size_bytes\x18\x05 \x01(\r:\x02\x33\x32\"5\n\x04Type\x12\n\n\x06SINGLE\x10\x00\x12\x0f\n\x0b\x45NTITLEMENT\x10\x01\x12\x10\n\x0c\x45NTITLED_KEY\x10\x02\"(\n\tAlgorithm\x12\x0f\n\x0bUNENCRYPTED\x10\x00\x12\n\n\x06\x41\x45SCTR\x10\x01\"\xc6\x01\n\nFileHashes\x12\x0e\n\x06signer\x18\x01 \x01(\x0c\x12:\n\nsignatures\x18\x02 \x03(\x0b\x32&.license_protocol.FileHashes.Signature\x1al\n\tSignature\x12\x10\n\x08\x66ilename\x18\x01 \x01(\t\x12\x14\n\x0ctest_signing\x18\x02 \x01(\x08\x12\x12\n\nSHA512Hash\x18\x03 \x01(\x0c\x12\x10\n\x08main_exe\x18\x04 \x01(\x08\x12\x11\n\tsignature\x18\x05 \x01(\x0c*8\n\x0bLicenseType\x12\r\n\tSTREAMING\x10\x01\x12\x0b\n\x07OFFLINE\x10\x02\x12\r\n\tAUTOMATIC\x10\x03*\xd9\x01\n\x1aPlatformVerificationStatus\x12\x17\n\x13PLATFORM_UNVERIFIED\x10\x00\x12\x15\n\x11PLATFORM_TAMPERED\x10\x01\x12\x1e\n\x1aPLATFORM_SOFTWARE_VERIFIED\x10\x02\x12\x1e\n\x1aPLATFORM_HARDWARE_VERIFIED\x10\x03\x12\x1c\n\x18PLATFORM_NO_VERIFICATION\x10\x04\x12-\n)PLATFORM_SECURE_STORAGE_SOFTWARE_VERIFIED\x10\x05*D\n\x0fProtocolVersion\x12\x0f\n\x0bVERSION_2_0\x10\x14\x12\x0f\n\x0bVERSION_2_1\x10\x15\x12\x0f\n\x0bVERSION_2_2\x10\x16*\x86\x01\n\x12HashAlgorithmProto\x12\x1e\n\x1aHASH_ALGORITHM_UNSPECIFIED\x10\x00\x12\x18\n\x14HASH_ALGORITHM_SHA_1\x10\x01\x12\x1a\n\x16HASH_ALGORITHM_SHA_256\x10\x02\x12\x1a\n\x16HASH_ALGORITHM_SHA_384\x10\x03\x42\x02H\x03')
+
+_globals = globals()
+_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
+_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'license_protocol_pb2', _globals)
+if not _descriptor._USE_C_DESCRIPTORS:
+ _globals['DESCRIPTOR']._loaded_options = None
+ _globals['DESCRIPTOR']._serialized_options = b'H\003'
+ _globals['_DRMCERTIFICATE'].fields_by_name['test_device_deprecated']._loaded_options = None
+ _globals['_DRMCERTIFICATE'].fields_by_name['test_device_deprecated']._serialized_options = b'\030\001'
+ _globals['_WIDEVINEPSSHDATA'].fields_by_name['algorithm']._loaded_options = None
+ _globals['_WIDEVINEPSSHDATA'].fields_by_name['algorithm']._serialized_options = b'\030\001'
+ _globals['_WIDEVINEPSSHDATA'].fields_by_name['provider']._loaded_options = None
+ _globals['_WIDEVINEPSSHDATA'].fields_by_name['provider']._serialized_options = b'\030\001'
+ _globals['_WIDEVINEPSSHDATA'].fields_by_name['track_type']._loaded_options = None
+ _globals['_WIDEVINEPSSHDATA'].fields_by_name['track_type']._serialized_options = b'\030\001'
+ _globals['_WIDEVINEPSSHDATA'].fields_by_name['policy']._loaded_options = None
+ _globals['_WIDEVINEPSSHDATA'].fields_by_name['policy']._serialized_options = b'\030\001'
+ _globals['_WIDEVINEPSSHDATA'].fields_by_name['grouped_license']._loaded_options = None
+ _globals['_WIDEVINEPSSHDATA'].fields_by_name['grouped_license']._serialized_options = b'\030\001'
+ _globals['_LICENSETYPE']._serialized_start=9826
+ _globals['_LICENSETYPE']._serialized_end=9882
+ _globals['_PLATFORMVERIFICATIONSTATUS']._serialized_start=9885
+ _globals['_PLATFORMVERIFICATIONSTATUS']._serialized_end=10102
+ _globals['_PROTOCOLVERSION']._serialized_start=10104
+ _globals['_PROTOCOLVERSION']._serialized_end=10172
+ _globals['_HASHALGORITHMPROTO']._serialized_start=10175
+ _globals['_HASHALGORITHMPROTO']._serialized_end=10309
+ _globals['_LICENSEIDENTIFICATION']._serialized_start=45
+ _globals['_LICENSEIDENTIFICATION']._serialized_end=223
+ _globals['_LICENSE']._serialized_start=226
+ _globals['_LICENSE']._serialized_end=3246
+ _globals['_LICENSE_POLICY']._serialized_start=698
+ _globals['_LICENSE_POLICY']._serialized_end=1256
+ _globals['_LICENSE_KEYCONTAINER']._serialized_start=1259
+ _globals['_LICENSE_KEYCONTAINER']._serialized_end=3246
+ _globals['_LICENSE_KEYCONTAINER_KEYCONTROL']._serialized_start=1985
+ _globals['_LICENSE_KEYCONTAINER_KEYCONTROL']._serialized_end=2036
+ _globals['_LICENSE_KEYCONTAINER_OUTPUTPROTECTION']._serialized_start=2039
+ _globals['_LICENSE_KEYCONTAINER_OUTPUTPROTECTION']._serialized_end=2674
+ _globals['_LICENSE_KEYCONTAINER_OUTPUTPROTECTION_HDCP']._serialized_start=2428
+ _globals['_LICENSE_KEYCONTAINER_OUTPUTPROTECTION_HDCP']._serialized_end=2549
+ _globals['_LICENSE_KEYCONTAINER_OUTPUTPROTECTION_CGMS']._serialized_start=2551
+ _globals['_LICENSE_KEYCONTAINER_OUTPUTPROTECTION_CGMS']._serialized_end=2618
+ _globals['_LICENSE_KEYCONTAINER_OUTPUTPROTECTION_HDCPSRMRULE']._serialized_start=2620
+ _globals['_LICENSE_KEYCONTAINER_OUTPUTPROTECTION_HDCPSRMRULE']._serialized_end=2674
+ _globals['_LICENSE_KEYCONTAINER_VIDEORESOLUTIONCONSTRAINT']._serialized_start=2677
+ _globals['_LICENSE_KEYCONTAINER_VIDEORESOLUTIONCONSTRAINT']._serialized_end=2852
+ _globals['_LICENSE_KEYCONTAINER_OPERATORSESSIONKEYPERMISSIONS']._serialized_start=2855
+ _globals['_LICENSE_KEYCONTAINER_OPERATORSESSIONKEYPERMISSIONS']._serialized_end=3012
+ _globals['_LICENSE_KEYCONTAINER_KEYTYPE']._serialized_start=3014
+ _globals['_LICENSE_KEYCONTAINER_KEYTYPE']._serialized_end=3122
+ _globals['_LICENSE_KEYCONTAINER_SECURITYLEVEL']._serialized_start=3124
+ _globals['_LICENSE_KEYCONTAINER_SECURITYLEVEL']._serialized_end=3246
+ _globals['_LICENSEREQUEST']._serialized_start=3249
+ _globals['_LICENSEREQUEST']._serialized_end=4820
+ _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION']._serialized_start=3702
+ _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION']._serialized_end=4770
+ _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_WIDEVINEPSSHDATA']._serialized_start=4105
+ _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_WIDEVINEPSSHDATA']._serialized_end=4215
+ _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_WEBMKEYID']._serialized_start=4217
+ _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_WEBMKEYID']._serialized_end=4317
+ _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_EXISTINGLICENSE']._serialized_start=4320
+ _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_EXISTINGLICENSE']._serialized_end=4499
+ _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_INITDATA']._serialized_start=4502
+ _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_INITDATA']._serialized_end=4748
+ _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_INITDATA_INITDATATYPE']._serialized_start=4714
+ _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_INITDATA_INITDATATYPE']._serialized_end=4748
+ _globals['_LICENSEREQUEST_REQUESTTYPE']._serialized_start=4772
+ _globals['_LICENSEREQUEST_REQUESTTYPE']._serialized_end=4820
+ _globals['_METRICDATA']._serialized_start=4823
+ _globals['_METRICDATA']._serialized_end=5044
+ _globals['_METRICDATA_TYPEVALUE']._serialized_start=4918
+ _globals['_METRICDATA_TYPEVALUE']._serialized_end=5002
+ _globals['_METRICDATA_METRICTYPE']._serialized_start=5004
+ _globals['_METRICDATA_METRICTYPE']._serialized_end=5044
+ _globals['_VERSIONINFO']._serialized_start=5046
+ _globals['_VERSIONINFO']._serialized_end=5121
+ _globals['_SIGNEDMESSAGE']._serialized_start=5124
+ _globals['_SIGNEDMESSAGE']._serialized_end=5838
+ _globals['_SIGNEDMESSAGE_MESSAGETYPE']._serialized_start=5517
+ _globals['_SIGNEDMESSAGE_MESSAGETYPE']._serialized_end=5753
+ _globals['_SIGNEDMESSAGE_SESSIONKEYTYPE']._serialized_start=5755
+ _globals['_SIGNEDMESSAGE_SESSIONKEYTYPE']._serialized_end=5838
+ _globals['_CLIENTIDENTIFICATION']._serialized_start=5841
+ _globals['_CLIENTIDENTIFICATION']._serialized_end=7616
+ _globals['_CLIENTIDENTIFICATION_NAMEVALUE']._serialized_start=6271
+ _globals['_CLIENTIDENTIFICATION_NAMEVALUE']._serialized_end=6311
+ _globals['_CLIENTIDENTIFICATION_CLIENTCAPABILITIES']._serialized_start=6314
+ _globals['_CLIENTIDENTIFICATION_CLIENTCAPABILITIES']._serialized_end=7391
+ _globals['_CLIENTIDENTIFICATION_CLIENTCAPABILITIES_HDCPVERSION']._serialized_start=7012
+ _globals['_CLIENTIDENTIFICATION_CLIENTCAPABILITIES_HDCPVERSION']._serialized_end=7140
+ _globals['_CLIENTIDENTIFICATION_CLIENTCAPABILITIES_CERTIFICATEKEYTYPE']._serialized_start=7142
+ _globals['_CLIENTIDENTIFICATION_CLIENTCAPABILITIES_CERTIFICATEKEYTYPE']._serialized_end=7247
+ _globals['_CLIENTIDENTIFICATION_CLIENTCAPABILITIES_ANALOGOUTPUTCAPABILITIES']._serialized_start=7250
+ _globals['_CLIENTIDENTIFICATION_CLIENTCAPABILITIES_ANALOGOUTPUTCAPABILITIES']._serialized_end=7391
+ _globals['_CLIENTIDENTIFICATION_CLIENTCREDENTIALS']._serialized_start=7393
+ _globals['_CLIENTIDENTIFICATION_CLIENTCREDENTIALS']._serialized_end=7499
+ _globals['_CLIENTIDENTIFICATION_TOKENTYPE']._serialized_start=7501
+ _globals['_CLIENTIDENTIFICATION_TOKENTYPE']._serialized_end=7616
+ _globals['_ENCRYPTEDCLIENTIDENTIFICATION']._serialized_start=7619
+ _globals['_ENCRYPTEDCLIENTIDENTIFICATION']._serialized_end=7806
+ _globals['_DRMCERTIFICATE']._serialized_start=7809
+ _globals['_DRMCERTIFICATE']._serialized_end=8708
+ _globals['_DRMCERTIFICATE_ENCRYPTIONKEY']._serialized_start=8288
+ _globals['_DRMCERTIFICATE_ENCRYPTIONKEY']._serialized_end=8391
+ _globals['_DRMCERTIFICATE_TYPE']._serialized_start=8393
+ _globals['_DRMCERTIFICATE_TYPE']._serialized_end=8469
+ _globals['_DRMCERTIFICATE_SERVICETYPE']._serialized_start=8472
+ _globals['_DRMCERTIFICATE_SERVICETYPE']._serialized_end=8606
+ _globals['_DRMCERTIFICATE_ALGORITHM']._serialized_start=8608
+ _globals['_DRMCERTIFICATE_ALGORITHM']._serialized_end=8708
+ _globals['_SIGNEDDRMCERTIFICATE']._serialized_start=8711
+ _globals['_SIGNEDDRMCERTIFICATE']._serialized_end=8895
+ _globals['_WIDEVINEPSSHDATA']._serialized_start=8898
+ _globals['_WIDEVINEPSSHDATA']._serialized_end=9623
+ _globals['_WIDEVINEPSSHDATA_ENTITLEDKEY']._serialized_start=9404
+ _globals['_WIDEVINEPSSHDATA_ENTITLEDKEY']._serialized_end=9526
+ _globals['_WIDEVINEPSSHDATA_TYPE']._serialized_start=9528
+ _globals['_WIDEVINEPSSHDATA_TYPE']._serialized_end=9581
+ _globals['_WIDEVINEPSSHDATA_ALGORITHM']._serialized_start=9583
+ _globals['_WIDEVINEPSSHDATA_ALGORITHM']._serialized_end=9623
+ _globals['_FILEHASHES']._serialized_start=9626
+ _globals['_FILEHASHES']._serialized_end=9824
+ _globals['_FILEHASHES_SIGNATURE']._serialized_start=9716
+ _globals['_FILEHASHES_SIGNATURE']._serialized_end=9824
+
+class Exception(Exception):
+ """Exceptions used by ."""
+
+class TooManySessions(Exception):
+ """Too many Sessions are open."""
+
+class InvalidSession(Exception):
+ """No Session is open with the specified identifier."""
+
+class InvalidInitData(Exception):
+ """The Widevine Cenc Header Data is invalid or empty."""
+
+class InvalidLicenseType(Exception):
+ """The License Type is an Invalid Value."""
+
+class InvalidLicenseMessage(Exception):
+ """The License Message is Invalid or Missing."""
+
+class InvalidContext(Exception):
+ """The Context is Invalid or Missing."""
+
+
+class SignatureMismatch(Exception):
+ """The Signature did not match."""
+
+
+class NoKeysLoaded(Exception):
+ """No License was parsed for this Session, No Keys available."""
+
+
+class DeviceMismatch(Exception):
+ """The Remote CDMs Device information and the APIs Device information did not match."""
+
+class Key:
+ def __init__(self, type_: str, kid: UUID, key: bytes, permissions: Optional[list[str]] = None):
+ self.type = type_
+ self.kid = kid
+ self.key = key
+ self.permissions = permissions or []
+
+ def __repr__(self) -> str:
+ return "{name}({items})".format(
+ name=self.__class__.__name__,
+ items=", ".join([f"{k}={repr(v)}" for k, v in self.__dict__.items()])
+ )
+
+ @classmethod
+ def from_key_container(cls, key: License.KeyContainer, enc_key: bytes) -> Key:
+ """Load Key from a KeyContainer object."""
+ permissions = []
+ if key.type == License.KeyContainer.KeyType.Value("OPERATOR_SESSION"):
+ for descriptor, value in key.operator_session_key_permissions.ListFields():
+ if value == 1:
+ permissions.append(descriptor.name)
+
+ return Key(
+ type_=License.KeyContainer.KeyType.Name(key.type),
+ kid=cls.kid_to_uuid(key.id),
+ key=CryptoPadding.unpad(
+ AES.new(enc_key, AES.MODE_CBC, iv=key.iv).decrypt(key.key),
+ 16
+ ),
+ permissions=permissions
+ )
+
+ @staticmethod
+ def kid_to_uuid(kid: Union[str, bytes]) -> UUID:
+ """
+ Convert a Key ID from a string or bytes to a UUID object.
+ At first this may seem very simple but some types of Key IDs
+ may not be 16 bytes and some may be decimal vs. hex.
+ """
+ if isinstance(kid, str):
+ kid = base64.b64decode(kid)
+ if not kid:
+ kid = b"\x00" * 16
+
+ if kid.decode(errors="replace").isdigit():
+ return UUID(int=int(kid.decode()))
+
+ if len(kid) < 16:
+ kid += b"\x00" * (16 - len(kid))
+
+ return UUID(bytes=kid)
+
+__all__ = ("Key",)
+
+class Session:
+ def __init__(self, number: int):
+ self.number = number
+ self.id = get_random_bytes(16)
+ self.service_certificate: Optional[SignedDrmCertificate] = None
+ self.context: dict[bytes, tuple[bytes, bytes]] = {}
+ self.keys: list[Key] = []
+
+__all__ = ("Session",)
+
+class DeviceTypes(Enum):
+ CHROME = 1
+ ANDROID = 2
+
+class _Structures:
+ magic = Const(b"WVD")
+
+ header = Struct(
+ "signature" / magic,
+ "version" / Int8ub
+ )
+
+
+ v2 = Struct(
+ "signature" / magic,
+ "version" / Const(Int8ub, 2),
+ "type_" / CEnum(
+ Int8ub,
+ **{t.name: t.value for t in DeviceTypes}
+ ),
+ "security_level" / Int8ub,
+ "flags" / Padded(1, COptional(BitStruct(
+
+ Padding(8)
+ ))),
+ "private_key_len" / Int16ub,
+ "private_key" / Bytes(this.private_key_len),
+ "client_id_len" / Int16ub,
+ "client_id" / Bytes(this.client_id_len)
+ )
+
+
+ v1 = Struct(
+ "signature" / magic,
+ "version" / Const(Int8ub, 1),
+ "type_" / CEnum(
+ Int8ub,
+ **{t.name: t.value for t in DeviceTypes}
+ ),
+ "security_level" / Int8ub,
+ "flags" / Padded(1, COptional(BitStruct(
+
+ Padding(8)
+ ))),
+ "private_key_len" / Int16ub,
+ "private_key" / Bytes(this.private_key_len),
+ "client_id_len" / Int16ub,
+ "client_id" / Bytes(this.client_id_len),
+ "vmp_len" / Int16ub,
+ "vmp" / Bytes(this.vmp_len)
+ )
+
+class Device:
+ Structures = _Structures
+ supported_structure = Structures.v2
+
+ def __init__(self, *_: Any, type_: DeviceTypes, security_level: int, flags: Optional[dict], private_key: Optional[bytes], client_id: Optional[bytes], **__: Any):
+
+ if not client_id:
+ raise ValueError("Client ID is required, the WVD does not contain one or is malformed.")
+ if not private_key:
+ raise ValueError("Private Key is required, the WVD does not contain one or is malformed.")
+
+ self.type = DeviceTypes[type_] if isinstance(type_, str) else type_
+ self.security_level = security_level
+ self.flags = flags or {}
+ self.private_key = RSA.importKey(private_key)
+ self.client_id = ClientIdentification()
+ try:
+ self.client_id.ParseFromString(client_id)
+ if self.client_id.SerializeToString() != client_id:
+ raise DecodeError("partial parse")
+ except DecodeError as e:
+ raise DecodeError(f"Failed to parse client_id as a ClientIdentification, {e}")
+
+ self.vmp = FileHashes()
+ if self.client_id.vmp_data:
+ try:
+ self.vmp.ParseFromString(self.client_id.vmp_data)
+ if self.vmp.SerializeToString() != self.client_id.vmp_data:
+ raise DecodeError("partial parse")
+ except DecodeError as e:
+ raise DecodeError(f"Failed to parse Client ID's VMP data as a FileHashes, {e}")
+
+ signed_drm_certificate = SignedDrmCertificate()
+ drm_certificate = DrmCertificate()
+
+ try:
+ signed_drm_certificate.ParseFromString(self.client_id.token)
+ if signed_drm_certificate.SerializeToString() != self.client_id.token:
+ raise DecodeError("partial parse")
+ except DecodeError as e:
+ raise DecodeError(f"Failed to parse the Signed DRM Certificate of the Client ID, {e}")
+
+ try:
+ drm_certificate.ParseFromString(signed_drm_certificate.drm_certificate)
+ if drm_certificate.SerializeToString() != signed_drm_certificate.drm_certificate:
+ raise DecodeError("partial parse")
+ except DecodeError as e:
+ raise DecodeError(f"Failed to parse the DRM Certificate of the Client ID, {e}")
+
+ self.system_id = drm_certificate.system_id
+
+ def __repr__(self) -> str:
+ return "{name}({items})".format(
+ name=self.__class__.__name__,
+ items=", ".join([f"{k}={repr(v)}" for k, v in self.__dict__.items()])
+ )
+
+ @classmethod
+ def loads(cls, data: Union[bytes, str]) -> Device:
+ if isinstance(data, str):
+ data = base64.b64decode(data)
+ if not isinstance(data, bytes):
+ raise ValueError(f"Expecting Bytes or Base64 input, got {data!r}")
+ return cls(**cls.supported_structure.parse(data))
+
+ @classmethod
+ def from_files(
+ cls,
+ private_key: Union[Path, str, bytes],
+ client_id: Union[Path, str, bytes],
+ vmp: Optional[Union[Path, str, bytes]] = None,
+ type_: Union[DeviceTypes, str] = DeviceTypes.ANDROID,
+ security_level: int = 3,
+ flags: Optional[dict] = None
+ ) -> Device:
+ private_key_bytes = private_key if isinstance(private_key, bytes) else Path(private_key).read_bytes()
+ client_id_bytes = client_id if isinstance(client_id, bytes) else Path(client_id).read_bytes()
+ device = cls(
+ type_=type_,
+ security_level=security_level,
+ flags=flags,
+ private_key=private_key_bytes,
+ client_id=client_id_bytes
+ )
+ if vmp:
+ vmp_bytes = vmp if isinstance(vmp, bytes) else Path(vmp).read_bytes()
+ parsed_vmp = FileHashes()
+ try:
+ parsed_vmp.ParseFromString(vmp_bytes)
+ if parsed_vmp.SerializeToString() != vmp_bytes:
+ raise DecodeError("partial parse")
+ except DecodeError as e:
+ raise DecodeError(f"Failed to parse VMP data as FileHashes, {e}")
+ device.client_id.vmp_data = vmp_bytes
+ device.vmp = parsed_vmp
+ return device
+
+ @classmethod
+ def load_from_files(cls, private_key: Union[Path, str, bytes], client_id: Union[Path, str, bytes], vmp: Optional[Union[Path, str, bytes]] = None, type: Union[DeviceTypes, str] = DeviceTypes.ANDROID, type_: Optional[Union[DeviceTypes, str]] = None, security_level: int = 3, flags: Optional[dict] = None) -> Device:
+ selected_type = type_ if type_ is not None else type
+ selected_vmp = None
+ if vmp is not None:
+ if isinstance(vmp, bytes):
+ selected_vmp = vmp
+ else:
+ vmp_path = Path(vmp)
+ if vmp_path.exists() and vmp_path.is_file():
+ selected_vmp = vmp_path
+ return cls.from_files(private_key=private_key,
+ client_id=client_id,
+ vmp=selected_vmp,
+ type_=selected_type,
+ security_level=security_level,
+ flags=flags)
+
+ @classmethod
+ def from_directory(cls, path: Union[Path, str], type_: Union[DeviceTypes, str] = DeviceTypes.ANDROID, security_level: int = 3, flags: Optional[dict] = None) -> Device:
+ directory = Path(path)
+ if not directory.exists() or not directory.is_dir():
+ raise ValueError(f"Device directory does not exist: {directory}")
+ private_key_path = directory / "device_private_key"
+ client_id_path = directory / "device_client_id_blob"
+ vmp_path = directory / "device_vmp_blob"
+ if not private_key_path.exists():
+ raise FileNotFoundError(f"Missing device private key file: {private_key_path}")
+ if not client_id_path.exists():
+ raise FileNotFoundError(f"Missing device client ID blob file: {client_id_path}")
+ return cls.from_files(private_key=private_key_path, client_id=client_id_path, vmp=vmp_path if vmp_path.exists() else None,
+ type_=type_,
+ security_level=security_level,
+ flags=flags
+ )
+
+ @classmethod
+ def load(cls, path: Union[Path, str]) -> Device:
+ if not isinstance(path, (Path, str)):
+ raise ValueError(f"Expecting Path object or path string, got {path!r}")
+ path = Path(path)
+ if path.is_dir():
+ return cls.from_directory(path)
+ with path.open(mode="rb") as f:
+ return cls(**cls.supported_structure.parse_stream(f))
+
+ def dumps(self) -> bytes:
+ private_key = self.private_key.export_key("DER") if self.private_key else None
+ return self.supported_structure.build(dict(
+ version=2,
+ type_=self.type.value,
+ security_level=self.security_level,
+ flags=self.flags,
+ private_key_len=len(private_key) if private_key else 0,
+ private_key=private_key,
+ client_id_len=len(self.client_id.SerializeToString()) if self.client_id else 0,
+ client_id=self.client_id.SerializeToString() if self.client_id else None
+ ))
+
+ def dump(self, path: Union[Path, str]) -> None:
+ if not isinstance(path, (Path, str)):
+ raise ValueError(f"Expecting Path object or path string, got {path!r}")
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(self.dumps())
+
+ @classmethod
+ def migrate(cls, data: Union[bytes, str]) -> Device:
+ if isinstance(data, str):
+ data = base64.b64decode(data)
+ if not isinstance(data, bytes):
+ raise ValueError(f"Expecting Bytes or Base64 input, got {data!r}")
+
+ header = _Structures.header.parse(data)
+ if header.version == 2:
+ raise ValueError("Device Data is already migrated to the latest version.")
+ if header.version == 0 or header.version > 2:
+
+ raise ValueError("Device Data does not seem to be a WVD file (v0).")
+
+ if header.version == 1:
+ v1_struct = _Structures.v1.parse(data)
+ v1_struct.version = 2
+ v1_struct.flags = Container()
+
+ vmp = FileHashes()
+ if v1_struct.vmp:
+ try:
+ vmp.ParseFromString(v1_struct.vmp)
+ if vmp.SerializeToString() != v1_struct.vmp:
+ raise DecodeError("partial parse")
+ except DecodeError as e:
+ raise DecodeError(f"Failed to parse VMP data as FileHashes, {e}")
+ v1_struct.vmp = vmp
+
+ client_id = ClientIdentification()
+ try:
+ client_id.ParseFromString(v1_struct.client_id)
+ if client_id.SerializeToString() != v1_struct.client_id:
+ raise DecodeError("partial parse")
+ except DecodeError as e:
+ raise DecodeError(f"Failed to parse VMP data as FileHashes, {e}")
+
+ new_vmp_data = v1_struct.vmp.SerializeToString()
+ if client_id.vmp_data and client_id.vmp_data != new_vmp_data:
+ logging.getLogger("migrate").warning("Client ID already has Verified Media Path data")
+ client_id.vmp_data = new_vmp_data
+ v1_struct.client_id = client_id.SerializeToString()
+
+ try:
+ data = _Structures.v2.build(v1_struct)
+ except ConstructError as e:
+ raise ValueError(f"Migration failed, {e}")
+
+ try:
+ return cls.loads(data)
+ except ConstructError as e:
+ raise ValueError(f"Device Data seems to be corrupt or invalid, or migration failed, {e}")
+
+
+__all__ = ("Device", "DeviceTypes")
+
+
+class PSSH:
+ class SystemId:
+ Widevine = UUID(hex="edef8ba979d64acea3c827dcd51d21ed")
+ PlayReady = UUID(hex="9a04f07998404286ab92e65be0885f95")
+
+ def __init__(self, data: Union[Container, str, bytes], strict: bool = False):
+ if not data:
+ raise ValueError("Data must not be empty.")
+
+ if isinstance(data, Container):
+ box = data
+ else:
+ if isinstance(data, str):
+ try:
+ data = base64.b64decode(data)
+ except (binascii.Error, binascii.Incomplete) as e:
+ raise binascii.Error(f"Could not decode data as Base64, {e}")
+
+ if not isinstance(data, bytes):
+ raise TypeError(f"Expected data to be a {Container}, bytes, or base64, not {data!r}")
+
+ try:
+ box = Box.parse(data)
+ except (IOError, construct.ConstructError):
+ try:
+ widevine_pssh_data = WidevinePsshData()
+ widevine_pssh_data.ParseFromString(data)
+ data_serialized = widevine_pssh_data.SerializeToString()
+ if data_serialized != data:
+ raise DecodeError()
+ box = Box.parse(Box.build(dict(
+ type=b"pssh",
+ version=0,
+ flags=0,
+ system_ID=PSSH.SystemId.Widevine,
+ init_data=data_serialized
+ )))
+ except DecodeError:
+ if "".encode("utf-16-le") in data:
+
+ box = Box.parse(Box.build(dict(
+ type=b"pssh",
+ version=0,
+ flags=0,
+ system_ID=PSSH.SystemId.PlayReady,
+ init_data=data
+ )))
+ elif strict:
+ raise DecodeError(f"Could not parse data as a {Container} nor a {WidevinePsshData}.")
+ else:
+ box = Box.parse(Box.build(dict(type=b"pssh",
+ version=0,
+ flags=0,
+ system_ID=PSSH.SystemId.Widevine,
+ init_data=data)))
+ self.version = box.version
+ self.flags = box.flags
+ self.system_id = box.system_ID
+ self.__key_ids = box.key_IDs
+ self.init_data = box.init_data
+
+ def __repr__(self) -> str:
+ return f"PSSH<{self.system_id}>(v{self.version}; {self.flags}, {self.key_ids}, {self.init_data})"
+
+ def __str__(self) -> str:
+ return self.dumps()
+
+ @classmethod
+ def new(
+ cls,
+ system_id: UUID,
+ key_ids: Optional[list[Union[UUID, str, bytes]]] = None,
+ init_data: Optional[Union[WidevinePsshData, str, bytes]] = None,
+ version: int = 0,
+ flags: int = 0
+ ) -> PSSH:
+ """Craft a new version 0 or 1 PSSH Box."""
+ if not system_id:
+ raise ValueError("A System ID must be specified.")
+ if not isinstance(system_id, UUID):
+ raise TypeError(f"Expected system_id to be a UUID, not {system_id!r}")
+
+ if key_ids is not None and not isinstance(key_ids, list):
+ raise TypeError(f"Expected key_ids to be a list not {key_ids!r}")
+
+ if init_data is not None and not isinstance(init_data, (WidevinePsshData, str, bytes)):
+ raise TypeError(f"Expected init_data to be a {WidevinePsshData}, base64, or bytes, not {init_data!r}")
+
+ if not isinstance(version, int):
+ raise TypeError(f"Expected version to be an int not {version!r}")
+ if version not in (0, 1):
+ raise ValueError(f"Invalid version, must be either 0 or 1, not {version}.")
+
+ if not isinstance(flags, int):
+ raise TypeError(f"Expected flags to be an int not {flags!r}")
+ if flags < 0:
+ raise ValueError("Invalid flags, cannot be less than 0.")
+
+ if version == 0 and key_ids is not None and init_data is not None:
+
+ raise ValueError("Version 0 PSSH boxes must use only init_data, not init_data and key_ids.")
+ elif version == 1:
+
+
+ if init_data is None and key_ids is None:
+ raise ValueError("Version 1 PSSH boxes must use either init_data or key_ids but neither were provided")
+
+ if init_data is not None:
+ if isinstance(init_data, WidevinePsshData):
+ init_data = init_data.SerializeToString()
+ elif isinstance(init_data, str):
+ if all(c in string.hexdigits for c in init_data):
+ init_data = bytes.fromhex(init_data)
+ else:
+ init_data = base64.b64decode(init_data)
+ elif not isinstance(init_data, bytes):
+ raise TypeError(
+ f"Expecting init_data to be {WidevinePsshData}, hex, base64, or bytes, not {init_data!r}"
+ )
+
+ pssh = cls(Box.parse(Box.build(dict(type=b"pssh",
+ version=version,
+ flags=flags,
+ system_ID=system_id,
+ init_data=[init_data, b""][init_data is None]))))
+
+ if key_ids:
+ pssh.version = version
+ pssh.set_key_ids(key_ids)
+ return pssh
+
+ @property
+ def key_ids(self) -> list[UUID]:
+ if self.version == 1 and self.__key_ids:
+ return self.__key_ids
+
+ if self.system_id == PSSH.SystemId.Widevine:
+
+ cenc_header = WidevinePsshData()
+ cenc_header.ParseFromString(self.init_data)
+ return [
+ (
+ UUID(bytes=key_id) if len(key_id) == 16 else
+ UUID(hex=key_id.decode()) if len(key_id) == 32 else
+ UUID(int=int.from_bytes(key_id, "big"))
+ )
+ for key_id in cenc_header.key_ids
+ ]
+
+ if self.system_id == PSSH.SystemId.PlayReady:
+
+
+ pro_data = BytesIO(self.init_data)
+ pro_length = int.from_bytes(pro_data.read(4), "little")
+ if pro_length != len(self.init_data):
+ raise ValueError("The PlayReadyObject seems to be corrupt (too big or small, or missing data).")
+ pro_record_count = int.from_bytes(pro_data.read(2), "little")
+
+ for _ in range(pro_record_count):
+ prr_type = int.from_bytes(pro_data.read(2), "little")
+ prr_length = int.from_bytes(pro_data.read(2), "little")
+ prr_value = pro_data.read(prr_length)
+ if prr_type != 0x01:
+ continue
+
+ wrm_ns = {"wrm": "http://schemas.microsoft.com/DRM/2007/03/PlayReadyHeader"}
+ prr_header = XML(prr_value.decode("utf-16-le"))
+ prr_header_version = prr_header.get("version")
+ if prr_header_version == "4.0.0.0":
+ key_ids = [
+ x.text
+ for x in prr_header.findall("./wrm:DATA/wrm:KID", wrm_ns)
+ if x.text
+ ]
+ elif prr_header_version == "4.1.0.0":
+ key_ids = [
+ x.attrib["VALUE"]
+ for x in prr_header.findall("./wrm:DATA/wrm:PROTECTINFO/wrm:KID", wrm_ns)
+ ]
+ elif prr_header_version in ("4.2.0.0", "4.3.0.0"):
+ key_ids = [
+ x.attrib["VALUE"]
+ for x in prr_header.findall("./wrm:DATA/wrm:PROTECTINFO/wrm:KIDS/wrm:KID", wrm_ns)
+ ]
+ else:
+ raise ValueError(f"Unsupported PlayReadyHeader version {prr_header_version}")
+
+ return [
+ UUID(bytes=base64.b64decode(key_id))
+ for key_id in key_ids
+ ]
+
+ raise ValueError("Unsupported PlayReadyObject, no PlayReadyHeader within the object.")
+
+ raise ValueError(f"This PSSH is not supported by key_ids() property, {self.dumps()}")
+
+ def dump(self) -> bytes:
+ return Box.build(dict(
+ type=b"pssh",
+ version=self.version,
+ flags=self.flags,
+ system_ID=self.system_id,
+ key_IDs=self.key_ids if self.version == 1 and self.key_ids else None,
+ init_data=self.init_data
+ ))
+
+ def dumps(self) -> str:
+ """Export the PSSH object as a full PSSH box in base64 form."""
+ return base64.b64encode(self.dump()).decode()
+
+ def to_widevine(self) -> None:
+ """
+ Convert PlayReady PSSH data to Widevine PSSH data.
+
+ There's only a limited amount of information within a PlayReady PSSH header that
+ can be used in a Widevine PSSH Header. The converted data may or may not result
+ in an accepted PSSH. It depends on what the License Server is expecting.
+ """
+ if self.system_id == PSSH.SystemId.Widevine:
+ raise ValueError("This is already a Widevine PSSH")
+
+ widevine_pssh_data = WidevinePsshData(
+ key_ids=[x.bytes for x in self.key_ids],
+ algorithm="AESCTR"
+ )
+
+ if self.version == 1:
+ self.__key_ids = self.key_ids
+
+ self.init_data = widevine_pssh_data.SerializeToString()
+ self.system_id = PSSH.SystemId.Widevine
+
+ def to_playready(self, la_url: Optional[str] = None, lui_url: Optional[str] = None, ds_id: Optional[bytes] = None, decryptor_setup: Optional[str] = None, custom_data: Optional[str] = None) -> None:
+
+ if self.system_id == PSSH.SystemId.PlayReady:
+ raise ValueError("This is already a PlayReady PSSH")
+
+ key_ids_xml = ""
+ for key_id in self.key_ids:
+
+ key_ids_xml += f"""
+
+ """
+
+ prr_value = f"""
+
+
+
+ {key_ids_xml}
+
+ {'%s' % la_url if la_url else ''}
+ {'%s' % lui_url if lui_url else ''}
+ {'%s' % base64.b64encode(ds_id).decode() if ds_id else ''}
+ {'%s' % decryptor_setup if decryptor_setup else ''}
+ {'%s' % custom_data if custom_data else ''}
+
+
+ """.encode("utf-16-le")
+
+ prr_length = len(prr_value).to_bytes(2, "little")
+ prr_type = (1).to_bytes(2, "little")
+ pro_record_count = (1).to_bytes(2, "little")
+ pro = pro_record_count + prr_type + prr_length + prr_value
+ pro = (len(pro) + 4).to_bytes(4, "little") + pro
+
+ self.init_data = pro
+ self.system_id = PSSH.SystemId.PlayReady
+
+ def set_key_ids(self, key_ids: list[Union[UUID, str, bytes]]) -> None:
+ if self.system_id != PSSH.SystemId.Widevine:
+
+ raise ValueError(f"Only Widevine PSSH Boxes are supported, not {self.system_id}.")
+
+ key_id_uuids = self.parse_key_ids(key_ids)
+
+ if self.version == 1 or self.__key_ids:
+ self.__key_ids = key_id_uuids
+ cenc_header = WidevinePsshData()
+ cenc_header.ParseFromString(self.init_data)
+
+ cenc_header.key_ids[:] = [
+ key_id.bytes
+ for key_id in key_id_uuids
+ ]
+ self.init_data = cenc_header.SerializeToString()
+
+ @staticmethod
+ def parse_key_ids(key_ids: list[Union[UUID, str, bytes]]) -> list[UUID]:
+ if not isinstance(key_ids, list):
+ raise TypeError(f"Expected key_ids to be a list, not {key_ids!r}")
+
+ if not all(isinstance(x, (UUID, str, bytes)) for x in key_ids):
+ raise TypeError("Some items of key_ids are not a UUID, str, or bytes. Unsure how to continue...")
+
+ uuids = [
+ UUID(bytes=key_id_b)
+ for key_id in key_ids
+ for key_id_b in [
+ key_id.bytes if isinstance(key_id, UUID) else
+ (
+ bytes.fromhex(key_id) if all(c in string.hexdigits for c in key_id) else
+ base64.b64decode(key_id)
+ ) if isinstance(key_id, str) else
+ key_id
+ ]
+ ]
+ return uuids
+
+__all__ = ("PSSH",)
+
+def get_binary_path(*names: str) -> Optional[Path]:
+ """Get the path of the first found binary name."""
+ for name in names:
+ path = shutil.which(name)
+ if path:
+ return Path(path)
+ return None
+
+class Cdm:
+ uuid = UUID(bytes=b"\xed\xef\x8b\xa9\x79\xd6\x4a\xce\xa3\xc8\x27\xdc\xd5\x1d\x21\xed")
+ urn = f"urn:uuid:{uuid}"
+ key_format = urn
+ service_certificate_challenge = b"\x08\x04"
+ common_privacy_cert = (
+ "CAUSxwUKwQIIAxIQFwW5F8wSBIaLBjM6L3cqjBiCtIKSBSKOAjCCAQoCggEBAJntWzsyfateJO/DtiqVtZhSCtW8yzdQPgZFuBTYdrjfQFEE"
+ "Qa2M462xG7iMTnJaXkqeB5UpHVhYQCOn4a8OOKkSeTkwCGELbxWMh4x+Ib/7/up34QGeHleB6KRfRiY9FOYOgFioYHrc4E+shFexN6jWfM3r"
+ "M3BdmDoh+07svUoQykdJDKR+ql1DghjduvHK3jOS8T1v+2RC/THhv0CwxgTRxLpMlSCkv5fuvWCSmvzu9Vu69WTi0Ods18Vcc6CCuZYSC4NZ"
+ "7c4kcHCCaA1vZ8bYLErF8xNEkKdO7DevSy8BDFnoKEPiWC8La59dsPxebt9k+9MItHEbzxJQAZyfWgkCAwEAAToUbGljZW5zZS53aWRldmlu"
+ "ZS5jb20SgAOuNHMUtag1KX8nE4j7e7jLUnfSSYI83dHaMLkzOVEes8y96gS5RLknwSE0bv296snUE5F+bsF2oQQ4RgpQO8GVK5uk5M4PxL/C"
+ "CpgIqq9L/NGcHc/N9XTMrCjRtBBBbPneiAQwHL2zNMr80NQJeEI6ZC5UYT3wr8+WykqSSdhV5Cs6cD7xdn9qm9Nta/gr52u/DLpP3lnSq8x2"
+ "/rZCR7hcQx+8pSJmthn8NpeVQ/ypy727+voOGlXnVaPHvOZV+WRvWCq5z3CqCLl5+Gf2Ogsrf9s2LFvE7NVV2FvKqcWTw4PIV9Sdqrd+QLeF"
+ "Hd/SSZiAjjWyWOddeOrAyhb3BHMEwg2T7eTo/xxvF+YkPj89qPwXCYcOxF+6gjomPwzvofcJOxkJkoMmMzcFBDopvab5tDQsyN9UPLGhGC98"
+ "X/8z8QSQ+spbJTYLdgFenFoGq47gLwDS6NWYYQSqzE3Udf2W7pzk4ybyG4PHBYV3s4cyzdq8amvtE/sNSdOKReuHpfQ=")
+ staging_privacy_cert = (
+ "CAUSxQUKvwIIAxIQKHA0VMAI9jYYredEPbbEyBiL5/mQBSKOAjCCAQoCggEBALUhErjQXQI/zF2V4sJRwcZJtBd82NK+7zVbsGdD3mYePSq8"
+ "MYK3mUbVX9wI3+lUB4FemmJ0syKix/XgZ7tfCsB6idRa6pSyUW8HW2bvgR0NJuG5priU8rmFeWKqFxxPZmMNPkxgJxiJf14e+baq9a1Nuip+"
+ "FBdt8TSh0xhbWiGKwFpMQfCB7/+Ao6BAxQsJu8dA7tzY8U1nWpGYD5LKfdxkagatrVEB90oOSYzAHwBTK6wheFC9kF6QkjZWt9/v70JIZ2fz"
+ "PvYoPU9CVKtyWJOQvuVYCPHWaAgNRdiTwryi901goMDQoJk87wFgRwMzTDY4E5SGvJ2vJP1noH+a2UMCAwEAAToSc3RhZ2luZy5nb29nbGUu"
+ "Y29tEoADmD4wNSZ19AunFfwkm9rl1KxySaJmZSHkNlVzlSlyH/iA4KrvxeJ7yYDa6tq/P8OG0ISgLIJTeEjMdT/0l7ARp9qXeIoA4qprhM19"
+ "ccB6SOv2FgLMpaPzIDCnKVww2pFbkdwYubyVk7jei7UPDe3BKTi46eA5zd4Y+oLoG7AyYw/pVdhaVmzhVDAL9tTBvRJpZjVrKH1lexjOY9Dv"
+ "1F/FJp6X6rEctWPlVkOyb/SfEJwhAa/K81uDLyiPDZ1Flg4lnoX7XSTb0s+Cdkxd2b9yfvvpyGH4aTIfat4YkF9Nkvmm2mU224R1hx0WjocL"
+ "sjA89wxul4TJPS3oRa2CYr5+DU4uSgdZzvgtEJ0lksckKfjAF0K64rPeytvDPD5fS69eFuy3Tq26/LfGcF96njtvOUA4P5xRFtICogySKe6W"
+ "nCUZcYMDtQ0BMMM1LgawFNg4VA+KDCJ8ABHg9bOOTimO0sswHrRWSWX1XF15dXolCk65yEqz5lOfa2/fVomeopkU")
+ root_signed_cert = SignedDrmCertificate()
+ root_signed_cert.ParseFromString(base64.b64decode(
+ "CpwDCAASAQAY3ZSIiwUijgMwggGKAoIBgQC0/jnDZZAD2zwRlwnoaM3yw16b8udNI7EQ24dl39z7nzWgVwNTTPZtNX2meNuzNtI/nECplSZy"
+ "f7i+Zt/FIZh4FRZoXS9GDkPLioQ5q/uwNYAivjQji6tTW3LsS7VIaVM+R1/9Cf2ndhOPD5LWTN+udqm62SIQqZ1xRdbX4RklhZxTmpfrhNfM"
+ "qIiCIHAmIP1+QFAn4iWTb7w+cqD6wb0ptE2CXMG0y5xyfrDpihc+GWP8/YJIK7eyM7l97Eu6iR8nuJuISISqGJIOZfXIbBH/azbkdDTKjDOx"
+ "+biOtOYS4AKYeVJeRTP/Edzrw1O6fGAaET0A+9K3qjD6T15Id1sX3HXvb9IZbdy+f7B4j9yCYEy/5CkGXmmMOROtFCXtGbLynwGCDVZEiMg1"
+ "7B8RsyTgWQ035Ec86kt/lzEcgXyUikx9aBWE/6UI/Rjn5yvkRycSEbgj7FiTPKwS0ohtQT3F/hzcufjUUT4H5QNvpxLoEve1zqaWVT94tGSC"
+ "UNIzX5ECAwEAARKAA1jx1k0ECXvf1+9dOwI5F/oUNnVKOGeFVxKnFO41FtU9v0KG9mkAds2T9Hyy355EzUzUrgkYU0Qy7OBhG+XaE9NVxd0a"
+ "y5AeflvG6Q8in76FAv6QMcxrA4S9IsRV+vXyCM1lQVjofSnaBFiC9TdpvPNaV4QXezKHcLKwdpyywxXRESYqI3WZPrl3IjINvBoZwdVlkHZV"
+ "dA8OaU1fTY8Zr9/WFjGUqJJfT7x6Mfiujq0zt+kw0IwKimyDNfiKgbL+HIisKmbF/73mF9BiC9yKRfewPlrIHkokL2yl4xyIFIPVxe9enz2F"
+ "RXPia1BSV0z7kmxmdYrWDRuu8+yvUSIDXQouY5OcCwEgqKmELhfKrnPsIht5rvagcizfB0fbiIYwFHghESKIrNdUdPnzJsKlVshWTwApHQh7"
+ "evuVicPumFSePGuUBRMS9nG5qxPDDJtGCHs9Mmpoyh6ckGLF7RC5HxclzpC5bc3ERvWjYhN0AqdipPpV2d7PouaAdFUGSdUCDA=="
+ ))
+ root_cert = DrmCertificate()
+ root_cert.ParseFromString(root_signed_cert.drm_certificate)
+
+ MAX_NUM_OF_SESSIONS = 16
+
+ def __init__(
+ self,
+ device_type: Union[DeviceTypes, str],
+ system_id: int,
+ security_level: int,
+ client_id: ClientIdentification,
+ rsa_key: RSA.RsaKey
+ ):
+ """Initialize a Widevine Content Decryption Module (CDM)."""
+ if not device_type:
+ raise ValueError("Device Type must be provided")
+ if isinstance(device_type, str):
+ device_type = DeviceTypes[device_type]
+ if not isinstance(device_type, DeviceTypes):
+ raise TypeError(f"Expected device_type to be a {DeviceTypes!r} not {device_type!r}")
+
+ if not system_id:
+ raise ValueError("System ID must be provided")
+ if not isinstance(system_id, int):
+ raise TypeError(f"Expected system_id to be a {int} not {system_id!r}")
+
+ if not security_level:
+ raise ValueError("Security Level must be provided")
+ if not isinstance(security_level, int):
+ raise TypeError(f"Expected security_level to be a {int} not {security_level!r}")
+
+ if not client_id:
+ raise ValueError("Client ID must be provided")
+ if not isinstance(client_id, ClientIdentification):
+ raise TypeError(f"Expected client_id to be a {ClientIdentification} not {client_id!r}")
+
+ if not rsa_key:
+ raise ValueError("RSA Key must be provided")
+ if not isinstance(rsa_key, RSA.RsaKey):
+ raise TypeError(f"Expected rsa_key to be a {RSA.RsaKey} not {rsa_key!r}")
+
+ self.device_type = device_type
+ self.system_id = system_id
+ self.security_level = security_level
+ self.__client_id = client_id
+
+ self.__signer = pss.new(rsa_key)
+ self.__decrypter = PKCS1_OAEP.new(rsa_key)
+
+ self.__sessions: dict[bytes, Session] = {}
+
+ @classmethod
+ def from_device(cls, device: Device) -> Cdm:
+ return cls(
+ device_type=device.type,
+ system_id=device.system_id,
+ security_level=device.security_level,
+ client_id=device.client_id,
+ rsa_key=device.private_key
+ )
+
+ def open(self) -> bytes:
+ if len(self.__sessions) > self.MAX_NUM_OF_SESSIONS:
+ raise TooManySessions(f"Too many Sessions open ({self.MAX_NUM_OF_SESSIONS}).")
+
+ session = Session(len(self.__sessions) + 1)
+ self.__sessions[session.id] = session
+
+ return session.id
+
+ def close(self, session_id: bytes) -> None:
+ session = self.__sessions.get(session_id)
+ if not session:
+ raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
+ del self.__sessions[session_id]
+
+ def set_service_certificate(self, session_id: bytes, certificate: Optional[Union[bytes, str]]) -> Optional[str]:
+ session = self.__sessions.get(session_id)
+ if not session:
+ raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
+
+ if certificate is None:
+ if session.service_certificate:
+ drm_certificate = DrmCertificate()
+ drm_certificate.ParseFromString(session.service_certificate.drm_certificate)
+ provider_id = drm_certificate.provider_id
+ else:
+ provider_id = None
+ session.service_certificate = None
+ return provider_id
+
+ if isinstance(certificate, str):
+ try:
+ certificate = base64.b64decode(certificate)
+ except binascii.Error:
+ raise DecodeError("Could not decode certificate string as Base64, expected bytes.")
+ elif not isinstance(certificate, bytes):
+ raise DecodeError(f"Expecting Certificate to be bytes, not {certificate!r}")
+
+ signed_message = SignedMessage()
+ signed_drm_certificate = SignedDrmCertificate()
+ drm_certificate = DrmCertificate()
+
+ try:
+ signed_message.ParseFromString(certificate)
+ if all(
+
+ bytes(chunk) == signed_message.SerializeToString()
+ for chunk in zip(*[iter(certificate)] * len(signed_message.SerializeToString()))
+ ):
+ signed_drm_certificate.ParseFromString(signed_message.msg)
+ else:
+ signed_drm_certificate.ParseFromString(certificate)
+ if signed_drm_certificate.SerializeToString() != certificate:
+ raise DecodeError("partial parse")
+ except DecodeError as e:
+
+ raise DecodeError(f"Could not parse certificate as a SignedDrmCertificate, {e}")
+
+ try:
+ pss. \
+ new(RSA.import_key(self.root_cert.public_key)). \
+ verify(
+ msg_hash=SHA1.new(signed_drm_certificate.drm_certificate),
+ signature=signed_drm_certificate.signature
+ )
+ except (ValueError, TypeError):
+ raise SignatureMismatch("Signature Mismatch on SignedDrmCertificate, rejecting certificate")
+
+ try:
+ drm_certificate.ParseFromString(signed_drm_certificate.drm_certificate)
+ if drm_certificate.SerializeToString() != signed_drm_certificate.drm_certificate:
+ raise DecodeError("partial parse")
+ except DecodeError as e:
+ raise DecodeError(f"Could not parse signed certificate's message as a DrmCertificate, {e}")
+
+
+
+ session.service_certificate = signed_drm_certificate
+ return drm_certificate.provider_id
+
+ def get_service_certificate(self, session_id: bytes) -> Optional[SignedDrmCertificate]:
+ session = self.__sessions.get(session_id)
+ if not session:
+ raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
+
+ return session.service_certificate
+
+ def get_license_challenge(
+ self,
+ session_id: bytes,
+ pssh: PSSH,
+ license_type: str = "STREAMING",
+ privacy_mode: bool = True
+ ) -> bytes:
+ session = self.__sessions.get(session_id)
+ if not session:
+ raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
+
+ if not pssh:
+ raise InvalidInitData("A pssh must be provided.")
+ if not isinstance(pssh, PSSH):
+ raise InvalidInitData(f"Expected pssh to be a {PSSH}, not {pssh!r}")
+
+ if not isinstance(license_type, str):
+ raise InvalidLicenseType(f"Expected license_type to be a {str}, not {license_type!r}")
+ if license_type not in LicenseType.keys():
+ raise InvalidLicenseType(
+ f"Invalid license_type value of '{license_type}'. "
+ f"Available values: {LicenseType.keys()}"
+ )
+
+ if self.device_type == DeviceTypes.ANDROID:
+ request_id = (get_random_bytes(4) + (b"\x00" * 4))
+ request_id += session.number.to_bytes(8, "little")
+ request_id = request_id.hex().upper().encode()
+ else:
+ request_id = get_random_bytes(16)
+
+ license_request = LicenseRequest(
+ client_id=(
+ self.__client_id
+ ) if not (session.service_certificate and privacy_mode) else None,
+ encrypted_client_id=self.encrypt_client_id(
+ client_id=self.__client_id,
+ service_certificate=session.service_certificate
+ ) if session.service_certificate and privacy_mode else None,
+ content_id=LicenseRequest.ContentIdentification(
+ widevine_pssh_data=LicenseRequest.ContentIdentification.WidevinePsshData(
+ pssh_data=[pssh.init_data],
+ license_type=license_type,
+ request_id=request_id
+ )
+ ),
+ type="NEW",
+ request_time=int(time.time()),
+ protocol_version="VERSION_2_1",
+ key_control_nonce=random.randrange(1, 2 ** 31),
+ ).SerializeToString()
+
+ signed_license_request = SignedMessage(
+ type="LICENSE_REQUEST",
+ msg=license_request,
+ signature=self.__signer.sign(SHA1.new(license_request))
+ ).SerializeToString()
+
+ session.context[request_id] = self.derive_context(license_request)
+
+ return signed_license_request
+
+ def parse_license(self, session_id: bytes, license_message: Union[SignedMessage, bytes, str]) -> None:
+ session = self.__sessions.get(session_id)
+ if not session:
+ raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
+
+ if not license_message:
+ raise InvalidLicenseMessage("Cannot parse an empty license_message")
+
+ if isinstance(license_message, str):
+ try:
+ license_message = base64.b64decode(license_message)
+ except (binascii.Error, binascii.Incomplete) as e:
+ raise InvalidLicenseMessage(f"Could not decode license_message as Base64, {e}")
+
+ if isinstance(license_message, bytes):
+ signed_message = SignedMessage()
+ try:
+ signed_message.ParseFromString(license_message)
+ if signed_message.SerializeToString() != license_message:
+ raise DecodeError(license_message)
+ except DecodeError as e:
+ raise InvalidLicenseMessage(f"Could not parse license_message as a SignedMessage, {e}")
+ license_message = signed_message
+
+ if not isinstance(license_message, SignedMessage):
+ raise InvalidLicenseMessage(f"Expecting license_response to be a SignedMessage, got {license_message!r}")
+
+ if license_message.type != SignedMessage.MessageType.Value("LICENSE"):
+ raise InvalidLicenseMessage(
+ f"Expecting a LICENSE message, not a "
+ f"'{SignedMessage.MessageType.Name(license_message.type)}' message."
+ )
+
+ licence = License()
+ licence.ParseFromString(license_message.msg)
+
+ context = session.context.get(licence.id.request_id)
+ if not context:
+ raise InvalidContext("Cannot parse a license message without first making a license request")
+
+ enc_key, mac_key_server, _ = self.derive_keys(
+ *context,
+ key=self.__decrypter.decrypt(license_message.session_key)
+ )
+ computed_signature = HMAC. \
+ new(mac_key_server, digestmod=SHA256). \
+ update(license_message.oemcrypto_core_message or b""). \
+ update(license_message.msg). \
+ digest()
+
+ if license_message.signature != computed_signature:
+ raise SignatureMismatch("Signature Mismatch on License Message, rejecting license")
+
+ session.keys = [
+ Key.from_key_container(key, enc_key)
+ for key in licence.key
+ ]
+
+ del session.context[licence.id.request_id]
+
+ def get_keys(self, session_id: bytes, type_: Optional[Union[int, str]] = None) -> list[Key]:
+ session = self.__sessions.get(session_id)
+ if not session:
+ raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
+
+ try:
+ if isinstance(type_, str):
+ type_ = License.KeyContainer.KeyType.Value(type_)
+ elif isinstance(type_, int):
+ License.KeyContainer.KeyType.Name(type_)
+ elif type_ is not None:
+ raise TypeError(f"Expected type_ to be a {License.KeyContainer.KeyType} or int, not {type_!r}")
+ except ValueError as e:
+ raise ValueError(f"Could not parse type_ as a {License.KeyContainer.KeyType}, {e}")
+
+ return [
+ key
+ for key in session.keys
+ if not type_ or key.type == License.KeyContainer.KeyType.Name(type_)
+ ]
+
+ def decrypt(
+ self,
+ session_id: bytes,
+ input_file: Union[Path, str],
+ output_file: Union[Path, str],
+ temp_dir: Optional[Union[Path, str]] = None,
+ exists_ok: bool = False
+ ) -> int:
+ if not input_file:
+ raise ValueError("Cannot decrypt nothing, specify an input path")
+ if not output_file:
+ raise ValueError("Cannot decrypt nowhere, specify an output path")
+
+ if not isinstance(input_file, (Path, str)):
+ raise ValueError(f"Expecting input_file to be a Path or str, got {input_file!r}")
+ if not isinstance(output_file, (Path, str)):
+ raise ValueError(f"Expecting output_file to be a Path or str, got {output_file!r}")
+ if not isinstance(temp_dir, (Path, str)) and temp_dir is not None:
+ raise ValueError(f"Expecting temp_dir to be a Path or str, got {temp_dir!r}")
+
+ input_file = Path(input_file)
+ output_file = Path(output_file)
+ temp_dir_ = Path(temp_dir) if temp_dir else None
+
+ if not input_file.is_file():
+ raise FileNotFoundError(f"Input file does not exist, {input_file}")
+ if output_file.is_file() and not exists_ok:
+ raise FileExistsError(f"Output file already exists, {output_file}")
+
+ session = self.__sessions.get(session_id)
+ if not session:
+ raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
+
+ if not session.keys:
+ raise NoKeysLoaded("No Keys are loaded yet, cannot decrypt")
+
+ platform = {"win32": "win", "darwin": "osx"}.get(sys.platform, sys.platform)
+ executable = get_binary_path("shaka-packager", f"packager-{platform}", f"packager-{platform}-x64")
+ if not executable:
+ raise EnvironmentError("Shaka Packager executable not found but is required")
+
+ args = [
+ f"input={input_file},stream=0,output={output_file}",
+ "--enable_raw_key_decryption",
+ "--keys", ",".join([
+ label
+ for i, key in enumerate(session.keys)
+ for label in [
+ f"label=1_{i}:key_id={key.kid.hex}:key={key.key.hex()}",
+
+ f"label=2_{i}:key_id={'0' * 32}:key={key.key.hex()}"
+ ]
+ if key.type == "CONTENT"
+ ])
+ ]
+
+ if temp_dir_:
+ temp_dir_.mkdir(parents=True, exist_ok=True)
+ args.extend(["--temp_dir", str(temp_dir_)])
+
+ return subprocess.check_call([executable, *args])
+
+ @staticmethod
+ def encrypt_client_id(
+ client_id: ClientIdentification,
+ service_certificate: Union[SignedDrmCertificate, DrmCertificate],
+ key: Optional[bytes] = None,
+ iv: Optional[bytes] = None
+ ) -> EncryptedClientIdentification:
+ """Encrypt the Client ID with the Service's Privacy Certificate."""
+ privacy_key = key or get_random_bytes(16)
+ privacy_iv = iv or get_random_bytes(16)
+
+ if isinstance(service_certificate, SignedDrmCertificate):
+ drm_certificate = DrmCertificate()
+ drm_certificate.ParseFromString(service_certificate.drm_certificate)
+ service_certificate = drm_certificate
+ if not isinstance(service_certificate, DrmCertificate):
+ raise ValueError(f"Expecting Service Certificate to be a DrmCertificate, not {service_certificate!r}")
+
+ encrypted_client_id = EncryptedClientIdentification(
+ provider_id=service_certificate.provider_id,
+ service_certificate_serial_number=service_certificate.serial_number,
+ encrypted_client_id=AES.
+ new(privacy_key, AES.MODE_CBC, privacy_iv).
+ encrypt(CryptoPadding.pad(client_id.SerializeToString(), 16)),
+ encrypted_client_id_iv=privacy_iv,
+ encrypted_privacy_key=PKCS1_OAEP.
+ new(RSA.importKey(service_certificate.public_key)).
+ encrypt(privacy_key)
+ )
+
+ return encrypted_client_id
+
+ @staticmethod
+ def derive_context(message: bytes) -> tuple[bytes, bytes]:
+ """Returns 2 Context Data used for computing the AES Encryption and HMAC Keys."""
+
+ def _get_enc_context(msg: bytes) -> bytes:
+ label = b"ENCRYPTION"
+ key_size = 16 * 8
+ return label + b"\x00" + msg + key_size.to_bytes(4, "big")
+
+ def _get_mac_context(msg: bytes) -> bytes:
+ label = b"AUTHENTICATION"
+ key_size = 32 * 8 * 2
+ return label + b"\x00" + msg + key_size.to_bytes(4, "big")
+
+ return _get_enc_context(message), _get_mac_context(message)
+
+ @staticmethod
+ def derive_keys(enc_context: bytes, mac_context: bytes, key: bytes) -> tuple[bytes, bytes, bytes]:
+
+ def _derive(session_key: bytes, context: bytes, counter: int) -> bytes:
+ return CMAC. \
+ new(session_key, ciphermod=AES). \
+ update(counter.to_bytes(1, "big") + context). \
+ digest()
+
+ enc_key = _derive(key, enc_context, 1)
+ mac_key_server = _derive(key, mac_context, 1)
+ mac_key_server += _derive(key, mac_context, 2)
+ mac_key_client = _derive(key, mac_context, 3)
+ mac_key_client += _derive(key, mac_context, 4)
+ return enc_key, mac_key_server, mac_key_client
+
+
+__all__ = ("Cdm",)
+
+def read_binary_argument(value: str) -> bytes:
+ path = Path(value)
+ if path.exists():
+ return path.read_bytes()
+ cleaned = value.strip()
+ try:
+ return bytes.fromhex(cleaned)
+ except ValueError:
+ pass
+ try:
+ return base64.b64decode(cleaned)
+ except binascii.Error as error:
+ raise ValueError(f"Input is not a valid path, hex value, or Base64 value: {value}") from error
+
+def parse_headers(values: Optional[list[str]]) -> dict[str, str]:
+ headers = {}
+ for item in values or []:
+ if ":" not in item:
+ raise ValueError(f"Invalid header value: {item}. Expected format: Name: Value")
+ name, value = item.split(":", 1)
+ headers[name.strip()] = value.strip().strip('"')
+ return headers
+
+def build_device_from_args(args: argparse.Namespace) -> Device:
+ if getattr(args, "wvd", None):
+ return Device.load(args.wvd)
+ if getattr(args, "device_dir", None):
+ return Device.from_directory(
+ path=args.device_dir,
+ type_=normalize_device_type(args.type),
+ security_level=args.level,
+ flags=None
+ )
+ if getattr(args, "key", None) and getattr(args, "client_id", None):
+ return Device.from_files(
+ private_key=args.key,
+ client_id=args.client_id,
+ vmp=getattr(args, "vmp", None),
+ type_=normalize_device_type(args.type),
+ security_level=args.level,
+ flags=None
+ )
+ raise ValueError("Provide a WVD file, a device directory, or both a private key file and a client ID blob file.")
+
+
+def command_info(args: argparse.Namespace) -> int:
+ device = build_device_from_args(args)
+ client_info = {entry.name: entry.value for entry in device.client_id.client_info}
+ result = {
+ "type": device.type.name,
+ "system_id": device.system_id,
+ "security_level": device.security_level,
+ "flags": dict(device.flags),
+ "client_info": client_info,
+ "has_vmp": bool(device.client_id.vmp_data)
+ }
+ print(json.dumps(result, indent=2, ensure_ascii=False))
+ return 0
+
+
+def normalize_device_type(value: Union[str, DeviceTypes]) -> DeviceTypes:
+ if isinstance(value, DeviceTypes):
+ return value
+ if not isinstance(value, str):
+ raise ValueError(f"Invalid device type: {value!r}")
+ normalized = value.strip().upper()
+ if normalized not in DeviceTypes.__members__:
+ valid = ", ".join(DeviceTypes.__members__)
+ raise ValueError(f"Invalid device type '{value}'. Expected one of: {valid}")
+ return DeviceTypes[normalized]
+
+
+def build_wvd_name(device: Device, data: bytes) -> str:
+ client_info = {entry.name: entry.value for entry in device.client_id.client_info}
+ company = (
+ client_info.get("company_name")
+ or client_info.get("manufacturer")
+ or client_info.get("vendor")
+ or "unknown"
+ )
+ model = (
+ client_info.get("model_name")
+ or client_info.get("model")
+ or client_info.get("device_name")
+ or "device"
+ )
+ name = f"{company} {model}"
+ if client_info.get("widevine_cdm_version"):
+ name += f" {client_info['widevine_cdm_version']}"
+ name += f" {crc32(data).to_bytes(4, 'big').hex()}"
+ try:
+ name = unidecode(name.strip().lower().replace(" ", "_"))
+ except UnidecodeError as exc:
+ raise ValueError(f"Failed to sanitize WVD name, {exc}") from exc
+ name = re.sub(r"[^a-zA-Z0-9_.-]+", "_", name).strip("._-")
+ return f"{name}_{device.system_id}_l{device.security_level}.wvd"
+
+
+def command_create_wvd(args: argparse.Namespace) -> int:
+ device_type = normalize_device_type(args.type)
+ if getattr(args, "device_dir", None):
+ device = Device.from_directory(
+ path=args.device_dir,
+ type_=device_type,
+ security_level=args.level,
+ flags=None
+ )
+ else:
+ if not args.key:
+ raise ValueError("A private key file is required when --device-dir is not used.")
+ if not args.client_id:
+ raise ValueError("A client ID blob file is required when --device-dir is not used.")
+ device = Device.from_files(
+ private_key=args.key,
+ client_id=args.client_id,
+ vmp=args.vmp,
+ type_=device_type,
+ security_level=args.level,
+ flags=None
+ )
+ wvd_data = device.dumps()
+ if args.output:
+ output = Path(args.output)
+ if output.suffix:
+ if output.suffix.lower() != ".wvd":
+ logging.getLogger("create-wvd").warning(
+ "Saving WVD with extension '%s', but '.wvd' is recommended.",
+ output.suffix
+ )
+ output_path = output
+ else:
+ output_path = output / build_wvd_name(device, wvd_data)
+ else:
+ output_path = Path.cwd() / build_wvd_name(device, wvd_data)
+ if output_path.exists() and not args.overwrite:
+ raise FileExistsError(f"Output already exists: {output_path}")
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_bytes(wvd_data)
+ print(f"Created Widevine Device file: {output_path}")
+ print(f"Type: {device.type.name}")
+ print(f"System ID: {device.system_id}")
+ print(f"Security Level: {device.security_level}")
+ return 0
+
+
+def find_single_wvd_in_current_directory() -> Path:
+ candidates = sorted(Path.cwd().glob("*.wvd"))
+ if not candidates:
+ raise FileNotFoundError("No WVD file was provided and no .wvd file was found in the current directory.")
+ if len(candidates) > 1:
+ names = ", ".join(path.name for path in candidates)
+ raise ValueError(f"No WVD file was provided and multiple .wvd files were found: {names}")
+ return candidates[0]
+
+
+def write_metadata_file(path: Path, device: Device) -> None:
+ client_info = {entry.name: entry.value for entry in device.client_id.client_info}
+ capabilities = {}
+ try:
+ capabilities = MessageToDict(
+ device.client_id,
+ preserving_proto_field_name=True
+ ).get("client_capabilities", {})
+ except Exception:
+ capabilities = {}
+ lines = [
+ "wvd:",
+ f" device_type: {device.type.name}",
+ f" security_level: {device.security_level}",
+ "client_info:",
+ ]
+ for key, value in client_info.items():
+ safe_value = str(value).replace("\\", "\\\\").replace('"', '\\"')
+ lines.append(f' {key}: "{safe_value}"')
+ lines.append("capabilities:")
+ if capabilities:
+ for key, value in capabilities.items():
+ safe_value = str(value).replace("\\", "\\\\").replace('"', '\\"')
+ lines.append(f' {key}: "{safe_value}"')
+ else:
+ lines.append(" {}")
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+def command_export_wvd(args: argparse.Namespace) -> int:
+ input_path = Path(args.input) if args.input else find_single_wvd_in_current_directory()
+ if not input_path.is_file():
+ raise FileNotFoundError(f"WVD file does not exist: {input_path}")
+ device = Device.load(input_path)
+ output_root = Path(args.output) if args.output else Path.cwd()
+ output = output_root / input_path.stem
+ if output.exists():
+ if any(output.iterdir()) and not args.overwrite:
+ raise FileExistsError(f"Output directory is not empty: {output}")
+ else:
+ output.mkdir(parents=True, exist_ok=True)
+ metadata_path = output / "metadata.yml"
+ private_key_pem_path = output / "private_key.pem"
+ private_key_der_path = output / "private_key.der"
+ client_id_path = output / "client_id.bin"
+ vmp_path = output / "vmp.bin"
+ target_paths = [metadata_path, private_key_pem_path, private_key_der_path, client_id_path]
+ if device.client_id.vmp_data:
+ target_paths.append(vmp_path)
+ for target in target_paths:
+ if target.exists() and not args.overwrite:
+ raise FileExistsError(f"Output already exists: {target}")
+ write_metadata_file(metadata_path, device)
+ private_key_pem_path.write_text(device.private_key.export_key().decode(), encoding="utf-8")
+ private_key_der_path.write_bytes(device.private_key.export_key(format="DER"))
+ client_id_path.write_bytes(device.client_id.SerializeToString())
+ if device.client_id.vmp_data:
+ vmp_path.write_bytes(device.client_id.vmp_data)
+ print(f"Exported Widevine Device file: {input_path}")
+ print(f"Output directory: {output}")
+ print(f"Exported metadata: {metadata_path}")
+ print(f"Exported private key PEM: {private_key_pem_path}")
+ print(f"Exported private key DER: {private_key_der_path}")
+ print(f"Exported client ID: {client_id_path}")
+ if device.client_id.vmp_data:
+ print(f"Exported VMP: {vmp_path}")
+ else:
+ print("No VMP data available.")
+ return 0
+
+
+def command_migrate_wvd(args: argparse.Namespace) -> int:
+ output = Path(args.output)
+ if output.exists() and not args.overwrite:
+ raise FileExistsError(f"Output already exists: {output}")
+ device = Device.migrate(Path(args.input).read_bytes())
+ device.dump(output)
+ print(f"Migrated WVD file: {output}")
+ return 0
+
+
+def command_license(args: argparse.Namespace) -> int:
+ device = build_device_from_args(args)
+ pssh = PSSH(args.pssh)
+ cdm = Cdm.from_device(device)
+ session_id = cdm.open()
+ try:
+ if args.privacy and args.certificate:
+ cdm.set_service_certificate(session_id, read_binary_argument(args.certificate))
+ challenge = cdm.get_license_challenge(session_id, pssh, args.license_type, privacy_mode=args.privacy)
+ if args.challenge_output:
+ Path(args.challenge_output).write_bytes(challenge)
+ if not args.server and not args.license_response:
+ print(base64.b64encode(challenge).decode())
+ return 0
+ if args.license_response:
+ license_message = read_binary_argument(args.license_response)
+ else:
+ response = requests.post(args.server, headers=parse_headers(args.header), data=challenge)
+ response.raise_for_status()
+ license_message = response.content
+ cdm.parse_license(session_id, license_message)
+ for key in cdm.get_keys(session_id):
+ if args.include_non_content or key.type == "CONTENT":
+ print(f"[{key.type}] {key.kid.hex}:{key.key.hex()}")
+ finally:
+ cdm.close(session_id)
+ return 0
+
+
+def command_pssh(args: argparse.Namespace) -> int:
+ pssh = PSSH(args.input)
+ if args.to_widevine:
+ pssh.to_widevine()
+ if args.set_key_id:
+ pssh.set_key_ids([UUID(value) for value in args.set_key_id])
+ if args.output == "base64":
+ print(pssh.dumps())
+ elif args.output == "hex":
+ print(pssh.dump().hex())
+ elif args.output == "json":
+ data = {
+ "system_id": str(pssh.system_id),
+ "key_ids": [str(key_id) for key_id in pssh.key_ids],
+ "init_data": pssh.init_data.hex() if isinstance(pssh.init_data, bytes) else str(pssh.init_data),
+ "box": pssh.dump().hex()
+ }
+ print(json.dumps(data, indent=2, ensure_ascii=False))
+ else:
+ sys.stdout.buffer.write(pssh.dump())
+ return 0
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="pywv",
+ description="Single-file Widevine utility with integrated proto, WVD, blob, key, local CDM, and PSSH support."
+ )
+ parser.add_argument("-d", "--debug", action="store_true", help="Enable debug logging.")
+ parser.add_argument("-v", "--version", action="store_true", help="Print version information.")
+ sub = parser.add_subparsers(dest="cmd", required=False)
+
+ sp = sub.add_parser("info", help="Show information about a WVD file, device directory, or key/blob pair.")
+ sp.add_argument("-w", "--wvd")
+ sp.add_argument("-D", "--device-dir")
+ sp.add_argument("-k", "--key")
+ sp.add_argument("-c", "--client-id")
+ sp.add_argument("-vmp", "--vmp")
+ sp.add_argument("-t", "--type", default="ANDROID", help="Device type: ANDROID/android or CHROME/chrome.")
+ sp.add_argument("-l", "--level", type=int, default=3)
+ sp.set_defaults(func=command_info)
+
+ sp = sub.add_parser("create-wvd", help="Create a WVD v2 file from a device directory or key/blob files.")
+ sp.add_argument("-D", "--device-dir")
+ sp.add_argument("-k", "--key")
+ sp.add_argument("-c", "--client-id")
+ sp.add_argument("-vmp", "--vmp")
+ sp.add_argument("-t", "--type", default="ANDROID", help="Device type: ANDROID/android or CHROME/chrome.")
+ sp.add_argument("-l", "--level", type=int, default=3)
+ sp.add_argument("-o", "--output", default=None)
+ sp.add_argument("--overwrite", action="store_true")
+ sp.set_defaults(func=command_create_wvd)
+
+ sp = sub.add_parser("export-wvd", help="Export a WVD file into key, blob, and optional VMP files.")
+ sp.add_argument("input", nargs="?")
+ sp.add_argument("-o", "--output", default=None)
+ sp.add_argument("--overwrite", action="store_true")
+ sp.set_defaults(func=command_export_wvd)
+
+ sp = sub.add_parser("migrate-wvd", help="Migrate a WVD v1 file to WVD v2.")
+ sp.add_argument("input")
+ sp.add_argument("-o", "--output", default=None)
+ sp.add_argument("--overwrite", action="store_true")
+ sp.set_defaults(func=command_migrate_wvd)
+
+ sp = sub.add_parser("license", help="Create a license challenge and optionally parse a license response.")
+ sp.add_argument("--pssh", required=True)
+ sp.add_argument("--server")
+ sp.add_argument("-H", "--header", action="append", default=[])
+ sp.add_argument("-w", "--wvd")
+ sp.add_argument("-D", "--device-dir")
+ sp.add_argument("-k", "--key")
+ sp.add_argument("-c", "--client-id")
+ sp.add_argument("-vmp", "--vmp")
+ sp.add_argument("-t", "--type", default="ANDROID", help="Device type: ANDROID/android or CHROME/chrome.")
+ sp.add_argument("-l", "--level", type=int, default=3)
+ sp.add_argument("--certificate")
+ sp.add_argument("--privacy", action="store_true")
+ sp.add_argument("--license-type", default="STREAMING", choices=LicenseType.keys())
+ sp.add_argument("--challenge-output")
+ sp.add_argument("--license-response")
+ sp.add_argument("--include-non-content", action="store_true")
+ sp.set_defaults(func=command_license)
+
+ sp = sub.add_parser("pssh", help="Inspect or rewrite PSSH data.")
+ sp.add_argument("input")
+ sp.add_argument("-o", "--output", default="base64", choices=["base64", "hex", "json", "raw"])
+ sp.add_argument("--to-widevine", action="store_true")
+ sp.add_argument("--set-key-id", action="append")
+ sp.set_defaults(func=command_pssh)
+ return parser
+
+__all__ = ("PSSH", "Device", "DeviceTypes", "Cdm", "Key", "Session", "ClientIdentification", "DrmCertificate", "SignedDrmCertificate", "SignedMessage", "License", "LicenseRequest", "WidevinePsshData", "FileHashes", "EncryptedClientIdentification", "Exception", "TooManySessions", "InvalidSession", "InvalidInitData", "InvalidLicenseType", "InvalidLicenseMessage", "InvalidContext", "SignatureMismatch", "NoKeysLoaded")
+
+if __name__ == "__main__":
+ cli_parser = build_parser()
+ cli_args = cli_parser.parse_args()
+ logging.basicConfig(level=logging.DEBUG if cli_args.debug else logging.INFO, format="%(name)s - %(levelname)s - %(message)s")
+ if cli_args.version:
+ print(__version__)
+ raise SystemExit(0)
+ if not cli_args.cmd:
+ cli_parser.print_help()
+ raise SystemExit(0)
+ try:
+ raise SystemExit(cli_args.func(cli_args))
+ except Exception as error:
+ logging.getLogger("main").error(str(error))
+ raise SystemExit(1)
\ No newline at end of file