4. DRM, Encryption & the LAN Proxy Trick
4. DRM, Encryption & the LAN Proxy Trick
Section titled “4. DRM, Encryption & the LAN Proxy Trick”Where we left off
Section titled “Where we left off”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.
The standard: HLS AES-128
Section titled “The standard: HLS AES-128”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 succeedsWhere 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 theLOADmessage 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.
Why segments are not proxied
Section titled “Why segments are not proxied”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 freekey + segments proxied → EVERY byte of the film flows through the phoneWith 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 → 404AVPlayer 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.m3u8That 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.
Where this lives in the code
Section titled “Where this lives in the code”lib/data/services/cast/cast_lan_proxy_server_impl.dart— theHttpServeritself: 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-KEYURLs 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—EncryptionHelperbuilds 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 takeaway
Section titled “The takeaway”“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.