Skip to content

4. DRM, Encryption & the LAN Proxy Trick

Doc 3 established the key fact this whole doc builds on: the TV fetches the video URL itself, independently of the phone. That works great for a plain public URL. This doc is about what happens when the URL isn’t plain and public — which, for a paid streaming service, is most of the time — and the workaround this app builds to handle it.

Why streaming video needs protecting at all

Section titled “Why streaming video needs protecting at all”

If master.m3u8 and every .ts segment were sitting at a public URL with no protection, anyone with the link could rip the video — no login, no subscription check, nothing. Content owners (studios, licensors) generally require some form of protection before they’ll license content to a streaming service at all. There’s a spectrum of how strong that protection needs to be:

  • Full DRM (Widevine, FairPlay, PlayReady) — hardware-backed encryption with licensing servers, used for the highest-value content (new releases, premium tiers). This app does not use full DRM — worth knowing, because “DRM” and “encrypted” get used interchangeably but aren’t the same thing.
  • HLS AES-128 encryption — a lighter-weight, standards-based scheme built directly into the HLS spec itself (not a separate proprietary system). This is what this app actually uses, and it’s the subject of the rest of this doc.
  • Signed / authenticated URLs — no encryption at all, just “you may only fetch this URL if you can prove who you are” (a token, a signed header, a short-lived signature). Often layered on top of one of the above.

This is a real part of the HLS specification, not a custom invention. A media playlist that uses it has one extra tag before the segment list:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-KEY:METHOD=AES-128,URI="https://kms.example.com/v1/keys?content_id=abc123"
#EXTINF:4.0,
segment_000.ts
#EXTINF:4.0,
segment_001.ts
#EXT-X-ENDLIST

#EXT-X-KEY says: every segment listed below this line is encrypted with AES-128, and the 16-byte key to decrypt them lives at this URL. Any compliant HLS player — including the one running on a Chromecast — already knows how to fetch that key and decrypt segments on the fly. This part needs no special handling at all.

master.m3u8 ──► 720p/media.m3u8 ──► lists segments + one #EXT-X-KEY
│
┌───────────────┘
▼
GET https://kms.example.com/v1/keys?...
│
▼
16 raw bytes (the AES-128 key)
│
▼
player uses the key to decrypt each .ts segment
as it's fetched — completely transparent to the
viewer, as long as the key request succeeds

Where it actually breaks: authentication on the key request

Section titled “Where it actually breaks: authentication on the key request”

The key server in the example above isn’t public — if it were, encrypting the segments would be pointless (anyone could fetch the key too). So it requires proof the request is legitimate, typically via custom HTTP headers: a signed token, a Referer check, a platform identifier — this app’s key server specifically wants an HMAC-signed header computed from app secrets.

Here’s the problem, stated as plainly as possible:

A native mobile player can attach any HTTP headers it wants to every request it makes. The Chromecast Default Media Receiver cannot. It’s a Google-hosted web app you don’t control, fetching URLs with the browser’s own fetch()/XHR, and there is no API in the LOAD message to say “also attach these headers to every request.”

So: the phone can decrypt this content fine (it’s a normal player, making normal authenticated requests). The TV, given the exact same URL, hits the key server, gets rejected (401/403 — no valid signed header), and can never decrypt a single segment.

Phone playing locally: TV playing via cast:
Phone ──GET /keys (+ signed header)──► KMS TV ──GET /keys (no headers)──► KMS
Phone ◄────────── 200 OK, key bytes ──┤ TV ◄──────────── 403 Forbidden ┤
(works fine) (can never decrypt anything)

The workaround: turn the phone into a tiny local web server

Section titled “The workaround: turn the phone into a tiny local web server”

If the TV can’t send the headers the key server wants, the obvious fix is: don’t make the TV talk to the key server at all. Instead, make the TV talk to the phone, and have the phone — which can send the right headers — do the authenticated fetching on its behalf.

This means the phone runs an actual HttpServer, bound to its own LAN IP, for as long as a cast session is active. The playlist handed to the TV isn’t the real CDN URL — it’s a rewritten version pointing back at the phone, with the child playlists and the key swapped for a http://<phone-ip>:<port>/... URL. Segment URLs are left pointing at the CDN — see the note after the diagram, it matters a lot:

TV Phone (local proxy) Real CDN / KMS
│ │ │
│ GET /m/<token> (master playlist) │ │
├─────────────────────────────────────►│ │
│ │ GET real master.m3u8 URL │
│ ├──────────────────────────────────►│
│ │◄──────────────────────────────────┤
│ │ (rewrite every child playlist │
│ │ URL to point back at itself) │
│◄─────────────────────────────────────┤ │
│ │ │
│ GET /v/<token> (media playlist) │ │
├─────────────────────────────────────►│ │
│ │ GET real media playlist URL │
│ ├──────────────────────────────────►│
│ │◄──────────────────────────────────┤
│ │ (rewrite the #EXT-X-KEY URI; │
│ │ absolutise segments to the CDN) │
│◄─────────────────────────────────────┤ │
│ │ │
│ GET /k/<token> (the AES-128 key) │ │
├─────────────────────────────────────►│ │
│ │ GET real key URL, WITH the │
│ │ signed headers the TV can't send │
│ ├──────────────────────────────────►│
│ │◄──────────────────────────────────┤
│◄──────────────── 16 key bytes ───────┤ │
│ │ │
│ GET segment_000.ts ────────────────────────────────────────────────────► │
│◄──────────────────── segment bytes ──────────────────────────────────────┤
│ (phone not involved — it can go away now) │

Each route has one job: /m/ re-serves the master playlist (rewritten), /v/ re-serves a media playlist (rewritten), and /k/ fetches the key with the required headers. Only the key genuinely needs the phone; playlists are proxied because that is where the key’s URI has to be rewritten.

There used to be a fourth route, /s/, which re-served every video segment so the phone could correct a Content-Type the CDN gets wrong (text/vnd.trolltech.linguist instead of video/mp2t — native players sniff the container and never notice, but the receiver is a web player and in principle could reject it).

That was a precaution against a receiver behaviour never actually observed, and it was enormously expensive. Think about what it means:

key proxied only → 3 requests hit the phone, then it's free
key + segments proxied → EVERY byte of the film flows through the phone

With segments proxied, the phone stops being a remote control and becomes the TV’s origin server. Kill the app — or just let the OS freeze it in the background — and the TV stalls as soon as its buffer drains. Casting used to survive being swiped away, and that is precisely what it lost.

The key is 16 bytes, fetched once per VOD and cached by the receiver. Segments are gigabytes. Only one of those is worth putting the phone in the path for.

One subtlety that comes with this: segment URIs upstream are relative (segment_000.ts), and a receiver resolves a relative URI against the playlist’s own address — which is now the proxy. So the rewriter still touches every segment line, but only to absolutise it against the real CDN playlist URL.

What the rewriter has to catch (and what it missed)

Section titled “What the rewriter has to catch (and what it missed)”

The proxy only works if every URL the receiver will fetch is one it can actually reach. That sounds obvious, and it is where the subtle bugs live, because HLS puts URIs in two different places:

on their own line inside a quoted attribute
───────────────── ────────────────────────────
#EXT-X-STREAM-INF:BANDWIDTH=... #EXT-X-MEDIA:...,URI="audio/media.m3u8"
720p/media.m3u8 ← easy #EXT-X-I-FRAME-STREAM-INF:...,URI="720p/iframe.m3u8"
#EXTINF:4.0, #EXT-X-KEY:...,URI="https://kms.../keys?..."
segment_000.ts ← easy #EXT-X-SESSION-KEY:...,URI="..."
#EXT-X-MAP:URI="init.mp4"

A line-based rewriter sees the left column and skips everything starting with # — which is the entire right column. Each attribute needs its own rule, and a missing rule fails silently: the URI stays relative, and the receiver resolves it against the playlist’s address, which is now the proxy.

That is exactly what happened with trick-play playlists. Two titles in the catalogue carry #EXT-X-I-FRAME-STREAM-INF (the tiny I-frame-only playlists a player uses to render a scrub preview), and their URIs went through untouched:

served master: http://<phone>:<port>/m/<token>
resolve("720p/iframe.m3u8")
→ http://<phone>:<port>/m/720p/iframe.m3u8
three path segments, not two → 404

AVPlayer only reads I-frame playlists when the user actually scrubs, so AirPlay never noticed. Shaka parses them while loading the master, so a Chromecast died on content that AirPlayed perfectly. Same manifest, same proxy, opposite outcome — a good reminder that “it works on one receiver” says nothing about the other.

One more thing the receiver sees: the variant list

Section titled “One more thing the receiver sees: the variant list”

The proxy is also the last place to fix a manifest the transcoder got slightly wrong. The demuxed encodes end with:

#EXT-X-STREAM-INF:BANDWIDTH=237000,CODECS="mp4a.40.2",AUDIO="group_audio"
audio/media.m3u8

That is an audio-only “variant” pointing at the very playlist the #EXT-X-MEDIA group already names — and at 237 kbps it is the lowest-bandwidth entry in the master, which is precisely where adaptive bitrate starts. Apple’s authoring spec wants an audio-only rendition (it is the cellular fallback), which is why AVPlayer treats it correctly; a mains-powered TV on Wi-Fi has no use for it. The proxy drops it on the way through, so no receiver can choose it.

Same reasoning applies to forcing a quality. The obvious implementation — hand the receiver the chosen variant’s own media playlist — quietly breaks demuxed content, because a media playlist has no #EXT-X-MEDIA: you get video with no sound. The master has to stay the master, narrowed to one variant.

Why the phone, and not just “fix the CDN”?

Section titled “Why the phone, and not just “fix the CDN”?”

Fixing the CDN (making the key server accept unauthenticated requests, or making segments carry the right Content-Type always) would remove the need for this proxy entirely — and is arguably the “correct” long-term fix. But that requires changing infrastructure the app doesn’t own or can’t change on its own timeline. The LAN proxy is a client-side workaround for a server-side constraint — a very common shape for this kind of problem: when you can’t change the far end, you interpose something you do control in the middle.

  • lib/data/services/cast/cast_lan_proxy_server_impl.dart — the HttpServer itself: the three routes (/m/, /v/, /k/) and retry logic for flaky Wi-Fi.
  • lib/data/services/cast/cast_playlist_rewriter.dart — pure text transformation: given a fetched playlist, rewrite the child-playlist and #EXT-X-KEY URLs to point back at the proxy, absolutise segment lines against the real CDN, rewrite every attribute-embedded URI (see above), and drop variants no receiver should choose.
  • lib/core/helper/encryption_helper.dart — EncryptionHelper builds the actual signed headers (playerHeader()) the key request needs; this is the piece that could never run on the TV itself.
  • lib/domain/services/cast_lan_proxy_server.dart — the abstract interface, kept separate from the implementation so it can be swapped for a fake in tests.

“The TV fetches the video itself” (doc 3) is usually true, but the LAN proxy is a reminder that it’s really “the TV fetches whatever URL you gave it, and it’s on you to make sure that URL is something the TV can actually succeed at fetching.” When the real URL requires something the TV structurally cannot provide, the fix isn’t to change the TV (you can’t) — it’s to put something in between that can.