mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 13:05:09 +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:
|
remote_cdm:
|
||||||
- name: decryptlabs
|
- name: decryptlabs
|
||||||
type: decrypt_labs
|
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
|
# host: https://keyxtractor.decryptlabs.com # optional, this is the default
|
||||||
# secret: ... # optional if decrypt_labs_api_key is set globally
|
# 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 |
|
| Field | Default | Notes |
|
||||||
|-------|---------|-------|
|
|-------|---------|-------|
|
||||||
| `host` | `https://keyxtractor.decryptlabs.com` | |
|
| `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. |
|
| `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) | |
|
| `system_id` | `26830` (Widevine), `0` (PlayReady) | |
|
||||||
| `security_level` | Widevine `3`; PlayReady `2000` for `SL2`, else `3000` | |
|
| `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")
|
raise ValueError("No challenge available - call get_license_challenge first")
|
||||||
|
|
||||||
if isinstance(license_message, str):
|
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")
|
license_message = license_message.encode("utf-8")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ class DecryptLabsRemoteCDM:
|
|||||||
Args:
|
Args:
|
||||||
secret: Decrypt Labs API key (matches config format)
|
secret: Decrypt Labs API key (matches config format)
|
||||||
host: Decrypt Labs API host URL (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
|
service_name: Service tag for content key caching and vault operations
|
||||||
vaults: Vaults instance for local content key caching
|
vaults: Vaults instance for local content key caching
|
||||||
device_type: Device type (CHROME, ANDROID, PLAYREADY) - for compatibility
|
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")
|
raise ValueError("No challenge available - call get_license_challenge first")
|
||||||
|
|
||||||
if isinstance(license_message, str):
|
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")
|
license_message = license_message.encode("utf-8")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user