mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 21:15:06 +00:00
fix(vaults): stop false bad-key flags from a failed decode check
Judge a vault KID only on a file whose KID map holds it, tell remote vaults of a bad pair only once another key replaces it, and let the server CDM unflag a pair it returns; the server never flags a CDM-licensed pair. Log FFmpeg stderr on a failed check and name the missing CDM when none is set.
This commit is contained in:
@@ -287,6 +287,26 @@ def test_cdm_returning_the_flagged_key_clears_the_flag(tmp_path: Path, monkeypat
|
||||
assert not local.is_bad_key(KID, BAD)
|
||||
|
||||
|
||||
def test_a_licence_key_that_replaces_a_bad_local_pair_is_stored(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The local vault held the bad pair when the licence key arrived, so it skipped the KID; store it now."""
|
||||
local = SQLite("local", tmp_path / "local.db")
|
||||
local.add_key("SVC", KID, BAD)
|
||||
vaults = Vaults("SVC")
|
||||
vaults.vaults = [local]
|
||||
cmd = make_cmd(monkeypatch, vaults)
|
||||
drm = FakeDRM({KID: vaults.get_key(KID)[0]})
|
||||
path = tmp_path / "video.mp4"
|
||||
path.write_bytes(b"cipher")
|
||||
assert local.add_keys("SVC", {KID: GOOD}) == 0
|
||||
|
||||
cmd.decrypt_verified(drm, path, lambda drm, track_kid: drm.content_keys.update({KID: GOOD}), KID)
|
||||
cmd.wait_vault_writes()
|
||||
assert local.is_bad_key(KID, BAD)
|
||||
assert local.get_key(KID, "SVC") == GOOD
|
||||
|
||||
|
||||
def test_stale_backup_from_a_killed_run_is_not_restored(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
poisoned = Remote("poisoned", tmp_path / "poisoned.db")
|
||||
poisoned.add_key("SVC", KID, BAD)
|
||||
@@ -426,7 +446,7 @@ def test_only_the_kid_whose_window_fails_is_flagged(tmp_path: Path, monkeypatch:
|
||||
|
||||
|
||||
def test_a_kid_absent_from_the_map_is_judged_by_the_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A KID the map does not know (a zero-KID tenc, a licence KID the file does not carry)
|
||||
"""A map that holds an all-zero tenc KID is not complete, so a KID it does not know
|
||||
shares the whole-file verdict, and a wrong key there is flagged."""
|
||||
lead = UUID(int=7)
|
||||
local = SQLite("local", tmp_path / "local.db")
|
||||
@@ -446,7 +466,9 @@ def test_a_kid_absent_from_the_map_is_judged_by_the_fallback(tmp_path: Path, mon
|
||||
return drm.decrypted_with[-1][lead if start is not None else KID] == GOOD
|
||||
|
||||
monkeypatch.setattr(
|
||||
dl_module.verify, "kid_windows", lambda path: dl_module.verify.KidMap({lead: [(4.0, 6.0)]}, True)
|
||||
dl_module.verify,
|
||||
"kid_windows",
|
||||
lambda path: dl_module.verify.KidMap({lead: [(4.0, 6.0)], UUID(int=0): [(8.0, 10.0)]}, True),
|
||||
)
|
||||
monkeypatch.setattr(dl_module, "ffmpeg_decodes", decodes)
|
||||
path = tmp_path / "video.mp4"
|
||||
@@ -501,11 +523,93 @@ def test_a_wrong_mapped_key_does_not_flag_a_kid_the_file_does_not_carry(
|
||||
assert drm.content_keys == {audio: GOOD, KID: GOOD}
|
||||
assert local.is_bad_key(KID, BAD)
|
||||
assert not local.is_bad_key(audio, GOOD)
|
||||
assert local.get_key(audio, "SVC") == GOOD # judged on the retry and copied
|
||||
assert local.get_key(audio, "SVC") is None
|
||||
|
||||
|
||||
def test_a_kid_the_file_does_not_carry_is_not_judged_by_it(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The CDM licensed the video KID and a vault served the audio KID. An FFmpeg that fails
|
||||
every decode of the video file must not flag the audio KID, nor copy it to another vault."""
|
||||
audio = UUID(int=7)
|
||||
local = SQLite("local", tmp_path / "local.db")
|
||||
remote = Remote("remote", tmp_path / "remote.db")
|
||||
remote.add_key("SVC", audio, GOOD)
|
||||
vaults = Vaults("SVC")
|
||||
vaults.vaults = [local, remote]
|
||||
cmd = make_cmd(monkeypatch, vaults)
|
||||
drm = FakeDRM({audio: vaults.get_key(audio)[0], KID: GOOD})
|
||||
monkeypatch.setattr(
|
||||
dl_module.verify, "kid_windows", lambda path: dl_module.verify.KidMap({KID: [(4.0, 6.0)]}, True)
|
||||
)
|
||||
monkeypatch.setattr(dl_module, "ffmpeg_decodes", lambda path, start=None, seconds=3, video=None: False)
|
||||
path = tmp_path / "video.mp4"
|
||||
path.write_bytes(b"cipher")
|
||||
licences: list[UUID] = []
|
||||
|
||||
cmd.decrypt_verified(drm, path, lambda drm, track_kid: licences.append(track_kid), KID)
|
||||
cmd.wait_vault_writes()
|
||||
assert drm.content_keys == {audio: GOOD, KID: GOOD}
|
||||
assert not licences
|
||||
assert not local.is_bad_key(audio, GOOD)
|
||||
assert local.get_key(audio, "SVC") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("served", "decodes", "told"),
|
||||
[
|
||||
(GOOD, False, False), # broken FFmpeg, the CDM returns the same key
|
||||
(BAD, True, True), # the CDM returns another key
|
||||
],
|
||||
)
|
||||
def test_a_remote_vault_is_told_only_when_another_key_replaces_its_pair(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, served: str, decodes: bool, told: bool
|
||||
) -> None:
|
||||
remote = Remote("remote", tmp_path / "remote.db")
|
||||
remote.add_key("SVC", KID, served)
|
||||
vaults = Vaults("SVC")
|
||||
vaults.vaults = [SQLite("local", tmp_path / "local.db"), remote]
|
||||
cmd = make_cmd(monkeypatch, vaults)
|
||||
if not decodes:
|
||||
monkeypatch.setattr(dl_module, "ffmpeg_decodes", lambda path, start=None, seconds=3, video=None: False)
|
||||
drm = FakeDRM({KID: vaults.get_key(KID)[0]})
|
||||
path = tmp_path / "video.mp4"
|
||||
path.write_bytes(b"cipher")
|
||||
|
||||
def licence(drm: FakeDRM, track_kid: UUID) -> None:
|
||||
assert not remote.is_bad_key(KID, served)
|
||||
drm.content_keys[KID] = GOOD
|
||||
|
||||
cmd.decrypt_verified(drm, path, licence, KID)
|
||||
assert drm.content_keys == {KID: GOOD}
|
||||
assert remote.is_bad_key(KID, served) is told
|
||||
|
||||
|
||||
def test_the_server_vault_is_told_at_once(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Over --remote the report is what makes the server skip the pair on the next licence."""
|
||||
reports: list[tuple[UUID, str]] = []
|
||||
server = SimpleNamespace(
|
||||
name="server_cdm",
|
||||
local=False,
|
||||
report_now=True,
|
||||
flag_bad_key=lambda service, kid, key, source: reports.append((kid, key)),
|
||||
)
|
||||
vaults = Vaults("SVC")
|
||||
vaults.vaults = [SQLite("local", tmp_path / "local.db")]
|
||||
vaults.sources[KID] = (BAD, server)
|
||||
cmd = make_cmd(monkeypatch, vaults)
|
||||
drm = FakeDRM({KID: BAD})
|
||||
path = tmp_path / "video.mp4"
|
||||
path.write_bytes(b"cipher")
|
||||
|
||||
def licence(drm: FakeDRM, track_kid: UUID) -> None:
|
||||
assert reports == [(KID, BAD)]
|
||||
drm.content_keys[KID] = GOOD
|
||||
|
||||
cmd.decrypt_verified(drm, path, licence, KID)
|
||||
assert drm.content_keys == {KID: GOOD}
|
||||
|
||||
|
||||
def test_only_checked_kids_are_copied_to_other_vaults(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Both the mapped KID and the absent one are proven before they reach another vault."""
|
||||
"""Only the mapped KID is proven and copied; the KID this file does not carry waits for its own file."""
|
||||
lead = UUID(int=7)
|
||||
local = SQLite("local", tmp_path / "local.db")
|
||||
remote = Remote("remote", tmp_path / "remote.db")
|
||||
@@ -530,9 +634,9 @@ def test_only_checked_kids_are_copied_to_other_vaults(tmp_path: Path, monkeypatc
|
||||
|
||||
cmd.decrypt_verified(drm, path, None, KID)
|
||||
cmd.wait_vault_writes()
|
||||
assert starts.count(None) == 1 # the absent KID went through the fallback before its copy
|
||||
assert None not in starts
|
||||
assert local.get_key(lead, "SVC") == GOOD
|
||||
assert local.get_key(KID, "SVC") == GOOD
|
||||
assert local.get_key(KID, "SVC") is None
|
||||
|
||||
|
||||
def test_flagged_pair_warns_once_per_vault(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
@@ -602,6 +706,8 @@ def test_api_vault_reports_the_pair_upstream(monkeypatch: pytest.MonkeyPatch, ca
|
||||
vaults.vaults = [api]
|
||||
vaults.sources[KID] = (BAD, api)
|
||||
vaults.flag_bad_key(KID, BAD)
|
||||
assert not calls
|
||||
vaults.report_replaced({KID: GOOD})
|
||||
vaults.unflag_bad_key(KID, BAD)
|
||||
|
||||
assert calls == [
|
||||
@@ -609,28 +715,31 @@ def test_api_vault_reports_the_pair_upstream(monkeypatch: pytest.MonkeyPatch, ca
|
||||
("DELETE", f"https://vault.example/bad-keys/{KID.hex}/{BAD}", None),
|
||||
]
|
||||
|
||||
def flag_and_replace(source: object) -> None:
|
||||
vaults.sources[KID] = (BAD, source)
|
||||
vaults.flag_bad_key(KID, BAD)
|
||||
vaults.report_replaced({KID: GOOD})
|
||||
|
||||
calls.clear()
|
||||
vaults.sources[KID] = (BAD, SimpleNamespace(name="other"))
|
||||
vaults.flag_bad_key(KID, BAD)
|
||||
flag_and_replace(SimpleNamespace(name="other", local=False, report_now=False))
|
||||
assert calls == []
|
||||
vaults.sources[KID] = (BAD, api)
|
||||
|
||||
monkeypatch.setattr(api.session, "post", lambda url, **kw: (_ for _ in ()).throw(ConnectionError("down")))
|
||||
caplog.clear()
|
||||
with caplog.at_level(logging.DEBUG, logger="unshackle.core.vaults"):
|
||||
vaults.flag_bad_key(KID, BAD)
|
||||
flag_and_replace(api)
|
||||
assert [r.levelname for r in caplog.records] == ["DEBUG"]
|
||||
|
||||
gone = SimpleNamespace(status_code=404, raise_for_status=lambda: (_ for _ in ()).throw(RuntimeError("404")))
|
||||
monkeypatch.setattr(api.session, "post", lambda url, **kw: gone)
|
||||
caplog.clear()
|
||||
vaults.flag_bad_key(KID, BAD)
|
||||
flag_and_replace(api)
|
||||
assert not caplog.records
|
||||
|
||||
denied = SimpleNamespace(status_code=200, raise_for_status=lambda: None, json=lambda: {"code": 1, "message": "no"})
|
||||
monkeypatch.setattr(api.session, "post", lambda url, **kw: denied)
|
||||
monkeypatch.setattr(api.session, "delete", lambda url, **kw: denied)
|
||||
vaults.flag_bad_key(KID, BAD)
|
||||
flag_and_replace(api)
|
||||
vaults.unflag_bad_key(KID, BAD)
|
||||
assert not caplog.records
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ def fake_server_vaults(pushes: list, vault_list: list[FakeVault]) -> Any:
|
||||
def __len__(self) -> int:
|
||||
return len(vault_list)
|
||||
|
||||
def clear_licensed_flags(self, content_keys: dict) -> list:
|
||||
return []
|
||||
|
||||
def add_keys(self, key_map: dict) -> int:
|
||||
pushes.append(key_map)
|
||||
return sum(vault.ok for vault in vault_list)
|
||||
|
||||
@@ -43,11 +43,12 @@ def test_client_marks_vault_keys_and_forgets_cdm_keys():
|
||||
assert svc.server_vault_keys == {}
|
||||
|
||||
|
||||
def test_report_bad_posts_only_the_pair():
|
||||
def test_server_vault_flag_posts_only_the_pair():
|
||||
svc = remote_service()
|
||||
posted: list = []
|
||||
svc.client = SimpleNamespace(post_optional=lambda ep, data: posted.append((ep, data)) or {})
|
||||
svc.server_vault.report_bad(KID, "v")
|
||||
assert svc.server_vault.report_now
|
||||
svc.server_vault.flag_bad_key("SVC", KID, "v", "server_cdm")
|
||||
assert posted == [("/api/session/sess/keys/bad", {"kid": KID.hex, "key": "v"})]
|
||||
|
||||
|
||||
@@ -77,18 +78,27 @@ def session(monkeypatch):
|
||||
return entry, vault
|
||||
|
||||
|
||||
async def test_server_flags_a_served_pair_with_its_own_vault_name(session):
|
||||
async def test_server_flags_a_served_pair_with_its_own_vault_name(session, monkeypatch):
|
||||
entry, vault = session
|
||||
monkeypatch.setattr(handlers, "CACHED_PAIRS", {("SimpleNamespace", KID.hex, "v")})
|
||||
resp = await handlers.session_bad_key_handler({"kid": KID.hex, "key": "v"}, "sess")
|
||||
assert resp.status == 200
|
||||
assert vault.flagged == [("SVC", KID, "v", "sqlite")]
|
||||
assert KID.hex not in entry.served_keys
|
||||
assert not handlers.CACHED_PAIRS
|
||||
|
||||
with pytest.raises(APIError):
|
||||
await handlers.session_bad_key_handler({"kid": CDM_KID.hex, "key": "nope"}, "sess")
|
||||
assert len(vault.flagged) == 1
|
||||
|
||||
|
||||
async def test_server_does_not_flag_a_pair_its_cdm_licensed(session):
|
||||
entry, vault = session
|
||||
resp = await handlers.session_bad_key_handler({"kid": CDM_KID.hex, "key": "c"}, "sess")
|
||||
assert json.loads(resp.body) == {"flagged": False}
|
||||
assert vault.flagged == []
|
||||
|
||||
|
||||
class _WV:
|
||||
def __init__(self, b64, kids):
|
||||
self._pssh = SimpleNamespace(dumps=lambda: b64)
|
||||
@@ -218,3 +228,20 @@ def test_reconstruct_drm_keeps_the_default_kid_of_a_pssh_without_kids():
|
||||
(drm,) = reconstruct_drm([{"type": "widevine", "pssh": content_id_only.dumps(), "kids": [str(KID)]}])
|
||||
|
||||
assert real_kids(drm) == [KID]
|
||||
|
||||
|
||||
def test_a_cdm_licence_unflags_a_pair_a_client_reported(monkeypatch, tmp_path):
|
||||
"""The CDM returned the reported pair, so the client's decode check was wrong."""
|
||||
from unshackle.core.vaults import Vaults
|
||||
from unshackle.vaults.SQLite import SQLite
|
||||
|
||||
local = SQLite("local", tmp_path / "local.db")
|
||||
vaults = Vaults("SVC")
|
||||
vaults.vaults = [local]
|
||||
vaults.flag_bad_key(KID, "ab" * 16)
|
||||
monkeypatch.setattr(handlers, "load_server_vaults", lambda service: vaults)
|
||||
monkeypatch.setattr(handlers, "CACHED_PAIRS", set())
|
||||
|
||||
handlers.cache_to_vaults({KID.hex: "ab" * 16}, "SVC")
|
||||
assert not local.is_bad_key(KID, "ab" * 16)
|
||||
assert local.get_key(KID, "SVC") == "ab" * 16
|
||||
|
||||
@@ -42,6 +42,9 @@ class FakeVaults:
|
||||
def is_flagged(self, kid, key):
|
||||
return False
|
||||
|
||||
def clear_licensed_flags(self, content_keys):
|
||||
return []
|
||||
|
||||
def add_keys(self, kid_keys):
|
||||
self.added.update(kid_keys)
|
||||
self.pushes.append(dict(kid_keys))
|
||||
@@ -227,3 +230,21 @@ def test_cdm_only_trusts_server_vault_keys():
|
||||
|
||||
assert vaults.sources == {}
|
||||
assert dl.LICENSE_KEY_CACHE == {CACHED: "k_cached"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system", ["widevine", "playready"])
|
||||
def test_no_cdm_and_no_server_cdm_names_the_missing_cdm(system):
|
||||
"""The server does not license this service for the API key, and the client has no CDM for it."""
|
||||
import click
|
||||
|
||||
from unshackle.core.drm import PlayReady, Widevine
|
||||
|
||||
cmd = make_cmd(FakeVaults())
|
||||
cmd._remote_service = SimpleNamespace(_server_cdm=False)
|
||||
cmd.cdm = None
|
||||
cmd.cdm_for = lambda system, quality=None: None
|
||||
drm_class = PlayReady if system == "playready" else Widevine
|
||||
drm = drm_class.__new__(drm_class)
|
||||
drm.content_keys = {}
|
||||
with pytest.raises(click.ClickException, match="No CDM configured for TEST"):
|
||||
prepare(cmd, drm, lambda **kwargs: None, NEW)
|
||||
|
||||
Reference in New Issue
Block a user