Start .NET rewrite with wire and media crypto conformance
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s

This commit is contained in:
2026-09-15 17:54:16 +02:00
parent c6c003b8a7
commit b76181d9fb
37 changed files with 1328 additions and 19 deletions
+57
View File
@@ -0,0 +1,57 @@
# Initial managed API contract
Status: initial port slice, API revision 1. No change to protobuf or media wire formats.
These are shared infrastructure APIs; the client-facing API follows with the client core.
## Protocol
`VoiceCat.Protocol` generates `Voicecat.V1` protobuf messages from the existing schema.
`ControlFraming.TryReadFrame(ref ReadOnlySequence<byte>, out ReadOnlySequence<byte>)`
extracts a payload and advances input only when a full frame exists. Returned memory
borrows the input's lifetime. Lengths above 16 MiB throw `InvalidDataException`.
Empty payloads are valid. `WriteFrame` and `WriteEnvelope` target `IBufferWriter<byte>`;
oversized outgoing payloads throw before output is written.
`ReadEnvelopesAsync(PipeReader, CancellationToken)` produces parsed envelopes and
advances consumed pipe data. It does not complete or dispose the caller's reader.
Clean EOF ends enumeration; partial EOF and oversized frames throw
`InvalidDataException`; malformed protobuf throws `InvalidProtocolBufferException`.
Cancellation propagates. A connection owner must close on protocol errors or
cancellation partway through a frame; partial frame bytes may already be consumed.
Fragments are consumed as they arrive so frames larger than pipe backpressure
thresholds make progress. Stopping enumeration between envelopes preserves the next frame.
`VoiceFrameHeader` is an immutable value with type, flags, codec, SSRC, sequence,
and timestamp. `Write(Span<byte>)` writes its 20-byte big-endian representation;
`TryRead` accepts at least 20 bytes and preserves unknown type/flag/codec values.
Higher layers decide which values they support.
## Media encryption
`MediaEncryptor` and `MediaDecryptor` each own one directional 32-byte session key
and mutable packet state. Use one owner at a time; they provide no synchronization.
Production constructs them from TLS exporter keys when TLS is implemented. Raw-key
constructors support conformance tests and the future TLS integration.
`MediaEncryptor.Encrypt(VoiceFrameHeader, ReadOnlySpan<byte>, Span<byte>)` writes
the full header plus ciphertext and 16-byte tag and returns packet length. It replaces
the supplied sequence with its own counter, starting at zero. Capacity and overlap
errors throw before reserving a counter. Reserved counters are never reused after
encryption failure. At `ulong.MaxValue`, encryption throws and requires a new session.
`MediaDecryptor.TryDecrypt(ReadOnlySpan<byte>, Span<byte>, out VoiceFrameHeader,
out int)` authenticates and decrypts a complete packet. Short packets, failed tags,
replays, and packets outside the 64-packet window return false with default header
and zero bytes written. Authentication failure clears the attempted plaintext region;
structural/replay rejection leaves storage untouched. Callers must only consume
output after success. Invalid storage capacity and overlapping buffers throw.
The nonce is four zero bytes plus the big-endian header counter. All 20 header bytes
are authenticated associated data. The replay window advances after authentication.
The platform ChaCha20-Poly1305 implementation is preferred; BouncyCastle is used when
platform support is absent. Both produce the same wire bytes. The fallback currently
allocates per packet; audio and relay allocation guarantees are later checkpoints.
Dispose both objects to clear their owned key arrays and release platform crypto
resources. Use after disposal throws `ObjectDisposedException`.
+9 -2
View File
@@ -1,5 +1,10 @@
# Architecture
The parallel .NET rewrite under `dotnet/` currently implements shared protocol framing,
voice headers, and media crypto. Existing server/client/audio behavior remains in C++.
See `docs/api-dotnet.md` for the initial managed contract and
`docs/porting-to-dotnet.md` for subsequent migration phases.
## 1. The shared-core model
All non-UI logic lives in one C++ library, **`libvoicecat`**. The same library is linked
@@ -205,8 +210,10 @@ callback: no allocations, no blocking calls.
```
- **Voice router is a relay, not a mixer.** For each incoming voice frame it looks up the
sender's channel and forwards the *unmodified Opus payload* (restamped with the sender's
user id) to every other subscribed member. No server-side decode/transcode → low CPU,
sender's channel and forwards the *unmodified encoded Opus bytes* to other members.
It authenticates/decrypts incoming media, then reseals with each recipient's directional
key and counter. SSRC/timestamp/flags/codec pass through; sequence and ciphertext/tag change.
No server-side decode/transcode → low CPU,
low latency, and end-to-content is just Opus. Per-channel Opus params are enforced so all
members are mutually decodable.
- **Subscriptions.** Clients implicitly subscribe to their current channel's voice; text
+13
View File
@@ -1,5 +1,18 @@
# Building & Manual Testing
## .NET rewrite
The initial managed wire/crypto slice is under `dotnet/`, targeting .NET 10. From the root:
```powershell
dotnet restore dotnet/VoiceCat.slnx --locked-mode
dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
dotnet test dotnet/VoiceCat.slnx -c Release --no-build
```
See `dotnet/README.md` for conformance fixtures and conventions. The C++ commands
below remain required while the existing implementation is the migration oracle.
This doc explains what each CMake preset in [`CMakePresets.json`](../CMakePresets.json) is
*for*, which one to actually use day-to-day, and the commands to stand up a real server +
`vccli` clients against each other for manual testing. For the one-paragraph quick-start see
+7 -3
View File
@@ -1,6 +1,7 @@
# Porting VoiceCat to pure .NET / C#
**Status:** proposal / plan. Nothing here is implemented yet.
**Status:** initial wire/crypto slice implemented under `dotnet/`; later 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.
**Scope:** replace the C++ core (`libvoicecat`), the C++ server, the C++ `vccli`, and the
Swift macOS/iOS clients with a single C# codebase. The Windows WinForms client is already C#
@@ -418,7 +419,7 @@ The most mechanical part of the project. Straight `async`/`await` network code.
| `server.cpp` — accept loop | `Socket.AcceptAsync` loop + `Task` per connection. Trivial. |
| `conn_session.cpp` (34 K) — per-conn protocol | The bulk. A big `switch` on `Envelope.BodyCase`. Mechanical; write it against the ported xUnit tests. |
| `session_registry.cpp` | `ConcurrentDictionary<ulong, Session>` + a channel-membership index. Simpler than the C++. |
| `media_relay.cpp` — the SFU | ⚠️ **The one hot path on the server.** Per inbound datagram: parse 20-byte header → look up ssrc → fan out unmodified to N subscribers. Must be allocation-free: `Socket.ReceiveFromAsync(Memory<byte>, SocketAddress)` into a pooled buffer, `SendToAsync` per subscriber. Do **not** decrypt — the design already forbids it, which is what keeps this cheap. Benchmark this specifically (§11.5). |
| `media_relay.cpp` — the SFU | **The server hot path.** Authenticate/decrypt using the sender's directional key, then reseal for each recipient with its directional key and next counter. Preserve SSRC, timestamp, flags, and encoded Opus bytes; replace sequence and ciphertext/tag. Use pooled buffers and `Socket.ReceiveFromAsync(Memory<byte>, SocketAddress)`. Never decode audio. Benchmark fan-out and allocations. |
| `db.cpp` (26 K) — SQLite | `Microsoft.Data.Sqlite`, same schema, same file. Keep raw SQL — do not introduce EF Core; the schema is 4 tables and EF's startup cost hurts the "single binary, instant start" goal. |
| `identity.cpp` | `CertificateRequest` + BouncyCastle Ed25519. Reads the same on-disk files. |
| Keepalive reaper | `PeriodicTimer` — cleaner than the `asio::steady_timer`. |
@@ -618,7 +619,10 @@ not delete anything until the C# equivalent passes the same test against it. Thi
possible because Option A (§3.2) preserves wire compatibility — which is the main reason to
choose it.
Work on a long-lived branch (`cs-port` already exists). Each phase ends with a green build,
The rewrite lives under `dotnet/`; initial implementation branch: `dotnet/foundations`,
created from `cs-port`. Keep the existing schema at `core/proto/voicecat.proto` during migration.
Native packaging is deferred until the codec/audio phase rather than blocking the wire slice.
Each phase ends with a green build,
green tests, and an updated `PROGRESS.md` entry.
---
+14
View File
@@ -2,6 +2,20 @@
## 1. Milestones
### .NET port — initial slice
**Complete 2026-09-15:** managed Release build and 34/34 xUnit tests, C++ golden
fixtures for both crypto backends, fresh native build and 29/29 CTest tests. Native
packaging and TLS/server/client migration remain later checkpoints.
- `dotnet/` contains .NET 10 protocol and crypto assemblies plus xUnit conformance tests.
- 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.
- See `docs/porting-to-dotnet.md` and `dotnet/README.md`.
Each milestone is shippable/testable on its own. The headless C++ test client (`vccli`)
exists from M1 so the protocol can be exercised long before any GUI.
+13 -10
View File
@@ -67,13 +67,15 @@ mandatory from the first build. This was chosen over DTLS after weighing two fin
### 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
1. After the TLS 1.3 control handshake, both sides call the keying-material exporter with
label `"voicecat media v1"` and a one-byte context: `0x00` for client→server,
`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).
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.
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.
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
@@ -84,11 +86,12 @@ 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.
- **Associated data:** all 20 header bytes remain visible and authenticated; the Opus
payload is encrypted and followed by a 16-byte tag.
- **Nonce discipline:** `nonce = four_zero_bytes ‖ counter_u64_big_endian`. Counters are
per directional session key, shared across its streams. Direction separation comes
from exporter contexts, not nonce bits. Automatic epoch rekeying is not implemented;
the .NET encryptor refuses counter exhaustion and requires a new session.
- **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
+13
View File
@@ -1,5 +1,18 @@
# Tech Stack & Dependencies
## Initial .NET rewrite
The parallel rewrite under `dotnet/` targets .NET 10. Its initial dependencies are
Google.Protobuf 3.36.1 (BSD-3-Clause), build-only Grpc.Tools 2.83.0 (Apache-2.0), and
BouncyCastle.Cryptography 2.6.2 (MIT). Media AEAD prefers the platform implementation;
BouncyCastle provides the managed fallback and is the planned TLS/exporter provider.
No managed server or audio replacement is shipped yet.
Project files and NuGet lock files pin versions. `dotnet/check-licenses.ps1` checks
all restored direct/transitive packages against a permissive license allowlist in CI;
unknown or copyleft licenses fail. See `dotnet/README.md` for build and test commands.
The existing implementation's dependency choices follow below.
Concrete library choices with versions and rationale. Everything in the **core** is C++
(C++20). UIs are Swift and C#. Build is CMake + vcpkg.
+5 -4
View File
@@ -66,9 +66,10 @@ payload one Opus packet (the encoder's output for one frame)
> interoperate; the `Hello` handshake rejects on `proto_version` mismatch.
This is intentionally RTP-shaped (familiar semantics: ssrc/seq/timestamp) without RTP's
full machinery. The **server relays the payload unmodified** — it only reads the header to
route by ssrc→channel and may restamp nothing (the client's ssrc is globally unique once
assigned at `StreamAnnounce`). No server-side decode.
full machinery. The server authenticates/decrypts each incoming packet and reseals its
encoded Opus bytes for each recipient using that recipient's directional key and send
counter. SSRC, timestamp, flags, and codec pass through; sequence and ciphertext/tag change.
There is no server-side audio decoding or transcoding.
### Why client-sends-ssrc is safe
@@ -183,7 +184,7 @@ Each receiver keeps an **adaptive jitter buffer per ssrc** with **bounded-depth
- A `KEEPALIVE` (type 2) frame flows both directions on the media channel every ~5 s to
hold NAT bindings and measure media-path RTT/loss independent of TCP. The frame is
plaintext (14-byte header, no payload, no AEAD) — the server identifies the sender by
plaintext (20-byte header, no payload, no AEAD) — the server identifies the sender by
its already-verified UDP endpoint (established during the `UdpBinding` handshake). On
receipt the server bumps the sender's `last_seen` (so media activity defers the TCP
reaper independently of control-channel traffic) and echoes the frame back so the