From e5782f5674c5dee5b5af0c7d05732f12cfb77dff Mon Sep 17 00:00:00 2001 From: BuildTools Date: Fri, 6 Dec 2024 19:17:04 +0100 Subject: [PATCH] + File structure re-design + Moved ECC functions to a dedicated Crypto class --- README.md | 2 +- pyplayready/__init__.py | 25 +- pyplayready/cdm.py | 81 +-- pyplayready/crypto/__init__.py | 96 +++ pyplayready/{ => crypto}/ecc_key.py | 2 +- pyplayready/{ => crypto}/elgamal.py | 0 pyplayready/{device.py => device/__init__.py} | 62 +- pyplayready/device/structs.py | 39 ++ pyplayready/{ => license}/key.py | 0 pyplayready/{ => license}/xml_key.py | 10 +- pyplayready/{ => license}/xmrlicense.py | 3 +- pyplayready/main.py | 8 +- pyplayready/{ => remote}/remotecdm.py | 316 ++++----- pyplayready/{ => remote}/serve.py | 643 +++++++++--------- pyplayready/{ => system}/bcert.py | 25 +- pyplayready/{ => system}/pssh.py | 2 +- pyplayready/{ => system}/session.py | 38 +- pyplayready/{ => system}/wrmheader.py | 0 pyproject.toml | 2 +- 19 files changed, 713 insertions(+), 641 deletions(-) create mode 100644 pyplayready/crypto/__init__.py rename pyplayready/{ => crypto}/ecc_key.py (98%) rename pyplayready/{ => crypto}/elgamal.py (100%) rename pyplayready/{device.py => device/__init__.py} (70%) create mode 100644 pyplayready/device/structs.py rename pyplayready/{ => license}/key.py (100%) rename pyplayready/{ => license}/xml_key.py (65%) rename pyplayready/{ => license}/xmrlicense.py (98%) rename pyplayready/{ => remote}/remotecdm.py (96%) rename pyplayready/{ => remote}/serve.py (96%) rename pyplayready/{ => system}/bcert.py (96%) rename pyplayready/{ => system}/pssh.py (98%) rename pyplayready/{ => system}/session.py (63%) rename pyplayready/{ => system}/wrmheader.py (100%) diff --git a/README.md b/README.md index 83cc9eb..96903bc 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ An example code snippet: ```python from pyplayready.cdm import Cdm from pyplayready.device import Device -from pyplayready.pssh import PSSH +from pyplayready.system.pssh import PSSH import requests diff --git a/pyplayready/__init__.py b/pyplayready/__init__.py index 7351361..c54646c 100644 --- a/pyplayready/__init__.py +++ b/pyplayready/__init__.py @@ -1,13 +1,14 @@ -from .bcert import * -from .cdm import * -from .device import * -from .ecc_key import * -from .elgamal import * -from .key import * -from .pssh import * -from .remotecdm import * -from .session import * -from .xml_key import * -from .xmrlicense import * +from pyplayready.cdm import * +from pyplayready.crypto.ecc_key import * +from pyplayready.crypto.elgamal import * +from pyplayready.device import * +from pyplayready.license.key import * +from pyplayready.license.xml_key import * +from pyplayready.license.xmrlicense import * +from pyplayready.remote.remotecdm import * +from pyplayready.system.bcert import * +from pyplayready.system.pssh import * +from pyplayready.system.session import * -__version__ = "0.4.3" + +__version__ = "0.4.4" diff --git a/pyplayready/cdm.py b/pyplayready/cdm.py index 6b88ef5..5153527 100644 --- a/pyplayready/cdm.py +++ b/pyplayready/cdm.py @@ -10,23 +10,21 @@ 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 - +from pyplayready.crypto import Crypto +from pyplayready.system.bcert import CertificateChain +from pyplayready.crypto.ecc_key import ECCKey +from pyplayready.license.key import Key +from pyplayready.license.xml_key import XmlKey +from pyplayready.license.xmrlicense import XMRLicense from pyplayready.exceptions import (InvalidSession, TooManySessions, InvalidLicense) -from pyplayready.session import Session +from pyplayready.system.session import Session class Cdm: - MAX_NUM_OF_SESSIONS = 16 def __init__( @@ -45,13 +43,11 @@ class Cdm: self.client_version = client_version self.protocol_version = protocol_version - self.curve = Curve.get_curve("secp256r1") - self.elgamal = ElGamal(self.curve) - + self.__crypto = Crypto() self._wmrm_key = Point( x=0xc8b6af16ee941aadaa5389b4af2c10e356be42af175ef3face93254e7b0b3d9b, y=0x982b27b5cb2341326e56aa857dbfd5c634ce2cf9ea74fca8f2af5957efeea562, - curve=self.curve + curve=Curve.get_curve("secp256r1") ) self.__sessions: dict[bytes, Session] = {} @@ -98,11 +94,10 @@ class Cdm: del self.__sessions[session_id] def _get_key_data(self, session: Session) -> bytes: - point1, point2 = self.elgamal.encrypt( - message_point=session.xml_key.get_point(self.elgamal.curve), - public_key=self._wmrm_key + return self.__crypto.ecc256_encrypt( + 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: b64_chain = base64.b64encode(self.certificate_chain.dumps()).decode() @@ -190,10 +185,7 @@ class Cdm: 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(session.signing_key.key, 'fips-186-3') - signature = signer.sign(signed_info_digest) + signature = self.__crypto.ecc256_sign(session.signing_key, signed_info.encode()) # haven't found a better way to do this. xmltodict.unparse doesn't work main_body = ( @@ -224,23 +216,8 @@ class Cdm: 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 - 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)) if not ecc_keys: raise InvalidLicense("No ECC public key in license") @@ -258,21 +235,29 @@ class Cdm: 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) - 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") - for key in parsed_licence.get_content_keys(): - if Key.CipherType(key.cipher_type) == Key.CipherType.ECC_256: - session.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(session, key.encrypted_key) - )) + for content_key in parsed_licence.get_content_keys(): + if Key.CipherType(content_key.cipher_type) == Key.CipherType.ECC_256: + key = self.__crypto.ecc256_decrypt( + private_key=session.encryption_key, + ciphertext=content_key.encrypted_key + )[16:32] + else: + 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: raise InvalidLicense(e) except Exception as e: diff --git a/pyplayready/crypto/__init__.py b/pyplayready/crypto/__init__.py new file mode 100644 index 0000000..4e339eb --- /dev/null +++ b/pyplayready/crypto/__init__.py @@ -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 diff --git a/pyplayready/ecc_key.py b/pyplayready/crypto/ecc_key.py similarity index 98% rename from pyplayready/ecc_key.py rename to pyplayready/crypto/ecc_key.py index 1fbf78b..01c497c 100644 --- a/pyplayready/ecc_key.py +++ b/pyplayready/crypto/ecc_key.py @@ -11,7 +11,7 @@ from ecpy.curves import Curve, Point class ECCKey: - """Represents a PlayReady ECC key""" + """Represents a PlayReady ECC key pair""" def __init__(self, key: EccKey): self.key = key diff --git a/pyplayready/elgamal.py b/pyplayready/crypto/elgamal.py similarity index 100% rename from pyplayready/elgamal.py rename to pyplayready/crypto/elgamal.py diff --git a/pyplayready/device.py b/pyplayready/device/__init__.py similarity index 70% rename from pyplayready/device.py rename to pyplayready/device/__init__.py index b52005a..a23da4d 100644 --- a/pyplayready/device.py +++ b/pyplayready/device/__init__.py @@ -5,61 +5,21 @@ 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") - - 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), - ) +from pyplayready.device.structs import DeviceStructs +from pyplayready.system.bcert import CertificateChain +from pyplayready.crypto.ecc_key import ECCKey class Device: """Represents a PlayReady Device (.prd)""" - CURRENT_STRUCT = _DeviceStructs.v3 + CURRENT_STRUCT = DeviceStructs.v3 CURRENT_VERSION = 3 + class SecurityLevel(IntEnum): + SL150 = 150 + SL2000 = 2000 + SL3000 = 3000 + def __init__( self, *_: Any, @@ -100,11 +60,11 @@ class Device: if not isinstance(data, bytes): 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: return cls( group_key=None, - **_DeviceStructs.v2.parse(data) + **DeviceStructs.v2.parse(data) ) return cls(**cls.CURRENT_STRUCT.parse(data)) diff --git a/pyplayready/device/structs.py b/pyplayready/device/structs.py new file mode 100644 index 0000000..cf6ca02 --- /dev/null +++ b/pyplayready/device/structs.py @@ -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), + ) diff --git a/pyplayready/key.py b/pyplayready/license/key.py similarity index 100% rename from pyplayready/key.py rename to pyplayready/license/key.py diff --git a/pyplayready/xml_key.py b/pyplayready/license/xml_key.py similarity index 65% rename from pyplayready/xml_key.py rename to pyplayready/license/xml_key.py index 468a3bd..cc501fd 100644 --- a/pyplayready/xml_key.py +++ b/pyplayready/license/xml_key.py @@ -1,13 +1,15 @@ from ecpy.curves import Point, Curve -from pyplayready.ecc_key import ECCKey -from pyplayready.elgamal import ElGamal +from pyplayready.crypto.ecc_key import ECCKey +from pyplayready.crypto.elgamal import ElGamal class XmlKey: """Represents a PlayReady XMLKey""" def __init__(self): + self.curve = Curve.get_curve("secp256r1") + 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 @@ -16,5 +18,5 @@ class XmlKey: 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) + def get_point(self) -> Point: + return Point(self.shared_key_x, self.shared_key_y, self.curve) diff --git a/pyplayready/xmrlicense.py b/pyplayready/license/xmrlicense.py similarity index 98% rename from pyplayready/xmrlicense.py rename to pyplayready/license/xmrlicense.py index bdc92a9..76255b6 100644 --- a/pyplayready/xmrlicense.py +++ b/pyplayready/license/xmrlicense.py @@ -249,5 +249,4 @@ class XMRLicense(_XMRLicenseStructs): yield container.data def get_content_keys(self): - for content_key in self.get_object(10): - yield content_key + yield from self.get_object(10) diff --git a/pyplayready/main.py b/pyplayready/main.py index 570e441..73c8d49 100644 --- a/pyplayready/main.py +++ b/pyplayready/main.py @@ -8,12 +8,12 @@ import requests from Crypto.Random import get_random_bytes from pyplayready import __version__ -from pyplayready.bcert import CertificateChain, Certificate +from pyplayready.system.bcert import CertificateChain, Certificate from pyplayready.cdm import Cdm 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.pssh import PSSH +from pyplayready.system.pssh import PSSH @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. 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 config = yaml.safe_load(config_path.read_text(encoding="utf8")) diff --git a/pyplayready/remotecdm.py b/pyplayready/remote/remotecdm.py similarity index 96% rename from pyplayready/remotecdm.py rename to pyplayready/remote/remotecdm.py index 1a405fd..9afd6b3 100644 --- a/pyplayready/remotecdm.py +++ b/pyplayready/remote/remotecdm.py @@ -1,158 +1,158 @@ -from __future__ import annotations - -import re - -import requests - -from pyplayready.cdm import Cdm -from pyplayready.device import Device -from pyplayready.key import Key - -from pyplayready.exceptions import (DeviceMismatch, InvalidInitData) - - -class RemoteCdm(Cdm): - """Remote Accessible CDM using pyplayready's serve schema.""" - - def __init__( - self, - security_level: int, - host: str, - secret: str, - device_name: str - ): - """Initialize a Playready Content Decryption Module (CDM).""" - if not security_level: - raise ValueError("Security Level must be provided") - if not isinstance(security_level, int): - raise TypeError(f"Expected security_level to be a {int} not {security_level!r}") - - if not host: - raise ValueError("API Host must be provided") - if not isinstance(host, str): - raise TypeError(f"Expected host to be a {str} not {host!r}") - - if not secret: - raise ValueError("API Secret must be provided") - if not isinstance(secret, str): - raise TypeError(f"Expected secret to be a {str} not {secret!r}") - - if not device_name: - raise ValueError("API Device name must be provided") - if not isinstance(device_name, str): - raise TypeError(f"Expected device_name to be a {str} not {device_name!r}") - - self.security_level = security_level - self.host = host - self.device_name = device_name - - # spoof certificate_chain and ecc_key just so we can construct via super call - super().__init__(security_level, None, None, None) - - self.__session = requests.Session() - self.__session.headers.update({ - "X-Secret-Key": secret - }) - - r = requests.head(self.host) - if r.status_code != 200: - raise ValueError(f"Could not test Remote API version [{r.status_code}]") - server = r.headers.get("Server") - 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}).") - server_version_re = re.search(r"pyplayready serve v([\d.]+)", server, re.IGNORECASE) - if not server_version_re: - raise ValueError("The pyplayready server API is not stating the version correctly, cannot continue.") - server_version = server_version_re.group(1) - if server_version < "0.3.1": - raise ValueError(f"This pyplayready serve API version ({server_version}) is not supported.") - - @classmethod - def from_device(cls, device: Device) -> RemoteCdm: - raise NotImplementedError("You cannot load a RemoteCdm from a local Device file.") - - def open(self) -> bytes: - r = self.__session.get( - url=f"{self.host}/{self.device_name}/open" - ).json() - - if r['status'] != 200: - raise ValueError(f"Cannot Open CDM Session, {r['message']} [{r['status']}]") - r = r["data"] - - 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.") - - return bytes.fromhex(r["session_id"]) - - def close(self, session_id: bytes) -> None: - r = self.__session.get( - url=f"{self.host}/{self.device_name}/close/{session_id.hex()}" - ).json() - if r["status"] != 200: - raise ValueError(f"Cannot Close CDM Session, {r['message']} [{r['status']}]") - - def get_license_challenge( - self, - session_id: bytes, - wrm_header: str, - ) -> str: - if not wrm_header: - raise InvalidInitData("A wrm_header must be provided.") - if not isinstance(wrm_header, str): - raise InvalidInitData(f"Expected wrm_header to be a {str}, not {wrm_header!r}") - - r = self.__session.post( - url=f"{self.host}/{self.device_name}/get_license_challenge", - json={ - "session_id": session_id.hex(), - "init_data": wrm_header, - } - ).json() - if r["status"] != 200: - raise ValueError(f"Cannot get Challenge, {r['message']} [{r['status']}]") - r = r["data"] - - return r["challenge"] - - def parse_license(self, session_id: bytes, license_message: str) -> None: - if not license_message: - raise Exception("Cannot parse an empty license_message") - - if not isinstance(license_message, str): - raise Exception(f"Expected license_message to be a {str}, not {license_message!r}") - - r = self.__session.post( - url=f"{self.host}/{self.device_name}/parse_license", - json={ - "session_id": session_id.hex(), - "license_message": license_message - } - ).json() - if r["status"] != 200: - raise ValueError(f"Cannot parse License, {r['message']} [{r['status']}]") - - def get_keys(self, session_id: bytes) -> list[Key]: - r = self.__session.post( - url=f"{self.host}/{self.device_name}/get_keys", - json={ - "session_id": session_id.hex() - } - ).json() - if r["status"] != 200: - raise ValueError(f"Could not get Keys, {r['message']} [{r['status']}]") - r = r["data"] - - return [ - Key( - key_type=key["type"], - key_id=Key.kid_to_uuid(bytes.fromhex(key["key_id"])), - key=bytes.fromhex(key["key"]), - cipher_type=key["cipher_type"], - key_length=key["key_length"] - ) - for key in r["keys"] - ] - - -__all__ = ("RemoteCdm",) +from __future__ import annotations + +import re + +import requests + +from pyplayready.cdm import Cdm +from pyplayready.device import Device +from pyplayready.license.key import Key + +from pyplayready.exceptions import (DeviceMismatch, InvalidInitData) + + +class RemoteCdm(Cdm): + """Remote Accessible CDM using pyplayready's serve schema.""" + + def __init__( + self, + security_level: int, + host: str, + secret: str, + device_name: str + ): + """Initialize a Playready Content Decryption Module (CDM).""" + if not security_level: + raise ValueError("Security Level must be provided") + if not isinstance(security_level, int): + raise TypeError(f"Expected security_level to be a {int} not {security_level!r}") + + if not host: + raise ValueError("API Host must be provided") + if not isinstance(host, str): + raise TypeError(f"Expected host to be a {str} not {host!r}") + + if not secret: + raise ValueError("API Secret must be provided") + if not isinstance(secret, str): + raise TypeError(f"Expected secret to be a {str} not {secret!r}") + + if not device_name: + raise ValueError("API Device name must be provided") + if not isinstance(device_name, str): + raise TypeError(f"Expected device_name to be a {str} not {device_name!r}") + + self.security_level = security_level + self.host = host + self.device_name = device_name + + # spoof certificate_chain and ecc_key just so we can construct via super call + super().__init__(security_level, None, None, None) + + self.__session = requests.Session() + self.__session.headers.update({ + "X-Secret-Key": secret + }) + + r = requests.head(self.host) + if r.status_code != 200: + raise ValueError(f"Could not test Remote API version [{r.status_code}]") + server = r.headers.get("Server") + 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}).") + server_version_re = re.search(r"pyplayready serve v([\d.]+)", server, re.IGNORECASE) + if not server_version_re: + raise ValueError("The pyplayready server API is not stating the version correctly, cannot continue.") + server_version = server_version_re.group(1) + if server_version < "0.3.1": + raise ValueError(f"This pyplayready serve API version ({server_version}) is not supported.") + + @classmethod + def from_device(cls, device: Device) -> RemoteCdm: + raise NotImplementedError("You cannot load a RemoteCdm from a local Device file.") + + def open(self) -> bytes: + r = self.__session.get( + url=f"{self.host}/{self.device_name}/open" + ).json() + + if r['status'] != 200: + raise ValueError(f"Cannot Open CDM Session, {r['message']} [{r['status']}]") + r = r["data"] + + 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.") + + return bytes.fromhex(r["session_id"]) + + def close(self, session_id: bytes) -> None: + r = self.__session.get( + url=f"{self.host}/{self.device_name}/close/{session_id.hex()}" + ).json() + if r["status"] != 200: + raise ValueError(f"Cannot Close CDM Session, {r['message']} [{r['status']}]") + + def get_license_challenge( + self, + session_id: bytes, + wrm_header: str, + ) -> str: + if not wrm_header: + raise InvalidInitData("A wrm_header must be provided.") + if not isinstance(wrm_header, str): + raise InvalidInitData(f"Expected wrm_header to be a {str}, not {wrm_header!r}") + + r = self.__session.post( + url=f"{self.host}/{self.device_name}/get_license_challenge", + json={ + "session_id": session_id.hex(), + "init_data": wrm_header, + } + ).json() + if r["status"] != 200: + raise ValueError(f"Cannot get Challenge, {r['message']} [{r['status']}]") + r = r["data"] + + return r["challenge"] + + def parse_license(self, session_id: bytes, license_message: str) -> None: + if not license_message: + raise Exception("Cannot parse an empty license_message") + + if not isinstance(license_message, str): + raise Exception(f"Expected license_message to be a {str}, not {license_message!r}") + + r = self.__session.post( + url=f"{self.host}/{self.device_name}/parse_license", + json={ + "session_id": session_id.hex(), + "license_message": license_message + } + ).json() + if r["status"] != 200: + raise ValueError(f"Cannot parse License, {r['message']} [{r['status']}]") + + def get_keys(self, session_id: bytes) -> list[Key]: + r = self.__session.post( + url=f"{self.host}/{self.device_name}/get_keys", + json={ + "session_id": session_id.hex() + } + ).json() + if r["status"] != 200: + raise ValueError(f"Could not get Keys, {r['message']} [{r['status']}]") + r = r["data"] + + return [ + Key( + key_type=key["type"], + key_id=Key.kid_to_uuid(bytes.fromhex(key["key_id"])), + key=bytes.fromhex(key["key"]), + cipher_type=key["cipher_type"], + key_length=key["key_length"] + ) + for key in r["keys"] + ] + + +__all__ = ("RemoteCdm",) diff --git a/pyplayready/serve.py b/pyplayready/remote/serve.py similarity index 96% rename from pyplayready/serve.py rename to pyplayready/remote/serve.py index a1de8be..b6d5a0a 100644 --- a/pyplayready/serve.py +++ b/pyplayready/remote/serve.py @@ -1,322 +1,321 @@ -import base64 -from pathlib import Path -from typing import Any, Optional, Union - -from aiohttp.typedefs import Handler -from aiohttp import web - -from pyplayready import __version__, PSSH -from pyplayready.cdm import Cdm -from pyplayready.device import Device - -from pyplayready.exceptions import (InvalidSession, TooManySessions, InvalidLicense, InvalidPssh) - -routes = web.RouteTableDef() - - -async def _startup(app: web.Application) -> None: - app["cdms"] = {} - app["config"]["devices"] = { - path.stem: path - for x in app["config"]["devices"] - for path in [Path(x)] - } - for device in app["config"]["devices"].values(): - if not device.is_file(): - raise FileNotFoundError(f"Device file does not exist: {device}") - - -async def _cleanup(app: web.Application) -> None: - app["cdms"].clear() - del app["cdms"] - app["config"].clear() - del app["config"] - - -@routes.get("/") -async def ping(_: Any) -> web.Response: - return web.json_response({ - "status": 200, - "message": "Pong!" - }) - - -@routes.get("/{device}/open") -async def open_(request: web.Request) -> web.Response: - secret_key = request.headers["X-Secret-Key"] - device_name = request.match_info["device"] - user = request.app["config"]["users"][secret_key] - - 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 - # by trial and error to users that are not authorized to use them - return web.json_response({ - "status": 403, - "message": f"Device '{device_name}' is not found or you are not authorized to use it." - }, status=403) - - cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name)) - if not cdm: - device = Device.load(request.app["config"]["devices"][device_name]) - cdm = request.app["cdms"][(secret_key, device_name)] = Cdm.from_device(device) - - try: - session_id = cdm.open() - except TooManySessions as e: - return web.json_response({ - "status": 400, - "message": str(e) - }, status=400) - - return web.json_response({ - "status": 200, - "message": "Success", - "data": { - "session_id": session_id.hex(), - "device": { - "security_level": cdm.security_level - } - } - }) - - -@routes.get("/{device}/close/{session_id}") -async def close(request: web.Request) -> web.Response: - secret_key = request.headers["X-Secret-Key"] - device_name = request.match_info["device"] - session_id = bytes.fromhex(request.match_info["session_id"]) - - cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name)) - if not cdm: - return web.json_response({ - "status": 400, - "message": f"No Cdm session for {device_name} has been opened yet. No session to close." - }, status=400) - - try: - cdm.close(session_id) - except InvalidSession: - return web.json_response({ - "status": 400, - "message": f"Invalid Session ID '{session_id.hex()}', it may have expired." - }, status=400) - - return web.json_response({ - "status": 200, - "message": f"Successfully closed Session '{session_id.hex()}'." - }) - - -@routes.post("/{device}/get_license_challenge") -async def get_license_challenge(request: web.Request) -> web.Response: - secret_key = request.headers["X-Secret-Key"] - device_name = request.match_info["device"] - - body = await request.json() - for required_field in ("session_id", "init_data"): - if not body.get(required_field): - return web.json_response({ - "status": 400, - "message": f"Missing required field '{required_field}' in JSON body." - }, status=400) - - # get session id - session_id = bytes.fromhex(body["session_id"]) - - # get cdm - cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name)) - if not cdm: - return web.json_response({ - "status": 400, - "message": f"No Cdm session for {device_name} has been opened yet. No session to use." - }, status=400) - - # get init data - init_data = body["init_data"] - - if not init_data.startswith(" web.Response: - secret_key = request.headers["X-Secret-Key"] - device_name = request.match_info["device"] - - body = await request.json() - for required_field in ("session_id", "license_message"): - if not body.get(required_field): - return web.json_response({ - "status": 400, - "message": f"Missing required field '{required_field}' in JSON body." - }, status=400) - - # get session id - session_id = bytes.fromhex(body["session_id"]) - - # get cdm - cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name)) - if not cdm: - return web.json_response({ - "status": 400, - "message": f"No Cdm session for {device_name} has been opened yet. No session to use." - }, status=400) - - # parse the license message - try: - cdm.parse_license(session_id, body["license_message"]) - except InvalidSession: - return web.json_response({ - "status": 400, - "message": f"Invalid Session ID '{session_id.hex()}', it may have expired." - }, status=400) - except InvalidLicense as e: - return web.json_response({ - "status": 400, - "message": f"Invalid License, {e}" - }, status=400) - except Exception as e: - return web.json_response({ - "status": 500, - "message": f"Error, {e}" - }, status=500) - - return web.json_response({ - "status": 200, - "message": "Successfully parsed and loaded the Keys from the License message." - }) - - -@routes.post("/{device}/get_keys") -async def get_keys(request: web.Request) -> web.Response: - secret_key = request.headers["X-Secret-Key"] - device_name = request.match_info["device"] - - body = await request.json() - for required_field in ("session_id",): - if not body.get(required_field): - return web.json_response({ - "status": 400, - "message": f"Missing required field '{required_field}' in JSON body." - }, status=400) - - # get session id - session_id = bytes.fromhex(body["session_id"]) - - # get cdm - cdm = request.app["cdms"].get((secret_key, device_name)) - if not cdm: - return web.json_response({ - "status": 400, - "message": f"No Cdm session for {device_name} has been opened yet. No session to use." - }, status=400) - - # get keys - try: - keys = cdm.get_keys(session_id) - except InvalidSession: - return web.json_response({ - "status": 400, - "message": f"Invalid Session ID '{session_id.hex()}', it may have expired." - }, status=400) - except Exception as e: - return web.json_response({ - "status": 500, - "message": f"Error, {e}" - }, status=500) - - # get the keys in json form - keys_json = [ - { - "key_id": key.key_id.hex, - "key": key.key.hex(), - "type": key.key_type.value, - "cipher_type": key.cipher_type.value, - "key_length": key.key_length, - } - for key in keys - ] - - return web.json_response({ - "status": 200, - "message": "Success", - "data": { - "keys": keys_json - } - }) - - -@web.middleware -async def authentication(request: web.Request, handler: Handler) -> web.Response: - secret_key = request.headers.get("X-Secret-Key") - - if request.path != "/" and not secret_key: - request.app.logger.debug(f"{request.remote} did not provide authorization.") - response = web.json_response({ - "status": "401", - "message": "Secret Key is Empty." - }, status=401) - elif request.path != "/" and secret_key not in request.app["config"]["users"]: - request.app.logger.debug(f"{request.remote} failed authentication with '{secret_key}'.") - response = web.json_response({ - "status": "401", - "message": "Secret Key is Invalid, the Key is case-sensitive." - }, status=401) - else: - try: - response = await handler(request) # type: ignore[assignment] - except web.HTTPException as e: - request.app.logger.error(f"An unexpected error has occurred, {e}") - response = web.json_response({ - "status": 500, - "message": e.reason - }, status=500) - - response.headers.update({ - "Server": f"https://github.com/ready-dl/pyplayready serve v{__version__}" - }) - - return response - - -def run(config: dict, host: Optional[Union[str, web.HostSequence]] = None, port: Optional[int] = None) -> None: - app = web.Application(middlewares=[authentication]) - app.on_startup.append(_startup) - app.on_cleanup.append(_cleanup) - app.add_routes(routes) - app["config"] = config - web.run_app(app, host=host, port=port) +from pathlib import Path +from typing import Any, Optional, Union + +from aiohttp.typedefs import Handler +from aiohttp import web + +from pyplayready import __version__, PSSH +from pyplayready.cdm import Cdm +from pyplayready.device import Device + +from pyplayready.exceptions import (InvalidSession, TooManySessions, InvalidLicense, InvalidPssh) + +routes = web.RouteTableDef() + + +async def _startup(app: web.Application) -> None: + app["cdms"] = {} + app["config"]["devices"] = { + path.stem: path + for x in app["config"]["devices"] + for path in [Path(x)] + } + for device in app["config"]["devices"].values(): + if not device.is_file(): + raise FileNotFoundError(f"Device file does not exist: {device}") + + +async def _cleanup(app: web.Application) -> None: + app["cdms"].clear() + del app["cdms"] + app["config"].clear() + del app["config"] + + +@routes.get("/") +async def ping(_: Any) -> web.Response: + return web.json_response({ + "status": 200, + "message": "Pong!" + }) + + +@routes.get("/{device}/open") +async def open_(request: web.Request) -> web.Response: + secret_key = request.headers["X-Secret-Key"] + device_name = request.match_info["device"] + user = request.app["config"]["users"][secret_key] + + 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 + # by trial and error to users that are not authorized to use them + return web.json_response({ + "status": 403, + "message": f"Device '{device_name}' is not found or you are not authorized to use it." + }, status=403) + + cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name)) + if not cdm: + device = Device.load(request.app["config"]["devices"][device_name]) + cdm = request.app["cdms"][(secret_key, device_name)] = Cdm.from_device(device) + + try: + session_id = cdm.open() + except TooManySessions as e: + return web.json_response({ + "status": 400, + "message": str(e) + }, status=400) + + return web.json_response({ + "status": 200, + "message": "Success", + "data": { + "session_id": session_id.hex(), + "device": { + "security_level": cdm.security_level + } + } + }) + + +@routes.get("/{device}/close/{session_id}") +async def close(request: web.Request) -> web.Response: + secret_key = request.headers["X-Secret-Key"] + device_name = request.match_info["device"] + session_id = bytes.fromhex(request.match_info["session_id"]) + + cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name)) + if not cdm: + return web.json_response({ + "status": 400, + "message": f"No Cdm session for {device_name} has been opened yet. No session to close." + }, status=400) + + try: + cdm.close(session_id) + except InvalidSession: + return web.json_response({ + "status": 400, + "message": f"Invalid Session ID '{session_id.hex()}', it may have expired." + }, status=400) + + return web.json_response({ + "status": 200, + "message": f"Successfully closed Session '{session_id.hex()}'." + }) + + +@routes.post("/{device}/get_license_challenge") +async def get_license_challenge(request: web.Request) -> web.Response: + secret_key = request.headers["X-Secret-Key"] + device_name = request.match_info["device"] + + body = await request.json() + for required_field in ("session_id", "init_data"): + if not body.get(required_field): + return web.json_response({ + "status": 400, + "message": f"Missing required field '{required_field}' in JSON body." + }, status=400) + + # get session id + session_id = bytes.fromhex(body["session_id"]) + + # get cdm + cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name)) + if not cdm: + return web.json_response({ + "status": 400, + "message": f"No Cdm session for {device_name} has been opened yet. No session to use." + }, status=400) + + # get init data + init_data = body["init_data"] + + if not init_data.startswith(" web.Response: + secret_key = request.headers["X-Secret-Key"] + device_name = request.match_info["device"] + + body = await request.json() + for required_field in ("session_id", "license_message"): + if not body.get(required_field): + return web.json_response({ + "status": 400, + "message": f"Missing required field '{required_field}' in JSON body." + }, status=400) + + # get session id + session_id = bytes.fromhex(body["session_id"]) + + # get cdm + cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name)) + if not cdm: + return web.json_response({ + "status": 400, + "message": f"No Cdm session for {device_name} has been opened yet. No session to use." + }, status=400) + + # parse the license message + try: + cdm.parse_license(session_id, body["license_message"]) + except InvalidSession: + return web.json_response({ + "status": 400, + "message": f"Invalid Session ID '{session_id.hex()}', it may have expired." + }, status=400) + except InvalidLicense as e: + return web.json_response({ + "status": 400, + "message": f"Invalid License, {e}" + }, status=400) + except Exception as e: + return web.json_response({ + "status": 500, + "message": f"Error, {e}" + }, status=500) + + return web.json_response({ + "status": 200, + "message": "Successfully parsed and loaded the Keys from the License message." + }) + + +@routes.post("/{device}/get_keys") +async def get_keys(request: web.Request) -> web.Response: + secret_key = request.headers["X-Secret-Key"] + device_name = request.match_info["device"] + + body = await request.json() + for required_field in ("session_id",): + if not body.get(required_field): + return web.json_response({ + "status": 400, + "message": f"Missing required field '{required_field}' in JSON body." + }, status=400) + + # get session id + session_id = bytes.fromhex(body["session_id"]) + + # get cdm + cdm = request.app["cdms"].get((secret_key, device_name)) + if not cdm: + return web.json_response({ + "status": 400, + "message": f"No Cdm session for {device_name} has been opened yet. No session to use." + }, status=400) + + # get keys + try: + keys = cdm.get_keys(session_id) + except InvalidSession: + return web.json_response({ + "status": 400, + "message": f"Invalid Session ID '{session_id.hex()}', it may have expired." + }, status=400) + except Exception as e: + return web.json_response({ + "status": 500, + "message": f"Error, {e}" + }, status=500) + + # get the keys in json form + keys_json = [ + { + "key_id": key.key_id.hex, + "key": key.key.hex(), + "type": key.key_type.value, + "cipher_type": key.cipher_type.value, + "key_length": key.key_length, + } + for key in keys + ] + + return web.json_response({ + "status": 200, + "message": "Success", + "data": { + "keys": keys_json + } + }) + + +@web.middleware +async def authentication(request: web.Request, handler: Handler) -> web.Response: + secret_key = request.headers.get("X-Secret-Key") + + if request.path != "/" and not secret_key: + request.app.logger.debug(f"{request.remote} did not provide authorization.") + response = web.json_response({ + "status": "401", + "message": "Secret Key is Empty." + }, status=401) + elif request.path != "/" and secret_key not in request.app["config"]["users"]: + request.app.logger.debug(f"{request.remote} failed authentication with '{secret_key}'.") + response = web.json_response({ + "status": "401", + "message": "Secret Key is Invalid, the Key is case-sensitive." + }, status=401) + else: + try: + response = await handler(request) # type: ignore[assignment] + except web.HTTPException as e: + request.app.logger.error(f"An unexpected error has occurred, {e}") + response = web.json_response({ + "status": 500, + "message": e.reason + }, status=500) + + response.headers.update({ + "Server": f"https://github.com/ready-dl/pyplayready serve v{__version__}" + }) + + return response + + +def run(config: dict, host: Optional[Union[str, web.HostSequence]] = None, port: Optional[int] = None) -> None: + app = web.Application(middlewares=[authentication]) + app.on_startup.append(_startup) + app.on_cleanup.append(_cleanup) + app.add_routes(routes) + app["config"] = config + web.run_app(app, host=host, port=port) diff --git a/pyplayready/bcert.py b/pyplayready/system/bcert.py similarity index 96% rename from pyplayready/bcert.py rename to pyplayready/system/bcert.py index 9bb6a1e..6bfe758 100644 --- a/pyplayready/bcert.py +++ b/pyplayready/system/bcert.py @@ -3,6 +3,7 @@ import collections.abc from Crypto.PublicKey import ECC +from pyplayready.crypto import Crypto from pyplayready.exceptions import InvalidCertificateChain # monkey patch for construct 2.8.8 compatibility @@ -13,13 +14,11 @@ 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 +from pyplayready.crypto.ecc_key import ECCKey class _BCertStructs: @@ -249,7 +248,7 @@ class Certificate(_BCertStructs): # 2, # Receiver # 3, # SharedCertificate 4, # SecureClock - 5, # AntiRollBackClock + # 5, # AntiRollBackClock # 6, # ReservedMetering # 7, # ReservedLicSync # 8, # ReservedSymOpt @@ -323,10 +322,7 @@ class Certificate(_BCertStructs): 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 = Crypto.ecc256_sign(group_key, sign_payload) signature_info = Container( signature_type=1, @@ -403,14 +399,11 @@ class Certificate(_BCertStructs): point_y=int.from_bytes(raw_signature_key[32:], 'big') ) - hash_obj = SHA256.new(sign_payload) - verifier = DSS.new(signature_key, 'fips-186-3') - - try: - verifier.verify(hash_obj, signature_attribute.signature) - return True - except ValueError: - return False + return Crypto.ecc256_verify( + public_key=signature_key, + data=sign_payload, + signature=signature_attribute.signature + ) class CertificateChain(_BCertStructs): diff --git a/pyplayready/pssh.py b/pyplayready/system/pssh.py similarity index 98% rename from pyplayready/pssh.py rename to pyplayready/system/pssh.py index 122105b..5ce5b87 100644 --- a/pyplayready/pssh.py +++ b/pyplayready/system/pssh.py @@ -5,7 +5,7 @@ from uuid import UUID from construct import Struct, Int32ul, Int16ul, Array, this, Bytes, Switch, Int32ub, Const, Container, ConstructError from pyplayready.exceptions import InvalidPssh -from pyplayready.wrmheader import WRMHeader +from pyplayready.system.wrmheader import WRMHeader class _PlayreadyPSSHStructs: diff --git a/pyplayready/session.py b/pyplayready/system/session.py similarity index 63% rename from pyplayready/session.py rename to pyplayready/system/session.py index d3bad5d..f6be649 100644 --- a/pyplayready/session.py +++ b/pyplayready/system/session.py @@ -1,20 +1,18 @@ -from typing import Optional - -from Crypto.Random import get_random_bytes - -from pyplayready.key import Key -from pyplayready.ecc_key import ECCKey -from pyplayready.xml_key import XmlKey - - -class Session: - def __init__(self, number: int): - self.number = number - self.id = get_random_bytes(16) - self.xml_key = XmlKey() - self.signing_key: ECCKey = None - self.encryption_key: ECCKey = None - self.keys: list[Key] = [] - - -__all__ = ("Session",) \ No newline at end of file +from Crypto.Random import get_random_bytes + +from pyplayready.license.key import Key +from pyplayready.crypto.ecc_key import ECCKey +from pyplayready.license.xml_key import XmlKey + + +class Session: + def __init__(self, number: int): + self.number = number + self.id = get_random_bytes(16) + self.xml_key = XmlKey() + self.signing_key: ECCKey = None + self.encryption_key: ECCKey = None + self.keys: list[Key] = [] + + +__all__ = ("Session",) diff --git a/pyplayready/wrmheader.py b/pyplayready/system/wrmheader.py similarity index 100% rename from pyplayready/wrmheader.py rename to pyplayready/system/wrmheader.py diff --git a/pyproject.toml b/pyproject.toml index 6251d18..a817d52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "pyplayready" -version = "0.4.3" +version = "0.4.4" description = "pyplayready CDM (Content Decryption Module) implementation in Python." license = "CC BY-NC-ND 4.0" authors = ["DevLARLEY, Erevoc", "DevataDev"]