Files
voice-cat/docs/security.md
Talon 6071c8e238 fix(media): stop permanent voice loss after bad-network blip (protocol v2)
A bad UDP packet on a flaky link could permanently wedge the voice path,
unrecoverable even across app restarts. Three defects:

1. Anti-replay window was advanced from the UNAUTHENTICATED header seq
   before the AEAD tag was checked, and not rolled back on failure. One
   corrupted/forged frame shoved recv_highest_ far ahead, after which every
   legitimate frame was rejected as "too old" forever. Reorder to
   replay-check -> authenticate -> update (RFC 3711 3.3); the window now
   moves only after a successful tag check.

2. The wire seq was only the low 16 bits of the nonce counter (zero-extended
   on receive). After 65,536 frames the nonce desynced and all frames failed
   auth. Widen the voice frame seq u16 -> u64 (header 14 -> 20 bytes). The
   core owns all UDP framing, so Swift/C# clients need only a rebuild. This
   is a versioned wire change: VOICECAT_PROTOCOL_VERSION 1 -> 2, handshake
   rejects on mismatch.

3. Server leaked per-session UDP state on disconnect; unregister_session now
   frees udp_endpoints_/udp_tokens_/ssrc_to_session_.

Also add rate-limited dropped-frame logging to MediaRelay so a wedged media
path is observable. New regression tests in test_media_aead.cpp cover the
poison (fails on old code) and the 16-bit wrap. ctest --preset dev
-E external_pcm: 22/22 pass (external_pcm aborts on a pre-existing CoreAudio
shutdown race, unrelated).
2026-06-21 17:45:28 +02:00

11 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. On first connect the client shows an identity dialog and, if accepted, pins the value locally. Subsequent connects verify the pin silently; a changed value warns loudly (MISMATCH).
  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.

What is actually pinned (M4 implementation): the TLS leaf certificate's SHA-256 fingerprint — verifiable directly from the TLS handshake before any application data is trusted. The server also declares an Ed25519 identity fingerprint in ServerHello, but this value is display-only and is not the value that is pinned or verified. Reason: the TLS cert and the Ed25519 identity key are generated independently with no cryptographic binding between them, so pinning the self-declared Ed25519 value (sent inside the channel being trust-decided) would be circular — an attacker who impersonates the server at the TLS level would supply whatever Ed25519 value they like. Pinning the TLS cert fingerprint is the only value that is genuinely verifiable at the moment of trust decision.

This is a known limitation of the current design. Closing it properly requires binding the Ed25519 key into the TLS cert (e.g. as a SubjectAltName or extension), which is a planned future improvement. Until then, clients display both values but gate on the cert fingerprint.

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 64-bit sliding-window replay filter keyed on the packet counter (à la IPsec). The window is advanced only after the AEAD tag verifies (RFC 3711 §3.3 order: replay-check → authenticate → update). The counter is read from the unauthenticated header, so advancing the high-water mark before authentication would let a single corrupted or forged packet jump it far ahead, after which every legitimate packet is rejected as "too old" — a permanent denial of the whole stream. Failed-auth packets leave the window untouched. 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 with BLAKE2b (libsodium crypto_generichash) plus a per-channel salt. BLAKE2b is used instead of Argon2id here because channel-password checks happen on the net thread during JoinChannelRequest; a slow hash would block real-time message processing. The password itself still crosses the wire only inside TLS 1.3.
  • 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, created_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.