fix(cdm): send a PlayReady licence without an XML declaration as XML

A remote CDM tried base64 first on any licence not starting with "<?xml", which can decode XML text into garbage. Also lists L3 as a device_name.
This commit is contained in:
imSp4rky
2026-10-06 13:59:40 -06:00
parent 3477d09cc2
commit 94e5264396
5 changed files with 82 additions and 5 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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` | |
@@ -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 = (
'<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body>'
"<AcquireLicenseResponse><License>AAAA</License></AcquireLicenseResponse>"
"</soap:Body></soap:Envelope>"
)
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,
'<?xml version="1.0" encoding="utf-8"?>' + 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"
+1 -1
View File
@@ -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("<?xml"):
if self.is_playready and license_message.lstrip().startswith("<"):
license_message = license_message.encode("utf-8")
else:
try:
@@ -114,7 +114,7 @@ class DecryptLabsRemoteCDM:
Args:
secret: Decrypt Labs API key (matches config format)
host: Decrypt Labs API host URL (matches config format)
device_name: DRM system device name. Widevine takes ChromeCDM, L1 or L2, and PlayReady takes SL2 or SL3
device_name: DRM system device name. Widevine takes ChromeCDM, L1, L2 or L3, and PlayReady takes SL2 or SL3
service_name: Service tag for content key caching and vault operations
vaults: Vaults instance for local content key caching
device_type: Device type (CHROME, ANDROID, PLAYREADY) - for compatibility
@@ -585,7 +585,7 @@ class DecryptLabsRemoteCDM:
raise ValueError("No challenge available - call get_license_challenge first")
if isinstance(license_message, str):
if self.is_playready and license_message.strip().startswith("<?xml"):
if self.is_playready and license_message.lstrip().startswith("<"):
license_message = license_message.encode("utf-8")
else:
try: