repo/services/multistreaming/SECURITY.md
Ezequiel C. 187379de4e multistreaming: scenes/composition, installer build support, service updates
- 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.
2026-09-02 21:18:25 +02:00

220 lines
9.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 | Lowmoderate |
| 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:<accountId>`, 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).