From 334542279075df0bf13c390d21af2dcb14c0efbe Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Fri, 23 Oct 2026 15:55:01 -0600 Subject: [PATCH] fix(drm): load a bare PlayReady WRMHEADER from DASH, HLS and ISM manifests PlayReady.from_pssh_b64 wraps a bare UTF-16LE WRMHEADER in a PlayReady Object and keeps that as pssh_b64. DASH and ISM log and skip a PlayReady entry they cannot parse. --- tests/core/test_playready_bare_header.py | 112 +++++++++++++++++++++++ unshackle/core/drm/playready.py | 21 +++++ unshackle/core/manifests/dash.py | 7 +- unshackle/core/manifests/hls.py | 5 +- unshackle/core/manifests/ism.py | 5 +- unshackle/core/tracks/track.py | 2 +- 6 files changed, 140 insertions(+), 12 deletions(-) create mode 100644 tests/core/test_playready_bare_header.py diff --git a/tests/core/test_playready_bare_header.py b/tests/core/test_playready_bare_header.py new file mode 100644 index 00000000..ca75652a --- /dev/null +++ b/tests/core/test_playready_bare_header.py @@ -0,0 +1,112 @@ +"""A bare UTF-16LE WRMHEADER in place of a PSSH box or PlayReady Object loads as PlayReady DRM.""" + +from __future__ import annotations + +import base64 +import logging +from uuid import UUID + +import m3u8 +import pytest +from lxml import etree +from pyplayready.misc.exceptions import InvalidPssh +from pyplayready.system.pssh import PSSH +from pywidevine.pssh import PSSH as WV_PSSH + +from unshackle.core.drm import PlayReady, Widevine, drm_from_dict +from unshackle.core.manifests.dash import DASH +from unshackle.core.manifests.hls import HLS +from unshackle.core.manifests.ism import ISM + +KID = UUID("00112233-4455-6677-8899-aabbccddeeff") +KID_B64 = base64.b64encode(KID.bytes_le).decode() +PR_URN = "urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95" +WV_URN = "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed" + + +def bare_header(version: str) -> str: + """Base64 of a bare WRMHEADER, with the KID where the given header version puts it.""" + if version == "4.0.0.0": + data = f"16AESCTR{KID_B64}" + else: + data = f'' + xml = ( + f'' + f"{data}https://example.invalid/pr" + ) + return base64.b64encode(xml.encode("utf-16-le")).decode() + + +def pr_protection(child: str, value: str) -> etree._Element: + return etree.fromstring( + f'<{child}>{value}' + ) + + +@pytest.mark.parametrize("version", ["4.0.0.0", "4.1.0.0", "4.2.0.0", "4.3.0.0"]) +@pytest.mark.parametrize("child", ["mspr:pro", "cenc:pssh"]) +def test_dash_loads_a_bare_header(child: str, version: str) -> None: + drm = DASH.get_drm([pr_protection(child, bare_header(version))]) + + assert len(drm) == 1 + assert isinstance(drm[0], PlayReady) + assert KID in drm[0].kids + assert drm[0].pssh_b64 + PSSH(drm[0].pssh_b64) + assert KID in drm_from_dict(drm[0].to_dict()).kids + + +def test_wrapped_header_bytes_are_unchanged() -> None: + header = bare_header("4.0.0.0") + drm = PlayReady.from_pssh_b64(header) + + assert drm.pssh.wrm_headers[0].dumps().encode("utf-16-le") == base64.b64decode(header) + + +def test_dash_skips_an_unparseable_playready_entry_and_keeps_widevine(caplog: pytest.LogCaptureFixture) -> None: + widevine = etree.fromstring( + f'' + f"{WV_PSSH.new(system_id=WV_PSSH.SystemId.Widevine, key_ids=[KID]).dumps()}" + "" + ) + garbage = pr_protection("mspr:pro", base64.b64encode(b"\x00\x00not a header").decode()) + + with caplog.at_level(logging.WARNING, logger="DASH"): + drm = DASH.get_drm([widevine, garbage]) + + assert [type(d) for d in drm] == [Widevine] + assert "Skipping unparseable PlayReady ContentProtection" in caplog.text + + +@pytest.mark.parametrize( + "data", + [b"\x00\x00not a header", "".encode("utf-8")], + ids=["garbage", "utf-8-header"], +) +def test_other_bad_data_keeps_the_original_error(data: bytes) -> None: + with pytest.raises(InvalidPssh): + PlayReady.from_pssh_b64(base64.b64encode(data).decode()) + + +def test_hls_loads_a_bare_header() -> None: + key = m3u8.loads( + "#EXTM3U\n" + f'#EXT-X-KEY:METHOD=SAMPLE-AES-CTR,URI="data:text/plain;charset=UTF-16;base64,{bare_header("4.0.0.0")}",' + 'KEYFORMAT="com.microsoft.playready",KEYFORMATVERSIONS="1"\n' + "#EXTINF:4.0,\nseg0.mp4\n" + ).keys[0] + + drm = HLS.get_drm(key) + + assert isinstance(drm, PlayReady) + assert KID in drm.kids + + +def test_ism_loads_a_bare_header() -> None: + header = etree.fromstring(f'{bare_header("4.0.0.0")}') + + drm = ISM.get_drm([header]) + + assert len(drm) == 1 + assert KID in drm[0].kids diff --git a/unshackle/core/drm/playready.py b/unshackle/core/drm/playready.py index d59b171f..6a30d4a3 100644 --- a/unshackle/core/drm/playready.py +++ b/unshackle/core/drm/playready.py @@ -15,6 +15,7 @@ import m3u8 from construct import Container from pymp4.parser import Box from pyplayready.cdm import Cdm as PlayReadyCdm +from pyplayready.misc.exceptions import InvalidPssh from pyplayready.system.pssh import PSSH from pyplayready.system.wrmheader import WRMHeader from requests import Session @@ -170,6 +171,26 @@ class PlayReady: return [] + @classmethod + def from_pssh_b64(cls, pssh_b64: str, kid: Optional[UUID] = None) -> PlayReady: + """Load a base64 PSSH box or PlayReady Object, or a bare UTF-16LE WRMHEADER that some packagers emit. + + It wraps a bare WRMHEADER in a PlayReady Object and keeps that as `pssh_b64`, so remote CDMs and + the REST API can parse it again. + """ + try: + pssh = PSSH(pssh_b64) + except InvalidPssh: + try: + raw = base64.b64decode(pssh_b64) + except ValueError: + raw = b"" + if not raw.startswith(b"<\x00"): + raise + pssh_b64 = cls.pro_b64_for(WRMHeader(raw)) + pssh = PSSH(pssh_b64) + return cls(pssh=pssh, kid=kid, pssh_b64=pssh_b64) + @classmethod def from_track(cls, track: AnyTrack, session: Optional[Session] = None) -> PlayReady: if not session: diff --git a/unshackle/core/manifests/dash.py b/unshackle/core/manifests/dash.py index c01ca039..06261999 100644 --- a/unshackle/core/manifests/dash.py +++ b/unshackle/core/manifests/dash.py @@ -20,7 +20,6 @@ from zlib import crc32 import requests from langcodes import Language, tag_is_valid from lxml.etree import Element, ElementTree, XMLSyntaxError -from pyplayready.system.pssh import PSSH as PR_PSSH from pywidevine.cdm import Cdm as WidevineCdm from pywidevine.pssh import PSSH from requests import Session @@ -1355,7 +1354,6 @@ class DASH: ) if not pr_pssh_b64: continue - pr_pssh = PR_PSSH(pr_pssh_b64) kid_b64 = protection.findtext("kid") kid = None if kid_b64: @@ -1364,7 +1362,10 @@ class DASH: except ValueError: kid = None - drm.append(PlayReady(pssh=pr_pssh, kid=kid, pssh_b64=pr_pssh_b64)) + try: + drm.append(PlayReady.from_pssh_b64(pr_pssh_b64, kid=kid)) + except Exception as e: + logging.getLogger("DASH").warning(f"Skipping unparseable PlayReady ContentProtection: {e!r}") elif urn == ClearKeyCENC.urn: # W3C EME ClearKey (org.w3.clearkey): match the scheme UUID alone, diff --git a/unshackle/core/manifests/hls.py b/unshackle/core/manifests/hls.py index f28f44e8..cf81f8d5 100644 --- a/unshackle/core/manifests/hls.py +++ b/unshackle/core/manifests/hls.py @@ -1578,10 +1578,7 @@ class HLS: **extra_params, ) elif key.keyformat and key.keyformat.lower() in {f"urn:uuid:{PR_PSSH.SYSTEM_ID}", "com.microsoft.playready"}: - drm = PlayReady( - pssh=PR_PSSH(key.uri.split(",")[-1]), - pssh_b64=key.uri.split(",")[-1], - ) + drm = PlayReady.from_pssh_b64(key.uri.split(",")[-1]) else: raise NotImplementedError(f"The key system is not supported: {key}") diff --git a/unshackle/core/manifests/ism.py b/unshackle/core/manifests/ism.py index 0af45c65..87ad6ef8 100644 --- a/unshackle/core/manifests/ism.py +++ b/unshackle/core/manifests/ism.py @@ -15,7 +15,6 @@ from typing import Any, BinaryIO, Optional, Union import requests from langcodes import Language, tag_is_valid from lxml.etree import Element -from pyplayready.system.pssh import PSSH as PR_PSSH from pywidevine.pssh import PSSH from requests import Session @@ -104,11 +103,9 @@ class ISM: drm.append(Widevine(pssh=pssh, kid=kid)) elif system_id == "9a04f079-9840-4286-ab92-e65be0885f95": try: - pr_pssh = PR_PSSH(data) + drm.append(PlayReady.from_pssh_b64(data)) except Exception as e: logging.getLogger("ISM").warning(f"Skipping unparseable PlayReady ProtectionHeader: {e!r}") - continue - drm.append(PlayReady(pssh=pr_pssh, pssh_b64=data)) return drm @staticmethod diff --git a/unshackle/core/tracks/track.py b/unshackle/core/tracks/track.py index a43ccc24..9fe233de 100644 --- a/unshackle/core/tracks/track.py +++ b/unshackle/core/tracks/track.py @@ -1174,7 +1174,7 @@ class Track: drm_list.append(drm) elif fmt in {f"urn:uuid:{PR_PSSH.SYSTEM_ID}", "com.microsoft.playready"}: pssh_b64 = key.uri.split(",")[-1] - drm = PlayReady(pssh=PR_PSSH(pssh_b64), pssh_b64=pssh_b64) + drm = PlayReady.from_pssh_b64(pssh_b64) drm_list.append(drm) if drm_list: