refactor(drm): share one CENC decrypt path across Widevine, PlayReady and ClearKey

This commit is contained in:
imSp4rky
2026-11-12 13:38:40 -07:00
parent b48da2a16a
commit f873ea1fb8
7 changed files with 275 additions and 464 deletions
+74 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import io
import subprocess
import sys
from pathlib import Path
@@ -14,9 +15,11 @@ from tests.test_segment_decrypt import read_argv
from unshackle.core import binaries
from unshackle.core.config import config
from unshackle.core.drm import ClearKeyCENC
from unshackle.core.drm import decrypters as decrypters_module
from unshackle.core.drm.decrypters import mp4decrypt_or_shaka
from unshackle.core.drm.key_args import shaka_noise, track_key_args
from unshackle.core.drm.segment_decrypt import SegmentDecrypter, can_use
from unshackle.core.drm.verify import kidless_track_kids, mp4decrypt_or_shaka
from unshackle.core.drm.verify import kidless_track_kids
KEY = "b5df2ae3f63b8dda9049d6e41422e075"
OTHER_KEY = "11" * 16
@@ -219,6 +222,76 @@ def test_shaka_non_zero_exit_raises(
drm.decrypt_with_shaka_packager(path)
class FakePackager:
"""A shaka-packager Popen that records its argv, prints ``log`` and exits with ``code``."""
code = 0
log = ""
argv: list[str] = []
def __init__(self, cmd: list[Any], **kwargs: Any) -> None:
FakePackager.argv = [str(c) for c in cmd]
self.stderr = io.StringIO(FakePackager.log)
self.returncode = FakePackager.code
def wait(self) -> int:
return self.returncode
@pytest.fixture
def fake_packager(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> list[str]:
"""Route shaka-packager to ``FakePackager`` and collect what the console prints."""
printed: list[str] = []
FakePackager.code, FakePackager.log = 0, ""
monkeypatch.setattr(decrypters_module.subprocess, "Popen", FakePackager)
monkeypatch.setattr(binaries, "ShakaPackager", Path("packager"))
monkeypatch.setattr(config.directories, "temp", tmp_path)
monkeypatch.setattr(decrypters_module.console, "log", lambda text: printed.append(str(text)))
return printed
def shaka_run(tmp_path: Path, make: Callable[[], Any], raises: type[BaseException]) -> None:
"""Decrypt a one-key track of ``make()`` through ``FakePackager`` and expect ``raises``."""
path = tmp_path / "track.mp4"
path.write_bytes(b"encrypted")
drm = make()
drm.content_keys[A] = KEY
with pytest.raises(raises):
drm.decrypt_with_shaka_packager(path)
@pytest.mark.parametrize("make", DRMS)
def test_every_drm_passes_the_fake_clock(tmp_path: Path, fake_packager: list[str], make: Callable[[], Any]) -> None:
shaka_run(tmp_path, make, FileNotFoundError)
assert "--use_fake_clock_for_muxer" in FakePackager.argv
def test_clearkey_gives_no_zero_kid_fallback(tmp_path: Path, fake_packager: list[str]) -> None:
shaka_run(tmp_path, lambda: ClearKeyCENC(kids=[A]), FileNotFoundError)
assert FakePackager.argv[FakePackager.argv.index("--keys") + 1] == f"label=0:key_id={A.hex}:key={KEY}"
@pytest.mark.parametrize(
("make", "label"),
[
pytest.param(lambda: _widevine(A), "Widevine", id="widevine"),
pytest.param(lambda: _playready(A), "PlayReady", id="playready"),
pytest.param(lambda: ClearKeyCENC(kids=[A]), "ClearKey", id="clearkey"),
],
)
def test_a_shaka_failure_logs_under_the_drm_label(
tmp_path: Path, fake_packager: list[str], make: Callable[[], Any], label: str
) -> None:
FakePackager.code, FakePackager.log = 1, "[1103/153702:ERROR:main.cc(1)] Bad key\n"
shaka_run(tmp_path, make, subprocess.CalledProcessError)
assert len(fake_packager) == 1 and f"[{label}]: " in fake_packager[0] and "Bad key" in fake_packager[0]
def test_a_shaka_ctrl_c_exit_is_a_keyboard_interrupt(tmp_path: Path, fake_packager: list[str]) -> None:
FakePackager.code = 0xC000013A
shaka_run(tmp_path, lambda: _widevine(A), KeyboardInterrupt)
@pytest.mark.parametrize(
("line", "noise"),
[
+4 -4
View File
@@ -99,7 +99,7 @@ def test_several_keys_and_no_single_own_kid_give_no_zero_kid_entry(make) -> None
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
from unshackle.core.drm import decrypters as decrypters_module
seen: list[list[str]] = []
@@ -110,9 +110,9 @@ def test_shaka_decrypt_passes_the_built_keys(monkeypatch: pytest.MonkeyPatch, tm
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)
monkeypatch.setattr(decrypters_module.subprocess, "Popen", fake_popen)
monkeypatch.setattr(decrypters_module.binaries, "ShakaPackager", "shaka-packager")
monkeypatch.setattr(decrypters_module.config.directories, "temp", tmp_path)
drm = _with_keys(_widevine(VIDEO_KID), BOTH_KEYS)
media = tmp_path / "track.mp4"
media.write_bytes(b"x")
+11 -152
View File
@@ -2,22 +2,15 @@ from __future__ import annotations
import base64
import json
import shutil
import subprocess
import textwrap
import time
from pathlib import Path
from typing import Any, Callable, Iterable, Optional, Union
from uuid import UUID
from requests import Session
from rich.text import Text
from unshackle.core import binaries
from unshackle.core.config import config
from unshackle.core.console import console
from unshackle.core.drm import key_args as drm_key_args
from unshackle.core.drm.verify import decrypter_for, kidless_track_kids, mp4decrypt_or_shaka, read_top_level_box
from unshackle.core.drm.decrypters import decrypt_cenc, mp4decrypt, shaka_decrypt
from unshackle.core.utilities import log_event
from unshackle.core.utils import run_proxy
@@ -181,50 +174,8 @@ class ClearKeyCENC:
)
def decrypt(self, path: Path) -> None:
"""
Decrypt a Track with ClearKey DRM (standard CENC).
Args:
path: Path to the encrypted file to decrypt
Raises:
EnvironmentError if the required decryption executable could not be found.
ValueError if unshackle has not yet downloaded the track.
SubprocessError if the decryption process returned a non-zero exit code.
"""
if not self.content_keys:
raise ValueError("Cannot decrypt a Track without any Content Keys...")
if not path or not path.exists():
raise ValueError("Tried to decrypt a file that does not exist.")
decrypter = decrypter_for(path)
tool = "mp4decrypt" if decrypter == "mp4decrypt" else "shaka-packager"
log_event(
"drm_decrypt",
level="DEBUG",
message=f"Decrypting {path.name} with {tool}",
drm_type="ClearKeyCENC",
tool=tool,
file=path.name,
key_count=len(self.content_keys),
)
decrypt_start = time.monotonic()
if decrypter == "mp4decrypt":
mp4decrypt_or_shaka(self, path)
else:
self.decrypt_with_shaka_packager(path)
log_event(
"drm_decrypt_complete",
level="DEBUG",
message=f"Decrypted {path.name} with {tool}",
drm_type="ClearKeyCENC",
tool=tool,
file=path.name,
duration_ms=round((time.monotonic() - decrypt_start) * 1000, 1),
output_size=path.stat().st_size if path.exists() else 0,
)
"""Decrypt the file at ``path`` in place with this track's ClearKey (standard CENC) content keys."""
decrypt_cenc(self, path, "ClearKeyCENC")
def mp4decrypt_key_args(self, track_kids: Optional[dict[int, UUID]] = None) -> list[str]:
"""Build the mp4decrypt --key arguments for every content key, and by track ID for each ``track_kids`` KID."""
@@ -234,108 +185,16 @@ class ClearKeyCENC:
return key_args + drm_key_args.track_key_args(self.content_keys, track_kids)
def decrypt_with_mp4decrypt(self, path: Path) -> None:
"""Decrypt using mp4decrypt"""
if not binaries.Mp4decrypt:
raise EnvironmentError("mp4decrypt executable not found but is required.")
output_path = path.with_stem(f"{path.stem}_decrypted")
key_args = self.mp4decrypt_key_args(kidless_track_kids(read_top_level_box(path, b"moov") or b""))
cmd = [
str(binaries.Mp4decrypt),
"--show-progress",
*key_args,
str(path),
str(output_path),
]
try:
subprocess.run(
cmd,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
)
except subprocess.CalledProcessError as e:
error_msg = e.stderr if e.stderr else f"mp4decrypt failed with exit code {e.returncode}"
raise subprocess.CalledProcessError(e.returncode, cmd, output=e.stdout, stderr=error_msg)
if not output_path.exists():
raise RuntimeError(f"mp4decrypt failed: output file {output_path} was not created")
if output_path.stat().st_size == 0:
raise RuntimeError(f"mp4decrypt failed: output file {output_path} is empty")
path.unlink()
shutil.move(output_path, path)
"""Decrypt with mp4decrypt."""
mp4decrypt(path, self.mp4decrypt_key_args)
def decrypt_with_shaka_packager(self, path: Path) -> None:
"""Decrypt with shaka-packager"""
if not binaries.ShakaPackager:
raise EnvironmentError("Shaka Packager executable not found but is required.")
output_path = path.with_stem(f"{path.stem}_decrypted")
config.directories.temp.mkdir(parents=True, exist_ok=True)
try:
arguments: list[str | Path] = [
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())
),
"--use_fake_clock_for_muxer",
"--temp_dir",
config.directories.temp,
]
p = subprocess.Popen(
[binaries.ShakaPackager, *arguments],
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
universal_newlines=True,
encoding="utf-8",
errors="replace",
)
stream_skipped = False
shaka_log_buffer = ""
assert p.stderr is not None
for line in iter(p.stderr.readline, ""):
line = line.strip()
if not line:
continue
if "Skip stream" in line:
# file/segment was so small that it didn't have any actual data, ignore
stream_skipped = True
if drm_key_args.shaka_noise(line):
continue
shaka_log_buffer += f"{line.strip()}\n"
p.wait()
if p.returncode != 0:
if shaka_log_buffer:
# wrap to console width - padding - '[ClearKey]: '
shaka_log_buffer = "\n ".join(
textwrap.wrap(shaka_log_buffer.rstrip(), width=console.width - 22, initial_indent="")
)
console.log(Text.from_ansi("\n[ClearKey]: " + shaka_log_buffer))
raise subprocess.CalledProcessError(p.returncode, [binaries.ShakaPackager, *arguments])
path.unlink()
if not stream_skipped:
shutil.move(output_path, path)
except subprocess.CalledProcessError as e:
if e.returncode == 0xC000013A: # STATUS_CONTROL_C_EXIT
raise KeyboardInterrupt()
raise
"""Decrypt with shaka-packager."""
keys = ",".join(
"label={}:key_id={}:key={}".format(i, kid.hex, key.lower())
for i, (kid, key) in enumerate(self.content_keys.items())
)
shaka_decrypt(path, keys, "ClearKey")
class Exceptions:
class KIDNotFound(Exception):
+172
View File
@@ -0,0 +1,172 @@
"""Whole-file CENC decryption with mp4decrypt or shaka-packager, shared by Widevine, PlayReady and ClearKeyCENC."""
import logging
import shutil
import subprocess
import textwrap
from pathlib import Path
from time import monotonic
from typing import Any, Callable
from uuid import UUID
from rich.text import Text
from unshackle.core import binaries
from unshackle.core.config import config
from unshackle.core.console import console
from unshackle.core.drm.key_args import shaka_noise
from unshackle.core.drm.verify import (
decrypter_for,
has_encrypted_sample_entry,
kidless_track_kids,
read_top_level_box,
)
from unshackle.core.utilities import log_event
log = logging.getLogger(__name__)
def mp4decrypt_or_shaka(drm: Any, path: Path) -> None:
"""Decrypt ``path`` with mp4decrypt, and again with shaka-packager when mp4decrypt copied it through.
mp4decrypt copies a track it has no content key for unchanged, so its first moof stays the same.
"""
first_moof = read_top_level_box(path, b"moof")
drm.decrypt_with_mp4decrypt(path)
if has_encrypted_sample_entry(path) and read_top_level_box(path, b"moof") == first_moof:
log.warning(f"mp4decrypt left {path.name} encrypted, decrypting it with shaka-packager")
drm.decrypt_with_shaka_packager(path)
def decrypt_cenc(drm: Any, path: Path, drm_type: str) -> None:
"""Decrypt ``path`` in place with the decrypter ``decrypter_for`` picks, using the content keys of ``drm``."""
if not drm.content_keys:
raise ValueError("Cannot decrypt a Track without any Content Keys...")
if not path or not path.exists():
raise ValueError("Tried to decrypt a file that does not exist.")
decrypter = decrypter_for(path)
tool = "mp4decrypt" if decrypter == "mp4decrypt" else "shaka-packager"
log_event(
"drm_decrypt",
level="DEBUG",
message=f"Decrypting {path.name} with {tool}",
drm_type=drm_type,
tool=tool,
file=path.name,
key_count=len(drm.content_keys),
)
decrypt_start = monotonic()
if decrypter == "mp4decrypt":
mp4decrypt_or_shaka(drm, path)
else:
drm.decrypt_with_shaka_packager(path)
log_event(
"drm_decrypt_complete",
level="DEBUG",
message=f"Decrypted {path.name} with {tool}",
drm_type=drm_type,
tool=tool,
file=path.name,
duration_ms=round((monotonic() - decrypt_start) * 1000, 1),
output_size=path.stat().st_size if path.exists() else 0,
)
def mp4decrypt(path: Path, key_args_for: Callable[[dict[int, UUID]], list[str]]) -> None:
"""Decrypt ``path`` in place with mp4decrypt, taking the ``--key`` arguments from ``key_args_for(track_kids)``."""
if not binaries.Mp4decrypt:
raise EnvironmentError("mp4decrypt executable not found but is required.")
output_path = path.with_stem(f"{path.stem}_decrypted")
key_args = key_args_for(kidless_track_kids(read_top_level_box(path, b"moov") or b""))
cmd = [str(binaries.Mp4decrypt), "--show-progress", *key_args, str(path), str(output_path)]
try:
subprocess.run(
cmd,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
)
except subprocess.CalledProcessError as e:
error_msg = e.stderr if e.stderr else f"mp4decrypt failed with exit code {e.returncode}"
raise subprocess.CalledProcessError(e.returncode, cmd, output=e.stdout, stderr=error_msg)
if not output_path.exists():
raise RuntimeError(f"mp4decrypt failed: output file {output_path} was not created")
if output_path.stat().st_size == 0:
raise RuntimeError(f"mp4decrypt failed: output file {output_path} is empty")
path.unlink()
shutil.move(output_path, path)
def shaka_decrypt(path: Path, keys: str, label: str) -> None:
"""Decrypt ``path`` in place with shaka-packager and its ``--keys`` value ``keys``.
Only a non-zero exit is a failure; its non-noise log lines go to the console under ``[label]``.
"""
if not binaries.ShakaPackager:
raise EnvironmentError("Shaka Packager executable not found but is required.")
output_path = path.with_stem(f"{path.stem}_decrypted")
config.directories.temp.mkdir(parents=True, exist_ok=True)
arguments: list[str | Path] = [
f"input={path},stream=0,output={output_path},output_format=MP4",
"--enable_raw_key_decryption",
"--keys",
keys,
"--use_fake_clock_for_muxer",
"--temp_dir",
config.directories.temp,
]
try:
p = subprocess.Popen(
[binaries.ShakaPackager, *arguments],
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
)
stream_skipped = False
shaka_log_buffer = ""
assert p.stderr is not None
for line in iter(p.stderr.readline, ""):
line = line.strip()
if not line:
continue
if "Skip stream" in line:
# file/segment was so small that it didn't have any actual data, ignore
stream_skipped = True
if shaka_noise(line):
continue
shaka_log_buffer += f"{line}\n"
p.wait()
if p.returncode != 0:
if shaka_log_buffer:
shaka_log_buffer = "\n ".join(
textwrap.wrap(shaka_log_buffer.rstrip(), width=console.width - 22, initial_indent="")
)
console.log(Text.from_ansi(f"\n[{label}]: " + shaka_log_buffer))
raise subprocess.CalledProcessError(p.returncode, [binaries.ShakaPackager, *arguments])
path.unlink()
if not stream_skipped:
shutil.move(output_path, path)
except subprocess.CalledProcessError as e:
if e.returncode == 0xC000013A: # STATUS_CONTROL_C_EXIT
raise KeyboardInterrupt()
raise
+7 -146
View File
@@ -2,10 +2,6 @@ from __future__ import annotations
import base64
import re
import shutil
import subprocess
import textwrap
import time
from pathlib import Path
from threading import Lock
from typing import Any, Callable, Optional, Union
@@ -18,14 +14,11 @@ 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 rich.text import Text
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.drm.verify import decrypter_for, kidless_track_kids, mp4decrypt_or_shaka, read_top_level_box
from unshackle.core.drm.decrypters import decrypt_cenc, mp4decrypt, shaka_decrypt
from unshackle.core.session import AnySession, utf8_text
from unshackle.core.utilities import call_licence, get_boxes, log_event
from unshackle.core.utils import run_proxy
@@ -542,50 +535,8 @@ class PlayReady:
return None
def decrypt(self, path: Path) -> None:
"""
Decrypt a Track with PlayReady DRM.
Args:
path: Path to the encrypted file to decrypt
Raises:
EnvironmentError if the required decryption executable could not be found.
ValueError if unshackle has not yet downloaded the track.
SubprocessError if the decryption process returned a non-zero exit code.
"""
if not self.content_keys:
raise ValueError("Cannot decrypt a Track without any Content Keys...")
if not path or not path.exists():
raise ValueError("Tried to decrypt a file that does not exist.")
decrypter = decrypter_for(path)
tool = "mp4decrypt" if decrypter == "mp4decrypt" else "shaka-packager"
log_event(
"drm_decrypt",
level="DEBUG",
message=f"Decrypting {path.name} with {tool}",
drm_type="PlayReady",
tool=tool,
file=path.name,
key_count=len(self.content_keys),
)
decrypt_start = time.monotonic()
if decrypter == "mp4decrypt":
mp4decrypt_or_shaka(self, path)
else:
self.decrypt_with_shaka_packager(path)
log_event(
"drm_decrypt_complete",
level="DEBUG",
message=f"Decrypted {path.name} with {tool}",
drm_type="PlayReady",
tool=tool,
file=path.name,
duration_ms=round((time.monotonic() - decrypt_start) * 1000, 1),
output_size=path.stat().st_size if path.exists() else 0,
)
"""Decrypt the file at ``path`` in place with this track's PlayReady content keys."""
decrypt_cenc(self, path, "PlayReady")
def own_kids(self) -> list[UUID]:
"""Return the KIDs that identify this track's own content key.
@@ -602,102 +553,12 @@ class PlayReady:
return drm_key_args.mp4decrypt_key_args(self.content_keys, self.own_kids(), track_kids)
def decrypt_with_mp4decrypt(self, path: Path) -> None:
"""Decrypt using mp4decrypt"""
if not binaries.Mp4decrypt:
raise EnvironmentError("mp4decrypt executable not found but is required.")
output_path = path.with_stem(f"{path.stem}_decrypted")
key_args = self.mp4decrypt_key_args(kidless_track_kids(read_top_level_box(path, b"moov") or b""))
cmd = [
str(binaries.Mp4decrypt),
"--show-progress",
*key_args,
str(path),
str(output_path),
]
try:
subprocess.run(
cmd,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
)
except subprocess.CalledProcessError as e:
error_msg = e.stderr if e.stderr else f"mp4decrypt failed with exit code {e.returncode}"
raise subprocess.CalledProcessError(e.returncode, cmd, output=e.stdout, stderr=error_msg)
if not output_path.exists():
raise RuntimeError(f"mp4decrypt failed: output file {output_path} was not created")
if output_path.stat().st_size == 0:
raise RuntimeError(f"mp4decrypt failed: output file {output_path} is empty")
path.unlink()
shutil.move(output_path, path)
"""Decrypt with mp4decrypt."""
mp4decrypt(path, self.mp4decrypt_key_args)
def decrypt_with_shaka_packager(self, path: Path) -> None:
"""Decrypt with shaka-packager (original method)"""
if not binaries.ShakaPackager:
raise EnvironmentError("Shaka Packager executable not found but is required.")
output_path = path.with_stem(f"{path.stem}_decrypted")
config.directories.temp.mkdir(parents=True, exist_ok=True)
try:
arguments: list[str | Path] = [
f"input={path},stream=0,output={output_path},output_format=MP4",
"--enable_raw_key_decryption",
"--keys",
drm_key_args.shaka_keys(self.content_keys, self.own_kids()),
"--use_fake_clock_for_muxer",
"--temp_dir",
config.directories.temp,
]
p = subprocess.Popen(
[binaries.ShakaPackager, *arguments],
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
)
stream_skipped = False
shaka_log_buffer = ""
assert p.stderr is not None
for line in iter(p.stderr.readline, ""):
line = line.strip()
if not line:
continue
if "Skip stream" in line:
stream_skipped = True
if drm_key_args.shaka_noise(line):
continue
shaka_log_buffer += f"{line.strip()}\n"
p.wait()
if p.returncode != 0:
if shaka_log_buffer:
shaka_log_buffer = "\n ".join(
textwrap.wrap(shaka_log_buffer.rstrip(), width=console.width - 22, initial_indent="")
)
console.log(Text.from_ansi("\n[PlayReady]: " + shaka_log_buffer))
raise subprocess.CalledProcessError(p.returncode, [binaries.ShakaPackager, *arguments])
path.unlink()
if not stream_skipped:
shutil.move(output_path, path)
except subprocess.CalledProcessError as e:
if e.returncode == 0xC000013A:
raise KeyboardInterrupt()
raise
"""Decrypt with shaka-packager."""
shaka_decrypt(path, drm_key_args.shaka_keys(self.content_keys, self.own_kids()), "PlayReady")
class Exceptions:
class PSSHNotFound(Exception):
-12
View File
@@ -254,18 +254,6 @@ def decrypter_for(path: Path) -> str:
return configured
def mp4decrypt_or_shaka(drm: Any, path: Path) -> None:
"""Decrypt ``path`` with mp4decrypt, and again with shaka-packager when mp4decrypt copied it through.
mp4decrypt copies a track it has no content key for unchanged, so its first moof stays the same.
"""
first_moof = read_top_level_box(path, b"moof")
drm.decrypt_with_mp4decrypt(path)
if has_encrypted_sample_entry(path) and read_top_level_box(path, b"moof") == first_moof:
log.warning(f"mp4decrypt left {path.name} encrypted, decrypting it with shaka-packager")
drm.decrypt_with_shaka_packager(path)
def senc_protects_samples(buf: bytes, body: int, end: int) -> bool:
"""True if a senc/PIFF-uuid box describes protected samples.
+7 -149
View File
@@ -1,10 +1,6 @@
from __future__ import annotations
import base64
import shutil
import subprocess
import textwrap
import time
from pathlib import Path
from typing import Any, Callable, Optional, Union
from uuid import UUID
@@ -14,14 +10,11 @@ from construct import Container
from pymp4.parser import Box
from pywidevine.cdm import Cdm as WidevineCdm
from pywidevine.pssh import PSSH
from rich.text import Text
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.drm.verify import decrypter_for, kidless_track_kids, mp4decrypt_or_shaka, read_top_level_box
from unshackle.core.drm.decrypters import decrypt_cenc, mp4decrypt, shaka_decrypt
from unshackle.core.session import AnySession, utf8_text
from unshackle.core.utilities import call_licence, get_boxes, log_event
from unshackle.core.utils import run_proxy
@@ -292,50 +285,8 @@ class Widevine:
cdm.close(session_id)
def decrypt(self, path: Path) -> None:
"""
Decrypt a Track with Widevine DRM.
Args:
path: Path to the encrypted file to decrypt
Raises:
EnvironmentError if the required decryption executable could not be found.
ValueError if unshackle has not yet downloaded the track.
SubprocessError if the decryption process returned a non-zero exit code.
"""
if not self.content_keys:
raise ValueError("Cannot decrypt a Track without any Content Keys...")
if not path or not path.exists():
raise ValueError("Tried to decrypt a file that does not exist.")
decrypter = decrypter_for(path)
tool = "mp4decrypt" if decrypter == "mp4decrypt" else "shaka-packager"
log_event(
"drm_decrypt",
level="DEBUG",
message=f"Decrypting {path.name} with {tool}",
drm_type="Widevine",
tool=tool,
file=path.name,
key_count=len(self.content_keys),
)
decrypt_start = time.monotonic()
if decrypter == "mp4decrypt":
mp4decrypt_or_shaka(self, path)
else:
self.decrypt_with_shaka_packager(path)
log_event(
"drm_decrypt_complete",
level="DEBUG",
message=f"Decrypted {path.name} with {tool}",
drm_type="Widevine",
tool=tool,
file=path.name,
duration_ms=round((time.monotonic() - decrypt_start) * 1000, 1),
output_size=path.stat().st_size if path.exists() else 0,
)
"""Decrypt the file at ``path`` in place with this track's Widevine content keys."""
decrypt_cenc(self, path, "Widevine")
def own_kids(self) -> list[UUID]:
"""Return the KIDs that identify this track's own content key.
@@ -352,105 +303,12 @@ class Widevine:
return drm_key_args.mp4decrypt_key_args(self.content_keys, self.own_kids(), track_kids)
def decrypt_with_mp4decrypt(self, path: Path) -> None:
"""Decrypt using mp4decrypt"""
if not binaries.Mp4decrypt:
raise EnvironmentError("mp4decrypt executable not found but is required.")
output_path = path.with_stem(f"{path.stem}_decrypted")
key_args = self.mp4decrypt_key_args(kidless_track_kids(read_top_level_box(path, b"moov") or b""))
cmd = [
str(binaries.Mp4decrypt),
"--show-progress",
*key_args,
str(path),
str(output_path),
]
try:
subprocess.run(
cmd,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
)
except subprocess.CalledProcessError as e:
error_msg = e.stderr if e.stderr else f"mp4decrypt failed with exit code {e.returncode}"
raise subprocess.CalledProcessError(e.returncode, cmd, output=e.stdout, stderr=error_msg)
if not output_path.exists():
raise RuntimeError(f"mp4decrypt failed: output file {output_path} was not created")
if output_path.stat().st_size == 0:
raise RuntimeError(f"mp4decrypt failed: output file {output_path} is empty")
path.unlink()
shutil.move(output_path, path)
"""Decrypt with mp4decrypt."""
mp4decrypt(path, self.mp4decrypt_key_args)
def decrypt_with_shaka_packager(self, path: Path) -> None:
"""Decrypt with shaka-packager (original method)"""
if not binaries.ShakaPackager:
raise EnvironmentError("Shaka Packager executable not found but is required.")
output_path = path.with_stem(f"{path.stem}_decrypted")
config.directories.temp.mkdir(parents=True, exist_ok=True)
try:
arguments: list[str | Path] = [
f"input={path},stream=0,output={output_path},output_format=MP4",
"--enable_raw_key_decryption",
"--keys",
drm_key_args.shaka_keys(self.content_keys, self.own_kids()),
"--use_fake_clock_for_muxer",
"--temp_dir",
config.directories.temp,
]
p = subprocess.Popen(
[binaries.ShakaPackager, *arguments],
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
universal_newlines=True,
encoding="utf-8",
errors="replace",
)
stream_skipped = False
shaka_log_buffer = ""
assert p.stderr is not None
for line in iter(p.stderr.readline, ""):
line = line.strip()
if not line:
continue
if "Skip stream" in line:
# file/segment was so small that it didn't have any actual data, ignore
stream_skipped = True
if drm_key_args.shaka_noise(line):
continue
shaka_log_buffer += f"{line.strip()}\n"
p.wait()
if p.returncode != 0:
if shaka_log_buffer:
# wrap to console width - padding - '[Widevine]: '
shaka_log_buffer = "\n ".join(
textwrap.wrap(shaka_log_buffer.rstrip(), width=console.width - 22, initial_indent="")
)
console.log(Text.from_ansi("\n[Widevine]: " + shaka_log_buffer))
raise subprocess.CalledProcessError(p.returncode, [binaries.ShakaPackager, *arguments])
path.unlink()
if not stream_skipped:
shutil.move(output_path, path)
except subprocess.CalledProcessError as e:
if e.returncode == 0xC000013A: # STATUS_CONTROL_C_EXIT
raise KeyboardInterrupt()
raise
"""Decrypt with shaka-packager."""
shaka_decrypt(path, drm_key_args.shaka_keys(self.content_keys, self.own_kids()), "Widevine")
class Exceptions:
class PSSHNotFound(Exception):