Files
voice-cat/docs/security.md
Talon 268d511f79 docs: initial design baseline for VoiceCat voice/text chat
Establish the design spec in docs/ before implementation:

- README: overview, locked decisions, principles, glossary
- architecture: shared C++ core + C ABI, native UIs (Swift/C#),
  threading model, server design (SFU relay)
- protocol: TCP/TLS control plane, protobuf Envelope + message
  catalog, connection lifecycle, extensibility rules
- voice: UDP media frame format, per-channel Opus config,
  multi-stream model, two-sided noise reduction, VAD/PTT,
  jitter buffer, iOS ReplayKit screen-audio
- security: mandatory encryption (TLS 1.3 + exported-key AEAD),
  TOFU server identity, admin-provisioned accounts, anti-replay
- tech-stack: permissive-only deps (mbedTLS, libsodium, opus,
  miniaudio, webrtc-apm, ...), build tooling, no GPL/LGPL
- deployment: zero-config self-host (Docker / binary / source)
- roadmap: M0-M5 milestones, resolved decisions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:47:09 +02:00

158 lines
9.0 KiB
Markdown

# Security Model
Two encrypted transports: **TLS 1.3** on the TCP control channel, and an encrypted **UDP**
media channel. Plus server identity, authentication, accounts at rest, and anti-replay.
> **Encryption is mandatory — there is no unencrypted mode.** The server has no plaintext
> listener, the client has no "insecure" option, and there is no config flag to turn either
> off. A connection is encrypted or it does not exist. This is a hard product rule, not a
> default. It is also *zero-config* (see §1): the server generates its own key/cert on first
> run, so "secured by default" never costs the operator a setup step.
## 1. Control channel — TLS 1.3 (settled)
- TCP control is wrapped in **TLS 1.3** (TLS 1.2 disabled). AEAD cipher suites only
(AES-128-GCM, AES-256-GCM, ChaCha20-Poly1305). X25519 key exchange.
- Library: **mbedTLS 3.6 LTS** — Apache-2.0 (permissive, fine for an eventual closed-source
distribution), TLS 1.3 client+server, and `mbedtls_ssl_export_keying_material()` for the
media path (§2). It also **static-links cleanly into a single self-host binary**, which is
a deliberate choice in service of the easy-deploy goal. (OpenSSL 3.x, also permissive
Apache-2.0, is a drop-in alternative behind the same internal interface.)
- **Zero-config TLS:** on first launch the server auto-generates a self-signed certificate
bound to a freshly generated **Ed25519 identity key** and persists both. The operator does
nothing. Clients pin the identity on first connect (TOFU, §1.1). A server *with* a domain
can drop in a CA cert later, but it is never required to be encrypted.
- All authentication and account material crosses the wire only inside this tunnel.
### 1.1 Server identity — two modes
Self-hosting means most servers won't have a CA-signed cert for a hostname. We support
both, advertised in `ServerHello`:
1. **TOFU (Trust On First Use)** — default for hobby servers. The server has a long-lived
**Ed25519 identity key**; its fingerprint is shown to the user on first connect (like
SSH host keys / TeamSpeak server keys) and pinned locally. Subsequent connects verify
the pin; a changed key warns loudly. The TLS cert is self-signed and bound to this
identity key.
2. **PKI** — a server with a domain can use a normal CA-signed cert (e.g. Let's Encrypt);
clients validate the chain conventionally. TOFU pinning still applies on top.
Client certificates are reserved for a future "key-based identity" option (see roadmap) but
are not required in v1.
## 2. Media channel — UDP encryption (settled: exported-keys + AEAD)
The UDP media path uses **TLS-exported keys + per-packet AEAD** (an SRTP-style design),
mandatory from the first build. This was chosen over DTLS after weighing two findings:
> **Finding 1 — DTLS 1.3 (RFC 9147) is not in stable OpenSSL or mbedTLS.** It ships
> production-ready only in **wolfSSL**, which is **GPLv2-or-commercial** — disqualified,
> because the code will eventually be distributed in closed-source form (no GPL/LGPL deps).
>
> **Finding 2 — mbedTLS 3.6 LTS already exposes `mbedtls_ssl_export_keying_material()`**
> (RFC 5705 / RFC 8446 §7.5 exporter). So we can derive media keys from the existing TLS 1.3
> control session with *zero* extra handshake and *zero* extra dependency.
### How it works
1. During the TLS 1.3 control handshake, both sides call the keying-material exporter with a
fixed label (`"voicecat media v1"`) to derive independent **send/recv media keys** and a
salt. No second handshake, no certificates on the UDP path — the UDP channel inherits the
authenticated, MITM-resistant TLS session's trust.
2. Each UDP voice frame is sealed with **ChaCha20-Poly1305** (libsodium, ISC license).
3. The readable routing field (`ssrc`) is passed as AEAD **associated data** so the relay can
route without decrypting and an attacker cannot tamper with it undetected.
This keeps the entire crypto surface on two permissive libraries (mbedTLS + libsodium), adds
no handshake latency to voice startup, and is small enough to audit fully. It is abstracted
behind a `MediaCrypto { seal(frame)->bytes; open(bytes)->frame }` interface, so a future
DTLS 1.3 backend could slot in later if a permissive implementation matures — but nothing in
the design depends on that.
### Per-frame protections
- **AEAD** (ChaCha20-Poly1305) over each voice frame — confidentiality + integrity.
- **Associated data:** the `ssrc` (and version/flags) are authenticated-but-visible so the
relay routes without decrypting; everything else is encrypted.
- **Nonce discipline:** `nonce = direction_bit ‖ ssrc ‖ monotonic_packet_counter`. The
counter never repeats under one key; the session **rekeys** (re-derives via the exporter
with a bumped epoch) well before counter exhaustion or on a time/byte budget.
- **Anti-replay:** a sliding-window replay filter per ssrc (à la IPsec) keyed on the packet
counter. Replays and out-of-window packets are dropped before decode.
## 3. UDP session binding
UDP packets are not individually authenticated to a *user* beyond the transport session.
Binding works as:
1. `AuthResult.udp_token` (issued over TLS) is a short-lived, single-use, random token tied
to `session_id`.
2. Client's first UDP message is `UdpBinding{udp_token}`, sent as the first AEAD media frame
using the keys exported from the TLS session.
3. Server validates the token, binds the **5-tuple → session_id**, and discards the token.
4. Thereafter, frames are accepted only on that bound tuple; ssrcs are checked against the
streams the session announced. Source-address spoofing can't hijack a session because the
attacker lacks the media key and the token.
## 4. Authentication & accounts (settled: guests + local accounts)
- **Guests:** toggled by server config (`allow_guests`). A guest picks a nickname and joins;
no persistent identity. Nicknames are non-reserved and may be uniquified by the server.
- **Local accounts:** username + password, **admin-provisioned** (no self-serve registration
in v1). An admin creates/resets/deletes accounts either via the `voicecat-admin` CLI or the
in-app admin interface, which sends the privileged `CreateAccount`/`ResetPassword`/
`DeleteAccount` messages (protocol.md §3, permission-gated). Stored in **SQLite**; passwords
hashed with **Argon2id** (via libsodium `crypto_pwhash`) using per-install-tuned memory/time
parameters; never stored or logged in plaintext. Verification runs on the worker pool (it's
deliberately slow) to avoid stalling the net thread.
- **Channel passwords:** hashed at rest too; join attempts compare server-side.
- **Brute-force defense:** per-IP and per-account rate limiting on auth attempts with
exponential backoff; configurable lockout. Generic `auth_request` failures return a
non-enumerating error ("invalid credentials") to avoid username probing.
```
accounts( id INTEGER PK, username TEXT UNIQUE,
pw_argon2id TEXT, -- encoded hash incl. params + salt
created_at, last_login, flags )
bans( id, subject_type, subject, reason, expires_at )
```
## 5. Permissions (scaffold for v1, enforced server-side)
A `Permissions` set is attached to each session at auth time and is the *only* authority —
clients never self-grant. v1 needs a minimal set (join channel, send text, create temporary
channel, kick/move if moderator); the model is a role/flag bitset that the moderation
milestone expands. All privileged operations (`CreateChannel`, `Kick`, `Ban`,
`MoveUser`, server-mute) are checked against it server-side regardless of client UI.
## 6. Threat model & non-goals
**In scope:**
- Passive eavesdropping on either transport → defeated by TLS 1.3 (control) and the
exported-key AEAD (media).
- Active MITM on first connect → mitigated by TOFU pin + Ed25519 identity (user must verify
fingerprint out-of-band for the strongest guarantee).
- UDP source spoofing / session hijack → defeated by token binding + media-key secrecy +
anti-replay.
- Password theft at rest → mitigated by Argon2id; in transit → only inside TLS.
**Explicit non-goals (v1):**
- **End-to-end encryption between users.** The server relays Opus and can see who talks to
whom; with the SFU relay it does *not* decode audio, but the media key is per
client↔server, not per pair. True E2EE (server can't read media) is a possible future
feature, not v1.
- Anonymity / metadata hiding. The server, by design, knows the channel graph.
- DoS resilience at scale beyond basic rate limiting and the bounded-frame guards.
## 7. Crypto dependency summary
Two libraries, both permissive (no GPL/LGPL), so a future closed-source distribution stays
clean:
- **TLS 1.3:** **mbedTLS 3.6 LTS** (Apache-2.0). Control-channel TLS + the keying-material
exporter that seeds the media path. Static-links into a single binary. (OpenSSL 3.x,
Apache-2.0, is an interchangeable alternative.)
- **Primitives & password hashing:** **libsodium** (ISC) — Argon2id (account passwords),
ChaCha20-Poly1305 (the media AEAD), Ed25519 (server identity), X25519, secure RNG. All
non-TLS crypto goes through libsodium so we never hand-roll a primitive.