+ File structure re-design
+ Moved ECC functions to a dedicated Crypto class
This commit is contained in:
parent
c910d1f349
commit
e5782f5674
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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))
|
|
@ -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),
|
||||||
|
)
|
|
@ -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)
|
|
@ -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
|
|
|
@ -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"))
|
||||||
|
|
|
@ -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",)
|
|
@ -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)
|
|
|
@ -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):
|
|
@ -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:
|
|
@ -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",)
|
|
|
@ -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"]
|
||||||
|
|
Loading…
Reference in New Issue