mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 13:05:09 +00:00
refactor(drm): resolve content keys in one licence path behind ServerKeySource
Drop null keys from every licence, server and export source, not only Widevine. dl now finds remote sessions and imports through ServerKeySource instead of private fields.
This commit is contained in:
@@ -301,7 +301,7 @@ def test_import_takes_its_drm_type_from_the_export(tmp_path: Path, system: str,
|
||||
doc["titles"][0]["drm"] = [{"system": system, "pssh": "AAAA"}]
|
||||
export.write_text(json.dumps(doc), encoding="utf8")
|
||||
|
||||
assert ImportService(import_ctx(), "EXAMPLE", "movie-1", str(export))._server_cdm_type == expected
|
||||
assert ImportService(import_ctx(), "EXAMPLE", "movie-1", str(export)).server_cdm_type == expected
|
||||
|
||||
|
||||
class KeyDRM:
|
||||
|
||||
@@ -18,6 +18,7 @@ import pytest
|
||||
from pywidevine.pssh import PSSH
|
||||
from rich.table import Table
|
||||
|
||||
from tests.remote.key_source import remote_keys
|
||||
from unshackle.commands import dl as dl_module
|
||||
from unshackle.commands.dl import dl
|
||||
from unshackle.core.drm import Widevine
|
||||
@@ -69,7 +70,7 @@ def make_cmd(monkeypatch: pytest.MonkeyPatch, vaults: Any) -> dl:
|
||||
cmd.service = "EXAMPLE"
|
||||
cmd.vaults = vaults
|
||||
cmd.vault_cache_tally = None
|
||||
cmd._remote_service = SimpleNamespace(_server_cdm=True, _server_cdm_type="widevine")
|
||||
cmd._remote_service = remote_keys()
|
||||
dl.LICENSE_KEY_CACHE.clear()
|
||||
dl.DRM_LOCKS.clear()
|
||||
return cmd
|
||||
@@ -111,7 +112,7 @@ def test_the_error_names_the_kids_without_a_content_key() -> None:
|
||||
|
||||
def test_the_server_cdm_branch_lets_the_import_error_through(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
drm = FakeDRM({})
|
||||
track = SimpleNamespace(drm=[drm])
|
||||
track = SimpleNamespace(id="vid", drm=[drm])
|
||||
cmd = make_cmd(monkeypatch, EmptyVaults())
|
||||
|
||||
with pytest.raises(click.ClickException, match=KID.hex):
|
||||
@@ -130,7 +131,7 @@ def test_the_retry_after_a_bad_vault_key_ends_in_the_import_error(
|
||||
assert vaults.get_key(KID) == (BAD, poisoned)
|
||||
cmd = make_cmd(monkeypatch, vaults)
|
||||
drm = FakeDRM({KID: BAD, OTHER: OTHER_KEY})
|
||||
track = SimpleNamespace(drm=[drm])
|
||||
track = SimpleNamespace(id="vid", drm=[drm])
|
||||
calls: list[str] = []
|
||||
path = tmp_path / "video.mp4"
|
||||
path.write_bytes(b"cipher")
|
||||
|
||||
@@ -6,7 +6,7 @@ import logging
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from typing import Any, Callable, Optional
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
@@ -14,10 +14,11 @@ from pywidevine.pssh import PSSH
|
||||
from rich.table import Table
|
||||
from rich.tree import Tree
|
||||
|
||||
from tests.core.test_all_drm import playready
|
||||
from unshackle.commands.dl import dl
|
||||
from unshackle.core.cdm.detect import cdm_type_stub
|
||||
from unshackle.core.constants import DOWNLOAD_CANCELLED, DownloadCancelled
|
||||
from unshackle.core.drm import Widevine
|
||||
from unshackle.core.drm import ClearKeyCENC, PlayReady, Widevine
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
@@ -151,3 +152,48 @@ def test_a_key_label_is_added_once_with_its_marker_and_source() -> None:
|
||||
f"[text2]{KID_A.hex}:{KEYS[KID_A]}* from cache",
|
||||
f"[text2]{KID_B.hex}:{KEYS[KID_B]}",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"drm_class, build, cdm",
|
||||
[
|
||||
(Widevine, lambda: widevine(KID_A), "widevine"),
|
||||
(PlayReady, lambda: playready(KID_A), "playready"),
|
||||
(ClearKeyCENC, lambda: ClearKeyCENC(kids=[KID_A]), None),
|
||||
],
|
||||
ids=["widevine", "playready", "clearkey"],
|
||||
)
|
||||
def test_an_all_zero_licence_key_is_dropped(
|
||||
cmd: dl, monkeypatch: pytest.MonkeyPatch, drm_class: type, build: Callable[[], Any], cdm: Optional[str]
|
||||
) -> None:
|
||||
def get_content_keys(self: Any, **_: Any) -> None:
|
||||
self.content_keys.update({KID_A: KEYS[KID_A], KID_C: "0" * 32})
|
||||
|
||||
monkeypatch.setattr(drm_class, "get_content_keys", get_content_keys)
|
||||
monkeypatch.setattr(dl, "LICENSE_KEY_CACHE", {})
|
||||
monkeypatch.setattr(dl, "DRM_LOCKS", {})
|
||||
drm = build()
|
||||
if cdm:
|
||||
cmd.cdm = cdm_type_stub(cdm)
|
||||
|
||||
prepare(cmd, drm, KID_A, new_table())
|
||||
|
||||
assert drm.content_keys == {KID_A: KEYS[KID_A]}
|
||||
assert dl.LICENSE_KEY_CACHE == {KID_A: KEYS[KID_A]}
|
||||
|
||||
|
||||
def test_vaults_only_stops_at_the_first_kid_no_vault_holds(cmd: dl, licences: list[str]) -> None:
|
||||
asked: list[UUID] = []
|
||||
|
||||
class EmptyVaults(NoVaults):
|
||||
def get_key(self, kid: UUID) -> tuple[None, None]:
|
||||
asked.append(kid)
|
||||
return None, None
|
||||
|
||||
cmd.vaults = EmptyVaults()
|
||||
|
||||
with pytest.raises(Widevine.Exceptions.CEKNotFound, match=KID_A.hex):
|
||||
prepare(cmd, widevine(KID_A, KID_B), KID_A, new_table(), vaults_only=True)
|
||||
|
||||
assert asked == [KID_A]
|
||||
assert licences == []
|
||||
|
||||
@@ -8,6 +8,7 @@ from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.remote.key_source import remote_keys
|
||||
from unshackle.commands import dl as dl_module
|
||||
from unshackle.commands.dl import dl
|
||||
from unshackle.core.vault import RateLimited, vault_session
|
||||
@@ -831,10 +832,11 @@ def test_prefer_vault_keys_queries_each_named_kid_once() -> None:
|
||||
asked: list[UUID] = []
|
||||
cmd = dl.__new__(dl)
|
||||
cmd.vaults = SimpleNamespace(get_key=lambda kid: asked.append(kid) or (None, None))
|
||||
cmd._remote_service = SimpleNamespace(server_vault_keys={})
|
||||
video, audio = (SimpleNamespace(kids=[KID], content_keys={KID: GOOD, SIBLING: BAD}) for _ in range(2))
|
||||
|
||||
cmd.prefer_vault_keys(SimpleNamespace(tracks=[SimpleNamespace(drm=[video]), SimpleNamespace(drm=[audio])]))
|
||||
cmd.prefer_vault_keys(
|
||||
remote_keys(), SimpleNamespace(tracks=[SimpleNamespace(drm=[video]), SimpleNamespace(drm=[audio])])
|
||||
)
|
||||
|
||||
assert asked == [KID]
|
||||
|
||||
@@ -845,13 +847,12 @@ def test_cache_resolved_keys_skips_a_key_a_local_vault_holds(tmp_path: Path) ->
|
||||
cmd = dl.__new__(dl)
|
||||
cmd.vaults = Vaults("SVC")
|
||||
cmd.vaults.vaults = [local]
|
||||
cmd._remote_service = SimpleNamespace(server_vault_keys={})
|
||||
pushed: list[dict] = []
|
||||
cmd.flush_vault_writes = lambda writes: pushed.extend(w.args[0] for w in writes)
|
||||
dl.LICENSE_KEY_CACHE.clear()
|
||||
drm = SimpleNamespace(kids=[KID], content_keys={KID: GOOD, SIBLING: BAD})
|
||||
|
||||
cmd.cache_resolved_keys(SimpleNamespace(tracks=[SimpleNamespace(drm=[drm])]))
|
||||
cmd.cache_resolved_keys(remote_keys(), SimpleNamespace(tracks=[SimpleNamespace(drm=[drm])]))
|
||||
|
||||
assert pushed == [{KID: GOOD}]
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Iterable, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from unshackle.core.remote_service import RemoteService, ServerVault
|
||||
|
||||
|
||||
def remote_keys(
|
||||
server_cdm: Optional[bool] = True,
|
||||
server_cdm_type: str = "widevine",
|
||||
client_licensed: Iterable[str] = (),
|
||||
local_cdm: Any = None,
|
||||
server_vault_keys: Optional[dict[UUID, str]] = None,
|
||||
) -> RemoteService:
|
||||
"""A RemoteService with only the state dl reads through ServerKeySource, and no server."""
|
||||
svc = RemoteService.__new__(RemoteService)
|
||||
svc._server_cdm = server_cdm
|
||||
svc.server_cdm_type = server_cdm_type
|
||||
svc.client_licensed = set(client_licensed)
|
||||
svc.server_vault_keys = dict(server_vault_keys or {})
|
||||
svc.server_vault = ServerVault(svc)
|
||||
svc.ctx = SimpleNamespace(obj=SimpleNamespace(cdm=local_cdm))
|
||||
return svc
|
||||
@@ -11,7 +11,7 @@ from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
from unshackle.core.remote_service import RemoteService
|
||||
from tests.remote.key_source import remote_keys
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
@@ -46,14 +46,10 @@ class _Client:
|
||||
|
||||
|
||||
def _service(server_type, answer):
|
||||
svc = RemoteService.__new__(RemoteService)
|
||||
svc = remote_keys(server_cdm_type=server_type)
|
||||
svc._track_alias = {}
|
||||
svc._title_alias = {}
|
||||
svc._server_cdm = True
|
||||
svc._server_cdm_type = server_type
|
||||
svc.client_licensed = set()
|
||||
svc._session_id = "sess"
|
||||
svc.server_vault_keys = {}
|
||||
svc.log = logging.getLogger("test-remote-license")
|
||||
svc.client = _Client(answer)
|
||||
return svc
|
||||
@@ -86,7 +82,7 @@ def test_response_type_refreshes_the_server_type():
|
||||
|
||||
svc.proxy_license(b"", track, "widevine")
|
||||
|
||||
assert svc._server_cdm_type == "playready"
|
||||
assert svc.server_cdm_type == "playready"
|
||||
|
||||
|
||||
def test_proxy_mode_keeps_the_callers_type():
|
||||
|
||||
@@ -12,11 +12,12 @@ from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.remote.key_source import remote_keys
|
||||
from unshackle.core.api import handlers
|
||||
from unshackle.core.api.errors import APIError
|
||||
from unshackle.core.api.session_store import SessionEntry
|
||||
from unshackle.core.drm import BlowfishStripe
|
||||
from unshackle.core.remote_service import RemoteService, ServerVault
|
||||
from unshackle.core.remote_service import RemoteService
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
@@ -25,13 +26,10 @@ CDM_KID = UUID("43de497c6d1945f49acc2951696ec628")
|
||||
|
||||
|
||||
def remote_service() -> RemoteService:
|
||||
svc = RemoteService.__new__(RemoteService)
|
||||
svc = remote_keys(server_cdm=None)
|
||||
svc._track_alias = {}
|
||||
svc._title_alias = {}
|
||||
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
|
||||
|
||||
@@ -277,3 +275,15 @@ async def test_a_stripe_cipher_track_is_clear_and_never_licensed(monkeypatch):
|
||||
|
||||
assert json.loads(resp.body)["clear_tracks"] == ["aud"]
|
||||
assert isinstance(track.drm[0], BlowfishStripe)
|
||||
|
||||
|
||||
def test_the_server_licence_drops_a_null_key():
|
||||
kid, null = UUID(int=1), UUID(int=2)
|
||||
|
||||
class Licensed:
|
||||
content_keys: dict = {}
|
||||
|
||||
def get_content_keys(self, **kwargs):
|
||||
self.content_keys = {kid: "ab" * 16, null: "0" * 32}
|
||||
|
||||
assert handlers.license_with_track_fallback(Licensed(), SimpleNamespace(drm=None)) == {kid.hex: "ab" * 16}
|
||||
|
||||
@@ -13,6 +13,7 @@ import pywidevine.pssh as wv_pssh_mod
|
||||
from rich.table import Table
|
||||
|
||||
import unshackle.core.drm as drm_mod
|
||||
from tests.remote.key_source import remote_keys
|
||||
from unshackle.commands.dl import dl
|
||||
from unshackle.core.api import handlers
|
||||
from unshackle.core.api.errors import APIError, APIErrorCode
|
||||
@@ -203,16 +204,11 @@ _WV.__name__ = "Widevine"
|
||||
|
||||
|
||||
def remote(local_cdm, answer):
|
||||
svc = RemoteService.__new__(RemoteService)
|
||||
svc = remote_keys(server_cdm_type="playready", local_cdm=local_cdm)
|
||||
svc._track_alias = {}
|
||||
svc._title_alias = {}
|
||||
svc._server_cdm = True
|
||||
svc._server_cdm_type = "playready"
|
||||
svc._session_id = "sess"
|
||||
svc.server_vault_keys = {}
|
||||
svc.client_licensed = set()
|
||||
svc.log = logging.getLogger("test-server-cdm-cap")
|
||||
svc.ctx = SimpleNamespace(obj=SimpleNamespace(cdm=local_cdm))
|
||||
posts: list = []
|
||||
svc.client = SimpleNamespace(post=lambda endpoint, payload, expect=(): posts.append(payload) or answer)
|
||||
svc.drain_server_logs = lambda: None
|
||||
@@ -557,7 +553,7 @@ def prepare(svc, licence):
|
||||
|
||||
|
||||
def test_dl_licenses_a_client_licensed_track_with_the_local_device():
|
||||
svc = SimpleNamespace(_server_cdm=True, client_licensed={"vid"}, local_cdm="local device")
|
||||
svc = remote_keys(client_licensed={"vid"}, local_cdm="local device")
|
||||
|
||||
server_calls: list = []
|
||||
|
||||
@@ -566,7 +562,7 @@ def test_dl_licenses_a_client_licensed_track_with_the_local_device():
|
||||
|
||||
|
||||
def test_dl_takes_the_local_path_when_the_server_hands_the_track_over():
|
||||
svc = SimpleNamespace(_server_cdm=True, client_licensed=set(), local_cdm="local device")
|
||||
svc = remote_keys(local_cdm="local device")
|
||||
assert prepare(svc, lambda **kwargs: svc.client_licensed.add("vid")) == "local device"
|
||||
|
||||
|
||||
@@ -587,7 +583,7 @@ class PlayReady:
|
||||
def test_a_mid_download_handover_puts_the_keys_on_the_callers_drm(monkeypatch):
|
||||
"""The caller decrypts with the DRM object it passed in, so the local licence must fill that one."""
|
||||
monkeypatch.setattr(dl, "LICENSE_KEY_CACHE", {})
|
||||
svc = SimpleNamespace(_server_cdm=True, client_licensed=set(), local_cdm="local device", server_vault_keys={})
|
||||
svc = remote_keys(local_cdm="local device")
|
||||
runner = dl.__new__(dl)
|
||||
runner._remote_service = svc
|
||||
runner.cdm = "server stub"
|
||||
|
||||
@@ -13,6 +13,7 @@ from uuid import UUID
|
||||
import pytest
|
||||
from rich.table import Table
|
||||
|
||||
from tests.remote.key_source import remote_keys
|
||||
from unshackle.commands.dl import dl
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
@@ -68,7 +69,7 @@ def make_cmd(vaults):
|
||||
cmd.vaults = vaults
|
||||
cmd.vault_cache_tally = None
|
||||
cmd.debug_logger = None
|
||||
cmd._remote_service = SimpleNamespace(_server_cdm=True, _server_cdm_type="widevine")
|
||||
cmd._remote_service = remote_keys()
|
||||
dl.LICENSE_KEY_CACHE.clear()
|
||||
dl.DRM_LOCKS.clear()
|
||||
return cmd
|
||||
@@ -78,7 +79,7 @@ def prepare(cmd, drm, licence, track_kid, **kwargs):
|
||||
table = Table.grid()
|
||||
table.add_row("")
|
||||
cmd.prepare_drm(
|
||||
track=SimpleNamespace(drm=[drm]),
|
||||
track=SimpleNamespace(id="vid", drm=[drm]),
|
||||
title=None,
|
||||
drm=drm,
|
||||
licence=licence,
|
||||
@@ -128,7 +129,7 @@ def test_batch_resolved_keys_are_cached_before_download():
|
||||
drm.content_keys = {NEW: "k_new"}
|
||||
title = SimpleNamespace(tracks=[SimpleNamespace(drm=[drm]), SimpleNamespace(drm=None)])
|
||||
|
||||
cmd.cache_resolved_keys(title)
|
||||
cmd.cache_resolved_keys(remote_keys(), title)
|
||||
cmd.wait_vault_writes()
|
||||
|
||||
assert vaults.added == {NEW: "k_new"}
|
||||
@@ -168,7 +169,7 @@ def test_server_vault_keys_are_held_back_until_verified():
|
||||
drm2 = FakeWidevine([CACHED])
|
||||
drm2.content_keys = {CACHED: "k_cached"}
|
||||
dl.LICENSE_KEY_CACHE.clear()
|
||||
cmd.cache_resolved_keys(SimpleNamespace(tracks=[SimpleNamespace(drm=[drm2])]))
|
||||
cmd.cache_resolved_keys(remote, SimpleNamespace(tracks=[SimpleNamespace(drm=[drm2])]))
|
||||
cmd.wait_vault_writes()
|
||||
assert vaults.added == {NEW: "k_new"}
|
||||
|
||||
@@ -242,7 +243,7 @@ def test_no_cdm_and_no_server_cdm_names_the_missing_cdm(system):
|
||||
from unshackle.core.drm import PlayReady, Widevine
|
||||
|
||||
cmd = make_cmd(FakeVaults())
|
||||
cmd._remote_service = SimpleNamespace(_server_cdm=False)
|
||||
cmd._remote_service = remote_keys(server_cdm=False)
|
||||
cmd.cdm = None
|
||||
cmd.cdm_for = lambda system, quality=None: None
|
||||
drm_class = PlayReady if system == "playready" else Widevine
|
||||
@@ -250,3 +251,32 @@ def test_no_cdm_and_no_server_cdm_names_the_missing_cdm(system):
|
||||
drm.content_keys = {}
|
||||
with pytest.raises(click.ClickException, match="No CDM configured for TEST"):
|
||||
prepare(cmd, drm, lambda **kwargs: None, NEW)
|
||||
|
||||
|
||||
NULL = UUID("0b3e2d1c4a5f46e7b8c9d0e1f2a3b4c5")
|
||||
|
||||
|
||||
def test_a_null_key_from_the_server_licence_is_dropped():
|
||||
vaults = FakeVaults()
|
||||
drm = run_prepare(vaults, [NEW], {NEW: "k_new", NULL: "0" * 32}, track_kid=NEW)
|
||||
assert drm.content_keys == {NEW: "k_new"}
|
||||
assert vaults.added == {NEW: "k_new"}
|
||||
assert NULL not in dl.LICENSE_KEY_CACHE
|
||||
|
||||
|
||||
def test_a_null_key_from_the_batch_licence_is_dropped(monkeypatch):
|
||||
vaults = FakeVaults()
|
||||
cmd = make_cmd(vaults)
|
||||
drm = FakeWidevine([NEW])
|
||||
title = SimpleNamespace(tracks=[SimpleNamespace(drm=[drm])])
|
||||
source = cmd._remote_service
|
||||
monkeypatch.setattr(
|
||||
source, "resolve_server_keys", lambda t: drm.content_keys.update({NEW: "k_new", NULL: "0" * 32})
|
||||
)
|
||||
|
||||
cmd.take_server_keys(source, title, cdm_only=False)
|
||||
cmd.wait_vault_writes()
|
||||
|
||||
assert drm.content_keys == {NEW: "k_new"}
|
||||
assert vaults.added == {NEW: "k_new"}
|
||||
assert NULL not in dl.LICENSE_KEY_CACHE
|
||||
|
||||
Reference in New Issue
Block a user