+ 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
from pyplayready.cdm import Cdm
from pyplayready.device import Device
from pyplayready.pssh import PSSH
from pyplayready.system.pssh import PSSH
import requests

View File

@ -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"

View File

@ -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:

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:
"""Represents a PlayReady ECC key"""
"""Represents a PlayReady ECC key pair"""
def __init__(self, key: EccKey):
self.key = key

View File

@ -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))

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 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)

View File

@ -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)

View File

@ -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"))

View File

@ -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",)

View File

@ -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("<WRMHEADER"):
try:
pssh = PSSH(init_data)
wrm_headers = pssh.get_wrm_headers(downgrade_to_v4=True)
if wrm_headers:
init_data = wrm_headers[0]
except InvalidPssh as e:
return web.json_response({
"status": 500,
"message": f"Unable to parse base64 PSSH, {e}"
}, status=500)
# get challenge
try:
license_request = cdm.get_license_challenge(
session_id=session_id,
wrm_header=init_data,
)
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)
return web.json_response({
"status": 200,
"message": "Success",
"data": {
"challenge": license_request
}
}, status=200)
@routes.post("/{device}/parse_license")
async def parse_license(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", "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("<WRMHEADER"):
try:
pssh = PSSH(init_data)
wrm_headers = pssh.get_wrm_headers(downgrade_to_v4=True)
if wrm_headers:
init_data = wrm_headers[0]
except InvalidPssh as e:
return web.json_response({
"status": 500,
"message": f"Unable to parse base64 PSSH, {e}"
}, status=500)
# get challenge
try:
license_request = cdm.get_license_challenge(
session_id=session_id,
wrm_header=init_data,
)
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)
return web.json_response({
"status": 200,
"message": "Success",
"data": {
"challenge": license_request
}
}, status=200)
@routes.post("/{device}/parse_license")
async def parse_license(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", "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)

View File

@ -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):

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 pyplayready.exceptions import InvalidPssh
from pyplayready.wrmheader import WRMHeader
from pyplayready.system.wrmheader import WRMHeader
class _PlayreadyPSSHStructs:

View File

@ -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",)
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",)

View File

@ -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"]