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.
This commit is contained in:
imSp4rky
2026-09-07 10:05:09 -06:00
parent 627f1fa81c
commit 7065cbf911
5 changed files with 593 additions and 41 deletions
+416
View File
@@ -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'<KID ALGID="AESCTR" VALUE="{base64.b64encode(kid.bytes_le).decode()}"></KID>' for kid in kids)
xml = (
'<WRMHEADER xmlns="http://schemas.microsoft.com/DRM/2007/03/PlayReadyHeader" version="4.3.0.0">'
f"<DATA><PROTECTINFO><KIDS>{kid_xml}</KIDS></PROTECTINFO><LA_URL>{la_url}</LA_URL></DATA>"
"</WRMHEADER>"
)
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 "<License>x</License>"
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'<WRMHEADER xmlns="http://schemas.microsoft.com/DRM/2007/03/PlayReadyHeader" version="{version}">'
f"<DATA>{body}</DATA></WRMHEADER>"
).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"<PROTECTINFO><KEYLEN>16</KEYLEN><ALGID>AESCTR</ALGID></PROTECTINFO><KID>{_b64le(KID_A)}</KID>",
[KID_A],
),
("4.1.0.0", f'<PROTECTINFO><KID ALGID="AESCTR" VALUE="{_b64le(KID_B)}"></KID></PROTECTINFO>', [KID_B]),
(
"4.2.0.0",
f'<PROTECTINFO><KIDS><KID ALGID="AESCTR" VALUE="{_b64le(KID_A)}"></KID>'
f'<KID ALGID="AESCTR" VALUE="{_b64le(KID_B)}"></KID></KIDS></PROTECTINFO>',
[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'<PROTECTINFO><KID ALGID="AESCTR" VALUE="{_b64le(KID_A)}"></KID></PROTECTINFO>'
f'<CUSTOMATTRIBUTES><KIDS><KID VALUE="{_b64le(KID_B)}"/><KID VALUE="{_b64le(kid_c)}"/></KIDS></CUSTOMATTRIBUTES>',
)
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 "<License>x</License>"
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("<StatusCode>0x8004C065</StatusCode>")
return "<License>x</License>"
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)
@@ -108,6 +108,8 @@ class StubPlayReady(PlayReady):
self.pssh = SimpleNamespace(wrm_headers=["<WRMHEADER/>"])
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]:
+7
View File
@@ -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)
+164 -38
View File
@@ -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.
+4 -3
View File
@@ -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]