Merge pull request #3 from Erevoc/main

Session ID and RemoteCDM implementation
This commit is contained in:
larley 2024-11-15 18:41:55 +01:00 committed by GitHub
commit 859d78e175
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 656 additions and 28 deletions

View File

@ -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,10 +62,12 @@ 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()}")
cdm.close(session_id)
```
## Disclaimer

View File

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

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import base64
import math
import time
from typing import List
from typing import List, Union
from uuid import UUID
import xml.etree.ElementTree as ET
@ -21,14 +21,20 @@ 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,
certificate_chain: CertificateChain,
encryption_key: ECCKey,
signing_key: ECCKey,
certificate_chain: Union[CertificateChain, None],
encryption_key: Union[ECCKey, None],
signing_key: Union[ECCKey, None],
client_version: str = "10.0.16384.10011",
protocol_version: int = 1
):
@ -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"<Data><CertificateChains><CertificateChain>{b64_chain}</CertificateChain></CertificateChains></Data>"
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:
'</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(
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:
'<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">'
'<KeyValue>'
'<ECCKeyValue>'
f'<PublicKey>{base64.b64encode(self.signing_key.public_bytes()).decode()}</PublicKey>'
f'<PublicKey>{base64.b64encode(session.signing_key.public_bytes()).decode()}</PublicKey>'
'</ECCKeyValue>'
'</KeyValue>'
'</KeyInfo>'
@ -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

18
pyplayready/exceptions.py Normal file
View File

@ -0,0 +1,18 @@
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 InvalidInitData(PyPlayredyException):
"""The Playready Cenc Header Data is invalid or empty."""
class DeviceMismatch(PyPlayredyException):
"""The Remote CDMs Device information and the APIs Device information did not match."""

View File

@ -1,5 +1,7 @@
import base64
from enum import Enum
from uuid import UUID
from typing import Optional, Union
class Key:
@ -40,3 +42,23 @@ class Key:
self.cipher_type = self.CipherType(cipher_type)
self.key_length = key_length
self.key = key
@staticmethod
def kid_to_uuid(kid: Union[str, bytes]) -> UUID:
"""
Convert a Key ID from a string or bytes to a UUID object.
At first this may seem very simple but some types of Key IDs
may not be 16 bytes and some may be decimal vs. hex.
"""
if isinstance(kid, str):
kid = base64.b64decode(kid)
if not kid:
kid = b"\x00" * 16
if kid.decode(errors="replace").isdigit():
return UUID(int=int(kid.decode()))
if len(kid) < 16:
kid += b"\x00" * (16 - len(kid))
return UUID(bytes=kid)

View File

@ -52,6 +52,9 @@ def license_(device_path: Path, pssh: PSSH, server: str) -> None:
cdm = Cdm.from_device(device)
log.info("Loaded CDM")
session_id = cdm.open()
log.info("Opened Session")
challenge = cdm.get_license_challenge(pssh.get_wrm_headers(downgrade_to_v4=True)[0])
log.info("Created License Request (Challenge)")
log.debug(challenge)
@ -78,6 +81,9 @@ def license_(device_path: Path, pssh: PSSH, server: str) -> None:
for key in cdm.get_keys():
log.info(f"{key.key_id.hex}:{key.key.hex()}")
cdm.close(session_id)
log.info("Clossed Session")
@main.command()
@click.argument("device", type=Path)
@ -227,3 +233,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=7723, 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)

158
pyplayready/remotecdm.py Normal file
View File

@ -0,0 +1,158 @@
from __future__ import annotations
import re
import requests
from pyplayready.cdm import Cdm
from pyplayready.device import Device
from pyplayready.key import Key
from pyplayready.exceptions import (DeviceMismatch, InvalidInitData)
class RemoteCdm(Cdm):
"""Remote Accessible CDM using pyplayready's serve schema."""
def __init__(
self,
security_level: int,
host: str,
secret: str,
device_name: str
):
"""Initialize a Playready Content Decryption Module (CDM)."""
if not security_level:
raise ValueError("Security Level must be provided")
if not isinstance(security_level, int):
raise TypeError(f"Expected security_level to be a {int} not {security_level!r}")
if not host:
raise ValueError("API Host must be provided")
if not isinstance(host, str):
raise TypeError(f"Expected host to be a {str} not {host!r}")
if not secret:
raise ValueError("API Secret must be provided")
if not isinstance(secret, str):
raise TypeError(f"Expected secret to be a {str} not {secret!r}")
if not device_name:
raise ValueError("API Device name must be provided")
if not isinstance(device_name, str):
raise TypeError(f"Expected device_name to be a {str} not {device_name!r}")
self.security_level = security_level
self.host = host
self.device_name = device_name
# spoof certificate_chain and ecc_key just so we can construct via super call
super().__init__(security_level, None, None, None)
self.__session = requests.Session()
self.__session.headers.update({
"X-Secret-Key": secret
})
r = requests.head(self.host)
if r.status_code != 200:
raise ValueError(f"Could not test Remote API version [{r.status_code}]")
server = r.headers.get("Server")
if not server or "pyplayready serve" not in server.lower():
raise ValueError(f"This Remote CDM API does not seem to be a pyplayready serve API ({server}).")
server_version_re = re.search(r"pyplayready serve v([\d.]+)", server, re.IGNORECASE)
if not server_version_re:
raise ValueError("The pyplayready server API is not stating the version correctly, cannot continue.")
server_version = server_version_re.group(1)
if server_version < "0.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"]["security_level"]) != self.security_level:
raise DeviceMismatch("The Security Level specified does not match the one specified in the API response.")
return bytes.fromhex(r["session_id"])
def close(self, session_id: bytes) -> None:
r = self.__session.get(
url=f"{self.host}/{self.device_name}/close/{session_id.hex()}"
).json()
if r["status"] != 200:
raise ValueError(f"Cannot Close CDM Session, {r['message']} [{r['status']}]")
def get_license_challenge(
self,
session_id: bytes,
wrm_header: str,
) -> str:
if not wrm_header:
raise InvalidInitData("A wrm_header must be provided.")
if not isinstance(wrm_header, str):
raise InvalidInitData(f"Expected wrm_header to be a {str}, not {wrm_header!r}")
r = self.__session.post(
url=f"{self.host}/{self.device_name}/get_license_challenge",
json={
"session_id": session_id.hex(),
"init_data": wrm_header,
}
).json()
if r["status"] != 200:
raise ValueError(f"Cannot get Challenge, {r['message']} [{r['status']}]")
r = r["data"]
return r["challenge_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 Keys, {r['message']} [{r['status']}]")
r = r["data"]
return [
Key(
key_type=key["type"],
key_id=Key.kid_to_uuid(bytes.fromhex(key["key_id"])),
key=bytes.fromhex(key["key"]),
cipher_type=key["cipher_type"],
key_length=key["key_length"]
)
for key in r["keys"]
]
__all__ = ("RemoteCdm",)

306
pyplayready/serve.py Normal file
View File

@ -0,0 +1,306 @@
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.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"):
if not body.get(required_field):
return web.json_response({
"status": 400,
"message": f"Missing required field '{required_field}' in JSON body."
}, status=400)
# get session id
session_id = bytes.fromhex(body["session_id"])
# get cdm
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
if not cdm:
return web.json_response({
"status": 400,
"message": f"No Cdm session for {device_name} has been opened yet. No session to use."
}, status=400)
# get init data
init_data = body["init_data"]
# get challenge
try:
license_request = cdm.get_license_challenge(
session_id=session_id,
content_header=init_data,
)
except InvalidSession:
return web.json_response({
"status": 400,
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
}, status=400)
except Exception as e:
return web.json_response({
"status": 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),
"key_length": str(key.key_length),
}
for key in keys
]
return web.json_response({
"status": 200,
"message": "Success",
"data": {
"keys": keys_json
}
})
@web.middleware
async def authentication(request: web.Request, handler: Handler) -> web.Response:
secret_key = request.headers.get("X-Secret-Key")
if request.path != "/" and not secret_key:
request.app.logger.debug(f"{request.remote} did not provide authorization.")
response = web.json_response({
"status": "401",
"message": "Secret Key is Empty."
}, status=401)
elif request.path != "/" and secret_key not in request.app["config"]["users"]:
request.app.logger.debug(f"{request.remote} failed authentication with '{secret_key}'.")
response = web.json_response({
"status": "401",
"message": "Secret Key is Invalid, the Key is case-sensitive."
}, status=401)
else:
try:
response = await handler(request) # type: ignore[assignment]
except web.HTTPException as e:
request.app.logger.error(f"An unexpected error has occurred, {e}")
response = web.json_response({
"status": 500,
"message": e.reason
}, status=500)
response.headers.update({
"Server": f"https://github.com/ready-dl/pyplayready serve v{__version__}"
})
return response
def run(config: dict, host: Optional[Union[str, web.HostSequence]] = None, port: Optional[int] = None) -> None:
app = web.Application(middlewares=[authentication])
app.on_startup.append(_startup)
app.on_cleanup.append(_cleanup)
app.add_routes(routes)
app["config"] = config
web.run_app(app, host=host, port=port)

20
pyplayready/session.py Normal file
View File

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

View File

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

View File

@ -3,4 +3,6 @@ pycryptodome
ecpy
construct
click
PyYAML
aiohttp
xmltodict

15
serve.example.yml Normal file
View File

@ -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
# ...