- multistreaming (new): RTMP ingest + multi-platform fan-out with pluggable providers (Twitch/YouTube/Kick/custom), zero-knowledge key vaults, Authelia OIDC auth, shared rooms with editor/streamer roles, single-use invites, per-account streaming grants, and scenes & composition (grid/PiP layouts, text/image overlays, per-output audio routing). - installer: support Dockerfile build in metadata (not just image) and RSA key generation for the Authelia OIDC JWKS. - authelia: add OIDC provider with portainer + multistreaming clients (public + PKCE). - services: remove allprox; add nginx-proxy-manager and portainer; update lldap; regenerate catalog.
9.3 KiB
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
- Browser — holds the vault key (VK) only while a vault is unlocked, in memory; encrypts/decrypts keys client-side.
- Panel server (the container, the "center machine") — persists only ciphertext; holds plaintext keys transiently in memory during a stream.
- 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 AADmultistreaming:vault:v1. Stored server-side; useless without the master passphrase.deviceWrapped— the VK wrapped by a non-extractable browser device key under AADfingerprint|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:<accountId>, so a ciphertext cannot be replayed against a different account id.
Consequences:
- A database/disk leak of
config.jsonyields 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 anHttpOnly; SameSite=Laxcookie, 12-hour lifetime.SESSION_SECRETshould 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:
- Keys are held only in memory, only for granted accounts, and only for
GRANT_TTL_MS. - Revocation is one click ("Stop streaming") or automatic on expiry.
- 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) plusdrawtext=...: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/uploadswith server-generated UUID filenames (never client-supplied paths), and the path is single-quote escaped before reaching themoviefilter. - Log redaction applies to composed outputs exactly as to passthrough: destination URLs (which embed keys) are replaced before logging.
9. Residual risks & known limits
- Center-machine memory — see §7. Root/host access during a stream reads live keys.
- 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.
- 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.
- Panel TLS — the container serves HTTP on 8080; TLS termination is expected at a reverse proxy (nginx-proxy-manager).
- Local auth mode —
AUTH_MODE=localis intended for development; OIDC is the recommended production mode. - No audit log — grant/activate/invite events are not persisted for audit.
- 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=oidcbehind Authelia with PKCE and matchingOIDC_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_MSas short as operationally comfortable. - Back up
dataDir(it holds the only copy of wrapped keys; there is no recovery without the passphrase).