From 84bb07f589125e20e3d96c636a912c27b99a9209 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Wed, 23 Sep 2026 14:33:34 -0600 Subject: [PATCH] fix(import): stop clearly when no export or vault key exists The bad-key retry now requests each dropped KID. --- docs/guide/downloading.md | 4 + tests/core/test_import_manifest_data.py | 2 +- tests/core/test_import_missing_key.py | 153 ++++++++++++++++++++++++ unshackle/commands/dl.py | 49 ++++++-- unshackle/core/import_service.py | 27 ++++- 5 files changed, 218 insertions(+), 17 deletions(-) create mode 100644 tests/core/test_import_missing_key.py diff --git a/docs/guide/downloading.md b/docs/guide/downloading.md index e10943f2..408e8c15 100644 --- a/docs/guide/downloading.md +++ b/docs/guide/downloading.md @@ -792,6 +792,10 @@ file another person can use, select generously: for example `-a AAC,EC3` and eve you want them to have. An import can only pick tracks whose key is in the file. A subtitle that another tool exported as a direct URL is added next to the manifest's own. +An import uses only the content keys in the file and in your vaults. It never gets a licence +from a CDM. When a track needs a content key that neither has, the import stops and names the +KID. To fix this, export the title again with that track selected. + !!! warning "The export file is a secret" It holds the content keys and usually a signed manifest URL. Hand it over on purpose, not by accident. diff --git a/tests/core/test_import_manifest_data.py b/tests/core/test_import_manifest_data.py index dac53442..beb91232 100644 --- a/tests/core/test_import_manifest_data.py +++ b/tests/core/test_import_manifest_data.py @@ -222,5 +222,5 @@ def test_clearkey_license_hook_is_bindable(tmp_path: Path) -> None: service = make_service(tmp_path, AVC_URL, {}) bound = partial(service.get_clearkey_license, title=None, track=None) assert callable(bound) - with pytest.raises(RuntimeError, match="keys come from the export"): + with pytest.raises(click.ClickException, match="export has no content key"): bound(challenge=b"") diff --git a/tests/core/test_import_missing_key.py b/tests/core/test_import_missing_key.py new file mode 100644 index 00000000..75f25dce --- /dev/null +++ b/tests/core/test_import_missing_key.py @@ -0,0 +1,153 @@ +"""An import stops with a clear error when neither the export nor a vault has a content key. + +An import never licenses through a CDM. When dl asks it for a licence, it names the KIDs it +has no content key for, and dl shows that message without a traceback, also when the retry +after a failed vault key asks for the licence. +""" + +from __future__ import annotations + +from functools import partial +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from uuid import UUID + +import click +import pytest +from pywidevine.pssh import PSSH +from rich.table import Table + +from unshackle.commands import dl as dl_module +from unshackle.commands.dl import dl +from unshackle.core.drm import Widevine +from unshackle.core.import_service import ImportService +from unshackle.core.vaults import Vaults +from unshackle.vaults.SQLite import SQLite + +KID = UUID("11111111-2222-3333-4444-555555555555") +OTHER = UUID("66666666-7777-8888-9999-000000000000") +BAD = "aa" * 16 +OTHER_KEY = "cc" * 16 + + +class Remote(SQLite): + """A second SQLite file standing in for a remote vault.""" + + local = False + + +class FakeDRM: + """A track DRM with two KIDs; decrypting records the content keys it had.""" + + def __init__(self, keys: dict[UUID, str]) -> None: + self.kids = [KID, OTHER] + self.content_keys = dict(keys) + self.decrypted_with: list[dict[UUID, str]] = [] + + def decrypt(self, path: Path) -> None: + self.decrypted_with.append(dict(self.content_keys)) + out = path.with_stem(path.stem + "_decrypted") + out.write_bytes(b"plain:" + self.content_keys.get(KID, "none").encode()) + path.unlink() + out.rename(path) + + +class EmptyVaults: + sources: dict[UUID, Any] = {} + + def get_key(self, kid: UUID) -> tuple[None, None]: + return None, None + + +def make_cmd(monkeypatch: pytest.MonkeyPatch, vaults: Any) -> dl: + """A dl in server-CDM mode, the mode an import runs in, with a decoder that fails every file.""" + monkeypatch.setattr(dl_module.binaries, "FFMPEG", "ffmpeg") + monkeypatch.setattr(dl_module, "ffmpeg_decodes", lambda path, start=None, seconds=3, video=None: False) + cmd = dl.__new__(dl) + cmd.log = SimpleNamespace(warning=print, info=print, debug=print, error=print) + cmd.service = "EXAMPLE" + cmd.vaults = vaults + cmd.vault_cache_tally = None + cmd._remote_service = SimpleNamespace(_server_cdm=True, _server_cdm_type="widevine") + dl.LICENSE_KEY_CACHE.clear() + dl.DRM_LOCKS.clear() + return cmd + + +def import_licence(track: Any, calls: list[str]) -> Any: + """The licence dl binds for an import: service_licence calls the ImportService method.""" + + def licence(**kwargs: Any) -> None: + calls.append("licence") + raise ImportService.missing_key(track) + + return licence + + +def prepare_drm_for(cmd: dl, track: Any, calls: list[str]) -> Any: + table = Table.grid() + table.add_row("") + return partial( + cmd.prepare_drm, + track=track, + title=None, + licence=import_licence(track, calls), + certificate=None, + table=table, + ) + + +def test_the_error_names_the_kids_without_a_content_key() -> None: + drm = Widevine(pssh=PSSH.new(system_id=PSSH.SystemId.Widevine, key_ids=[KID, OTHER])) + drm.content_keys[OTHER] = OTHER_KEY + + error = ImportService.missing_key(SimpleNamespace(drm=[drm])) + + assert isinstance(error, click.ClickException) + assert KID.hex in error.message and OTHER.hex not in error.message + assert "no vault has it" in error.message + + +def test_the_server_cdm_branch_lets_the_import_error_through(monkeypatch: pytest.MonkeyPatch) -> None: + drm = FakeDRM({}) + track = SimpleNamespace(drm=[drm]) + cmd = make_cmd(monkeypatch, EmptyVaults()) + + with pytest.raises(click.ClickException, match=KID.hex): + prepare_drm_for(cmd, track, [])(drm, track_kid=None) + + +def test_the_retry_after_a_bad_vault_key_ends_in_the_import_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The track still holds another content key and has no track KID, so only the request for + the dropped KID reaches the licence. It must stop there, once, not decrypt without the key.""" + poisoned = Remote("poisoned", tmp_path / "poisoned.db") + poisoned.add_key("SVC", KID, BAD) + vaults = Vaults("SVC") + vaults.vaults = [SQLite("local", tmp_path / "local.db"), poisoned] + assert vaults.get_key(KID) == (BAD, poisoned) + cmd = make_cmd(monkeypatch, vaults) + drm = FakeDRM({KID: BAD, OTHER: OTHER_KEY}) + track = SimpleNamespace(drm=[drm]) + calls: list[str] = [] + path = tmp_path / "video.mp4" + path.write_bytes(b"cipher") + + with pytest.raises(click.ClickException, match=KID.hex): + cmd.decrypt_verified(drm, path, prepare_drm_for(cmd, track, calls), None) + cmd.wait_vault_writes() + + assert calls == ["licence"] + assert drm.decrypted_with == [{KID: BAD, OTHER: OTHER_KEY}] + assert not (tmp_path / "video.mp4.enc").exists() + + +def test_a_download_stopped_by_a_click_exception_reports_only_its_message() -> None: + error = ImportService.missing_key(SimpleNamespace(drm=[FakeDRM({})])) + + lines = dl.failure_lines(error) + + assert lines == [":x: Download Failed...", f" {error.format_message()}"] + assert "unexpected error" in dl.failure_lines(ValueError("boom"))[-1] diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 7ca03888..8c957353 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -3034,17 +3034,9 @@ class dl: # Reported and swallowed (no re-raise) so the CLI exits cleanly; flag it so the # API worker sees the title failed rather than completing with no output. self.download_failed = True - error_messages = [ - ":x: Download Failed...", - f" {type(e).__name__}: {e}", - ] - if hasattr(e, "returncode"): - error_messages.append(f" Binary call failed, Process exit code: {e.returncode}") - error_messages.append( - " An unexpected error occurred in one of the download workers.", - ) - console.print(Padding(Group(*error_messages), (1, 5))) - console.print_exception() + console.print(Padding(Group(*self.failure_lines(e)), (1, 5))) + if not isinstance(e, click.ClickException): + console.print_exception() if self.debug_logger: self.debug_logger.log_error( @@ -4084,6 +4076,21 @@ class dl: }, ) + @staticmethod + def failure_lines(e: BaseException) -> list[str]: + """Return the lines that report a failed download. + + A ClickException is a deliberate stop with a message for the user, so it gets no + "unexpected error" line, and the caller prints no traceback for it. + """ + if isinstance(e, click.ClickException): + return [":x: Download Failed...", f" {e.format_message()}"] + lines = [":x: Download Failed...", f" {type(e).__name__}: {e}"] + if (returncode := getattr(e, "returncode", None)) is not None: + lines.append(f" Binary call failed, Process exit code: {returncode}") + lines.append(" An unexpected error occurred in one of the download workers.") + return lines + def decrypt_verified( self, drm: DRM_T, @@ -4126,11 +4133,25 @@ class dl: self.LICENSE_KEY_CACHE.pop(kid) return key + def relicense(dropped: set[UUID]) -> None: + """Licence again, then ask for each dropped KID that is still without a content key. + + prepare_drm licenses only for a KID it is told to need, so without the second call a + track that still holds other content keys would decrypt without the dropped one. + """ + if not licence: + return + licence(drm, track_kid=track_kid) + for kid in sorted(dropped): + # prepare_drm can replace the dict, so read it from the DRM each time + if kid != track_kid and kid not in getattr(drm, "content_keys", {}): + licence(drm, track_kid=kid) + known_bad = flagged_kids() if known_bad and licence: for kid in known_bad: drop(kid) - licence(drm, track_kid=track_kid) + relicense(set(known_bad)) def vault_kids() -> dict[UUID, Vault]: """The KIDs whose key on the track came from a vault, with that vault.""" @@ -4209,7 +4230,7 @@ class dl: raise ValueError("The content key from the vault did not decrypt the track; run again") path.unlink() os.link(backup, path) - licence(drm, track_kid=track_kid) + relicense(set(bad)) for kid, key in bad.items(): if keys.get(kid) != key: continue @@ -4390,6 +4411,8 @@ class dl: drm_system="playready" if drm.__class__.__name__ == "PlayReady" else "widevine", challenge=b"", ) + except click.ClickException: + raise except Exception as e: self.log.debug(f"Server CDM licence with an empty challenge failed: {e!r}") diff --git a/unshackle/core/import_service.py b/unshackle/core/import_service.py index 541732fc..cbb0f4b8 100644 --- a/unshackle/core/import_service.py +++ b/unshackle/core/import_service.py @@ -558,17 +558,38 @@ class ImportService: return None def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]: - raise RuntimeError("ImportService should not request a license; keys come from the export.") + raise self.missing_key(track) def get_playready_license( self, *, challenge: bytes, title: Title_T, track: AnyTrack ) -> Optional[Union[bytes, str]]: - raise RuntimeError("ImportService should not request a license; keys come from the export.") + raise self.missing_key(track) def get_clearkey_license( self, *, challenge: bytes, title: Title_T, track: AnyTrack ) -> Optional[Union[bytes, str, dict]]: - raise RuntimeError("ImportService should not request a license; keys come from the export.") + raise self.missing_key(track) + + @staticmethod + def missing_key(track: AnyTrack) -> click.ClickException: + """Return the error for a content key that neither the export nor a vault holds. + + An import uses only the content keys in the export and in the vaults, and never + licenses through a CDM. dl then asks for a licence, and this error stops the download. + """ + kids = sorted( + { + kid.hex + for drm in getattr(track, "drm", None) or [] + for kid in real_kids(drm) + if kid not in (getattr(drm, "content_keys", None) or {}) + } + ) + subject = f"KID {', '.join(kids)}" if kids else f"track {track}" + return click.ClickException( + f"The export has no content key for {subject}, and no vault has it. An import does not " + "license through a CDM: export the title again with the tracks you need selected." + ) def on_segment_downloaded(self, track: AnyTrack, segment: Any) -> None: pass