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

Relay the service's CDM calls to the client, accept the service's allowed identity config (for example ESN/Kpe/Kph), add the server_vault grant and a lent server-device mode that reads keys only from vaults and returns no login cache. Also fixes the proxied certificate fetch, Base64 Widevine licences, an answered call that stayed pending and a missing default quality.
This commit is contained in:
imSp4rky
2026-09-25 12:32:49 -06:00
parent 174192882f
commit 69360d1b4c
19 changed files with 1244 additions and 69 deletions
+72 -6
View File
@@ -215,7 +215,8 @@ hood the client walks a remote session through its lifecycle.
client saves them locally, so the **next** remote session can skip interactive
authentication. The login belongs to the client when the client sent
cookies, credentials, or cache files, or answered a login prompt (a device code
or an OTP) that led to a successful login. A server-account login never does.
or an OTP) that led to a successful login. A server-account login never does,
and neither does a remote session on the server's device (see below).
The client saves only the returned files that pass the same profile check it
applies before it sends them, so a file for another profile cannot overwrite
that profile's local file.
@@ -245,6 +246,10 @@ There are two ways DRM keys get resolved, chosen by the client's `server_cdm` fl
- Keeps your CDM local. The server only relays the license request.
- Used when `server_cdm` is `false` (the default).
- The service on the server sees your device, not the server's: its DRM system,
security level, system ID and device type. A CDM call that the service makes
(for example a session-key exchange at login) goes to your client as a `cdm_call`,
and your local CDM answers it. See [Your own device identity](#your-own-device-identity).
=== "Server-side CDM (server_cdm: true)"
@@ -277,6 +282,48 @@ There are two ways DRM keys get resolved, chosen by the client's `server_cdm` fl
licenses those tracks in proxy mode with its own local CDM. See
[`server_cdm_max_height`](../../reference/configuration/services.md).
### Your own device identity
A service can need more than a licence challenge from your device: an ESN, model keys, or a
session-key exchange that runs on the CDM. The device never leaves your machine. Only these cross
the wire:
- **Device facts.** When it creates the remote session, the client sends its DRM system, security level,
system ID and device type. The service sees a stand-in with these facts.
- **CDM calls.** When the service calls the CDM, the server sends a `cdm_call` to the client
and waits. The client makes the challenge, or parses the licence, on its own CDM. When
the service closes a CDM session, the server sends a `close` call, because a CDM holds
only a few sessions. The client polls for calls during login and while one of its own
requests runs service code, so an idle remote session sends nothing extra.
- **Session keys only.** A `keys` call returns Widevine `OPERATOR_SESSION` and `SIGNING`
keys. It never returns a content key, and the client refuses a PlayReady `keys` call.
So a server cannot use your device to license titles for itself.
- **Identity config.** A service names the config keys a client may send (for example
`esn`, `Kpe`, `Kph`, `esn_map`). The server lists them in the service listing as
`client_config`. The client sends only those keys, from its own `services: <TAG>:`
block in `unshackle.yaml`. When the client sends an identity, the server drops its own
value for each of those keys, so the two identities never mix. A mapping (such as
`esn_map`) merges the client's entries over the server's. A client entry replaces a
server entry with the same key, also when the server's key is a number. The server keeps
these values in the remote session's memory only.
A service can also offer an option that builds the remote session on the server's own
device and identity, for the quality that device reaches. The session then makes no live
licence at all: each content key comes from the server vault or the client's own vaults,
and a content key that no vault holds stops the download with an error. The create
response sets `server_device` to `true` for such a remote session. The service then uses
the server's own config: the client's identity values do not apply. The client reads the
server vault only when the API key has the `server_vault` grant (or `server_cdm`); without
it, the client reads only its own vaults. The login tokens of such a remote session hold the
server's device identity, so the server keeps them: `DELETE` returns no cache files, and the
HTTP session headers and `track.data` lose their secret-looking keys, as for a server login.
!!! note "The server vault in a session your device licenses"
With the `server_vault` grant (or `server_cdm`) on the API key, a remote session that your own
device licenses still takes content keys from the server vault. The server tries the
vault first. A track that no server vault holds goes back to your device. See
[`server_vault`](../../reference/configuration/services.md).
---
## Remote session lifecycle and expiry
@@ -365,8 +412,10 @@ The server mounts all these routes, even in `--remote-only` mode. Paths use the
`POST /api/session/create` requires `service` and `title_id`. It also accepts
`credentials`, `cookies` (base64 of zlib-compressed Netscape cookie file), `proxy`,
`no_proxy`, `profile`, `cache` (a map of `filename → base64(zlib(bytes))`),
`client_region`, `cdm_type`, `client` (a freeform identity object the dashboard
shows as sent), and the track-selection hints `range_`, `vcodec`,
`client_region`, `cdm_type`, `cdm_security_level`, `cdm_relay` (`true` when the client
answers CDM calls), `cdm_system_id` and `cdm_device_type` (Widevine only), `service_config`
(the identity config keys the service lists in `client_config`), `client` (a freeform identity
object the dashboard shows as sent), and the track-selection hints `range_`, `vcodec`,
`quality`, `best_available`, plus arbitrary service CLI options
(`additionalProperties: true`).
@@ -395,9 +444,14 @@ a per-profile ESN.
The response returns **before** authentication finishes:
```json
{ "session_id": "...uuid4...", "service": "EXAMPLE", "status": "authenticating" }
{ "session_id": "...uuid4...", "service": "EXAMPLE", "status": "authenticating",
"server_cdm": false, "server_cdm_max_height": null, "server_vault": true, "server_device": false }
```
`server_vault` tells the client that the server vault serves a remote session its own device
licenses. `server_device` tells it that the service runs on the server's device, so every
content key comes from a vault.
Authentication runs on a background thread (`asyncio.to_thread(authenticate, ...)`).
The remote session starts in `AUTHENTICATING`. The client must poll the prompt
endpoint until it reaches `authenticated` (or `failed`).
@@ -415,6 +469,10 @@ layer.
- A prompt after authentication (in `get_titles()`, `get_tracks()` or licensing) raises
`RuntimeError` at once. The client polls for prompts only during authentication, so
no answer can come.
- A CDM call (`InputBridge.request_cdm`) works during and after authentication, because the
client also polls while its own requests run. A call that no client fetches within 10 seconds
fails then, because no client is listening. A fetched call waits 60 seconds for its answer.
The call leaves the auth status as it was. An answer never marks the remote session as client-authenticated.
- `AUTH_INPUT_TIMEOUT = 600.0` seconds. This is also the TTL granted to
`AUTHENTICATING` / `PENDING_INPUT` sessions in the store.
@@ -424,9 +482,17 @@ layer.
{ "status": "authenticated" }
{ "status": "authenticating" }
{ "status": "pending_input", "prompt": "Enter code: " }
{ "status": "pending_input", "prompt": "...", "cdm_call": { "op": "challenge", "drm": "widevine", "init_data": "...", "license_type": "OFFLINE", "privacy_mode": true, "service_certificate": "..." } }
{ "status": "failed", "error": "...message..." }
```
A `cdm_call` comes first, in any auth state. The client answers it with a JSON object in
`response`: `{"session": "...", "challenge": "<base64>"}` for `challenge`,
`{"keys": [{"kid", "type", "key", "permissions"}]}` for `keys`, `{}` for `close`, or
`{"error": "..."}` when its CDM refuses. An older client refuses `close`, and the server
ignores that refusal. An answered call stops being pending at once, so a client that polls again
straight away does not see it twice.
A missing remote session returns `404 SESSION_NOT_FOUND`. An IP mismatch returns
`403 FORBIDDEN`. `POST /api/session/{id}/prompt` takes `{ "response": "..." }` and
returns `{ "status": "accepted" }`. A post with no pending prompt is an
@@ -477,8 +543,8 @@ _sessions/<pbkdf2_hmac(sha256, X-Secret-Key, "unshackle-session-ns", 100000)[:12
```
The handler writes forwarded `cache` files into that directory before authentication.
On `DELETE`, when the login belongs to the client (`client_auth`), the handler
harvests updated cache files from the whole directory tree (compressing each
On `DELETE`, when the login belongs to the client (`client_auth`) and the remote
session does not run on the server's device, the handler harvests updated cache files from the whole directory tree (compressing each
with zlib and base64-encoding, **excluding** `titles_*` files) and returns them
under a `cache` field, so the client can keep refreshed tokens. Each cache key
is the file path relative to that directory, with `/` separators and no `.json`