Start .NET rewrite with wire and media crypto conformance
This commit is contained in:
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user