Files
voice-cat/docs/protocol.md
Talon 694494a5be feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer
Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.

Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:31:14 +02:00

296 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Control Protocol
The control plane runs over **TCP, wrapped in TLS 1.3**. It carries everything that is not
real-time media: handshake, authentication, channel/user/presence state, text chat, and
voice *signaling* (announcing that a media stream is starting/stopping). Real-time voice
travels separately over UDP — see [voice.md](voice.md).
## 1. Framing
Inside the TLS stream, messages are length-prefixed:
```
┌──────────────┬───────────────────────────────────────────────┐
│ u32 length │ protobuf-encoded Envelope (length bytes) │
│ (big-endian)│ │
└──────────────┴───────────────────────────────────────────────┘
```
- `length` is the byte count of the payload that follows (not including the 4 length
bytes). Hard cap (e.g. 16 MiB) to bound memory; oversized frame → protocol error +
disconnect.
- The payload is a single **`Envelope`** protobuf message. We do **not** add our own type
byte; the type is the `oneof` discriminator inside the Envelope, which keeps the framing
trivial and lets protobuf own all forward/backward compatibility.
TLS already provides record framing, integrity, and ordering; we only add message
boundaries on top of the TLS byte stream.
## 2. Why Protocol Buffers for control
- Schema-driven codegen for **C++, C#, and Swift** (all first-class) → no hand-rolled
parsers, no drift between client and server.
- **Forward/backward compatible by construction**: unknown fields are preserved/ignored,
new fields and new `oneof` arms are additive. This is exactly the "extensible protocol"
requirement.
- Compact enough for a control plane (text/state, not media). We use **`oneof`** envelopes
rather than `Any` so the wire stays tight and the switch is exhaustive.
> Media frames do **not** use protobuf — they use a fixed binary header (see voice.md),
> because per-packet protobuf overhead and allocation are unacceptable on the RT path.
## 3. The Envelope
```proto
syntax = "proto3";
package voicecat.v1;
message Envelope {
// Monotonic per-connection id set by the sender of a request; echoed in the
// matching response so async callers can correlate. 0 for unsolicited events.
uint64 request_id = 1;
oneof body {
// ── Session / handshake ───────────────────────────────
ClientHello client_hello = 10;
ServerHello server_hello = 11;
AuthRequest auth_request = 12;
AuthResult auth_result = 13;
Disconnect disconnect = 14;
Ping ping = 15;
Pong pong = 16;
// ── State sync ────────────────────────────────────────
ServerStateSnapshot server_state = 20;
ChannelEvent channel_event = 21; // created/updated/deleted
UserEvent user_event = 22; // joined/left/updated
SubscribeRequest subscribe = 23;
// ── Channel operations ────────────────────────────────
JoinChannelRequest join_channel = 30;
JoinChannelResult join_channel_result= 31;
LeaveChannelRequest leave_channel = 32;
CreateChannelRequest create_channel = 33;
EditChannelRequest edit_channel = 34;
DeleteChannelRequest delete_channel = 35;
MoveUserRequest move_user = 36;
GenericResult generic_result = 37; // ack/err for the above
// ── Voice signaling (media is on UDP) ─────────────────
StreamAnnounce stream_announce = 40;
StreamAnnounceResult stream_announce_result = 41;
StreamStop stream_stop = 42;
StreamStateUpdate stream_state = 43; // talking/muted indicator
UdpBinding udp_binding = 44; // token to bind the UDP 5-tuple
// ── Text ──────────────────────────────────────────────
TextMessage text_message = 50;
TextMessageAck text_message_ack = 51;
TypingIndicator typing = 52;
// ── Moderation / permissions ──────────────────────────
KickRequest kick = 60;
BanRequest ban = 61;
SetPermissionRequest set_permission = 62;
// ── Admin account management (privileged) ─────────────
// Accounts are admin-provisioned (no self-serve registration in v1).
// These ride the same TLS control channel and require an admin permission.
CreateAccountRequest create_account = 70;
ResetPasswordRequest reset_password = 71;
DeleteAccountRequest delete_account = 72;
ListAccountsRequest list_accounts = 73;
// ── Extension escape hatch ────────────────────────────
Extension extension = 200; // {string ns; bytes payload;}
}
}
```
Reserved tag ranges keep future families from colliding: **1019** session, **2029** state,
**3039** channels, **4049** voice signaling, **5059** text, **6099** moderation,
**100199** future (e.g. file transfer = 100109), **200+** extensions.
## 4. Connection lifecycle
```
Client Server
│ TCP connect ───────────────────────────────────▶│
│ ◀──────────────── TLS 1.3 handshake ────────────▶│ server cert (TOFU/PKI, see security.md)
│ │
│ ClientHello (proto_version, features[], info) ──▶│
│ ◀── ServerHello (proto_version, features[], │ feature intersection negotiated here
│ server_info, auth_methods, udp_port) │
│ │
│ AuthRequest (guest{nick} | user{name,pass}) ────▶│ password verified w/ Argon2id
│ ◀── AuthResult (ok, session_id, self, perms, │
│ udp_token) │
│ │
│ ◀── ServerStateSnapshot (channel tree, users) ───│ initial sync
│ │
│ UdpBinding(udp_token) [TCP/TLS] ─────────────────▶│ confirms token, no-ops if mismatched
│ ◀── UdpBinding(ack=true) [TCP/TLS] ───────────────│
│ │
│ ===== UDP side (parallel) ===================== │
│ (media keys derived from TLS exporter — no 2nd │
│ handshake; see security.md §2) │
│ UDP_BINDING frame(udp_token) [plaintext] ───────▶│ binds 5-tuple → session_id
│ ── voice frames (AEAD, exported keys) ──────────▶│
│ │
│ JoinChannelRequest(id, password?) ──────────────▶│
│ ◀── JoinChannelResult(ok, members, audio_cfg) ───│
│ StreamAnnounce(kind=mic, opus_params) ──────────▶│
│ ◀── StreamAnnounceResult(ok, ssrc) │
│ ── voice frames flow over UDP ──────────────────▶│
│ │
│ Ping / Pong (TCP keepalive) ◀──────────────────▶│
```
Notes:
- **Version negotiation.** Each side sends `proto_version` (integer) and a `features`
string list. The effective version is `min(client, server)`; the effective feature set
is the intersection. A client that doesn't understand a feature simply never uses it.
- **Auth over TLS.** Passwords cross the wire only inside TLS 1.3 and are verified against
an Argon2id hash at rest (see security.md). `auth_methods` in `ServerHello` advertises
whether `guest` is enabled.
- **UDP token.** `AuthResult.udp_token` is a short-lived opaque token. The client confirms it
over TCP/TLS (`UdpBinding` request/ack) and also sends it as the payload of a plaintext
`UDP_BINDING`-type media frame so the server can bind the UDP 5-tuple to the authenticated
session without trusting the source address. This bootstrap frame is the only UDP message
that carries identity material in the clear; everything after (voice frames) is AEAD-sealed
and routed purely by the bound tuple + media-AEAD session.
- **Snapshot then deltas.** After auth the server pushes a `ServerStateSnapshot` (full
channel tree + visible users), then streams incremental `ChannelEvent`/`UserEvent`
deltas. Clients reconcile by id.
## 5. Message catalog (selected definitions)
Representative messages; the full `.proto` is the source of truth in `core/proto/`.
```proto
message ClientHello {
uint32 proto_version = 1;
repeated string features = 2; // e.g. "opus", "fec", "screen-audio"
string client_name = 3; // "VoiceCat-macOS"
string client_version = 4;
string preferred_locale = 5;
}
message ServerHello {
uint32 proto_version = 1;
repeated string features = 2;
string server_name = 3;
string server_version = 4;
repeated string auth_methods = 5; // "guest", "password"
uint32 udp_port = 6;
bytes server_identity_fingerprint = 7; // Ed25519 key fp for TOFU display
}
message AuthRequest {
oneof method {
GuestAuth guest = 1; // { string nickname; }
PasswordAuth password = 2; // { string username; string password; }
}
}
message AuthResult {
bool ok = 1;
string error = 2;
uint64 session_id = 3;
User self = 4;
Permissions permissions = 5;
bytes udp_token = 6; // bind UDP 5-tuple with this
}
message Channel {
uint32 id = 1;
uint32 parent_id = 2; // 0 = root
string name = 3;
string topic = 4;
bool password_protected = 5;
uint32 max_users = 6;
ChannelType type = 7; // PERMANENT / TEMPORARY
AudioConfig audio = 8; // per-channel Opus settings (see voice.md)
int32 order = 9;
}
message User {
uint32 id = 1;
string nickname = 2;
bool is_guest = 3;
uint32 channel_id = 4;
bool self_mic_muted = 5;
bool self_deafened = 6;
bool server_muted = 7;
repeated StreamInfo streams = 8; // active media streams this user publishes
}
message StreamInfo {
uint32 stream_id = 1; // unique within the user
uint32 ssrc = 2; // media-plane id assigned by server
StreamKind kind = 3; // MIC / SCREEN_AUDIO / AUX_DEVICE
AudioConfig audio = 4;
string label = 5; // "Microphone", "Desktop audio"
}
message StreamAnnounce { // client → server: "I'm about to publish media"
StreamKind kind = 1;
AudioConfig requested_audio = 2; // server may clamp to channel policy
string label = 3;
}
message StreamAnnounceResult {
bool ok = 1; string error = 2;
uint32 stream_id = 3; uint32 ssrc = 4;
AudioConfig effective_audio = 5; // authoritative params to encode with
}
message TextMessage {
TextScope scope = 1; // CHANNEL / PRIVATE / SERVER
uint32 target_id = 2; // channel_id or user_id depending on scope
uint32 sender_id = 3; // set by server on relay
string body = 4; // UTF-8, server-bounded length
uint64 sent_at_unix_ms = 5; // server timestamp on relay
string client_msg_id = 6; // client-chosen, echoed in ack (dedup)
}
```
> **Text is ephemeral (v1).** The server relays messages live to currently-connected,
> subscribed recipients and **does not persist history** — there is no store and no backfill
> on join. Clients may keep their own local scrollback for the session. Server-side history
> is a deliberate non-feature for now (it can be added later behind a capability flag without
> changing `TextMessage`).
## 6. Request / response & errors
- Any message a client expects a direct answer to sets a nonzero **`request_id`**; the
server echoes it in the response (`*Result` or `GenericResult`). Unsolicited
server→client events use `request_id = 0`.
- **`GenericResult { bool ok; uint32 code; string message; }`** is the default
acknowledgement for operations without a richer reply (create/edit/delete channel, move
user, etc.). Error `code`s are an enumerated, stable list.
- Fatal conditions send **`Disconnect { code; reason }`** then close the TLS connection.
## 7. Keepalive & timeouts
- **TCP:** `Ping`/`Pong` every ~15 s; missing N consecutive pongs → drop. `Pong` echoes the
`Ping` nonce so RTT is measurable.
- **UDP:** a separate lightweight keepalive on the media channel (voice.md §6) keeps NAT
bindings alive and detects media-path failure independently of the control channel.
## 8. Extensibility checklist
When adding a feature later (e.g. **file transfer**), the rules are:
1. Add new `oneof` arms in the reserved tag range (file transfer = 100109) — never reuse
or renumber existing tags.
2. Advertise a feature string in `ClientHello`/`ServerHello`; only use the feature if both
peers list it.
3. Prefer extending an existing message with new fields (additive) over inventing a new
message where it fits.
4. For experimental/out-of-tree features, ride inside `Extension { ns; payload }` until it
is promoted to a first-class `oneof` arm.
This guarantees a v1 client and a v3 server interoperate at the negotiated lowest common
denominator.