+ File structure re-design

+ Moved ECC functions to a dedicated Crypto class
This commit is contained in:
BuildTools 2024-12-06 19:17:04 +01:00
parent c910d1f349
commit e5782f5674
19 changed files with 713 additions and 641 deletions

View File

@ -33,7 +33,7 @@ An example code snippet:
```python ```python
from pyplayready.cdm import Cdm from pyplayready.cdm import Cdm
from pyplayready.device import Device from pyplayready.device import Device
from pyplayready.pssh import PSSH from pyplayready.system.pssh import PSSH
import requests import requests

View File

@ -1,13 +1,14 @@
from .bcert import * from pyplayready.cdm import *
from .cdm import * from pyplayready.crypto.ecc_key import *
from .device import * from pyplayready.crypto.elgamal import *
from .ecc_key import * from pyplayready.device import *
from .elgamal import * from pyplayready.license.key import *
from .key import * from pyplayready.license.xml_key import *
from .pssh import * from pyplayready.license.xmrlicense import *
from .remotecdm import * from pyplayready.remote.remotecdm import *
from .session import * from pyplayready.system.bcert import *
from .xml_key import * from pyplayready.system.pssh import *
from .xmrlicense import * from pyplayready.system.session import *
__version__ = "0.4.3"
__version__ = "0.4.4"

View File

@ -10,23 +10,21 @@ import xml.etree.ElementTree as ET
from Crypto.Cipher import AES from Crypto.Cipher import AES
from Crypto.Hash import SHA256 from Crypto.Hash import SHA256
from Crypto.Random import get_random_bytes from Crypto.Random import get_random_bytes
from Crypto.Signature import DSS
from Crypto.Util.Padding import pad from Crypto.Util.Padding import pad
from ecpy.curves import Point, Curve from ecpy.curves import Point, Curve
from pyplayready.bcert import CertificateChain from pyplayready.crypto import Crypto
from pyplayready.ecc_key import ECCKey from pyplayready.system.bcert import CertificateChain
from pyplayready.key import Key from pyplayready.crypto.ecc_key import ECCKey
from pyplayready.xml_key import XmlKey from pyplayready.license.key import Key
from pyplayready.elgamal import ElGamal from pyplayready.license.xml_key import XmlKey
from pyplayready.xmrlicense import XMRLicense from pyplayready.license.xmrlicense import XMRLicense
from pyplayready.exceptions import (InvalidSession, TooManySessions, InvalidLicense) from pyplayready.exceptions import (InvalidSession, TooManySessions, InvalidLicense)
from pyplayready.session import Session from pyplayready.system.session import Session
class Cdm: class Cdm:
MAX_NUM_OF_SESSIONS = 16 MAX_NUM_OF_SESSIONS = 16
def __init__( def __init__(
@ -45,13 +43,11 @@ class Cdm:
self.client_version = client_version self.client_version = client_version
self.protocol_version = protocol_version self.protocol_version = protocol_version
self.curve = Curve.get_curve("secp256r1") self.__crypto = Crypto()
self.elgamal = ElGamal(self.curve)
self._wmrm_key = Point( self._wmrm_key = Point(
x=0xc8b6af16ee941aadaa5389b4af2c10e356be42af175ef3face93254e7b0b3d9b, x=0xc8b6af16ee941aadaa5389b4af2c10e356be42af175ef3face93254e7b0b3d9b,
y=0x982b27b5cb2341326e56aa857dbfd5c634ce2cf9ea74fca8f2af5957efeea562, y=0x982b27b5cb2341326e56aa857dbfd5c634ce2cf9ea74fca8f2af5957efeea562,
curve=self.curve curve=Curve.get_curve("secp256r1")
) )
self.__sessions: dict[bytes, Session] = {} self.__sessions: dict[bytes, Session] = {}
@ -98,11 +94,10 @@ class Cdm:
del self.__sessions[session_id] del self.__sessions[session_id]
def _get_key_data(self, session: Session) -> bytes: def _get_key_data(self, session: Session) -> bytes:
point1, point2 = self.elgamal.encrypt( return self.__crypto.ecc256_encrypt(
message_point=session.xml_key.get_point(self.elgamal.curve), public_key=self._wmrm_key,
public_key=self._wmrm_key plaintext=session.xml_key.get_point()
) )
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, session: Session) -> bytes: def _get_cipher_data(self, session: Session) -> bytes:
b64_chain = base64.b64encode(self.certificate_chain.dumps()).decode() b64_chain = base64.b64encode(self.certificate_chain.dumps()).decode()
@ -190,10 +185,7 @@ class Cdm:
la_hash = la_hash_obj.digest() la_hash = la_hash_obj.digest()
signed_info = self._build_signed_info(base64.b64encode(la_hash).decode()) signed_info = self._build_signed_info(base64.b64encode(la_hash).decode())
signed_info_digest = SHA256.new(signed_info.encode()) signature = self.__crypto.ecc256_sign(session.signing_key, signed_info.encode())
signer = DSS.new(session.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 # haven't found a better way to do this. xmltodict.unparse doesn't work
main_body = ( main_body = (
@ -224,23 +216,8 @@ class Cdm:
return main_body return main_body
def _decrypt_ecc256_key(self, session: Session, 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(session.encryption_key.key.d))
return self.elgamal.to_bytes(decrypted.x)[16:32]
@staticmethod @staticmethod
def _verify_ecc_key(session: Session, licence: XMRLicense) -> bool: def _verify_encryption_key(session: Session, licence: XMRLicense) -> bool:
ecc_keys = list(licence.get_object(42)) ecc_keys = list(licence.get_object(42))
if not ecc_keys: if not ecc_keys:
raise InvalidLicense("No ECC public key in license") raise InvalidLicense("No ECC public key in license")
@ -258,21 +235,29 @@ class Cdm:
try: try:
root = ET.fromstring(licence) root = ET.fromstring(licence)
license_elements = root.findall(".//{http://schemas.microsoft.com/DRM/2007/03/protocols}License") license_elements = root.findall(".//{http://schemas.microsoft.com/DRM/2007/03/protocols}License")
for license_element in license_elements: for license_element in license_elements:
parsed_licence = XMRLicense.loads(license_element.text) parsed_licence = XMRLicense.loads(license_element.text)
if not self._verify_ecc_key(session, parsed_licence): if not self._verify_encryption_key(session, parsed_licence):
raise InvalidLicense("Public encryption key does not match") raise InvalidLicense("Public encryption key does not match")
for key in parsed_licence.get_content_keys(): for content_key in parsed_licence.get_content_keys():
if Key.CipherType(key.cipher_type) == Key.CipherType.ECC_256: if Key.CipherType(content_key.cipher_type) == Key.CipherType.ECC_256:
session.keys.append(Key( key = self.__crypto.ecc256_decrypt(
key_id=UUID(bytes_le=key.key_id), private_key=session.encryption_key,
key_type=key.key_type, ciphertext=content_key.encrypted_key
cipher_type=key.cipher_type, )[16:32]
key_length=key.key_length, else:
key=self._decrypt_ecc256_key(session, key.encrypted_key) continue
))
session.keys.append(Key(
key_id=UUID(bytes_le=content_key.key_id),
key_type=content_key.key_type,
cipher_type=content_key.cipher_type,
key_length=content_key.key_length,
key=key
))
except InvalidLicense as e: except InvalidLicense as e:
raise InvalidLicense(e) raise InvalidLicense(e)
except Exception as e: except Exception as e:

View File

@ -0,0 +1,96 @@
from typing import Union, Tuple
from Crypto.Hash import SHA256
from Crypto.Hash.SHA256 import SHA256Hash
from Crypto.PublicKey.ECC import EccKey
from Crypto.Signature import DSS
from ecpy.curves import Point, Curve
from pyplayready.crypto.elgamal import ElGamal
from pyplayready.crypto.ecc_key import ECCKey
class Crypto:
def __init__(self, curve: str = "secp256r1"):
self.curve = Curve.get_curve(curve)
self.elgamal = ElGamal(self.curve)
def ecc256_encrypt(self, public_key: Union[ECCKey, Point], plaintext: Union[Point, bytes]) -> bytes:
if isinstance(public_key, ECCKey):
public_key = public_key.get_point(self.curve)
if not isinstance(public_key, Point):
raise ValueError(f"Expecting ECCKey or Point input, got {public_key!r}")
if isinstance(plaintext, bytes):
plaintext = Point(
x=int.from_bytes(plaintext[:32], 'big'),
y=int.from_bytes(plaintext[32:64], 'big'),
curve=self.curve
)
if not isinstance(plaintext, Point):
raise ValueError(f"Expecting Point or Bytes input, got {plaintext!r}")
point1, point2 = self.elgamal.encrypt(
message_point=plaintext,
public_key=public_key
)
return b''.join([
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 ecc256_decrypt(self, private_key: ECCKey, ciphertext: Union[Tuple[Point, Point], bytes]) -> bytes:
if isinstance(ciphertext, bytes):
ciphertext = (
Point(
x=int.from_bytes(ciphertext[:32], 'big'),
y=int.from_bytes(ciphertext[32:64], 'big'),
curve=self.curve
),
Point(
x=int.from_bytes(ciphertext[64:96], 'big'),
y=int.from_bytes(ciphertext[96:128], 'big'),
curve=self.curve
)
)
if not isinstance(ciphertext, Tuple):
raise ValueError(f"Expecting Tuple[Point, Point] or Bytes input, got {ciphertext!r}")
decrypted = self.elgamal.decrypt(ciphertext, int(private_key.key.d))
return self.elgamal.to_bytes(decrypted.x)
@staticmethod
def ecc256_sign(private_key: Union[ECCKey, EccKey], data: Union[SHA256Hash, bytes]) -> bytes:
if isinstance(private_key, ECCKey):
private_key = private_key.key
if not isinstance(private_key, EccKey):
raise ValueError(f"Expecting ECCKey or EccKey input, got {private_key!r}")
if isinstance(data, bytes):
data = SHA256.new(data)
if not isinstance(data, SHA256Hash):
raise ValueError(f"Expecting SHA256Hash or Bytes input, got {data!r}")
signer = DSS.new(private_key, 'fips-186-3')
return signer.sign(data)
@staticmethod
def ecc256_verify(public_key: Union[ECCKey, EccKey], data: Union[SHA256Hash, bytes], signature: bytes) -> bool:
if isinstance(public_key, ECCKey):
public_key = public_key.key
if not isinstance(public_key, EccKey):
raise ValueError(f"Expecting ECCKey or EccKey input, got {public_key!r}")
if isinstance(data, bytes):
data = SHA256.new(data)
if not isinstance(data, SHA256Hash):
raise ValueError(f"Expecting SHA256Hash or Bytes input, got {data!r}")
verifier = DSS.new(public_key, 'fips-186-3')
try:
verifier.verify(data, signature)
return True
except ValueError:
return False

View File

@ -11,7 +11,7 @@ from ecpy.curves import Curve, Point
class ECCKey: class ECCKey:
"""Represents a PlayReady ECC key""" """Represents a PlayReady ECC key pair"""
def __init__(self, key: EccKey): def __init__(self, key: EccKey):
self.key = key self.key = key

View File

@ -5,61 +5,21 @@ from enum import IntEnum
from pathlib import Path from pathlib import Path
from typing import Union, Any from typing import Union, Any
from construct import Struct, Const, Int8ub, Bytes, this, Int32ub from pyplayready.device.structs import DeviceStructs
from pyplayready.system.bcert import CertificateChain
from pyplayready.bcert import CertificateChain from pyplayready.crypto.ecc_key import ECCKey
from pyplayready.ecc_key import ECCKey
class SecurityLevel(IntEnum):
SL150 = 150
SL2000 = 2000
SL3000 = 3000
class _DeviceStructs:
magic = Const(b"PRD")
header = Struct(
"signature" / magic,
"version" / Int8ub,
)
# was never in production
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),
)
v3 = Struct(
"signature" / magic,
"version" / Int8ub,
"group_key" / Bytes(96),
"encryption_key" / Bytes(96),
"signing_key" / Bytes(96),
"group_certificate_length" / Int32ub,
"group_certificate" / Bytes(this.group_certificate_length),
)
class Device: class Device:
"""Represents a PlayReady Device (.prd)""" """Represents a PlayReady Device (.prd)"""
CURRENT_STRUCT = _DeviceStructs.v3 CURRENT_STRUCT = DeviceStructs.v3
CURRENT_VERSION = 3 CURRENT_VERSION = 3
class SecurityLevel(IntEnum):
SL150 = 150
SL2000 = 2000
SL3000 = 3000
def __init__( def __init__(
self, self,
*_: Any, *_: Any,
@ -100,11 +60,11 @@ class Device:
if not isinstance(data, bytes): if not isinstance(data, bytes):
raise ValueError(f"Expecting Bytes or Base64 input, got {data!r}") raise ValueError(f"Expecting Bytes or Base64 input, got {data!r}")
prd_header = _DeviceStructs.header.parse(data) prd_header = DeviceStructs.header.parse(data)
if prd_header.version == 2: if prd_header.version == 2:
return cls( return cls(
group_key=None, group_key=None,
**_DeviceStructs.v2.parse(data) **DeviceStructs.v2.parse(data)
) )
return cls(**cls.CURRENT_STRUCT.parse(data)) return cls(**cls.CURRENT_STRUCT.parse(data))

View File

@ -0,0 +1,39 @@
from construct import Struct, Const, Int8ub, Bytes, this, Int32ub
class DeviceStructs:
magic = Const(b"PRD")
header = Struct(
"signature" / magic,
"version" / Int8ub,
)
# was never in production
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),
)
v3 = Struct(
"signature" / magic,
"version" / Int8ub,
"group_key" / Bytes(96),
"encryption_key" / Bytes(96),
"signing_key" / Bytes(96),
"group_certificate_length" / Int32ub,
"group_certificate" / Bytes(this.group_certificate_length),
)

View File

@ -1,13 +1,15 @@
from ecpy.curves import Point, Curve from ecpy.curves import Point, Curve
from pyplayready.ecc_key import ECCKey from pyplayready.crypto.ecc_key import ECCKey
from pyplayready.elgamal import ElGamal from pyplayready.crypto.elgamal import ElGamal
class XmlKey: class XmlKey:
"""Represents a PlayReady XMLKey""" """Represents a PlayReady XMLKey"""
def __init__(self): def __init__(self):
self.curve = Curve.get_curve("secp256r1")
self._shared_point = ECCKey.generate() self._shared_point = ECCKey.generate()
self.shared_key_x = self._shared_point.key.pointQ.x self.shared_key_x = self._shared_point.key.pointQ.x
self.shared_key_y = self._shared_point.key.pointQ.y self.shared_key_y = self._shared_point.key.pointQ.y
@ -16,5 +18,5 @@ class XmlKey:
self.aes_iv = self._shared_key_x_bytes[:16] self.aes_iv = self._shared_key_x_bytes[:16]
self.aes_key = self._shared_key_x_bytes[16:] self.aes_key = self._shared_key_x_bytes[16:]
def get_point(self, curve: Curve) -> Point: def get_point(self) -> Point:
return Point(self.shared_key_x, self.shared_key_y, curve) return Point(self.shared_key_x, self.shared_key_y, self.curve)

View File

@ -249,5 +249,4 @@ class XMRLicense(_XMRLicenseStructs):
yield container.data yield container.data
def get_content_keys(self): def get_content_keys(self):
for content_key in self.get_object(10): yield from self.get_object(10)
yield content_key

View File

@ -8,12 +8,12 @@ import requests
from Crypto.Random import get_random_bytes from Crypto.Random import get_random_bytes
from pyplayready import __version__ from pyplayready import __version__
from pyplayready.bcert import CertificateChain, Certificate from pyplayready.system.bcert import CertificateChain, Certificate
from pyplayready.cdm import Cdm from pyplayready.cdm import Cdm
from pyplayready.device import Device from pyplayready.device import Device
from pyplayready.ecc_key import ECCKey from pyplayready.crypto.ecc_key import ECCKey
from pyplayready.exceptions import OutdatedDevice from pyplayready.exceptions import OutdatedDevice
from pyplayready.pssh import PSSH from pyplayready.system.pssh import PSSH
@click.group(invoke_without_command=True) @click.group(invoke_without_command=True)
@ -306,7 +306,7 @@ def serve_(config_path: Path, host: str, port: int) -> None:
Host as 127.0.0.1 may block remote access even if port-forwarded. Host as 127.0.0.1 may block remote access even if port-forwarded.
Instead, use 0.0.0.0 and ensure the TCP port you choose is forwarded. Instead, use 0.0.0.0 and ensure the TCP port you choose is forwarded.
""" """
from pyplayready import serve from pyplayready.remote import serve
import yaml import yaml
config = yaml.safe_load(config_path.read_text(encoding="utf8")) config = yaml.safe_load(config_path.read_text(encoding="utf8"))

View File

@ -1,158 +1,158 @@
from __future__ import annotations from __future__ import annotations
import re import re
import requests import requests
from pyplayready.cdm import Cdm from pyplayready.cdm import Cdm
from pyplayready.device import Device from pyplayready.device import Device
from pyplayready.key import Key from pyplayready.license.key import Key
from pyplayready.exceptions import (DeviceMismatch, InvalidInitData) from pyplayready.exceptions import (DeviceMismatch, InvalidInitData)
class RemoteCdm(Cdm): class RemoteCdm(Cdm):
"""Remote Accessible CDM using pyplayready's serve schema.""" """Remote Accessible CDM using pyplayready's serve schema."""
def __init__( def __init__(
self, self,
security_level: int, security_level: int,
host: str, host: str,
secret: str, secret: str,
device_name: str device_name: str
): ):
"""Initialize a Playready Content Decryption Module (CDM).""" """Initialize a Playready Content Decryption Module (CDM)."""
if not security_level: if not security_level:
raise ValueError("Security Level must be provided") raise ValueError("Security Level must be provided")
if not isinstance(security_level, int): if not isinstance(security_level, int):
raise TypeError(f"Expected security_level to be a {int} not {security_level!r}") raise TypeError(f"Expected security_level to be a {int} not {security_level!r}")
if not host: if not host:
raise ValueError("API Host must be provided") raise ValueError("API Host must be provided")
if not isinstance(host, str): if not isinstance(host, str):
raise TypeError(f"Expected host to be a {str} not {host!r}") raise TypeError(f"Expected host to be a {str} not {host!r}")
if not secret: if not secret:
raise ValueError("API Secret must be provided") raise ValueError("API Secret must be provided")
if not isinstance(secret, str): if not isinstance(secret, str):
raise TypeError(f"Expected secret to be a {str} not {secret!r}") raise TypeError(f"Expected secret to be a {str} not {secret!r}")
if not device_name: if not device_name:
raise ValueError("API Device name must be provided") raise ValueError("API Device name must be provided")
if not isinstance(device_name, str): if not isinstance(device_name, str):
raise TypeError(f"Expected device_name to be a {str} not {device_name!r}") raise TypeError(f"Expected device_name to be a {str} not {device_name!r}")
self.security_level = security_level self.security_level = security_level
self.host = host self.host = host
self.device_name = device_name self.device_name = device_name
# spoof certificate_chain and ecc_key just so we can construct via super call # spoof certificate_chain and ecc_key just so we can construct via super call
super().__init__(security_level, None, None, None) super().__init__(security_level, None, None, None)
self.__session = requests.Session() self.__session = requests.Session()
self.__session.headers.update({ self.__session.headers.update({
"X-Secret-Key": secret "X-Secret-Key": secret
}) })
r = requests.head(self.host) r = requests.head(self.host)
if r.status_code != 200: if r.status_code != 200:
raise ValueError(f"Could not test Remote API version [{r.status_code}]") raise ValueError(f"Could not test Remote API version [{r.status_code}]")
server = r.headers.get("Server") server = r.headers.get("Server")
if not server or "pyplayready serve" not in server.lower(): if not server or "pyplayready serve" not in server.lower():
raise ValueError(f"This Remote CDM API does not seem to be a pyplayready serve API ({server}).") raise ValueError(f"This Remote CDM API does not seem to be a pyplayready serve API ({server}).")
server_version_re = re.search(r"pyplayready serve v([\d.]+)", server, re.IGNORECASE) server_version_re = re.search(r"pyplayready serve v([\d.]+)", server, re.IGNORECASE)
if not server_version_re: if not server_version_re:
raise ValueError("The pyplayready server API is not stating the version correctly, cannot continue.") raise ValueError("The pyplayready server API is not stating the version correctly, cannot continue.")
server_version = server_version_re.group(1) server_version = server_version_re.group(1)
if server_version < "0.3.1": if server_version < "0.3.1":
raise ValueError(f"This pyplayready serve API version ({server_version}) is not supported.") raise ValueError(f"This pyplayready serve API version ({server_version}) is not supported.")
@classmethod @classmethod
def from_device(cls, device: Device) -> RemoteCdm: def from_device(cls, device: Device) -> RemoteCdm:
raise NotImplementedError("You cannot load a RemoteCdm from a local Device file.") raise NotImplementedError("You cannot load a RemoteCdm from a local Device file.")
def open(self) -> bytes: def open(self) -> bytes:
r = self.__session.get( r = self.__session.get(
url=f"{self.host}/{self.device_name}/open" url=f"{self.host}/{self.device_name}/open"
).json() ).json()
if r['status'] != 200: if r['status'] != 200:
raise ValueError(f"Cannot Open CDM Session, {r['message']} [{r['status']}]") raise ValueError(f"Cannot Open CDM Session, {r['message']} [{r['status']}]")
r = r["data"] r = r["data"]
if int(r["device"]["security_level"]) != self.security_level: if int(r["device"]["security_level"]) != self.security_level:
raise DeviceMismatch("The Security Level specified does not match the one specified in the API response.") raise DeviceMismatch("The Security Level specified does not match the one specified in the API response.")
return bytes.fromhex(r["session_id"]) return bytes.fromhex(r["session_id"])
def close(self, session_id: bytes) -> None: def close(self, session_id: bytes) -> None:
r = self.__session.get( r = self.__session.get(
url=f"{self.host}/{self.device_name}/close/{session_id.hex()}" url=f"{self.host}/{self.device_name}/close/{session_id.hex()}"
).json() ).json()
if r["status"] != 200: if r["status"] != 200:
raise ValueError(f"Cannot Close CDM Session, {r['message']} [{r['status']}]") raise ValueError(f"Cannot Close CDM Session, {r['message']} [{r['status']}]")
def get_license_challenge( def get_license_challenge(
self, self,
session_id: bytes, session_id: bytes,
wrm_header: str, wrm_header: str,
) -> str: ) -> str:
if not wrm_header: if not wrm_header:
raise InvalidInitData("A wrm_header must be provided.") raise InvalidInitData("A wrm_header must be provided.")
if not isinstance(wrm_header, str): if not isinstance(wrm_header, str):
raise InvalidInitData(f"Expected wrm_header to be a {str}, not {wrm_header!r}") raise InvalidInitData(f"Expected wrm_header to be a {str}, not {wrm_header!r}")
r = self.__session.post( r = self.__session.post(
url=f"{self.host}/{self.device_name}/get_license_challenge", url=f"{self.host}/{self.device_name}/get_license_challenge",
json={ json={
"session_id": session_id.hex(), "session_id": session_id.hex(),
"init_data": wrm_header, "init_data": wrm_header,
} }
).json() ).json()
if r["status"] != 200: if r["status"] != 200:
raise ValueError(f"Cannot get Challenge, {r['message']} [{r['status']}]") raise ValueError(f"Cannot get Challenge, {r['message']} [{r['status']}]")
r = r["data"] r = r["data"]
return r["challenge"] return r["challenge"]
def parse_license(self, session_id: bytes, license_message: str) -> None: def parse_license(self, session_id: bytes, license_message: str) -> None:
if not license_message: if not license_message:
raise Exception("Cannot parse an empty license_message") raise Exception("Cannot parse an empty license_message")
if not isinstance(license_message, str): if not isinstance(license_message, str):
raise Exception(f"Expected license_message to be a {str}, not {license_message!r}") raise Exception(f"Expected license_message to be a {str}, not {license_message!r}")
r = self.__session.post( r = self.__session.post(
url=f"{self.host}/{self.device_name}/parse_license", url=f"{self.host}/{self.device_name}/parse_license",
json={ json={
"session_id": session_id.hex(), "session_id": session_id.hex(),
"license_message": license_message "license_message": license_message
} }
).json() ).json()
if r["status"] != 200: if r["status"] != 200:
raise ValueError(f"Cannot parse License, {r['message']} [{r['status']}]") raise ValueError(f"Cannot parse License, {r['message']} [{r['status']}]")
def get_keys(self, session_id: bytes) -> list[Key]: def get_keys(self, session_id: bytes) -> list[Key]:
r = self.__session.post( r = self.__session.post(
url=f"{self.host}/{self.device_name}/get_keys", url=f"{self.host}/{self.device_name}/get_keys",
json={ json={
"session_id": session_id.hex() "session_id": session_id.hex()
} }
).json() ).json()
if r["status"] != 200: if r["status"] != 200:
raise ValueError(f"Could not get Keys, {r['message']} [{r['status']}]") raise ValueError(f"Could not get Keys, {r['message']} [{r['status']}]")
r = r["data"] r = r["data"]
return [ return [
Key( Key(
key_type=key["type"], key_type=key["type"],
key_id=Key.kid_to_uuid(bytes.fromhex(key["key_id"])), key_id=Key.kid_to_uuid(bytes.fromhex(key["key_id"])),
key=bytes.fromhex(key["key"]), key=bytes.fromhex(key["key"]),
cipher_type=key["cipher_type"], cipher_type=key["cipher_type"],
key_length=key["key_length"] key_length=key["key_length"]
) )
for key in r["keys"] for key in r["keys"]
] ]
__all__ = ("RemoteCdm",) __all__ = ("RemoteCdm",)

View File

@ -1,322 +1,321 @@
import base64 from pathlib import Path
from pathlib import Path from typing import Any, Optional, Union
from typing import Any, Optional, Union
from aiohttp.typedefs import Handler
from aiohttp.typedefs import Handler from aiohttp import web
from aiohttp import web
from pyplayready import __version__, PSSH
from pyplayready import __version__, PSSH from pyplayready.cdm import Cdm
from pyplayready.cdm import Cdm from pyplayready.device import Device
from pyplayready.device import Device
from pyplayready.exceptions import (InvalidSession, TooManySessions, InvalidLicense, InvalidPssh)
from pyplayready.exceptions import (InvalidSession, TooManySessions, InvalidLicense, InvalidPssh)
routes = web.RouteTableDef()
routes = web.RouteTableDef()
async def _startup(app: web.Application) -> None:
async def _startup(app: web.Application) -> None: app["cdms"] = {}
app["cdms"] = {} app["config"]["devices"] = {
app["config"]["devices"] = { path.stem: path
path.stem: path for x in app["config"]["devices"]
for x in app["config"]["devices"] for path in [Path(x)]
for path in [Path(x)] }
} for device in app["config"]["devices"].values():
for device in app["config"]["devices"].values(): if not device.is_file():
if not device.is_file(): raise FileNotFoundError(f"Device file does not exist: {device}")
raise FileNotFoundError(f"Device file does not exist: {device}")
async def _cleanup(app: web.Application) -> None:
async def _cleanup(app: web.Application) -> None: app["cdms"].clear()
app["cdms"].clear() del app["cdms"]
del app["cdms"] app["config"].clear()
app["config"].clear() del app["config"]
del app["config"]
@routes.get("/")
@routes.get("/") async def ping(_: Any) -> web.Response:
async def ping(_: Any) -> web.Response: return web.json_response({
return web.json_response({ "status": 200,
"status": 200, "message": "Pong!"
"message": "Pong!" })
})
@routes.get("/{device}/open")
@routes.get("/{device}/open") async def open_(request: web.Request) -> web.Response:
async def open_(request: web.Request) -> web.Response: secret_key = request.headers["X-Secret-Key"]
secret_key = request.headers["X-Secret-Key"] device_name = request.match_info["device"]
device_name = request.match_info["device"] user = request.app["config"]["users"][secret_key]
user = request.app["config"]["users"][secret_key]
if device_name not in user["devices"] or device_name not in request.app["config"]["devices"]:
if device_name not in user["devices"] or device_name not in request.app["config"]["devices"]: # we don't want to be verbose with the error as to not reveal device names
# we don't want to be verbose with the error as to not reveal device names # by trial and error to users that are not authorized to use them
# by trial and error to users that are not authorized to use them return web.json_response({
return web.json_response({ "status": 403,
"status": 403, "message": f"Device '{device_name}' is not found or you are not authorized to use it."
"message": f"Device '{device_name}' is not found or you are not authorized to use it." }, status=403)
}, status=403)
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name)) if not cdm:
if not cdm: device = Device.load(request.app["config"]["devices"][device_name])
device = Device.load(request.app["config"]["devices"][device_name]) cdm = request.app["cdms"][(secret_key, device_name)] = Cdm.from_device(device)
cdm = request.app["cdms"][(secret_key, device_name)] = Cdm.from_device(device)
try:
try: session_id = cdm.open()
session_id = cdm.open() except TooManySessions as e:
except TooManySessions as e: return web.json_response({
return web.json_response({ "status": 400,
"status": 400, "message": str(e)
"message": str(e) }, status=400)
}, status=400)
return web.json_response({
return web.json_response({ "status": 200,
"status": 200, "message": "Success",
"message": "Success", "data": {
"data": { "session_id": session_id.hex(),
"session_id": session_id.hex(), "device": {
"device": { "security_level": cdm.security_level
"security_level": cdm.security_level }
} }
} })
})
@routes.get("/{device}/close/{session_id}")
@routes.get("/{device}/close/{session_id}") async def close(request: web.Request) -> web.Response:
async def close(request: web.Request) -> web.Response: secret_key = request.headers["X-Secret-Key"]
secret_key = request.headers["X-Secret-Key"] device_name = request.match_info["device"]
device_name = request.match_info["device"] session_id = bytes.fromhex(request.match_info["session_id"])
session_id = bytes.fromhex(request.match_info["session_id"])
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name)) if not cdm:
if not cdm: return web.json_response({
return web.json_response({ "status": 400,
"status": 400, "message": f"No Cdm session for {device_name} has been opened yet. No session to close."
"message": f"No Cdm session for {device_name} has been opened yet. No session to close." }, status=400)
}, status=400)
try:
try: cdm.close(session_id)
cdm.close(session_id) except InvalidSession:
except InvalidSession: return web.json_response({
return web.json_response({ "status": 400,
"status": 400, "message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired." }, status=400)
}, status=400)
return web.json_response({
return web.json_response({ "status": 200,
"status": 200, "message": f"Successfully closed Session '{session_id.hex()}'."
"message": f"Successfully closed Session '{session_id.hex()}'." })
})
@routes.post("/{device}/get_license_challenge")
@routes.post("/{device}/get_license_challenge") async def get_license_challenge(request: web.Request) -> web.Response:
async def get_license_challenge(request: web.Request) -> web.Response: secret_key = request.headers["X-Secret-Key"]
secret_key = request.headers["X-Secret-Key"] device_name = request.match_info["device"]
device_name = request.match_info["device"]
body = await request.json()
body = await request.json() for required_field in ("session_id", "init_data"):
for required_field in ("session_id", "init_data"): if not body.get(required_field):
if not body.get(required_field): return web.json_response({
return web.json_response({ "status": 400,
"status": 400, "message": f"Missing required field '{required_field}' in JSON body."
"message": f"Missing required field '{required_field}' in JSON body." }, status=400)
}, status=400)
# get session id
# get session id session_id = bytes.fromhex(body["session_id"])
session_id = bytes.fromhex(body["session_id"])
# get cdm
# get cdm cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name)) if not cdm:
if not cdm: return web.json_response({
return web.json_response({ "status": 400,
"status": 400, "message": f"No Cdm session for {device_name} has been opened yet. No session to use."
"message": f"No Cdm session for {device_name} has been opened yet. No session to use." }, status=400)
}, status=400)
# get init data
# get init data init_data = body["init_data"]
init_data = body["init_data"]
if not init_data.startswith("<WRMHEADER"):
if not init_data.startswith("<WRMHEADER"): try:
try: pssh = PSSH(init_data)
pssh = PSSH(init_data) wrm_headers = pssh.get_wrm_headers(downgrade_to_v4=True)
wrm_headers = pssh.get_wrm_headers(downgrade_to_v4=True) if wrm_headers:
if wrm_headers: init_data = wrm_headers[0]
init_data = wrm_headers[0] except InvalidPssh as e:
except InvalidPssh as e: return web.json_response({
return web.json_response({ "status": 500,
"status": 500, "message": f"Unable to parse base64 PSSH, {e}"
"message": f"Unable to parse base64 PSSH, {e}" }, status=500)
}, status=500)
# get challenge
# get challenge try:
try: license_request = cdm.get_license_challenge(
license_request = cdm.get_license_challenge( session_id=session_id,
session_id=session_id, wrm_header=init_data,
wrm_header=init_data, )
) except InvalidSession:
except InvalidSession: return web.json_response({
return web.json_response({ "status": 400,
"status": 400, "message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired." }, status=400)
}, status=400) except Exception as e:
except Exception as e: return web.json_response({
return web.json_response({ "status": 500,
"status": 500, "message": f"Error, {e}"
"message": f"Error, {e}" }, status=500)
}, status=500)
return web.json_response({
return web.json_response({ "status": 200,
"status": 200, "message": "Success",
"message": "Success", "data": {
"data": { "challenge": license_request
"challenge": license_request }
} }, status=200)
}, status=200)
@routes.post("/{device}/parse_license")
@routes.post("/{device}/parse_license") async def parse_license(request: web.Request) -> web.Response:
async def parse_license(request: web.Request) -> web.Response: secret_key = request.headers["X-Secret-Key"]
secret_key = request.headers["X-Secret-Key"] device_name = request.match_info["device"]
device_name = request.match_info["device"]
body = await request.json()
body = await request.json() for required_field in ("session_id", "license_message"):
for required_field in ("session_id", "license_message"): if not body.get(required_field):
if not body.get(required_field): return web.json_response({
return web.json_response({ "status": 400,
"status": 400, "message": f"Missing required field '{required_field}' in JSON body."
"message": f"Missing required field '{required_field}' in JSON body." }, status=400)
}, status=400)
# get session id
# get session id session_id = bytes.fromhex(body["session_id"])
session_id = bytes.fromhex(body["session_id"])
# get cdm
# get cdm cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name)) if not cdm:
if not cdm: return web.json_response({
return web.json_response({ "status": 400,
"status": 400, "message": f"No Cdm session for {device_name} has been opened yet. No session to use."
"message": f"No Cdm session for {device_name} has been opened yet. No session to use." }, status=400)
}, status=400)
# parse the license message
# parse the license message try:
try: cdm.parse_license(session_id, body["license_message"])
cdm.parse_license(session_id, body["license_message"]) except InvalidSession:
except InvalidSession: return web.json_response({
return web.json_response({ "status": 400,
"status": 400, "message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired." }, status=400)
}, status=400) except InvalidLicense as e:
except InvalidLicense as e: return web.json_response({
return web.json_response({ "status": 400,
"status": 400, "message": f"Invalid License, {e}"
"message": f"Invalid License, {e}" }, status=400)
}, status=400) except Exception as e:
except Exception as e: return web.json_response({
return web.json_response({ "status": 500,
"status": 500, "message": f"Error, {e}"
"message": f"Error, {e}" }, status=500)
}, status=500)
return web.json_response({
return web.json_response({ "status": 200,
"status": 200, "message": "Successfully parsed and loaded the Keys from the License message."
"message": "Successfully parsed and loaded the Keys from the License message." })
})
@routes.post("/{device}/get_keys")
@routes.post("/{device}/get_keys") async def get_keys(request: web.Request) -> web.Response:
async def get_keys(request: web.Request) -> web.Response: secret_key = request.headers["X-Secret-Key"]
secret_key = request.headers["X-Secret-Key"] device_name = request.match_info["device"]
device_name = request.match_info["device"]
body = await request.json()
body = await request.json() for required_field in ("session_id",):
for required_field in ("session_id",): if not body.get(required_field):
if not body.get(required_field): return web.json_response({
return web.json_response({ "status": 400,
"status": 400, "message": f"Missing required field '{required_field}' in JSON body."
"message": f"Missing required field '{required_field}' in JSON body." }, status=400)
}, status=400)
# get session id
# get session id session_id = bytes.fromhex(body["session_id"])
session_id = bytes.fromhex(body["session_id"])
# get cdm
# get cdm cdm = request.app["cdms"].get((secret_key, device_name))
cdm = request.app["cdms"].get((secret_key, device_name)) if not cdm:
if not cdm: return web.json_response({
return web.json_response({ "status": 400,
"status": 400, "message": f"No Cdm session for {device_name} has been opened yet. No session to use."
"message": f"No Cdm session for {device_name} has been opened yet. No session to use." }, status=400)
}, status=400)
# get keys
# get keys try:
try: keys = cdm.get_keys(session_id)
keys = cdm.get_keys(session_id) except InvalidSession:
except InvalidSession: return web.json_response({
return web.json_response({ "status": 400,
"status": 400, "message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired." }, status=400)
}, status=400) except Exception as e:
except Exception as e: return web.json_response({
return web.json_response({ "status": 500,
"status": 500, "message": f"Error, {e}"
"message": f"Error, {e}" }, status=500)
}, status=500)
# get the keys in json form
# get the keys in json form keys_json = [
keys_json = [ {
{ "key_id": key.key_id.hex,
"key_id": key.key_id.hex, "key": key.key.hex(),
"key": key.key.hex(), "type": key.key_type.value,
"type": key.key_type.value, "cipher_type": key.cipher_type.value,
"cipher_type": key.cipher_type.value, "key_length": key.key_length,
"key_length": key.key_length, }
} for key in keys
for key in keys ]
]
return web.json_response({
return web.json_response({ "status": 200,
"status": 200, "message": "Success",
"message": "Success", "data": {
"data": { "keys": keys_json
"keys": keys_json }
} })
})
@web.middleware
@web.middleware async def authentication(request: web.Request, handler: Handler) -> web.Response:
async def authentication(request: web.Request, handler: Handler) -> web.Response: secret_key = request.headers.get("X-Secret-Key")
secret_key = request.headers.get("X-Secret-Key")
if request.path != "/" and not secret_key:
if request.path != "/" and not secret_key: request.app.logger.debug(f"{request.remote} did not provide authorization.")
request.app.logger.debug(f"{request.remote} did not provide authorization.") response = web.json_response({
response = web.json_response({ "status": "401",
"status": "401", "message": "Secret Key is Empty."
"message": "Secret Key is Empty." }, status=401)
}, status=401) elif request.path != "/" and secret_key not in request.app["config"]["users"]:
elif request.path != "/" and secret_key not in request.app["config"]["users"]: request.app.logger.debug(f"{request.remote} failed authentication with '{secret_key}'.")
request.app.logger.debug(f"{request.remote} failed authentication with '{secret_key}'.") response = web.json_response({
response = web.json_response({ "status": "401",
"status": "401", "message": "Secret Key is Invalid, the Key is case-sensitive."
"message": "Secret Key is Invalid, the Key is case-sensitive." }, status=401)
}, status=401) else:
else: try:
try: response = await handler(request) # type: ignore[assignment]
response = await handler(request) # type: ignore[assignment] except web.HTTPException as e:
except web.HTTPException as e: request.app.logger.error(f"An unexpected error has occurred, {e}")
request.app.logger.error(f"An unexpected error has occurred, {e}") response = web.json_response({
response = web.json_response({ "status": 500,
"status": 500, "message": e.reason
"message": e.reason }, status=500)
}, status=500)
response.headers.update({
response.headers.update({ "Server": f"https://github.com/ready-dl/pyplayready serve v{__version__}"
"Server": f"https://github.com/ready-dl/pyplayready serve v{__version__}" })
})
return response
return response
def run(config: dict, host: Optional[Union[str, web.HostSequence]] = None, port: Optional[int] = None) -> None:
def run(config: dict, host: Optional[Union[str, web.HostSequence]] = None, port: Optional[int] = None) -> None: app = web.Application(middlewares=[authentication])
app = web.Application(middlewares=[authentication]) app.on_startup.append(_startup)
app.on_startup.append(_startup) app.on_cleanup.append(_cleanup)
app.on_cleanup.append(_cleanup) app.add_routes(routes)
app.add_routes(routes) app["config"] = config
app["config"] = config web.run_app(app, host=host, port=port)
web.run_app(app, host=host, port=port)

View File

@ -3,6 +3,7 @@ import collections.abc
from Crypto.PublicKey import ECC from Crypto.PublicKey import ECC
from pyplayready.crypto import Crypto
from pyplayready.exceptions import InvalidCertificateChain from pyplayready.exceptions import InvalidCertificateChain
# monkey patch for construct 2.8.8 compatibility # monkey patch for construct 2.8.8 compatibility
@ -13,13 +14,11 @@ import base64
from pathlib import Path from pathlib import Path
from typing import Union 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 Bytes, Const, Int32ub, GreedyRange, Switch, Container, ListContainer
from construct import Int16ub, Array from construct import Int16ub, Array
from construct import Struct, this from construct import Struct, this
from pyplayready.ecc_key import ECCKey from pyplayready.crypto.ecc_key import ECCKey
class _BCertStructs: class _BCertStructs:
@ -249,7 +248,7 @@ class Certificate(_BCertStructs):
# 2, # Receiver # 2, # Receiver
# 3, # SharedCertificate # 3, # SharedCertificate
4, # SecureClock 4, # SecureClock
5, # AntiRollBackClock # 5, # AntiRollBackClock
# 6, # ReservedMetering # 6, # ReservedMetering
# 7, # ReservedLicSync # 7, # ReservedLicSync
# 8, # ReservedSymOpt # 8, # ReservedSymOpt
@ -323,10 +322,7 @@ class Certificate(_BCertStructs):
new_bcert_container.total_length = len(payload) + 144 # signature length new_bcert_container.total_length = len(payload) + 144 # signature length
sign_payload = _BCertStructs.BCert.build(new_bcert_container) sign_payload = _BCertStructs.BCert.build(new_bcert_container)
signature = Crypto.ecc256_sign(group_key, sign_payload)
hash_obj = SHA256.new(sign_payload)
signer = DSS.new(group_key.key, 'fips-186-3')
signature = signer.sign(hash_obj)
signature_info = Container( signature_info = Container(
signature_type=1, signature_type=1,
@ -403,14 +399,11 @@ class Certificate(_BCertStructs):
point_y=int.from_bytes(raw_signature_key[32:], 'big') point_y=int.from_bytes(raw_signature_key[32:], 'big')
) )
hash_obj = SHA256.new(sign_payload) return Crypto.ecc256_verify(
verifier = DSS.new(signature_key, 'fips-186-3') public_key=signature_key,
data=sign_payload,
try: signature=signature_attribute.signature
verifier.verify(hash_obj, signature_attribute.signature) )
return True
except ValueError:
return False
class CertificateChain(_BCertStructs): class CertificateChain(_BCertStructs):

View File

@ -5,7 +5,7 @@ from uuid import UUID
from construct import Struct, Int32ul, Int16ul, Array, this, Bytes, Switch, Int32ub, Const, Container, ConstructError from construct import Struct, Int32ul, Int16ul, Array, this, Bytes, Switch, Int32ub, Const, Container, ConstructError
from pyplayready.exceptions import InvalidPssh from pyplayready.exceptions import InvalidPssh
from pyplayready.wrmheader import WRMHeader from pyplayready.system.wrmheader import WRMHeader
class _PlayreadyPSSHStructs: class _PlayreadyPSSHStructs:

View File

@ -1,20 +1,18 @@
from typing import Optional from Crypto.Random import get_random_bytes
from Crypto.Random import get_random_bytes from pyplayready.license.key import Key
from pyplayready.crypto.ecc_key import ECCKey
from pyplayready.key import Key from pyplayready.license.xml_key import XmlKey
from pyplayready.ecc_key import ECCKey
from pyplayready.xml_key import XmlKey
class Session:
def __init__(self, number: int):
class Session: self.number = number
def __init__(self, number: int): self.id = get_random_bytes(16)
self.number = number self.xml_key = XmlKey()
self.id = get_random_bytes(16) self.signing_key: ECCKey = None
self.xml_key = XmlKey() self.encryption_key: ECCKey = None
self.signing_key: ECCKey = None self.keys: list[Key] = []
self.encryption_key: ECCKey = None
self.keys: list[Key] = []
__all__ = ("Session",)
__all__ = ("Session",)

View File

@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry] [tool.poetry]
name = "pyplayready" name = "pyplayready"
version = "0.4.3" version = "0.4.4"
description = "pyplayready CDM (Content Decryption Module) implementation in Python." description = "pyplayready CDM (Content Decryption Module) implementation in Python."
license = "CC BY-NC-ND 4.0" license = "CC BY-NC-ND 4.0"
authors = ["DevLARLEY, Erevoc", "DevataDev"] authors = ["DevLARLEY, Erevoc", "DevataDev"]