mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-13 12:35:05 +00:00
refactor(drm): share one CENC decrypt path across Widevine, PlayReady and ClearKey
This commit is contained in:
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user