diff --git a/tests/core/test_declared_kwargs.py b/tests/core/test_declared_kwargs.py new file mode 100644 index 00000000..d322469e --- /dev/null +++ b/tests/core/test_declared_kwargs.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from unshackle.core.utilities import declared_kwargs + +SENT = {"challenge": b"c", "title": "t", "track": "tr", "pssh": "P", "session_id": "S"} + + +def test_keeps_only_declared_arguments() -> None: + def licence(*, challenge: bytes, title: str, track: str) -> None: ... + + assert sorted(declared_kwargs(licence, SENT)) == ["challenge", "title", "track"] + + +def test_passes_session_id_to_a_function_that_asks_for_it() -> None: + def licence(*, challenge: bytes, title: str, track: str, session_id: str | None = None) -> None: ... + + assert sorted(declared_kwargs(licence, SENT)) == ["challenge", "session_id", "title", "track"] + + +def test_a_var_keyword_function_gets_everything() -> None: + def licence(*, challenge: bytes, **kwargs: object) -> None: ... + + assert declared_kwargs(licence, SENT) == SENT + + +def test_an_unreadable_signature_gets_everything() -> None: + # min() has no readable signature, and a compiled service may not either + assert declared_kwargs(min, SENT) == SENT + + +def test_a_signature_we_cannot_call_by_keyword_gets_everything() -> None: + def licence(challenge, /, **kwargs: object) -> None: ... + + assert declared_kwargs(licence, SENT) == SENT diff --git a/tests/core/test_widevine_get_content_keys.py b/tests/core/test_widevine_get_content_keys.py new file mode 100644 index 00000000..a24b0a61 --- /dev/null +++ b/tests/core/test_widevine_get_content_keys.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from typing import Any +from uuid import UUID + +import pytest +from pywidevine.pssh import PSSH + +from unshackle.core.drm import Widevine +from unshackle.core.utilities import declared_kwargs + +KID = UUID("00112233-4455-6677-8899-aabbccddeeff") +KEY = "00000000000000000000000000000001" + + +class FakeKey: + def __init__(self, kid: UUID, key: str) -> None: + self.kid = kid + self.key = bytes.fromhex(key) + self.type = "CONTENT" + + +class FakeCdm: + """The smallest CDM the licensing flow accepts; records what it was given.""" + + service_certificate_challenge = b"cert-challenge" + + def __init__(self, challenge: bytes = b"challenge") -> None: + self._challenge = challenge + self.parsed: list[Any] = [] + + def open(self) -> str: + return "session-1" + + def close(self, session_id: str) -> None: + pass + + def get_license_challenge(self, session_id: str, pssh: PSSH) -> bytes: + return self._challenge + + def parse_license(self, session_id: str, response: Any) -> None: + self.parsed.append(response) + + def get_keys(self, session_id: str, type_: str) -> list[FakeKey]: + return [FakeKey(KID, KEY)] + + +def _drm() -> Widevine: + return Widevine(pssh=PSSH.new(system_id=PSSH.SystemId.Widevine, key_ids=[KID]), kid=KID) + + +def _licence_for(fn: Any) -> Any: + """Build the callable dl.py gives the DRM object, filtered to what the service declares.""" + return lambda **kw: fn(**declared_kwargs(fn, {**kw, "title": "t", "track": "tr"})) + + +def test_a_session_aware_service_gets_the_open_session_id() -> None: + seen: dict[str, Any] = {} + + def get_widevine_license(*, challenge: bytes, title: str, track: str, session_id: Any = None) -> bytes: + seen.update(challenge=challenge, title=title, track=track, session_id=session_id) + return b"license" + + drm = _drm() + cdm = FakeCdm() + drm.get_content_keys(cdm=cdm, certificate=lambda **_: None, licence=_licence_for(get_widevine_license)) + + assert seen["session_id"] == "session-1" + assert "pssh" not in seen + assert drm.content_keys == {KID: KEY} + + +def test_a_plain_service_does_not_get_pssh_or_session_id() -> None: + seen: dict[str, Any] = {} + + def get_widevine_license(*, challenge: bytes, title: str, track: str) -> bytes: + seen.update(challenge=challenge, title=title, track=track) + return b"license" + + drm = _drm() + drm.get_content_keys(cdm=FakeCdm(), certificate=lambda **_: None, licence=_licence_for(get_widevine_license)) + + assert set(seen) == {"challenge", "title", "track"} + assert drm.content_keys == {KID: KEY} + + +def test_an_empty_challenge_raises_instead_of_licensing() -> None: + def get_widevine_license(*, challenge: bytes, title: str, track: str) -> bytes: + raise AssertionError("the licence request must not go out for an empty challenge") + + drm = _drm() + with pytest.raises(Widevine.Exceptions.EmptyLicense): + drm.get_content_keys( + cdm=FakeCdm(challenge=b""), certificate=lambda **_: None, licence=_licence_for(get_widevine_license) + ) diff --git a/tests/remote/unit/test_licence_declared_kwargs.py b/tests/remote/unit/test_licence_declared_kwargs.py new file mode 100644 index 00000000..5601514d --- /dev/null +++ b/tests/remote/unit/test_licence_declared_kwargs.py @@ -0,0 +1,381 @@ +"""The REST API licence paths must give a service only the arguments it declares. + +`serve` reaches a service's licence function through three call sites that bypass the +`dl` command: server_cdm Widevine, server_cdm PlayReady, and the proxy-license forward. +Each one now filters with `declared_kwargs`, so a service that never asked for `pssh` or +`session_id` keeps working while a session-aware service finally receives `session_id` +on the server_cdm path. +""" + +from __future__ import annotations + +import base64 +from types import SimpleNamespace +from typing import Any, Optional +from uuid import UUID + +import pyplayready.system.pssh as pr_pssh_mod +import pytest +import pywidevine.pssh as wv_pssh_mod + +import unshackle.core.cdm as cdm_mod +import unshackle.core.cdm.detect as detect_mod +import unshackle.core.drm as drm_mod +from unshackle.core.api import handlers +from unshackle.core.drm import PlayReady, Widevine +from unshackle.core.service import Service + +pytestmark = pytest.mark.unit + +KID = UUID("11111111111111111111111111111111") +KEY = bytes.fromhex("22222222222222222222222222222222") +PSSH_B64 = base64.b64encode(b"pssh").decode() +SESSION_ID = b"sid-bytes" + + +# --- fake services, one per licence signature shape ------------------------------------- + + +class _Recorder: + """Base for the fake services; every licence call lands in `calls`.""" + + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def get_widevine_service_certificate(self, *, challenge: Any, title: Any, track: Any) -> None: + return None + + +class ClassicService(_Recorder): + """EXAMPLE: the pre-`session_id` signature every older service uses.""" + + def get_widevine_license(self, *, challenge: bytes, title: Any, track: Any) -> bytes: + self.calls.append({"challenge": challenge, "title": title, "track": track}) + return b"LIC" + + def get_playready_license(self, *, challenge: bytes, title: Any, track: Any) -> str: + self.calls.append({"challenge": challenge, "title": title, "track": track}) + return "ok" + + +class SessionService(_Recorder): + """DEMO: a session-aware service, the shape that needs the open CDM session id.""" + + def get_widevine_license( + self, *, challenge: bytes, title: Any, track: Any, session_id: Optional[bytes] = None + ) -> bytes: + self.calls.append({"challenge": challenge, "title": title, "track": track, "session_id": session_id}) + return b"LIC" + + def get_playready_license( + self, *, challenge: bytes, title: Any, track: Any, session_id: Optional[bytes] = None + ) -> str: + self.calls.append({"challenge": challenge, "title": title, "track": track, "session_id": session_id}) + return "ok" + + +class VarKwService(_Recorder): + """DEMO2: a `**kwargs` signature takes whatever the framework offers.""" + + def get_widevine_license(self, *, challenge: bytes, **kwargs: Any) -> bytes: + self.calls.append({"challenge": challenge, **kwargs}) + return b"LIC" + + def get_playready_license(self, *, challenge: bytes, **kwargs: Any) -> str: + self.calls.append({"challenge": challenge, **kwargs}) + return "ok" + + +# --- stub DRM objects that keep the real get_content_keys call shape --------------------- + + +class StubWidevine(Widevine): + """Real `Widevine.get_content_keys`, with the PSSH parsing skipped.""" + + # pssh and kids are read-only properties on the real class; shadow them so a stub can set them + pssh: Any = None + kids: Any = None + + def __init__(self, pssh: Any = None, **kwargs: Any) -> None: + self.pssh = pssh + self.kids = [KID] + self.content_keys: dict[UUID, str] = {} + + +class StubPlayReady(PlayReady): + """Real `PlayReady.get_content_keys`, with the WRMHEADER parsing skipped.""" + + # pssh, pssh_b64 and kids are read-only properties on the real class + pssh: Any = None + pssh_b64: Any = None + kids: Any = None + + def __init__(self, pssh: Any = None, pssh_b64: Optional[str] = None, **kwargs: Any) -> None: + self.pssh = SimpleNamespace(wrm_headers=[""]) + self.pssh_b64 = pssh_b64 + self.kids = [KID] + self.content_keys: dict[UUID, str] = {} + + def extract_keys_from_cdm(self, cdm: Any, session_id: Any) -> dict[UUID, str]: + return {KID: KEY.hex()} + + +class FakeWidevineCdm: + service_certificate_challenge = b"cert-chal" + + def open(self) -> bytes: + return SESSION_ID + + def get_license_challenge(self, session_id: bytes, pssh: Any) -> bytes: + return b"CHAL" + + def parse_license(self, session_id: bytes, license_res: Any) -> None: + return None + + def get_keys(self, session_id: bytes, type_: str) -> list[SimpleNamespace]: + return [SimpleNamespace(kid=KID, key=KEY)] + + def close(self, session_id: bytes) -> None: + return None + + +class FakePlayReadyCdm: + def open(self) -> bytes: + return SESSION_ID + + def get_license_challenge(self, session_id: bytes, wrm_header: Any) -> str: + return "CHAL" + + def parse_license(self, session_id: bytes, license_str: str) -> None: + return None + + def close(self, session_id: bytes) -> None: + return None + + +# --- fixtures --------------------------------------------------------------------------- + + +@pytest.fixture +def widevine_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(wv_pssh_mod, "PSSH", lambda b64: SimpleNamespace(b64=b64)) + monkeypatch.setattr(drm_mod, "Widevine", StubWidevine) + monkeypatch.setattr(cdm_mod, "load_cdm", lambda *a, **k: FakeWidevineCdm()) + monkeypatch.setattr(detect_mod, "is_widevine_cdm", lambda cdm: True) + monkeypatch.setattr(handlers, "ensure_track_drm", lambda track, session=None, init_data=None: None) + monkeypatch.setattr(handlers, "resolve_device_name", lambda *a, **k: "dev") + monkeypatch.setattr(handlers, "check_vaults", lambda kids, name: None) + monkeypatch.setattr(handlers, "cache_to_vaults", lambda keys, name: None) + monkeypatch.setattr(handlers.config, "serve", {"users": {}}, raising=False) + + +@pytest.fixture +def playready_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(pr_pssh_mod, "PSSH", lambda data: SimpleNamespace(data=data)) + monkeypatch.setattr(drm_mod, "PlayReady", StubPlayReady) + monkeypatch.setattr(cdm_mod, "load_cdm", lambda *a, **k: FakePlayReadyCdm()) + monkeypatch.setattr(detect_mod, "is_playready_cdm", lambda cdm: True) + monkeypatch.setattr(handlers, "ensure_track_drm", lambda track, session=None, init_data=None: None) + monkeypatch.setattr(handlers, "resolve_device_name", lambda *a, **k: "dev") + monkeypatch.setattr(handlers, "cache_to_vaults", lambda keys, name: None) + monkeypatch.setattr(handlers.config, "serve", {"users": {}}, raising=False) + + +def _server_cdm(service: Any, drm_type: str) -> dict[str, str]: + return handlers.handle_single_server_cdm( + service=service, + title=SimpleNamespace(), + track=SimpleNamespace(), + pssh_b64=PSSH_B64, + drm_type=drm_type, + request=None, + ) + + +# --- server_cdm, Widevine --------------------------------------------------------------- + + +def test_server_cdm_widevine_classic_signature_gets_three_arguments(widevine_env: None) -> None: + service = ClassicService() + keys = _server_cdm(service, "widevine") + + assert keys == {KID.hex: KEY.hex()} + assert len(service.calls) == 1 + assert set(service.calls[0]) == {"challenge", "title", "track"} + assert service.calls[0]["challenge"] == b"CHAL" + + +def test_server_cdm_widevine_session_service_receives_the_open_session_id(widevine_env: None) -> None: + service = SessionService() + _server_cdm(service, "widevine") + + assert set(service.calls[0]) == {"challenge", "title", "track", "session_id"} + assert service.calls[0]["session_id"] == SESSION_ID + + +def test_server_cdm_widevine_var_kwargs_service_receives_pssh_and_session_id(widevine_env: None) -> None: + service = VarKwService() + _server_cdm(service, "widevine") + + assert set(service.calls[0]) == {"challenge", "title", "track", "pssh", "session_id"} + assert service.calls[0]["session_id"] == SESSION_ID + + +def test_server_cdm_widevine_unnarrowable_signature_retries_without_the_extras(widevine_env: None) -> None: + # declared_kwargs cannot narrow a signature it cannot read (a compiled service) so it offers + # everything. The call raises TypeError and Widevine retries with `challenge` alone, which + # the lambda turns back into the classic challenge/title/track set the service does accept. + service = ClassicService() + calls: list[dict[str, Any]] = [] + + def compiled(**kw: Any) -> bytes: + calls.append(kw) + if {"pssh", "session_id"} & set(kw): + raise TypeError("unexpected keyword argument") + return b"LIC" + + service.get_widevine_license = compiled # type: ignore[method-assign] + _server_cdm(service, "widevine") + + assert len(calls) == 2 + assert set(calls[0]) == {"challenge", "title", "track", "pssh", "session_id"} + assert set(calls[1]) == {"challenge", "title", "track"} + + +def test_server_cdm_widevine_lambda_is_never_itself_filtered(widevine_env: None) -> None: + # The `(**kw)` lambda would look like a VAR_KEYWORD service if declared_kwargs were ever + # applied to it, and a classic service would then be handed `pssh` and `session_id`. + service = ClassicService() + _server_cdm(service, "widevine") + + assert "pssh" not in service.calls[0] + assert "session_id" not in service.calls[0] + + +# --- server_cdm, PlayReady -------------------------------------------------------------- + + +def test_server_cdm_playready_classic_signature_gets_three_arguments(playready_env: None) -> None: + service = ClassicService() + keys = _server_cdm(service, "playready") + + assert keys == {KID.hex: KEY.hex()} + assert set(service.calls[0]) == {"challenge", "title", "track"} + + +def test_server_cdm_playready_offers_pssh_b64_not_pssh_or_session_id(playready_env: None) -> None: + # PlayReady.get_content_keys calls licence(challenge=..., pssh_b64=...); it has no + # session_id to give, so a session-aware service sees the None default there. + service = VarKwService() + _server_cdm(service, "playready") + + assert set(service.calls[0]) == {"challenge", "title", "track", "pssh_b64"} + assert service.calls[0]["pssh_b64"] == PSSH_B64 + + +def test_server_cdm_playready_session_service_is_not_passed_session_id(playready_env: None) -> None: + service = SessionService() + _server_cdm(service, "playready") + + assert set(service.calls[0]) == {"challenge", "title", "track", "session_id"} + assert service.calls[0]["session_id"] is None # the signature default, never an offered value + + +# --- proxy license ---------------------------------------------------------------------- + + +def _proxy(service: Any, drm_type: str) -> Any: + return handlers.handle_proxy_license( + service=service, + title=SimpleNamespace(), + track=SimpleNamespace(), + challenge_b64=base64.b64encode(b"CHAL").decode(), + drm_type=drm_type, + ) + + +@pytest.mark.parametrize("drm_type", ["widevine", "playready"]) +def test_proxy_license_classic_signature(drm_type: str) -> None: + service = ClassicService() + response = _proxy(service, drm_type) + + assert response.status == 200 + assert set(service.calls[0]) == {"challenge", "title", "track"} + assert service.calls[0]["challenge"] == b"CHAL" + + +@pytest.mark.parametrize("drm_type", ["widevine", "playready"]) +def test_proxy_license_var_kwargs_service_gets_no_session_id(drm_type: str) -> None: + # The client holds the CDM, so there is no server-side session to name. A `**kwargs` + # service must not see a session_id key at all. + service = VarKwService() + _proxy(service, drm_type) + + assert set(service.calls[0]) == {"challenge", "title", "track"} + + +@pytest.mark.parametrize("drm_type", ["widevine", "playready"]) +def test_proxy_license_session_service_sees_its_own_default(drm_type: str) -> None: + # A session-aware service is called without session_id, so its signature default applies. + # A service that then uses session_id unconditionally fails here, before and after the + # declared_kwargs change alike. + service = SessionService() + _proxy(service, drm_type) + + assert set(service.calls[0]) == {"challenge", "title", "track", "session_id"} + assert service.calls[0]["session_id"] is None + + +def test_proxy_license_rejects_an_unknown_drm_type() -> None: + from unshackle.core.api.errors import APIError + + with pytest.raises(APIError): + _proxy(ClassicService(), "monalisa") + + +# --- the base-class PlayReady delegate -------------------------------------------------- + + +class _DelegatingService(Service): + """A service with no PlayReady implementation: the base class delegates to Widevine.""" + + def __init__(self) -> None: # deliberately skips Service.__init__ + self.calls: list[dict[str, Any]] = [] + + def get_titles(self) -> Any: ... # pragma: no cover - abstract stub + + def get_tracks(self, title: Any) -> Any: ... # pragma: no cover - abstract stub + + def get_chapters(self, title: Any) -> Any: ... # pragma: no cover - abstract stub + + def get_widevine_license(self, *, challenge: bytes, title: Any, track: Any) -> bytes: + self.calls.append({"challenge": challenge, "title": title, "track": track}) + return b"LIC" + + +class _DelegatingVarKwService(_DelegatingService): + def get_widevine_license(self, *, challenge: bytes, **kwargs: Any) -> bytes: # type: ignore[override] + self.calls.append({"challenge": challenge, **kwargs}) + return b"LIC" + + +def test_base_playready_delegate_filters_to_the_widevine_signature() -> None: + service = _DelegatingService() + assert service.get_playready_license(challenge=b"C", title="T", track="TR") == b"LIC" + assert set(service.calls[0]) == {"challenge", "title", "track"} + + +def test_base_playready_delegate_passes_everything_to_a_var_kwargs_service() -> None: + service = _DelegatingVarKwService() + service.get_playready_license(challenge=b"C", title="T", track="TR") + assert set(service.calls[0]) == {"challenge", "title", "track"} + + +def test_base_playready_delegate_cannot_forward_session_id() -> None: + # Service.get_playready_license declares (challenge, title, track) only, so the server_cdm + # filter drops session_id before the delegate ever runs. A session-aware service that wants + # PlayReady must implement get_playready_license itself. + from unshackle.core.utilities import declared_kwargs + + offered = {"challenge": b"C", "title": "T", "track": "TR", "session_id": SESSION_ID} + assert "session_id" not in declared_kwargs(Service.get_playready_license, offered) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index c9d60020..9595b463 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -67,6 +67,7 @@ from unshackle.core.tracks.hybrid import Hybrid from unshackle.core.tracks.track import assert_fragments_decrypted, has_encrypted_sample_entry from unshackle.core.utilities import ( as_requested, + declared_kwargs, embedded_audio_langs, excluded_language_tags, find_font_with_fallbacks, @@ -4007,14 +4008,16 @@ class dl: def service_licence(self, service: Service, drm_system: Optional[str] = None, **kwargs: Any) -> Any: """Call the service's licence function for the track's DRM system. + Arguments go through declared_kwargs, so a service receives only the ones its own + licence function declares. + prepare_drm passes drm_system so the choice follows the track, not the loaded CDM: a track downloading at the same time can swap self.cdm. """ if drm_system is None: drm_system = "playready" if is_playready_cdm(self.cdm) else "widevine" - if drm_system == "playready": - return service.get_playready_license(**kwargs) - return service.get_widevine_license(**kwargs) + fn = service.get_playready_license if drm_system == "playready" else service.get_widevine_license + return fn(**declared_kwargs(fn, kwargs)) def prepare_drm( self, @@ -4281,10 +4284,7 @@ class dl: ) try: - if self.service == "NF": - drm.get_NF_content_keys(cdm=track_cdm, licence=licence, certificate=certificate) - else: - drm.get_content_keys(cdm=track_cdm, licence=licence, certificate=certificate) + drm.get_content_keys(cdm=track_cdm, licence=licence, certificate=certificate) except Exception as e: if drm.content_keys: self.log.debug(f"License call failed but keys already in content_keys: {e}") diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 304b0518..82f8fbe7 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -27,6 +27,7 @@ from unshackle.core.proxies.resolve import initialize_proxy_providers, resolve_p from unshackle.core.services import Services from unshackle.core.titles import Episode, Movie, Song, Title_T from unshackle.core.tracks import Audio, Subtitle, Tracks, Video +from unshackle.core.utilities import declared_kwargs from unshackle.core.utils.collections import ci_get from unshackle.core.utils.redact import REDACTED, URL_USERINFO_RE, redact_text @@ -3678,7 +3679,9 @@ def handle_single_server_cdm( pr_drm.get_content_keys( cdm=cdm, certificate=lambda challenge, **_: None, - licence=lambda challenge, **_: service.get_playready_license(challenge=challenge, title=title, track=track), + licence=lambda **kw: service.get_playready_license( + **declared_kwargs(service.get_playready_license, {**kw, "title": title, "track": track}) + ), ) keys = {kid.hex: key for kid, key in pr_drm.content_keys.items()} elif drm_type == "widevine": @@ -3705,7 +3708,9 @@ def handle_single_server_cdm( certificate=lambda challenge, **_: service.get_widevine_service_certificate( challenge=challenge, title=title, track=track ), - licence=lambda challenge, **_: service.get_widevine_license(challenge=challenge, title=title, track=track), + licence=lambda **kw: service.get_widevine_license( + **declared_kwargs(service.get_widevine_license, {**kw, "title": title, "track": track}) + ), ) keys = {kid.hex: key for kid, key in wv_drm.content_keys.items()} else: @@ -3736,9 +3741,17 @@ def handle_proxy_license( challenge_bytes = base64.b64decode(challenge_b64) if drm_type == "widevine": - license_response = service.get_widevine_license(challenge=challenge_bytes, title=title, track=track) + license_response = service.get_widevine_license( + **declared_kwargs( + service.get_widevine_license, {"challenge": challenge_bytes, "title": title, "track": track} + ) + ) elif drm_type == "playready": - license_response = service.get_playready_license(challenge=challenge_bytes, title=title, track=track) + license_response = service.get_playready_license( + **declared_kwargs( + service.get_playready_license, {"challenge": challenge_bytes, "title": title, "track": track} + ) + ) else: raise APIError( APIErrorCode.INVALID_PARAMETERS, diff --git a/unshackle/core/drm/widevine.py b/unshackle/core/drm/widevine.py index 4662c178..a6cbf407 100644 --- a/unshackle/core/drm/widevine.py +++ b/unshackle/core/drm/widevine.py @@ -220,6 +220,10 @@ class Widevine: if hasattr(cdm, "has_cached_keys") and cdm.has_cached_keys(session_id): pass + elif not challenge: + raise Widevine.Exceptions.EmptyLicense( + "The CDM returned an empty licence challenge and has no cached keys" + ) else: log_event( "drm_license_request", @@ -231,7 +235,7 @@ class Widevine: kid_count=len(self.kids), ) try: - license_res = licence(challenge=challenge, pssh=self.pssh) + license_res = licence(challenge=challenge, pssh=self.pssh, session_id=session_id) except TypeError: license_res = licence(challenge=challenge) cdm.parse_license(session_id, license_res) @@ -265,52 +269,6 @@ class Widevine: finally: cdm.close(session_id) - def get_NF_content_keys(self, cdm: WidevineCdm, certificate: Callable, licence: Callable) -> None: - """ - Make a CDM Session and get Content Keys for this DRM Instance. - The certificate and license params are functions. unshackle gives them - the challenge and the session ID. - """ - for kid in self.kids: - if kid in self.content_keys: - continue - - session_id = cdm.open() - - try: - cert = certificate(challenge=cdm.service_certificate_challenge) - if cert and hasattr(cdm, "set_service_certificate"): - cdm.set_service_certificate(session_id, cert) - - if hasattr(cdm, "set_required_kids"): - try: - cdm.set_required_kids(self.kids, session_id=session_id) - except TypeError: # CDM predating the per-session signature - cdm.set_required_kids(self.kids) - - challenge = cdm.get_license_challenge(session_id, self.pssh) - - if hasattr(cdm, "has_cached_keys") and cdm.has_cached_keys(session_id): - pass - elif not challenge: - raise Widevine.Exceptions.EmptyLicense( - "The CDM returned an empty license challenge and has no cached keys" - ) - else: - cdm.parse_license( - session_id, - licence(session_id=session_id, challenge=challenge), - ) - - self.content_keys = {key.kid: key.key.hex() for key in cdm.get_keys(session_id, "CONTENT")} - if not self.content_keys: - raise Widevine.Exceptions.EmptyLicense("No Content Keys were within the License") - - if kid not in self.content_keys: - raise Widevine.Exceptions.CEKNotFound(f"No Content Key for KID {kid.hex} within the License") - finally: - cdm.close(session_id) - def decrypt(self, path: Path) -> None: """ Decrypt a Track with Widevine DRM. diff --git a/unshackle/core/service.py b/unshackle/core/service.py index 804ef57b..6e3f1cd9 100644 --- a/unshackle/core/service.py +++ b/unshackle/core/service.py @@ -38,6 +38,7 @@ from unshackle.core.title_cacher import TitleCacher, get_account_hash, get_regio from unshackle.core.titles import Title_T, Titles_T, remap_titles from unshackle.core.tracks import Chapters, Tracks from unshackle.core.tracks.video import Video +from unshackle.core.utilities import declared_kwargs from unshackle.core.utils.ip_info import get_ip_info from unshackle.core.utils.redact import mask_proxy @@ -512,8 +513,11 @@ class Service(metaclass=ABCMeta): :return: The License response as Bytes or a Base64 string. Do not Base64 Encode or Decode the data, return as is to reduce unnecessary computations. """ - # Delegates license handling to the Widevine license method by default if a service-specific PlayReady implementation is not provided. - return self.get_widevine_license(challenge=challenge, title=title, track=track) + # A service that implements Widevine licensing only still gets PlayReady, with the + # arguments its own signature declares. + return self.get_widevine_license( + **declared_kwargs(self.get_widevine_license, {"challenge": challenge, "title": title, "track": track}) + ) def get_clearkey_license( self, *, challenge: bytes, title: Title_T, track: AnyTrack diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index c1cbe3f3..afb98f64 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -2,6 +2,7 @@ import ast import contextlib import gzip import importlib.util +import inspect import json import logging import os @@ -16,7 +17,7 @@ from collections import defaultdict from datetime import datetime, timezone from pathlib import Path from types import ModuleType -from typing import Any, Optional, Sequence, Union +from typing import Any, Callable, Optional, Sequence, Union from urllib.parse import ParseResult, urlparse from uuid import uuid4 @@ -1254,6 +1255,23 @@ def close_debug_logger(): debug_logger = None +def declared_kwargs(fn: Callable[..., Any], kwargs: dict[str, Any]) -> dict[str, Any]: + """Keep only the arguments ``fn`` declares, so an extra one never raises TypeError. + + A service licence function that never asked for ``pssh`` or ``session_id`` must not fail + on them. Filter only against a signature this function can read and call by keyword. + Anything else, such as the licence function of a compiled service, gets every argument, + and the caller handles the TypeError. + """ + try: + params = inspect.signature(fn).parameters + except (TypeError, ValueError): + return kwargs + if "challenge" not in params or any(p.kind is p.VAR_KEYWORD for p in params.values()): + return kwargs + return {k: v for k, v in kwargs.items() if k in params} + + __all__ = ( "DebugLogger", "get_debug_logger",