# SECURITY.md — multistreaming This document describes the security model of the self-hosted multistreaming service, its threat model, and — explicitly and honestly — the limits of what it can protect. Read it before exposing the service to anything but a trusted network. --- ## 1. What this service does `multistreaming` ingests RTMP feeds (typically one per person from OBS) and restreams them to one or more streaming platforms (Twitch, YouTube, Kick, or a custom RTMP endpoint). Two programming modes exist: - **Passthrough** — each live feed is remuxed (`-c copy`) to its destination accounts without re-encoding. - **Scenes & composition** — multiple live feeds are composed (grid or picture-in-picture, plus text/image overlays) and re-encoded into a single program pushed to destination accounts, with per-output audio routing. To push to a platform, the service must present that platform's **stream key**. Stream keys are the highest-value secret in the system. --- ## 2. Threat model ### Assets | Asset | Sensitivity | |-------|-------------| | Platform stream keys (Twitch/YouTube/Kick/custom) | **Highest** — compromise = account takeover of that channel | | Vault master passphrase / vault key (VK) | Highest — decrypts all keys in a vault | | Account metadata, room topology, scene definitions | Low–moderate | | Composed/relayed video | As sensitive as the stream content itself | ### Actors | Actor | Assumption | |-------|-----------| | Homelab operator / host root | **Fully trusted.** Can read any memory/disk on the machine. | | Service account (the container) | Trusted with plaintext keys *at push time only*. | | Vault owner | Holds the master passphrase; controls their own accounts. | | Room **owner** | Full control of a room's production; sees own keys only. | | Room **editor** | Edits feeds/outputs/scenes; **never** sees anyone's keys. | | Room **streamer** | Manages their own accounts + grants their own keys; read-only on production. | | Streaming platform | External; receives the pushed stream. | | Network observer (LAN/internet) | Assumed able to observe traffic unless TLS/VPN is used. | ### Trust zones 1. **Browser** — holds the vault key (VK) only while a vault is unlocked, in memory; encrypts/decrypts keys client-side. 2. **Panel server (the container, the "center machine")** — persists only ciphertext; holds plaintext keys transiently in memory during a stream. 3. **Ingest path** — RTMP from OBS to the container on port 1935. --- ## 3. Zero-knowledge vault The server **never stores a plaintext stream key**. Account secrets are stored as `secretCiphertext` (`{iv, data}`, AES-GCM) encrypted under a random 256-bit vault key (VK) that only the user's browser holds after unlock. Key hierarchy (`src/vault.js`): ``` master passphrase ──PBKDF2-HMAC-SHA256 (600k)──▶ KEK ──wraps──▶ VK ──encrypts──▶ account secrets ``` - `salt` — random 16 bytes, stored server-side. - `serverWrapped` — the VK wrapped by the KEK under AAD `multistreaming:vault:v1`. Stored server-side; useless without the master passphrase. - `deviceWrapped` — the VK wrapped by a non-extractable browser device key under AAD `fingerprint|sessionId`. Enables silent unlock and is **session-bound**: a different session id or browser fingerprint fails AES-GCM authentication. - Each account secret is encrypted under the VK with AAD `acct:`, so a ciphertext cannot be replayed against a different account id. Consequences: - A database/disk leak of `config.json` yields only PBKDF2-wrapped blobs and per-account ciphertexts — nothing decryptable without the passphrase or the device key. - There is **no password recovery**. Losing the passphrase and the device wrapping means the keys are unrecoverable (by design). --- ## 4. Capability grants (keys in memory) The zero-knowledge property cannot extend all the way to the push: **some machine must present the plaintext key to the platform at push time.** That machine is the multistreaming container. To minimize exposure, the service uses **short-lived, in-memory capability grants** (`src/grants.js`): - A streamer clicks **Start streaming**; their browser decrypts the selected keys client-side and sends them to the server over the panel connection. - The server stores them only in memory: `accountId → { key, grantedBy, expiresAt }`, never on disk. - Lifetime is `GRANT_TTL_MS` (default 6 hours). Expired grants are dropped on access. - Grants are revoked when the streamer clicks **Stop streaming**, when the account is deleted, or on expiry. - FFmpeg resolves the destination URL from the live grant at spawn time; the URL (which contains the key) is **redacted** in all logs (`redactUrl`). --- ## 5. Authentication & authorization - **Sessions** (`src/auth.js`): HMAC-SHA256-signed tokens (`base64url({sid, uid, iat}).signature`) in an `HttpOnly; SameSite=Lax` cookie, 12-hour lifetime. `SESSION_SECRET` should be set to a stable random value; if unset it is generated per boot (sessions do not survive restarts). - **Local auth** (dev only): scrypt password hashing, constant-time comparison. - **OIDC** (recommended, Authelia): authorization-code + PKCE (S256), with the verifier held server-side (10-minute state). Identity is mapped to a local user record by `sub`/username. OIDC is for authentication only — it never touches vault key material. ### Per-room roles | Capability | owner | editor | streamer | |------------|:-----:|:------:|:--------:| | Manage feeds, outputs, scenes, scene outputs, images | ✅ | ✅ | — | | Activate/deactivate scenes | ✅ | ✅ | — | | View room production | ✅ | ✅ | ✅ | | Manage own accounts + grant own keys | ✅ | ✅ | ✅ | | See another user's `secretCiphertext` | — | — | — | Non-members receive `404` (never `403`) on room-scoped reads, and insufficient roles receive `403`. `maskAccount` strips `secretCiphertext` from every account unless the viewer is its owner, so editors and streamers never see others' keys. --- ## 6. Single-use invites `POST /api/rooms/:roomId/invites` (owner only) mints an invite with a 256-bit random token, a role of `editor` or `streamer` (never `owner`), and a 7-day expiry. Accepting an invite consumes it (deletes the record) and creates the membership. Invites are: - single-use (consumed on accept), - expiring (pruned at load and on lookup), - role-limited (cannot mint an owner), - unguessable (256-bit token). --- ## 7. The inherent trust boundary (be explicit) **If you compromise the host (or the multistreaming container while a stream is running), you can read every key that is currently granted.** That is not a bug; it is the fundamental limit of any restreaming service — the machine that pushes must hold the key at push time. The mitigations are scope and time, not prevention: 1. Keys are held only in memory, only for granted accounts, and only for `GRANT_TTL_MS`. 2. Revocation is one click ("Stop streaming") or automatic on expiry. 3. Keys are never written to disk, never logged, and never sent to editors, streamers, or other room members. If the host is untrusted, do not run this service on it. --- ## 8. Scene & composition security - **FFmpeg argument injection** is prevented by: spawning via an argument array (no shell) and escaping overlay text for FFmpeg's filtergraph parser (`escapeDrawtext`: control chars stripped, `\` and `'` escaped) plus `drawtext=...:expansion=none`, a 256-character cap, and NUL stripping. - **Image overlays** accept only decoded payloads ≤ 1 MiB whose magic bytes are PNG/JPEG/WebP; files are stored under `dataDir/uploads` with server-generated UUID filenames (never client-supplied paths), and the path is single-quote escaped before reaching the `movie` filter. - **Log redaction** applies to composed outputs exactly as to passthrough: destination URLs (which embed keys) are replaced before logging. --- ## 9. Residual risks & known limits 1. **Center-machine memory** — see §7. Root/host access during a stream reads live keys. 2. **No CSP / XSS hardening** — the panel does not currently ship a strict Content-Security-Policy. A browser XSS could read a decrypted key while the vault is unlocked. Run the panel behind a TLS reverse proxy and keep dependencies updated. 3. **Plaintext RTMP ingest** — the ingest path (port 1935) is unencrypted and bypasses the HTTP proxy by design. Keep it on a trusted LAN/VPN; do not expose 1935 to the internet. 4. **Panel TLS** — the container serves HTTP on 8080; TLS termination is expected at a reverse proxy (nginx-proxy-manager). 5. **Local auth mode** — `AUTH_MODE=local` is intended for development; OIDC is the recommended production mode. 6. **No audit log** — grant/activate/invite events are not persisted for audit. 7. **Composition re-encode** — scene mode decodes and re-encodes feeds on the server; this does not weaken key handling but is a CPU cost and a single point of processing. --- ## 10. Operational checklist - Set a stable, high-entropy `SESSION_SECRET`. - Use `AUTH_MODE=oidc` behind Authelia with PKCE and matching `OIDC_REDIRECT_URI`. - Terminate TLS at the reverse proxy; expose only the panel there, not 1935. - Keep RTMP ingest on a private network. - Leave `GRANT_TTL_MS` as short as operationally comfortable. - Back up `dataDir` (it holds the only copy of wrapped keys; there is no recovery without the passphrase).