feat(remote): decrypt service-owned cbcs samples on the server

The client downloads the file and sends binary sample frames to a new samples route.
This commit is contained in:
imSp4rky
2026-10-14 11:03:14 -06:00
parent 5ffa9a4e2b
commit d41bb73d9e
10 changed files with 245 additions and 12 deletions
+19
View File
@@ -933,6 +933,7 @@ GET /api/session/{id}/titles → list titles
POST /api/session/{id}/tracks → tracks + chapters (with URLs) for one title
POST /api/session/{id}/segments → resolve segment URLs / DRM / headers per track
POST /api/session/{id}/segment_filter → HLS segments the service drops (ads, bumpers)
POST /api/session/{id}/samples → decrypt cbcs samples of a track on the server
POST /api/session/{id}/license → proxy or server-CDM DRM licensing
POST /api/session/{id}/keys/bad → report a server-vault content key that did not decrypt
DELETE /api/session/{id} → tear down, harvest updated cache
@@ -1140,6 +1141,24 @@ Get the HLS segments a service drops (ads, bumpers, dub cards). The server runs
| `404` | `TRACK_NOT_FOUND` | An unknown track ID was requested. |
| `500` | `SERVICE_ERROR` | The playlist request or the filter failed. |
### `POST /api/session/{session_id}/samples`
Decrypt the samples of a track that the service decrypts itself after the download. The tracks response lists those track IDs in `sample_decrypt_tracks`. The client downloads the file, then sends each `stsd` sample entry's samples here, and writes the plaintexts back in place. The query string names the track and the 1-based entry: `?track_id=a-1&entry=2`.
- An empty body asks how the entry decrypts. The JSON response is `{ "key": "<hex>" }`, which the client then uses itself, or `{ "decrypter": true }`.
- An `application/octet-stream` body is a sample frame: a big-endian `uint32` count, one `uint32` length per sample, then the samples. Each sample is a ciphertext cut to whole AES blocks. The response is a frame of the same shape with the plaintexts in the same order.
The client sends at most 8 MiB of samples per request, and the route accepts a body of up to 16 MiB. Binary frames keep the upload the size of the samples, and the server does not compress them. The route needs the `server_cdm` grant for the service, because the decrypter can use a process or an account of the server.
| Status | Error code | Meaning |
| --- | --- | --- |
| `200` | - | The content key, `decrypter`, or the plaintext frame. |
| `400` | `INVALID_INPUT` | Missing `track_id`, a bad `entry`, a malformed frame, or a track the service does not decrypt. |
| `413` | - | The body is larger than 16 MiB. |
| `403` | `FORBIDDEN` | The API key has no `server_cdm` grant for the service. |
| `404` | `TRACK_NOT_FOUND` | An unknown track ID was requested. |
| `500` | `SERVICE_ERROR` | The service's decrypter failed. |
### `POST /api/session/{session_id}/license`
Get the content keys for the DRM. The `mode` field selects one of two modes.
+1
View File
@@ -205,6 +205,7 @@ request and response in full.
| `POST` | `/api/session/{session_id}/tracks` | Tracks (with URLs) for a chosen title. |
| `POST` | `/api/session/{session_id}/segments` | Per-track segment/download details. |
| `POST` | `/api/session/{session_id}/segment_filter` | Server-side HLS segment filter results for one track. |
| `POST` | `/api/session/{session_id}/samples` | Server-side cbcs sample decryption for one track. |
| `POST` | `/api/session/{session_id}/license` | Proxy or server-side DRM licensing. |
| `POST` | `/api/session/{session_id}/keys/bad` | Report a server-vault content key that did not decrypt. |
| `GET` | `/api/session/{session_id}/logs` | Drain the service's server-side log output. |
+1
View File
@@ -410,6 +410,7 @@ The server mounts all these routes, even in `--remote-only` mode. Paths use the
| `POST` | `/api/session/{id}/tracks` | Resolve tracks, manifests, chapters, headers/cookies for a title |
| `POST` | `/api/session/{id}/segments` | Resolve per-segment/track download descriptors |
| `POST` | `/api/session/{id}/segment_filter` | Unwanted HLS segment URIs for one track (ads, bumpers) |
| `POST` | `/api/session/{id}/samples` | Decrypt the cbcs samples of a track the service decrypts itself |
| `POST` | `/api/session/{id}/license` | DRM licensing (proxy or server CDM) |
| `POST` | `/api/session/{id}/keys/bad` | Report a server-vault content key that did not decrypt |
| `GET` | `/api/session/{id}/logs` | Drain the service's server-side log output |
+13 -7
View File
@@ -418,14 +418,20 @@ classes drive the CDM handshake and call the decryption backend. Your hook is on
Some tracks cannot use the framework's decryption: a single cbcs file whose sample entries share
a zero default KID, or a sample entry whose content key no CDM can license and only a separate
process can use. For these, call `unshackle.core.drm.cbcs.decrypt_file(track.path, key_for)` from
`on_track_downloaded`. Set no `drm` on the track, so the framework does not try to license it.
process can use. For these, set no `drm` on the track and override two methods:
`key_for(track, entry)` gets the track layout and one protected `stsd` entry. It returns the
16-byte content key, or a `cbcs.SampleDecrypter`. A `SampleDecrypter` gets the protected bytes of
each sample of one fragment, cut to whole AES blocks, and returns plaintexts of the same lengths.
It handles full-sample cbcs only, not pattern encryption. `decrypt_file` writes the plaintext
back in place and removes the protection boxes, so the muxer reads a clear file.
- `decrypts_samples(track)` returns `True` for such a track.
- `get_sample_decrypter(track, entry)` returns, for the 1-based `stsd` entry, the 16-byte
content key or a `cbcs.SampleDecrypter`. A `SampleDecrypter` gets the protected bytes of each sample of
one fragment, cut to whole AES blocks, and returns plaintexts of the same lengths. It handles
full-sample cbcs only, not pattern encryption.
When the download of the track ends, unshackle calls `unshackle.core.drm.cbcs.decrypt_file`, and
the progress row shows "Decrypting". Your `on_track_downloaded` runs before this and gets the
encrypted file. `decrypt_file` writes the plaintext back in place and removes the
protection boxes, so the muxer reads a clear file. In a remote session `get_sample_decrypter`
runs on the server: the client sends the samples through the `samples` route, so a decrypter
that needs a process on the server also works for a remote client.
### How unshackle chooses a CDM and finds keys