From f873ea1fb822f7d8b4c06402b68ec4b8d14e5df1 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Thu, 12 Nov 2026 13:38:40 -0700 Subject: [PATCH] refactor(drm): share one CENC decrypt path across Widevine, PlayReady and ClearKey --- tests/core/test_mp4decrypt_track_key.py | 75 ++++++++++- tests/core/test_zero_kid_fallback.py | 8 +- unshackle/core/drm/clearkey_cenc.py | 163 ++-------------------- unshackle/core/drm/decrypters.py | 172 ++++++++++++++++++++++++ unshackle/core/drm/playready.py | 153 +-------------------- unshackle/core/drm/verify.py | 12 -- unshackle/core/drm/widevine.py | 156 +-------------------- 7 files changed, 275 insertions(+), 464 deletions(-) create mode 100644 unshackle/core/drm/decrypters.py diff --git a/tests/core/test_mp4decrypt_track_key.py b/tests/core/test_mp4decrypt_track_key.py index afc052e4..aab1bb68 100644 --- a/tests/core/test_mp4decrypt_track_key.py +++ b/tests/core/test_mp4decrypt_track_key.py @@ -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"), [ diff --git a/tests/core/test_zero_kid_fallback.py b/tests/core/test_zero_kid_fallback.py index 407d5d66..a91c3add 100644 --- a/tests/core/test_zero_kid_fallback.py +++ b/tests/core/test_zero_kid_fallback.py @@ -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") diff --git a/unshackle/core/drm/clearkey_cenc.py b/unshackle/core/drm/clearkey_cenc.py index f161ebf2..543aa178 100644 --- a/unshackle/core/drm/clearkey_cenc.py +++ b/unshackle/core/drm/clearkey_cenc.py @@ -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): diff --git a/unshackle/core/drm/decrypters.py b/unshackle/core/drm/decrypters.py new file mode 100644 index 00000000..837c4603 --- /dev/null +++ b/unshackle/core/drm/decrypters.py @@ -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 diff --git a/unshackle/core/drm/playready.py b/unshackle/core/drm/playready.py index 328d99ba..29b2fa08 100644 --- a/unshackle/core/drm/playready.py +++ b/unshackle/core/drm/playready.py @@ -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): diff --git a/unshackle/core/drm/verify.py b/unshackle/core/drm/verify.py index 2706d39c..394941d0 100644 --- a/unshackle/core/drm/verify.py +++ b/unshackle/core/drm/verify.py @@ -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. diff --git a/unshackle/core/drm/widevine.py b/unshackle/core/drm/widevine.py index 7d96c8fd..68ed69be 100644 --- a/unshackle/core/drm/widevine.py +++ b/unshackle/core/drm/widevine.py @@ -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):