1. AES-128 and the KMS
1. AES-128 and the KMS
Section titled “1. AES-128 and the KMS”Where we start
Section titled “Where we start”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.
What this is not: real DRM
Section titled “What this is not: real DRM”“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.
The DRM code that exists but never runs
Section titled “The DRM code that exists but never runs”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 fullAVAssetResourceLoaderDelegatethat interceptsskd://URLs, requests a certificate, builds an SPC, posts it to a licence server, and responds with the CKC. - Widevine —
PlayerController.kt, which builds anHttpMediaDrmCallbackand aDefaultDrmSessionManager, 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.
The standard: HLS AES-128
Section titled “The standard: HLS AES-128”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* knowThe key request
Section titled “The key request”It is about as simple as an HTTP request gets:
GET /v1/keys?content_id=a209cc70-25df-4d25-9d3e-3c7d4e5a1030 HTTP/1.1Host: kms.chorki.netreferer: <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).
Where the credentials come from
Section titled “Where the credentials come from”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.
The one thing that makes this all work
Section titled “The one thing that makes this all work”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
.tssegment, and the#EXT-X-KEYrequest.
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 askingSo 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.
Encryption is not entitlement
Section titled “Encryption is not entitlement”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’ssubscription.
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.
Where this lives in the code
Section titled “Where this lives in the code”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 aJsonHelper; L123-132 pulls the reversed keys out of it.lib/data/datasources/settings_data_source.dart:30— theGET /v2/settingscall that carries all of it, cached intoSharedPreferencesat L59.lib/core/constant/const.dart:99-106— the globalsettingsValueNotifierevery 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-KEYline, if you want to see the actual shape rather than an example.
The takeaway
Section titled “The takeaway”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.