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

9.0 KiB

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.