From b91d7bfa5bfe05dd5981d035633f3e665bf549b8 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Mon, 7 Sep 2026 11:54:35 -0600 Subject: [PATCH] fix(api): reach every PlayReady header on all server CDM routes The rebuild raised KIDNotFound for a header whose KID lives only in the manifest, the single-track route stripped the track's other headers, and the batch cache keyed on the first PSSH alone. Licensing failures now reach the client instead of only the server log. --- .../unit/test_server_cdm_absorb_routes.py | 161 ++++++++++++++++++ .../unit/test_server_cdm_manifest_kid.py | 160 +++++++++++++++++ unshackle/core/api/handlers.py | 58 +++---- 3 files changed, 349 insertions(+), 30 deletions(-) create mode 100644 tests/remote/unit/test_server_cdm_absorb_routes.py create mode 100644 tests/remote/unit/test_server_cdm_manifest_kid.py diff --git a/tests/remote/unit/test_server_cdm_absorb_routes.py b/tests/remote/unit/test_server_cdm_absorb_routes.py new file mode 100644 index 00000000..c55c3727 --- /dev/null +++ b/tests/remote/unit/test_server_cdm_absorb_routes.py @@ -0,0 +1,161 @@ +"""The server CDM must reach every WRM header on the routes that bypass the batch loop. + +Three regressions found by review of the absorb fix: the single-track route stripped the +track's other PlayReady objects before the absorb could see them, the batch cache keyed on +the first PSSH alone and so handed one track another track's narrower key set, and a track +with no readable PSSH told the client nothing. +""" + +import base64 +import json +from types import SimpleNamespace +from uuid import UUID + +import pyplayready.system.pssh as pr_pssh_mod +import pytest + +import unshackle.core.cdm as cdm_mod +import unshackle.core.cdm.detect as detect_mod +import unshackle.core.drm as drm_mod +from unshackle.core.api import handlers + +pytestmark = pytest.mark.unit + +KID_A = UUID("161219ec3df64e0eadbb3827c0ccc46d") +KID_B = UUID("43de497c6d1945f49acc2951696ec628") +KID_C = UUID("75982fac2cd8412a990a59c0ef2d4bb1") + + +class _FakePRPSSH: + def __init__(self, data): + self.data = data + + +class _FakePlayReady: + def __init__(self, pssh=None, pssh_b64=None, kid=None, **kwargs): + self.kids = [kid] if kid else [KID_A] + self.content_keys: dict = {} + self.pssh_b64 = pssh_b64 + self.data = {"pssh_b64": pssh_b64} if pssh_b64 else {} + + def absorb(self, *others): + for other in others: + if other is self: + continue + for kid in other.kids: + if kid not in self.kids: + self.kids.append(kid) + + def get_content_keys(self, cdm, certificate, licence): + self.content_keys = {kid: f"key_{kid.hex[:4]}" for kid in self.kids} + + +@pytest.fixture +def env(monkeypatch): + monkeypatch.setattr(pr_pssh_mod, "PSSH", _FakePRPSSH) + monkeypatch.setattr(drm_mod, "PlayReady", _FakePlayReady) + monkeypatch.setitem(cdm_mod.__dict__, "load_cdm", lambda *a, **k: object()) + monkeypatch.setattr(detect_mod, "is_playready_cdm", lambda cdm: True) + monkeypatch.setattr(handlers, "ensure_track_drm", lambda track, session=None, init_data=None: None) + monkeypatch.setattr(handlers, "resolve_device_name", lambda *a, **k: "dev") + monkeypatch.setattr(handlers, "check_vaults", lambda kids, name: None) + monkeypatch.setattr(handlers, "cache_to_vaults", lambda keys, name: None) + monkeypatch.setattr(handlers, "server_cdm_allowed", lambda request, tag: True) + monkeypatch.setattr(handlers, "find_title_for_track", lambda tid, session: SimpleNamespace()) + monkeypatch.setattr(handlers.config, "serve", {"users": {}}, raising=False) + + +_FakePlayReady.__name__ = "PlayReady" # the single-track route matches on class name, not type + +PSSH = base64.b64encode(b"A").decode() + + +async def test_single_track_path_keeps_siblings(env, monkeypatch): + """`proxy_license` always sends `pssh`, so this route must not discard the other headers.""" + track = SimpleNamespace( + id="vid", + drm=[_FakePlayReady(pssh_b64=PSSH, kid=KID_A), _FakePlayReady(pssh_b64=PSSH, kid=KID_B)], + ) + session = SimpleNamespace( + service_tag="EX", + service_instance=SimpleNamespace(), + tracks=SimpleNamespace(get={"vid": track}.get), + log_buffer=None, + ) + + async def fake_get_session(sid, req): + return session + + monkeypatch.setattr(handlers, "get_validated_session", fake_get_session) + + resp = await handlers.session_license_handler( + {"track_id": "vid", "mode": "server_cdm", "drm_type": "playready", "pssh": PSSH}, + "sess", + None, + ) + keys = json.loads(resp.body)["keys"] + assert set(keys) == {KID_A.hex, KID_B.hex}, f"lost sibling headers: {sorted(keys)}" + + +async def test_batch_cache_does_not_starve_second_track(env, monkeypatch): + """Two tracks sharing a first PSSH but holding different headers need separate licences.""" + vid = SimpleNamespace(id="vid", drm=[_FakePlayReady(pssh_b64=PSSH, kid=KID_A)]) + aud = SimpleNamespace( + id="aud", + drm=[ + _FakePlayReady(pssh_b64=PSSH, kid=KID_A), + _FakePlayReady(pssh_b64=PSSH, kid=KID_B), + _FakePlayReady(pssh_b64=PSSH, kid=KID_C), + ], + ) + tracks = {"vid": vid, "aud": aud} + session = SimpleNamespace( + service_tag="EX", + service_instance=SimpleNamespace(), + tracks=SimpleNamespace(get=tracks.get), + log_buffer=None, + ) + + async def fake_get_session(sid, req): + return session + + monkeypatch.setattr(handlers, "get_validated_session", fake_get_session) + monkeypatch.setattr(handlers, "detect_cdm_type_for_service", lambda tag, cfg: "playready") + monkeypatch.setattr(handlers, "drm_preference_name", lambda track: None) + monkeypatch.setattr(handlers, "fetch_init_segment", lambda track, sess: None) + + resp = await handlers.session_license_handler( + {"mode": "server_cdm", "track_ids": ["vid", "aud"], "drm_type": "playready"}, + "sess", + None, + ) + keys = json.loads(resp.body)["keys"] + assert set(keys["aud"]) == {KID_A.hex, KID_B.hex, KID_C.hex}, f"audio starved: {sorted(keys['aud'])}" + + +async def test_no_pssh_tells_the_client_why(env, monkeypatch): + """A track with DRM but no readable PSSH must give the client a reason, not silence.""" + from unshackle.core.api.session_log import SessionLogBuffer + + buf = SessionLogBuffer() + track = SimpleNamespace(id="vid", drm=[object()]) + session = SimpleNamespace( + service_tag="EX", + service_instance=SimpleNamespace(), + tracks=SimpleNamespace(get={"vid": track}.get), + log_buffer=buf, + ) + + async def fake_get_session(sid, req): + return session + + monkeypatch.setattr(handlers, "get_validated_session", fake_get_session) + monkeypatch.setattr(handlers, "detect_cdm_type_for_service", lambda tag, cfg: "playready") + monkeypatch.setattr(handlers, "drm_preference_name", lambda track: None) + monkeypatch.setattr(handlers, "fetch_init_segment", lambda track, sess: None) + monkeypatch.setattr(handlers, "extract_pssh_from_track", lambda track, drm: None) + + await handlers.session_license_handler( + {"mode": "server_cdm", "track_ids": ["vid"], "drm_type": "playready"}, "sess", None + ) + assert buf.since(0), "client gets no reason why the track produced no keys" diff --git a/tests/remote/unit/test_server_cdm_manifest_kid.py b/tests/remote/unit/test_server_cdm_manifest_kid.py new file mode 100644 index 00000000..5a153f4c --- /dev/null +++ b/tests/remote/unit/test_server_cdm_manifest_kid.py @@ -0,0 +1,160 @@ +"""The server CDM must keep a KID that only the manifest names, and tell the client when a key is missing. + +Three regressions found by a second review of the absorb fix: the server rebuilt a +`PlayReady` from the PSSH alone, so a header whose KID lives only in the manifest (a DASH +`` or `default_KID`, an init-segment `tenc` box) raised `KIDNotFound` before the absorb +could add it; the batch cache keyed on headers only, so two tracks that shared a header but +named different KIDs got one licence; and a KID the licence did not cover was logged on the +server alone. +""" + +import base64 +import json +from types import SimpleNamespace +from uuid import UUID + +import pyplayready.system.pssh as pr_pssh_mod +import pytest +from pyplayready.system.pssh import PSSH as RealPSSH + +import unshackle.core.cdm as cdm_mod +import unshackle.core.cdm.detect as detect_mod +import unshackle.core.drm as drm_mod +from unshackle.core.api import handlers +from unshackle.core.api.session_log import SessionLogBuffer +from unshackle.core.drm import PlayReady + +pytestmark = pytest.mark.unit + +KID_A = UUID("161219ec3df64e0eadbb3827c0ccc46d") +KID_B = UUID("43de497c6d1945f49acc2951696ec628") + + +def _kidless_pro_b64() -> str: + """A one-record PlayReady Object whose v4.2 header names no KID.""" + xml = ( + '' + "https://example.invalid/" + ) + record = xml.encode("utf-16-le") + pro = RealPSSH.PlayreadyHeader.build( + {"length": 10 + len(record), "records": [{"type": 1, "length": len(record), "data": record}]} + ) + return base64.b64encode(pro).decode() + + +class _FakePRPSSH: + def __init__(self, data): + self.data = data + + +class _FakePlayReady: + def __init__(self, pssh=None, pssh_b64=None, kid=None, **kwargs): + self.kids = [kid] if kid else [KID_A] + self.content_keys: dict = {} + self.pssh_b64 = pssh_b64 + self.data = {"pssh_b64": pssh_b64} if pssh_b64 else {} + + @property + def kid(self): + return self.kids[0] + + def absorb(self, *others): + for other in others: + if other is self: + continue + for kid in other.kids: + if kid not in self.kids: + self.kids.append(kid) + + def get_content_keys(self, cdm, certificate, licence): + self.content_keys = {kid: f"key_{kid.hex[:4]}" for kid in self.kids} + + +_FakePlayReady.__name__ = "PlayReady" + +PSSH = base64.b64encode(b"A").decode() + + +@pytest.fixture +def server_env(monkeypatch): + monkeypatch.setitem(cdm_mod.__dict__, "load_cdm", lambda *a, **k: object()) + monkeypatch.setattr(detect_mod, "is_playready_cdm", lambda cdm: True) + monkeypatch.setattr(handlers, "ensure_track_drm", lambda track, session=None, init_data=None: None) + monkeypatch.setattr(handlers, "resolve_device_name", lambda *a, **k: "dev") + monkeypatch.setattr(handlers, "check_vaults", lambda kids, name: None) + monkeypatch.setattr(handlers, "cache_to_vaults", lambda keys, name: None) + monkeypatch.setattr(handlers, "server_cdm_allowed", lambda request, tag: True) + monkeypatch.setattr(handlers, "find_title_for_track", lambda tid, session: SimpleNamespace()) + monkeypatch.setattr(handlers, "detect_cdm_type_for_service", lambda tag, cfg: "playready") + monkeypatch.setattr(handlers, "drm_preference_name", lambda track: None) + monkeypatch.setattr(handlers, "fetch_init_segment", lambda track, sess: None) + monkeypatch.setattr(handlers.config, "serve", {"users": {}}, raising=False) + + +@pytest.fixture +def fake_playready(monkeypatch): + monkeypatch.setattr(pr_pssh_mod, "PSSH", _FakePRPSSH) + monkeypatch.setattr(drm_mod, "PlayReady", _FakePlayReady) + + +def _session(tracks: dict, buf=None): + session = SimpleNamespace( + service_tag="EX", + service_instance=SimpleNamespace(), + tracks=SimpleNamespace(get=tracks.get), + log_buffer=buf, + ) + + async def fake_get_session(sid, req): + return session + + return fake_get_session + + +def test_manifest_kid_survives_the_rebuild(server_env, monkeypatch): + """A DASH `` reaches the local object through `kid=`; the server rebuild must not lose it.""" + monkeypatch.setattr( + PlayReady, + "get_content_keys", + lambda self, cdm, certificate, licence: self.content_keys.update({k: "k" for k in self.kids}), + ) + pro_b64 = _kidless_pro_b64() + track = SimpleNamespace( + id="vid", drm=[PlayReady(pssh=RealPSSH(base64.b64decode(pro_b64)), kid=KID_A, pssh_b64=pro_b64)] + ) + + keys = handlers.handle_single_server_cdm(SimpleNamespace(), SimpleNamespace(), track, pro_b64, "playready", None) + + assert set(keys) == {KID_A.hex} + + +async def test_batch_cache_splits_on_manifest_kid(server_env, fake_playready, monkeypatch): + """Two tracks with one shared header but their own manifest KIDs need their own licences.""" + tracks = { + "vid": SimpleNamespace(id="vid", drm=[_FakePlayReady(pssh_b64=PSSH, kid=KID_A)]), + "aud": SimpleNamespace(id="aud", drm=[_FakePlayReady(pssh_b64=PSSH, kid=KID_B)]), + } + monkeypatch.setattr(handlers, "get_validated_session", _session(tracks)) + + resp = await handlers.session_license_handler( + {"mode": "server_cdm", "track_ids": ["vid", "aud"], "drm_type": "playready"}, "sess", None + ) + + keys = json.loads(resp.body)["keys"] + assert KID_B.hex in keys["aud"], f"audio got the video licence: {sorted(keys['aud'])}" + + +async def test_uncovered_kid_reaches_the_client(server_env, fake_playready, monkeypatch): + """The client only sees `session.log_buffer`, so an uncovered track KID must land there.""" + buf = SessionLogBuffer() + track = SimpleNamespace(id="vid", drm=[_FakePlayReady(pssh_b64=PSSH, kid=KID_A)], get_key_id=lambda init: KID_B) + monkeypatch.setattr(handlers, "get_validated_session", _session({"vid": track}, buf)) + monkeypatch.setattr(handlers, "fetch_init_segment", lambda track, sess: b"init") + monkeypatch.setattr(handlers, "drm_from_init_segment", lambda track, session=None, init_data=None: []) + + await handlers.session_license_handler( + {"mode": "server_cdm", "track_ids": ["vid"], "drm_type": "playready"}, "sess", None + ) + + assert any(KID_B.hex in r["message"] for r in buf.since(0)), "client gets no reason for the missing key" diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index d1161796..746d4c06 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -3685,10 +3685,10 @@ def handle_single_server_cdm( from unshackle.core.drm import PlayReady pr_pssh = PlayReadyPSSH(base64.b64decode(pssh_b64)) - pr_drm = PlayReady(pssh=pr_pssh, pssh_b64=pssh_b64) - siblings = [d for d in (getattr(track, "drm", None) or []) if isinstance(d, PlayReady)] - if len(siblings) > 1: + manifest_kid = next((getattr(d, "kid", None) for d in siblings if getattr(d, "kid", None)), None) + pr_drm = PlayReady(pssh=pr_pssh, pssh_b64=pssh_b64, kid=manifest_kid) + if siblings: pr_drm.absorb(*siblings) # Gate on the caller's CDM device first: no device, no keys from the vault or CDM. @@ -3843,7 +3843,6 @@ async def session_license_handler( the challenge, get the license, and extract `KID:KEY` pairs. Supports batch (track_ids list) and single-track requests. """ - import base64 session = await get_validated_session(session_id, request) @@ -3873,10 +3872,23 @@ async def session_license_handler( all_keys: Dict[str, Dict[str, str]] = {} drm_types: Dict[str, str] = {} - keys_by_pssh: Dict[str, Dict[str, str]] = {} - drm_type_by_pssh: Dict[str, str] = {} + keys_by_pssh: Dict[tuple, Dict[str, str]] = {} + drm_type_by_pssh: Dict[tuple, str] = {} actual_drm_type: Optional[str] = None + def pssh_set(track: Any) -> tuple: + """Every PSSH and KID the track carries, as the rest of the cache key. + + Two tracks can share their first PSSH and still need different licences, + because the server licenses every header the track holds and asks for every + KID the manifest named on it. + """ + drms = getattr(track, "drm", None) or [] + return ( + tuple(sorted(str(getattr(d, "pssh_b64", "") or "") for d in drms)), + tuple(sorted(str(kid) for d in drms for kid in (getattr(d, "kids", None) or []))), + ) + def warn(message: str) -> None: """Log on the server and copy into the remote session buffer the client drains. @@ -3902,20 +3914,22 @@ async def session_license_handler( if pssh_str: break else: + warn(f"No PSSH on track {sanitize_log(str(track.id)[:12])} for {', '.join(candidates) or 'any CDM'}") return {}, None, None - if pssh_str not in keys_by_pssh: - keys_by_pssh[pssh_str] = {} + cache_key = (pssh_str, pssh_set(track)) + if cache_key not in keys_by_pssh: + keys_by_pssh[cache_key] = {} try: keys = handle_single_server_cdm(service, title, track, pssh_str, candidate, request) if keys: - keys_by_pssh[pssh_str] = keys - drm_type_by_pssh[pssh_str] = candidate + keys_by_pssh[cache_key] = keys + drm_type_by_pssh[cache_key] = candidate except SystemExit: warn(f"Service exited while resolving keys for track {sanitize_log(str(track.id)[:12])}, skipping") except (Exception, SystemExit) as e: warn(f"Failed to resolve keys for track {sanitize_log(str(track.id)[:12])}: {e}") - return keys_by_pssh[pssh_str], drm_type_by_pssh.get(pssh_str), pssh_str + return keys_by_pssh[cache_key], drm_type_by_pssh.get(cache_key), pssh_str for tid in track_ids: track = session.tracks.get(tid) @@ -3926,6 +3940,7 @@ async def session_license_handler( init_data = fetch_init_segment(track, svc_session) ensure_track_drm(track, svc_session, init_data) if not track.drm: + warn(f"Track {sanitize_log(tid[:12])} carries no DRM, so it has no keys to resolve") continue title = find_title_for_track(tid, session) @@ -3947,7 +3962,7 @@ async def session_license_handler( candidates.remove(preferred) candidates.insert(0, preferred) else: - log.warning( + warn( f"Track {sanitize_log(tid[:12])} wants {preferred} DRM " "but the server has no device for it, using the configured DRM instead" ) @@ -3973,7 +3988,7 @@ async def session_license_handler( keys, track_drm_type = init_keys, init_drm_type else: track.drm = manifest_drm - log.warning(f"No content key for KID {track_kid.hex} of track {sanitize_log(tid[:12])}") + warn(f"No content key for KID {track_kid.hex} of track {sanitize_log(tid[:12])}") if keys: all_keys[tid] = keys @@ -4005,25 +4020,8 @@ async def session_license_handler( pssh_b64 = data.get("pssh") if pssh_b64: - cls_name = {"playready": "PlayReady", "widevine": "Widevine"}.get(drm_type) - track.drm = [d for d in (track.drm or []) if d.__class__.__name__ != cls_name] if drm_type == "playready": track.pr_pssh = pssh_b64 - from pyplayready.system.pssh import PSSH as PlayReadyPSSH - - from unshackle.core.drm import PlayReady - - pr_pssh = PlayReadyPSSH(base64.b64decode(pssh_b64)) - pr_drm = PlayReady(pssh=pr_pssh, pssh_b64=pssh_b64) - track.drm.append(pr_drm) - elif drm_type == "widevine": - from pywidevine.pssh import PSSH as WidevinePSSH - - from unshackle.core.drm import Widevine - - wv_pssh = WidevinePSSH(pssh_b64) - wv_drm = Widevine(pssh=wv_pssh) - track.drm.append(wv_drm) if mode == "server_cdm": keys = handle_single_server_cdm(service, title, track, pssh_b64, drm_type, request)