feat(remote): let a remote client license with its own device identity

Relay the service's CDM calls to the client, accept the service's allowed identity config (for example ESN/Kpe/Kph), add the server_vault grant and a lent server-device mode that reads keys only from vaults and returns no login cache. Also fixes the proxied certificate fetch, Base64 Widevine licences, an answered call that stayed pending and a missing default quality.
This commit is contained in:
imSp4rky
2026-09-25 12:32:49 -06:00
parent 174192882f
commit 69360d1b4c
19 changed files with 1244 additions and 69 deletions
+2 -2
View File
@@ -84,8 +84,8 @@ Under the integrated server, an API key can do DRM licensing only through a CDM
!!! warning "PlayReady access is opt-in per user"
If a user entry omits `playready_devices`, the server gives that API key **no** PlayReady access, and logs a warning at startup. You must name the PlayReady devices explicitly for any API key that needs them. Widevine and PlayReady device files themselves are auto-loaded from your WVDs and PRDs directories.
!!! note "Device lists *are* the server-side-decryption switch"
There is no separate tier, capability flag, or permission toggle for whether the server will hand back content keys (`KID:KEY`) for a remote session download. The presence of devices on the calling API key decides it. With **empty** `devices` and `playready_devices`, the server can only proxy CDM challenges, so the client must operate its own CDM. Once those lists are **populated**, the client may request `mode:server_cdm` and receive content keys back. Do not look for a tier setting. The devices you configure are what enables server-side decryption.
!!! note "Grants decide whether the server returns content keys"
The devices on an API key decide which CDM the server can use. Two grants in the same user entry decide whether the server returns content keys (`KID:KEY`) for a remote session. `server_cdm` lets the server license with those devices. `server_vault` lets a remote session that the client's own device licenses read the server vault, and the server then makes no live licence. Without either grant, the server can only proxy CDM challenges, so the client must operate its own CDM. See [`serve`](../../reference/configuration/services.md) for both grants.
### Disabling authentication
+1 -1
View File
@@ -100,7 +100,7 @@ but the defaults hold for the overwhelming majority of responses.
| `INVALID_PARAMETERS` | 400 | One or more download or query parameters failed validation (bad codec, bitrate, sort field, and so on). |
| `AUTH_FAILED` | 401 | Authentication with the streaming service failed (bad credentials or cookies). |
| `FORBIDDEN` | 403 | The action is not allowed. Raised by server-side gates (per-key restrictions on CDM or credential overrides) and by remote session IP binding. |
| `SERVER_CDM_CAPPED` | 403 | The server CDM does not license live above the `server_cdm_max_height` of the API key. `details.reason` is `server_cdm_max_height` (with `details.max_height`) or `client_device`, for a remote session where the client's own device licenses. A remote client licenses the track with its own device. |
| `SERVER_CDM_CAPPED` | 403 | The server CDM does not license live above the `server_cdm_max_height` of the API key. `details.reason` is `server_cdm_max_height` (with `details.max_height`), `client_device`, for a remote session where the client's own device licenses, or `server_device`, for a remote session that runs on the server's device. A remote client licenses the track with its own device, except for `server_device`: then only its own vaults can supply the key. |
| `GEOFENCE` | 403 | The title is not available in the applicable region. |
| `NOT_FOUND` | 404 | A requested resource (title, history entry, and so on) does not exist. |
| `NO_CONTENT` | 404 | The request was valid but produced nothing: no matching titles, tracks, episodes, or keys. |
+72 -6
View File
@@ -215,7 +215,8 @@ hood the client walks a remote session through its lifecycle.
client saves them locally, so the **next** remote session can skip interactive
authentication. The login belongs to the client when the client sent
cookies, credentials, or cache files, or answered a login prompt (a device code
or an OTP) that led to a successful login. A server-account login never does.
or an OTP) that led to a successful login. A server-account login never does,
and neither does a remote session on the server's device (see below).
The client saves only the returned files that pass the same profile check it
applies before it sends them, so a file for another profile cannot overwrite
that profile's local file.
@@ -245,6 +246,10 @@ There are two ways DRM keys get resolved, chosen by the client's `server_cdm` fl
- Keeps your CDM local. The server only relays the license request.
- Used when `server_cdm` is `false` (the default).
- The service on the server sees your device, not the server's: its DRM system,
security level, system ID and device type. A CDM call that the service makes
(for example a session-key exchange at login) goes to your client as a `cdm_call`,
and your local CDM answers it. See [Your own device identity](#your-own-device-identity).
=== "Server-side CDM (server_cdm: true)"
@@ -277,6 +282,48 @@ There are two ways DRM keys get resolved, chosen by the client's `server_cdm` fl
licenses those tracks in proxy mode with its own local CDM. See
[`server_cdm_max_height`](../../reference/configuration/services.md).
### Your own device identity
A service can need more than a licence challenge from your device: an ESN, model keys, or a
session-key exchange that runs on the CDM. The device never leaves your machine. Only these cross
the wire:
- **Device facts.** When it creates the remote session, the client sends its DRM system, security level,
system ID and device type. The service sees a stand-in with these facts.
- **CDM calls.** When the service calls the CDM, the server sends a `cdm_call` to the client
and waits. The client makes the challenge, or parses the licence, on its own CDM. When
the service closes a CDM session, the server sends a `close` call, because a CDM holds
only a few sessions. The client polls for calls during login and while one of its own
requests runs service code, so an idle remote session sends nothing extra.
- **Session keys only.** A `keys` call returns Widevine `OPERATOR_SESSION` and `SIGNING`
keys. It never returns a content key, and the client refuses a PlayReady `keys` call.
So a server cannot use your device to license titles for itself.
- **Identity config.** A service names the config keys a client may send (for example
`esn`, `Kpe`, `Kph`, `esn_map`). The server lists them in the service listing as
`client_config`. The client sends only those keys, from its own `services: <TAG>:`
block in `unshackle.yaml`. When the client sends an identity, the server drops its own
value for each of those keys, so the two identities never mix. A mapping (such as
`esn_map`) merges the client's entries over the server's. A client entry replaces a
server entry with the same key, also when the server's key is a number. The server keeps
these values in the remote session's memory only.
A service can also offer an option that builds the remote session on the server's own
device and identity, for the quality that device reaches. The session then makes no live
licence at all: each content key comes from the server vault or the client's own vaults,
and a content key that no vault holds stops the download with an error. The create
response sets `server_device` to `true` for such a remote session. The service then uses
the server's own config: the client's identity values do not apply. The client reads the
server vault only when the API key has the `server_vault` grant (or `server_cdm`); without
it, the client reads only its own vaults. The login tokens of such a remote session hold the
server's device identity, so the server keeps them: `DELETE` returns no cache files, and the
HTTP session headers and `track.data` lose their secret-looking keys, as for a server login.
!!! note "The server vault in a session your device licenses"
With the `server_vault` grant (or `server_cdm`) on the API key, a remote session that your own
device licenses still takes content keys from the server vault. The server tries the
vault first. A track that no server vault holds goes back to your device. See
[`server_vault`](../../reference/configuration/services.md).
---
## Remote session lifecycle and expiry
@@ -365,8 +412,10 @@ The server mounts all these routes, even in `--remote-only` mode. Paths use the
`POST /api/session/create` requires `service` and `title_id`. It also accepts
`credentials`, `cookies` (base64 of zlib-compressed Netscape cookie file), `proxy`,
`no_proxy`, `profile`, `cache` (a map of `filename → base64(zlib(bytes))`),
`client_region`, `cdm_type`, `client` (a freeform identity object the dashboard
shows as sent), and the track-selection hints `range_`, `vcodec`,
`client_region`, `cdm_type`, `cdm_security_level`, `cdm_relay` (`true` when the client
answers CDM calls), `cdm_system_id` and `cdm_device_type` (Widevine only), `service_config`
(the identity config keys the service lists in `client_config`), `client` (a freeform identity
object the dashboard shows as sent), and the track-selection hints `range_`, `vcodec`,
`quality`, `best_available`, plus arbitrary service CLI options
(`additionalProperties: true`).
@@ -395,9 +444,14 @@ a per-profile ESN.
The response returns **before** authentication finishes:
```json
{ "session_id": "...uuid4...", "service": "EXAMPLE", "status": "authenticating" }
{ "session_id": "...uuid4...", "service": "EXAMPLE", "status": "authenticating",
"server_cdm": false, "server_cdm_max_height": null, "server_vault": true, "server_device": false }
```
`server_vault` tells the client that the server vault serves a remote session its own device
licenses. `server_device` tells it that the service runs on the server's device, so every
content key comes from a vault.
Authentication runs on a background thread (`asyncio.to_thread(authenticate, ...)`).
The remote session starts in `AUTHENTICATING`. The client must poll the prompt
endpoint until it reaches `authenticated` (or `failed`).
@@ -415,6 +469,10 @@ layer.
- A prompt after authentication (in `get_titles()`, `get_tracks()` or licensing) raises
`RuntimeError` at once. The client polls for prompts only during authentication, so
no answer can come.
- A CDM call (`InputBridge.request_cdm`) works during and after authentication, because the
client also polls while its own requests run. A call that no client fetches within 10 seconds
fails then, because no client is listening. A fetched call waits 60 seconds for its answer.
The call leaves the auth status as it was. An answer never marks the remote session as client-authenticated.
- `AUTH_INPUT_TIMEOUT = 600.0` seconds. This is also the TTL granted to
`AUTHENTICATING` / `PENDING_INPUT` sessions in the store.
@@ -424,9 +482,17 @@ layer.
{ "status": "authenticated" }
{ "status": "authenticating" }
{ "status": "pending_input", "prompt": "Enter code: " }
{ "status": "pending_input", "prompt": "...", "cdm_call": { "op": "challenge", "drm": "widevine", "init_data": "...", "license_type": "OFFLINE", "privacy_mode": true, "service_certificate": "..." } }
{ "status": "failed", "error": "...message..." }
```
A `cdm_call` comes first, in any auth state. The client answers it with a JSON object in
`response`: `{"session": "...", "challenge": "<base64>"}` for `challenge`,
`{"keys": [{"kid", "type", "key", "permissions"}]}` for `keys`, `{}` for `close`, or
`{"error": "..."}` when its CDM refuses. An older client refuses `close`, and the server
ignores that refusal. An answered call stops being pending at once, so a client that polls again
straight away does not see it twice.
A missing remote session returns `404 SESSION_NOT_FOUND`. An IP mismatch returns
`403 FORBIDDEN`. `POST /api/session/{id}/prompt` takes `{ "response": "..." }` and
returns `{ "status": "accepted" }`. A post with no pending prompt is an
@@ -477,8 +543,8 @@ _sessions/<pbkdf2_hmac(sha256, X-Secret-Key, "unshackle-session-ns", 100000)[:12
```
The handler writes forwarded `cache` files into that directory before authentication.
On `DELETE`, when the login belongs to the client (`client_auth`), the handler
harvests updated cache files from the whole directory tree (compressing each
On `DELETE`, when the login belongs to the client (`client_auth`) and the remote
session does not run on the server's device, the handler harvests updated cache files from the whole directory tree (compressing each
with zlib and base64-encoding, **excluding** `titles_*` files) and returns them
under a `cache` field, so the client can keep refreshed tokens. Each cache key
is the file path relative to that directory, with `/` separators and no `.json`
+11
View File
@@ -180,6 +180,14 @@ content key of a higher track when the service shares one content key between th
accounts enabled, a client that reports a stronger device than it holds makes the server's
account request those manifests; the licence server still refuses its challenge.
`server_vault` lets a remote session that the client's own device licenses take content keys
from the server vault. Set it to `true` for every service, or to a list of service tags. It is
`false` unless the entry sets it, and `server_cdm` for a service implies it. The server looks up
every KID of the track in its vault and makes no live licence. A track that the vault does not
hold goes back to the client's device. When the service runs the remote session on the server's
own device (a service option, such as a server-identity flag), the client's own vaults, and the
server vault with this grant, are the only source, and a missing content key stops the download.
`admin` is a boolean that lets the API key run the maintenance endpoints (clear-cache, clear-temp, refresh-services). It is `false` unless the entry sets it. Keys that have no `users` entry, such as `api_secret`, keep that access.
A `tier` names an entry under `serve.tiers`, which holds the settings that several API keys
@@ -274,6 +282,9 @@ serve:
server_cdm: [EXAMPLE1] # the server licenses only EXAMPLE1; EXAMPLE2 needs a local CDM
server_accounts: [EXAMPLE1] # may authenticate EXAMPLE1 with one of the server's own accounts
server_proxy: true # this key may also use the server's proxy providers
f3a4b5c6:
services: [EXAMPLE1]
server_vault: [EXAMPLE1] # the client's device licenses; the server vault still serves keys
```
!!! note "`dl` keys inside `serve`"
+455
View File
@@ -0,0 +1,455 @@
"""A remote client's own CDM behind the server's stand-in: the relay, the key policy and the config overlay."""
from __future__ import annotations
import json
import threading
import time
from types import SimpleNamespace
from typing import Any
from uuid import UUID
import click
import pytest
from pywidevine import DeviceTypes
from pywidevine.pssh import PSSH
from unshackle.core.api import handlers
from unshackle.core.api.errors import APIError
from unshackle.core.api.input_bridge import AuthStatus, InputBridge
from unshackle.core.cdm.client_relay import ClientDeviceCdm, answer_cdm_call
pytestmark = pytest.mark.unit
MSL_PSSH = PSSH("AAAANHBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAABQIARIQAAAAAAPSZ0kAAAAAAAAAAA==")
SESSION_KID = UUID("00000000-03d2-6749-0000-000000000000")
CONTENT_KID = UUID("11111111-2222-3333-4444-555555555555")
class FakeWidevineCdm:
"""Records the calls a relayed exchange makes on the client's device."""
is_playready = False
def __init__(self) -> None:
self.calls: list[tuple] = []
self.closed: list[bytes] = []
def open(self) -> bytes:
return b"local-session"
def close(self, session_id: bytes) -> None:
self.closed.append(session_id)
def set_service_certificate(self, session_id: bytes, certificate: str) -> None:
self.calls.append(("certificate", certificate))
def get_license_challenge(self, session_id: bytes, pssh: PSSH, license_type: str, privacy_mode: bool) -> bytes:
self.calls.append(("challenge", pssh.dumps(), license_type, privacy_mode))
return b"challenge-bytes"
def parse_license(self, session_id: bytes, license_message: bytes) -> None:
self.calls.append(("license", license_message))
def get_keys(self, session_id: bytes) -> list[Any]:
return [
SimpleNamespace(kid=SESSION_KID, type="OPERATOR_SESSION", key=b"\x01" * 16, permissions=["allow_encrypt"]),
SimpleNamespace(kid=CONTENT_KID, type="CONTENT", key=b"\x02" * 16, permissions=[]),
]
class DirectBridge:
"""Answers each call at once on the client's CDM, as the client poller would."""
def __init__(self, cdm: Any) -> None:
self.cdm = cdm
self.sessions: dict[str, bytes] = {}
def request_cdm(self, call: dict[str, Any]) -> dict[str, Any]:
return json.loads(json.dumps(answer_cdm_call(self.cdm, json.loads(json.dumps(call)), self.sessions)))
def test_a_key_exchange_through_the_stand_in_returns_only_session_keys() -> None:
device = FakeWidevineCdm()
cdm = ClientDeviceCdm(DirectBridge(device), is_playready=False, security_level=1, system_id=4464)
session_id = cdm.open()
cdm.set_service_certificate(session_id, "Q0VSVA==")
challenge = cdm.get_license_challenge(session_id, MSL_PSSH, "OFFLINE", True)
cdm.parse_license(session_id, b"licence")
keys = cdm.get_keys(session_id)
assert challenge == b"challenge-bytes"
assert device.calls == [
("certificate", "Q0VSVA=="),
("challenge", MSL_PSSH.dumps(), "OFFLINE", True),
("license", b"licence"),
]
assert [(k.kid, k.type, k.key, k.permissions) for k in keys] == [
(SESSION_KID, "OPERATOR_SESSION", b"\x01" * 16, ["allow_encrypt"])
]
def test_the_client_refuses_to_hand_over_playready_keys() -> None:
device = SimpleNamespace(is_playready=True)
answer = answer_cdm_call(device, {"op": "keys", "session": "x", "license": "bGljZW5jZQ=="}, {})
assert "stay on this machine" in answer["error"]
def test_the_client_refuses_a_challenge_for_the_other_drm_system() -> None:
answer = answer_cdm_call(FakeWidevineCdm(), {"op": "challenge", "drm": "playready", "init_data": "<x/>"}, {})
assert "not a playready device" in answer["error"]
def test_a_cdm_call_after_authentication_leaves_the_status_and_client_auth_alone() -> None:
bridge = InputBridge()
bridge.status = AuthStatus.AUTHENTICATED
result: list[dict] = []
worker = threading.Thread(target=lambda: result.append(bridge.request_cdm({"op": "challenge"}, timeout=5)))
worker.start()
for _ in range(100):
if bridge.get_pending_cdm_call():
break
time.sleep(0.01)
assert bridge.get_pending_cdm_call() == {"op": "challenge"}
assert bridge.submit_response(json.dumps({"session": "s", "challenge": "Yw=="}))
worker.join(timeout=2)
assert result == [{"session": "s", "challenge": "Yw=="}]
assert bridge.status is AuthStatus.AUTHENTICATED
assert bridge.answered is False
assert bridge.get_pending_cdm_call() is None
@pytest.mark.parametrize(
("answer", "message"),
[("not json", "update unshackle"), (json.dumps({"error": "no local CDM is loaded"}), "no local CDM")],
)
def test_a_bad_or_refused_answer_stops_the_service(answer: str, message: str) -> None:
bridge = InputBridge()
threading.Timer(0.1, lambda: bridge.submit_response(answer)).start()
with pytest.raises(RuntimeError, match=message):
bridge.request_cdm({"op": "keys"}, timeout=5)
def test_the_overlay_keeps_only_the_client_identity() -> None:
server = {"esn": "SERVER", "Kpe": "server-kpe", "esn_map": {7000: "server-template"}, "endpoints": {"a": "b"}}
merged, identity = handlers.client_config_overlay(
server,
("esn", "Kpe", "Kph", "esn_map"),
{"esn": "CLIENT", "esn_map": {"4464": "client-template"}, "endpoints": {"a": "evil"}},
)
assert identity is True
assert merged == {
"esn": "CLIENT",
"esn_map": {7000: "server-template", "4464": "client-template"},
"endpoints": {"a": "b"},
}
def test_the_overlay_keeps_the_server_identity_when_the_client_sends_none() -> None:
server = {"esn": "SERVER", "Kph": "k", "esn_map": {7000: "t"}, "other": 1}
merged, identity = handlers.client_config_overlay(server, ("esn", "Kph", "esn_map"), {"esn_map": {"1": "c"}})
assert identity is False
assert merged == {"esn": "SERVER", "Kph": "k", "esn_map": {7000: "t", "1": "c"}, "other": 1}
@pytest.mark.parametrize("supplied", [["esn"], {"esn": ["a"]}, {"esn": {"k": {"nested": 1}}}, {"esn": "x" * 20000}])
def test_the_overlay_rejects_ill_formed_client_config(supplied: Any) -> None:
with pytest.raises(APIError):
handlers.client_config_overlay({}, ("esn",), supplied)
def test_a_relaying_client_gets_a_stand_in_with_its_device_facts(monkeypatch: pytest.MonkeyPatch) -> None:
built: dict = {}
class Declares:
CLIENT_CONFIG = ("esn",)
monkeypatch.setattr(handlers, "load_service_yaml", lambda tag: {"esn": "SERVER"})
monkeypatch.setattr(
handlers,
"build_parent_ctx",
lambda profile, cdm, *a, **k: built.update(cdm=cdm, config=a[3], params=k["extra_params"]),
)
monkeypatch.setattr(handlers.Services, "load", lambda tag: Declares)
monkeypatch.setattr(handlers, "instantiate_service", lambda *a, **k: "service")
data = {
"cdm_type": "widevine",
"cdm_security_level": 1,
"cdm_relay": True,
"cdm_system_id": 4464,
"cdm_device_type": "ANDROID",
"service_config": {"esn": "CLIENT"},
}
bridge = InputBridge()
handlers.create_service_instance("EXAMPLE1", "t", data, None, [], None, client_device=True, input_bridge=bridge)
cdm = built["cdm"]
assert isinstance(cdm, ClientDeviceCdm)
assert (cdm.is_playready, cdm.security_level, cdm.system_id, cdm.device_type) == (
False,
1,
4464,
DeviceTypes.ANDROID,
)
assert cdm.bridge is bridge
assert built["config"] == {"esn": "CLIENT"}
assert built["params"]["client_identity"] is True
def test_an_answered_call_is_no_longer_pending_before_the_service_wakes() -> None:
bridge = InputBridge()
release = threading.Event()
real_wait = bridge._response_ready.wait
def slow_wait(timeout: float) -> bool:
release.wait(5) # hold the service thread asleep after the answer arrives
return real_wait(timeout)
bridge._response_ready.wait = slow_wait # type: ignore[method-assign]
worker = threading.Thread(target=lambda: bridge.request_cdm({"op": "challenge"}, timeout=5))
worker.start()
for _ in range(100):
if bridge.get_pending_cdm_call():
break
time.sleep(0.01)
assert bridge.submit_response(json.dumps({"session": "s", "challenge": "Yw=="}))
assert bridge.get_pending_cdm_call() is None
assert bridge.submit_response("{}") is False
release.set()
worker.join(timeout=2)
def test_a_lent_server_device_marks_the_stand_in() -> None:
server = object()
cdm = ClientDeviceCdm(DirectBridge(FakeWidevineCdm()), is_playready=True, server_device=lambda: server)
assert cdm.lent is False
assert cdm.lend_server_device() is server
assert cdm.lent is True
def test_a_lent_device_session_never_licenses_live() -> None:
refusal = handlers.live_licence_refusal(SimpleNamespace(server_device=True, server_cdm=False), object())
assert refusal is not None
assert refusal.details == {"reason": "server_device"}
def test_a_vault_only_miss_raises_the_refusal(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(handlers, "check_vaults", lambda kids, service: None)
monkeypatch.setattr(handlers, "ensure_track_drm", lambda *a, **k: None)
refusal = handlers.live_licence_refusal(SimpleNamespace(server_device=True), object())
with pytest.raises(APIError) as caught:
handlers.handle_single_server_cdm(
SimpleNamespace(),
None,
SimpleNamespace(drm=[]),
MSL_PSSH.dumps(),
"widevine",
None,
refusal=refusal,
vault_only=True,
)
assert caught.value is refusal
def test_a_vault_only_hit_needs_no_server_device(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
handlers, "check_vaults", lambda kids, service: ({SESSION_KID.hex: "k"}, {SESSION_KID.hex: "v"})
)
monkeypatch.setattr(handlers, "ensure_track_drm", lambda *a, **k: None)
monkeypatch.setattr(
handlers, "resolve_device_name", lambda *a, **k: pytest.fail("a vault-only lookup loads no device")
)
sources: dict = {}
keys = handlers.handle_single_server_cdm(
SimpleNamespace(), None, SimpleNamespace(drm=[]), MSL_PSSH.dumps(), "widevine", None, sources, vault_only=True
)
assert keys == {SESSION_KID.hex: "k"} and sources == {SESSION_KID.hex: "v"}
def test_the_client_stops_with_a_clear_message_when_no_vault_has_the_key() -> None:
from unshackle.core.remote_service import RemoteService
svc = object.__new__(RemoteService)
with pytest.raises(click.ClickException, match="in no vault"):
svc.license_locally(SimpleNamespace(id="vid"), {"reason": "server_device"})
class ClosingBridge(DirectBridge):
"""A client whose unshackle predates the ``close`` call, so it refuses it."""
def request_cdm(self, call: dict[str, Any]) -> dict[str, Any]:
if call["op"] == "close":
raise RuntimeError("The client's CDM refused the call: unknown CDM call 'close'")
return super().request_cdm(call)
def test_closing_the_stand_in_session_closes_the_client_session() -> None:
device = FakeWidevineCdm()
bridge = DirectBridge(device)
cdm = ClientDeviceCdm(bridge, is_playready=False)
session_id = cdm.open()
cdm.get_license_challenge(session_id, MSL_PSSH)
cdm.close(session_id)
assert device.closed == [b"local-session"]
assert bridge.sessions == {}
def test_a_client_that_refuses_close_does_not_stop_the_service() -> None:
device = FakeWidevineCdm()
cdm = ClientDeviceCdm(ClosingBridge(device), is_playready=False)
session_id = cdm.open()
cdm.get_license_challenge(session_id, MSL_PSSH)
cdm.close(session_id)
cdm.close(cdm.open())
assert cdm.get_keys(session_id) == []
def test_a_lent_device_gives_the_service_the_server_config_back(monkeypatch: pytest.MonkeyPatch) -> None:
built: dict = {}
server_device = object()
class Declares:
CLIENT_CONFIG = ("esn", "esn_map")
monkeypatch.setattr(
handlers, "load_service_yaml", lambda tag: {"esn": "SERVER", "esn_map": {7000: "s"}, "other": 1}
)
monkeypatch.setattr(handlers, "load_full_cdm", lambda *a: server_device)
monkeypatch.setattr(handlers, "build_parent_ctx", lambda profile, cdm, *a, **k: built.update(cdm=cdm, config=a[3]))
monkeypatch.setattr(handlers.Services, "load", lambda tag: Declares)
monkeypatch.setattr(handlers, "instantiate_service", lambda *a, **k: "service")
data = {"cdm_type": "widevine", "cdm_relay": True, "service_config": {"esn": "CLIENT", "esn_map": {"1": "c"}}}
handlers.create_service_instance(
"EXAMPLE1", "t", data, None, [], None, client_device=True, input_bridge=InputBridge()
)
service_config = built["config"]
assert service_config["esn"] == "CLIENT"
assert built["cdm"].lend_server_device() is server_device
assert service_config == {"esn": "SERVER", "esn_map": {7000: "s"}, "other": 1}
def test_a_client_map_entry_replaces_the_server_entry_of_the_same_number() -> None:
merged, _ = handlers.client_config_overlay(
{"esn_map": {4464: "server-template", 7000: "other"}}, ("esn_map",), {"esn_map": {"4464": "client-template"}}
)
assert merged["esn_map"] == {7000: "other", "4464": "client-template"}
def lent_device_session() -> Any:
return SimpleNamespace(
server_device=True,
client_auth=True,
cache_tag="_sessions/abc/sid/EXAMPLE1",
input_bridge=None,
service_instance=SimpleNamespace(
session=SimpleNamespace(
headers={"Authorization": "Bearer server", "User-Agent": "ua"},
cookies=[SimpleNamespace(name="account", value="client")],
)
),
)
def test_a_lent_device_session_returns_no_cache_at_the_end(monkeypatch: pytest.MonkeyPatch) -> None:
import asyncio
from unshackle.core.api import session_store
session = lent_device_session()
async def validated(session_id: str, request: Any) -> Any:
return session
class Store:
async def delete(self, session_id: str) -> bool:
return True
monkeypatch.setattr(handlers, "get_validated_session", validated)
monkeypatch.setattr(session_store, "get_session_store", lambda: Store())
monkeypatch.setattr(handlers, "collect_cache_files", lambda tag: {"MSL/server-esn": "tokens"})
response = asyncio.run(handlers.session_delete_handler("sid", None))
assert "cache" not in json.loads(response.body)
def test_a_lent_device_session_keeps_its_auth_headers_and_track_secrets() -> None:
session = lent_device_session()
headers, cookies = handlers.client_session_auth(session)
assert headers == {"User-Agent": "ua"}
assert cookies == {"account": "client"}
assert handlers.scrub_track_data({"token": "server", "id": 1}, session) == {"id": 1}
def test_a_lent_device_without_the_vault_grant_asks_the_server_for_no_key() -> None:
import logging
from unshackle.core.remote_service import RemoteService
svc = object.__new__(RemoteService)
svc.log = logging.getLogger("test")
svc._session_id = "sid"
svc._server_cdm = True
svc._server_device = True
svc._local_vaults_only = True
svc.client_licensed = set()
svc.client = SimpleNamespace(post=lambda *a, **k: pytest.fail("the server refuses every key request"))
track = SimpleNamespace(id="vid", drm=None)
svc.resolve_server_keys(SimpleNamespace(tracks=SimpleNamespace(videos=[track], audio=[])))
with pytest.raises(click.ClickException, match="cannot read the server vault"):
svc.proxy_license(b"challenge", track, "widevine")
@pytest.mark.parametrize("extra", [None, {"quality": None}])
def test_every_service_context_defaults_quality_to_a_list(extra: Any) -> None:
parent = handlers.build_parent_ctx(None, None, None, False, [], {}, extra_params=extra)
assert parent.params["quality"] == []
def test_a_call_no_client_fetches_fails_fast_and_restores_the_status() -> None:
bridge = InputBridge()
bridge.status = AuthStatus.AUTHENTICATED
started = time.monotonic()
with pytest.raises(TimeoutError, match="none is listening"):
bridge.request_cdm({"op": "challenge"}, timeout=30, pickup=0.2)
assert time.monotonic() - started < 5
assert bridge.status is AuthStatus.AUTHENTICATED
assert bridge.get_pending_cdm_call() is None
def test_a_fetched_call_gets_the_full_answer_window() -> None:
bridge = InputBridge()
result: list[dict] = []
worker = threading.Thread(target=lambda: result.append(bridge.request_cdm({"op": "keys"}, timeout=5, pickup=0.3)))
worker.start()
for _ in range(100):
if bridge.get_pending_cdm_call():
break
time.sleep(0.01)
time.sleep(0.6) # past the pickup window: a fetched call must still wait for its answer
assert bridge.submit_response(json.dumps({"keys": []}))
worker.join(timeout=2)
assert result == [{"keys": []}]
def test_cancel_wakes_a_call_waiting_for_a_client() -> None:
bridge = InputBridge()
threading.Timer(0.1, bridge.cancel).start()
started = time.monotonic()
with pytest.raises(RuntimeError, match="cancelled"):
bridge.request_cdm({"op": "challenge"}, timeout=30, pickup=20)
assert time.monotonic() - started < 5
@@ -55,7 +55,7 @@ async def _run_batch(monkeypatch, tracks, cdm_type, calls):
async def fake_get_session(session_id, request):
return session
def fake_single(service, title, track, pssh_str, drm_type, request, sources=None, refusal=None):
def fake_single(service, title, track, pssh_str, drm_type, request, sources=None, refusal=None, vault_only=False):
calls.append((pssh_str, drm_type))
return {"00" * 16: "key"}
@@ -549,7 +549,7 @@ def test_session_create_writes_nested_cache_and_skips_colliding_keys(tmp_path, m
"create_service_instance",
lambda *args, **kwargs: (SimpleNamespace(log=logging.getLogger("Svc")), None, None),
)
monkeypatch.setattr(handlers, "InputBridge", lambda: (_ for _ in ()).throw(Stop()))
monkeypatch.setattr("unshackle.core.api.session_store.get_session_store", lambda: (_ for _ in ()).throw(Stop()))
cache = {"x": blob("flat"), "x.json/y": blob("collides"), "MSL/keys.v2": blob("dotted")}
response = asyncio.run(handlers.session_create_handler({"service": "TESTSVC", "title_id": "t", "cache": cache}))
@@ -107,7 +107,7 @@ async def test_batch_reports_vault_keys_from_the_init_segment_pssh(monkeypatch):
async def validated(sid, req):
return session
def fake_single(service, title, track, pssh_str, drm_type, request, sources=None, refusal=None):
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"}
@@ -373,7 +373,7 @@ def licence_env(monkeypatch):
calls: list = []
vault = {}
def fake_single(service, title, track, pssh_str, drm_type, request, sources=None, refusal=None):
def fake_single(service, title, track, pssh_str, drm_type, request, sources=None, refusal=None, vault_only=False):
calls.append((track.id, pssh_str))
if pssh_str in vault:
return dict(vault[pssh_str])
@@ -133,7 +133,7 @@ async def test_batch_shares_full_bundle_per_pssh(monkeypatch):
bundle_a = {REAL.hex: "k_real", PSSH_KID.hex: "k_pssh", OTHER.hex: "k_other"}
bundle_b = {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": "k_b"}
def fake_single(service, title, track, pssh_str, drm_type, request, sources=None, refusal=None):
def fake_single(service, title, track, pssh_str, drm_type, request, sources=None, refusal=None, vault_only=False):
calls.append(pssh_str)
return dict(bundle_a) if pssh_str == pssh_a else dict(bundle_b)
+225 -26
View File
@@ -218,9 +218,11 @@ def build_parent_ctx(
parent = click.Context(dummy)
parent.obj = ContextData(config=service_config, cdm=cdm, proxy_providers=proxy_providers, profile=profile)
params = {"proxy": proxy_param, "no_proxy": no_proxy, "served": True}
params: Dict[str, Any] = {"proxy": proxy_param, "no_proxy": no_proxy, "served": True}
if extra_params:
params.update(extra_params)
# dl defaults -q to [], and services iterate it
params["quality"] = params.get("quality") or []
parent.params = params
return parent
@@ -586,9 +588,19 @@ def serve_user_config(api_key: str) -> Dict[str, Any]:
def server_cdm_allowed(request: Optional[web.Request] = None, service: Optional[str] = None) -> bool:
"""Whether the calling API key may have the server operate the CDM licensing for ``service``.
"""Whether the calling API key may have the server operate the CDM licensing for ``service``."""
return key_grant_allows(request, service, "server_cdm")
Configured API keys opt in with ``server_cdm: true`` for every service, or with a list of
def server_vault_allowed(request: Optional[web.Request] = None, service: Optional[str] = None) -> bool:
"""Whether the calling API key may read the server vault in a remote session its own device licenses."""
return key_grant_allows(request, service, "server_vault") or server_cdm_allowed(request, service)
def key_grant_allows(request: Optional[web.Request], service: Optional[str], name: str) -> bool:
"""Whether the calling API key holds the ``name`` grant for ``service``.
Configured API keys opt in with ``<name>: true`` for every service, or with a list of
service tags for only those. An API key absent from ``serve.users`` (the admin secret) keeps
full access.
"""
@@ -598,7 +610,7 @@ def server_cdm_allowed(request: Optional[web.Request] = None, service: Optional[
user_config = config.serve.get("users", {}).get(secret_key)
if user_config is None:
return True
allowed = user_config.get("server_cdm", False)
allowed = user_config.get(name, False)
if isinstance(allowed, str):
allowed = [allowed]
if isinstance(allowed, (list, tuple, set)):
@@ -692,6 +704,12 @@ def live_licence_refusal(session: Any, track: Any) -> Optional[APIError]:
Only a live licence spends the server's device, so a vault hit always passes. A track measures as the
smallest ``-q`` that selects it: the lower of its height and the 16:9 height of its width.
"""
if getattr(session, "server_device", False):
return APIError(
APIErrorCode.SERVER_CDM_CAPPED,
"This session runs on the server's device, so its keys come only from a vault.",
details={"reason": "server_device"},
)
if getattr(session, "server_cdm", None) is False:
return capped_error("This session licenses with your own device, so the server CDM makes no licence for it.")
cap = getattr(session, "server_cdm_max_height", None)
@@ -699,7 +717,6 @@ def live_licence_refusal(session: Any, track: Any) -> Optional[APIError]:
width = getattr(track, "width", None) if isinstance(track, Video) else None
measures = [m for m in (height, int(width * 9 / 16) if width else None) if m]
height = min(measures) if measures else None
# ponytail: a track with no size passes; a parser that fills sizes in closes that gap
if cap is None or not height or height <= cap:
return None
return capped_error(
@@ -3133,6 +3150,10 @@ SESSION_TRANSPORT_KEYS = {
"proxy_region",
"cdm_type",
"cdm_security_level",
"cdm_relay",
"cdm_system_id",
"cdm_device_type",
"service_config",
"range_",
"vcodec",
"quality",
@@ -3178,6 +3199,80 @@ def forwarded_dl_params(data: Dict[str, Any]) -> Dict[str, Any]:
return params
# A client's service config carries a device identity (an ESN, a few keys), never a bulk payload.
MAX_CLIENT_CONFIG_BYTES = 16384
def client_config_overlay(
service_config: Dict[str, Any], allowed: Iterable[str], supplied: Any
) -> tuple[Dict[str, Any], bool]:
"""The service config for a remote session that the client's own device licenses, and whether the client sent an identity.
Only the ``allowed`` keys come from the client, and a mapping merges its entries over the server's.
When the client sends any scalar value (an identity such as an ESN), the server's scalar values for
the allowed keys are dropped, so the two identities never mix. With none, the server's stay.
"""
allowed = set(allowed)
if supplied is None:
supplied = {}
if not isinstance(supplied, dict) or len(json.dumps(supplied, default=str)) > MAX_CLIENT_CONFIG_BYTES:
raise APIError(
APIErrorCode.INVALID_INPUT, f"service_config must be an object under {MAX_CLIENT_CONFIG_BYTES} bytes"
)
def scalar(value: Any) -> bool:
return isinstance(value, str) or is_whole_number(value)
for key, value in supplied.items():
if key in allowed and not scalar(value) and not (isinstance(value, dict) and all(map(scalar, value.values()))):
raise APIError(
APIErrorCode.INVALID_INPUT,
f"service_config.{key} must be a string, a whole number, or an object of them",
)
identity = any(key in allowed and scalar(value) for key, value in supplied.items())
merged = {k: v for k, v in service_config.items() if not identity or k not in allowed or isinstance(v, dict)}
for key, value in supplied.items():
if key not in allowed:
log.warning(f"Ignoring service_config.{sanitize_log(str(key))}: the service does not take it from a client")
elif isinstance(value, dict):
existing = merged.get(key)
base = existing if isinstance(existing, dict) else {}
# JSON makes every client key a string, so a server key it spells the same way gives way
merged[key] = {**{k: v for k, v in base.items() if str(k) not in value}, **value}
else:
merged[key] = value
return merged, identity
def client_device_cdm(
data: Dict[str, Any], input_bridge: Optional[InputBridge], server_device: Optional[Callable[[], Any]] = None
) -> Any:
"""The service's view of the client's device: a relaying stand-in when the client can answer CDM calls."""
from unshackle.core.cdm.client_relay import ClientDeviceCdm
level = data.get("cdm_security_level")
level = level if is_whole_number(level) else None
if not (data.get("cdm_relay") is True and input_bridge is not None):
return cdm_type_stub(str(data.get("cdm_type")), level)
from pywidevine import DeviceTypes
system_id = data.get("cdm_system_id")
device_type = data.get("cdm_device_type")
if device_type is not None and device_type not in DeviceTypes.__members__:
raise APIError(
APIErrorCode.INVALID_INPUT, f"cdm_device_type must be one of {', '.join(DeviceTypes.__members__)}"
)
return ClientDeviceCdm(
input_bridge,
server_device=server_device,
is_playready=data.get("cdm_type") == "playready",
security_level=level,
system_id=system_id if is_whole_number(system_id) else None,
device_type=DeviceTypes[device_type] if device_type else None,
)
def create_service_instance(
normalized_service: str,
title_id: str,
@@ -3188,10 +3283,12 @@ def create_service_instance(
server_account: bool = False,
server_cdm: bool = False,
client_device: bool = False,
input_bridge: Optional[InputBridge] = None,
) -> Any:
"""Make a service instance and resolve its credentials and cookies.
With ``client_device`` the service sees a stand-in with the client's DRM system and security level.
With ``client_device`` the service sees a stand-in for the client's device, which relays CDM calls
through ``input_bridge`` when the client can answer them, and a config holding the client's own identity.
With ``server_account`` the service takes the server's own credential and cookie file for
``profile`` and drops anything the client sent. Otherwise only client-sent data counts: a
@@ -3202,11 +3299,25 @@ def create_service_instance(
from unshackle.core.credential import Credential
from unshackle.core.tracks import Video
service_config = load_service_yaml(normalized_service)
server_config = service_config = load_service_yaml(normalized_service)
service_module = Services.load(normalized_service)
cdm: Any
client_identity = False
if client_device:
level = data.get("cdm_security_level")
cdm = cdm_type_stub(str(data.get("cdm_type")), level if is_whole_number(level) else None)
client_config = getattr(service_module, "CLIENT_CONFIG", ())
if client_config:
service_config, client_identity = client_config_overlay(
server_config, client_config, data.get("service_config")
)
def lend_server_device() -> Any:
"""The server's device, with the server's own config back in the dict the service holds as ``self.config``."""
if service_config is not server_config:
service_config.clear()
service_config.update(server_config)
return load_full_cdm(normalized_service, profile, None)
cdm = client_device_cdm(data, input_bridge, lend_server_device)
else:
cdm = load_full_cdm(normalized_service, profile, None if server_cdm else data.get("cdm_type"))
@@ -3248,6 +3359,10 @@ def create_service_instance(
cookies = load_client_cookies(data.get("cookies"))
extra_params["cookies_supplied"] = cookies is not None
# A service with CLIENT_CONFIG reads these: with client_identity it uses only what the client sent,
# and without it the server's identity, whose keys then come only from a vault.
extra_params["client_device"] = client_device
extra_params["client_identity"] = client_identity
# Services key their token caches on this. Only sessions get it: they run on a cache
# directory of their own, where the list and search handlers share the server's cache.
extra_params["profile"] = profile
@@ -3262,7 +3377,6 @@ def create_service_instance(
extra_params=extra_params,
)
service_module = Services.load(normalized_service)
service_instance = instantiate_service(parent_ctx, service_module, title_id, data, SESSION_TRANSPORT_KEYS)
return service_instance, cookies, credential
@@ -3315,7 +3429,13 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req
log.info(f"Using server account '{sanitize_log(profile or 'default')}' for {normalized_service}")
use_server_cdm, max_height = choose_session_cdm(request, normalized_service, data)
client_device = max_height is not None and not use_server_cdm
# an old client without cdm_relay keeps the server's device outside a capped session
client_device = (
not use_server_cdm
and bool(data.get("cdm_type"))
and (max_height is not None or data.get("cdm_relay") is True)
)
bridge = InputBridge()
log_buffer = None if server_account else SessionLogBuffer()
service_class_name = getattr(Services.load(normalized_service), "__name__", normalized_service)
@@ -3331,6 +3451,7 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req
server_account=server_account,
server_cdm=use_server_cdm,
client_device=client_device,
input_bridge=bridge,
)
service_instance, cookies, credential = await asyncio.to_thread(build_service)
@@ -3347,7 +3468,6 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req
if cache_data and session_cache_tag:
write_client_cache(cache_data, session_cache_tag)
bridge = InputBridge()
service_instance._input_bridge = bridge
store = get_session_store()
@@ -3362,6 +3482,10 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req
session.cache_tag = session_cache_tag
session.server_cdm = use_server_cdm
session.server_cdm_max_height = max_height
session.server_vault = client_device and server_vault_allowed(request, normalized_service)
# the service asked for the server's device in __init__, so the session never licenses live
stand_in = getattr(getattr(getattr(service_instance, "ctx", None), "obj", None), "cdm", None)
session.server_device = getattr(stand_in, "lent", False) is True
session.client_auth = not server_account and (
cookies is not None or credential is not None or bool(cache_data and session_cache_tag)
)
@@ -3398,6 +3522,8 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req
"server_account": server_account,
"server_cdm": use_server_cdm,
"server_cdm_max_height": max_height,
"server_vault": session.server_vault,
"server_device": session.server_device,
}
)
@@ -3467,7 +3593,8 @@ def client_session_auth(session: Any) -> tuple[Dict[str, str], Dict[str, str]]:
Only a client that sent its own cookies or credentials gets them back. Otherwise (a server
account, or an anonymous login through the server's proxy) the cookie jar stays on the
server and the server drops the auth-bearing headers, so the client cannot reuse a login
the server paid for.
the server paid for. A remote session on the server's lent device keeps its cookies (the client's account)
but drops those headers too.
"""
svc_session = session.service_instance.session
headers = dict(svc_session.headers) if hasattr(svc_session, "headers") and svc_session.headers else {}
@@ -3476,12 +3603,21 @@ def client_session_auth(session: Any) -> tuple[Dict[str, str], Dict[str, str]]:
for cookie in svc_session.cookies:
if hasattr(cookie, "name") and hasattr(cookie, "value"):
cookies[cookie.name] = cookie.value
if not session.client_auth:
if not client_owns_login(session):
headers = {k: v for k, v in headers.items() if not CONFIG_SECRET_KEY_RE.search(str(k))}
if not session.client_auth:
cookies = {}
return headers, cookies
def client_owns_login(session: Any) -> bool:
"""Whether the login state of ``session`` is the client's to take back.
A remote session on the server's lent device logs in with the server's device identity, so its tokens stay there.
"""
return bool(session.client_auth) and not getattr(session, "server_device", False)
def scrub_secret_keys(value: Any) -> Any:
"""``value`` without any dict entry, at any depth, whose key looks like a secret."""
if isinstance(value, dict):
@@ -3497,7 +3633,7 @@ def scrub_track_data(data: Optional[Dict[str, Any]], session: Any) -> Optional[D
A service can stash a token in ``track.data`` for its license call; under the same rule as
:func:`client_session_auth` that token never reaches a client that did not log in itself.
"""
if not data or session.client_auth:
if not data or client_owns_login(session):
return data
return scrub_secret_keys(data) or None
@@ -3802,20 +3938,26 @@ def require_authenticated(session: Any) -> None:
async def session_prompt_get_handler(session_id: str, request: Optional[web.Request] = None) -> web.Response:
"""Poll for pending interactive prompts during authentication.
"""Poll for pending interactive prompts during authentication, and for CDM calls at any time.
Returns the current auth status and any pending prompt that the
remote client should display to the user.
remote client should display to the user. A CDM call comes first, because service code can
wait on the client's CDM after authentication too, for example in get_tracks.
"""
session = await get_validated_session(session_id, request)
bridge = session.input_bridge
if bridge and (cdm_call := bridge.get_pending_cdm_call()):
return web.json_response(
{"status": "pending_input", "prompt": bridge.get_pending_prompt(), "cdm_call": cdm_call}
)
if session.auth_status == AuthStatus.AUTHENTICATED:
return web.json_response({"status": "authenticated"})
if session.auth_status == AuthStatus.FAILED:
return web.json_response({"status": "failed", "error": session.auth_error or "unknown error"})
bridge = session.input_bridge
if bridge:
prompt = bridge.get_pending_prompt()
if prompt:
@@ -4156,6 +4298,12 @@ def resolve_device_name(user_config: dict, drm_type: str, service_tag: str = "")
return device_name
def vault_candidates(client_drm_type: str) -> List[str]:
"""Both DRM systems, the client's first: a vault lookup needs only the KIDs, whatever device made the manifest."""
first = client_drm_type if client_drm_type in ("widevine", "playready") else "widevine"
return [first, "playready" if first == "widevine" else "widevine"]
def server_drm_candidates(
service_tag: str,
user_config: dict,
@@ -4370,12 +4518,14 @@ def handle_single_server_cdm(
request: Optional[web.Request],
sources: Optional[Dict[str, str]] = None,
refusal: Optional[APIError] = None,
vault_only: bool = False,
) -> Dict[str, str]:
"""Do the single-track server_cdm licensing with the DRM class get_content_keys() flow.
``sources`` is filled with the vault name for every returned content key a server vault
supplied. A content key the CDM licensed gets no entry. It raises ``refusal`` before a live licence:
after a Widevine vault miss, or at once for PlayReady, whose licence can carry keys its header does not name.
With ``vault_only`` no server device is involved: the header's KIDs come from the vault, else ``refusal``.
"""
import base64
@@ -4389,6 +4539,24 @@ def handle_single_server_cdm(
if not pssh_b64:
raise APIError(APIErrorCode.INVALID_INPUT, "No PSSH available for server_cdm licensing")
if vault_only:
from unshackle.core.drm import PlayReady, Widevine
if drm_type == "playready":
from pyplayready.system.pssh import PSSH as PlayReadyPSSH
kids = PlayReady(pssh=PlayReadyPSSH(base64.b64decode(pssh_b64)), pssh_b64=pssh_b64).kids
else:
from pywidevine.pssh import PSSH as WvPSSH
kids = Widevine(pssh=WvPSSH(pssh_b64)).kids
vault_hit = check_vaults(kids, service.__class__.__name__)
if vault_hit:
if sources is not None:
sources.update(vault_hit[1])
return vault_hit[0]
raise refusal or APIError(APIErrorCode.NO_CONTENT, "The server vault holds no key for this track")
api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous"
user_config = serve_user_config(api_key)
@@ -4476,8 +4644,9 @@ def handle_proxy_license(
track: Any,
challenge_b64: Optional[str],
drm_type: str,
certificate: bool = False,
) -> web.Response:
"""Forward a client CDM challenge to the service license endpoint."""
"""Forward a client CDM challenge to the service license endpoint, or to its certificate hook with ``certificate``."""
import base64
if not challenge_b64:
@@ -4494,7 +4663,14 @@ def handle_proxy_license(
# A service raises when the upstream licence server rejects the challenge.
# Surface it as a structured licence error, not an uncaught 500 the edge turns into a 502.
try:
if drm_type == "widevine":
if drm_type == "widevine" and certificate:
license_blob = service.get_widevine_service_certificate(
**declared_kwargs(
service.get_widevine_service_certificate,
{"challenge": challenge_bytes, "title": title, "track": track},
)
)
elif drm_type == "widevine":
license_blob = service.get_widevine_license(
**declared_kwargs(
service.get_widevine_license, {"challenge": challenge_bytes, "title": title, "track": track}
@@ -4513,8 +4689,11 @@ def handle_proxy_license(
log.exception(f"{sanitize_log(drm_type)} licence request failed for the proxied challenge")
raise APIError(APIErrorCode.SERVICE_ERROR, f"Licence request failed: {exc}") from None
if license_blob is None:
return web.json_response({"license": None})
if isinstance(license_blob, str):
license_blob = license_blob.encode("utf-8")
# a Widevine service may answer in Base64 text, and the client hands its CDM the raw bytes
license_blob = base64.b64decode(license_blob) if drm_type == "widevine" else license_blob.encode("utf-8")
return web.json_response({"license": base64.b64encode(license_blob).decode("ascii")})
@@ -4599,7 +4778,11 @@ def license_response(
drm_type = data.get("drm_type", "widevine")
mode = data.get("mode", "proxy")
if mode == "server_cdm" and not server_cdm_allowed(request, session.service_tag):
# a session the client's device licenses reaches the server vault only; its live licences stay client-side
vault_only = getattr(session, "server_cdm", None) is False
if mode == "server_cdm" and not (
getattr(session, "server_vault", False) if vault_only else server_cdm_allowed(request, session.service_tag)
):
raise APIError(
APIErrorCode.FORBIDDEN,
"Server CDM licensing is not enabled for this key on this service. Use a local CDM (proxy mode).",
@@ -4672,6 +4855,7 @@ def license_response(
request,
sources_by_pssh[cache_key],
live_licence_refusal(session, track),
vault_only=vault_only,
)
if keys:
keys_by_pssh[cache_key] = keys
@@ -4700,7 +4884,12 @@ def license_response(
continue
title = find_title_for_track(tid, session)
candidates = server_drm_candidates(service_tag, user_config, drm_type, track, warn)
# with no server device in play, the client's DRM system is the one its vault keys are for
candidates = (
vault_candidates(drm_type)
if vault_only
else server_drm_candidates(service_tag, user_config, drm_type, track, warn)
)
keys, track_drm_type, pssh_str = license_track(track, title, candidates)
# a refused track still tries its init segment PSSH, where a server vault can hold the key
@@ -4780,7 +4969,11 @@ def license_response(
# The server's config.cdm mapping decides the DRM system, as in the batch path.
# The client only knows its own local device, which the server never uses here.
api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous"
candidates = server_drm_candidates(session.service_tag, serve_user_config(api_key), drm_type, track)
candidates = (
vault_candidates(drm_type)
if vault_only
else server_drm_candidates(session.service_tag, serve_user_config(api_key), drm_type, track)
)
picked = pick_server_pssh(track, candidates)
if not picked:
raise APIError(
@@ -4808,6 +5001,7 @@ def license_response(
request,
key_sources,
live_licence_refusal(session, track),
vault_only=vault_only,
)
log.info(f"Server CDM resolved {len(keys)} key(s) for track {sanitize_log(track_id[:12])}")
note_served_keys(session, keys, key_sources)
@@ -4818,7 +5012,12 @@ def license_response(
if drm_type == "playready":
track.pr_pssh = pssh_b64
return handle_proxy_license(service, title, track, challenge_b64, drm_type)
certificate = data.get("is_certificate") is True
if getattr(session, "server_device", False) and not certificate:
denied = live_licence_refusal(session, track)
if denied:
raise denied
return handle_proxy_license(service, title, track, challenge_b64, drm_type, certificate=certificate)
except APIError:
raise
@@ -4918,7 +5117,7 @@ async def session_delete_handler(session_id: str, request: Optional[web.Request]
session.input_bridge.cancel()
cache_tag = session.cache_tag
cache_data = collect_cache_files(cache_tag) if cache_tag and session.client_auth else {}
cache_data = collect_cache_files(cache_tag) if cache_tag and client_owns_login(session) else {}
await store.delete(session_id)
+78 -4
View File
@@ -9,10 +9,11 @@ response value.
from __future__ import annotations
import json
import threading
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
from typing import Any, Optional
class AuthStatus(Enum):
@@ -27,6 +28,13 @@ class AuthStatus(Enum):
# Seconds the server waits for a remote client to answer an input prompt before
# giving up; also the TTL SessionStore grants AUTHENTICATING/PENDING_INPUT sessions.
AUTH_INPUT_TIMEOUT = 600.0
CDM_CALL_TIMEOUT = 60.0
# A listening client polls every few seconds, so a CDM call nobody fetches in this time has no client behind it.
CDM_PICKUP_TIMEOUT = 10.0
CDM_CALL_FALLBACK_PROMPT = (
"This service needs your local CDM during login, and your unshackle cannot answer it. Update unshackle"
)
@dataclass
@@ -42,7 +50,9 @@ class InputBridge:
_status: AuthStatus = field(default=AuthStatus.AUTHENTICATING, init=False)
_cancelled: bool = field(default=False, init=False, repr=False)
_answered: bool = field(default=False, init=False, repr=False)
_cdm_call: Optional[dict[str, Any]] = field(default=None, init=False, repr=False)
_response_ready: threading.Event = field(default_factory=threading.Event, init=False, repr=False)
_picked_up: threading.Event = field(default_factory=threading.Event, init=False, repr=False)
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
def request_input(self, prompt: str, timeout: float = AUTH_INPUT_TIMEOUT) -> str:
@@ -85,10 +95,63 @@ class InputBridge:
self._status = AuthStatus.AUTHENTICATING
return response
def request_cdm(
self, call: dict[str, Any], timeout: float = CDM_CALL_TIMEOUT, pickup: float = CDM_PICKUP_TIMEOUT
) -> dict[str, Any]:
"""Block until the remote client runs *call* on its own CDM, and return its JSON answer.
Unlike a prompt, a CDM call also works after authentication, and it leaves the auth status as it was.
A call that no client fetches within ``pickup`` seconds fails at once, so a vanished client holds the
service thread for seconds, not for the full ``timeout``.
"""
with self._lock:
if self._cancelled:
raise RuntimeError("Session was cancelled")
previous = self._status
self._prompt = CDM_CALL_FALLBACK_PROMPT
self._cdm_call = call
self._response = None
self._status = AuthStatus.PENDING_INPUT
self._response_ready.clear()
self._picked_up.clear()
picked_up = self._picked_up.wait(timeout=pickup)
answered = picked_up and self._response_ready.wait(timeout=timeout)
with self._lock:
answer = self._response
self._prompt = self._response = self._cdm_call = None
cancelled = self._cancelled
if not cancelled:
self._status = previous
if cancelled:
raise RuntimeError("Session was cancelled")
if not picked_up:
raise TimeoutError(f"No client fetched the CDM call within {pickup:.0f}s, so none is listening")
if not answered:
raise TimeoutError(f"The client did not answer the CDM call within {timeout:.0f}s")
try:
result = json.loads(answer or "")
except ValueError:
result = None
if not isinstance(result, dict):
raise RuntimeError("The client did not answer the CDM call; update unshackle on the client")
if result.get("error"):
raise RuntimeError(f"The client's CDM refused the call: {result['error']}")
return result
def get_pending_cdm_call(self) -> Optional[dict[str, Any]]:
"""Return the CDM call that service code waits on, if any, and mark it as fetched by a client."""
with self._lock:
if self._awaiting_answer() and self._cdm_call is not None:
self._picked_up.set()
return self._cdm_call
return None
def get_pending_prompt(self) -> Optional[str]:
"""Return the current prompt when the auth thread waits for input."""
with self._lock:
if self._status == AuthStatus.PENDING_INPUT:
if self._awaiting_answer():
return self._prompt
return None
@@ -100,18 +163,29 @@ class InputBridge:
``False`` otherwise.
"""
with self._lock:
if self._status != AuthStatus.PENDING_INPUT:
if not self._awaiting_answer():
return False
self._response = response
self._answered = True
if self._cdm_call is None:
self._answered = True
self._picked_up.set()
self._response_ready.set()
return True
def _awaiting_answer(self) -> bool:
"""True while a prompt or CDM call waits and no answer has arrived; call it under the lock.
An answered call stops being pending at once, before the waiting thread wakes, so a client
that polls again straight away never sees it twice.
"""
return self._status == AuthStatus.PENDING_INPUT and not self._response_ready.is_set()
def cancel(self) -> None:
"""Cancel the bridge, unblocking any waiting auth thread."""
with self._lock:
self._cancelled = True
self._status = AuthStatus.FAILED
self._picked_up.set()
self._response_ready.set()
@property
+47 -2
View File
@@ -383,6 +383,9 @@ async def services(request: web.Request) -> web.Response:
service_data["needs_auth"] = (
getattr(service_module, "authenticate", None) is not _BaseService.authenticate
)
client_config = getattr(service_module, "CLIENT_CONFIG", ())
if client_config:
service_data["client_config"] = list(client_config)
service_data["has_search"] = getattr(service_module, "search", None) is not _BaseService.search
service_data["has_drm"] = (
getattr(service_module, "get_widevine_license", None) is not _BaseService.get_widevine_license
@@ -1813,6 +1816,24 @@ async def session_create(request: web.Request) -> web.Response:
description: |
Security level of the client's local CDM, in the device's own numbers
(Widevine 1 to 3, PlayReady 150 to 3000)
cdm_relay:
type: boolean
description: |
True when the client answers CDM calls, so the service sees a stand-in for its
device instead of the server's
cdm_system_id:
type: integer
description: System ID of the client's Widevine device
cdm_device_type:
type: string
enum: [CHROME, ANDROID]
description: Device type of the client's Widevine device
service_config:
type: object
additionalProperties: true
description: |
The client's own identity config for the service. The server takes only the keys
the service lists in `client_config`, and only when the client's device licenses.
client:
type: object
additionalProperties: true
@@ -1848,6 +1869,14 @@ async def session_create(request: web.Request) -> web.Response:
description: |
Tallest video track the server CDM licenses live for this API key and service;
null for no limit
server_vault:
type: boolean
description: True when the server vault serves content keys to a remote session the client's device licenses
server_device:
type: boolean
description: |
True when the service runs on the server's own device. Every content key then comes
from a vault, and the server makes no live licence.
'400':
description: Invalid request
'403':
@@ -2093,16 +2122,24 @@ async def session_license(request: web.Request) -> web.Response:
type: string
enum: [widevine, playready]
description: DRM type (default widevine)
is_certificate:
type: boolean
description: >-
True to send the challenge to the service's Widevine service certificate hook
instead of its licence hook. `license` is then the raw certificate, or null.
responses:
'200':
description: >-
License response. In server_cdm mode `keys` maps KID to content key and `vault_keys`,
License response. A Widevine licence or certificate that the service returns as Base64
text arrives decoded, as raw bytes in Base64. In server_cdm mode `keys` maps KID to content key and `vault_keys`,
an array of KID hex strings that may be absent and may repeat a KID shared by several
tracks, lists the content keys a server vault supplied, which the client has to prove
before it trusts them. `clear_tracks`, absent when empty, lists the requested track ids
that carry no DRM and so have no keys. `capped_tracks`, absent when empty, maps each
track id the server refused to license live to the refusal details (`reason` and
`max_height`); the client licenses those tracks with its own local CDM.
`max_height`); the client licenses those tracks with its own local CDM. A `reason` of
`server_device` means the remote session runs on the server's device: the client reads
only its own vaults for that track and makes no licence.
After 30 s the route sends 200 and a newline every 30 s. A failure after that time
arrives as the error body with status 200.
'404':
@@ -2253,6 +2290,14 @@ async def session_prompt_get(request: web.Request) -> web.Response:
prompt:
type: string
description: Prompt to display to the user (only when status is pending_input)
cdm_call:
type: object
additionalProperties: true
description: |
A call the service makes on the client's CDM, in any auth state: `op` is `challenge`
(with `drm`, `init_data`, `license_type`, `privacy_mode` and `service_certificate`),
`keys` (with `session` and `license`) or `close` (with `session`). The client answers
with a JSON object in the POST `response`, or `{"error": ...}` for an op it does not know.
error:
type: string
description: Error message (only for the failed status)
+2
View File
@@ -48,6 +48,8 @@ class SessionEntry:
server_account: Optional[str] = None # profile name when the server lent its own account
server_cdm: Optional[bool] = None # False: the client's own device licenses this session
server_cdm_max_height: Optional[int] = None
server_vault: bool = False # a session the client's device licenses may still take keys from the server vault
server_device: bool = False # its service runs on the server's lent device, so keys come only from a vault
client_auth: bool = (
False # the client sent its own cookies, credentials, or cache files, or answered a login prompt
)
+192
View File
@@ -0,0 +1,192 @@
"""A remote client's own CDM, used by a server-side service through the session's auth prompt bridge.
The device never leaves the client. The server sends a ``cdm_call`` and the client runs it on its
local CDM: ``challenge`` makes a licence challenge, ``keys`` parses a licence and returns its
non-content keys, and ``close`` closes the CDM session. The client never returns content keys, so a server
cannot use it to license titles.
"""
from __future__ import annotations
import base64
import binascii
import contextlib
import uuid
from typing import Any, Callable, Optional, Protocol, Union
from uuid import UUID
from pywidevine import DeviceTypes
from pywidevine.key import Key
from pywidevine.pssh import PSSH
from unshackle.core.cdm.detect import is_playready_cdm
RELAYED_KEY_TYPES = frozenset({"OPERATOR_SESSION", "SIGNING"})
class CdmCallBridge(Protocol):
def request_cdm(self, call: dict[str, Any]) -> dict[str, Any]: ...
class ClientDeviceCdm:
"""The server's stand-in for a remote client's CDM: the client's device facts, with its calls relayed.
``open`` and ``set_service_certificate`` stay local, so one challenge-and-licence exchange costs two
round trips. The client answers only while one of its requests runs service code (login included).
"""
def __init__(
self,
bridge: CdmCallBridge,
is_playready: bool,
server_device: Optional[Callable[[], Any]] = None,
security_level: Optional[int] = None,
system_id: Optional[int] = None,
device_type: Optional[DeviceTypes] = None,
) -> None:
self.bridge = bridge
self.is_playready = is_playready
self.server_device = server_device
self.lent = False
if security_level is not None:
self.security_level = security_level
if system_id is not None:
self.system_id = system_id
if device_type is not None:
self.device_type = device_type
self._certificates: dict[bytes, Optional[str]] = {}
self._client_sessions: dict[bytes, str] = {}
self._keys: dict[bytes, list[Key]] = {}
def lend_server_device(self) -> Any:
"""The server's own device, for a service that builds its remote session on the server's identity.
The remote session then licenses nothing live: its content keys come only from a vault. The
loader also gives the service the server's own config back, so the two identities never mix.
"""
if self.server_device is None:
raise ValueError("This server has no device to lend")
self.lent = True
return self.server_device()
def open(self) -> bytes:
session_id = uuid.uuid4().bytes
self._certificates[session_id] = None
return session_id
def close(self, session_id: bytes) -> None:
"""Close the CDM session, and the client's CDM session behind it, as the client's CDM holds only a few.
A client too old to close one refuses the call, and it then closes the CDM session when its request ends.
"""
self._certificates.pop(session_id, None)
self._keys.pop(session_id, None)
client_session = self._client_sessions.pop(session_id, None)
if client_session is not None:
with contextlib.suppress(Exception):
self.bridge.request_cdm({"op": "close", "session": client_session})
def set_service_certificate(self, session_id: bytes, certificate: Optional[Union[bytes, str]]) -> None:
if isinstance(certificate, bytes):
certificate = base64.b64encode(certificate).decode()
self._certificates[session_id] = certificate
def get_license_challenge(
self,
session_id: bytes,
pssh_or_wrm: Any,
license_type: str = "STREAMING",
privacy_mode: bool = True,
*_: Any,
**__: Any,
) -> Union[bytes, str]:
"""Have the client's CDM make a challenge. Widevine returns bytes, PlayReady the challenge XML."""
if self.is_playready:
init_data = pssh_or_wrm if isinstance(pssh_or_wrm, str) else pssh_or_wrm.dumps()
else:
init_data = (pssh_or_wrm if isinstance(pssh_or_wrm, PSSH) else PSSH(pssh_or_wrm)).dumps()
answer = self.bridge.request_cdm(
{
"op": "challenge",
"drm": "playready" if self.is_playready else "widevine",
"init_data": init_data,
"license_type": license_type,
"privacy_mode": privacy_mode,
"service_certificate": self._certificates.get(session_id),
}
)
self._client_sessions[session_id] = str(answer["session"])
challenge = base64.b64decode(answer["challenge"])
return challenge.decode("utf-8") if self.is_playready else challenge
def parse_license(self, session_id: bytes, license_message: Union[bytes, str]) -> None:
client_session = self._client_sessions.get(session_id)
if client_session is None:
raise ValueError("No client CDM challenge was made in this session")
if isinstance(license_message, str):
license_message = (
license_message.encode("utf-8") if self.is_playready else base64.b64decode(license_message)
)
answer = self.bridge.request_cdm(
{"op": "keys", "session": client_session, "license": base64.b64encode(license_message).decode()}
)
self._keys[session_id] = [
Key(k["type"], UUID(hex=k["kid"]), bytes.fromhex(k["key"]), list(k.get("permissions") or []))
for k in answer.get("keys") or []
]
def get_keys(self, session_id: bytes, type_: Optional[str] = None) -> list[Key]:
keys = self._keys.get(session_id, [])
return [k for k in keys if type_ is None or k.type == type_]
def answer_cdm_call(cdm: Any, call: dict[str, Any], sessions: dict[str, bytes]) -> dict[str, Any]:
"""Run one relayed call on this machine's CDM and return the answer the server gets.
``sessions`` maps the refs handed to the server to open local CDM sessions; the caller closes them.
A refused or failed call answers ``{"error": ...}`` so the server's login stops with the reason.
"""
if cdm is None:
return {"error": "no local CDM is loaded"}
playready = is_playready_cdm(cdm)
try:
op = call.get("op")
if op == "challenge":
if (call.get("drm") == "playready") != playready:
return {"error": f"the local CDM is not a {call.get('drm')} device"}
session_id = cdm.open()
ref = uuid.uuid4().hex
sessions[ref] = session_id
if playready:
challenge: Union[bytes, str] = cdm.get_license_challenge(session_id, str(call["init_data"]))
challenge_bytes = challenge.encode("utf-8") if isinstance(challenge, str) else challenge
else:
if call.get("service_certificate"):
cdm.set_service_certificate(session_id, call["service_certificate"])
challenge_bytes = cdm.get_license_challenge(
session_id,
PSSH(call["init_data"]),
str(call.get("license_type") or "STREAMING"),
bool(call.get("privacy_mode", True)),
)
return {"session": ref, "challenge": base64.b64encode(challenge_bytes).decode()}
if op == "keys":
if playready:
return {"error": "a PlayReady licence carries only content keys, so they stay on this machine"}
session_id = sessions[str(call["session"])]
cdm.parse_license(session_id, base64.b64decode(call["license"]))
return {
"keys": [
{"kid": k.kid.hex, "type": k.type, "key": k.key.hex(), "permissions": list(k.permissions)}
for k in cdm.get_keys(session_id)
if k.type in RELAYED_KEY_TYPES
]
}
if op == "close":
cdm.close(sessions.pop(str(call["session"])))
return {}
return {"error": f"unknown CDM call {op!r}"}
except (KeyError, ValueError, TypeError, binascii.Error) as e:
return {"error": f"malformed CDM call: {e}"}
except Exception as e:
return {"error": f"{type(e).__name__}: {e}"}
+1 -1
View File
@@ -75,7 +75,7 @@ class RollingMerge:
self.ready: dict[int, tuple[Path, Optional[Future]]] = {}
def add(self, index: int, segment_file: Path, future: Optional[Future] = None) -> None:
# ponytail: runs on the downloader's own loop, so a decrypt wait or a catch-up burst
# runs on the downloader's own loop, so a decrypt wait or a catch-up burst
# delays new submissions; move the drain to a writer thread if throughput measurably drops
self.ready[index] = (segment_file, future)
while self.cursor in self.ready:
+149 -22
View File
@@ -9,7 +9,9 @@ from __future__ import annotations
import atexit
import base64
import contextlib
import hashlib
import json
import logging
import re
import shlex
@@ -75,6 +77,8 @@ class RemoteClient:
self.auth_headers = list(auth_headers) if auth_headers else list(DEFAULT_AUTH_HEADERS)
self._auth_header_index = 0
self._session: Optional[requests.Session] = None
# wraps every request, so the service's CDM calls get answered while the server runs its code
self.in_flight: Callable[[str], contextlib.AbstractContextManager[Any]] = lambda _: contextlib.nullcontext()
@property
def session(self) -> requests.Session:
@@ -109,7 +113,8 @@ class RemoteClient:
url = f"{self.server_url}{endpoint}"
while True:
try:
resp = getattr(self.session, method)(url, json=data, timeout=self.timeout)
with self.in_flight(endpoint):
resp = getattr(self.session, method)(url, json=data, timeout=self.timeout)
except requests.ConnectionError as e:
if isinstance(e.args[0] if e.args else None, urllib3.exceptions.ReadTimeoutError):
log.error(
@@ -910,7 +915,17 @@ class RemoteService:
svc_config = services_config.get(service_tag, {})
self._server_cdm: bool | None = services_config.get("_server_cdm")
self._server_vault = False
self._server_device = False
self._local_vaults_only = False
self._server_accounts: Optional[dict] = services_config.get("_server_accounts")
# read before apply_service_config merges the server's values into the same section
own = config.services.get(service_tag) or {}
self._client_config = {k: own[k] for k in services_config.get("_client_config") or [] if k in own}
self._cdm_sessions: Dict[str, bytes] = {}
self._cdm_lock = Lock()
self._cdm_requests = 0
self._cdm_stop = Event()
self.apply_service_config(svc_config)
def apply_service_config(self, svc_config: dict) -> None:
@@ -1058,6 +1073,15 @@ class RemoteService:
level = getattr(cdm, "security_level", None)
if isinstance(level, int):
create_data["cdm_security_level"] = level
create_data["cdm_relay"] = True
system_id = getattr(cdm, "system_id", None)
if isinstance(system_id, int):
create_data["cdm_system_id"] = system_id
device_type = getattr(getattr(cdm, "device_type", None), "name", None)
if device_type:
create_data["cdm_device_type"] = device_type
if self._client_config:
create_data["service_config"] = self._client_config
cache_data = self.load_cache_files(profile) if self._server_accounts is None else None
if cache_data:
@@ -1080,10 +1104,21 @@ class RemoteService:
# an older server omits the field, and the tracks response settles it
if "server_cdm" in result:
self.adopt_session_cdm(bool(result["server_cdm"]), result.get("server_cdm_max_height"))
self._server_device = bool(result.get("server_device"))
vault = bool(result.get("server_vault"))
if self._server_device:
self._server_cdm = True
whose = "the server vault or yours" if vault else "your vaults only"
self.log.info(f"This session runs on the server's device: keys come from {whose}")
self._server_vault = vault and not self._server_cdm
# without the server vault grant the server refuses every key request, so none is sent
self._local_vaults_only = self._server_device and not vault
status = result.get("status", "authenticated")
if status == "authenticating":
self.poll_auth_completion()
if create_data.get("cdm_relay"):
self.client.in_flight = self.answering_cdm_calls
self.drain_server_logs()
self.start_keepalive()
@@ -1092,8 +1127,20 @@ class RemoteService:
"""The CDM device dl loaded on this machine, or None. dl may later swap its own copy for a stub."""
return self.ctx.obj.cdm if self.ctx.obj else None
@property
def local_vaults_only(self) -> bool:
"""True for a remote session on the server's device whose API key cannot read the server vault."""
return getattr(self, "_local_vaults_only", False)
def adopt_session_cdm(self, server_cdm: bool, max_height: Optional[int]) -> None:
"""Take the server's choice of who licenses this remote session, and say why when a cap made it."""
"""Take the server's choice of who licenses this remote session, and say why when a cap made it.
A remote session on the server's lent device keeps the server-CDM flow: dl checks the local vaults, then asks the
server, which answers only from its vault.
"""
if getattr(self, "_server_device", False):
self._server_cdm = True
return
if self._server_cdm and not server_cdm and max_height is None:
self.log.warning(
f"{self.service_tag} is not available for server CDM licensing with this API key, "
@@ -1162,7 +1209,12 @@ class RemoteService:
response, and POST it back. The server resumes its auth flow.
"""
deadline = time.monotonic() + timeout
try:
self.poll_auth(deadline, poll_interval)
finally:
self.close_cdm_sessions()
def poll_auth(self, deadline: float, poll_interval: float) -> None:
while time.monotonic() < deadline:
resp = self.client.get(f"/api/session/{self._session_id}/prompt")
status = resp.get("status")
@@ -1176,6 +1228,10 @@ class RemoteService:
log.error(f"Remote auth failed: {error}")
raise SystemExit(1)
if status == "pending_input" and isinstance(resp.get("cdm_call"), dict):
self.answer_cdm_call(resp["cdm_call"], self._cdm_sessions)
continue
if status == "pending_input":
prompt = resp.get("prompt", "Enter input: ")
try:
@@ -1194,6 +1250,59 @@ class RemoteService:
log.error("Remote authentication timed out")
raise SystemExit(1)
def answer_cdm_call(self, call: Dict[str, Any], sessions: Dict[str, bytes]) -> None:
"""Run one of the server's CDM calls on this machine's device and post the answer."""
from unshackle.core.cdm.client_relay import answer_cdm_call
answer = answer_cdm_call(self.local_cdm, call, sessions)
self.log.debug(f"Answered a server CDM call: {call.get('op')} ({'refused' if 'error' in answer else 'ok'})")
self.client.post(f"/api/session/{self._session_id}/prompt", {"response": json.dumps(answer)})
@contextlib.contextmanager
def answering_cdm_calls(self, endpoint: str) -> Iterator[None]:
"""Answer the server's CDM calls while a remote session request runs service code.
One poller serves every concurrent request, and the local CDM sessions close when the last one ends.
"""
if not endpoint.startswith(f"/api/session/{self._session_id}/") or endpoint.endswith(("/prompt", "/logs")):
yield
return
with self._cdm_lock:
self._cdm_requests += 1
if self._cdm_requests == 1:
self._cdm_stop = Event()
Thread(
target=self.poll_cdm_calls, args=(self._cdm_stop,), name=f"{self.service_tag}-cdm", daemon=True
).start()
try:
yield
finally:
with self._cdm_lock:
self._cdm_requests -= 1
if self._cdm_requests == 0:
self._cdm_stop.set()
def poll_cdm_calls(self, stop: Event, interval: float = 0.5) -> None:
url = f"{self.client.server_url}/api/session/{self._session_id}/prompt"
sessions: Dict[str, bytes] = {}
while not stop.wait(interval):
try:
call = self.client.session.get(url, timeout=30).json().get("cdm_call")
if isinstance(call, dict):
self.answer_cdm_call(call, sessions)
except (requests.RequestException, ValueError, SystemExit) as e:
self.log.debug(f"CDM call poll failed: {e}")
self.close_cdm_sessions(sessions)
def close_cdm_sessions(self, sessions: Optional[Dict[str, bytes]] = None) -> None:
"""Close the local CDM sessions that the server's calls opened; best effort."""
sessions = self._cdm_sessions if sessions is None else sessions
cdm = self.local_cdm
for session_id in sessions.values():
with contextlib.suppress(Exception):
cdm.close(session_id)
sessions.clear()
def get_titles(self) -> Titles_T:
if self._titles is not None:
return self._titles
@@ -1244,7 +1353,7 @@ class RemoteService:
resolve_manifest_data(tracks, result.get("manifests", []))
apply_service_track_data(tracks, result)
server_cdm = bool(result.get("server_cdm", False))
server_cdm = bool(result.get("server_cdm", False)) or getattr(self, "_server_device", False)
if self._server_cdm is None:
self._server_cdm = server_cdm
elif self._server_cdm and not server_cdm:
@@ -1298,7 +1407,7 @@ class RemoteService:
"""
# track ids can repeat across titles, so a refusal for the last title must not carry over
self.client_licensed.clear()
if not self._server_cdm:
if not (self._server_cdm or getattr(self, "_server_vault", False)) or self.local_vaults_only:
return
from uuid import UUID
@@ -1308,6 +1417,10 @@ class RemoteService:
return
drm_type = getattr(self, "_server_cdm_type", "widevine")
if getattr(self, "_server_vault", False):
from unshackle.core.cdm.detect import is_playready_cdm
drm_type = "playready" if is_playready_cdm(self.local_cdm) else "widevine"
self.log.debug(f"Requesting server CDM keys (server_cdm_type={drm_type})")
capped: Dict[str, Any] = {}
@@ -1356,7 +1469,8 @@ class RemoteService:
self.log.warning("Failed to resolve server CDM keys: %s", e)
for track in title.tracks:
if str(track.id) in capped:
# a lent-device miss still gets the local vaults, which dl reads before asking the server again
if str(track.id) in capped and capped[str(track.id)].get("reason") != "server_device":
self.license_locally(track, capped[str(track.id)])
def license_locally(self, track: AnyTrack, refusal: Dict[str, Any]) -> None:
@@ -1367,8 +1481,23 @@ class RemoteService:
"""
from unshackle.core.cdm.detect import is_playready_cdm
if refusal.get("reason") == "server_device":
vaults = (
"none of your vaults, and this API key cannot read the server vault"
if self.local_vaults_only
else "no vault, neither the server's nor yours"
)
raise click.ClickException(
f"The key for {track} is in {vaults}. This session runs on the server's device, which "
"licenses nothing, so drop the service's server-identity option to license it with your own device."
)
cap = refusal.get("max_height")
why = f"The server CDM licenses up to {cap}p for this key" if cap else "The server CDM refused this track"
if cap:
why = f"The server CDM licenses up to {cap}p for this key"
elif getattr(self, "_server_vault", False):
why = "The server vault holds no key for this track"
else:
why = "The server CDM refused this track"
cdm = self.local_cdm
if cdm is None:
raise click.ClickException(f"{why}, and {track} needs a local CDM to license it with your own device.")
@@ -1472,27 +1601,25 @@ class RemoteService:
challenge: bytes,
title: Title_T,
track: AnyTrack,
) -> Union[bytes, str]:
try:
resp = self.client.post(
f"/api/session/{self._session_id}/license",
{
"track_id": str(track.id),
"challenge": base64.b64encode(challenge).decode("ascii"),
"drm_type": "widevine",
"is_certificate": True,
},
)
return base64.b64decode(resp["license"])
# a missing certificate is legal (services may not use one); log and continue without
except Exception as e:
self.log.debug(f"Service certificate fetch failed: {e!r}")
return None
) -> Optional[Union[bytes, str]]:
# a missing certificate is legal (services may not use one), so a failed fetch continues without one
resp = self.client.post_optional(
f"/api/session/{self._session_id}/license",
{
"track_id": str(track.id),
"challenge": base64.b64encode(challenge).decode("ascii"),
"drm_type": "widevine",
"is_certificate": True,
},
)
return base64.b64decode(resp["license"]) if resp.get("license") else None
def proxy_license(self, challenge: Union[bytes, str], track: AnyTrack, drm_type: str) -> bytes:
if isinstance(challenge, str):
challenge = challenge.encode("utf-8")
if self.local_vaults_only:
self.license_locally(track, {"reason": "server_device"})
if self._server_cdm and str(track.id) not in self.client_licensed:
from uuid import UUID
+3
View File
@@ -158,6 +158,9 @@ class Service(metaclass=ABCMeta):
# Auth methods the service accepts ("cookies"/"credentials"); when None the REST /services
# endpoint infers them from authenticate().
AUTH_METHODS: Optional[tuple[str, ...]] = None
# Config keys a remote client may supply for a session its own device licenses (a device identity
# such as an ESN); the server's own values for these keys are withheld from that session.
CLIENT_CONFIG: tuple[str, ...] = ()
def __init__(self, ctx: click.Context):
console.print(Padding(Rule(f"[rule.text]Service: {self.__class__.__name__}"), (1, 2)))
+1
View File
@@ -510,6 +510,7 @@ class Services(click.Group):
server_name = ctx.parent.params.get("server") if ctx.parent else None
server_url, api_key, services_config = resolve_server(server_name)
services_config["_server_accounts"] = svc_info.get("server_accounts") if svc_info else None
services_config["_client_config"] = svc_info.get("client_config") if svc_info else None
service_params = {k: v for k, v in kwargs.items() if v is not None and v is not False}
return RemoteService(ctx, tag, title, server_url, api_key, services_config, service_params=service_params)