fix(drm): give the zero-KID fallback only the track's own key

mp4decrypt and shaka use the last key for a repeated KID.
This commit is contained in:
imSp4rky
2026-09-23 14:27:30 -06:00
parent f470eed703
commit 47f30668ee
5 changed files with 228 additions and 58 deletions
+131
View File
@@ -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'<KID ALGID="AESCTR" VALUE="{base64.b64encode(k.bytes_le).decode()}"></KID>' for k in kids)
header = (
'<WRMHEADER xmlns="http://schemas.microsoft.com/DRM/2007/03/PlayReadyHeader" version="4.3.0.0">'
f"<DATA><PROTECTINFO><KIDS>{kid_xml}</KIDS></PROTECTINFO></DATA></WRMHEADER>"
).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()) == []
+5 -1
View File
@@ -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}")
+62
View File
@@ -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)
+16 -28
View File
@@ -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,
]
+14 -29
View File
@@ -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,
]