From 47f30668ee7bd13a4f5702ad2dcb8be63d87ce2c Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Wed, 23 Sep 2026 14:27:30 -0600 Subject: [PATCH] fix(drm): give the zero-KID fallback only the track's own key mp4decrypt and shaka use the last key for a repeated KID. --- tests/core/test_zero_kid_fallback.py | 131 +++++++++++++++++++++++++++ unshackle/core/drm/__init__.py | 6 +- unshackle/core/drm/key_args.py | 62 +++++++++++++ unshackle/core/drm/playready.py | 44 ++++----- unshackle/core/drm/widevine.py | 43 +++------ 5 files changed, 228 insertions(+), 58 deletions(-) create mode 100644 tests/core/test_zero_kid_fallback.py create mode 100644 unshackle/core/drm/key_args.py diff --git a/tests/core/test_zero_kid_fallback.py b/tests/core/test_zero_kid_fallback.py new file mode 100644 index 00000000..dd41f3b3 --- /dev/null +++ b/tests/core/test_zero_kid_fallback.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import base64 +from typing import Union +from uuid import UUID + +import pytest +from pyplayready.system.pssh import PSSH as PlayReadyPSSH +from pywidevine.pssh import PSSH as WidevinePSSH + +from unshackle.core.drm import PlayReady, Widevine, drm_from_dict, key_args +from unshackle.core.drm.key_args import ZERO_KID + +VIDEO_KID = UUID("00112233-4455-6677-8899-aabbccddeeff") +AUDIO_KID = UUID("ffeeddcc-bbaa-9988-7766-554433221100") +VIDEO_KEY = "0000000000000000000000000000000a" +AUDIO_KEY = "0000000000000000000000000000000b" + +DRM = Union[Widevine, PlayReady] + + +def _playready_pssh(*kids: UUID) -> tuple[PlayReadyPSSH, str]: + kid_xml = "".join(f'' for k in kids) + header = ( + '' + f"{kid_xml}" + ).encode("utf-16-le") + pro = PlayReadyPSSH.PlayreadyHeader.build( + {"length": 6 + 4 + len(header), "records": [{"type": 1, "length": len(header), "data": header}]} + ) + return PlayReadyPSSH(pro), base64.b64encode(pro).decode() + + +def _widevine(*kids: UUID, kid: UUID | None = None) -> Widevine: + return Widevine(pssh=WidevinePSSH.new(system_id=WidevinePSSH.SystemId.Widevine, key_ids=list(kids)), kid=kid) + + +def _playready(*kids: UUID, kid: UUID | None = None) -> PlayReady: + pssh, pssh_b64 = _playready_pssh(*kids) + return PlayReady(pssh=pssh, kid=kid, pssh_b64=pssh_b64) + + +def _with_keys(drm: DRM, keys: dict[UUID, str]) -> DRM: + drm.content_keys.update(keys) + return drm + + +BOTH_KEYS = {VIDEO_KID: VIDEO_KEY, AUDIO_KID: AUDIO_KEY} + + +def _zero_entries(args: list[str]) -> list[str]: + return [a.split(":", 1)[1] for a in args if a.startswith(f"{ZERO_KID}:")] + + +def _shaka_zero_entries(keys: str) -> list[str]: + return [e.rsplit("key=", 1)[1] for e in keys.split(",") if f"key_id={ZERO_KID}" in e] + + +def _shaka_keys(drm: DRM) -> str: + return key_args.shaka_keys(drm.content_keys, drm.own_kids()) + + +OWN_KID_KNOWN = [ + pytest.param(lambda: _widevine(VIDEO_KID), id="widevine-pssh-kid"), + pytest.param(lambda: _widevine(VIDEO_KID, AUDIO_KID, kid=VIDEO_KID), id="widevine-constructor-kid"), + pytest.param(lambda: _playready(VIDEO_KID), id="playready-pssh-kid"), + pytest.param(lambda: _playready(VIDEO_KID, AUDIO_KID, kid=VIDEO_KID), id="playready-constructor-kid"), +] + + +@pytest.mark.parametrize("make", OWN_KID_KNOWN) +def test_several_keys_give_the_zero_kid_only_the_own_key(make) -> None: + drm = _with_keys(make(), BOTH_KEYS) + + args = drm.mp4decrypt_key_args() + + assert f"{VIDEO_KID.hex}:{VIDEO_KEY}" in args and f"{AUDIO_KID.hex}:{AUDIO_KEY}" in args + assert _zero_entries(args) == [VIDEO_KEY] + assert _shaka_zero_entries(_shaka_keys(drm)) == [VIDEO_KEY] + + +@pytest.mark.parametrize("make", [_widevine, _playready]) +def test_a_single_key_keeps_its_zero_kid_entry(make) -> None: + drm = _with_keys(make(VIDEO_KID), {VIDEO_KID: VIDEO_KEY}) + + assert drm.mp4decrypt_key_args() == ["--key", f"{VIDEO_KID.hex}:{VIDEO_KEY}", "--key", f"{ZERO_KID}:{VIDEO_KEY}"] + assert _shaka_keys(drm) == ( + f"label=0:key_id={VIDEO_KID.hex}:key={VIDEO_KEY},label=1:key_id={ZERO_KID}:key={VIDEO_KEY}" + ) + + +@pytest.mark.parametrize("make", [_widevine, _playready]) +def test_several_keys_and_no_single_own_kid_give_no_zero_kid_entry(make) -> None: + drm = _with_keys(make(VIDEO_KID, AUDIO_KID), BOTH_KEYS) + + assert _zero_entries(drm.mp4decrypt_key_args()) == [] + assert _shaka_zero_entries(_shaka_keys(drm)) == [] + + +def test_shaka_decrypt_passes_the_built_keys(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + """The shaka-packager call uses the same builder the tests above cover.""" + from unshackle.core.drm import widevine as widevine_module + + seen: list[list[str]] = [] + + class StopPopen(Exception): + pass + + def fake_popen(cmd, **kwargs): + seen.append([str(c) for c in cmd]) + raise StopPopen + + monkeypatch.setattr(widevine_module.subprocess, "Popen", fake_popen) + monkeypatch.setattr(widevine_module.binaries, "ShakaPackager", "shaka-packager") + monkeypatch.setattr(widevine_module.config.directories, "temp", tmp_path) + drm = _with_keys(_widevine(VIDEO_KID), BOTH_KEYS) + media = tmp_path / "track.mp4" + media.write_bytes(b"x") + + with pytest.raises(StopPopen): + drm.decrypt_with_shaka_packager(media) + + keys = seen[0][seen[0].index("--keys") + 1] + assert _shaka_zero_entries(keys) == [VIDEO_KEY] + + +def test_a_rebuilt_widevine_does_not_take_the_first_pssh_kid_as_its_own() -> None: + drm = drm_from_dict(_widevine(VIDEO_KID, AUDIO_KID).to_dict()) + drm.content_keys.update(BOTH_KEYS) + + assert _zero_entries(drm.mp4decrypt_key_args()) == [] diff --git a/unshackle/core/drm/__init__.py b/unshackle/core/drm/__init__.py index d9893e2f..bae6603a 100644 --- a/unshackle/core/drm/__init__.py +++ b/unshackle/core/drm/__init__.py @@ -34,7 +34,11 @@ def drm_from_dict(data: dict[str, Any]) -> Union[Widevine, PlayReady, ClearKeyCE elif system == "Widevine": from pywidevine.pssh import PSSH as WidevinePSSH - drm = Widevine(pssh=WidevinePSSH(pssh_b64), kid=kids[0] if kids else None) + wv_pssh = WidevinePSSH(pssh_b64) + # kids repeats the PSSH KIDs, so kids[0] is not the track's own KID; only a PSSH + # without KIDs needs it + + drm = Widevine(pssh=wv_pssh, kid=kids[0] if kids and not wv_pssh.key_ids else None) else: raise ValueError(f"Unsupported DRM system for reconstruction: {system!r}") diff --git a/unshackle/core/drm/key_args.py b/unshackle/core/drm/key_args.py new file mode 100644 index 00000000..7b310e15 --- /dev/null +++ b/unshackle/core/drm/key_args.py @@ -0,0 +1,62 @@ +"""Decrypter key arguments shared by the Widevine and PlayReady DRM systems.""" + +from __future__ import annotations + +from typing import Any, Iterable, Optional + +ZERO_KID = "00" * 16 + + +def _hex(value: Any) -> str: + """Return a KID or content key as lowercase hex without dashes.""" + if isinstance(value, bytes): + return value.hex() + if hasattr(value, "hex") and not isinstance(value, str): + return str(value.hex) + return str(value).replace("-", "").lower() + + +def zero_kid_key(content_keys: dict[Any, Any], own_kids: Iterable[Any]) -> Optional[str]: + """Return the content key to give the all-zero KID, or None when no content key is safe. + + Some tracks carry an all-zero default KID in the init segment and signal the real KID + out of band. The decrypter then needs the track's content key under the zero KID as well. + + A decrypter applies the last content key given for a repeated KID. Thus only one content + key can go under the zero KID, and it must be the content key of the track: + + - The DRM holds exactly one content key: that content key. + - The DRM holds several content keys: the content key of the one own KID that has one. + - Otherwise: None. A missing content key makes the decrypt fail loudly, but an incorrect + content key gives output that does not decode, and the verify step then flags good + content keys as bad. + """ + keys = {_hex(kid): _hex(key) for kid, key in content_keys.items()} + if ZERO_KID in keys or not keys: + return None + if len(keys) == 1: + return next(iter(keys.values())) + matches = {_hex(kid) for kid in own_kids} & keys.keys() + if len(matches) == 1: + return keys[matches.pop()] + return None + + +def mp4decrypt_key_args(content_keys: dict[Any, Any], own_kids: Iterable[Any]) -> list[str]: + """Build the mp4decrypt --key arguments: every content key, then the zero-KID fallback.""" + key_args: list[str] = [] + for kid, key in content_keys.items(): + key_args.extend(["--key", f"{_hex(kid)}:{_hex(key)}"]) + fallback = zero_kid_key(content_keys, own_kids) + if fallback: + key_args.extend(["--key", f"{ZERO_KID}:{fallback}"]) + return key_args + + +def shaka_keys(content_keys: dict[Any, Any], own_kids: Iterable[Any]) -> str: + """Build the shaka-packager --keys value: every content key, then the zero-KID fallback.""" + entries = [f"label={i}:key_id={_hex(kid)}:key={_hex(key)}" for i, (kid, key) in enumerate(content_keys.items())] + fallback = zero_kid_key(content_keys, own_kids) + if fallback: + entries.append(f"label={len(entries)}:key_id={ZERO_KID}:key={fallback}") + return ",".join(entries) diff --git a/unshackle/core/drm/playready.py b/unshackle/core/drm/playready.py index cddb04e7..f75d9d61 100644 --- a/unshackle/core/drm/playready.py +++ b/unshackle/core/drm/playready.py @@ -24,6 +24,7 @@ from unshackle.core import binaries from unshackle.core.config import config from unshackle.core.console import console from unshackle.core.constants import AnyTrack +from unshackle.core.drm import key_args as drm_key_args from unshackle.core.utilities import get_boxes, log_event from unshackle.core.utils.subprocess import ffprobe @@ -55,6 +56,7 @@ class PlayReady: if extra_kid not in kids: kids.append(extra_kid) + self._kid: Optional[UUID] = None if kid: if isinstance(kid, str): kid = UUID(hex=kid) @@ -62,6 +64,7 @@ class PlayReady: kid = UUID(bytes=kid) if not isinstance(kid, UUID): raise ValueError(f"Expected kid to be a {UUID}, str, or bytes, not {kid!r}") + self._kid = kid if kid not in kids: kids.append(kid) @@ -563,23 +566,19 @@ class PlayReady: output_size=path.stat().st_size if path.exists() else 0, ) - def mp4decrypt_key_args(self) -> list[str]: - """Build the mp4decrypt --key arguments for every content key.""" - key_args = [] - for kid, key in self.content_keys.items(): - kid_hex = kid.hex if hasattr(kid, "hex") else str(kid).replace("-", "") - key_hex = key if isinstance(key, str) else key.hex() - key_args.extend(["--key", f"{kid_hex}:{key_hex}"]) + def own_kids(self) -> list[UUID]: + """Return the KIDs that identify this track's own content key. - # Fallback for tracks whose tenc default_KID is all-zero and whose real - # KID is signalled out-of-band: emit a zero-KID entry per content key. - zero_kid = "00" * 16 - existing_kids = {kid.hex if hasattr(kid, "hex") else str(kid).replace("-", "") for kid in self.content_keys} - if zero_kid not in existing_kids: - for key in self.content_keys.values(): - key_hex = key if isinstance(key, str) else key.hex() - key_args.extend(["--key", f"{zero_kid}:{key_hex}"]) - return key_args + The KID given to the constructor (from the manifest or the init segment) names the + track exactly. A PSSH can name the KIDs of other renditions too. + """ + if self._kid and self._kid in self.content_keys: + return [self._kid] + return self.kids + + def mp4decrypt_key_args(self) -> list[str]: + """Build the mp4decrypt --key arguments for every content key and the zero-KID fallback.""" + return drm_key_args.mp4decrypt_key_args(self.content_keys, self.own_kids()) def decrypt_with_mp4decrypt(self, path: Path) -> None: """Decrypt using mp4decrypt""" @@ -633,18 +632,7 @@ class PlayReady: f"input={path},stream=0,output={output_path},output_format=MP4", "--enable_raw_key_decryption", "--keys", - ",".join( - [ - *[ - f"label={i}:key_id={kid.hex}:key={key.lower()}" - for i, (kid, key) in enumerate(self.content_keys.items()) - ], - *[ - f"label={i}:key_id={'00' * 16}:key={key.lower()}" - for i, (kid, key) in enumerate(self.content_keys.items(), len(self.content_keys)) - ], - ] - ), + drm_key_args.shaka_keys(self.content_keys, self.own_kids()), "--temp_dir", config.directories.temp, ] diff --git a/unshackle/core/drm/widevine.py b/unshackle/core/drm/widevine.py index a6cbf407..377e2b9b 100644 --- a/unshackle/core/drm/widevine.py +++ b/unshackle/core/drm/widevine.py @@ -21,6 +21,7 @@ from unshackle.core import binaries from unshackle.core.config import config from unshackle.core.console import console from unshackle.core.constants import AnyTrack +from unshackle.core.drm import key_args as drm_key_args from unshackle.core.utilities import get_boxes, log_event from unshackle.core.utils.subprocess import ffprobe @@ -315,23 +316,19 @@ class Widevine: output_size=path.stat().st_size if path.exists() else 0, ) - def mp4decrypt_key_args(self) -> list[str]: - """Build the mp4decrypt --key arguments for every content key.""" - key_args = [] - for kid, key in self.content_keys.items(): - kid_hex = kid.hex if hasattr(kid, "hex") else str(kid).replace("-", "") - key_hex = key if isinstance(key, str) else key.hex() - key_args.extend(["--key", f"{kid_hex}:{key_hex}"]) + def own_kids(self) -> list[UUID]: + """Return the KIDs that identify this track's own content key. - # Fallback for tracks whose tenc default_KID is all-zero and whose real - # KID is signalled out-of-band: emit a zero-KID entry per content key. - zero_kid = "00" * 16 - existing_kids = {kid.hex if hasattr(kid, "hex") else str(kid).replace("-", "") for kid in self.content_keys} - if zero_kid not in existing_kids: - for key in self.content_keys.values(): - key_hex = key if isinstance(key, str) else key.hex() - key_args.extend(["--key", f"{zero_kid}:{key_hex}"]) - return key_args + The KID given to the constructor (from the manifest or the init segment) names the + track exactly. A PSSH can name the KIDs of other renditions too. + """ + if self._kid and self._kid in self.content_keys: + return [self._kid] + return self.kids + + def mp4decrypt_key_args(self) -> list[str]: + """Build the mp4decrypt --key arguments for every content key and the zero-KID fallback.""" + return drm_key_args.mp4decrypt_key_args(self.content_keys, self.own_kids()) def decrypt_with_mp4decrypt(self, path: Path) -> None: """Decrypt using mp4decrypt""" @@ -385,19 +382,7 @@ class Widevine: f"input={path},stream=0,output={output_path},output_format=MP4", "--enable_raw_key_decryption", "--keys", - ",".join( - [ - *[ - "label={}:key_id={}:key={}".format(i, kid.hex, key.lower()) - for i, (kid, key) in enumerate(self.content_keys.items()) - ], - *[ - # some services use a blank KID on the file, but real KID for license server - "label={}:key_id={}:key={}".format(i, "00" * 16, key.lower()) - for i, (kid, key) in enumerate(self.content_keys.items(), len(self.content_keys)) - ], - ] - ), + drm_key_args.shaka_keys(self.content_keys, self.own_kids()), "--temp_dir", config.directories.temp, ]