5. Threat Model and Gaps
5. Threat Model and Gaps
Section titled “5. Threat Model and Gaps”Where we left off
Section titled “Where we left off”Docs 1–4 described how the system works. This one is about what it is actually for — and, just as importantly, what it is not for. A protection scheme you misjudge is worse than one you understand the limits of, because you make decisions assuming cover you do not have.
Everything here describes main. Where a fix already exists on
feature/anti-piracy-hardening but is not merged, it is marked as such.
What this design does buy
Section titled “What this design does buy” ┌────────────────────────────────────────────────────────────────┐ │ ✓ A CDN URL alone is worthless. │ │ Someone who sniffs or shares a master.m3u8 link gets │ │ encrypted segments and a key URL that 403s them. │ │ │ │ ✓ Casual scraping is off the table. │ │ You cannot point youtube-dl at a link and get a film. │ │ │ │ ✓ The licensor's baseline is met. │ │ "Content is encrypted in transit with a key behind an │ │ authenticated endpoint" is a box most catalogue deals want │ │ ticked, and it is honestly ticked. │ │ │ │ ✓ Entitlement is enforced separately, and properly. │ │ Device counts, concurrent streams and subscription state │ │ are real authenticated REST calls tied to a real user. │ └────────────────────────────────────────────────────────────────┘What it does not buy
Section titled “What it does not buy” ┌────────────────────────────────────────────────────────────────┐ │ ✗ It does not stop a determined attacker with the app. │ │ The signing secret is in the binary's reach, the signature │ │ is deterministic, and the key request is a plain GET. │ │ │ │ ✗ It does not protect frames in memory or on screen. │ │ No secure video path, no output protection. That is what │ │ Widevine L1 / FairPlay buy, and this app uses neither. │ │ │ │ ✗ It does not protect a downloaded film at rest. (see below) │ │ │ │ ✗ It cannot revoke anything. │ │ No per-device keys, no per-user keys, nothing to revoke. │ └────────────────────────────────────────────────────────────────┘None of that makes the design wrong. AES-128 plus a signed key request is a deliberate, common trade: it raises the cost of casual piracy by a lot, at a fraction of the operational complexity of real DRM. The mistake would be believing it does more.
The specific weak points
Section titled “The specific weak points”1. The offline key sits in plaintext beside what it decrypts
Section titled “1. The offline key sits in plaintext beside what it decrypts”This is the big one.
<Documents>/<slug>/ ├── video.m3u8 ← says: the key is "key.bin" ├── key.bin ← the actual 16 bytes, in the clear ├── segment_000.ts ← encrypted… with the key in the same folder └── …Copy that one folder off a device and it plays anywhere, in any HLS player, forever. The key travels with the ciphertext. The AES-128 layer that genuinely protects the stream in transit provides no protection at rest once a download completes.
Status: fixed on feature/anti-piracy-hardening, not merged. That
branch seals the key with a per-install AES-256-GCM wrapping key held in
the Keychain (iOS, first_unlock_this_device) or
EncryptedSharedPreferences (Android, Keystore-backed). On disk it becomes
<name>.enc, laid out as [12-byte IV][ciphertext+GCM tag], and the
loopback server unseals it in memory when the player asks — so the player
never sees the wire format and the plaintext never touches disk. A copied
folder becomes useless on any other device.
The same branch also carries a Keychain/Keystore migration for the auth token, a root/jailbreak playback block, and screen-capture blocking.
2. The settings passphrase is a literal in the binary
Section titled “2. The settings passphrase is a literal in the binary”EncryptionHelper’s AES-256-CBC key and IV are derived from two string
literals compiled into the app, with a fixed IV and no per-install
variation. Reversing the JSON keys (ytiruces, sreyalp_rerrefer) is the
same category of measure.
This is obfuscation, not secrecy — it raises the effort of pulling the CDN secret out of an APK from “trivial” to “twenty minutes with a disassembler”. Worth having; worth not mistaking for a security boundary.
3. The signature is printed to the log
Section titled “3. The signature is printed to the log”keyGenerate() prints both the resource string and the resulting HMAC
signature. LocalProxyServer separately dumps entire playlist bodies on
every playlist request. Neither is debug-gated, so both reach logcat and
Console in release builds, where any app-adjacent tooling can read them.
Cheap fix: gate on kDebugMode, or drop the prints entirely.
4. The manifest parser sends no headers — an asymmetry waiting to break
Section titled “4. The manifest parser sends no headers — an asymmetry waiting to break”The download pipeline fetches the master, variant and audio playlists with
no headers at all. Only listVariants passes them, and only the key
task carries them.
download parse: master.m3u8 ─── no headers ───► works today variant.m3u8 ─── no headers ───► works today key ─── SIGNED ───► required today
playback: everything ─── SIGNED ───► alwaysSo today the CDN enforces the signed headers on the key host only. If it
ever starts enforcing them on playlists too — a one-line config change on
someone else’s side — downloads break at parse time while playback and
casting keep working, which is a confusing failure to diagnose. Passing
the headers in parse() costs nothing and removes the trap.
This is the same class of bug as the EXT-X-SESSION-KEY issue in doc 4:
one path sends something, a sibling path does not, and nothing fails until
an external system changes its mind.
5. Dead configuration and dead code
Section titled “5. Dead configuration and dead code”security_players_cdn_auth_enabled,security_players_cdn_auth_typeand the wholesecurity_api_cdn_auth_*pair are parsed from settings and never read. Anyone togglingenabledserver-side would reasonably expect it to do something. It does not.- Both DRM implementations in
kvideo(Widevine, FairPlay) are complete and permanently unreachable, as doc 1 described. - Two full offline stacks ship unused — media3’s
KDownloadManageron Android andVidLoaderon iOS — alongside thebackground_downloaderpipeline that is actually used.
6. The loopback port is fixed
Section titled “6. The loopback port is fixed”LocalProxyServer binds 127.0.0.1:8080 with no fallback and no retry.
The field is late, so if anything else on the device holds 8080,
start() throws and offline playback fails. Binding to port 0 and
reading back the assigned port would remove the failure mode entirely.
If you were to strengthen this
Section titled “If you were to strengthen this”Roughly in order of value per unit of work:
- Merge the at-rest key wrapping. It closes the one gap where a single folder copy yields a permanently playable film.
- Remove the release-build logging of the signature and the playlist bodies.
- Send the signed headers from the manifest parser, so the download path stops depending on the CDN never tightening its rules.
- Make the
vpsidsignature vary — per session, per title, or with a timestamp the server checks. Today it is one value per platform per build, so one extracted signature works indefinitely for everyone. - Real DRM, if and only if a licensor requires it. Both code paths
already exist in
kvideo; what is missing is a licence server and the packaging pipeline to match. This is a large project, not a patch.
Where this lives in the code
Section titled “Where this lives in the code”lib/core/helper/encryption_helper.dart:12-13,50,54— the hardcoded passphrases and the twoprint()calls.lib/data/services/hls/download_task_factory.dart:82-95andlib/data/services/hls/local_playlist_writer.dart:38-42— where the plaintext key lands on disk and gets referenced.lib/data/services/hls/hls_manifest_parser.dart:332,346,375— the unheadered playlist fetches, versuslistVariantsat L320.lib/domain/services/local_server.dart:32(fixed port) and L121-126 (playlist body logging).lib/data/models/settings_model.dart:123-132— including the settings fields nothing ever reads.lib/core/security/key_file_crypto.dart— onfeature/anti-piracy-hardeningonly. Lazily generates the wrapping key, caches it for the session, exposesencryptBytes/decryptBytes, and arotate()that renders every existing.encunreadable (useful on sign-out or an integrity failure).
The takeaway
Section titled “The takeaway”This is a well-built implementation of a deliberately mid-strength scheme.
It stops link-sharing and casual scraping, it satisfies a standard
licensing requirement, and it does so without a licence server or a
packaging pipeline. What it does not do is defend against someone holding
the device — and on main the sharpest edge is that a completed download
is a portable, permanently playable copy, because the key is sitting next
to the film it unlocks. That fix is already written on a branch. Merging
it is the single highest-value change in this whole series.
Two of the weaknesses above — the static vpsid signature and
entitlement being enforced only client-side — are addressed by an
incoming backend change. Doc 6
assesses it: what it closes, what it leaves alone, and why it makes the
downloaded-key problem above the standout hole rather than one of
several.