mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 12:05:07 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user