Initial Commit
This commit is contained in:
parent
de14f87c5c
commit
dc9fc1e217
|
@ -0,0 +1,71 @@
|
|||
# pyplayready
|
||||
All of this is already public. 100% of this code has been derived from the mspr_toolkit.
|
||||
|
||||
## Installation
|
||||
```shell
|
||||
pip install pyplayready
|
||||
```
|
||||
|
||||
Run `pyplayready --help` to view available cli functions
|
||||
|
||||
## Devices
|
||||
Run the command below to create a Playready Device (.prd) from a `bgroupcert.dat` and `zgpriv.dat`:
|
||||
```shell
|
||||
pyplayready create-device -c bgroupcert.dat -g zgpriv.dat
|
||||
```
|
||||
|
||||
## Usage
|
||||
```python
|
||||
from pyplayready.cdm import Cdm
|
||||
from pyplayready.device import Device
|
||||
from pyplayready.pssh import PSSH
|
||||
|
||||
import requests
|
||||
|
||||
device = Device.load("C:/Path/To/A/Device.prd")
|
||||
cdm = Cdm.from_device(device)
|
||||
|
||||
pssh = PSSH(
|
||||
"AAADfHBzc2gAAAAAmgTweZhAQoarkuZb4IhflQAAA1xcAwAAAQABAFIDPABXAFIATQBIAEUAQQBEAEUAUgAgAHgAbQBsAG4AcwA9ACIAaAB0AH"
|
||||
"QAcAA6AC8ALwBzAGMAaABlAG0AYQBzAC4AbQBpAGMAcgBvAHMAbwBmAHQALgBjAG8AbQAvAEQAUgBNAC8AMgAwADAANwAvADAAMwAvAFAAbABh"
|
||||
"AHkAUgBlAGEAZAB5AEgAZQBhAGQAZQByACIAIAB2AGUAcgBzAGkAbwBuAD0AIgA0AC4AMAAuADAALgAwACIAPgA8AEQAQQBUAEEAPgA8AFAAUg"
|
||||
"BPAFQARQBDAFQASQBOAEYATwA+ADwASwBFAFkATABFAE4APgAxADYAPAAvAEsARQBZAEwARQBOAD4APABBAEwARwBJAEQAPgBBAEUAUwBDAFQA"
|
||||
"UgA8AC8AQQBMAEcASQBEAD4APAAvAFAAUgBPAFQARQBDAFQASQBOAEYATwA+ADwASwBJAEQAPgA0AFIAcABsAGIAKwBUAGIATgBFAFMAOAB0AE"
|
||||
"cAawBOAEYAVwBUAEUASABBAD0APQA8AC8ASwBJAEQAPgA8AEMASABFAEMASwBTAFUATQA+AEsATABqADMAUQB6AFEAUAAvAE4AQQA9ADwALwBD"
|
||||
"AEgARQBDAEsAUwBVAE0APgA8AEwAQQBfAFUAUgBMAD4AaAB0AHQAcABzADoALwAvAHAAcgBvAGYAZgBpAGMAaQBhAGwAcwBpAHQAZQAuAGsAZQ"
|
||||
"B5AGQAZQBsAGkAdgBlAHIAeQAuAG0AZQBkAGkAYQBzAGUAcgB2AGkAYwBlAHMALgB3AGkAbgBkAG8AdwBzAC4AbgBlAHQALwBQAGwAYQB5AFIA"
|
||||
"ZQBhAGQAeQAvADwALwBMAEEAXwBVAFIATAA+ADwAQwBVAFMAVABPAE0AQQBUAFQAUgBJAEIAVQBUAEUAUwA+ADwASQBJAFMAXwBEAFIATQBfAF"
|
||||
"YARQBSAFMASQBPAE4APgA4AC4AMQAuADIAMwAwADQALgAzADEAPAAvAEkASQBTAF8ARABSAE0AXwBWAEUAUgBTAEkATwBOAD4APAAvAEMAVQBT"
|
||||
"AFQATwBNAEEAVABUAFIASQBCAFUAVABFAFMAPgA8AC8ARABBAFQAQQA+ADwALwBXAFIATQBIAEUAQQBEAEUAUgA+AA=="
|
||||
)
|
||||
|
||||
request = cdm.get_license_challenge(pssh.wrm_headers[0])
|
||||
|
||||
response = requests.post(
|
||||
url="https://test.playready.microsoft.com/service/rightsmanager.asmx?cfg=(persist:false,sl:2000)",
|
||||
headers={
|
||||
'Content-Type': 'text/xml; charset=UTF-8',
|
||||
},
|
||||
data=request,
|
||||
)
|
||||
|
||||
cdm.parse_license(response.text)
|
||||
|
||||
for key in cdm.get_keys():
|
||||
print(f"{key.key_id.hex}:{key.key.hex()}")
|
||||
```
|
||||
|
||||
## Disclaimer
|
||||
|
||||
1. This project requires a valid Microsoft Certificate and Group Key, which are not provided by this project.
|
||||
2. Public test provisions are available and provided by Microsoft to use for testing projects such as this one.
|
||||
3. This project does not condone piracy or any action against the terms of the DRM systems.
|
||||
4. All efforts in this project have been the result of Reverse-Engineering, Publicly available research, and Trial & Error.
|
||||
5. Do not use this program to decrypt or access any content for which you do not have the legal rights or explicit permission.
|
||||
6. Unauthorized decryption or distribution of copyrighted materials is a violation of applicable laws and intellectual property rights.
|
||||
7. This tool must not be used for any illegal activities, including but not limited to piracy, circumventing digital rights management (DRM), or unauthorized access to protected content.
|
||||
8. The developers, contributors, and maintainers of this program are not responsible for any misuse or illegal activities performed using this software.
|
||||
9. By using this program, you agree to comply with all applicable laws and regulations governing digital rights and copyright protections.
|
||||
|
||||
## Credits
|
||||
+ [mspr_toolkit](https://security-explorations.com/materials/mspr_toolkit.zip)
|
|
@ -0,0 +1 @@
|
|||
__version__ = "0.0.1"
|
|
@ -0,0 +1,428 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
from Crypto.Hash import SHA256
|
||||
from Crypto.Signature import DSS
|
||||
from construct import Bytes, Const, Int32ub, GreedyRange, Switch, Container, ListContainer
|
||||
from construct import Int16ub, Array
|
||||
from construct import Struct, this
|
||||
|
||||
from pyplayready.ecc_key import ECCKey
|
||||
|
||||
|
||||
class _BCertStructs:
|
||||
DrmBCertBasicInfo = Struct(
|
||||
"cert_id" / Bytes(16),
|
||||
"security_level" / Int32ub,
|
||||
"flags" / Int32ub,
|
||||
"cert_type" / Int32ub,
|
||||
"public_key_digest" / Bytes(32),
|
||||
"expiration_date" / Int32ub,
|
||||
"client_id" / Bytes(16)
|
||||
)
|
||||
|
||||
# TODO: untested
|
||||
DrmBCertDomainInfo = Struct(
|
||||
"service_id" / Bytes(16),
|
||||
"account_id" / Bytes(16),
|
||||
"revision_timestamp" / Int32ub,
|
||||
"domain_url_length" / Int32ub,
|
||||
"domain_url" / Bytes((this.domain_url_length + 3) & 0xfffffffc)
|
||||
)
|
||||
|
||||
# TODO: untested
|
||||
DrmBCertPCInfo = Struct(
|
||||
"security_version" / Int32ub
|
||||
)
|
||||
|
||||
# TODO: untested
|
||||
DrmBCertDeviceInfo = Struct(
|
||||
"max_license" / Int32ub,
|
||||
"max_header" / Int32ub,
|
||||
"max_chain_depth" / Int32ub
|
||||
)
|
||||
|
||||
DrmBCertFeatureInfo = Struct(
|
||||
"feature_count" / Int32ub,
|
||||
"features" / Array(this.feature_count, Int32ub)
|
||||
)
|
||||
|
||||
DrmBCertKeyInfo = Struct(
|
||||
"key_count" / Int32ub,
|
||||
"cert_keys" / Array(this.key_count, Struct(
|
||||
"type" / Int16ub,
|
||||
"length" / Int16ub,
|
||||
"flags" / Int32ub,
|
||||
"key" / Bytes(this.length // 8),
|
||||
"usages_count" / Int32ub,
|
||||
"usages" / Array(this.usages_count, Int32ub)
|
||||
))
|
||||
)
|
||||
|
||||
DrmBCertManufacturerInfo = Struct(
|
||||
"flags" / Int32ub,
|
||||
"manufacturer_name_length" / Int32ub,
|
||||
"manufacturer_name" / Bytes((this.manufacturer_name_length + 3) & 0xfffffffc),
|
||||
"model_name_length" / Int32ub,
|
||||
"model_name" / Bytes((this.model_name_length + 3) & 0xfffffffc),
|
||||
"model_number_length" / Int32ub,
|
||||
"model_number" / Bytes((this.model_number_length + 3) & 0xfffffffc),
|
||||
)
|
||||
|
||||
DrmBCertSignatureInfo = Struct(
|
||||
"signature_type" / Int16ub,
|
||||
"signature_size" / Int16ub,
|
||||
"signature" / Bytes(this.signature_size),
|
||||
"signature_key_size" / Int32ub,
|
||||
"signature_key" / Bytes(this.signature_key_size // 8)
|
||||
)
|
||||
|
||||
# TODO: untested
|
||||
DrmBCertSilverlightInfo = Struct(
|
||||
"security_version" / Int32ub,
|
||||
"platform_identifier" / Int32ub
|
||||
)
|
||||
|
||||
# TODO: untested
|
||||
DrmBCertMeteringInfo = Struct(
|
||||
"metering_id" / Bytes(16),
|
||||
"metering_url_length" / Int32ub,
|
||||
"metering_url" / Bytes((this.metering_url_length + 3) & 0xfffffffc)
|
||||
)
|
||||
|
||||
# TODO: untested
|
||||
DrmBCertExtDataSignKeyInfo = Struct(
|
||||
"type" / Int16ub,
|
||||
"length" / Int16ub,
|
||||
"flags" / Int32ub,
|
||||
"key" / Bytes(this.length // 8)
|
||||
)
|
||||
|
||||
# TODO: untested
|
||||
BCertExtDataRecord = Struct(
|
||||
"data_size" / Int32ub,
|
||||
"data" / Bytes(this.data_size)
|
||||
)
|
||||
|
||||
# TODO: untested
|
||||
DrmBCertExtDataSignature = Struct(
|
||||
"signature_type" / Int16ub,
|
||||
"signature_size" / Int16ub,
|
||||
"signature" / Bytes(this.signature_size)
|
||||
)
|
||||
|
||||
# TODO: untested
|
||||
BCertExtDataContainer = Struct(
|
||||
"record_count" / Int32ub,
|
||||
"records" / Array(this.record_count, BCertExtDataRecord),
|
||||
"signature" / DrmBCertExtDataSignature
|
||||
)
|
||||
|
||||
# TODO: untested
|
||||
DrmBCertServerInfo = Struct(
|
||||
"warning_days" / Int32ub
|
||||
)
|
||||
|
||||
# TODO: untested
|
||||
DrmBcertSecurityVersion = Struct(
|
||||
"security_version" / Int32ub,
|
||||
"platform_identifier" / Int32ub
|
||||
)
|
||||
|
||||
Attribute = Struct(
|
||||
"flags" / Int16ub,
|
||||
"tag" / Int16ub,
|
||||
"length" / Int32ub,
|
||||
"attribute" / Switch(
|
||||
lambda this_: this_.tag,
|
||||
{
|
||||
1: DrmBCertBasicInfo,
|
||||
2: DrmBCertDomainInfo,
|
||||
3: DrmBCertPCInfo,
|
||||
4: DrmBCertDeviceInfo,
|
||||
5: DrmBCertFeatureInfo,
|
||||
6: DrmBCertKeyInfo,
|
||||
7: DrmBCertManufacturerInfo,
|
||||
8: DrmBCertSignatureInfo,
|
||||
9: DrmBCertSilverlightInfo,
|
||||
10: DrmBCertMeteringInfo,
|
||||
11: DrmBCertExtDataSignKeyInfo,
|
||||
12: BCertExtDataContainer,
|
||||
13: DrmBCertExtDataSignature,
|
||||
14: Bytes(this.length - 8),
|
||||
15: DrmBCertServerInfo,
|
||||
16: DrmBcertSecurityVersion,
|
||||
17: DrmBcertSecurityVersion
|
||||
},
|
||||
default=Bytes(this.length - 8)
|
||||
)
|
||||
)
|
||||
|
||||
BCert = Struct(
|
||||
"signature" / Const(b"CERT"),
|
||||
"version" / Int32ub,
|
||||
"total_length" / Int32ub,
|
||||
"certificate_length" / Int32ub,
|
||||
"attributes" / GreedyRange(Attribute)
|
||||
)
|
||||
|
||||
BCertChain = Struct(
|
||||
"signature" / Const(b"CHAI"),
|
||||
"version" / Int32ub,
|
||||
"total_length" / Int32ub,
|
||||
"flags" / Int32ub,
|
||||
"certificate_count" / Int32ub,
|
||||
"certificates" / GreedyRange(BCert)
|
||||
)
|
||||
|
||||
|
||||
class Certificate(_BCertStructs):
|
||||
def __init__(
|
||||
self,
|
||||
parsed_bcert: Container,
|
||||
bcert_obj: _BCertStructs.BCert = _BCertStructs.BCert
|
||||
):
|
||||
self.parsed = parsed_bcert
|
||||
self._BCERT = bcert_obj
|
||||
|
||||
@classmethod
|
||||
def new_key_cert(
|
||||
cls,
|
||||
cert_id: bytes,
|
||||
security_level: int,
|
||||
client_id: bytes,
|
||||
signing_key: ECCKey,
|
||||
encryption_key: ECCKey,
|
||||
group_key: ECCKey,
|
||||
parent: CertificateChain,
|
||||
expiry: int = 0xFFFFFFFF,
|
||||
max_license: int = 10240,
|
||||
max_header: int = 15360,
|
||||
max_chain_depth: int = 2
|
||||
) -> Certificate:
|
||||
if not cert_id:
|
||||
raise ValueError("Certificate ID is required")
|
||||
if not client_id:
|
||||
raise ValueError("Client ID is required")
|
||||
|
||||
basic_info = Container(
|
||||
cert_id=cert_id,
|
||||
security_level=security_level,
|
||||
flags=0,
|
||||
cert_type=2,
|
||||
public_key_digest=signing_key.public_sha256_digest(),
|
||||
expiration_date=expiry,
|
||||
client_id=client_id
|
||||
)
|
||||
basic_info_attribute = Container(
|
||||
flags=1,
|
||||
tag=1,
|
||||
length=len(_BCertStructs.DrmBCertBasicInfo.build(basic_info)) + 8,
|
||||
attribute=basic_info
|
||||
)
|
||||
|
||||
device_info = Container(
|
||||
max_license=max_license,
|
||||
max_header=max_header,
|
||||
max_chain_depth=max_chain_depth
|
||||
)
|
||||
device_info_attribute = Container(
|
||||
flags=1,
|
||||
tag=4,
|
||||
length=len(_BCertStructs.DrmBCertDeviceInfo.build(device_info)) + 8,
|
||||
attribute=device_info
|
||||
)
|
||||
|
||||
feature = Container(
|
||||
feature_count=1,
|
||||
features=ListContainer([
|
||||
4
|
||||
])
|
||||
)
|
||||
feature_attribute = Container(
|
||||
flags=1,
|
||||
tag=5,
|
||||
length=len(_BCertStructs.DrmBCertFeatureInfo.build(feature)) + 8,
|
||||
attribute=feature
|
||||
)
|
||||
|
||||
cert_key_sign = Container(
|
||||
type=1,
|
||||
length=512, # bits
|
||||
flags=0,
|
||||
key=signing_key.public_bytes(),
|
||||
usages_count=1,
|
||||
usages=ListContainer([
|
||||
1
|
||||
])
|
||||
)
|
||||
cert_key_encrypt = Container(
|
||||
type=1,
|
||||
length=512, # bits
|
||||
flags=0,
|
||||
key=encryption_key.public_bytes(),
|
||||
usages_count=1,
|
||||
usages=ListContainer([
|
||||
2
|
||||
])
|
||||
)
|
||||
key_info = Container(
|
||||
key_count=2,
|
||||
cert_keys=ListContainer([
|
||||
cert_key_sign,
|
||||
cert_key_encrypt
|
||||
])
|
||||
)
|
||||
key_info_attribute = Container(
|
||||
flags=1,
|
||||
tag=6,
|
||||
length=len(_BCertStructs.DrmBCertKeyInfo.build(key_info)) + 8,
|
||||
attribute=key_info
|
||||
)
|
||||
|
||||
manufacturer_info = parent.get_certificate(0).get_attribute(7)
|
||||
|
||||
new_bcert_container = Container(
|
||||
signature=b"CERT",
|
||||
version=1,
|
||||
total_length=0, # filled at a later time
|
||||
certificate_length=0, # filled at a later time
|
||||
attributes=ListContainer([
|
||||
basic_info_attribute,
|
||||
device_info_attribute,
|
||||
feature_attribute,
|
||||
key_info_attribute,
|
||||
manufacturer_info,
|
||||
])
|
||||
)
|
||||
|
||||
payload = _BCertStructs.BCert.build(new_bcert_container)
|
||||
new_bcert_container.certificate_length = len(payload)
|
||||
new_bcert_container.total_length = len(payload) + 144 # signature length
|
||||
|
||||
sign_payload = _BCertStructs.BCert.build(new_bcert_container)
|
||||
|
||||
hash_obj = SHA256.new(sign_payload)
|
||||
signer = DSS.new(group_key.key, 'fips-186-3')
|
||||
signature = signer.sign(hash_obj)
|
||||
|
||||
signature_info = Container(
|
||||
signature_type=1,
|
||||
signature_size=64,
|
||||
signature=signature,
|
||||
signature_key_size=512, # bits
|
||||
signature_key=group_key.public_bytes()
|
||||
)
|
||||
signature_info_attribute = Container(
|
||||
flags=1,
|
||||
tag=8,
|
||||
length=len(_BCertStructs.DrmBCertSignatureInfo.build(signature_info)) + 8,
|
||||
attribute=signature_info
|
||||
)
|
||||
new_bcert_container.attributes.append(signature_info_attribute)
|
||||
|
||||
return cls(new_bcert_container)
|
||||
|
||||
@classmethod
|
||||
def loads(cls, data: Union[str, bytes]) -> Certificate:
|
||||
if isinstance(data, str):
|
||||
data = base64.b64decode(data)
|
||||
if not isinstance(data, bytes):
|
||||
raise ValueError(f"Expecting Bytes or Base64 input, got {data!r}")
|
||||
|
||||
cert = _BCertStructs.BCert
|
||||
return cls(
|
||||
parsed_bcert=cert.parse(data),
|
||||
bcert_obj=cert
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Union[Path, str]) -> Certificate:
|
||||
if not isinstance(path, (Path, str)):
|
||||
raise ValueError(f"Expecting Path object or path string, got {path!r}")
|
||||
with Path(path).open(mode="rb") as f:
|
||||
return cls.loads(f.read())
|
||||
|
||||
def get_attribute(self, type_: int):
|
||||
for attribute in self.parsed.attributes:
|
||||
if attribute.tag == type_:
|
||||
return attribute
|
||||
|
||||
def get_security_level(self) -> int:
|
||||
basic_info_attribute = self.get_attribute(1).attribute
|
||||
if basic_info_attribute:
|
||||
return basic_info_attribute.security_level
|
||||
|
||||
@staticmethod
|
||||
def _unpad(name: bytes):
|
||||
return name.rstrip(b'\x00').decode("utf-8", errors="ignore")
|
||||
|
||||
def get_name(self):
|
||||
manufacturer_info = self.get_attribute(7).attribute
|
||||
if manufacturer_info:
|
||||
return f"{self._unpad(manufacturer_info.manufacturer_name)} {self._unpad(manufacturer_info.model_name)} {self._unpad(manufacturer_info.model_number)}"
|
||||
|
||||
def dumps(self) -> bytes:
|
||||
return self._BCERT.build(self.parsed)
|
||||
|
||||
def struct(self) -> _BCertStructs.BCert:
|
||||
return self._BCERT
|
||||
|
||||
|
||||
class CertificateChain(_BCertStructs):
|
||||
def __init__(
|
||||
self,
|
||||
parsed_bcert_chain: Container,
|
||||
bcert_chain_obj: _BCertStructs.BCertChain = _BCertStructs.BCertChain
|
||||
):
|
||||
self.parsed = parsed_bcert_chain
|
||||
self._BCERT_CHAIN = bcert_chain_obj
|
||||
|
||||
@classmethod
|
||||
def loads(cls, data: Union[str, bytes]) -> CertificateChain:
|
||||
if isinstance(data, str):
|
||||
data = base64.b64decode(data)
|
||||
if not isinstance(data, bytes):
|
||||
raise ValueError(f"Expecting Bytes or Base64 input, got {data!r}")
|
||||
|
||||
cert_chain = _BCertStructs.BCertChain
|
||||
return cls(
|
||||
parsed_bcert_chain=cert_chain.parse(data),
|
||||
bcert_chain_obj=cert_chain
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Union[Path, str]) -> CertificateChain:
|
||||
if not isinstance(path, (Path, str)):
|
||||
raise ValueError(f"Expecting Path object or path string, got {path!r}")
|
||||
with Path(path).open(mode="rb") as f:
|
||||
return cls.loads(f.read())
|
||||
|
||||
def dumps(self) -> bytes:
|
||||
return self._BCERT_CHAIN.build(self.parsed)
|
||||
|
||||
def struct(self) -> _BCertStructs.BCertChain:
|
||||
return self._BCERT_CHAIN
|
||||
|
||||
def get_certificate(self, index: int) -> Certificate:
|
||||
return Certificate(self.parsed.certificates[index])
|
||||
|
||||
def get_security_level(self) -> int:
|
||||
# not sure if there's a better way than this
|
||||
return self.get_certificate(0).get_security_level()
|
||||
|
||||
def get_name(self) -> str:
|
||||
return self.get_certificate(0).get_name()
|
||||
|
||||
def append(self, bcert: Certificate) -> None:
|
||||
self.parsed.certificate_count += 1
|
||||
self.parsed.certificates.append(bcert.parsed)
|
||||
self.parsed.total_length += len(bcert.dumps())
|
||||
|
||||
def prepend(self, bcert: Certificate) -> None:
|
||||
self.parsed.certificate_count += 1
|
||||
self.parsed.certificates.insert(0, bcert.parsed)
|
||||
self.parsed.total_length += len(bcert.dumps())
|
|
@ -0,0 +1,216 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import math
|
||||
import time
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Hash import SHA256
|
||||
from Crypto.Random import get_random_bytes
|
||||
from Crypto.Signature import DSS
|
||||
from Crypto.Util.Padding import pad
|
||||
from ecpy.curves import Point, Curve
|
||||
|
||||
from pyplayready.bcert import CertificateChain
|
||||
from pyplayready.ecc_key import ECCKey
|
||||
from pyplayready.key import Key
|
||||
from pyplayready.xml_key import XmlKey
|
||||
from pyplayready.elgamal import ElGamal
|
||||
from pyplayready.xmrlicense import XMRLicense
|
||||
|
||||
|
||||
class Cdm:
|
||||
def __init__(
|
||||
self,
|
||||
security_level: int,
|
||||
certificate_chain: CertificateChain,
|
||||
encryption_key: ECCKey,
|
||||
signing_key: ECCKey,
|
||||
client_version: str = "10.0.16384.10011"
|
||||
):
|
||||
self.security_level = security_level
|
||||
self.certificate_chain = certificate_chain
|
||||
self.encryption_key = encryption_key
|
||||
self.signing_key = signing_key
|
||||
self.client_version = client_version
|
||||
|
||||
self.curve = Curve.get_curve("secp256r1")
|
||||
self.elgamal = ElGamal(self.curve)
|
||||
|
||||
self._wmrm_key = Point(
|
||||
x=0xc8b6af16ee941aadaa5389b4af2c10e356be42af175ef3face93254e7b0b3d9b,
|
||||
y=0x982b27b5cb2341326e56aa857dbfd5c634ce2cf9ea74fca8f2af5957efeea562,
|
||||
curve=self.curve
|
||||
)
|
||||
self._xml_key = XmlKey()
|
||||
|
||||
self._keys: List[Key] = []
|
||||
|
||||
@classmethod
|
||||
def from_device(cls, device) -> Cdm:
|
||||
"""Initialize a Playready CDM from a Playready Device (.prd) file"""
|
||||
return cls(
|
||||
security_level=device.security_level,
|
||||
certificate_chain=device.group_certificate,
|
||||
encryption_key=device.encryption_key,
|
||||
signing_key=device.signing_key
|
||||
)
|
||||
|
||||
def get_key_data(self):
|
||||
point1, point2 = self.elgamal.encrypt(
|
||||
message_point=self._xml_key.get_point(self.elgamal.curve),
|
||||
public_key=self._wmrm_key
|
||||
)
|
||||
return self.elgamal.to_bytes(point1.x) + self.elgamal.to_bytes(point1.y) + self.elgamal.to_bytes(point2.x) + self.elgamal.to_bytes(point2.y)
|
||||
|
||||
def get_cipher_data(self):
|
||||
b64_chain = base64.b64encode(self.certificate_chain.dumps()).decode()
|
||||
body = f"<Data><CertificateChains><CertificateChain>{b64_chain}</CertificateChain></CertificateChains></Data>"
|
||||
|
||||
cipher = AES.new(
|
||||
key=self._xml_key.aes_key,
|
||||
mode=AES.MODE_CBC,
|
||||
iv=self._xml_key.aes_iv
|
||||
)
|
||||
|
||||
ciphertext = cipher.encrypt(pad(
|
||||
body.encode(),
|
||||
AES.block_size
|
||||
))
|
||||
|
||||
return self._xml_key.aes_iv + ciphertext
|
||||
|
||||
def _build_digest_content(
|
||||
self,
|
||||
content_header: str,
|
||||
nonce: str,
|
||||
wmrm_cipher: str,
|
||||
cert_cipher: str
|
||||
) -> str:
|
||||
return (
|
||||
'<LA xmlns="http://schemas.microsoft.com/DRM/2007/03/protocols" Id="SignedData" xml:space="preserve">'
|
||||
'<Version>1</Version>'
|
||||
f'<ContentHeader>{content_header}</ContentHeader>'
|
||||
'<CLIENTINFO>'
|
||||
f'<CLIENTVERSION>{self.client_version}</CLIENTVERSION>'
|
||||
'</CLIENTINFO>'
|
||||
f'<LicenseNonce>{nonce}</LicenseNonce>'
|
||||
f'<ClientTime>{math.floor(time.time())}</ClientTime>'
|
||||
'<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#" Type="http://www.w3.org/2001/04/xmlenc#Element">'
|
||||
'<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"></EncryptionMethod>'
|
||||
'<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">'
|
||||
'<EncryptedKey xmlns="http://www.w3.org/2001/04/xmlenc#">'
|
||||
'<EncryptionMethod Algorithm="http://schemas.microsoft.com/DRM/2007/03/protocols#ecc256"></EncryptionMethod>'
|
||||
'<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">'
|
||||
'<KeyName>WMRMServer</KeyName>'
|
||||
'</KeyInfo>'
|
||||
'<CipherData>'
|
||||
f'<CipherValue>{wmrm_cipher}</CipherValue>'
|
||||
'</CipherData>'
|
||||
'</EncryptedKey>'
|
||||
'</KeyInfo>'
|
||||
'<CipherData>'
|
||||
f'<CipherValue>{cert_cipher}</CipherValue>'
|
||||
'</CipherData>'
|
||||
'</EncryptedData>'
|
||||
'</LA>'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_signed_info(digest_value: str) -> str:
|
||||
return (
|
||||
'<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">'
|
||||
'<CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></CanonicalizationMethod>'
|
||||
'<SignatureMethod Algorithm="http://schemas.microsoft.com/DRM/2007/03/protocols#ecdsa-sha256"></SignatureMethod>'
|
||||
'<Reference URI="#SignedData">'
|
||||
'<DigestMethod Algorithm="http://schemas.microsoft.com/DRM/2007/03/protocols#sha256"></DigestMethod>'
|
||||
f'<DigestValue>{digest_value}</DigestValue>'
|
||||
'</Reference>'
|
||||
'</SignedInfo>'
|
||||
)
|
||||
|
||||
def get_license_challenge(self, content_header: str) -> str:
|
||||
la_content = self._build_digest_content(
|
||||
content_header=content_header,
|
||||
nonce=base64.b64encode(get_random_bytes(16)).decode(),
|
||||
wmrm_cipher=base64.b64encode(self.get_key_data()).decode(),
|
||||
cert_cipher=base64.b64encode(self.get_cipher_data()).decode()
|
||||
)
|
||||
|
||||
la_hash_obj = SHA256.new()
|
||||
la_hash_obj.update(la_content.encode())
|
||||
la_hash = la_hash_obj.digest()
|
||||
|
||||
signed_info = self._build_signed_info(base64.b64encode(la_hash).decode())
|
||||
signed_info_digest = SHA256.new(signed_info.encode())
|
||||
|
||||
signer = DSS.new(self.signing_key.key, 'fips-186-3')
|
||||
signature = signer.sign(signed_info_digest)
|
||||
|
||||
# haven't found a better way to do this. xmltodict.unparse doesn't work
|
||||
main_body = (
|
||||
'<?xml version="1.0" encoding="utf-8"?>'
|
||||
'<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">'
|
||||
'<soap:Body>'
|
||||
'<AcquireLicense xmlns="http://schemas.microsoft.com/DRM/2007/03/protocols">'
|
||||
'<challenge>'
|
||||
'<Challenge xmlns="http://schemas.microsoft.com/DRM/2007/03/protocols/messages">'
|
||||
+ la_content +
|
||||
'<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">'
|
||||
+ signed_info +
|
||||
f'<SignatureValue>{base64.b64encode(signature).decode()}</SignatureValue>'
|
||||
'<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">'
|
||||
'<KeyValue>'
|
||||
'<ECCKeyValue>'
|
||||
f'<PublicKey>{base64.b64encode(self.signing_key.public_bytes()).decode()}</PublicKey>'
|
||||
'</ECCKeyValue>'
|
||||
'</KeyValue>'
|
||||
'</KeyInfo>'
|
||||
'</Signature>'
|
||||
'</Challenge>'
|
||||
'</challenge>'
|
||||
'</AcquireLicense>'
|
||||
'</soap:Body>'
|
||||
'</soap:Envelope>'
|
||||
)
|
||||
|
||||
return main_body
|
||||
|
||||
def _decrypt_ecc256_key(self, encrypted_key: bytes) -> bytes:
|
||||
point1 = Point(
|
||||
x=int.from_bytes(encrypted_key[:32], 'big'),
|
||||
y=int.from_bytes(encrypted_key[32:64], 'big'),
|
||||
curve=self.curve
|
||||
)
|
||||
point2 = Point(
|
||||
x=int.from_bytes(encrypted_key[64:96], 'big'),
|
||||
y=int.from_bytes(encrypted_key[96:128], 'big'),
|
||||
curve=self.curve
|
||||
)
|
||||
|
||||
decrypted = self.elgamal.decrypt((point1, point2), int(self.encryption_key.key.d))
|
||||
return self.elgamal.to_bytes(decrypted.x)[16:32]
|
||||
|
||||
def parse_license(self, licence: str) -> None:
|
||||
try:
|
||||
root = ET.fromstring(licence)
|
||||
license_elements = root.findall(".//{http://schemas.microsoft.com/DRM/2007/03/protocols}License")
|
||||
for license_element in license_elements:
|
||||
parsed_licence = XMRLicense.loads(license_element.text)
|
||||
for key in parsed_licence.get_content_keys():
|
||||
if Key.CipherType(key.cipher_type) == Key.CipherType.ECC256:
|
||||
self._keys.append(Key(
|
||||
key_id=UUID(bytes_le=key.key_id),
|
||||
key_type=key.key_type,
|
||||
cipher_type=key.cipher_type,
|
||||
key_length=key.key_length,
|
||||
key=self._decrypt_ecc256_key(key.encrypted_key)
|
||||
))
|
||||
except Exception as e:
|
||||
raise Exception(f"Unable to parse license, {e}")
|
||||
|
||||
def get_keys(self) -> List[Key]:
|
||||
return self._keys
|
|
@ -0,0 +1,105 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from enum import IntEnum
|
||||
from pathlib import Path
|
||||
from typing import Union, Any
|
||||
|
||||
from construct import Struct, Const, Int8ub, Bytes, this, Int32ub
|
||||
|
||||
from pyplayready.bcert import CertificateChain
|
||||
from pyplayready.ecc_key import ECCKey
|
||||
|
||||
|
||||
class SecurityLevel(IntEnum):
|
||||
SL150 = 150
|
||||
SL2000 = 2000
|
||||
SL3000 = 3000
|
||||
|
||||
|
||||
class _DeviceStructs:
|
||||
magic = Const(b"PRD")
|
||||
|
||||
v1 = Struct(
|
||||
"signature" / magic,
|
||||
"version" / Int8ub,
|
||||
"group_key_length" / Int32ub,
|
||||
"group_key" / Bytes(this.group_key_length),
|
||||
"group_certificate_length" / Int32ub,
|
||||
"group_certificate" / Bytes(this.group_certificate_length)
|
||||
)
|
||||
|
||||
v2 = Struct(
|
||||
"signature" / magic,
|
||||
"version" / Int8ub,
|
||||
"group_certificate_length" / Int32ub,
|
||||
"group_certificate" / Bytes(this.group_certificate_length),
|
||||
"encryption_key" / Bytes(96),
|
||||
"signing_key" / Bytes(96),
|
||||
)
|
||||
|
||||
|
||||
class Device:
|
||||
CURRENT_STRUCT = _DeviceStructs.v2
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*_: Any,
|
||||
group_certificate: Union[str, bytes],
|
||||
encryption_key: Union[str, bytes],
|
||||
signing_key: Union[str, bytes],
|
||||
**__: Any
|
||||
):
|
||||
if isinstance(group_certificate, str):
|
||||
group_certificate = base64.b64decode(group_certificate)
|
||||
if not isinstance(group_certificate, bytes):
|
||||
raise ValueError(f"Expecting Bytes or Base64 input, got {group_certificate!r}")
|
||||
|
||||
if isinstance(encryption_key, str):
|
||||
encryption_key = base64.b64decode(encryption_key)
|
||||
if not isinstance(encryption_key, bytes):
|
||||
raise ValueError(f"Expecting Bytes or Base64 input, got {encryption_key!r}")
|
||||
if isinstance(signing_key, str):
|
||||
signing_key = base64.b64decode(signing_key)
|
||||
if not isinstance(signing_key, bytes):
|
||||
raise ValueError(f"Expecting Bytes or Base64 input, got {signing_key!r}")
|
||||
|
||||
self.group_certificate = CertificateChain.loads(group_certificate)
|
||||
self.encryption_key = ECCKey.loads(encryption_key)
|
||||
self.signing_key = ECCKey.loads(signing_key)
|
||||
self.security_level = self.group_certificate.get_security_level()
|
||||
|
||||
@classmethod
|
||||
def loads(cls, data: Union[str, bytes]) -> 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.CURRENT_STRUCT.parse(data))
|
||||
|
||||
@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}")
|
||||
with Path(path).open(mode="rb") as f:
|
||||
return cls.loads(f.read())
|
||||
|
||||
def dumps(self) -> bytes:
|
||||
return self.CURRENT_STRUCT.build(dict(
|
||||
version=2,
|
||||
group_certificate_length=len(self.group_certificate.dumps()),
|
||||
group_certificate=self.group_certificate.dumps(),
|
||||
encryption_key=self.encryption_key.dumps(),
|
||||
signing_key=self.signing_key.dumps()
|
||||
))
|
||||
|
||||
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())
|
||||
|
||||
def get_name(self):
|
||||
name = f"{self.group_certificate.get_name()}_sl{self.group_certificate.get_security_level()}"
|
||||
return ''.join(char for char in name if char.isascii()).strip().lower().replace(" ", "_")
|
|
@ -0,0 +1,105 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
from Crypto.Hash import SHA256
|
||||
from Crypto.PublicKey import ECC
|
||||
from Crypto.PublicKey.ECC import EccKey
|
||||
from ecpy.curves import Curve, Point
|
||||
|
||||
|
||||
class ECCKey:
|
||||
def __init__(
|
||||
self,
|
||||
key: EccKey
|
||||
):
|
||||
"""Represents a PlayReady ECC key"""
|
||||
self.key = key
|
||||
|
||||
@classmethod
|
||||
def generate(cls):
|
||||
return cls(key=ECC.generate(curve='P-256'))
|
||||
|
||||
@classmethod
|
||||
def construct(
|
||||
cls,
|
||||
private_key: Union[bytes, int],
|
||||
public_key_x: Union[bytes, int],
|
||||
public_key_y: Union[bytes, int]
|
||||
):
|
||||
if isinstance(private_key, bytes):
|
||||
private_key = int.from_bytes(private_key, 'big')
|
||||
if not isinstance(private_key, int):
|
||||
raise ValueError(f"Expecting Bytes or Int input, got {private_key!r}")
|
||||
|
||||
if isinstance(public_key_x, bytes):
|
||||
public_key_x = int.from_bytes(public_key_x, 'big')
|
||||
if not isinstance(public_key_x, int):
|
||||
raise ValueError(f"Expecting Bytes or Int input, got {public_key_x!r}")
|
||||
|
||||
if isinstance(public_key_y, bytes):
|
||||
public_key_y = int.from_bytes(public_key_y, 'big')
|
||||
if not isinstance(public_key_y, int):
|
||||
raise ValueError(f"Expecting Bytes or Int input, got {public_key_y!r}")
|
||||
|
||||
# The public is always derived from the private key; loading the other stuff won't work
|
||||
key = ECC.construct(
|
||||
curve='P-256',
|
||||
d=private_key,
|
||||
)
|
||||
|
||||
return cls(key=key)
|
||||
|
||||
@classmethod
|
||||
def loads(cls, data: Union[str, bytes]) -> ECCKey:
|
||||
if isinstance(data, str):
|
||||
data = base64.b64decode(data)
|
||||
if not isinstance(data, bytes):
|
||||
raise ValueError(f"Expecting Bytes or Base64 input, got {data!r}")
|
||||
|
||||
if len(data) not in [96, 32]:
|
||||
raise ValueError(f"Invalid data length. Expecting 96 or 32 bytes, got {len(data)}")
|
||||
|
||||
return cls.construct(
|
||||
private_key=data[:32],
|
||||
public_key_x=data[32:64],
|
||||
public_key_y=data[64:96]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Union[Path, str]) -> ECCKey:
|
||||
if not isinstance(path, (Path, str)):
|
||||
raise ValueError(f"Expecting Path object or path string, got {path!r}")
|
||||
with Path(path).open(mode="rb") as f:
|
||||
return cls.loads(f.read())
|
||||
|
||||
def dumps(self):
|
||||
return self.private_bytes() + self.public_bytes()
|
||||
|
||||
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())
|
||||
|
||||
def get_point(self, curve: Curve) -> Point:
|
||||
return Point(self.key.pointQ.x, self.key.pointQ.y, curve)
|
||||
|
||||
def private_bytes(self) -> bytes:
|
||||
return self.key.d.to_bytes()
|
||||
|
||||
def private_sha256_digest(self) -> bytes:
|
||||
hash_object = SHA256.new()
|
||||
hash_object.update(self.private_bytes())
|
||||
return hash_object.digest()
|
||||
|
||||
def public_bytes(self) -> bytes:
|
||||
return self.key.pointQ.x.to_bytes() + self.key.pointQ.y.to_bytes()
|
||||
|
||||
def public_sha256_digest(self) -> bytes:
|
||||
hash_object = SHA256.new()
|
||||
hash_object.update(self.public_bytes())
|
||||
return hash_object.digest()
|
|
@ -0,0 +1,36 @@
|
|||
from typing import Tuple
|
||||
|
||||
from ecpy.curves import Curve, Point
|
||||
import secrets
|
||||
|
||||
|
||||
class ElGamal:
|
||||
def __init__(self, curve: Curve):
|
||||
self.curve = curve
|
||||
|
||||
@staticmethod
|
||||
def to_bytes(n: int) -> bytes:
|
||||
byte_len = (n.bit_length() + 7) // 8
|
||||
if byte_len % 2 != 0:
|
||||
byte_len += 1
|
||||
return n.to_bytes(byte_len, 'big')
|
||||
|
||||
def encrypt(
|
||||
self,
|
||||
message_point: Point,
|
||||
public_key: Point
|
||||
) -> Tuple[Point, Point]:
|
||||
ephemeral_key = secrets.randbelow(self.curve.order)
|
||||
point1 = ephemeral_key * self.curve.generator
|
||||
point2 = message_point + (ephemeral_key * public_key)
|
||||
return point1, point2
|
||||
|
||||
@staticmethod
|
||||
def decrypt(
|
||||
encrypted: Tuple[Point, Point],
|
||||
private_key: int
|
||||
) -> Point:
|
||||
point1, point2 = encrypted
|
||||
shared_secret = private_key * point1
|
||||
decrypted_message = point2 - shared_secret
|
||||
return decrypted_message
|
|
@ -0,0 +1,42 @@
|
|||
from enum import Enum
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class Key:
|
||||
class KeyType(Enum):
|
||||
Invalid = 0x0000
|
||||
AES128CTR = 0x0001
|
||||
RC4 = 0x0002
|
||||
AES128ECB = 0x0003
|
||||
Cocktail = 0x0004
|
||||
UNKNOWN = 0xffff
|
||||
|
||||
@classmethod
|
||||
def _missing_(cls, value):
|
||||
return cls.UNKNOWN
|
||||
|
||||
class CipherType(Enum):
|
||||
Invalid = 0x0000
|
||||
RSA128 = 0x0001
|
||||
ChainedLicense = 0x0002
|
||||
ECC256 = 0x0003
|
||||
ECCforScalableLicenses = 4
|
||||
UNKNOWN = 0xffff
|
||||
|
||||
@classmethod
|
||||
def _missing_(cls, value):
|
||||
return cls.UNKNOWN
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key_id: UUID,
|
||||
key_type: int,
|
||||
cipher_type: int,
|
||||
key_length: int,
|
||||
key: bytes
|
||||
):
|
||||
self.key_id = key_id
|
||||
self.key_type = self.KeyType(key_type)
|
||||
self.cipher_type = self.CipherType(cipher_type)
|
||||
self.key_length = key_length
|
||||
self.key = key
|
|
@ -0,0 +1,230 @@
|
|||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from zlib import crc32
|
||||
|
||||
import click
|
||||
import requests
|
||||
from Crypto.Random import get_random_bytes
|
||||
|
||||
from pyplayready import __version__
|
||||
from pyplayready.bcert import CertificateChain, Certificate
|
||||
from pyplayready.cdm import Cdm
|
||||
from pyplayready.device import Device
|
||||
from pyplayready.ecc_key import ECCKey
|
||||
from pyplayready.pssh import PSSH
|
||||
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
@click.option("-v", "--version", is_flag=True, default=False, help="Print version information.")
|
||||
@click.option("-d", "--debug", is_flag=True, default=False, help="Enable DEBUG level logs.")
|
||||
def main(version: bool, debug: bool) -> None:
|
||||
"""Python PlayReady CDM implementation"""
|
||||
logging.basicConfig(level=logging.DEBUG if debug else logging.INFO)
|
||||
log = logging.getLogger()
|
||||
|
||||
current_year = datetime.now().year
|
||||
copyright_years = f"2024-{current_year}"
|
||||
|
||||
log.info("pyplayready version %s Copyright (c) %s DevLARLEY", __version__, copyright_years)
|
||||
log.info("https://github.com/ready-dl/pyplayready")
|
||||
log.info("Run 'pyplayready --help' for help")
|
||||
if version:
|
||||
return
|
||||
|
||||
|
||||
@main.command(name="license")
|
||||
@click.argument("device_path", type=Path)
|
||||
@click.argument("pssh", type=PSSH)
|
||||
@click.argument("server", type=str)
|
||||
def license_(device_path: Path, pssh: PSSH, server: str) -> None:
|
||||
"""
|
||||
Make a License Request to a server using a given PSSH
|
||||
Will return a list of all keys within the returned license
|
||||
|
||||
Only works for standard license servers that don't use any license wrapping
|
||||
"""
|
||||
log = logging.getLogger("license")
|
||||
|
||||
device = Device.load(device_path)
|
||||
log.info(f"Loaded Device: {device.get_name()}")
|
||||
|
||||
cdm = Cdm.from_device(device)
|
||||
log.info("Loaded CDM")
|
||||
|
||||
challenge = cdm.get_license_challenge(pssh.wrm_headers[0])
|
||||
log.info("Created License Request (Challenge)")
|
||||
log.debug(challenge)
|
||||
|
||||
license_res = requests.post(
|
||||
url=server,
|
||||
headers={
|
||||
'Content-Type': 'text/xml; charset=UTF-8',
|
||||
},
|
||||
data=challenge
|
||||
)
|
||||
|
||||
if license_res.status_code != 200:
|
||||
log.error(f"Failed to send challenge: [{license_res.status_code}] {license_res.text}")
|
||||
return
|
||||
|
||||
licence = license_res.text
|
||||
log.info("Got License Message")
|
||||
log.debug(licence)
|
||||
|
||||
cdm.parse_license(licence)
|
||||
log.info("License Parsed Successfully")
|
||||
|
||||
for key in cdm.get_keys():
|
||||
log.info(f"{key.key_id.hex}:{key.key.hex()}")
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.argument("device", type=Path)
|
||||
@click.pass_context
|
||||
def test(ctx: click.Context, device: Path) -> None:
|
||||
"""
|
||||
Test the CDM code by getting Content Keys for the Tears Of Steel demo on the Playready Test Server.
|
||||
https://testweb.playready.microsoft.com/Content/Content2X
|
||||
+ DASH Manifest URL: https://test.playready.microsoft.com/media/profficialsite/tearsofsteel_4k.ism/manifest.mpd
|
||||
+ MSS Manifest URL: https://test.playready.microsoft.com/media/profficialsite/tearsofsteel_4k.ism.smoothstreaming/manifest
|
||||
|
||||
The device argument is a Path to a Playready Device (.prd) file which contains the device's group key and
|
||||
group certificate.
|
||||
"""
|
||||
pssh = PSSH(
|
||||
"AAADfHBzc2gAAAAAmgTweZhAQoarkuZb4IhflQAAA1xcAwAAAQABAFIDPABXAFIATQBIAEUAQQBEAEUAUgAgAHgAbQBsAG4AcwA9ACIAaAB0AH"
|
||||
"QAcAA6AC8ALwBzAGMAaABlAG0AYQBzAC4AbQBpAGMAcgBvAHMAbwBmAHQALgBjAG8AbQAvAEQAUgBNAC8AMgAwADAANwAvADAAMwAvAFAAbABh"
|
||||
"AHkAUgBlAGEAZAB5AEgAZQBhAGQAZQByACIAIAB2AGUAcgBzAGkAbwBuAD0AIgA0AC4AMAAuADAALgAwACIAPgA8AEQAQQBUAEEAPgA8AFAAUg"
|
||||
"BPAFQARQBDAFQASQBOAEYATwA+ADwASwBFAFkATABFAE4APgAxADYAPAAvAEsARQBZAEwARQBOAD4APABBAEwARwBJAEQAPgBBAEUAUwBDAFQA"
|
||||
"UgA8AC8AQQBMAEcASQBEAD4APAAvAFAAUgBPAFQARQBDAFQASQBOAEYATwA+ADwASwBJAEQAPgA0AFIAcABsAGIAKwBUAGIATgBFAFMAOAB0AE"
|
||||
"cAawBOAEYAVwBUAEUASABBAD0APQA8AC8ASwBJAEQAPgA8AEMASABFAEMASwBTAFUATQA+AEsATABqADMAUQB6AFEAUAAvAE4AQQA9ADwALwBD"
|
||||
"AEgARQBDAEsAUwBVAE0APgA8AEwAQQBfAFUAUgBMAD4AaAB0AHQAcABzADoALwAvAHAAcgBvAGYAZgBpAGMAaQBhAGwAcwBpAHQAZQAuAGsAZQ"
|
||||
"B5AGQAZQBsAGkAdgBlAHIAeQAuAG0AZQBkAGkAYQBzAGUAcgB2AGkAYwBlAHMALgB3AGkAbgBkAG8AdwBzAC4AbgBlAHQALwBQAGwAYQB5AFIA"
|
||||
"ZQBhAGQAeQAvADwALwBMAEEAXwBVAFIATAA+ADwAQwBVAFMAVABPAE0AQQBUAFQAUgBJAEIAVQBUAEUAUwA+ADwASQBJAFMAXwBEAFIATQBfAF"
|
||||
"YARQBSAFMASQBPAE4APgA4AC4AMQAuADIAMwAwADQALgAzADEAPAAvAEkASQBTAF8ARABSAE0AXwBWAEUAUgBTAEkATwBOAD4APAAvAEMAVQBT"
|
||||
"AFQATwBNAEEAVABUAFIASQBCAFUAVABFAFMAPgA8AC8ARABBAFQAQQA+ADwALwBXAFIATQBIAEUAQQBEAEUAUgA+AA=="
|
||||
)
|
||||
|
||||
license_server = "https://test.playready.microsoft.com/service/rightsmanager.asmx?cfg=(persist:false,sl:2000)"
|
||||
|
||||
ctx.invoke(
|
||||
license_,
|
||||
device_path=device,
|
||||
pssh=pssh,
|
||||
server=license_server
|
||||
)
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.option("-k", "--group_key", type=Path, required=True, help="Device ECC private group key")
|
||||
@click.option("-c", "--group_certificate", type=Path, required=True, help="Device group certificate chain")
|
||||
@click.option("-o", "--output", type=Path, default=None, help="Output Path or Directory")
|
||||
@click.pass_context
|
||||
def create_device(
|
||||
ctx: click.Context,
|
||||
group_key: Path,
|
||||
group_certificate: Path,
|
||||
output: Optional[Path] = None
|
||||
) -> None:
|
||||
"""Create a Playready Device (.prd) file from an ECC private group key and group certificate chain"""
|
||||
if not group_key.is_file():
|
||||
raise click.UsageError("group_key: Not a path to a file, or it doesn't exist.", ctx)
|
||||
if not group_certificate.is_file():
|
||||
raise click.UsageError("group_certificate: Not a path to a file, or it doesn't exist.", ctx)
|
||||
|
||||
log = logging.getLogger("create-device")
|
||||
|
||||
encryption_key = ECCKey.generate()
|
||||
signing_key = ECCKey.generate()
|
||||
|
||||
certificate_chain = CertificateChain.load(group_certificate)
|
||||
group_key = ECCKey.load(group_key)
|
||||
|
||||
new_certificate = Certificate.new_key_cert(
|
||||
cert_id=get_random_bytes(16),
|
||||
security_level=certificate_chain.get_security_level(),
|
||||
client_id=get_random_bytes(16),
|
||||
signing_key=signing_key,
|
||||
encryption_key=encryption_key,
|
||||
group_key=group_key,
|
||||
parent=certificate_chain
|
||||
)
|
||||
certificate_chain.prepend(new_certificate)
|
||||
|
||||
device = Device(
|
||||
group_certificate=certificate_chain.dumps(),
|
||||
encryption_key=encryption_key.dumps(),
|
||||
signing_key=signing_key.dumps()
|
||||
)
|
||||
|
||||
prd_bin = device.dumps()
|
||||
|
||||
if output and output.suffix:
|
||||
if output.suffix.lower() != ".prd":
|
||||
log.warning(f"Saving PRD with the file extension '{output.suffix}' but '.prd' is recommended.")
|
||||
out_path = output
|
||||
else:
|
||||
out_dir = output or Path.cwd()
|
||||
out_path = out_dir / f"{device.get_name()}_{crc32(prd_bin).to_bytes(4, 'big').hex()}.prd"
|
||||
|
||||
if out_path.exists():
|
||||
log.error(f"A file already exists at the path '{out_path}', cannot overwrite.")
|
||||
return
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_bytes(prd_bin)
|
||||
|
||||
log.info("Created Playready Device (.prd) file, %s", out_path.name)
|
||||
log.info(" + Security Level: %s", device.security_level)
|
||||
log.info(" + Group Certificate: %s (%s bytes)", bool(device.group_certificate.dumps()), len(device.group_certificate.dumps()))
|
||||
log.info(" + Encryption Key: %s (%s bytes)", bool(device.encryption_key.dumps()), len(device.encryption_key.dumps()))
|
||||
log.info(" + Signing Key: %s (%s bytes)", bool(device.signing_key.dumps()), len(device.signing_key.dumps()))
|
||||
log.info(" + Saved to: %s", out_path.absolute())
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.argument("prd_path", type=Path)
|
||||
@click.option("-o", "--out_dir", type=Path, default=None, help="Output Directory")
|
||||
@click.pass_context
|
||||
def export_device(ctx: click.Context, prd_path: Path, out_dir: Optional[Path] = None) -> None:
|
||||
"""
|
||||
Export a Playready Device (.prd) file to a Group Certificate, Encryption Key and Signing Key
|
||||
If an output directory is not specified, it will be stored in the current working directory
|
||||
"""
|
||||
if not prd_path.is_file():
|
||||
raise click.UsageError("prd_path: Not a path to a file, or it doesn't exist.", ctx)
|
||||
|
||||
log = logging.getLogger("export-device")
|
||||
log.info("Exporting Playready Device (.prd) file, %s", prd_path.stem)
|
||||
|
||||
if not out_dir:
|
||||
out_dir = Path.cwd()
|
||||
|
||||
out_path = out_dir / prd_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(prd_path)
|
||||
|
||||
log.info(f"L{device.security_level} {device.get_name()}")
|
||||
log.info(f"Saving to: {out_path}")
|
||||
|
||||
client_id_path = out_path / "bgroupcert.dat"
|
||||
client_id_path.write_bytes(device.group_certificate.dumps())
|
||||
log.info("Exported Group Certificate to bgroupcert.dat")
|
||||
|
||||
private_key_path = out_path / "zprivencr.dat"
|
||||
private_key_path.write_bytes(device.encryption_key.dumps())
|
||||
log.info("Exported Encryption Key as zprivencr.dat")
|
||||
|
||||
private_key_path = out_path / "zprivsig.dat"
|
||||
private_key_path.write_bytes(device.signing_key.dumps())
|
||||
log.info("Exported Signing Key as zprivsig.dat")
|
|
@ -0,0 +1,87 @@
|
|||
import base64
|
||||
from typing import Union
|
||||
from uuid import UUID
|
||||
|
||||
from construct import Struct, Int32ul, Int16ul, Array, this, Bytes, PaddedString, Switch, Int32ub, Const, Container
|
||||
|
||||
|
||||
class _PlayreadyPSSHStructs:
|
||||
PSSHBox = Struct(
|
||||
"length" / Int32ub,
|
||||
"pssh" / Const(b"pssh"),
|
||||
"fullbox" / Int32ub,
|
||||
"system_id" / Bytes(16),
|
||||
"data_length" / Int32ub,
|
||||
"data" / Bytes(this.data_length)
|
||||
)
|
||||
|
||||
PlayreadyObject = Struct(
|
||||
"type" / Int16ul,
|
||||
"length" / Int16ul,
|
||||
"data" / Switch(
|
||||
this.type,
|
||||
{
|
||||
1: PaddedString(this.length, "utf16")
|
||||
},
|
||||
default=Bytes(this.length)
|
||||
)
|
||||
)
|
||||
|
||||
PlayreadyHeader = Struct(
|
||||
"length" / Int32ul,
|
||||
"record_count" / Int16ul,
|
||||
"records" / Array(this.record_count, PlayreadyObject)
|
||||
)
|
||||
|
||||
|
||||
class PSSH:
|
||||
SYSTEM_ID = UUID(hex="9a04f07998404286ab92e65be0885f95")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: Union[str, bytes]
|
||||
):
|
||||
"""Represents a PlayReady PSSH"""
|
||||
if not data:
|
||||
raise ValueError("Data must not be empty")
|
||||
|
||||
if isinstance(data, str):
|
||||
try:
|
||||
data = base64.b64decode(data)
|
||||
except Exception as e:
|
||||
raise Exception(f"Could not decode data as Base64, {e}")
|
||||
|
||||
try:
|
||||
if self._is_playready_pssh_box(data):
|
||||
pssh_box = _PlayreadyPSSHStructs.PSSHBox.parse(data)
|
||||
if bool(self._is_utf_16(pssh_box.data)):
|
||||
self.wrm_headers = [pssh_box.data.decode("utf-16-le")]
|
||||
elif bool(self._is_utf_16(pssh_box.data[6:])):
|
||||
self.wrm_headers = [pssh_box.data[6:].decode("utf-16-le")]
|
||||
elif bool(self._is_utf_16(pssh_box.data[10:])):
|
||||
self.wrm_headers = [pssh_box.data[10:].decode("utf-16-le")]
|
||||
else:
|
||||
self.wrm_headers = list(self._get_wrm_headers(_PlayreadyPSSHStructs.PlayreadyHeader.parse(pssh_box.data)))
|
||||
elif bool(self._is_utf_16(data)):
|
||||
self.wrm_headers = [data.decode("utf-16-le")]
|
||||
elif bool(self._is_utf_16(data[6:])):
|
||||
self.wrm_headers = [data[6:].decode("utf-16-le")]
|
||||
elif bool(self._is_utf_16(data[10:])):
|
||||
self.wrm_headers = [data[10:].decode("utf-16-le")]
|
||||
else:
|
||||
self.wrm_headers = list(self._get_wrm_headers(_PlayreadyPSSHStructs.PlayreadyHeader.parse(data)))
|
||||
except Exception:
|
||||
raise Exception("Could not parse data as a PSSH Box nor a PlayReadyHeader")
|
||||
|
||||
def _is_playready_pssh_box(self, data: bytes) -> bool:
|
||||
return data[12:28] == self.SYSTEM_ID.bytes
|
||||
|
||||
@staticmethod
|
||||
def _is_utf_16(data: bytes) -> bool:
|
||||
return all(map(lambda i: data[i] == 0, range(1, len(data), 2)))
|
||||
|
||||
@staticmethod
|
||||
def _get_wrm_headers(wrm_header: Container):
|
||||
for record in wrm_header.records:
|
||||
if record.type == 1:
|
||||
yield record.data
|
|
@ -0,0 +1,18 @@
|
|||
from ecpy.curves import Point, Curve
|
||||
|
||||
from pyplayready.ecc_key import ECCKey
|
||||
from pyplayready.elgamal import ElGamal
|
||||
|
||||
|
||||
class XmlKey:
|
||||
def __init__(self):
|
||||
self._shared_point = ECCKey.generate()
|
||||
self.shared_key_x = self._shared_point.key.pointQ.x
|
||||
self.shared_key_y = self._shared_point.key.pointQ.y
|
||||
|
||||
self._shared_key_x_bytes = ElGamal.to_bytes(int(self.shared_key_x))
|
||||
self.aes_iv = self._shared_key_x_bytes[:16]
|
||||
self.aes_key = self._shared_key_x_bytes[16:]
|
||||
|
||||
def get_point(self, curve: Curve) -> Point:
|
||||
return Point(self.shared_key_x, self.shared_key_y, curve)
|
|
@ -0,0 +1,251 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
from construct import Const, GreedyRange, Struct, Int32ub, Bytes, Int16ub, this, Switch, LazyBound, Array, Container
|
||||
|
||||
|
||||
class _XMRLicenseStructs:
|
||||
PlayEnablerType = Struct(
|
||||
"player_enabler_type" / Bytes(16)
|
||||
)
|
||||
|
||||
DomainRestrictionObject = Struct(
|
||||
"account_id" / Bytes(16),
|
||||
"revision" / Int32ub
|
||||
)
|
||||
|
||||
IssueDateObject = Struct(
|
||||
"issue_date" / Int32ub
|
||||
)
|
||||
|
||||
RevInfoVersionObject = Struct(
|
||||
"sequence" / Int32ub
|
||||
)
|
||||
|
||||
SecurityLevelObject = Struct(
|
||||
"minimum_security_level" / Int16ub
|
||||
)
|
||||
|
||||
EmbeddedLicenseSettingsObject = Struct(
|
||||
"indicator" / Int16ub
|
||||
)
|
||||
|
||||
ECCKeyObject = Struct(
|
||||
"curve_type" / Int16ub,
|
||||
"key_length" / Int16ub,
|
||||
"key" / Bytes(this.key_length)
|
||||
)
|
||||
|
||||
SignatureObject = Struct(
|
||||
"signature_type" / Int16ub,
|
||||
"signature_data_length" / Int16ub,
|
||||
"signature_data" / Bytes(this.signature_data_length)
|
||||
)
|
||||
|
||||
ContentKeyObject = Struct(
|
||||
"key_id" / Bytes(16),
|
||||
"key_type" / Int16ub,
|
||||
"cipher_type" / Int16ub,
|
||||
"key_length" / Int16ub,
|
||||
"encrypted_key" / Bytes(this.key_length)
|
||||
)
|
||||
|
||||
RightsSettingsObject = Struct(
|
||||
"rights" / Int16ub
|
||||
)
|
||||
|
||||
OutputProtectionLevelRestrictionObject = Struct(
|
||||
"minimum_compressed_digital_video_opl" / Int16ub,
|
||||
"minimum_uncompressed_digital_video_opl" / Int16ub,
|
||||
"minimum_analog_video_opl" / Int16ub,
|
||||
"minimum_digital_compressed_audio_opl" / Int16ub,
|
||||
"minimum_digital_uncompressed_audio_opl" / Int16ub,
|
||||
)
|
||||
|
||||
ExpirationRestrictionObject = Struct(
|
||||
"begin_date" / Int32ub,
|
||||
"end_date" / Int32ub
|
||||
)
|
||||
|
||||
RemovalDateObject = Struct(
|
||||
"removal_date" / Int32ub
|
||||
)
|
||||
|
||||
UplinkKIDObject = Struct(
|
||||
"uplink_kid" / Bytes(16),
|
||||
"chained_checksum_type" / Int16ub,
|
||||
"chained_checksum_length" / Int16ub,
|
||||
"chained_checksum" / Bytes(this.chained_checksum_length)
|
||||
)
|
||||
|
||||
AnalogVideoOutputConfigurationRestriction = Struct(
|
||||
"video_output_protection_id" / Bytes(16),
|
||||
"binary_configuration_data" / Bytes(this._.length - 24)
|
||||
)
|
||||
|
||||
DigitalVideoOutputRestrictionObject = Struct(
|
||||
"video_output_protection_id" / Bytes(16),
|
||||
"binary_configuration_data" / Bytes(this._.length - 24)
|
||||
)
|
||||
|
||||
DigitalAudioOutputRestrictionObject = Struct(
|
||||
"audio_output_protection_id" / Bytes(16),
|
||||
"binary_configuration_data" / Bytes(this._.length - 24)
|
||||
)
|
||||
|
||||
PolicyMetadataObject = Struct(
|
||||
"metadata_type" / Bytes(16),
|
||||
"policy_data" / Bytes(this._.length)
|
||||
)
|
||||
|
||||
SecureStopRestrictionObject = Struct(
|
||||
"metering_id" / Bytes(16)
|
||||
)
|
||||
|
||||
MeteringRestrictionObject = Struct(
|
||||
"metering_id" / Bytes(16)
|
||||
)
|
||||
|
||||
ExpirationAfterFirstPlayRestrictionObject = Struct(
|
||||
"seconds" / Int32ub
|
||||
)
|
||||
|
||||
GracePeriodObject = Struct(
|
||||
"grace_period" / Int32ub
|
||||
)
|
||||
|
||||
SourceIdObject = Struct(
|
||||
"source_id" / Int32ub
|
||||
)
|
||||
|
||||
AuxiliaryKey = Struct(
|
||||
"location" / Int32ub,
|
||||
"key" / Bytes(16)
|
||||
)
|
||||
|
||||
AuxiliaryKeysObject = Struct(
|
||||
"count" / Int16ub,
|
||||
"auxiliary_keys" / Array(this.count, AuxiliaryKey)
|
||||
)
|
||||
|
||||
UplinkKeyObject3 = Struct(
|
||||
"uplink_key_id" / Bytes(16),
|
||||
"chained_length" / Int16ub,
|
||||
"checksum" / Bytes(this.chained_length),
|
||||
"count" / Int16ub,
|
||||
"entries" / Array(this.count, Int32ub)
|
||||
)
|
||||
|
||||
CopyEnablerObject = Struct(
|
||||
"copy_enabler_type" / Bytes(16)
|
||||
)
|
||||
|
||||
CopyCountRestrictionObject = Struct(
|
||||
"count" / Int32ub
|
||||
)
|
||||
|
||||
MoveObject = Struct(
|
||||
"minimum_move_protection_level" / Int32ub
|
||||
)
|
||||
|
||||
XMRObject = Struct(
|
||||
"flags" / Int16ub,
|
||||
"type" / Int16ub,
|
||||
"length" / Int32ub,
|
||||
"data" / Switch(
|
||||
lambda this_: this_.type,
|
||||
{
|
||||
0x0005: OutputProtectionLevelRestrictionObject,
|
||||
0x0008: AnalogVideoOutputConfigurationRestriction,
|
||||
0x000a: ContentKeyObject,
|
||||
0x000b: SignatureObject,
|
||||
0x000d: RightsSettingsObject,
|
||||
0x0012: ExpirationRestrictionObject,
|
||||
0x0013: IssueDateObject,
|
||||
0x0016: MeteringRestrictionObject,
|
||||
0x001a: GracePeriodObject,
|
||||
0x0022: SourceIdObject,
|
||||
0x002a: ECCKeyObject,
|
||||
0x002c: PolicyMetadataObject,
|
||||
0x0029: DomainRestrictionObject,
|
||||
0x0030: ExpirationAfterFirstPlayRestrictionObject,
|
||||
0x0031: DigitalAudioOutputRestrictionObject,
|
||||
0x0032: RevInfoVersionObject,
|
||||
0x0033: EmbeddedLicenseSettingsObject,
|
||||
0x0034: SecurityLevelObject,
|
||||
0x0037: MoveObject,
|
||||
0x0039: PlayEnablerType,
|
||||
0x003a: CopyEnablerObject,
|
||||
0x003b: UplinkKIDObject,
|
||||
0x003d: CopyCountRestrictionObject,
|
||||
0x0050: RemovalDateObject,
|
||||
0x0051: AuxiliaryKeysObject,
|
||||
0x0052: UplinkKeyObject3,
|
||||
0x005a: SecureStopRestrictionObject,
|
||||
0x0059: DigitalVideoOutputRestrictionObject
|
||||
},
|
||||
default=LazyBound(lambda: _XMRLicenseStructs.XMRObject)
|
||||
)
|
||||
)
|
||||
|
||||
XmrLicense = Struct(
|
||||
"signature" / Const(b"XMR\x00"),
|
||||
"xmr_version" / Int32ub,
|
||||
"rights_id" / Bytes(16),
|
||||
"containers" / GreedyRange(XMRObject)
|
||||
)
|
||||
|
||||
|
||||
class XMRLicense(_XMRLicenseStructs):
|
||||
def __init__(
|
||||
self,
|
||||
parsed_license: Container,
|
||||
license_obj: _XMRLicenseStructs.XmrLicense = _XMRLicenseStructs.XmrLicense
|
||||
):
|
||||
self.parsed = parsed_license
|
||||
self._LICENSE = license_obj
|
||||
|
||||
@classmethod
|
||||
def loads(cls, data: Union[str, bytes]) -> XMRLicense:
|
||||
if isinstance(data, str):
|
||||
data = base64.b64decode(data)
|
||||
if not isinstance(data, bytes):
|
||||
raise ValueError(f"Expecting Bytes or Base64 input, got {data!r}")
|
||||
|
||||
licence = _XMRLicenseStructs.XmrLicense
|
||||
return cls(
|
||||
parsed_license=licence.parse(data),
|
||||
license_obj=licence
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Union[Path, str]) -> XMRLicense:
|
||||
if not isinstance(path, (Path, str)):
|
||||
raise ValueError(f"Expecting Path object or path string, got {path!r}")
|
||||
with Path(path).open(mode="rb") as f:
|
||||
return cls.loads(f.read())
|
||||
|
||||
def dumps(self) -> bytes:
|
||||
return self._LICENSE.build(self.parsed)
|
||||
|
||||
def struct(self) -> _XMRLicenseStructs.XmrLicense:
|
||||
return self._LICENSE
|
||||
|
||||
def _locate(self, container: Container):
|
||||
if container.flags == 2 or container.flags == 3:
|
||||
return self._locate(container.data)
|
||||
else:
|
||||
return container
|
||||
|
||||
def get_object(self, type_: int):
|
||||
for obj in self.parsed.containers:
|
||||
container = self._locate(obj)
|
||||
if container.type == type_:
|
||||
yield container.data
|
||||
|
||||
def get_content_keys(self):
|
||||
for content_key in self.get_object(10):
|
||||
yield content_key
|
|
@ -0,0 +1,41 @@
|
|||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry]
|
||||
name = "pyplayready"
|
||||
version = "0.1.6"
|
||||
description = "pyplayready CDM (Content Decryption Module) implementation in Python."
|
||||
license = "GPL-3.0-only"
|
||||
authors = ["DevLARLEY"]
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/ready-dl/pyplayready"
|
||||
keywords = ["python", "drm", "playready", "microsoft"]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"Natural Language :: English",
|
||||
"Operating System :: OS Independent",
|
||||
"Topic :: Multimedia :: Video",
|
||||
"Topic :: Security :: Cryptography",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules"
|
||||
]
|
||||
include = [
|
||||
{ path = "README.md", format = "sdist" },
|
||||
{ path = "LICENSE", format = "sdist" },
|
||||
]
|
||||
|
||||
[tool.poetry.urls]
|
||||
"Issues" = "https://github.com/ready-dl/pyplayready/issues"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.8,<4.0"
|
||||
requests = "^2.32.3"
|
||||
pycryptodome = "^3.21.0"
|
||||
construct = "^2.10.70"
|
||||
ECPy = "^1.2.5"
|
||||
click = "^8.1.7"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
pyplayready = "pyplayready.main:main"
|
|
@ -0,0 +1,5 @@
|
|||
requests
|
||||
pycryptodome
|
||||
ecpy
|
||||
construct
|
||||
click
|
Loading…
Reference in New Issue