mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 12:05:07 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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}"}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user