fix(import): stop clearly when no export or vault key exists

The bad-key retry now requests each dropped KID.
This commit is contained in:
imSp4rky
2026-09-23 14:33:34 -06:00
parent fc26297ff0
commit 84bb07f589
5 changed files with 218 additions and 17 deletions
+4
View File
@@ -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.
+1 -1
View File
@@ -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"")
+153
View File
@@ -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]
+36 -13
View File
@@ -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}")
+24 -3
View File
@@ -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