Add encrypted managed UDP relay and native voice conformance

This commit is contained in:
2026-09-15 22:53:54 +02:00
parent 4067bab7c2
commit 05eacb3092
19 changed files with 1068 additions and 46 deletions
+32 -2
View File
@@ -188,8 +188,38 @@ Success returns permissions, then a cloned snapshot; peers receive joined/update
events. Server-authoritative text replaces supplied sender ids/timestamps, limits
bodies to 4096 UTF-8 bytes, and acknowledges valid or rejected routing. Channel text
requires membership; private text echoes to sender and recipient. Protected channel
joins and all admin/moderation handlers are pending. No UDP port or media features
are advertised; voice subscription explicitly fails until the SFU is implemented.
joins and all admin/moderation handlers are pending.
`VoiceServer.MediaEndPoint` exposes the bound UDP endpoint; UDP uses the same address
and port number as TCP, and `ServerHello.udp_port` advertises it. Successful authentication
issues a 16-byte binding token. TLS confirmation echoes an acknowledgement; a protocol-v2
bootstrap packet binds the first UDP endpoint. Tokens cannot replace an established
endpoint; reconnect to change endpoints. Invalid tokens and malformed packets are ignored.
Voice subscription, unsubscribe, stream announce/stop and stream-state signaling are
implemented. Announces require subscription and support microphone, screen audio and
auxiliary device streams, with at most 16 streams per user and labels up to 128 characters.
Stream ids are monotonically assigned per user; SSRCs are assigned server-wide.
Channel audio settings are authoritative; requested bitrate may lower the channel ceiling
(nonzero requests below 500 bps fail). User updates include the actor. Stream-state updates
use the authenticated sender id and ignore unknown stream ids.
Channel movement clears active streams; joining the current channel preserves them.
Unsubscribe clears streams. Disconnect removes routing and retires media resources,
even if no UDP traffic follows. Senders must own the SSRC and be subscribed; recipients
must be subscribed, bound, in the same channel and not deafened. Server-muted senders
cannot relay. Every voice packet is authenticated with the sender's directional key;
the SFU reseals encoded bytes for each recipient without decoding, replacing only the
sequence and ciphertext/tag. Replay rejection precedes authentication; successful
authentication advances the replay window.
The UDP loop exclusively owns media crypto, endpoint mutation and packet buffers.
Control handlers publish immutable routing snapshots. Crypto is created within the
TLS owner loop and transferred once. A coalesced notification wakes retired-key cleanup.
The synchronous fan-out core allocates zero managed bytes with platform ChaCha20-Poly1305;
socket scheduling and the allocating BouncyCastle fallback are outside that guarantee.
UDP keepalives are echoed for bound endpoints. The full media-aware reaper remains pending;
the existing 60-second TLS receive-idle timeout still applies.
`AccountStore(path)` retains the C++ schema version 2, accepts version 1 migration,
and rejects unknown revisions. Opening an existing channel table does not reseed it.
+19 -4
View File
@@ -3,7 +3,7 @@
**Status:** wire/media crypto and TLS/exporter foundations implemented under `dotnet/`,
including C++ interoperability, persisted TOFU, compatible server credentials,
codec/DSP wrappers, desktop native staging, and the initial managed TLS control server.
Phase 4 remains in progress; UDP relay, complete session administration, device audio,
Phase 4 remains in progress; complete session administration, device audio,
managed client state, and UI phases remain planned.
See `dotnet/README.md`, `docs/api-dotnet.md`, and `PROGRESS.md` for verification and next steps.
**Target runtime:** .NET 10 LTS (in-service to Nov 2028), with .NET 11 as the follow-on.
@@ -729,9 +729,24 @@ the existing BouncyCastle dependency with a strict libsodium PHC parser, not a n
Konscious dependency. Native database tests prove password compatibility in both
directions without resets. SQLite uses `Microsoft.Data.Sqlite.Core` 10.0.5,
SQLitePCLRaw bundle 3.0.2 and explicitly pinned SourceGear SQLite 3.50.4.2.
See `docs/api-dotnet.md` for limits. UDP voice, streams, protected joins, moderation,
admin handlers and production configuration remain pending. The exit criterion
below has not yet been met.
See `docs/api-dotnet.md` for limits. This first checkpoint did not include UDP voice,
streams, protected joins, moderation, admin handlers or production configuration.
The subsequent voice checkpoint is described below.
**Voice checkpoint:** the managed server now advertises UDP, issues session-bound
tokens, implements voice subscription and multi-stream signaling, and relays encrypted
Opus with recipient-specific counters. The first UDP endpoint is fixed for the session;
reconnect for endpoint changes. Immutable routing snapshots separate control handlers
from the UDP crypto owner. Real-socket tests cover replay/forgery/SSRC rejection,
channel/subscription isolation, stream stop and disconnect. A native client oracle
exercises bidirectional microphone and screen audio in mono and stereo. The fan-out
core has a 50-subscriber allocation regression test; transport scheduling and the
BouncyCastle crypto fallback are excluded from its zero-allocation guarantee.
Two real C++ `vccli` processes also pass join/text/bidirectional voice tests using
finite `--test-tone-ms` external capture/playback. The transport load test delivers
all 2,500 recipient packets from a sender paced at 50 pps to 50 subscribers.
Administration, protected joins, production configuration and media-aware reaping
still remain before Phase 4 completion.
1. `VoiceCat.Server`: accept loop, `ConnSession` protocol handling, session registry.
2. `Db` on `Microsoft.Data.Sqlite` — same schema. **Resolve the Argon2id hash-compat
+7 -2
View File
@@ -12,8 +12,13 @@ packaging and TLS/server/client migration remain later checkpoints.
- Preserve the existing protobuf and 20-byte media wire formats; keep C++ as the oracle.
- **Exit:** managed framing, headers, and ciphertext match fixtures generated by C++;
managed tests and the existing C++ behavior suite pass.
- **Next:** prove TLS 1.3/exporter interoperability with C++, then port the server before
client state/audio/UI migration. Native audio packaging follows with codec/audio work.
- **Subsequent checkpoints:** TLS/exporter and credential interoperability, codec/DSP
desktop packaging, and managed control/UDP server slices are implemented. Two C++
`vccli` processes authenticate, join, chat and exchange mono/stereo voice through
the managed server. The 50-subscriber fan-out core has an allocation regression test.
- **Next:** finish managed server administration, protected joins, production configuration
and media-aware reaping, then audio/client core, Windows cutover, C# AppKit and UIKit.
Keep the Swift ReplayKit extension and its shared ring; defer C++ removal until parity.
- See `docs/porting-to-dotnet.md` and `dotnet/README.md`.
Each milestone is shippable/testable on its own. The headless C++ test client (`vccli`)
+15 -7
View File
@@ -82,7 +82,8 @@ mandatory from the first build. This was chosen over DTLS after weighing two fin
`0x01` for server→client. Each export yields a 32-byte directional media key.
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).
2. Each UDP voice frame is sealed with **ChaCha20-Poly1305** (libsodium in C++;
platform cryptography with a BouncyCastle fallback in .NET).
3. The full 20-byte header is AEAD **associated data**. The server authenticates/decrypts
inbound media and reseals for each recipient, replacing the sequence with that
recipient's next send counter. It forwards the encoded Opus bytes without decoding audio.
@@ -115,14 +116,21 @@ the design depends on that.
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.
1. `AuthResult.udp_token` (issued over TLS) is a random 16-byte token tied to the
authenticated session. The client confirms it with `UdpBinding` over TLS.
2. Protocol v2 bootstraps UDP with a **plaintext** `UDP_BINDING` packet: the 20-byte
binary header followed by the token. This is not a protobuf or an AEAD voice frame.
3. Server validates the token and binds the **5-tuple → session_id**. The managed server
accepts the first endpoint only; further bootstrap packets cannot replace it.
Endpoint changes require a new authenticated session. The token remains available
for TLS confirmation but cannot establish a second binding. Session removal retires
its endpoint, token and directional keys. The C++ oracle currently permits rebinding
with the same token; this differs in policy, not in the packet format.
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.
attacker lacks the media key. The bootstrap token is visible on UDP, so it is not
a substitute for AEAD authentication and SSRC ownership checks. Header-only keepalives
are echoed only for bound endpoints; they provide liveness, not authenticated content.
## 4. Authentication & accounts (settled: guests + local accounts)