Skip to content

Audio streaming protection: tiered approach from cookie refresh to HLS encryption #9

Description

@rmzi

Context

From #7 point 3: now that we have a Lambda (sync endpoint), we can level up audio content protection. This issue captures comprehensive research on AWS streaming security options, cost modeling across deployment scales, and a recommended migration path.

Primary concern: Protecting the music.
Secondary concerns: Efficiency, resiliency, observability, cost management.
Constraint: Maintain backwards compatibility — existing deployments (e.g., 36247) can stay on current architecture.


Current Architecture (Tier 0)

Component Details
Audio storage Private S3 bucket
CDN CloudFront with PriceClass_100 (US/CA/EU)
Auth Signed cookies (RSA-SHA1, canned policy)
Cookie TTL 1 year (hardcoded in deploy-cookies.py and cookies.js)
Cookie delivery Baked into js/config.js at deploy time, precached by service worker
Seeking Byte-range requests supported (S3 CORS + CloudFront)
Compression Disabled for audio (correct — MP3 is already compressed)
DRM None
Observability GA page-level only — no CF access logs, no CloudWatch alerts

Key Security Gaps

  1. 1-year static cookies — anyone who visits the site has year-long access to all audio files. Cookies are precached in the SW shell, so they persist even if the user clears browser cookies.
  2. Wildcard resource pattern — cookies grant access to https://{domain}/*, not per-track.
  3. No cookie rotation — cookies never refresh until a full redeploy.
  4. cookies.js hardcodes max-age=31536000 — ignores the server-side policy TTL entirely.
  5. cookie_ttl_hours terraform var (default 24) is disconnected from deploy-cookies.py's --hours 8760 default.

Tier Options

Tier 1b: Cookie Refresh via Lambda (⭐ Recommended First Step)

What: Add a /sync/refresh route to the existing sync Lambda. Shorten cookie TTL from 1 year to 72 hours. Client auto-refreshes on app load.

Why this first: Zero additional AWS cost. Biggest security improvement per unit of effort. No new services. No client library changes. SW-compatible.

Changes needed:

Area Change
Terraform Add secretsmanager:GetSecretValue to sync Lambda IAM role for the signing key secret
Lambda Add /sync/refresh route: fetch RSA key from Secrets Manager (cached in memory), generate fresh canned-policy cookies, return as JSON
cookies.js Add getCookieExpiry() to parse CloudFront-Policy cookie. Add refreshCookiesIfNeeded() — call on app load, refresh if within 24h of expiry
deploy-cookies.py Change default --hours from 8760 to 72
config.js Can revert to SIGNED_COOKIES = null — cookies come from Lambda, not baked config
SW Remove config.js dependency for cookies — stale-while-revalidate race condition eliminated

Cookie refresh flow:

App loads → getCookieExpiry() → within 24h of expiry?
  → Yes: fetch("/sync/refresh") → set fresh cookies → proceed
  → No: cookies still valid → proceed

Risks:

  • samesite=strict may cause issues on iOS PWA launch (cross-context navigation). Test carefully.
  • Existing deployments without the Lambda endpoint will fail to refresh. Ship the endpoint first, then shorten TTL.

Tier 1: Signed URLs via Lambda (Per-Track)

What: Generate a signed URL per track at play time via Lambda. Each URL expires in 15 minutes.

Pros: Per-track granularity, short-lived, auditable.
Cons: Breaks service worker audio caching (URL includes query params that change per generation). Requires either stripping query params in SW cache matching or accepting no SW caching.

Cost impact: Zero at small/medium scale (Lambda free tier). At large scale, Secrets Manager API calls dominate if key isn't cached in Lambda memory — mitigated by in-memory caching.

CloudFront supports signed cookies AND signed URLs simultaneously — no terraform changes needed. Both are validated by the same trusted_key_groups.


Tier 2: HLS + AES-128 Encryption

What: Transcode MP3 to HLS segments, encrypt with AES-128-CBC, serve key via authenticated Lambda endpoint.

Pros: Segments are ciphertext at rest. Casual copying requires finding the key server. Seeking improves (segment-level jumps).
Cons: Requires hls.js (~250KB) for Chrome/Firefox. Native <audio> doesn't support HLS except Safari. Key server Lambda needs its own auth. ffmpeg can still decrypt with the key.

Component Small (100 tracks) Medium (500) Large (2000)
MediaConvert (one-time)
Additional S3 storage +/bin/zsh.07/mo +/bin/zsh.35/mo +.40/mo
Additional CF requests (60x/play) negligible /bin/zsh.25/mo .50/mo
Key server Lambda free tier free tier </mo

Tier 3: Full DRM (MediaPackage + Widevine/FairPlay)

What: AWS Elemental MediaPackage with commercial DRM license server.

Cost: +/month minimum for the license server alone (BuyDRM, EZDRM, etc.). MediaPackage adds ~2x the per-GB cost vs CloudFront direct.

Verdict: Non-starter for personal/self-hosted use. Only viable at commercial streaming scale.


Cost Summary

Tier Small (100T, 10U/day) Medium (500T, 100U/day) Large (2000T, 1000U/day) Key Tradeoff
0: Current /bin/zsh.40*/mo /bin/zsh.40*/mo .56/mo 1-year static cookies
1b: Cookie refresh /bin/zsh.40*/mo /bin/zsh.40*/mo .56/mo 72h TTL, auto-refresh
1: Signed URLs /bin/zsh.40*/mo /bin/zsh.40*/mo .56/mo Per-play signing, breaks SW cache
2: HLS+AES-128 /bin/zsh.50/mo + 1x /bin/zsh.75/mo + 1x .56/mo + 1x Needs hls.js + key server
3: Full DRM +/mo +/mo +/mo Commercial only

*Free tier covers entirely (1M Lambda reqs/mo + 1TB CF transfer/mo).


Observability (All Tiers)

Currently no CF access logs are enabled. Recommended additions:

  1. CloudFront access logs → S3 logging bucket → Athena queries (~20 lines terraform, /bin/zsh/mo at small scale)
  2. AWS Budgets alert at /mo threshold (~10 lines terraform)
  3. Lambda CloudWatch metrics already auto-publish (Invocations, Errors, Duration)

Key metrics for a music streaming service:

  • Unique listeners/day (CF logs, IP approximation)
  • Track play counts (CF logs, 200s on /audio/*)
  • Cache hit ratio (CF console)
  • Data transfer/day
  • Audio error rate (403/404 on /audio/*)

Backwards Compatibility

CloudFront validates signed cookies first, then signed URLs. Both work simultaneously on the same distribution with the same trusted_key_groups. No terraform changes needed to support mixed deployments.

Migration path:

  1. Phase 0 (current): All instances on Tier 0.
  2. Phase 1: Crate adds cookie refresh. 36247 stays on Tier 0. Both work.
  3. Phase 2 (optional): Crate adds signed URL support. 36247 unchanged.
  4. Phase 3 (optional): Crate adds HLS+AES-128 for new uploads. 36247 unchanged.

Recommendation

Start with Tier 1b (cookie refresh). It's the highest-leverage change:

  • Shrinks the attack window from 1 year to 72 hours
  • Zero additional AWS cost
  • Reuses the existing sync Lambda
  • SW-compatible (no cache-breaking URL changes)
  • 1 terraform change + 1 Lambda route + 1 client function

Then add observability (CF logs + budget alert) and defer HLS until there's a concrete need for stronger protection.


Relevant Files

File Role
terraform/cloudfront.tf Distribution config, trusted_key_groups on audio behavior
terraform/cloudfront-keys.tf RSA-2048 key in Secrets Manager, key group
terraform/lambda-sync.tf Sync Lambda, IAM role (needs SM access)
terraform/variables.tf cookie_ttl_hours var (default 24, unused by deploy script)
tools/deploy-cookies.py Generates signed cookies, bakes into config.js
www/js/cookies.js Sets cookies, hardcodes 1-year max-age
www/js/config.js SIGNED_COOKIES = null (replaced at deploy)
www/sw.js Precaches config.js in shell, cache-first audio

Acceptance Criteria

  • (add criteria here)

Research Notes

(populated by Phase 1 researcher)

Task DAG

  • task 1 (unblocked)
  • task 2 (blocked-by: 1)

Session State

(set on first deploy)

Dev Diary & Transcript

(appended on finish)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions