diff --git a/docs/guide/drm-and-cdm.md b/docs/guide/drm-and-cdm.md index 8805521f..852e161e 100644 --- a/docs/guide/drm-and-cdm.md +++ b/docs/guide/drm-and-cdm.md @@ -336,7 +336,7 @@ includes an intelligent key-caching layer: remote_cdm: - name: decryptlabs type: decrypt_labs - device_name: L1 # ChromeCDM, L1, L2 (Widevine) or SL2, SL3 (PlayReady) + device_name: L1 # ChromeCDM, L1, L2, L3 (Widevine) or SL2, SL3 (PlayReady) # host: https://keyxtractor.decryptlabs.com # optional, this is the default # secret: ... # optional if decrypt_labs_api_key is set globally diff --git a/docs/reference/configuration/drm.md b/docs/reference/configuration/drm.md index deef6625..f177f92e 100644 --- a/docs/reference/configuration/drm.md +++ b/docs/reference/configuration/drm.md @@ -117,7 +117,7 @@ styles: `Device Type`/`device_type`, `System ID`/`system_id`, `Security Level`/` | Field | Default | Notes | |-------|---------|-------| | `host` | `https://keyxtractor.decryptlabs.com` | | -| `device_name` | `ChromeCDM` | `ChromeCDM`, `L1`, `L2` (Widevine) or `SL2`, `SL3` (PlayReady) | +| `device_name` | `ChromeCDM` | `ChromeCDM`, `L1`, `L2`, `L3` (Widevine) or `SL2`, `SL3` (PlayReady). The host decides which of these names it serves. | | `secret` | from [`decrypt_labs_api_key`](misc.md#external-api-keys) | Sent as the `decrypt-labs-api-key` header. An error is raised if neither is set. | | `system_id` | `26830` (Widevine), `0` (PlayReady) | | | `security_level` | Widevine `3`; PlayReady `2000` for `SL2`, else `3000` | | diff --git a/tests/core/test_remote_cdm_playready_licence.py b/tests/core/test_remote_cdm_playready_licence.py new file mode 100644 index 00000000..b10e0765 --- /dev/null +++ b/tests/core/test_remote_cdm_playready_licence.py @@ -0,0 +1,77 @@ +"""A remote CDM must send a PlayReady licence to the host as the XML it received. + +A licence server may return the SOAP envelope with or without an XML declaration. +``base64.b64decode`` drops every character it cannot read, so XML text can decode +without an error and the host then receives bytes that are not a licence. +""" + +from __future__ import annotations + +import base64 +from typing import Any + +import pytest + +from unshackle.core.cdm.custom_remote_cdm import CustomRemoteCDM +from unshackle.core.cdm.decrypt_labs_remote_cdm import DecryptLabsRemoteCDM + +pytestmark = pytest.mark.unit + +ENVELOPE = ( + '' + "AAAA" + "" +) + + +class Sent(Exception): + """Stops ``parse_license`` after the request body has been captured.""" + + +def decrypt_labs_cdm() -> tuple[Any, str]: + return DecryptLabsRemoteCDM(secret="x", host="https://cdm.test", device_name="SL3"), "decrypt_labs_session_id" + + +def custom_cdm() -> tuple[Any, str]: + cdm = CustomRemoteCDM(host="https://cdm.test", device={"name": "SL3000", "type": "PLAYREADY"}) + return cdm, "remote_session_id" + + +def licence_sent(cdm: Any, remote_id_key: str, licence: str) -> bytes: + """Run ``parse_license`` on a session that has a challenge, and return the licence bytes it posts.""" + session_id = cdm.open() + cdm.set_pssh_b64("UFNTSA==", session_id=session_id) + cdm._sessions[session_id].update({"challenge": b"challenge", remote_id_key: "remote", "pssh": object()}) + + body: dict[str, Any] = {} + + def capture(_url: str, json: dict[str, Any], **_: Any) -> None: + body.update(json) + raise Sent + + cdm._http_session.post = capture + with pytest.raises(Sent): + cdm.parse_license(session_id, licence) + return base64.b64decode(body["license_response"]) + + +@pytest.mark.parametrize("make_cdm", [decrypt_labs_cdm, custom_cdm]) +@pytest.mark.parametrize( + "licence", + [ + ENVELOPE, + '' + ENVELOPE, + "\n " + ENVELOPE, + # Each length makes a different count of base64 characters, so one of them decodes without an error. + *(ENVELOPE.replace("AAAA", "A" * n) for n in range(5, 8)), + ], +) +def test_a_playready_xml_licence_is_sent_as_it_is(make_cdm: Any, licence: str) -> None: + cdm, remote_id_key = make_cdm() + assert licence_sent(cdm, remote_id_key, licence) == licence.encode("utf-8") + + +@pytest.mark.parametrize("make_cdm", [decrypt_labs_cdm, custom_cdm]) +def test_a_base64_licence_is_still_decoded(make_cdm: Any) -> None: + cdm, remote_id_key = make_cdm() + assert licence_sent(cdm, remote_id_key, base64.b64encode(b"binary licence").decode()) == b"binary licence" diff --git a/unshackle/core/cdm/custom_remote_cdm.py b/unshackle/core/cdm/custom_remote_cdm.py index 36431425..d4891a27 100644 --- a/unshackle/core/cdm/custom_remote_cdm.py +++ b/unshackle/core/cdm/custom_remote_cdm.py @@ -940,7 +940,7 @@ class CustomRemoteCDM: raise ValueError("No challenge available - call get_license_challenge first") if isinstance(license_message, str): - if self.is_playready and license_message.strip().startswith("