Session ID and RemoteCDM implementation
This commit is contained in:
parent
70e47800df
commit
8a4be776eb
|
@ -34,6 +34,7 @@ import requests
|
||||||
|
|
||||||
device = Device.load("C:/Path/To/A/Device.prd")
|
device = Device.load("C:/Path/To/A/Device.prd")
|
||||||
cdm = Cdm.from_device(device)
|
cdm = Cdm.from_device(device)
|
||||||
|
session_id = cdm.open()
|
||||||
|
|
||||||
pssh = PSSH(
|
pssh = PSSH(
|
||||||
"AAADfHBzc2gAAAAAmgTweZhAQoarkuZb4IhflQAAA1xcAwAAAQABAFIDPABXAFIATQBIAEUAQQBEAEUAUgAgAHgAbQBsAG4AcwA9ACIAaAB0AH"
|
"AAADfHBzc2gAAAAAmgTweZhAQoarkuZb4IhflQAAA1xcAwAAAQABAFIDPABXAFIATQBIAEUAQQBEAEUAUgAgAHgAbQBsAG4AcwA9ACIAaAB0AH"
|
||||||
|
@ -51,7 +52,7 @@ pssh = PSSH(
|
||||||
|
|
||||||
# set to `True` if your device doesn't support scalable licenses (this projects also doesn't yet) to downgrade the WRMHEADERs to v4.0.0.0
|
# set to `True` if your device doesn't support scalable licenses (this projects also doesn't yet) to downgrade the WRMHEADERs to v4.0.0.0
|
||||||
wrm_headers = pssh.get_wrm_headers(downgrade_to_v4=False)
|
wrm_headers = pssh.get_wrm_headers(downgrade_to_v4=False)
|
||||||
request = cdm.get_license_challenge(wrm_headers[0])
|
request = cdm.get_license_challenge(session_id, wrm_headers[0])
|
||||||
|
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
url="https://test.playready.microsoft.com/service/rightsmanager.asmx?cfg=(persist:false,sl:2000)",
|
url="https://test.playready.microsoft.com/service/rightsmanager.asmx?cfg=(persist:false,sl:2000)",
|
||||||
|
@ -61,9 +62,9 @@ response = requests.post(
|
||||||
data=request,
|
data=request,
|
||||||
)
|
)
|
||||||
|
|
||||||
cdm.parse_license(response.text)
|
cdm.parse_license(session_id, response.text)
|
||||||
|
|
||||||
for key in cdm.get_keys():
|
for key in cdm.get_keys(session_id):
|
||||||
print(f"{key.key_id.hex}:{key.key.hex()}")
|
print(f"{key.key_id.hex}:{key.key.hex()}")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,9 @@ from .ecc_key import *
|
||||||
from .elgamal import *
|
from .elgamal import *
|
||||||
from .key import *
|
from .key import *
|
||||||
from .pssh import *
|
from .pssh import *
|
||||||
|
from .remotecdm import *
|
||||||
|
from .session import *
|
||||||
from .xml_key import *
|
from .xml_key import *
|
||||||
from .xmrlicense import *
|
from .xmrlicense import *
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.0"
|
|
@ -21,8 +21,14 @@ from pyplayready.xml_key import XmlKey
|
||||||
from pyplayready.elgamal import ElGamal
|
from pyplayready.elgamal import ElGamal
|
||||||
from pyplayready.xmrlicense import XMRLicense
|
from pyplayready.xmrlicense import XMRLicense
|
||||||
|
|
||||||
|
from pyplayready.exceptions import (InvalidSession, TooManySessions)
|
||||||
|
from pyplayready.session import Session
|
||||||
|
|
||||||
|
|
||||||
class Cdm:
|
class Cdm:
|
||||||
|
|
||||||
|
MAX_NUM_OF_SESSIONS = 16
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
security_level: int,
|
security_level: int,
|
||||||
|
@ -47,9 +53,8 @@ class Cdm:
|
||||||
y=0x982b27b5cb2341326e56aa857dbfd5c634ce2cf9ea74fca8f2af5957efeea562,
|
y=0x982b27b5cb2341326e56aa857dbfd5c634ce2cf9ea74fca8f2af5957efeea562,
|
||||||
curve=self.curve
|
curve=self.curve
|
||||||
)
|
)
|
||||||
self._xml_key = XmlKey()
|
|
||||||
|
|
||||||
self._keys: List[Key] = []
|
self.__sessions: dict[bytes, Session] = {}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_device(cls, device) -> Cdm:
|
def from_device(cls, device) -> Cdm:
|
||||||
|
@ -61,21 +66,52 @@ class Cdm:
|
||||||
signing_key=device.signing_key
|
signing_key=device.signing_key
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_key_data(self) -> bytes:
|
def open(self) -> bytes:
|
||||||
|
"""
|
||||||
|
Open a Playready Content Decryption Module (CDM) session.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TooManySessions: If the session cannot be opened as limit has been reached.
|
||||||
|
"""
|
||||||
|
if len(self.__sessions) > self.MAX_NUM_OF_SESSIONS:
|
||||||
|
raise TooManySessions(f"Too many Sessions open ({self.MAX_NUM_OF_SESSIONS}).")
|
||||||
|
|
||||||
|
session = Session(len(self.__sessions) + 1)
|
||||||
|
self.__sessions[session.id] = session
|
||||||
|
session._xml_key = XmlKey()
|
||||||
|
|
||||||
|
return session.id
|
||||||
|
|
||||||
|
def close(self, session_id: bytes) -> None:
|
||||||
|
"""
|
||||||
|
Close a Playready Content Decryption Module (CDM) session.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
session_id: Session identifier.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InvalidSession: If the Session identifier is invalid.
|
||||||
|
"""
|
||||||
|
session = self.__sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||||
|
del self.__sessions[session_id]
|
||||||
|
|
||||||
|
def get_key_data(self, session: Session) -> bytes:
|
||||||
point1, point2 = self.elgamal.encrypt(
|
point1, point2 = self.elgamal.encrypt(
|
||||||
message_point=self._xml_key.get_point(self.elgamal.curve),
|
message_point= session._xml_key.get_point(self.elgamal.curve),
|
||||||
public_key=self._wmrm_key
|
public_key=self._wmrm_key
|
||||||
)
|
)
|
||||||
return self.elgamal.to_bytes(point1.x) + self.elgamal.to_bytes(point1.y) + self.elgamal.to_bytes(point2.x) + self.elgamal.to_bytes(point2.y)
|
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) -> 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()
|
||||||
body = f"<Data><CertificateChains><CertificateChain>{b64_chain}</CertificateChain></CertificateChains></Data>"
|
body = f"<Data><CertificateChains><CertificateChain>{b64_chain}</CertificateChain></CertificateChains></Data>"
|
||||||
|
|
||||||
cipher = AES.new(
|
cipher = AES.new(
|
||||||
key=self._xml_key.aes_key,
|
key=session._xml_key.aes_key,
|
||||||
mode=AES.MODE_CBC,
|
mode=AES.MODE_CBC,
|
||||||
iv=self._xml_key.aes_iv
|
iv=session._xml_key.aes_iv
|
||||||
)
|
)
|
||||||
|
|
||||||
ciphertext = cipher.encrypt(pad(
|
ciphertext = cipher.encrypt(pad(
|
||||||
|
@ -83,7 +119,7 @@ class Cdm:
|
||||||
AES.block_size
|
AES.block_size
|
||||||
))
|
))
|
||||||
|
|
||||||
return self._xml_key.aes_iv + ciphertext
|
return session._xml_key.aes_iv + ciphertext
|
||||||
|
|
||||||
def _build_digest_content(
|
def _build_digest_content(
|
||||||
self,
|
self,
|
||||||
|
@ -134,12 +170,19 @@ class Cdm:
|
||||||
'</SignedInfo>'
|
'</SignedInfo>'
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_license_challenge(self, content_header: str) -> str:
|
def get_license_challenge(self, session_id: bytes, content_header: str) -> str:
|
||||||
|
session = self.__sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||||
|
|
||||||
|
session.signing_key = self.signing_key
|
||||||
|
session.encryption_key = self.encryption_key
|
||||||
|
|
||||||
la_content = self._build_digest_content(
|
la_content = self._build_digest_content(
|
||||||
content_header=content_header,
|
content_header=content_header,
|
||||||
nonce=base64.b64encode(get_random_bytes(16)).decode(),
|
nonce=base64.b64encode(get_random_bytes(16)).decode(),
|
||||||
wmrm_cipher=base64.b64encode(self.get_key_data()).decode(),
|
wmrm_cipher=base64.b64encode(self.get_key_data(session)).decode(),
|
||||||
cert_cipher=base64.b64encode(self.get_cipher_data()).decode()
|
cert_cipher=base64.b64encode(self.get_cipher_data(session)).decode()
|
||||||
)
|
)
|
||||||
|
|
||||||
la_hash_obj = SHA256.new()
|
la_hash_obj = SHA256.new()
|
||||||
|
@ -149,7 +192,7 @@ class Cdm:
|
||||||
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())
|
signed_info_digest = SHA256.new(signed_info.encode())
|
||||||
|
|
||||||
signer = DSS.new(self.signing_key.key, 'fips-186-3')
|
signer = DSS.new(session.signing_key.key, 'fips-186-3')
|
||||||
signature = signer.sign(signed_info_digest)
|
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
|
||||||
|
@ -167,7 +210,7 @@ class Cdm:
|
||||||
'<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">'
|
'<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">'
|
||||||
'<KeyValue>'
|
'<KeyValue>'
|
||||||
'<ECCKeyValue>'
|
'<ECCKeyValue>'
|
||||||
f'<PublicKey>{base64.b64encode(self.signing_key.public_bytes()).decode()}</PublicKey>'
|
f'<PublicKey>{base64.b64encode(session.signing_key.public_bytes()).decode()}</PublicKey>'
|
||||||
'</ECCKeyValue>'
|
'</ECCKeyValue>'
|
||||||
'</KeyValue>'
|
'</KeyValue>'
|
||||||
'</KeyInfo>'
|
'</KeyInfo>'
|
||||||
|
@ -181,7 +224,7 @@ class Cdm:
|
||||||
|
|
||||||
return main_body
|
return main_body
|
||||||
|
|
||||||
def _decrypt_ecc256_key(self, encrypted_key: bytes) -> bytes:
|
def _decrypt_ecc256_key(self, session: Session, encrypted_key: bytes) -> bytes:
|
||||||
point1 = Point(
|
point1 = Point(
|
||||||
x=int.from_bytes(encrypted_key[:32], 'big'),
|
x=int.from_bytes(encrypted_key[:32], 'big'),
|
||||||
y=int.from_bytes(encrypted_key[32:64], 'big'),
|
y=int.from_bytes(encrypted_key[32:64], 'big'),
|
||||||
|
@ -193,10 +236,14 @@ class Cdm:
|
||||||
curve=self.curve
|
curve=self.curve
|
||||||
)
|
)
|
||||||
|
|
||||||
decrypted = self.elgamal.decrypt((point1, point2), int(self.encryption_key.key.d))
|
decrypted = self.elgamal.decrypt((point1, point2), int(session.encryption_key.key.d))
|
||||||
return self.elgamal.to_bytes(decrypted.x)[16:32]
|
return self.elgamal.to_bytes(decrypted.x)[16:32]
|
||||||
|
|
||||||
def parse_license(self, licence: str) -> None:
|
def parse_license(self, session_id: bytes, licence: str) -> None:
|
||||||
|
session = self.__sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||||
|
|
||||||
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")
|
||||||
|
@ -204,15 +251,19 @@ class Cdm:
|
||||||
parsed_licence = XMRLicense.loads(license_element.text)
|
parsed_licence = XMRLicense.loads(license_element.text)
|
||||||
for key in parsed_licence.get_content_keys():
|
for key in parsed_licence.get_content_keys():
|
||||||
if Key.CipherType(key.cipher_type) == Key.CipherType.ECC256:
|
if Key.CipherType(key.cipher_type) == Key.CipherType.ECC256:
|
||||||
self._keys.append(Key(
|
session.keys.append(Key(
|
||||||
key_id=UUID(bytes_le=key.key_id),
|
key_id=UUID(bytes_le=key.key_id),
|
||||||
key_type=key.key_type,
|
key_type=key.key_type,
|
||||||
cipher_type=key.cipher_type,
|
cipher_type=key.cipher_type,
|
||||||
key_length=key.key_length,
|
key_length=key.key_length,
|
||||||
key=self._decrypt_ecc256_key(key.encrypted_key)
|
key=self._decrypt_ecc256_key(session, key.encrypted_key)
|
||||||
))
|
))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Unable to parse license, {e}")
|
raise Exception(f"Unable to parse license, {e}")
|
||||||
|
|
||||||
def get_keys(self) -> List[Key]:
|
def get_keys(self, session_id: bytes) -> List[Key]:
|
||||||
return self._keys
|
session = self.__sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||||
|
|
||||||
|
return session.keys
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
class PyPlayredyException(Exception):
|
||||||
|
"""Exceptions used by pyplayready."""
|
||||||
|
|
||||||
|
|
||||||
|
class TooManySessions(PyPlayredyException):
|
||||||
|
"""Too many Sessions are open."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidSession(PyPlayredyException):
|
||||||
|
"""No Session is open with the specified identifier."""
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceMismatch(PyPlayredyException):
|
||||||
|
"""The Remote CDMs Device information and the APIs Device information did not match."""
|
|
@ -227,3 +227,26 @@ def export_device(ctx: click.Context, prd_path: Path, out_dir: Optional[Path] =
|
||||||
private_key_path = out_path / "zprivsig.dat"
|
private_key_path = out_path / "zprivsig.dat"
|
||||||
private_key_path.write_bytes(device.signing_key.dumps())
|
private_key_path.write_bytes(device.signing_key.dumps())
|
||||||
log.info("Exported Signing Key as zprivsig.dat")
|
log.info("Exported Signing Key as zprivsig.dat")
|
||||||
|
|
||||||
|
|
||||||
|
@main.command("serve", short_help="Serve your local CDM and Playready Devices Remotely.")
|
||||||
|
@click.argument("config_path", type=Path)
|
||||||
|
@click.option("-h", "--host", type=str, default="127.0.0.1", help="Host to serve from.")
|
||||||
|
@click.option("-p", "--port", type=int, default=8786, help="Port to serve from.")
|
||||||
|
def serve_(config_path: Path, host: str, port: int) -> None:
|
||||||
|
"""
|
||||||
|
Serve your local CDM and Playready Devices Remotely.
|
||||||
|
|
||||||
|
\b
|
||||||
|
[CONFIG] is a path to a serve config file.
|
||||||
|
See `serve.example.yml` for an example config file.
|
||||||
|
|
||||||
|
\b
|
||||||
|
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 # isort:skip
|
||||||
|
import yaml # isort:skip
|
||||||
|
|
||||||
|
config = yaml.safe_load(config_path.read_text(encoding="utf8"))
|
||||||
|
serve.run(config, host, port)
|
||||||
|
|
|
@ -0,0 +1,164 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from pyplayready.cdm import Cdm
|
||||||
|
from pyplayready.bcert import CertificateChain
|
||||||
|
from pyplayready.ecc_key import ECCKey
|
||||||
|
from pyplayready.pssh import PSSH
|
||||||
|
|
||||||
|
from pyplayready.exceptions import (DeviceMismatch, )
|
||||||
|
|
||||||
|
|
||||||
|
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, CertificateChain(), ECCKey(), ECCKey())
|
||||||
|
|
||||||
|
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.0.2":
|
||||||
|
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"]["system_id"]) != self.system_id:
|
||||||
|
raise DeviceMismatch("The System ID specified does not match the one specified in the API response.")
|
||||||
|
|
||||||
|
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,
|
||||||
|
pssh: PSSH,
|
||||||
|
downgrade: str
|
||||||
|
) -> str:
|
||||||
|
if not pssh:
|
||||||
|
raise InvalidInitData("A pssh must be provided.")
|
||||||
|
if not isinstance(pssh, PSSH):
|
||||||
|
raise InvalidInitData(f"Expected pssh to be a {PSSH}, not {pssh!r}")
|
||||||
|
|
||||||
|
r = self.__session.post(
|
||||||
|
url=f"{self.host}/{self.device_name}/get_license_challenge",
|
||||||
|
json={
|
||||||
|
"session_id": session_id.hex(),
|
||||||
|
"init_data": pssh.dumps(),
|
||||||
|
"downgrade": downgrade,
|
||||||
|
}
|
||||||
|
).json()
|
||||||
|
if r["status"] != 200:
|
||||||
|
raise ValueError(f"Cannot get Challenge, {r['message']} [{r['status']}]")
|
||||||
|
r = r["data"]
|
||||||
|
|
||||||
|
return r["challenge_b64"]
|
||||||
|
|
||||||
|
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 {type_} Keys, {r['message']} [{r['status']}]")
|
||||||
|
r = r["data"]
|
||||||
|
|
||||||
|
return [
|
||||||
|
Key(
|
||||||
|
type_=key["type"],
|
||||||
|
kid=Key.kid_to_uuid(bytes.fromhex(key["key_id"])),
|
||||||
|
key=bytes.fromhex(key["key"]),
|
||||||
|
cipher_type=key["cipher_type"]
|
||||||
|
)
|
||||||
|
for key in r["keys"]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ("RemoteCdm",)
|
|
@ -0,0 +1,311 @@
|
||||||
|
import base64
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
|
from aiohttp.typedefs import Handler
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from pyplayready import __version__
|
||||||
|
from pyplayready.cdm import Cdm
|
||||||
|
from pyplayready.device import Device
|
||||||
|
from pyplayready.pssh import PSSH
|
||||||
|
|
||||||
|
from pyplayready.exceptions import (InvalidSession, TooManySessions)
|
||||||
|
|
||||||
|
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", "downgrade"):
|
||||||
|
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 downgrade
|
||||||
|
downgrade = False
|
||||||
|
if body['downgrade'] == 'true':
|
||||||
|
downgrade = True
|
||||||
|
|
||||||
|
# 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 = PSSH(body["init_data"]).get_wrm_headers(downgrade_to_v4=downgrade)
|
||||||
|
|
||||||
|
# get challenge
|
||||||
|
try:
|
||||||
|
license_request = cdm.get_license_challenge(
|
||||||
|
session_id=session_id,
|
||||||
|
content_header=init_data[0],
|
||||||
|
)
|
||||||
|
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": 400,
|
||||||
|
"message": f"Error, {e}"
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"status": 200,
|
||||||
|
"message": "Success",
|
||||||
|
"data": {
|
||||||
|
"challenge_b64": base64.b64encode(license_request.encode()).decode()
|
||||||
|
}
|
||||||
|
}, 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 Exception as e:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"Error, {e}"
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
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": 400,
|
||||||
|
"message": f"Error, {e}"
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# get the keys in json form
|
||||||
|
keys_json = [
|
||||||
|
{
|
||||||
|
"key_id": key.key_id.hex,
|
||||||
|
"key": key.key.hex(),
|
||||||
|
"type": str(key.key_type),
|
||||||
|
"cipher_type": str(key.cipher_type),
|
||||||
|
}
|
||||||
|
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)
|
|
@ -0,0 +1,20 @@
|
||||||
|
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",)
|
|
@ -37,6 +37,8 @@ construct = "^2.10.70"
|
||||||
ECPy = "^1.2.5"
|
ECPy = "^1.2.5"
|
||||||
click = "^8.1.7"
|
click = "^8.1.7"
|
||||||
xmltodict = "^0.14.2"
|
xmltodict = "^0.14.2"
|
||||||
|
PyYAML = "^6.0.1"
|
||||||
|
aiohttp = {version = "^3.9.1", optional = true}
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
pyplayready = "pyplayready.main:main"
|
pyplayready = "pyplayready.main:main"
|
||||||
|
|
|
@ -3,4 +3,6 @@ pycryptodome
|
||||||
ecpy
|
ecpy
|
||||||
construct
|
construct
|
||||||
click
|
click
|
||||||
|
PyYAML
|
||||||
|
aiohttp
|
||||||
xmltodict
|
xmltodict
|
|
@ -0,0 +1,15 @@
|
||||||
|
# This data serves as an example configuration file for the `serve` command.
|
||||||
|
# None of the sensitive data should be re-used.
|
||||||
|
|
||||||
|
# List of Playready Device (.prd) file paths to use with serve.
|
||||||
|
# Note: Each individual user needs explicit permission to use a device listed.
|
||||||
|
devices:
|
||||||
|
- 'C:\Users\ready-dl\Documents\PRDs\test_device_001.prd'
|
||||||
|
|
||||||
|
# List of User's by Secret Key. The Secret Key must be supplied by the User to use the API.
|
||||||
|
users:
|
||||||
|
fx206W0FaB2O34HzGsgb8rcDe9e3ijsf: # secret key, a-zA-Z-09{32} is recommended, case-sensitive
|
||||||
|
username: chloe # only for internal logging, user will not see this name
|
||||||
|
devices: # list of allowed devices by filename
|
||||||
|
- test_key_001
|
||||||
|
# ...
|
Loading…
Reference in New Issue