diff --git a/README.md b/README.md index 5b8cf6d..d5e0c97 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ import requests device = Device.load("C:/Path/To/A/Device.prd") cdm = Cdm.from_device(device) +session_id = cdm.open() pssh = PSSH( "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 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( url="https://test.playready.microsoft.com/service/rightsmanager.asmx?cfg=(persist:false,sl:2000)", @@ -61,9 +62,9 @@ response = requests.post( 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()}") ``` diff --git a/pyplayready/__init__.py b/pyplayready/__init__.py index ea3b145..823c68f 100644 --- a/pyplayready/__init__.py +++ b/pyplayready/__init__.py @@ -5,7 +5,9 @@ from .ecc_key import * from .elgamal import * from .key import * from .pssh import * +from .remotecdm import * +from .session import * from .xml_key import * from .xmrlicense import * -__version__ = "0.1.0" +__version__ = "0.1.0" \ No newline at end of file diff --git a/pyplayready/cdm.py b/pyplayready/cdm.py index ff6cbb9..2d385ba 100644 --- a/pyplayready/cdm.py +++ b/pyplayready/cdm.py @@ -21,8 +21,14 @@ from pyplayready.xml_key import XmlKey from pyplayready.elgamal import ElGamal from pyplayready.xmrlicense import XMRLicense +from pyplayready.exceptions import (InvalidSession, TooManySessions) +from pyplayready.session import Session + class Cdm: + + MAX_NUM_OF_SESSIONS = 16 + def __init__( self, security_level: int, @@ -47,9 +53,8 @@ class Cdm: y=0x982b27b5cb2341326e56aa857dbfd5c634ce2cf9ea74fca8f2af5957efeea562, curve=self.curve ) - self._xml_key = XmlKey() - self._keys: List[Key] = [] + self.__sessions: dict[bytes, Session] = {} @classmethod def from_device(cls, device) -> Cdm: @@ -61,21 +66,52 @@ class Cdm: 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( - 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 ) 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() body = f"{b64_chain}" cipher = AES.new( - key=self._xml_key.aes_key, + key=session._xml_key.aes_key, mode=AES.MODE_CBC, - iv=self._xml_key.aes_iv + iv=session._xml_key.aes_iv ) ciphertext = cipher.encrypt(pad( @@ -83,7 +119,7 @@ class Cdm: AES.block_size )) - return self._xml_key.aes_iv + ciphertext + return session._xml_key.aes_iv + ciphertext def _build_digest_content( self, @@ -134,12 +170,19 @@ class Cdm: '' ) - 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( content_header=content_header, nonce=base64.b64encode(get_random_bytes(16)).decode(), - wmrm_cipher=base64.b64encode(self.get_key_data()).decode(), - cert_cipher=base64.b64encode(self.get_cipher_data()).decode() + wmrm_cipher=base64.b64encode(self.get_key_data(session)).decode(), + cert_cipher=base64.b64encode(self.get_cipher_data(session)).decode() ) la_hash_obj = SHA256.new() @@ -149,7 +192,7 @@ class Cdm: signed_info = self._build_signed_info(base64.b64encode(la_hash).decode()) 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) # haven't found a better way to do this. xmltodict.unparse doesn't work @@ -167,7 +210,7 @@ class Cdm: '' '' '' - f'{base64.b64encode(self.signing_key.public_bytes()).decode()}' + f'{base64.b64encode(session.signing_key.public_bytes()).decode()}' '' '' '' @@ -181,7 +224,7 @@ class Cdm: 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( x=int.from_bytes(encrypted_key[:32], 'big'), y=int.from_bytes(encrypted_key[32:64], 'big'), @@ -193,10 +236,14 @@ class Cdm: 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] - 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: root = ET.fromstring(licence) 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) for key in parsed_licence.get_content_keys(): 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_type=key.key_type, cipher_type=key.cipher_type, 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: raise Exception(f"Unable to parse license, {e}") - def get_keys(self) -> List[Key]: - return self._keys + def get_keys(self, session_id: bytes) -> List[Key]: + session = self.__sessions.get(session_id) + if not session: + raise InvalidSession(f"Session identifier {session_id!r} is invalid.") + + return session.keys diff --git a/pyplayready/exceptions.py b/pyplayready/exceptions.py new file mode 100644 index 0000000..244aedf --- /dev/null +++ b/pyplayready/exceptions.py @@ -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.""" \ No newline at end of file diff --git a/pyplayready/main.py b/pyplayready/main.py index 4af1f6a..83eca9c 100644 --- a/pyplayready/main.py +++ b/pyplayready/main.py @@ -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.write_bytes(device.signing_key.dumps()) 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) diff --git a/pyplayready/remotecdm.py b/pyplayready/remotecdm.py new file mode 100644 index 0000000..2433590 --- /dev/null +++ b/pyplayready/remotecdm.py @@ -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",) diff --git a/pyplayready/serve.py b/pyplayready/serve.py new file mode 100644 index 0000000..14e9fda --- /dev/null +++ b/pyplayready/serve.py @@ -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) diff --git a/pyplayready/session.py b/pyplayready/session.py new file mode 100644 index 0000000..8bd92fe --- /dev/null +++ b/pyplayready/session.py @@ -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",) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 43f17a5..60a7987 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,8 @@ construct = "^2.10.70" ECPy = "^1.2.5" click = "^8.1.7" xmltodict = "^0.14.2" +PyYAML = "^6.0.1" +aiohttp = {version = "^3.9.1", optional = true} [tool.poetry.scripts] pyplayready = "pyplayready.main:main" diff --git a/requirements.txt b/requirements.txt index 308c138..57d9df2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,6 @@ pycryptodome ecpy construct click +PyYAML +aiohttp xmltodict \ No newline at end of file diff --git a/serve.example.yml b/serve.example.yml new file mode 100644 index 0000000..dfd8694 --- /dev/null +++ b/serve.example.yml @@ -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 + # ... \ No newline at end of file