mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 03:55:05 +00:00
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.
248 lines
9.7 KiB
Python
248 lines
9.7 KiB
Python
"""A server vault key is unproven until the client decodes with it.
|
|
|
|
Over --remote with server_cdm the server never sees a segment, so the client holds a
|
|
vault-sourced key back from its own vaults, checks the decode, and reports a bad pair to
|
|
the server. The client never learns which server vault answered: the server keeps the
|
|
name on its served-key record and flags the row itself, only for a pair it served.
|
|
"""
|
|
|
|
import json
|
|
from types import SimpleNamespace
|
|
from uuid import UUID
|
|
|
|
import pytest
|
|
|
|
from unshackle.core.api import handlers
|
|
from unshackle.core.api.errors import APIError
|
|
from unshackle.core.api.session_store import SessionEntry
|
|
from unshackle.core.remote_service import RemoteService, ServerVault
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
KID = UUID("161219ec3df64e0eadbb3827c0ccc46d")
|
|
CDM_KID = UUID("43de497c6d1945f49acc2951696ec628")
|
|
|
|
|
|
def remote_service() -> RemoteService:
|
|
svc = RemoteService.__new__(RemoteService)
|
|
svc.server_vault_keys = {}
|
|
svc.server_vault = ServerVault(svc)
|
|
svc._session_id = "sess"
|
|
svc.client_licensed = set()
|
|
svc.log = SimpleNamespace(warning=lambda *a, **k: None)
|
|
return svc
|
|
|
|
|
|
def test_client_marks_vault_keys_and_forgets_cdm_keys():
|
|
svc = remote_service()
|
|
svc.note_vault_keys({KID.hex: "v", CDM_KID.hex: "c"}, {KID.hex})
|
|
assert svc.server_vault_keys == {KID: "v"}
|
|
|
|
# a re-licence that came from the CDM clears the marker
|
|
svc.note_vault_keys({KID.hex: "v2"}, set())
|
|
assert svc.server_vault_keys == {}
|
|
|
|
|
|
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 {})
|
|
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"})]
|
|
|
|
|
|
class _Vault:
|
|
local = True
|
|
|
|
def __init__(self):
|
|
self.flagged: list = []
|
|
|
|
def flag_bad_key(self, service, kid, key, source):
|
|
self.flagged.append((service, kid, key, source))
|
|
|
|
|
|
@pytest.fixture
|
|
def session(monkeypatch):
|
|
entry = SessionEntry(session_id="sess", service_tag="SVC", service_instance=SimpleNamespace())
|
|
handlers.note_served_keys(entry, {KID.hex: "v", CDM_KID.hex: "c"}, {KID.hex: "sqlite"})
|
|
assert entry.served_keys == {KID.hex: ("v", "sqlite"), CDM_KID.hex: ("c", "cdm")}
|
|
|
|
async def validated(session_id, request):
|
|
return entry
|
|
|
|
monkeypatch.setattr(handlers, "get_validated_session", validated)
|
|
monkeypatch.setattr(handlers, "require_authenticated", lambda s: None)
|
|
vault = _Vault()
|
|
monkeypatch.setattr(handlers, "load_server_vaults", lambda name: SimpleNamespace(vaults=[vault], service="SVC"))
|
|
return entry, vault
|
|
|
|
|
|
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)
|
|
self.kids = kids
|
|
|
|
|
|
_WV.__name__ = "Widevine"
|
|
|
|
|
|
async def test_batch_reports_vault_keys_from_the_init_segment_pssh(monkeypatch):
|
|
"""A vault hit on the init-segment PSSH retry must reach `vault_keys` like a manifest one."""
|
|
track = SimpleNamespace(id="vid", drm=[_WV("manifest", [KID])], get_key_id=lambda init: CDM_KID)
|
|
session = SessionEntry(session_id="sess", service_tag="SVC", service_instance=SimpleNamespace())
|
|
session.tracks = {"vid": track}
|
|
|
|
async def validated(sid, req):
|
|
return session
|
|
|
|
def fake_single(service, title, track, pssh_str, drm_type, request, sources=None, refusal=None, vault_only=False):
|
|
if pssh_str == "init":
|
|
sources[CDM_KID.hex] = "sqlite"
|
|
return {CDM_KID.hex: "v"}
|
|
return {KID.hex: "c"}
|
|
|
|
monkeypatch.setattr(handlers, "get_validated_session", validated)
|
|
monkeypatch.setattr(handlers, "server_cdm_allowed", lambda request, tag: True)
|
|
monkeypatch.setattr(handlers, "ensure_track_drm", lambda track, session=None, init_data=None: None)
|
|
monkeypatch.setattr(handlers, "find_title_for_track", lambda tid, session: SimpleNamespace())
|
|
monkeypatch.setattr(handlers, "detect_cdm_type_for_service", lambda tag, cfg: "widevine")
|
|
monkeypatch.setattr(handlers, "resolve_device_name", lambda *a, **k: "dev")
|
|
monkeypatch.setattr(handlers, "fetch_init_segment", lambda track, sess: b"init")
|
|
monkeypatch.setattr(
|
|
handlers, "drm_from_init_segment", lambda track, session=None, init_data=None: [_WV("init", [CDM_KID])]
|
|
)
|
|
monkeypatch.setattr(handlers, "handle_single_server_cdm", fake_single)
|
|
monkeypatch.setattr(handlers.config, "serve", {"users": {}}, raising=False)
|
|
|
|
resp = await handlers.session_license_handler(
|
|
{"mode": "server_cdm", "track_ids": ["vid"], "drm_type": "widevine"}, "sess", None
|
|
)
|
|
|
|
payload = json.loads(resp.body)
|
|
assert payload["keys"] == {"vid": {CDM_KID.hex: "v"}}
|
|
assert payload["vault_keys"] == [CDM_KID.hex]
|
|
assert session.served_keys == {CDM_KID.hex: ("v", "sqlite")}
|
|
|
|
|
|
async def test_server_tells_a_remote_vault_only_when_it_served_the_pair(monkeypatch, session):
|
|
entry, local = session
|
|
bystander, source = _Vault(), _Vault()
|
|
bystander.local = source.local = False
|
|
bystander.name, source.name = "other", "sqlite"
|
|
monkeypatch.setattr(
|
|
handlers, "load_server_vaults", lambda name: SimpleNamespace(vaults=[local, bystander, source], service="SVC")
|
|
)
|
|
await handlers.session_bad_key_handler({"kid": KID.hex, "key": "v"}, "sess")
|
|
assert local.flagged == [("SVC", KID, "v", "sqlite")]
|
|
assert source.flagged == [("SVC", KID, "v", "sqlite")]
|
|
assert bystander.flagged == []
|
|
|
|
|
|
def test_resolve_server_keys_does_not_warn_for_clear_tracks(monkeypatch):
|
|
"""A requested track the server lists in clear_tracks carries no DRM, so its
|
|
missing keys are expected and draw no warning."""
|
|
svc = remote_service()
|
|
warnings: list = []
|
|
svc.log = SimpleNamespace(warning=lambda msg, *a, **k: warnings.append(msg), debug=lambda *a, **k: None)
|
|
svc._server_cdm = True
|
|
svc.drain_server_logs = lambda: None
|
|
svc.client = SimpleNamespace(post=lambda ep, data: {"keys": {}, "clear_tracks": ["a-1"]})
|
|
|
|
video_track = SimpleNamespace(id="v-1", drm=None)
|
|
audio_track = SimpleNamespace(id="a-1", drm=None)
|
|
|
|
class Tracks(list):
|
|
videos = [video_track]
|
|
audio = [audio_track]
|
|
|
|
svc.resolve_server_keys(SimpleNamespace(tracks=Tracks([video_track, audio_track])))
|
|
|
|
assert warnings == ["Server CDM returned no content keys for track v-1"]
|
|
|
|
|
|
def resolve(manifest_drm, drm_type="widevine"):
|
|
"""Run resolve_server_keys for one track the server answers with its own key and a sibling."""
|
|
svc = remote_service()
|
|
svc.log = SimpleNamespace(warning=lambda *a, **k: None, debug=lambda *a, **k: None)
|
|
svc._server_cdm = True
|
|
svc.drain_server_logs = lambda: None
|
|
keys = {CDM_KID.hex: "bb" * 16, KID.hex: "aa" * 16}
|
|
svc.client = SimpleNamespace(post=lambda ep, data: {"keys": {"v-1": keys}, "drm_type": drm_type})
|
|
track = SimpleNamespace(id="v-1", drm=manifest_drm)
|
|
|
|
class Tracks(list):
|
|
videos = [track]
|
|
audio: list = []
|
|
|
|
svc.resolve_server_keys(SimpleNamespace(tracks=Tracks([track])))
|
|
assert set(track.drm[0].content_keys) == {KID, CDM_KID}
|
|
return track.drm[0]
|
|
|
|
|
|
def test_resolve_server_keys_names_only_the_manifest_kids():
|
|
"""Sibling keys in the licence stay content keys but are not KIDs the track names."""
|
|
from pywidevine.pssh import PSSH
|
|
|
|
from unshackle.core.drm import Widevine
|
|
|
|
widevine = Widevine(pssh=PSSH.new(system_id=PSSH.SystemId.Widevine, key_ids=[KID]))
|
|
assert resolve([widevine]).kids == [KID]
|
|
|
|
playready = RemoteService.create_drm_stub("playready", [KID.hex])
|
|
assert resolve([playready], "playready").kids == [KID]
|
|
|
|
|
|
def test_resolve_server_keys_names_every_kid_without_manifest_drm():
|
|
assert set(resolve(None).kids) == {KID, CDM_KID}
|
|
|
|
|
|
def test_reconstruct_drm_keeps_the_default_kid_of_a_pssh_without_kids():
|
|
from pywidevine.pssh import PSSH
|
|
|
|
from unshackle.core.drm import real_kids
|
|
from unshackle.core.remote_service import reconstruct_drm
|
|
|
|
content_id_only = PSSH.new(system_id=PSSH.SystemId.Widevine, init_data=b"\x22\x03abc")
|
|
(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
|