Skip to content

5. Threat Model and Gaps

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.

┌────────────────────────────────────────────────────────────────┐
│ ✓ 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. │
└────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────┐
│ ✗ 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.

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.

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 ───► always

So 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.

  • security_players_cdn_auth_enabled, security_players_cdn_auth_type and the whole security_api_cdn_auth_* pair are parsed from settings and never read. Anyone toggling enabled server-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 KDownloadManager on Android and VidLoader on iOS — alongside the background_downloader pipeline that is actually used.

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.

Roughly in order of value per unit of work:

  1. Merge the at-rest key wrapping. It closes the one gap where a single folder copy yields a permanently playable film.
  2. Remove the release-build logging of the signature and the playlist bodies.
  3. Send the signed headers from the manifest parser, so the download path stops depending on the CDN never tightening its rules.
  4. Make the vpsid signature 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.
  5. 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.
  • lib/core/helper/encryption_helper.dart:12-13,50,54 — the hardcoded passphrases and the two print() calls.
  • lib/data/services/hls/download_task_factory.dart:82-95 and lib/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, versus listVariants at 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 — on feature/anti-piracy-hardening only. Lazily generates the wrapping key, caches it for the session, exposes encryptBytes / decryptBytes, and a rotate() that renders every existing .enc unreadable (useful on sign-out or an integrity failure).

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.