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

Relay the service's CDM calls to the client, accept the service's allowed identity config (for example ESN/Kpe/Kph), add the server_vault grant and a lent server-device mode that reads keys only from vaults and returns no login cache. Also fixes the proxied certificate fetch, Base64 Widevine licences, an answered call that stayed pending and a missing default quality.
This commit is contained in:
imSp4rky
2026-09-25 12:32:49 -06:00
parent 174192882f
commit 69360d1b4c
19 changed files with 1244 additions and 69 deletions
+2 -2
View File
@@ -84,8 +84,8 @@ Under the integrated server, an API key can do DRM licensing only through a CDM
!!! warning "PlayReady access is opt-in per user"
If a user entry omits `playready_devices`, the server gives that API key **no** PlayReady access, and logs a warning at startup. You must name the PlayReady devices explicitly for any API key that needs them. Widevine and PlayReady device files themselves are auto-loaded from your WVDs and PRDs directories.
!!! note "Device lists *are* the server-side-decryption switch"
There is no separate tier, capability flag, or permission toggle for whether the server will hand back content keys (`KID:KEY`) for a remote session download. The presence of devices on the calling API key decides it. With **empty** `devices` and `playready_devices`, the server can only proxy CDM challenges, so the client must operate its own CDM. Once those lists are **populated**, the client may request `mode:server_cdm` and receive content keys back. Do not look for a tier setting. The devices you configure are what enables server-side decryption.
!!! note "Grants decide whether the server returns content keys"
The devices on an API key decide which CDM the server can use. Two grants in the same user entry decide whether the server returns content keys (`KID:KEY`) for a remote session. `server_cdm` lets the server license with those devices. `server_vault` lets a remote session that the client's own device licenses read the server vault, and the server then makes no live licence. Without either grant, the server can only proxy CDM challenges, so the client must operate its own CDM. See [`serve`](../../reference/configuration/services.md) for both grants.
### Disabling authentication
+1 -1
View File
@@ -100,7 +100,7 @@ but the defaults hold for the overwhelming majority of responses.
| `INVALID_PARAMETERS` | 400 | One or more download or query parameters failed validation (bad codec, bitrate, sort field, and so on). |
| `AUTH_FAILED` | 401 | Authentication with the streaming service failed (bad credentials or cookies). |
| `FORBIDDEN` | 403 | The action is not allowed. Raised by server-side gates (per-key restrictions on CDM or credential overrides) and by remote session IP binding. |
| `SERVER_CDM_CAPPED` | 403 | The server CDM does not license live above the `server_cdm_max_height` of the API key. `details.reason` is `server_cdm_max_height` (with `details.max_height`) or `client_device`, for a remote session where the client's own device licenses. A remote client licenses the track with its own device. |
| `SERVER_CDM_CAPPED` | 403 | The server CDM does not license live above the `server_cdm_max_height` of the API key. `details.reason` is `server_cdm_max_height` (with `details.max_height`), `client_device`, for a remote session where the client's own device licenses, or `server_device`, for a remote session that runs on the server's device. A remote client licenses the track with its own device, except for `server_device`: then only its own vaults can supply the key. |
| `GEOFENCE` | 403 | The title is not available in the applicable region. |
| `NOT_FOUND` | 404 | A requested resource (title, history entry, and so on) does not exist. |
| `NO_CONTENT` | 404 | The request was valid but produced nothing: no matching titles, tracks, episodes, or keys. |
+72 -6
View File
@@ -215,7 +215,8 @@ hood the client walks a remote session through its lifecycle.
client saves them locally, so the **next** remote session can skip interactive
authentication. The login belongs to the client when the client sent
cookies, credentials, or cache files, or answered a login prompt (a device code
or an OTP) that led to a successful login. A server-account login never does.
or an OTP) that led to a successful login. A server-account login never does,
and neither does a remote session on the server's device (see below).
The client saves only the returned files that pass the same profile check it
applies before it sends them, so a file for another profile cannot overwrite
that profile's local file.
@@ -245,6 +246,10 @@ There are two ways DRM keys get resolved, chosen by the client's `server_cdm` fl
- Keeps your CDM local. The server only relays the license request.
- Used when `server_cdm` is `false` (the default).
- The service on the server sees your device, not the server's: its DRM system,
security level, system ID and device type. A CDM call that the service makes
(for example a session-key exchange at login) goes to your client as a `cdm_call`,
and your local CDM answers it. See [Your own device identity](#your-own-device-identity).
=== "Server-side CDM (server_cdm: true)"
@@ -277,6 +282,48 @@ There are two ways DRM keys get resolved, chosen by the client's `server_cdm` fl
licenses those tracks in proxy mode with its own local CDM. See
[`server_cdm_max_height`](../../reference/configuration/services.md).
### Your own device identity
A service can need more than a licence challenge from your device: an ESN, model keys, or a
session-key exchange that runs on the CDM. The device never leaves your machine. Only these cross
the wire:
- **Device facts.** When it creates the remote session, the client sends its DRM system, security level,
system ID and device type. The service sees a stand-in with these facts.
- **CDM calls.** When the service calls the CDM, the server sends a `cdm_call` to the client
and waits. The client makes the challenge, or parses the licence, on its own CDM. When
the service closes a CDM session, the server sends a `close` call, because a CDM holds
only a few sessions. The client polls for calls during login and while one of its own
requests runs service code, so an idle remote session sends nothing extra.
- **Session keys only.** A `keys` call returns Widevine `OPERATOR_SESSION` and `SIGNING`
keys. It never returns a content key, and the client refuses a PlayReady `keys` call.
So a server cannot use your device to license titles for itself.
- **Identity config.** A service names the config keys a client may send (for example
`esn`, `Kpe`, `Kph`, `esn_map`). The server lists them in the service listing as
`client_config`. The client sends only those keys, from its own `services: <TAG>:`
block in `unshackle.yaml`. When the client sends an identity, the server drops its own
value for each of those keys, so the two identities never mix. A mapping (such as
`esn_map`) merges the client's entries over the server's. A client entry replaces a
server entry with the same key, also when the server's key is a number. The server keeps
these values in the remote session's memory only.
A service can also offer an option that builds the remote session on the server's own
device and identity, for the quality that device reaches. The session then makes no live
licence at all: each content key comes from the server vault or the client's own vaults,
and a content key that no vault holds stops the download with an error. The create
response sets `server_device` to `true` for such a remote session. The service then uses
the server's own config: the client's identity values do not apply. The client reads the
server vault only when the API key has the `server_vault` grant (or `server_cdm`); without
it, the client reads only its own vaults. The login tokens of such a remote session hold the
server's device identity, so the server keeps them: `DELETE` returns no cache files, and the
HTTP session headers and `track.data` lose their secret-looking keys, as for a server login.
!!! note "The server vault in a session your device licenses"
With the `server_vault` grant (or `server_cdm`) on the API key, a remote session that your own
device licenses still takes content keys from the server vault. The server tries the
vault first. A track that no server vault holds goes back to your device. See
[`server_vault`](../../reference/configuration/services.md).
---
## Remote session lifecycle and expiry
@@ -365,8 +412,10 @@ The server mounts all these routes, even in `--remote-only` mode. Paths use the
`POST /api/session/create` requires `service` and `title_id`. It also accepts
`credentials`, `cookies` (base64 of zlib-compressed Netscape cookie file), `proxy`,
`no_proxy`, `profile`, `cache` (a map of `filename → base64(zlib(bytes))`),
`client_region`, `cdm_type`, `client` (a freeform identity object the dashboard
shows as sent), and the track-selection hints `range_`, `vcodec`,
`client_region`, `cdm_type`, `cdm_security_level`, `cdm_relay` (`true` when the client
answers CDM calls), `cdm_system_id` and `cdm_device_type` (Widevine only), `service_config`
(the identity config keys the service lists in `client_config`), `client` (a freeform identity
object the dashboard shows as sent), and the track-selection hints `range_`, `vcodec`,
`quality`, `best_available`, plus arbitrary service CLI options
(`additionalProperties: true`).
@@ -395,9 +444,14 @@ a per-profile ESN.
The response returns **before** authentication finishes:
```json
{ "session_id": "...uuid4...", "service": "EXAMPLE", "status": "authenticating" }
{ "session_id": "...uuid4...", "service": "EXAMPLE", "status": "authenticating",
"server_cdm": false, "server_cdm_max_height": null, "server_vault": true, "server_device": false }
```
`server_vault` tells the client that the server vault serves a remote session its own device
licenses. `server_device` tells it that the service runs on the server's device, so every
content key comes from a vault.
Authentication runs on a background thread (`asyncio.to_thread(authenticate, ...)`).
The remote session starts in `AUTHENTICATING`. The client must poll the prompt
endpoint until it reaches `authenticated` (or `failed`).
@@ -415,6 +469,10 @@ layer.
- A prompt after authentication (in `get_titles()`, `get_tracks()` or licensing) raises
`RuntimeError` at once. The client polls for prompts only during authentication, so
no answer can come.
- A CDM call (`InputBridge.request_cdm`) works during and after authentication, because the
client also polls while its own requests run. A call that no client fetches within 10 seconds
fails then, because no client is listening. A fetched call waits 60 seconds for its answer.
The call leaves the auth status as it was. An answer never marks the remote session as client-authenticated.
- `AUTH_INPUT_TIMEOUT = 600.0` seconds. This is also the TTL granted to
`AUTHENTICATING` / `PENDING_INPUT` sessions in the store.
@@ -424,9 +482,17 @@ layer.
{ "status": "authenticated" }
{ "status": "authenticating" }
{ "status": "pending_input", "prompt": "Enter code: " }
{ "status": "pending_input", "prompt": "...", "cdm_call": { "op": "challenge", "drm": "widevine", "init_data": "...", "license_type": "OFFLINE", "privacy_mode": true, "service_certificate": "..." } }
{ "status": "failed", "error": "...message..." }
```
A `cdm_call` comes first, in any auth state. The client answers it with a JSON object in
`response`: `{"session": "...", "challenge": "<base64>"}` for `challenge`,
`{"keys": [{"kid", "type", "key", "permissions"}]}` for `keys`, `{}` for `close`, or
`{"error": "..."}` when its CDM refuses. An older client refuses `close`, and the server
ignores that refusal. An answered call stops being pending at once, so a client that polls again
straight away does not see it twice.
A missing remote session returns `404 SESSION_NOT_FOUND`. An IP mismatch returns
`403 FORBIDDEN`. `POST /api/session/{id}/prompt` takes `{ "response": "..." }` and
returns `{ "status": "accepted" }`. A post with no pending prompt is an
@@ -477,8 +543,8 @@ _sessions/<pbkdf2_hmac(sha256, X-Secret-Key, "unshackle-session-ns", 100000)[:12
```
The handler writes forwarded `cache` files into that directory before authentication.
On `DELETE`, when the login belongs to the client (`client_auth`), the handler
harvests updated cache files from the whole directory tree (compressing each
On `DELETE`, when the login belongs to the client (`client_auth`) and the remote
session does not run on the server's device, the handler harvests updated cache files from the whole directory tree (compressing each
with zlib and base64-encoding, **excluding** `titles_*` files) and returns them
under a `cache` field, so the client can keep refreshed tokens. Each cache key
is the file path relative to that directory, with `/` separators and no `.json`
+11
View File
@@ -180,6 +180,14 @@ content key of a higher track when the service shares one content key between th
accounts enabled, a client that reports a stronger device than it holds makes the server's
account request those manifests; the licence server still refuses its challenge.
`server_vault` lets a remote session that the client's own device licenses take content keys
from the server vault. Set it to `true` for every service, or to a list of service tags. It is
`false` unless the entry sets it, and `server_cdm` for a service implies it. The server looks up
every KID of the track in its vault and makes no live licence. A track that the vault does not
hold goes back to the client's device. When the service runs the remote session on the server's
own device (a service option, such as a server-identity flag), the client's own vaults, and the
server vault with this grant, are the only source, and a missing content key stops the download.
`admin` is a boolean that lets the API key run the maintenance endpoints (clear-cache, clear-temp, refresh-services). It is `false` unless the entry sets it. Keys that have no `users` entry, such as `api_secret`, keep that access.
A `tier` names an entry under `serve.tiers`, which holds the settings that several API keys
@@ -274,6 +282,9 @@ serve:
server_cdm: [EXAMPLE1] # the server licenses only EXAMPLE1; EXAMPLE2 needs a local CDM
server_accounts: [EXAMPLE1] # may authenticate EXAMPLE1 with one of the server's own accounts
server_proxy: true # this key may also use the server's proxy providers
f3a4b5c6:
services: [EXAMPLE1]
server_vault: [EXAMPLE1] # the client's device licenses; the server vault still serves keys
```
!!! note "`dl` keys inside `serve`"