Skip to content

1. AES-128 and the KMS

Everything in this series hangs on one sentence:

The video files are encrypted, the key to decrypt them lives on a separate server, and that server will only hand the key to a request that proves it came from this app.

That is the whole system. The four docs after this one are just three different delivery situations, each having to satisfy that one sentence in a different way. So it is worth being precise about what “encrypted” means here, because the word carries a lot of baggage.

“DRM” and “encrypted” get used interchangeably, and they are not the same thing. There is a spectrum:

WEAKEST STRONGEST
│ │
▼ ▼
┌──────────────┐ ┌──────────────────┐ ┌───────────────────────┐
│ Signed URLs │ │ HLS AES-128 │ │ Widevine / FairPlay / │
│ │ │ │ │ PlayReady │
│ no encryption│ │ real encryption, │ │ hardware-backed keys, │
│ at all, just │ │ standard part of │ │ secure video path, │
│ "prove who │ │ the HLS spec, │ │ output protection, │
│ you are to │ │ key fetched over │ │ device revocation │
│ fetch this" │ │ plain HTTPS │ │ │
└──────────────┘ └──────────────────┘ └───────────────────────┘
▲
│
═══ THIS APP IS HERE ═══
(with a signed-URL-style
check layered on top)

This app uses no DRM whatsoever. No Widevine, no FairPlay, no PlayReady. The distinction matters practically, not just pedantically:

  • With real DRM, the decrypted frames never leave a protected memory path, and the OS refuses to screenshot or capture them. With AES-128, once the player has the key, the plaintext is ordinary memory.
  • With real DRM, a compromised device can be revoked server-side. With AES-128 there is nothing to revoke — the key is the key.
  • With real DRM you need a licence server speaking a proprietary protocol. With AES-128 you need a web server that returns 16 bytes.

This is worth knowing before you go looking. The player package kvideo contains complete, working implementations of both major DRM systems:

  • FairPlay — DRMLoaderDelegate.swift, a full AVAssetResourceLoaderDelegate that intercepts skd:// URLs, requests a certificate, builds an SPC, posts it to a licence server, and responds with the CKC.
  • Widevine — PlayerController.kt, which builds an HttpMediaDrmCallback and a DefaultDrmSessionManager, forcing security level L3.

Both branches are guarded on media.drmLicenseUrl being non-null. This app never sets it — buildPlayerMedia() simply does not populate drmLicenseURL or drmCertificate. So both paths are permanently dead code that ships in every build. If you grep for Widevine and find a hit, that is why.

HLS delivers video as a text manifest listing many small segment files. Encryption is one extra tag in that manifest:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-KEY:METHOD=AES-128,URI="https://kms.chorki.net/v1/keys?content_id=a209cc70-25df-4d25-9d3e-3c7d4e5a1030",IV=0xe1be0390e0d28b09cdb56aecf9d592cc
#EXTINF:4.0,
segment_000.ts
#EXTINF:4.0,
segment_001.ts
#EXT-X-ENDLIST

#EXT-X-KEY says: every segment below this line is AES-128 encrypted, the 16-byte key is at this URL, and here is the initialisation vector.

Note what that means for the app: the KMS URL is not something the app knows. It is not in api_url_const.dart, not in the Postman collection, not in any constant anywhere. It arrives at runtime, embedded in a text file the CDN serves. The app’s only relationship with the KMS is that it must be able to authenticate to whatever host the manifest names.

The app knows this: The app learns this at runtime:
┌──────────────────┐ ┌────────────────────────────────┐
│ content.url │──fetch───►│ #EXT-X-KEY URI= │
│ (from the │ │ https://kms.chorki.net/... │
│ content API) │ └────────────────────────────────┘
└──────────────────┘ │
▼
the key request, which needs
credentials the app *does* know

It is about as simple as an HTTP request gets:

GET /v1/keys?content_id=a209cc70-25df-4d25-9d3e-3c7d4e5a1030 HTTP/1.1
Host: kms.chorki.net
referer: <decrypted referrer>
vpsid: <HMAC-SHA1 hex digest>
platform: android
──► 200 OK, 16 raw bytes (with the headers)
──► 403 Forbidden (without them)

No request body. No bearer token. No cookie. No user identity at all — note that carefully: the vpsid header proves this build of this app made the request, not this subscriber. Entitlement is enforced elsewhere (see the last section of this doc).

This is the part that surprises people. The two secrets that produce those headers are not in the binary as plaintext, and they are not fetched from a dedicated auth endpoint either. They arrive inside the ordinary app settings payload, wrapped in two layers of obfuscation.

┌────────────────────────────────────────────────────────────────────┐
│ STEP 1 GET https://api-static.chorki.net/v2/settings │
│ │
│ The response has a top-level key: "ytiruces" │
│ Read it backwards ──────────────► "security" │
└───────────────────────────────┬────────────────────────────────────┘
▼
┌────────────────────────────────────────────────────────────────────┐
│ STEP 2 Every key inside that block is reversed too: │
│ │
│ "sreyalp_rerrefer" ◄──reverse── "referrer_players" │
│ "sreyalp_ndc_htua_nekot" ◄──reverse── "token_auth_cdn_players" │
│ │
│ The *values* are base64 ciphertext, not readable strings. │
└───────────────────────────────┬────────────────────────────────────┘
▼
┌────────────────────────────────────────────────────────────────────┐
│ STEP 3 EncryptionHelper.decrypt() — AES-256-CBC │
│ │
│ key = sha256("G0T!P4T#_Q3Y").hex[0..32] │
│ iv = sha256("G0T!P4T#_VL0CK").hex[0..16] │
│ │
│ Both passphrases are literals compiled into the app. │
│ This is obfuscation, not secrecy — see doc 5. │
└───────────────────────────────┬────────────────────────────────────┘
▼
referrer = "https://<the site>" (plaintext)
secret = "<the CDN auth token>" (plaintext)

Only now can the headers be built. EncryptionHelper.playerHeader() does it in one step:

Map<String, String> playerHeader() => {
'referer': decrypt(settings.value.security_players_referrer!),
'vpsid': keyGenerate(),
'platform': Platform.isAndroid ? "android" : "ios",
};

And vpsid — the header that actually proves anything — is an HMAC-SHA1:

resource = "android|https://<the site>" ← platform + "|" + referrer
secret = <decrypted CDN auth token>
vpsid = HMAC-SHA1(resource, secret).toHex()

Which means the signature has only two possible values per app version — one for android, one for ios. It does not vary per user, per title, per session, or over time. It is a shared secret, computed client-side, with a fixed input. Doc 5 returns to what that does and does not buy.

Here is the detail that makes the online path almost free, and the cast path almost impossible:

On both platforms, the HTTP headers you attach to a media item are applied to every request the player makes for that item — the master playlist, the media playlists, each .ts segment, and the #EXT-X-KEY request.

On iOS that is AVURLAssetHTTPHeaderFieldsKey; on Android it is setDefaultRequestProperties on the HTTP data source factory that ExoPlayer uses for everything.

buildPlayerMedia() attaches 3 headers, once
│
▼
┌───────────────────────────────┐
│ native player, one header set │
└───────────────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
master media segment KEY ← all four carry the headers
.m3u8 .m3u8 .ts request without anyone asking

So on the phone, authenticating to the KMS costs exactly one function call. Nothing has to know that a key request will happen, or when. The moment the player is instead a different device on the network, that free ride ends — which is doc 4.

One last clarification, because it is easy to assume the encryption is what stops a non-subscriber watching. It is not. The vpsid header carries no user identity, so as far as the KMS is concerned, every install of the app looks the same.

Subscription and access control happen before playback, in ordinary authenticated REST calls:

  • GET /auth/device/authorizations/usages — how many devices this account has already registered.
  • GET /user/watch-history/active-stream — how many streams are playing concurrently right now.
  • The content payload itself carries content_licenses (an entitlement record — despite the name, nothing to do with DRM licensing) and the user’s subscription.

The encryption protects the bytes on the CDN from being scraped by someone who never opened the app at all. The REST layer protects the catalogue from someone who has.

  • lib/core/helper/encryption_helper.dart — the entire mechanism, in 70 lines. key/iv (L12-13) are the hardcoded AES-256-CBC material; encrypt/decrypt (L16-33) unwrap settings strings only, never video; playerHeader() (L35-43) builds the three headers; keyGenerate() (L45-56) composes the resource string and signs it; generateHMACSha1() (L59-67) is the signature itself.
  • lib/data/models/settings_model.dart:80 — json['ytiruces'] is opened as a JsonHelper; L123-132 pulls the reversed keys out of it.
  • lib/data/datasources/settings_data_source.dart:30 — the GET /v2/settings call that carries all of it, cached into SharedPreferences at L59.
  • lib/core/constant/const.dart:99-106 — the global settings ValueNotifier every caller reads. If it has not loaded yet, playerHeader() throws on the ! — a case the cast proxy explicitly catches.
  • lib/core/constant/api_url_const.dart:64,66 — the device and concurrent-stream gates. Note there is no key, DRM, or playback URL anywhere in that file.
  • test/data/services/cast/cast_playlist_rewriter_test.dart:24 — a real captured manifest with a live #EXT-X-KEY line, if you want to see the actual shape rather than an example.

The protection is a standard HLS AES-128 layer, and the only clever part is the key request: three headers, one of them an HMAC that any build of the app can compute and every build computes identically. On the phone the native player attaches those headers to the key request for free, without being told a key request exists. Every remaining doc in this series is about a situation where that free ride does not apply — because the player is a TV that cannot send headers (doc 4), or because there is no network at all (doc 3). Doc 2 starts with the easy case.