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>
This commit is contained in:
157
docs/security.md
Normal file
157
docs/security.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user