From b76181d9fb0adbc857988cec30502779711894d0 Mon Sep 17 00:00:00 2001 From: Talon Date: Tue, 15 Sep 2026 17:54:16 +0200 Subject: [PATCH] Start .NET rewrite with wire and media crypto conformance --- .github/workflows/dotnet.yml | 56 ++++++ .gitignore | 3 + CLAUDE.md | 13 ++ CMakeLists.txt | 5 + PROGRESS.md | 11 ++ docs/api-dotnet.md | 57 ++++++ docs/architecture.md | 11 +- docs/building.md | 13 ++ docs/porting-to-dotnet.md | 10 +- docs/roadmap.md | 14 ++ docs/security.md | 23 ++- docs/tech-stack.md | 13 ++ docs/voice.md | 9 +- dotnet/.editorconfig | 7 + dotnet/Directory.Build.props | 10 + dotnet/README.md | 54 ++++++ dotnet/VoiceCat.slnx | 9 + dotnet/check-licenses.ps1 | 30 +++ dotnet/global.json | 3 + dotnet/oracle/CMakeLists.txt | 4 + dotnet/oracle/main.cpp | 56 ++++++ dotnet/src/VoiceCat.Crypto/MediaCipher.cs | 72 +++++++ dotnet/src/VoiceCat.Crypto/MediaDecryptor.cs | 60 ++++++ dotnet/src/VoiceCat.Crypto/MediaEncryptor.cs | 40 ++++ .../VoiceCat.Crypto/VoiceCat.Crypto.csproj | 9 + dotnet/src/VoiceCat.Crypto/packages.lock.json | 24 +++ .../src/VoiceCat.Protocol/ControlFraming.cs | 95 +++++++++ .../VoiceCat.Protocol.csproj | 7 + .../src/VoiceCat.Protocol/VoiceFrameHeader.cs | 49 +++++ .../src/VoiceCat.Protocol/packages.lock.json | 19 ++ .../VoiceCat.Tests/Fixtures/cpp-wire.json | 9 + dotnet/tests/VoiceCat.Tests/FramingTests.cs | 181 ++++++++++++++++++ dotnet/tests/VoiceCat.Tests/GoldenTests.cs | 50 +++++ dotnet/tests/VoiceCat.Tests/MediaTests.cs | 166 ++++++++++++++++ .../VoiceCat.Tests/VoiceCat.Tests.csproj | 15 ++ .../tests/VoiceCat.Tests/VoiceHeaderTests.cs | 19 ++ .../tests/VoiceCat.Tests/packages.lock.json | 121 ++++++++++++ 37 files changed, 1328 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/dotnet.yml create mode 100644 docs/api-dotnet.md create mode 100644 dotnet/.editorconfig create mode 100644 dotnet/Directory.Build.props create mode 100644 dotnet/README.md create mode 100644 dotnet/VoiceCat.slnx create mode 100644 dotnet/check-licenses.ps1 create mode 100644 dotnet/global.json create mode 100644 dotnet/oracle/CMakeLists.txt create mode 100644 dotnet/oracle/main.cpp create mode 100644 dotnet/src/VoiceCat.Crypto/MediaCipher.cs create mode 100644 dotnet/src/VoiceCat.Crypto/MediaDecryptor.cs create mode 100644 dotnet/src/VoiceCat.Crypto/MediaEncryptor.cs create mode 100644 dotnet/src/VoiceCat.Crypto/VoiceCat.Crypto.csproj create mode 100644 dotnet/src/VoiceCat.Crypto/packages.lock.json create mode 100644 dotnet/src/VoiceCat.Protocol/ControlFraming.cs create mode 100644 dotnet/src/VoiceCat.Protocol/VoiceCat.Protocol.csproj create mode 100644 dotnet/src/VoiceCat.Protocol/VoiceFrameHeader.cs create mode 100644 dotnet/src/VoiceCat.Protocol/packages.lock.json create mode 100644 dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json create mode 100644 dotnet/tests/VoiceCat.Tests/FramingTests.cs create mode 100644 dotnet/tests/VoiceCat.Tests/GoldenTests.cs create mode 100644 dotnet/tests/VoiceCat.Tests/MediaTests.cs create mode 100644 dotnet/tests/VoiceCat.Tests/VoiceCat.Tests.csproj create mode 100644 dotnet/tests/VoiceCat.Tests/VoiceHeaderTests.cs create mode 100644 dotnet/tests/VoiceCat.Tests/packages.lock.json diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..7935604 --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,56 @@ +name: .NET port + +on: + push: + paths: ['dotnet/**', 'core/**', 'server/**', 'tests/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml'] + pull_request: + paths: ['dotnet/**', 'core/**', 'server/**', 'tests/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml'] + workflow_dispatch: + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-24.04, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + global-json-file: dotnet/global.json + cache: true + cache-dependency-path: dotnet/**/packages.lock.json + - run: dotnet restore dotnet/VoiceCat.slnx --locked-mode + - run: dotnet build dotnet/VoiceCat.slnx -c Release --no-restore + - run: dotnet test dotnet/VoiceCat.slnx -c Release --no-build + - shell: pwsh + run: ./dotnet/check-licenses.ps1 + + cpp-conformance: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: actions/setup-dotnet@v4 + with: + global-json-file: dotnet/global.json + - uses: actions/cache@v4 + with: + path: ~/.cache/vcpkg + key: dotnet-oracle-linux-${{ hashFiles('vcpkg.json', 'vcpkg') }} + - name: Install C++ build dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build curl zip unzip tar pkg-config autoconf autoconf-archive automake libtool nasm python3 + ./vcpkg/bootstrap-vcpkg.sh -disableMetrics + - name: Build and verify both implementations + run: | + cmake --preset dev -DVOICECAT_BUILD_DOTNET_ORACLE=ON + cmake --build --preset dev + ctest --preset dev + ./build/dev/bin/voicecat-dotnet-oracle build/dev/cpp-wire.json + diff -u dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json build/dev/cpp-wire.json + dotnet restore dotnet/VoiceCat.slnx --locked-mode + dotnet test dotnet/VoiceCat.slnx -c Release --no-restore diff --git a/.gitignore b/.gitignore index 55492c2..2b83c0e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ # Build output +/dotnet/**/bin/ +/dotnet/**/obj/ +/dotnet/**/TestResults/ /build/ /out/ diff --git a/CLAUDE.md b/CLAUDE.md index 96555d4..110038b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,19 @@ native clients (Swift on macOS/iOS, C# on Windows) and the server. ## Build & test commands +The .NET rewrite lives under `dotnet/`. Build and test its initial wire/crypto slice +alongside the existing C++ tree: + +```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 +./dotnet/check-licenses.ps1 +``` + +See `dotnet/README.md` for C# conventions and C++ fixture regeneration, and +`docs/api-dotnet.md` for managed interfaces. Subsequent port phases remain planned. + The default development preset is **`dev`** — it builds everything (server + tools + tests) with real vcpkg deps. The `skeleton` preset (no deps, stubs only) is a fast smoke check; see [`docs/building.md`](docs/building.md) for the full preset matrix. diff --git a/CMakeLists.txt b/CMakeLists.txt index 024b551..658b11c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,6 +49,11 @@ endif() # ── Targets ─────────────────────────────────────────────────────────────────── add_subdirectory(core) +option(VOICECAT_BUILD_DOTNET_ORACLE "Build the .NET port conformance fixture generator" OFF) +if(VOICECAT_BUILD_DOTNET_ORACLE) + add_subdirectory(dotnet/oracle) +endif() + if(VOICECAT_BUILD_SERVER) add_subdirectory(server) endif() diff --git a/PROGRESS.md b/PROGRESS.md index 86eaeac..c91cf88 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,6 +10,17 @@ up instantly. Newest status at the top. ## ▶ Where we left off / next action +- **Done (2026-09-15): Initial .NET wire/crypto port** on `dotnet/foundations`, from `cs-port`. + Added `dotnet/` solution, schema code generation, pipe framing, immutable voice headers, + directional media encryption/decryption, and xUnit conformance tests. Both platform + and managed crypto paths are tested. Added optional C++ fixture oracle, managed CI, + dependency lock files, license audit, and `docs/api-dotnet.md`. **Verified:** managed + Release build, 34/34 tests including C++ golden bytes, and 16 permissive package + licenses. Fresh `cmake --build --preset dev` and `ctest --preset dev` green (29/29); + regenerating the C++ fixtures produces identical bytes. Native codec/audio packaging + is deferred to its implementation phase. Next checkpoint: BouncyCastle TLS 1.3 + exporter interoperability with the existing server. + - **Done (2026-07-23):** **First comment-density cleanup across core, server, and native clients.** Condensed comments in the highest-noise audio, reconnect, registry, and binding files; removed implementation history and narration; retained ABI ownership, threading, diff --git a/docs/api-dotnet.md b/docs/api-dotnet.md new file mode 100644 index 0000000..937201a --- /dev/null +++ b/docs/api-dotnet.md @@ -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, out ReadOnlySequence)` +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`; +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)` 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, Span)` 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, Span, 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`. diff --git a/docs/architecture.md b/docs/architecture.md index acc4842..f937bc1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/building.md b/docs/building.md index bc40daf..1e2a837 100644 --- a/docs/building.md +++ b/docs/building.md @@ -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 diff --git a/docs/porting-to-dotnet.md b/docs/porting-to-dotnet.md index 59cf99f..0598091 100644 --- a/docs/porting-to-dotnet.md +++ b/docs/porting-to-dotnet.md @@ -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` + 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, 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, 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. --- diff --git a/docs/roadmap.md b/docs/roadmap.md index 4293155..6cec4fc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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. diff --git a/docs/security.md b/docs/security.md index 0a17abc..b7e6b4a 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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 diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 3122245..2300e5e 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -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. diff --git a/docs/voice.md b/docs/voice.md index 214617e..6d94b15 100644 --- a/docs/voice.md +++ b/docs/voice.md @@ -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 diff --git a/dotnet/.editorconfig b/dotnet/.editorconfig new file mode 100644 index 0000000..6617bdd --- /dev/null +++ b/dotnet/.editorconfig @@ -0,0 +1,7 @@ +root = true + +[*.cs] +indent_style = space +indent_size = 4 +csharp_style_namespace_declarations = file_scoped:warning +dotnet_sort_system_directives_first = true diff --git a/dotnet/Directory.Build.props b/dotnet/Directory.Build.props new file mode 100644 index 0000000..37d9341 --- /dev/null +++ b/dotnet/Directory.Build.props @@ -0,0 +1,10 @@ + + + net10.0 + enable + enable + true + latest + true + + diff --git a/dotnet/README.md b/dotnet/README.md new file mode 100644 index 0000000..9edf9cc --- /dev/null +++ b/dotnet/README.md @@ -0,0 +1,54 @@ +# VoiceCat .NET rewrite + +The first slice targets .NET 10: protobuf, control framing, voice headers, and media +encryption. TLS, server/client state, audio, and UI migration are next. The existing +C++ implementation remains the conformance oracle. + +From the repository 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 +``` + +Dependencies are pinned in project files and lock files. Generated protobuf is build +output; the schema remains `core/proto/voicecat.proto`. Production dependencies are +Google.Protobuf (BSD-3-Clause), BouncyCastle.Cryptography (MIT), and the build-only +Grpc.Tools (Apache-2.0). No GPL/LGPL dependencies are permitted. + +## C# conventions + +Use file-scoped namespaces, standard .NET naming, immutable values where useful, and +spans for binary data. Invalid arguments throw; invalid network packets use parsing +results or protocol exceptions. Async APIs accept cancellation tokens. + +Comments explain constraints that cannot be made clear in code. Avoid banners, +implementation history, and narration. Keep durable design explanations in `docs/`. + +## Regenerating C++ fixtures + +The optional oracle target calls the existing C++ protobuf, header serializer, and +libsodium media implementation. From the root, with the development dependencies: + +```powershell +cmake --preset dev -DVOICECAT_BUILD_DOTNET_ORACLE=ON +cmake --build --preset dev --target voicecat-dotnet-oracle +New-Item -ItemType Directory -Force dotnet/tests/VoiceCat.Tests/Fixtures +./build/dev/bin/voicecat-dotnet-oracle.exe dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json +git diff -- dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json +``` + +On Linux/macOS, omit `.exe` and create the directory with `mkdir -p`. +The oracle writes deterministic JSON directly, avoiding shell output encoding. +Fixtures contain a framed ClientHello and media packets at counters 0, 1, 65535, +and 65536. Keys contain bytes 0–31; payload bytes count upward from zero. The +20-byte header has type 1, marker flag, codec 0, SSRC `0xcafebabe`, timestamp 960. +Both managed crypto backends must match these bytes. + +## Next checkpoint + +Prove BouncyCastle TLS 1.3 loopback and interoperability with the C++ mbedTLS server, +including exporter label `voicecat media v1`, one-byte direction contexts 0/1, +and TLS leaf certificate fingerprint pinning. Then implement the managed server, +tested first with the existing C++ CLI. diff --git a/dotnet/VoiceCat.slnx b/dotnet/VoiceCat.slnx new file mode 100644 index 0000000..112203a --- /dev/null +++ b/dotnet/VoiceCat.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dotnet/check-licenses.ps1 b/dotnet/check-licenses.ps1 new file mode 100644 index 0000000..e141b0f --- /dev/null +++ b/dotnet/check-licenses.ps1 @@ -0,0 +1,30 @@ +$ErrorActionPreference = 'Stop' +$allowed = @('MIT', 'BSD-2-Clause', 'BSD-3-Clause', 'Apache-2.0', 'ISC', '0BSD') +$seen = @{} +foreach ($lockPath in (Get-ChildItem -LiteralPath $PSScriptRoot -Filter packages.lock.json -Recurse)) { + $lock = Get-Content -Raw -LiteralPath $lockPath.FullName | ConvertFrom-Json + $assets = Get-Content -Raw -LiteralPath (Join-Path $lockPath.DirectoryName 'obj/project.assets.json') | ConvertFrom-Json + foreach ($framework in $lock.dependencies.PSObject.Properties) { + foreach ($package in $framework.Value.PSObject.Properties) { + if ($package.Value.type -eq 'Project') { continue } + $id = $package.Name.ToLowerInvariant() + $version = $package.Value.resolved + if ($seen.ContainsKey("$id/$version")) { continue } + $seen["$id/$version"] = $true + $nuspec = $null + foreach ($folder in $assets.packageFolders.PSObject.Properties.Name) { + $candidate = Join-Path $folder "$id/$version/$id.nuspec" + if (Test-Path -LiteralPath $candidate) { $nuspec = $candidate; break } + } + if (!$nuspec) { throw "Restore dependencies before auditing $id/$version." } + [xml]$spec = Get-Content -Raw -LiteralPath $nuspec + $license = $spec.package.metadata.license + if ($license.type -eq 'expression' -and $allowed -contains $license.InnerText) { continue } + # This legacy pinned package predates NuGet license expressions (Apache-2.0). + if ($id -eq 'xunit.abstractions' -and $version -eq '2.0.3' -and + $spec.package.metadata.licenseUrl -eq 'https://raw.githubusercontent.com/xunit/xunit/master/license.txt') { continue } + throw "Unapproved license for $id/$version. Review before changing the allowlist." + } + } +} +Write-Output "Checked $($seen.Count) package licenses: permissive allowlist passed." diff --git a/dotnet/global.json b/dotnet/global.json new file mode 100644 index 0000000..83cb9ae --- /dev/null +++ b/dotnet/global.json @@ -0,0 +1,3 @@ +{ + "sdk": { "version": "10.0.203", "rollForward": "latestFeature" } +} diff --git a/dotnet/oracle/CMakeLists.txt b/dotnet/oracle/CMakeLists.txt new file mode 100644 index 0000000..efb56eb --- /dev/null +++ b/dotnet/oracle/CMakeLists.txt @@ -0,0 +1,4 @@ +add_executable(voicecat-dotnet-oracle main.cpp) +target_link_libraries(voicecat-dotnet-oracle PRIVATE voicecat::voicecat) +target_include_directories(voicecat-dotnet-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src) +target_compile_features(voicecat-dotnet-oracle PRIVATE cxx_std_20) diff --git a/dotnet/oracle/main.cpp b/dotnet/oracle/main.cpp new file mode 100644 index 0000000..88e1047 --- /dev/null +++ b/dotnet/oracle/main.cpp @@ -0,0 +1,56 @@ +#include "crypto/crypto.h" +#include "net/voice_frame.h" +#include "protocol/envelope.h" + +#include +#include +#include +#include + +static std::string hex(const std::vector& bytes) { + std::ostringstream result; + result << std::hex << std::setfill('0'); + for (auto byte : bytes) result << std::setw(2) << unsigned(byte); + return result.str(); +} + +int main(int argc, char** argv) { + if (argc != 2 || sodium_init() < 0) return 1; + std::ofstream output(argv[1], std::ios::binary); + if (!output) return 1; + voicecat::v1::Envelope envelope; + envelope.set_request_id(42); + auto* hello = envelope.mutable_client_hello(); + hello->set_proto_version(1); + hello->set_client_name("test-client"); + hello->set_client_version("0.0.1"); + hello->add_features("text"); + std::vector framed; + if (!voicecat::protocol::encode_envelope(envelope, framed)) return 1; + output << "{\n \"envelope\": \"" << hex(framed) << "\",\n \"media\": [\n"; + std::array key{}; + for (size_t i = 0; i < key.size(); ++i) key[i] = uint8_t(i); + voicecat::crypto::SodiumMediaCrypto sender(key.data()); + for (uint64_t sequence = 0; sequence <= 65536; ++sequence) { + voicecat::net::VoiceFrame header; + header.flags = voicecat::net::kFlagMarker; + header.ssrc = 0xcafebabe; + header.seq = sender.peek_send_counter(); + header.timestamp = 960; + const size_t length = sequence == 0 ? 0 : sequence == 1 ? 100 : 8; + std::vector plaintext(length); + for (size_t i = 0; i < length; ++i) plaintext[i] = uint8_t(i); + std::vector packet(voicecat::net::kVoiceHeaderSize + length + 16); + voicecat::net::serialize_header(header, packet.data()); + if (sender.seal(plaintext.data(), length, packet.data(), 20, packet.data() + 20, length + 16) < 0) return 1; + if (sequence == 0 || sequence == 1 || sequence == 65535 || sequence == 65536) { + if (sequence != 0) output << ",\n"; + output << " {\"sequence\": " << sequence << ", \"key\": \"" + << hex(std::vector(key.begin(), key.end())) + << "\", \"plaintext\": \"" << hex(plaintext) + << "\", \"packet\": \"" << hex(packet) << "\"}"; + } + } + output << "\n ]\n}\n"; + return output ? 0 : 1; +} diff --git a/dotnet/src/VoiceCat.Crypto/MediaCipher.cs b/dotnet/src/VoiceCat.Crypto/MediaCipher.cs new file mode 100644 index 0000000..73ef331 --- /dev/null +++ b/dotnet/src/VoiceCat.Crypto/MediaCipher.cs @@ -0,0 +1,72 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Parameters; + +namespace VoiceCat.Crypto; + +internal sealed class MediaCipher : IDisposable +{ + private readonly byte[] key; + private readonly ChaCha20Poly1305? platformCipher; + private bool disposed; + + public MediaCipher(ReadOnlySpan key, bool useManaged) + { + if (key.Length != 32) throw new ArgumentException("Media keys must contain 32 bytes.", nameof(key)); + this.key = key.ToArray(); + if (!useManaged && ChaCha20Poly1305.IsSupported) platformCipher = new(this.key); + } + + public void Encrypt(ulong counter, ReadOnlySpan plaintext, ReadOnlySpan aad, Span output) + { + ObjectDisposedException.ThrowIf(disposed, this); + Span nonce = stackalloc byte[12]; + nonce.Clear(); + BinaryPrimitives.WriteUInt64BigEndian(nonce[4..], counter); + if (platformCipher is not null) + { + platformCipher.Encrypt(nonce, plaintext, output[..plaintext.Length], output.Slice(plaintext.Length, 16), aad); + return; + } + var cipher = new Org.BouncyCastle.Crypto.Modes.ChaCha20Poly1305(); + cipher.Init(true, new AeadParameters(new KeyParameter(key), 128, nonce.ToArray(), aad.ToArray())); + int written = cipher.ProcessBytes(plaintext, output); + cipher.DoFinal(output[written..]); + } + + public bool TryDecrypt(ulong counter, ReadOnlySpan sealedPayload, ReadOnlySpan aad, Span output) + { + ObjectDisposedException.ThrowIf(disposed, this); + Span nonce = stackalloc byte[12]; + nonce.Clear(); + BinaryPrimitives.WriteUInt64BigEndian(nonce[4..], counter); + int length = sealedPayload.Length - 16; + try + { + if (platformCipher is not null) + platformCipher.Decrypt(nonce, sealedPayload[..length], sealedPayload[length..], output[..length], aad); + else + { + var cipher = new Org.BouncyCastle.Crypto.Modes.ChaCha20Poly1305(); + cipher.Init(false, new AeadParameters(new KeyParameter(key), 128, nonce.ToArray(), aad.ToArray())); + int written = cipher.ProcessBytes(sealedPayload, output); + cipher.DoFinal(output[written..]); + } + return true; + } + catch (Exception exception) when (exception is AuthenticationTagMismatchException or InvalidCipherTextException) + { + CryptographicOperations.ZeroMemory(output[..length]); + return false; + } + } + + public void Dispose() + { + if (disposed) return; + disposed = true; + platformCipher?.Dispose(); + CryptographicOperations.ZeroMemory(key); + } +} diff --git a/dotnet/src/VoiceCat.Crypto/MediaDecryptor.cs b/dotnet/src/VoiceCat.Crypto/MediaDecryptor.cs new file mode 100644 index 0000000..b92af61 --- /dev/null +++ b/dotnet/src/VoiceCat.Crypto/MediaDecryptor.cs @@ -0,0 +1,60 @@ +using VoiceCat.Protocol; + +namespace VoiceCat.Crypto; + +public sealed class MediaDecryptor : IDisposable +{ + private readonly MediaCipher cipher; + private ulong highestSequence; + private ulong replayWindow; + private bool initialized; + private bool disposed; + + public MediaDecryptor(ReadOnlySpan key) : this(key, false) { } + + internal MediaDecryptor(ReadOnlySpan key, bool useManaged) => cipher = new(key, useManaged); + + public bool TryDecrypt(ReadOnlySpan packet, Span plaintext, out VoiceFrameHeader header, out int bytesWritten) + { + ObjectDisposedException.ThrowIf(disposed, this); + header = default; + bytesWritten = 0; + if (packet.Length < VoiceFrameHeader.Size + MediaEncryptor.TagSize) return false; + int length = packet.Length - VoiceFrameHeader.Size - MediaEncryptor.TagSize; + ArgumentOutOfRangeException.ThrowIfLessThan(plaintext.Length, length); + if (packet.Overlaps(plaintext)) throw new ArgumentException("Input and output must not overlap.", nameof(plaintext)); + VoiceFrameHeader.TryRead(packet, out var candidate); + ulong sequence = candidate.Sequence; + if (initialized && sequence <= highestSequence) + { + ulong offset = highestSequence - sequence; + if (offset >= 64 || (replayWindow & (1UL << (int)offset)) != 0) return false; + } + if (!cipher.TryDecrypt(sequence, packet[VoiceFrameHeader.Size..], packet[..VoiceFrameHeader.Size], plaintext[..length])) return false; + + // Only authenticated counters may move the replay window. + if (!initialized) + { + highestSequence = sequence; + replayWindow = 1; + initialized = true; + } + else if (sequence > highestSequence) + { + ulong shift = sequence - highestSequence; + replayWindow = (shift >= 64 ? 0 : replayWindow << (int)shift) | 1; + highestSequence = sequence; + } + else replayWindow |= 1UL << (int)(highestSequence - sequence); + header = candidate; + bytesWritten = length; + return true; + } + + public void Dispose() + { + if (disposed) return; + disposed = true; + cipher.Dispose(); + } +} diff --git a/dotnet/src/VoiceCat.Crypto/MediaEncryptor.cs b/dotnet/src/VoiceCat.Crypto/MediaEncryptor.cs new file mode 100644 index 0000000..9215c4e --- /dev/null +++ b/dotnet/src/VoiceCat.Crypto/MediaEncryptor.cs @@ -0,0 +1,40 @@ +using VoiceCat.Protocol; + +namespace VoiceCat.Crypto; + +public sealed class MediaEncryptor : IDisposable +{ + private readonly MediaCipher cipher; + private ulong nextSequence; + private bool disposed; + + public const int TagSize = 16; + + public MediaEncryptor(ReadOnlySpan key) : this(key, false) { } + + internal MediaEncryptor(ReadOnlySpan key, bool useManaged, ulong initialSequence = 0) + { + cipher = new(key, useManaged); + nextSequence = initialSequence; + } + + public int Encrypt(VoiceFrameHeader header, ReadOnlySpan plaintext, Span packet) + { + ObjectDisposedException.ThrowIf(disposed, this); + int size = checked(VoiceFrameHeader.Size + plaintext.Length + TagSize); + ArgumentOutOfRangeException.ThrowIfLessThan(packet.Length, size); + if (nextSequence == ulong.MaxValue) throw new InvalidOperationException("Media counter exhausted; establish a new session."); + if (plaintext.Overlaps(packet)) throw new ArgumentException("Input and output must not overlap.", nameof(packet)); + header = header with { Sequence = nextSequence++ }; + header.Write(packet); + cipher.Encrypt(header.Sequence, plaintext, packet[..VoiceFrameHeader.Size], packet.Slice(VoiceFrameHeader.Size, plaintext.Length + TagSize)); + return size; + } + + public void Dispose() + { + if (disposed) return; + disposed = true; + cipher.Dispose(); + } +} diff --git a/dotnet/src/VoiceCat.Crypto/VoiceCat.Crypto.csproj b/dotnet/src/VoiceCat.Crypto/VoiceCat.Crypto.csproj new file mode 100644 index 0000000..3f4e2e5 --- /dev/null +++ b/dotnet/src/VoiceCat.Crypto/VoiceCat.Crypto.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dotnet/src/VoiceCat.Crypto/packages.lock.json b/dotnet/src/VoiceCat.Crypto/packages.lock.json new file mode 100644 index 0000000..11b81a5 --- /dev/null +++ b/dotnet/src/VoiceCat.Crypto/packages.lock.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "BouncyCastle.Cryptography": { + "type": "Direct", + "requested": "[2.6.2, )", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + }, + "Google.Protobuf": { + "type": "Transitive", + "resolved": "3.36.1", + "contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ==" + }, + "voicecat.protocol": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.36.1, )" + } + } + } + } +} \ No newline at end of file diff --git a/dotnet/src/VoiceCat.Protocol/ControlFraming.cs b/dotnet/src/VoiceCat.Protocol/ControlFraming.cs new file mode 100644 index 0000000..57ed72d --- /dev/null +++ b/dotnet/src/VoiceCat.Protocol/ControlFraming.cs @@ -0,0 +1,95 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.IO.Pipelines; +using System.Runtime.CompilerServices; +using Google.Protobuf; +using Voicecat.V1; + +namespace VoiceCat.Protocol; + +public static class ControlFraming +{ + public const int MaxPayloadLength = 16 * 1024 * 1024; + + public static bool TryReadFrame(ref ReadOnlySequence input, out ReadOnlySequence payload) + { + payload = default; + if (input.Length < 4) return false; + Span prefix = stackalloc byte[4]; + input.Slice(0, 4).CopyTo(prefix); + uint length = BinaryPrimitives.ReadUInt32BigEndian(prefix); + if (length > MaxPayloadLength) throw new InvalidDataException("Control frame exceeds 16 MiB."); + if (input.Length < 4L + length) return false; + payload = input.Slice(4, length); + input = input.Slice(4L + length); + return true; + } + + public static void WriteFrame(IBufferWriter output, ReadOnlySpan payload) + { + ArgumentNullException.ThrowIfNull(output); + ArgumentOutOfRangeException.ThrowIfGreaterThan(payload.Length, MaxPayloadLength); + BinaryPrimitives.WriteUInt32BigEndian(output.GetSpan(4), (uint)payload.Length); + output.Advance(4); + output.Write(payload); + } + + public static void WriteEnvelope(IBufferWriter output, Envelope envelope) + { + ArgumentNullException.ThrowIfNull(envelope); + ArgumentNullException.ThrowIfNull(output); + int length = envelope.CalculateSize(); + ArgumentOutOfRangeException.ThrowIfGreaterThan(length, MaxPayloadLength); + BinaryPrimitives.WriteUInt32BigEndian(output.GetSpan(4), (uint)length); + output.Advance(4); + envelope.WriteTo(output); + } + + public static async IAsyncEnumerable ReadEnvelopesAsync( + PipeReader reader, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(reader); + byte[] prefix = new byte[4]; + while (true) + { + if (!await ReadExactlyAsync(reader, prefix, cancellationToken).ConfigureAwait(false)) yield break; + uint length = BinaryPrimitives.ReadUInt32BigEndian(prefix); + if (length > MaxPayloadLength) throw new InvalidDataException("Control frame exceeds 16 MiB."); + byte[] payload = length == 0 ? [] : new byte[length]; + if (length != 0 && !await ReadExactlyAsync(reader, payload, cancellationToken).ConfigureAwait(false)) + throw new InvalidDataException("Truncated control frame."); + yield return Envelope.Parser.ParseFrom(payload); + } + } + + private static async ValueTask ReadExactlyAsync(PipeReader reader, Memory destination, CancellationToken cancellationToken) + { + int written = 0; + while (written < destination.Length) + { + ReadResult result = await reader.ReadAsync(cancellationToken).ConfigureAwait(false); + var buffer = result.Buffer; + var consumed = buffer.Start; + try + { + if (result.IsCanceled) throw new OperationCanceledException(cancellationToken); + int count = (int)Math.Min(buffer.Length, destination.Length - written); + buffer.Slice(0, count).CopyTo(destination.Span[written..]); + consumed = buffer.GetPosition(count); + written += count; + if (written == destination.Length) return true; + if (result.IsCompleted) + { + if (written != 0) throw new InvalidDataException("Truncated control frame."); + return false; + } + } + finally + { + // Consume fragments so pipe backpressure cannot stall a large frame. + reader.AdvanceTo(consumed, consumed); + } + } + return true; + } +} diff --git a/dotnet/src/VoiceCat.Protocol/VoiceCat.Protocol.csproj b/dotnet/src/VoiceCat.Protocol/VoiceCat.Protocol.csproj new file mode 100644 index 0000000..b4be3bd --- /dev/null +++ b/dotnet/src/VoiceCat.Protocol/VoiceCat.Protocol.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/dotnet/src/VoiceCat.Protocol/VoiceFrameHeader.cs b/dotnet/src/VoiceCat.Protocol/VoiceFrameHeader.cs new file mode 100644 index 0000000..f8a9f51 --- /dev/null +++ b/dotnet/src/VoiceCat.Protocol/VoiceFrameHeader.cs @@ -0,0 +1,49 @@ +using System.Buffers.Binary; + +namespace VoiceCat.Protocol; + +public enum MediaFrameType : byte +{ + Voice = 1, + Keepalive = 2, + UdpBinding = 3 +} + +[Flags] +public enum VoiceFrameFlags : byte +{ + None = 0, + Marker = 1, + FecPresent = 2, + Dtx = 4, + Last = 8 +} + +public readonly record struct VoiceFrameHeader( + MediaFrameType Type, VoiceFrameFlags Flags, ushort Codec, uint Ssrc, ulong Sequence, uint Timestamp) +{ + public const int Size = 20; + + public void Write(Span destination) + { + ArgumentOutOfRangeException.ThrowIfLessThan(destination.Length, Size); + destination[0] = (byte)Type; + destination[1] = (byte)Flags; + BinaryPrimitives.WriteUInt16BigEndian(destination[2..], Codec); + BinaryPrimitives.WriteUInt32BigEndian(destination[4..], Ssrc); + BinaryPrimitives.WriteUInt64BigEndian(destination[8..], Sequence); + BinaryPrimitives.WriteUInt32BigEndian(destination[16..], Timestamp); + } + + public static bool TryRead(ReadOnlySpan source, out VoiceFrameHeader header) + { + header = default; + if (source.Length < Size) return false; + header = new((MediaFrameType)source[0], (VoiceFrameFlags)source[1], + BinaryPrimitives.ReadUInt16BigEndian(source[2..]), + BinaryPrimitives.ReadUInt32BigEndian(source[4..]), + BinaryPrimitives.ReadUInt64BigEndian(source[8..]), + BinaryPrimitives.ReadUInt32BigEndian(source[16..])); + return true; + } +} diff --git a/dotnet/src/VoiceCat.Protocol/packages.lock.json b/dotnet/src/VoiceCat.Protocol/packages.lock.json new file mode 100644 index 0000000..3b34259 --- /dev/null +++ b/dotnet/src/VoiceCat.Protocol/packages.lock.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Google.Protobuf": { + "type": "Direct", + "requested": "[3.36.1, )", + "resolved": "3.36.1", + "contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ==" + }, + "Grpc.Tools": { + "type": "Direct", + "requested": "[2.83.0, )", + "resolved": "2.83.0", + "contentHash": "vK2Go/83W0v2Nn7tTP9fGrX4IjmOa93s3M0SZeFimU1vIIr2wL9yNJlIyK21y85SGm3++JncB8IF751cjoLHuQ==" + } + } + } +} \ No newline at end of file diff --git a/dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json b/dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json new file mode 100644 index 0000000..33f6b04 --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json @@ -0,0 +1,9 @@ +{ + "envelope": "00000020082a521c08011204746578741a0b746573742d636c69656e742205302e302e31", + "media": [ + {"sequence": 0, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "", "packet": "01010000cafebabe0000000000000000000003c032faa61a66270f8b198f47e32e32ca84"}, + {"sequence": 1, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f60616263", "packet": "01010000cafebabe0000000000000001000003c0695d7eda350fbe7d25787424bf19191d00e02d53daa4ea625d23af3335f38115f30cce2997de88a40961c10f8ace84e1f5cf7740bd5e62025c022a75532a11465f9322f9867fcf6a35396f86fdca1959d8512ae564c3f09eb1e8e224cd6bdef556a073c12aa45bdae5e77e1f2827b1f3e549f15c"}, + {"sequence": 65535, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "0001020304050607", "packet": "01010000cafebabe000000000000ffff000003c096bac906a2d141b97834d57095a62f947529d13f6a74a866"}, + {"sequence": 65536, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "0001020304050607", "packet": "01010000cafebabe0000000000010000000003c005ecf39e7f89b45accd35e9b5c9b45bde30713a28b8f3183"} + ] +} diff --git a/dotnet/tests/VoiceCat.Tests/FramingTests.cs b/dotnet/tests/VoiceCat.Tests/FramingTests.cs new file mode 100644 index 0000000..23b532b --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/FramingTests.cs @@ -0,0 +1,181 @@ +using System.Buffers; +using System.IO.Pipelines; +using Google.Protobuf; +using VoiceCat.Protocol; +using Voicecat.V1; + +namespace VoiceCat.Tests; + +public class FramingTests +{ + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(65536)] + [InlineData(ControlFraming.MaxPayloadLength)] + public void PayloadRoundTrips(int size) + { + byte[] payload = Enumerable.Range(0, size).Select(i => (byte)i).ToArray(); + var output = new ArrayBufferWriter(); + ControlFraming.WriteFrame(output, payload); + var input = new ReadOnlySequence(output.WrittenMemory); + Assert.True(ControlFraming.TryReadFrame(ref input, out var actual)); + Assert.Equal(payload, actual.ToArray()); + Assert.True(input.IsEmpty); + } + + [Fact] + public void IncompleteFramesDoNotConsumeInput() + { + byte[] frame = [0, 0, 0, 3, 1, 2, 3]; + for (int size = 0; size < frame.Length; size++) + { + var input = new ReadOnlySequence(frame.AsMemory(0, size)); + Assert.False(ControlFraming.TryReadFrame(ref input, out _)); + Assert.Equal(size, input.Length); + } + } + + [Fact] + public void SegmentsAndBatchedFramesAreHandled() + { + byte[] bytes = [0, 0, 0, 3, 1, 2, 3, 0, 0, 0, 0]; + var first = new Segment(bytes.AsMemory(0, 1)); + var last = first; + for (int i = 1; i < bytes.Length; i++) last = last.Append(bytes.AsMemory(i, 1)); + var input = new ReadOnlySequence(first, 0, last, last.Memory.Length); + Assert.True(ControlFraming.TryReadFrame(ref input, out var payload)); + Assert.Equal(new byte[] { 1, 2, 3 }, payload.ToArray()); + Assert.True(ControlFraming.TryReadFrame(ref input, out payload)); + Assert.True(payload.IsEmpty); + Assert.True(input.IsEmpty); + } + + [Fact] + public void OversizedLengthsAreRejectedImmediately() + { + var input = new ReadOnlySequence(new byte[] { 1, 0, 0, 1 }); + Assert.Throws(() => ControlFraming.TryReadFrame(ref input, out _)); + Assert.Throws(() => ControlFraming.WriteFrame(new ArrayBufferWriter(), new byte[ControlFraming.MaxPayloadLength + 1])); + } + + [Fact] + public async Task EnvelopesRoundTripThroughPipe() + { + var expected = new Envelope { RequestId = 42, ClientHello = new() { ProtoVersion = 1, ClientName = "test-client", ClientVersion = "0.0.1" } }; + expected.ClientHello.Features.Add("text"); + var pipe = new Pipe(); + ControlFraming.WriteEnvelope(pipe.Writer, expected); + ControlFraming.WriteEnvelope(pipe.Writer, new()); + await pipe.Writer.CompleteAsync(); + var actual = new List(); + await foreach (var envelope in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) actual.Add(envelope); + Assert.Equal(new[] { expected, new Envelope() }, actual); + await pipe.Reader.CompleteAsync(); + } + + [Theory] + [InlineData(new byte[] { 0 })] + [InlineData(new byte[] { 0, 0, 0, 2, 1 })] + public async Task TruncatedEndOfStreamIsRejected(byte[] bytes) + { + var pipe = new Pipe(); + pipe.Writer.Write(bytes); + await pipe.Writer.CompleteAsync(); + await Assert.ThrowsAsync(async () => + { + await foreach (var _ in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) { } + }); + await pipe.Reader.CompleteAsync(); + } + + [Fact] + public async Task InvalidProtobufIsRejected() + { + var pipe = new Pipe(); + ControlFraming.WriteFrame(pipe.Writer, new byte[] { 0xff }); + await pipe.Writer.CompleteAsync(); + await Assert.ThrowsAsync(async () => + { + await foreach (var _ in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) { } + }); + await pipe.Reader.CompleteAsync(); + } + + [Fact] + public async Task ReadCanBeCanceled() + { + var pipe = new Pipe(); + using var cancellation = new CancellationTokenSource(); + await using var enumerator = ControlFraming.ReadEnvelopesAsync(pipe.Reader, cancellation.Token).GetAsyncEnumerator(); + var pending = enumerator.MoveNextAsync().AsTask(); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => pending); + await pipe.Writer.CompleteAsync(); + await pipe.Reader.CompleteAsync(); + } + + [Fact] + public void UnknownFieldsSurviveParsing() + { + byte[] bytes = [8, 42, 0xa0, 6, 7]; + Assert.Equal(bytes, Envelope.Parser.ParseFrom(bytes).ToByteArray()); + } + + [Fact] + public async Task FragmentedLargeEnvelopeMakesProgressUnderBackpressure() + { + var envelope = new Envelope { ClientHello = new() { ClientName = new string('a', 200000) } }; + var framed = new ArrayBufferWriter(); + ControlFraming.WriteEnvelope(framed, envelope); + var pipe = new Pipe(new PipeOptions(pauseWriterThreshold: 32, resumeWriterThreshold: 16)); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + async Task Produce() + { + for (int offset = 0; offset < framed.WrittenCount; offset += 7) + await pipe.Writer.WriteAsync(framed.WrittenMemory.Slice(offset, Math.Min(7, framed.WrittenCount - offset)), timeout.Token); + await pipe.Writer.CompleteAsync(); + } + var producer = Produce(); + var actual = new List(); + await foreach (var item in ControlFraming.ReadEnvelopesAsync(pipe.Reader, timeout.Token)) actual.Add(item); + await producer; + Assert.Equal(new[] { envelope }, actual); + await pipe.Reader.CompleteAsync(); + } + + [Fact] + public async Task StoppingEnumerationLeavesFollowingFramesAvailable() + { + var pipe = new Pipe(); + ControlFraming.WriteEnvelope(pipe.Writer, new() { RequestId = 1 }); + ControlFraming.WriteEnvelope(pipe.Writer, new() { RequestId = 2 }); + await pipe.Writer.FlushAsync(); + await using (var first = ControlFraming.ReadEnvelopesAsync(pipe.Reader).GetAsyncEnumerator()) + { + Assert.True(await first.MoveNextAsync()); + Assert.Equal(1UL, first.Current.RequestId); + } + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await using (var second = ControlFraming.ReadEnvelopesAsync(pipe.Reader, timeout.Token).GetAsyncEnumerator()) + { + Assert.True(await second.MoveNextAsync()); + Assert.Equal(2UL, second.Current.RequestId); + } + await pipe.Writer.CompleteAsync(); + await pipe.Reader.CompleteAsync(); + } + + private sealed class Segment : ReadOnlySequenceSegment + { + public Segment(ReadOnlyMemory memory) => Memory = memory; + + public Segment Append(ReadOnlyMemory memory) + { + var segment = new Segment(memory) { RunningIndex = RunningIndex + Memory.Length }; + Next = segment; + return segment; + } + + } +} diff --git a/dotnet/tests/VoiceCat.Tests/GoldenTests.cs b/dotnet/tests/VoiceCat.Tests/GoldenTests.cs new file mode 100644 index 0000000..dd6c81c --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/GoldenTests.cs @@ -0,0 +1,50 @@ +using System.Buffers; +using System.Text.Json; +using VoiceCat.Crypto; +using VoiceCat.Protocol; +using Voicecat.V1; + +namespace VoiceCat.Tests; + +public class GoldenTests +{ + [Fact] + public void EnvelopeMatchesCppFixture() + { + using var fixture = Load(); + var expected = Convert.FromHexString(fixture.RootElement.GetProperty("envelope").GetString()!); + var envelope = new Envelope { RequestId = 42, ClientHello = new() { ProtoVersion = 1, ClientName = "test-client", ClientVersion = "0.0.1" } }; + envelope.ClientHello.Features.Add("text"); + var output = new ArrayBufferWriter(); + ControlFraming.WriteEnvelope(output, envelope); + Assert.Equal(expected, output.WrittenSpan.ToArray()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void MediaPacketsMatchCppFixtures(bool managed) + { + using var fixture = Load(); + foreach (var vector in fixture.RootElement.GetProperty("media").EnumerateArray()) + { + byte[] key = Convert.FromHexString(vector.GetProperty("key").GetString()!); + byte[] plaintext = Convert.FromHexString(vector.GetProperty("plaintext").GetString()!); + byte[] expected = Convert.FromHexString(vector.GetProperty("packet").GetString()!); + ulong sequence = vector.GetProperty("sequence").GetUInt64(); + using var sender = new MediaEncryptor(key, managed, sequence); + using var receiver = new MediaDecryptor(key, managed); + var header = new VoiceFrameHeader(MediaFrameType.Voice, VoiceFrameFlags.Marker, 0, 0xcafebabe, 0, 960); + byte[] actual = new byte[expected.Length]; + sender.Encrypt(header, plaintext, actual); + Assert.Equal(expected, actual); + byte[] decoded = new byte[plaintext.Length]; + Assert.True(receiver.TryDecrypt(expected, decoded, out var parsed, out int written)); + Assert.Equal(sequence, parsed.Sequence); + Assert.Equal(plaintext.Length, written); + Assert.Equal(plaintext, decoded); + } + } + + private static JsonDocument Load() => JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "cpp-wire.json"))); +} diff --git a/dotnet/tests/VoiceCat.Tests/MediaTests.cs b/dotnet/tests/VoiceCat.Tests/MediaTests.cs new file mode 100644 index 0000000..5c1bf3b --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/MediaTests.cs @@ -0,0 +1,166 @@ +using System.Buffers.Binary; +using VoiceCat.Crypto; +using VoiceCat.Protocol; + +namespace VoiceCat.Tests; + +public class MediaTests +{ + private static readonly byte[] Key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray(); + private static readonly VoiceFrameHeader Header = new(MediaFrameType.Voice, VoiceFrameFlags.Marker, 0, 0xcafebabe, 0, 960); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void BothBackendsProduceIdenticalPackets(bool managed) + { + using var sender = new MediaEncryptor(Key, managed); + using var receiver = new MediaDecryptor(Key, !managed); + byte[] plaintext = Enumerable.Range(0, 100).Select(i => (byte)i).ToArray(); + byte[] packet = Seal(sender, plaintext); + byte[] output = new byte[plaintext.Length]; + Assert.True(receiver.TryDecrypt(packet, output, out var header, out int written)); + Assert.Equal(Header, header); + Assert.Equal(plaintext.Length, written); + Assert.Equal(plaintext, output); + Assert.False(receiver.TryDecrypt(packet, output, out _, out written)); + Assert.Equal(0, written); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ForgedCounterDoesNotPoisonReplayWindow(bool managed) + { + using var sender = new MediaEncryptor(Key, managed); + using var receiver = new MediaDecryptor(Key, managed); + byte[] output = new byte[8]; + Assert.True(receiver.TryDecrypt(Seal(sender, new byte[8]), output, out _, out _)); + byte[] packet = Seal(sender, new byte[8]); + byte[] forged = (byte[])packet.Clone(); + BinaryPrimitives.WriteUInt64BigEndian(forged.AsSpan(8), ulong.MaxValue); + Array.Fill(output, (byte)0xaa); + Assert.False(receiver.TryDecrypt(forged, output, out var header, out int written)); + Assert.Equal(default, header); + Assert.Equal(0, written); + Assert.All(output, value => Assert.Equal(0, value)); + Assert.True(receiver.TryDecrypt(packet, output, out _, out _)); + Assert.True(receiver.TryDecrypt(Seal(sender, new byte[8]), output, out _, out _)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TamperingEveryPacketRegionFailsAuthentication(bool managed) + { + using var sender = new MediaEncryptor(Key, managed); + byte[] packet = Seal(sender, new byte[80]); + for (int i = 0; i < packet.Length; i++) + { + using var receiver = new MediaDecryptor(Key, managed); + byte[] tampered = (byte[])packet.Clone(); + tampered[i] ^= 0x80; + Assert.False(receiver.TryDecrypt(tampered, new byte[80], out _, out _)); + Assert.True(receiver.TryDecrypt(packet, new byte[80], out _, out _)); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ReplayWindowAcceptsReorderingAndRejectsOldPackets(bool managed) + { + using var sender = new MediaEncryptor(Key, managed); + using var receiver = new MediaDecryptor(Key, managed); + var packets = Enumerable.Range(0, 130).Select(_ => Seal(sender, new byte[1])).ToArray(); + byte[] output = new byte[1]; + Assert.True(receiver.TryDecrypt(packets[64], output, out _, out _)); + Assert.False(receiver.TryDecrypt(packets[0], output, out _, out _)); + Assert.True(receiver.TryDecrypt(packets[1], output, out _, out _)); + Assert.False(receiver.TryDecrypt(packets[1], output, out _, out _)); + Assert.True(receiver.TryDecrypt(packets[63], output, out _, out _)); + Assert.True(receiver.TryDecrypt(packets[129], output, out _, out _)); + Assert.False(receiver.TryDecrypt(packets[64], output, out _, out _)); + Assert.True(receiver.TryDecrypt(packets[128], output, out _, out _)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CounterCrossesOldSixteenBitBoundary(bool managed) + { + using var sender = new MediaEncryptor(Key, managed, 65534); + using var receiver = new MediaDecryptor(Key, managed); + for (ulong sequence = 65534; sequence < 65540; sequence++) + { + Assert.True(receiver.TryDecrypt(Seal(sender, new byte[1]), new byte[1], out var header, out _)); + Assert.Equal(sequence, header.Sequence); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void InterleavedRelayUsesRecipientCounter(bool managed) + { + byte[] otherKey = Enumerable.Repeat((byte)42, 32).ToArray(); + using var a = new MediaEncryptor(Key, managed); + using var b = new MediaEncryptor(otherKey, managed); + using var receiveA = new MediaDecryptor(Key, managed); + using var receiveB = new MediaDecryptor(otherKey, managed); + using var relay = new MediaEncryptor(Key, managed); + using var listener = new MediaDecryptor(Key, managed); + byte[] plaintext = [1, 2, 3]; + byte[] decoded = new byte[3]; + for (int i = 0; i < 16; i++) + { + var sender = i % 2 == 0 ? a : b; + var receiver = i % 2 == 0 ? receiveA : receiveB; + Assert.True(receiver.TryDecrypt(Seal(sender, plaintext), decoded, out var header, out _)); + byte[] packet = new byte[39]; + relay.Encrypt(header, decoded, packet); + Assert.True(listener.TryDecrypt(packet, decoded, out var relayedHeader, out _)); + Assert.Equal((ulong)i, relayedHeader.Sequence); + Assert.Equal(plaintext, decoded); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void EmptyPayloadAndLargeCountersWork(bool managed) + { + using var sender = new MediaEncryptor(Key, managed, ulong.MaxValue - 1); + using var receiver = new MediaDecryptor(Key, managed); + var packet = Seal(sender, []); + Assert.True(receiver.TryDecrypt(packet, [], out var header, out int written)); + Assert.Equal(ulong.MaxValue - 1, header.Sequence); + Assert.Equal(0, written); + Assert.Throws(() => Seal(sender, [])); + } + + [Fact] + public void InvalidArgumentsAndDisposedInstancesAreRejected() + { + Assert.Throws(() => new MediaEncryptor(new byte[31])); + using var sender = new MediaEncryptor(Key); + using var receiver = new MediaDecryptor(Key); + Assert.Throws(() => sender.Encrypt(Header, new byte[1], new byte[36])); + byte[] packet = Seal(sender, new byte[8]); + Assert.True(receiver.TryDecrypt(packet, new byte[8], out var header, out _)); + Assert.Equal(0UL, header.Sequence); + Assert.False(receiver.TryDecrypt(new byte[35], [], out _, out _)); + Assert.Throws(() => receiver.TryDecrypt(packet, [], out _, out _)); + sender.Dispose(); + receiver.Dispose(); + Assert.Throws(() => Seal(sender, [])); + Assert.Throws(() => receiver.TryDecrypt(packet, new byte[8], out _, out _)); + } + + private static byte[] Seal(MediaEncryptor sender, byte[] plaintext) + { + byte[] packet = new byte[VoiceFrameHeader.Size + plaintext.Length + MediaEncryptor.TagSize]; + Assert.Equal(packet.Length, sender.Encrypt(Header, plaintext, packet)); + return packet; + } +} diff --git a/dotnet/tests/VoiceCat.Tests/VoiceCat.Tests.csproj b/dotnet/tests/VoiceCat.Tests/VoiceCat.Tests.csproj new file mode 100644 index 0000000..c4dcb3c --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/VoiceCat.Tests.csproj @@ -0,0 +1,15 @@ + + + false + true + + + + + + + + + + + diff --git a/dotnet/tests/VoiceCat.Tests/VoiceHeaderTests.cs b/dotnet/tests/VoiceCat.Tests/VoiceHeaderTests.cs new file mode 100644 index 0000000..0270706 --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/VoiceHeaderTests.cs @@ -0,0 +1,19 @@ +using VoiceCat.Protocol; + +namespace VoiceCat.Tests; + +public class VoiceHeaderTests +{ + [Fact] + public void HeaderUsesBigEndianFieldsAndPreservesUnknownValues() + { + var header = new VoiceFrameHeader((MediaFrameType)255, (VoiceFrameFlags)128, 0x1234, 0x56789abc, 0x0123456789abcdef, 0xfedcba98); + byte[] bytes = new byte[20]; + header.Write(bytes); + Assert.Equal("FF80123456789ABC0123456789ABCDEFFEDCBA98", Convert.ToHexString(bytes)); + Assert.True(VoiceFrameHeader.TryRead(bytes, out var parsed)); + Assert.Equal(header, parsed); + Assert.False(VoiceFrameHeader.TryRead(bytes.AsSpan(0, 19), out _)); + Assert.Throws(() => header.Write(new byte[19])); + } +} diff --git a/dotnet/tests/VoiceCat.Tests/packages.lock.json b/dotnet/tests/VoiceCat.Tests/packages.lock.json new file mode 100644 index 0000000..19678e3 --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/packages.lock.json @@ -0,0 +1,121 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.14.1, )", + "resolved": "17.14.1", + "contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==", + "dependencies": { + "Microsoft.CodeCoverage": "17.14.1", + "Microsoft.TestPlatform.TestHost": "17.14.1" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.1, )", + "resolved": "3.1.1", + "contentHash": "gNu2zhnuwjq5vQlU4S7yK/lfaKZDLmtcu+vTjnhfTlMAUYn+Hmgu8IIX0UCwWepYkk+Szx03DHx1bDnc9Fd+9w==" + }, + "BouncyCastle.Cryptography": { + "type": "Transitive", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + }, + "Google.Protobuf": { + "type": "Transitive", + "resolved": "3.36.1", + "contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.14.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "voicecat.crypto": { + "type": "Project", + "dependencies": { + "BouncyCastle.Cryptography": "[2.6.2, )", + "VoiceCat.Protocol": "[1.0.0, )" + } + }, + "voicecat.protocol": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.36.1, )" + } + } + } + } +} \ No newline at end of file