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