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.
This commit is contained in:
parent
bb754cdd8c
commit
187379de4e
106 changed files with 21391 additions and 286 deletions
220
services/multistreaming/SECURITY.md
Normal file
220
services/multistreaming/SECURITY.md
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
# 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:<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).
|
||||
Loading…
Add table
Add a link
Reference in a new issue