mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 13:05:09 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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"")
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user