6. The New KMS Authorization
6. The New KMS Authorization
Section titled “6. The New KMS Authorization”Status: implemented — see doc 7 for what actually shipped. This doc remains the assessment: why the scheme was worth adopting and what it would cost. The header proposal, endpoint shapes and open questions below are unchanged from when it was written.
Where we left off
Section titled “Where we left off”Doc 5 listed what this design protects against and what it does not. Two entries mattered more than the rest: xThe new guidelines address exactly those two. This doc is about how well, and at what cost.
What the change is
Section titled “What the change is”Two opaque signed tokens (PASETO or JWT), added to every KMS request:
| Header | Comes from | Scope |
|---|---|---|
x-content-access |
a new content_access_token field on the existing GET /v1/:type/:slug response |
one title |
x-entitlement |
a new GET /v1/user/entitlements endpoint, cached client-side |
one authenticated user |
Anonymous users send only x-content-access — enough for free (AVOD)
content. Today’s three headers stay exactly as they are.
TODAY AFTER ───── ───── referer: <site> referer: <site> vpsid: <HMAC-SHA1> vpsid: <HMAC-SHA1> platform: android platform: android x-entitlement: <user token> x-content-access:<title token>
proves: "an app build" proves: "this user, this title, right now"The frontend must treat both as opaque — never decode them, never make authorization decisions from their payload. Its only job is to fetch, cache, and forward.
What it fixes
Section titled “What it fixes”The static signature
Section titled “The static signature”This is the real win. Today the signature has two possible values in the whole world — one for Android, one for iOS — and they do not change until the app ships a new secret:
TODAY AFTER ┌────────────────────────┐ ┌────────────────────────────┐ │ extract vpsid once │ │ extract vpsid once │ │ ↓ │ │ ↓ │ │ mint keys for any │ │ …and get nothing. You also │ │ title, any user, │ │ need a live entitlement │ │ forever │ │ token for a real account │ └────────────────────────┘ │ and a token naming the │ │ title you want. │ └────────────────────────────┘An extracted secret stops being sufficient on its own. That is a genuine step up, and it is the single most valuable thing in the proposal.
Entitlement becomes server-enforced
Section titled “Entitlement becomes server-enforced”Doc 1 made the point that encryption is not entitlement: the vpsid header
carries no user, so KMS could not tell a subscriber from anyone else, and
the paywall lived entirely in the app. Anyone talking to KMS directly walked
straight around it.
After this change KMS validates entitlement itself. canAccessContent
becomes what it should always have been — a UX decision about which button
to draw, not a security boundary.
What it does not fix
Section titled “What it does not fix”| Gap from doc 5 | After this change |
|---|---|
key.bin plaintext at rest |
Untouched — and now the only path with no entitlement check at all |
| Settings passphrase is a binary literal | Still there, but devalued: the CDN secret alone no longer yields playable keys |
print() of the HMAC signature |
Untouched — and riskier, because the new tokens are bearer-grade credentials passing through the same code |
| Manifest parser sends no headers | Untouched, with two more headers to forget |
| Fixed port 8080, dead settings fields, dead DRM code | Unrelated |
The offline hole gets relatively worse
Section titled “The offline hole gets relatively worse”The tokens gate the key request. Downloads make that request once, at download time, and then never again — offline playback serves the key from disk through the loopback server and contacts KMS not at all.
ONLINE OFFLINE every play → KMS download once → key.bin on disk → entitlement checked → never contacts KMS again, ever subscription lapses subscription lapses → next play denied → still plays. Forever.So after this ships, every path is entitlement-checked except downloads — which makes downloading the one reliable way to keep access after cancelling. The gap is not new, but it goes from “one weak spot among several” to “the weak spot”.
Closing it properly needs a server-issued download licence with a TTL,
re-validated when the device next has network. That is a backend design
decision, not a client one, and it is not in these guidelines. The at-rest
wrapping on feature/anti-piracy-hardening helps with a different problem
(a copied folder playing on someone else’s device) and does not expire
anything.
The drawbacks
Section titled “The drawbacks”None of these are faults in the guidelines. They are consequences of how this app fetches keys, and they are what makes this more than a header change.
1. There is no “KMS interceptor” on native mobile
Section titled “1. There is no “KMS interceptor” on native mobile”The guidelines say to attach the tokens in the player’s KMS interceptor. On web that exists. Here it does not.
Doc 2 explained why the online path is so cheap: one header map is handed to the native player and applied to every request for that asset. That is also the problem — there is no way to say “these two headers, but only on the key request”.
kmsHeaders() attaches 5 headers, once │ ▼ ┌───────────────────────────────┐ │ native player, one header set │ └───────────────────────────────┘ │ │ │ │ ▼ ▼ ▼ ▼ master media segment KEY .m3u8 .m3u8 .ts request ▲ ▲ ▲ └────────┴────────┴── tokens go here too, unavoidablySo both tokens reach the CDN on every segment. At ~4-second segments a two-hour title is roughly 1,800 requests; two ~800-byte tokens adds on the order of 3 MB of request headers per view, and exposes user-scoped credentials to the CDN edge. Same organisation, but it should be a conscious decision rather than a side effect.
The only way to scope the tokens to the key request alone is to route key requests through the app’s own proxy — which is exactly what the cast path already does, and what doc 4 explains costs a process-lifetime dependency.
2. The cast/AirPlay proxy has no content identity
Section titled “2. The cast/AirPlay proxy has no content identity”/k/<token> decodes to an upstream URL and nothing else. The interface
carries no content either:
Future<String> proxiedMasterUrl(String masterUrl, {String? receiverHost, String? variantUrl});Future<String> playbackUrlFor(String cdnUrl); // AirPlayAnd it cannot be fixed with a single “current content token” field, because the proxy is a shared retained singleton: Chromecast and AirPlay both use it, both can be live across a handover, and a cast queue proxies an entire series in one dispatch loop — so several titles’ tokens are in flight at once.
It needs a map keyed by upstream URL, propagated down the rewrite chain the
same way _keyCache already works. Real work, not a one-liner.
3. Download tokens will be stale by the time they are used
Section titled “3. Download tokens will be stale by the time they are used”The key task’s headers are frozen when the task is built:
return DownloadTask( ... retries: 3, headers: headers, // captured now, sent whenever the OS gets to it);background_downloader hands that to WorkManager / URLSession, which may
run it much later, and reenqueueMissing re-enters days afterwards on
relaunch or network return. A short-lived signed token will not survive that
trip. It therefore cannot be persisted in Hive alongside the download; it
has to be re-minted on every enqueue — in an orchestrator that only ever
receives a CacheContentEntity and has no ContentEntity left to read a
token from.
4. Two of the three paths cannot see a rejection
Section titled “4. Two of the three paths cannot see a rejection”| Path | Who makes the key request | Can Dart see the status? |
|---|---|---|
| Online playback | native ExoPlayer / AVPlayer | No |
| Downloads | background_downloader, natively |
No |
| Cast / AirPlay | the app’s own proxy, dart:io |
Yes |
So “token expired → refresh and retry” is not implementable on the main playback path. An expired entitlement token surfaces as an opaque playback failure with no way to distinguish it from a network problem. This is the most consequential operational drawback, and it puts a hard requirement on token lifetime: it must comfortably outlast a full title.
5. playerHeader() is synchronous
Section titled “5. playerHeader() is synchronous”All seven call sites build the header map synchronously, on demand. Fetching an entitlement token is asynchronous. So the token has to already be in memory before playback starts — meaning a warm-up on login and app start, and a defined answer for “what do we send when it is not ready yet”. The cast proxy already guards the same class of race for settings, and fails the request loudly rather than caching an error body as a key; the new path needs an equivalent decision.
6. Cache invalidation has no single hook
Section titled “6. Cache invalidation has no single hook”The guidelines are explicit that User A’s token must never be reused after switching to User B. The app makes that easy to get wrong:
- There are two duplicated
removeUser()implementations, one inUserAuthDataSourceand one inProfileManagementDataSource. - Profile switching mints a brand-new bearer token without a logout
event — it calls
removeUser()thensaveUser(). So a clear insideremoveUser()does cover it, but only if it is added to both copies. - Nothing listens to
current_user, so there is no change notification to hang invalidation off.
Consolidating those two implementations is the natural prerequisite.
7. Absent tokens will arrive as empty strings, not null
Section titled “7. Absent tokens will arrive as empty strings, not null”JsonHelper.getJsonData returns "" when a key is missing — never null. So
content_access_token will read as empty on today’s backend, and every
consumer has to treat empty as absent, or the app will send an empty header
where it should send none. Same care is needed for anonymous users, who must
have x-entitlement omitted rather than sent blank.
8. Smaller things
Section titled “8. Smaller things”ContentEntity has ~60 fields, no copyWith, and an Equatable props list
that has to be updated in step. Trailers build their own media entry and
have no token of their own. And fromEntity() is already lossy, so it
cannot be relied on to round-trip a new field.
Questions for the backend team
Section titled “Questions for the backend team”- Expired vs. unentitled — what status does KMS return for each? Because of drawback 4 the client cannot distinguish them on the main playback path, so the entitlement token’s lifetime must exceed the length of a title.
- Offline licences — is there any intent to expire a downloaded title’s key? Without it, downloads permanently bypass everything above.
- Header bloat — both tokens will reach the CDN on every segment request on the direct path. Acceptable, or should key requests be proxied?
- Trailers — are trailer assets KMS-encrypted, and if so do they get
their own
content_access_token? - Profile switch — it issues a new user token without a logout. Does that invalidate the entitlement token too?
Where this lives in the code
Section titled “Where this lives in the code”Nothing implements this yet. These are the places it would touch, and the evidence behind each drawback above:
lib/core/helper/encryption_helper.dart:35-43—playerHeader(), the synchronous map that reaches all three key-request clients. The natural place to wrap, and the reason drawback 5 exists.lib/presentation/widgets/player/player_media_builder.dart:18-45— entity in scope, one-line change; also the source of drawback 1, since this map is applied to every request.lib/domain/services/cast_lan_proxy_server.dart:37-41andlib/data/services/cast/cast_lan_proxy_server_impl.dart:116,538-576— the URL-only interface, the retain/release sharing, and the_keyCachepattern a content map would follow.cast_controller.dart:990-1016is the queue loop that proxies a whole series at once.lib/presentation/widgets/player/airplay/airplay_controller.dart:293,710— the zero-arg header closure and the URL-onlyplaybackUrlFor.lib/data/services/hls/download_orchestrator.dart:108,198anddownload_task_factory.dart:82-95,254-268— where key headers are frozen into aDownloadTask.lib/data/datasources/user_auth_data_source.dart:216andlib/data/datasources/profile_management_data_source.dart:31-48,68— the tworemoveUser()copies and the profile switch.lib/core/helper/json_helper.dart:17-27— the empty-string return.lib/data/models/content_model.dart,lib/domain/entities/content.dart:31,160-223— where the new field and itspropsentry would go.
The takeaway
Section titled “The takeaway”The proposal fixes the right thing. A key request that proves only “some build of this app asked” becomes one that proves “this user, entitled to this title, right now” — which retires the static-signature weakness and moves the paywall from the client into KMS where it belongs.
What it costs is more than a header. The guidelines assume a KMS interceptor this app does not have on native, so the tokens will reach the CDN on every segment; the cast proxy has no notion of which title a key belongs to; the download pipeline freezes headers at enqueue time and will send stale ones; and on the two paths that matter most, the app cannot even see a rejection, so an expired token looks like a broken video.
And it leaves the biggest at-rest gap exactly where it was. After this ships, a downloaded film is the only thing in the system nobody checks — which makes the offline licence question the one worth raising first.