From 7065cbf9110c717f4246a2cb13bade8852d2a670 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Mon, 7 Sep 2026 10:05:09 -0600 Subject: [PATCH] fix(drm): license every PlayReady header until all KIDs are covered Only the first WRM header was read and licensed, so a manifest carrying several headers silently lost content keys. Collect KIDs from every header and license each distinct one until coverage is complete. --- tests/core/test_playready_multi_header.py | 416 ++++++++++++++++++ .../unit/test_licence_declared_kwargs.py | 2 + unshackle/commands/dl.py | 7 + unshackle/core/drm/playready.py | 202 +++++++-- unshackle/core/tracks/track.py | 7 +- 5 files changed, 593 insertions(+), 41 deletions(-) create mode 100644 tests/core/test_playready_multi_header.py diff --git a/tests/core/test_playready_multi_header.py b/tests/core/test_playready_multi_header.py new file mode 100644 index 00000000..f1efb11f --- /dev/null +++ b/tests/core/test_playready_multi_header.py @@ -0,0 +1,416 @@ +from __future__ import annotations + +import base64 +from typing import Any +from uuid import UUID + +import pytest +from pyplayready.system.pssh import PSSH +from pyplayready.system.wrmheader import WRMHeader + +from unshackle.core.drm import PlayReady + +KID_A = UUID("00112233-4455-6677-8899-aabbccddeeff") +KID_B = UUID("ffeeddcc-bbaa-9988-7766-554433221100") +KEY_A = "00000000000000000000000000000001" +KEY_B = "00000000000000000000000000000002" + + +def _wrm_header(*kids: UUID, la_url: str = "https://example.invalid/") -> bytes: + """A minimal v4.3 WRMHEADER naming the given KIDs, UTF-16LE as the PRO carries it.""" + kid_xml = "".join(f'' for kid in kids) + xml = ( + '' + f"{kid_xml}{la_url}" + "" + ) + return xml.encode("utf-16-le") + + +def _pro(*headers: bytes) -> bytes: + records = [{"type": 1, "length": len(h), "data": h} for h in headers] + return PSSH.PlayreadyHeader.build({"length": 6 + sum(4 + len(h) for h in headers), "records": records}) + + +def _drm(*headers: bytes) -> PlayReady: + pro = _pro(*headers) + return PlayReady(pssh=PSSH(pro), pssh_b64=base64.b64encode(pro).decode()) + + +class FakeKey: + def __init__(self, kid: UUID, key: str) -> None: + self.key_id = kid + self.key = bytes.fromhex(key) + + +class FakeCdm: + """Answers each challenge with the keys mapped for the header's KIDs; records what it saw.""" + + def __init__(self, answers: dict[UUID, str], fail_on: set[UUID] | None = None) -> None: + self.answers = answers + self.fail_on = fail_on or set() + self.challenged: list[list[UUID]] = [] + self.pssh_b64s: list[str] = [] + self.required: list[list[UUID]] = [] + self.attempted: list[list[UUID]] = [] + self._by_session: dict[str, list[UUID]] = {} + self._next = 0 + + def open(self) -> str: + self._next += 1 + return f"session-{self._next}" + + def close(self, session_id: str) -> None: + self._by_session.pop(session_id, None) + + def set_pssh_b64(self, pssh_b64: str, session_id: Any = None) -> None: + self.pssh_b64s.append(pssh_b64) + + def set_required_kids(self, kids: list[UUID], session_id: Any = None) -> None: + self.required.append(list(kids)) + + def get_license_challenge(self, session_id: str, header: WRMHeader) -> str: + kids = [k.value for k in header.key_ids] + self.attempted.append(kids) + if self.fail_on & set(kids): + raise RuntimeError("licence refused") + self.challenged.append(kids) + self._by_session[session_id] = kids + return "challenge" + + def parse_license(self, session_id: str, response: str) -> None: + pass + + def get_keys(self, session_id: str) -> list[FakeKey]: + return [FakeKey(kid, self.answers[kid]) for kid in self._by_session[session_id] if kid in self.answers] + + +def _licence(**_: Any) -> str: + return "x" + + +def _cert(**_: Any) -> None: + return None + + +def test_kids_are_collected_from_every_header() -> None: + drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B)) + + assert len(drm.pssh.wrm_headers) == 2 + assert drm.kids == [KID_A, KID_B] + + +def test_single_header_sends_one_challenge_with_the_original_pssh() -> None: + drm = _drm(_wrm_header(KID_A, KID_B)) + cdm = FakeCdm({KID_A: KEY_A, KID_B: KEY_B}) + + drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence) + + assert cdm.challenged == [[KID_A, KID_B]] + assert cdm.pssh_b64s == [drm.pssh_b64] + assert cdm.required == [[KID_A, KID_B]] + assert drm.content_keys == {KID_A: KEY_A, KID_B: KEY_B} + + +def test_two_distinct_headers_are_both_licensed_and_keys_merged() -> None: + drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B)) + cdm = FakeCdm({KID_A: KEY_A, KID_B: KEY_B}) + + drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence) + + assert cdm.challenged == [[KID_A], [KID_B]] + assert drm.content_keys == {KID_A: KEY_A, KID_B: KEY_B} + # a remote CDM sends whatever set_pssh_b64 gave it, so each round carries only its own header + assert [len(PSSH(b).wrm_headers) for b in cdm.pssh_b64s] == [1, 1] + for pro_b64 in cdm.pssh_b64s: + pro = base64.b64decode(pro_b64) + assert PSSH.PlayreadyHeader.parse(pro).length == len(pro) + assert _pro(PSSH(pro).wrm_headers[0].dumps().encode("utf-16-le")) == pro + assert [PSSH(b).wrm_headers[0].dumps() for b in cdm.pssh_b64s] == [h.dumps() for h in drm.pssh.wrm_headers] + + +def test_stacked_licence_covering_every_kid_stops_after_one_challenge() -> None: + drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B)) + cdm = FakeCdm({KID_A: KEY_A, KID_B: KEY_B}) + cdm.get_keys = lambda session_id: [FakeKey(KID_A, KEY_A), FakeKey(KID_B, KEY_B)] # type: ignore[method-assign] + + drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence) + + assert cdm.challenged == [[KID_A]] + assert drm.content_keys == {KID_A: KEY_A, KID_B: KEY_B} + + +def test_headers_naming_the_same_kids_cost_one_challenge() -> None: + drm = _drm(_wrm_header(KID_A), _wrm_header(KID_A)) + cdm = FakeCdm({KID_A: KEY_A}) + + drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence) + + assert cdm.challenged == [[KID_A]] + + +def test_a_header_already_covered_by_the_vault_is_skipped() -> None: + drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B)) + drm.content_keys[KID_A] = KEY_A + cdm = FakeCdm({KID_A: KEY_A, KID_B: KEY_B}) + + drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence) + + assert cdm.challenged == [[KID_B]] + + +def test_an_uncovered_kid_warns_instead_of_raising(monkeypatch: pytest.MonkeyPatch) -> None: + events: list[dict[str, Any]] = [] + monkeypatch.setattr( + "unshackle.core.drm.playready.log_event", + lambda operation, **kw: events.append({"operation": operation, **kw}), + ) + drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B)) + cdm = FakeCdm({KID_A: KEY_A}) + + drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence) + + assert drm.content_keys == {KID_A: KEY_A} + missing = [e for e in events if e["operation"] == "drm_missing_keys"] + assert missing and missing[0]["level"] == "WARNING" + assert missing[0]["missing_kids"] == [KID_B.hex] + + +def test_a_refused_header_does_not_lose_the_keys_of_the_others() -> None: + drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B)) + cdm = FakeCdm({KID_A: KEY_A, KID_B: KEY_B}, fail_on={KID_A}) + + drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence) + + assert drm.content_keys == {KID_B: KEY_B} + + +def test_every_header_refused_raises() -> None: + drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B)) + cdm = FakeCdm({}, fail_on={KID_A, KID_B}) + + with pytest.raises(RuntimeError, match="licence refused"): + drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence) + + +def test_get_drm_for_cdm_folds_sibling_playready_objects_into_the_first() -> None: + from pyplayready.cdm import Cdm as PlayReadyCdm + + from unshackle.core.tracks import Video + + first = _drm(_wrm_header(KID_A)) + second = _drm(_wrm_header(KID_B)) + track = Video( + id_="v", url="https://example.invalid/v.mpd", language="en", codec=Video.Codec.AVC, drm=[first, second] + ) + fake_cdm = PlayReadyCdm.__new__(PlayReadyCdm) + + chosen = track.get_drm_for_cdm(fake_cdm) + + assert chosen is first + assert chosen.kids == [KID_A, KID_B] + assert [[k.value for k in h.key_ids] for h in chosen.distinct_headers()] == [[KID_A], [KID_B]] + + cdm = FakeCdm({KID_A: KEY_A, KID_B: KEY_B}) + chosen.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence) + assert chosen.content_keys == {KID_A: KEY_A, KID_B: KEY_B} + + +def test_repeated_get_drm_for_cdm_does_not_grow_the_header_list() -> None: + from pyplayready.cdm import Cdm as PlayReadyCdm + + from unshackle.core.tracks import Video + + # a KID-less header is exempt from the KID-set dedupe, so only identity-level idempotence protects it + first = _drm(_wrm_header(KID_A)) + second = PlayReady(pssh=PSSH(_pro(_wrm_header(la_url="https://kidless.invalid/"))), kid=KID_B) + track = Video( + id_="v", url="https://example.invalid/v.mpd", language="en", codec=Video.Codec.AVC, drm=[first, second] + ) + fake_cdm = PlayReadyCdm.__new__(PlayReadyCdm) + + for _ in range(3): + chosen = track.get_drm_for_cdm(fake_cdm) + + assert chosen is first + assert len(chosen.distinct_headers()) == 2 + assert chosen.kids == [KID_A, KID_B] + + cdm = FakeCdm({KID_A: KEY_A}) + chosen.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence) + assert len(cdm.challenged) == 2 + + +def test_absorb_keeps_every_sibling_under_concurrent_calls() -> None: + from concurrent.futures import ThreadPoolExecutor + + first = _drm(_wrm_header(KID_A)) + # each call brings a sibling nobody else brings, so a lost update shows as a missing header + siblings = [PlayReady(pssh=PSSH(_pro(_wrm_header(la_url=f"https://x{i}.invalid/"))), kid=KID_B) for i in range(64)] + + with ThreadPoolExecutor(8) as pool: + list(pool.map(lambda sibling: first.absorb(sibling), siblings)) + + assert len(first.distinct_headers()) == 65 + assert first.kids == [KID_A, KID_B] + + +def test_a_track_carrying_playready_survives_deepcopy() -> None: + from copy import deepcopy + + from unshackle.core.tracks import Video + + first = _drm(_wrm_header(KID_A)) + second = _drm(_wrm_header(KID_B)) + first.absorb(second) + track = Video( + id_="v", url="https://example.invalid/v.mpd", language="en", codec=Video.Codec.AVC, drm=[first, second] + ) + + clone = deepcopy(track) + + assert clone.drm[0] is not first + assert clone.drm[0].kids == [KID_A, KID_B] + assert len(clone.drm[0].distinct_headers()) == 2 + + +def _pro_b64(*headers: bytes) -> str: + return base64.b64encode(_pro(*headers)).decode() + + +def _xml_header(version: str, body: str) -> bytes: + return ( + f'' + f"{body}" + ).encode("utf-16-le") + + +def _b64le(kid: UUID) -> str: + return base64.b64encode(kid.bytes_le).decode() + + +@pytest.mark.parametrize( + ("version", "body", "expected"), + [ + ( + "4.0.0.0", + f"16AESCTR{_b64le(KID_A)}", + [KID_A], + ), + ("4.1.0.0", f'', [KID_B]), + ( + "4.2.0.0", + f'' + f'', + [KID_A, KID_B], + ), + ], +) +def test_kids_are_read_from_every_header_version(version: str, body: str, expected: list[UUID]) -> None: + header = _xml_header(version, body) + assert PlayReady(pssh=PSSH(_pro(header)), pssh_b64=_pro_b64(header)).kids == expected + + +def test_customattributes_kids_follow_the_header_kids() -> None: + kid_c = UUID("11111111-2222-3333-4444-555555555555") + header = _xml_header( + "4.1.0.0", + f'' + f'', + ) + + drm = PlayReady(pssh=PSSH(_pro(header)), pssh_b64=_pro_b64(header)) + + # the header KID leads (it is what the licence server acts on); CUSTOMATTRIBUTES KIDs stay reachable + assert drm.kids == [KID_A, KID_B, kid_c] + assert drm.kid == KID_A + + +def test_device_revoked_on_the_first_header_is_not_swallowed() -> None: + drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B)) + cdm = FakeCdm({KID_A: KEY_A, KID_B: KEY_B}) + + calls: list[int] = [] + + def licence(**_: Any) -> str: + calls.append(1) + if len(calls) == 1: + raise PlayReady.Exceptions.DeviceRevoked("revoked") + return "x" + + with pytest.raises(PlayReady.Exceptions.DeviceRevoked): + drm.get_content_keys(cdm=cdm, certificate=_cert, licence=licence) + assert cdm.challenged == [[KID_A]] + + +def test_a_revocation_hresult_in_a_refusal_becomes_device_revoked() -> None: + drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B)) + cdm = FakeCdm({KID_A: KEY_A, KID_B: KEY_B}) + + calls: list[int] = [] + + def licence(**_: Any) -> str: + calls.append(1) + if len(calls) == 1: + raise RuntimeError("0x8004C065") + return "x" + + with pytest.raises(PlayReady.Exceptions.DeviceRevoked): + drm.get_content_keys(cdm=cdm, certificate=_cert, licence=licence) + assert cdm.challenged == [[KID_A]] + + +def _kidless_header() -> bytes: + return _wrm_header(la_url="https://kidless.invalid/") + + +def test_a_kidless_header_is_still_sent_when_the_vault_covered_every_kid() -> None: + drm = _drm(_wrm_header(KID_A), _kidless_header()) + drm.content_keys[KID_A] = KEY_A + cdm = FakeCdm({KID_A: KEY_A}) + + drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence) + + assert cdm.challenged == [[]] + + +def test_a_kidless_header_is_still_sent_when_a_licence_round_covered_every_kid() -> None: + drm = _drm(_wrm_header(KID_A), _kidless_header()) + cdm = FakeCdm({KID_A: KEY_A}) + + drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence) + + assert cdm.challenged == [[KID_A], []] + + +def test_a_header_that_yielded_nothing_is_not_sent_again_by_the_same_object() -> None: + drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B)) + cdm = FakeCdm({KID_A: KEY_A}) + + # every track of a title calls this on the shared object; the second call must not repeat B + drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence) + drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence) + + assert cdm.challenged == [[KID_A], [KID_B]] + assert drm.content_keys == {KID_A: KEY_A} + + +def test_a_refused_header_is_not_sent_again_by_the_same_object() -> None: + drm = _drm(_wrm_header(KID_A), _wrm_header(KID_B)) + cdm = FakeCdm({KID_A: KEY_A, KID_B: KEY_B}, fail_on={KID_B}) + + drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence) + attempts_after_first = len(cdm.attempted) + drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence) + + assert len(cdm.attempted) == attempts_after_first + assert drm.content_keys == {KID_A: KEY_A} + + +def test_a_single_refused_header_still_raises_on_every_call() -> None: + drm = _drm(_wrm_header(KID_A)) + cdm = FakeCdm({}, fail_on={KID_A}) + + for _ in range(2): + with pytest.raises(RuntimeError, match="licence refused"): + drm.get_content_keys(cdm=cdm, certificate=_cert, licence=_licence) diff --git a/tests/remote/unit/test_licence_declared_kwargs.py b/tests/remote/unit/test_licence_declared_kwargs.py index b70fc5c2..7faf54a3 100644 --- a/tests/remote/unit/test_licence_declared_kwargs.py +++ b/tests/remote/unit/test_licence_declared_kwargs.py @@ -108,6 +108,8 @@ class StubPlayReady(PlayReady): self.pssh = SimpleNamespace(wrm_headers=[""]) self.pssh_b64 = pssh_b64 self.kids = [KID] + self._extra_headers: list[Any] = [] + self._refused_headers: set[str] = set() self.content_keys: dict[UUID, str] = {} def extract_keys_from_cdm(self, cdm: Any, session_id: Any) -> dict[UUID, str]: diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 4c2582d8..678e2efc 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -4026,6 +4026,13 @@ class dl: Tracks that share KIDs still take turns, so the second one finds the content keys in LICENSE_KEY_CACHE and skips the challenge. Tracks with different KIDs send a challenge and query the key vaults at the same time. + + Callers take the lock after get_drm_for_cdm(), which folds a track's sibling PlayReady + objects into the returned one, so the KID set is complete for that track's DRM list + before the lock is chosen. This holds because every manifest parser gives the tracks + that share a PlayReady object the same DRM list (ISM attaches one list to all tracks; + DASH and HLS build a list per track). Two tracks that share one object but list + different siblings would get different locks. """ kids = getattr(drm, "kids", None) or [] key = ",".join(sorted(getattr(k, "hex", str(k)) for k in kids)) or getattr(drm, "content_id", None) diff --git a/unshackle/core/drm/playready.py b/unshackle/core/drm/playready.py index ea6e46a8..cddb04e7 100644 --- a/unshackle/core/drm/playready.py +++ b/unshackle/core/drm/playready.py @@ -7,6 +7,7 @@ import subprocess import textwrap import time from pathlib import Path +from threading import Lock from typing import Any, Callable, Optional, Union from uuid import UUID @@ -15,6 +16,7 @@ from construct import Container from pymp4.parser import Box from pyplayready.cdm import Cdm as PlayReadyCdm from pyplayready.system.pssh import PSSH +from pyplayready.system.wrmheader import WRMHeader from requests import Session from rich.text import Text @@ -29,6 +31,8 @@ from unshackle.core.utils.subprocess import ffprobe class PlayReady: """PlayReady DRM System.""" + _ABSORB_LOCK = Lock() + def __init__( self, pssh: PSSH, @@ -41,22 +45,15 @@ class PlayReady: if not isinstance(pssh, PSSH): raise TypeError(f"Expected pssh to be a {PSSH}, not {pssh!r}") + kids: list[UUID] = [] + for header in pssh.wrm_headers: + for header_kid in self.header_kids(header): + if header_kid not in kids: + kids.append(header_kid) if pssh_b64: - kids = self.extract_kids_from_pssh_b64(pssh_b64) - else: - kids = [] - - # Extract KIDs using pyplayready's WrmHeader key_ids - if not kids: - for header in pssh.wrm_headers: - for signed_key_id in getattr(header, "key_ids", []): - try: - if isinstance(signed_key_id.value, UUID): - kids.append(signed_key_id.value) - else: - kids.append(UUID(bytes_le=base64.b64decode(signed_key_id.value))) - except (ValueError, TypeError): - continue + for extra_kid in self.extract_kids_from_pssh_b64(pssh_b64): + if extra_kid not in kids: + kids.append(extra_kid) if kid: if isinstance(kid, str): @@ -70,6 +67,8 @@ class PlayReady: self._pssh = pssh self._kids = kids + self._extra_headers: list[WRMHeader] = [] + self._refused_headers: set[str] = set() if not self.kids: raise PlayReady.Exceptions.KIDNotFound("No Key ID was found within PSSH and none were provided.") @@ -266,6 +265,72 @@ class PlayReady: def kids(self) -> list[UUID]: return self._kids + @staticmethod + def header_kids(header: WRMHeader) -> list[UUID]: + """Return the KIDs one WRM header names, in header order.""" + kids: list[UUID] = [] + for signed_key_id in getattr(header, "key_ids", []): + try: + if isinstance(signed_key_id.value, UUID): + kids.append(signed_key_id.value) + else: + kids.append(UUID(bytes_le=base64.b64decode(signed_key_id.value))) + except (ValueError, TypeError): + continue + return kids + + def absorb(self, *others: PlayReady) -> None: + """Fold sibling PlayReady objects into this one so their headers and KIDs get licensed too. + + A manifest can attach one PlayReady object per WRM header to the same track + (ISM shares its Protection list across tracks; HLS emits one per EXT-X-KEY). The + caller keeps this object's identity, which the decrypt path and drm_lock depend on. + """ + with PlayReady._ABSORB_LOCK: + known = {header.dumps() for header in [*self.pssh.wrm_headers, *self._extra_headers]} + for other in others: + if other is self: + continue + for header in other.distinct_headers(): + if header.dumps() not in known: + known.add(header.dumps()) + self._extra_headers.append(header) + for kid in other.kids: + if kid not in self._kids: + self._kids.append(kid) + for kid, key in dict(other.content_keys).items(): + self.content_keys.setdefault(kid, key) + + def distinct_headers(self) -> list[WRMHeader]: + """Return the WRM headers to license, deduplicated by the KID set each one names. + + Two headers naming the same KIDs (a v4.0 header beside its v4.3 twin, or one PRH per + rendition for one content key) cost one challenge. A header with no readable KID is kept: it + may still be the only route to a content key nothing else names. + """ + headers: list[WRMHeader] = [] + seen: set[frozenset[UUID]] = set() + for header in [*self.pssh.wrm_headers, *self._extra_headers]: + kid_set = frozenset(self.header_kids(header)) + if kid_set and kid_set in seen: + continue + seen.add(kid_set) + headers.append(header) + return headers + + @staticmethod + def pro_b64_for(header: WRMHeader) -> str: + """Build a base64 one-record PlayReady Object holding only this header. + + Remote CDMs send the value given to set_pssh_b64 rather than the header passed + to get_license_challenge, so a per-header challenge needs a per-header PRO. + """ + record = header.dumps().encode("utf-16-le") + pro = PSSH.PlayreadyHeader.build( + {"length": 10 + len(record), "records": [{"type": 1, "length": len(record), "data": record}]} + ) + return base64.b64encode(pro).decode() + def extract_keys_from_cdm(self, cdm: PlayReadyCdm, session_id: bytes) -> dict: """Extract keys from CDM session with cross-library compatibility. @@ -298,21 +363,96 @@ class PlayReady: return keys def get_content_keys(self, cdm: PlayReadyCdm, certificate: Callable, licence: Callable) -> None: + """License every distinct WRM header whose KIDs still lack a content key. + + A v4.2+ header may list several KIDs and one response may stack several licences, + so one challenge often covers everything. Content that ships several headers (ISM + with one ProtectionHeader per track type, HLS with one PRH per playlist) gets one + challenge per header that is not yet covered. Headers are sent as-is: Microsoft lets + the server ignore the header, so a merged header could ask for keys it never grants. + + A header that a round refused, or that yielded nothing, is not sent again by this + object: every track of a title runs this method, and a repeat would only repeat the + refusal. + """ + headers = self.distinct_headers() + wanted = set(self.kids) + for index, header in enumerate(headers): + header_kids = self.header_kids(header) + if header_kids and set(header_kids) <= self.content_keys.keys(): + continue + if len(headers) > 1 and header.dumps() in self._refused_headers: + continue + if len(headers) == 1: + pssh_b64, required = self.pssh_b64, self.kids + else: + pssh_b64, required = self.pro_b64_for(header), header_kids or self.kids + try: + keys = self._license_header(cdm, header, pssh_b64, required, licence) + except PlayReady.Exceptions.DeviceRevoked: + raise + except Exception as e: + self._refused_headers.add(header.dumps()) + if not self.content_keys and index == len(headers) - 1: + raise + log_event( + "drm_license_error", + level="WARNING", + message=f"PlayReady header {index + 1}/{len(headers)} was refused: {e}", + drm_type="PlayReady", + header_kids=[kid.hex for kid in header_kids], + ) + continue + self.content_keys.update(keys) + if keys: + log_event( + "drm_content_keys", + level="INFO", + message=f"Recovered {len(keys)} PlayReady content key(s) from CDM", + drm_type="PlayReady", + key_count=len(keys), + keys=[{"kid": k.hex if hasattr(k, "hex") else str(k), "key": v} for k, v in keys.items()], + ) + else: + self._refused_headers.add(header.dumps()) + + if not self.content_keys: + raise PlayReady.Exceptions.EmptyLicense("No Content Keys were within the License") + + missing = wanted - self.content_keys.keys() + if missing: + log_event( + "drm_missing_keys", + level="WARNING", + message=f"No PlayReady key for KID(s) {', '.join(sorted(kid.hex for kid in missing))}", + drm_type="PlayReady", + missing_kids=[kid.hex for kid in missing], + ) + + def _license_header( + self, + cdm: PlayReadyCdm, + header: WRMHeader, + pssh_b64: Optional[str], + required_kids: list[UUID], + licence: Callable, + ) -> dict[UUID, str]: + """Run one challenge/response round for a single header and return the keys it yielded.""" session_id = cdm.open() try: - if hasattr(cdm, "set_pssh_b64") and self.pssh_b64: + if hasattr(cdm, "set_pssh_b64") and pssh_b64: try: - cdm.set_pssh_b64(self.pssh_b64, session_id=session_id) + cdm.set_pssh_b64(pssh_b64, session_id=session_id) except TypeError: # CDM predating the per-session signature - cdm.set_pssh_b64(self.pssh_b64) + cdm.set_pssh_b64(pssh_b64) if hasattr(cdm, "set_required_kids"): try: - cdm.set_required_kids(self.kids, session_id=session_id) + cdm.set_required_kids(required_kids, session_id=session_id) except TypeError: # CDM predating the per-session signature - cdm.set_required_kids(self.kids) + cdm.set_required_kids(required_kids) - challenge = cdm.get_license_challenge(session_id, self.pssh.wrm_headers[0]) + challenge = cdm.get_license_challenge(session_id, header) if challenge: license_str = "" @@ -323,10 +463,10 @@ class PlayReady: message="Requesting PlayReady license", drm_type="PlayReady", challenge_size=len(challenge), - kid_count=len(self.kids), + kid_count=len(required_kids), ) try: - license_res = licence(challenge=challenge, pssh_b64=self.pssh_b64) + license_res = licence(challenge=challenge, pssh_b64=pssh_b64) except TypeError: license_res = licence(challenge=challenge) if isinstance(license_res, bytes): @@ -356,24 +496,10 @@ class PlayReady: raise PlayReady.Exceptions.DeviceRevoked(revoked) from e raise - keys = self.extract_keys_from_cdm(cdm, session_id) - self.content_keys.update(keys) - - if keys: - log_event( - "drm_content_keys", - level="INFO", - message=f"Recovered {len(keys)} PlayReady content key(s) from CDM", - drm_type="PlayReady", - key_count=len(keys), - keys=[{"kid": k.hex if hasattr(k, "hex") else str(k), "key": v} for k, v in keys.items()], - ) + return self.extract_keys_from_cdm(cdm, session_id) finally: cdm.close(session_id) - if not self.content_keys: - raise PlayReady.Exceptions.EmptyLicense("No Content Keys were within the License") - @staticmethod def detect_revocation(text: str) -> Optional[str]: """Return the decoded error string if a revocation HRESULT is present, else None. diff --git a/unshackle/core/tracks/track.py b/unshackle/core/tracks/track.py index 06c6a513..e58adb1a 100644 --- a/unshackle/core/tracks/track.py +++ b/unshackle/core/tracks/track.py @@ -877,9 +877,10 @@ class Track: if isinstance(drm, Widevine): return drm elif is_playready_cdm(cdm): - for drm in self.drm: - if isinstance(drm, PlayReady): - return drm + playready = [drm for drm in self.drm if isinstance(drm, PlayReady)] + if playready: + playready[0].absorb(*playready[1:]) + return playready[0] return self.drm[0]