Compare commits
3
Commits
main
...
2df79cdd4c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2df79cdd4c | ||
|
|
b76181d9fb | ||
|
|
c6c003b8a7 |
@@ -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
|
||||
VOICECAT_TLS_ORACLE="$PWD/build/dev/bin/voicecat-dotnet-tls-oracle" dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
@@ -1,4 +1,7 @@
|
||||
# Build output
|
||||
/dotnet/**/bin/
|
||||
/dotnet/**/obj/
|
||||
/dotnet/**/TestResults/
|
||||
/build/
|
||||
/out/
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
+24
@@ -10,6 +10,30 @@ up instantly. Newest status at the top.
|
||||
|
||||
## ▶ Where we left off / next action
|
||||
|
||||
- **Done (2026-09-15): TLS exporter interoperability and persisted credentials.** Foundation commit
|
||||
`b76181d` pushed to `origin/dotnet/foundations`. Added a nonblocking managed TLS 1.3
|
||||
session with certificate acceptance gate and directional media factories. Managed
|
||||
loopback and C++ interoperability pass; exporter keys are captured inside BouncyCastle's
|
||||
handshake callback before its exporter secrets are destroyed. The C++ TLS oracle
|
||||
authenticates encrypted challenges in both directions against the existing mbedTLS
|
||||
context. Added explicit persisted TOFU pins, compatible Ed25519 identity/PEM import,
|
||||
new certificate identity SAN, and rejection of incomplete credential sets.
|
||||
**Verified:** 42/42 managed tests with the native TLS oracle enabled; native dev build
|
||||
and 29/29 CTest tests green; 16 permissive package licenses verified. Complete socket
|
||||
orchestration and managed server/client state remain pending.
|
||||
**Next:** Phase 3 codec/DSP wrappers and native packaging.
|
||||
|
||||
- **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,
|
||||
|
||||
@@ -34,6 +34,7 @@ that implementation can start from a shared, agreed plan.
|
||||
5. [tech-stack.md](tech-stack.md) — Concrete libraries with versions and rationale, the permissive-license rule, build tooling, per-platform notes.
|
||||
6. [deployment.md](deployment.md) — The "set it up in a few minutes" story: Docker, single binary, source build, zero-config defaults.
|
||||
7. [roadmap.md](roadmap.md) — Milestones, what ships when, and the list of open questions still to resolve.
|
||||
8. [porting-to-dotnet.md](porting-to-dotnet.md) — **Proposal.** Step-by-step plan to replace the C++ core, C++ server, and Swift clients with a single .NET 10 / C# codebase. Dependency map, the TLS-exporter blocker, real-time-audio design, phased migration.
|
||||
|
||||
## Design principles
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# 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 through `TlsSession` media factories after its handshake.
|
||||
Raw-key constructors support conformance tests.
|
||||
|
||||
`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`.
|
||||
|
||||
## TLS sessions
|
||||
|
||||
`TlsSession` is a single-owner, nonblocking BouncyCastle TLS 1.3 state machine.
|
||||
It owns no socket or worker thread. The transport owner feeds `ReceiveCiphertext`,
|
||||
fully drains `DrainCiphertext` to its socket (including partial sends), and reads
|
||||
application data through `ReadPlaintext`. Reads and drains return a byte count and
|
||||
may require repeated calls. `WritePlaintext` requires `IsReady`. Socket cancellation,
|
||||
backpressure, and connection lifetime belong to the transport owner.
|
||||
|
||||
`CreateClient(Func<string, bool>)` requires an explicit certificate acceptance
|
||||
callback. It receives the uppercase SHA-256 fingerprint of the leaf certificate's
|
||||
DER bytes during the handshake. Returning false rejects the session before application
|
||||
data or media keys are available. This is TOFU certificate pinning; there is no PKI
|
||||
chain or hostname validation. The synchronous callback must have the trust decision
|
||||
available; an asynchronous first-connect prompt requires a subsequent connection
|
||||
after explicit acceptance. Never automatically accept or persist an unknown pin.
|
||||
|
||||
`CreateServer(certificatePem, privateKeyPem)` supports ECDSA credentials; use
|
||||
`ServerCredentials.CreateTlsSession()` to import persisted credentials. TLS 1.2 is
|
||||
rejected. Handshake completion captures two 32-byte exporter keys using label
|
||||
`voicecat media v1` and one-byte contexts 0 (client to server) and 1 (server to client).
|
||||
BouncyCastle discards its exporter secrets after that callback. Media factories
|
||||
select the correct direction for each role and require a ready session.
|
||||
|
||||
Create one encryptor and decryptor per connection and retain them for the connection's
|
||||
lifetime: constructing a second encryptor resets its counter and would reuse nonces.
|
||||
Dispose media objects separately from the TLS session. `Close()` queues close_notify;
|
||||
drain it before disposal. On socket EOF call `CompleteInput()`; missing close_notify
|
||||
throws `IOException`. TLS/protocol errors require closing the connection. Disposal
|
||||
clears the session's owned exporter arrays and scratch buffer.
|
||||
|
||||
## Persisted trust and credentials
|
||||
|
||||
`TofuStore` uses the existing UTF-8 `host:port lowercase-hex-fingerprint` format.
|
||||
Host matching is ordinal and case sensitive, matching native behavior. `Check`
|
||||
returns `FirstConnect`, `Matched`, or `Mismatch` without changing persistence.
|
||||
Only explicit `Pin` or `Remove` changes the file. Pin replacement requires an
|
||||
explicit caller decision; malformed files fail closed. Changes replace the file
|
||||
atomically before updating memory. Use one owner per store/file.
|
||||
|
||||
`ServerIdentity` reads and writes the native 96-byte Ed25519 format:
|
||||
`public-key[32] || seed[32] || public-key[32]`. Loading verifies both public-key
|
||||
copies against the seed. Disposal clears the owned seed.
|
||||
|
||||
`ServerCredentials.LoadOrCreate(directory, serverName)` imports `identity.key`,
|
||||
`server.crt`, and `server.key` unchanged. If all are absent it creates an ECDSA-P256
|
||||
self-signed certificate and identity. If only some exist it rejects startup rather
|
||||
than rotating identity. Restore the missing files. New certificates include SAN URI
|
||||
`urn:voicecat:identity:ed25519:<lowercase-public-key-hex>`; legacy certificates are
|
||||
accepted unchanged. Checking this URI against ServerHello's identity is deferred
|
||||
until the managed handshake/session layer is implemented; trust currently pins the
|
||||
leaf certificate. Dispose credentials after their TLS sessions are created/finished
|
||||
as required by the application lifetime.
|
||||
|
||||
Private file writes use a same-directory temporary file, flush, and atomic replacement.
|
||||
On Unix new files use owner read/write permissions; Windows inherits directory ACLs.
|
||||
The credential directory must have one provisioning owner. PEM strings and crypto
|
||||
library internal copies are managed memory; owned-array clearing does not promise
|
||||
erasure of every runtime/library copy.
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,841 @@
|
||||
# Porting VoiceCat to pure .NET / C#
|
||||
|
||||
**Status:** wire/media crypto and TLS/exporter foundations implemented under `dotnet/`,
|
||||
including C++ interoperability, persisted TOFU, and compatible server credentials.
|
||||
Codec/audio, managed server/client state, and UI phases remain planned.
|
||||
See `dotnet/README.md`, `docs/api-dotnet.md`, and `PROGRESS.md` for verification and next steps.
|
||||
**Target runtime:** .NET 10 LTS (in-service to Nov 2028), with .NET 11 as the follow-on.
|
||||
**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#
|
||||
and is mostly *kept*.
|
||||
|
||||
This document is the map for that work: what maps 1:1, what has no .NET equivalent, what has
|
||||
to stay native, and the order to do it in so the tree is testable at every step.
|
||||
|
||||
---
|
||||
|
||||
## 0. Executive summary
|
||||
|
||||
**The port is feasible.** Roughly 80 % of the C++ core is protocol/state-machine/buffer code
|
||||
that translates to C# almost mechanically and gets *shorter*. The risk is concentrated in
|
||||
four places, and only one of them is a genuine design change:
|
||||
|
||||
| Risk | Verdict |
|
||||
|------|---------|
|
||||
| **TLS keying-material exporter (RFC 5705)** — the media-key derivation the whole UDP path depends on | ⚠️ **`SslStream` cannot do this.** [The API is an unapproved proposal](https://github.com/dotnet/runtime/issues/112529) targeting "Future", and SChannel structurally can't export secrets. **Must** use BouncyCastle's managed TLS stack, or change the protocol. See §3. |
|
||||
| **Real-time audio + GC** | Manageable, but needs deliberate design. Native audio callbacks must not enter managed code. See §5. |
|
||||
| **Opus 1.6 / DRED, RNNoise** | No managed equivalent exists. Stay native via P/Invoke. See §4. |
|
||||
| **iOS ReplayKit Broadcast Upload Extension** | ⚠️ **Keep this in Swift.** 50 MB jetsam cap + a managed runtime in an appex that .NET for iOS does not officially support. See §8.4. |
|
||||
|
||||
Everything else — protobuf, SQLite, sockets, Argon2id, ChaCha20-Poly1305, X.509 generation,
|
||||
jitter buffers, the mixer, the session model, the server — is either built into .NET or
|
||||
covered by a permissive, well-maintained NuGet package.
|
||||
|
||||
**Net effect on native dependencies:** from 8 vcpkg deps + 1 vendored, down to **3 native
|
||||
libraries** (libopus, RNNoise, miniaudio) — all three tiny, all three already vendored or
|
||||
trivially buildable, and all three optional to *replace* later.
|
||||
|
||||
---
|
||||
|
||||
## 1. What exists today (baseline inventory)
|
||||
|
||||
Sizes are source bytes, to calibrate effort.
|
||||
|
||||
### Core — `core/` (~310 KB C++)
|
||||
|
||||
| File | Bytes | What it is | Port difficulty |
|
||||
|------|-------|-----------|-----------------|
|
||||
| `core/src/core/client.cpp` + `.h` | 107 K | `vc_client` — the entire client state machine: connect/auth/TOFU, channel + user model, stream lifecycle, reframing, encode path, event queue | **Medium**, but large. Mostly mechanical. |
|
||||
| `core/src/audio/audio_engine.cpp` + `.h` | 69 K | miniaudio devices, `JitterBuffer`, per-ssrc decode, DRED/FEC/PLC ladder, mixer, level meters, talk detection, external feed/tap/mixed-sink | **Hard** — the RT-sensitive part |
|
||||
| `core/src/net/transport.cpp` + `.h` | 25 K | Asio TCP framing `[u32 len][payload]`, UDP socket | **Easy** — `Socket`/`System.IO.Pipelines` is nicer |
|
||||
| `core/src/crypto/` | 27 K | mbedTLS TLS 1.3 wrapper, exporter, self-signed cert gen, Ed25519 identity, ChaCha20-Poly1305 + anti-replay, TOFU pin store | **Hard** — see §3 |
|
||||
| `core/src/codec/opus_codec.*` | 9 K | libopus encode/decode wrapper incl. DRED | **Easy** — P/Invoke shim |
|
||||
| `core/src/protocol/`, `core/src/session/` | 12 K | Envelope (de)serialize, dispatch, channel/user/stream registry | **Easy** |
|
||||
| `core/src/audio/apm_processor.*` | 5 K | RNNoise wrapper + energy VAD | **Easy** |
|
||||
| `core/src/voicecat.cpp` | 13 K | C ABI façade over `vc_client` | **Deleted** — no ABI needed any more |
|
||||
| `core/include/voicecat.h` | 27 K | The C ABI | **Becomes a C# interface**, not an ABI |
|
||||
| `core/proto/voicecat.proto` | 9 K | Wire format, source of truth | **Unchanged** |
|
||||
|
||||
### Server — `server/` (~80 KB C++)
|
||||
|
||||
`conn_session.cpp` (34 K, per-connection protocol handling), `db.cpp` (26 K, SQLite:
|
||||
accounts, channels, bans), `session_registry.cpp` (20 K), `media_relay.cpp` (8 K, the SFU
|
||||
relay), `server.cpp`, `identity.cpp`. All **easy-to-medium** — this is ordinary async network
|
||||
server code and translates very well.
|
||||
|
||||
### Clients
|
||||
|
||||
| Client | Today | After the port |
|
||||
|--------|-------|----------------|
|
||||
| Windows | C#, WinForms, `net10.0-windows`, ~40 files | **Kept.** Swap `VoiceCat.Interop` P/Invoke for a direct project reference. |
|
||||
| macOS | Swift + AppKit, `MainWindowController.swift` alone is 70 K | Rewrite as C# AppKit on `net10.0-macos` — near-mechanical, AppKit maps 1:1 |
|
||||
| iOS | Swift + SwiftUI, ~150 K across views/audio | Rewrite as C# UIKit on `net10.0-ios` (or MAUI — see §8.3). **No 1:1 SwiftUI equivalent.** |
|
||||
| iOS broadcast appex | Swift, 12 K (`SampleHandler.swift` + `BroadcastAudioRing.swift`) | **Stays Swift.** See §8.4 |
|
||||
| `tools/vccli` | C++, 32 K | Rewrite as a C# console app — this becomes the primary conformance harness |
|
||||
|
||||
### Tests — `tests/` (29 files, ~290 KB, all green)
|
||||
|
||||
These are the real specification. Every one of them must be ported to xUnit and stay green;
|
||||
the milestone exit criteria in `docs/roadmap.md` are encoded here.
|
||||
|
||||
---
|
||||
|
||||
## 2. Target solution layout
|
||||
|
||||
```
|
||||
voice-cat/
|
||||
├── proto/voicecat.proto # unchanged, single source of truth
|
||||
├── native/ # the only C left
|
||||
│ ├── opus/ # libopus 1.6 build scripts
|
||||
│ ├── rnnoise/ # moved from third_party/
|
||||
│ ├── miniaudio/ # miniaudio.h + voicecat_audio_shim.c (§5.2)
|
||||
│ └── build-native.{ps1,sh} # produces per-RID binaries
|
||||
├── src/
|
||||
│ ├── VoiceCat.Protocol/ # Google.Protobuf codegen + Envelope framing
|
||||
│ ├── VoiceCat.Crypto/ # TLS, media AEAD, anti-replay, identity, TOFU store
|
||||
│ ├── VoiceCat.Codec/ # libopus P/Invoke + OpusEncoder/OpusDecoder
|
||||
│ ├── VoiceCat.Dsp/ # RNNoise P/Invoke, energy VAD, resample helpers
|
||||
│ ├── VoiceCat.Audio/ # devices, jitter buffer, mixer, RT ring buffers
|
||||
│ ├── VoiceCat.Core/ # VoiceCatClient — replaces vc_client + the C ABI
|
||||
│ ├── VoiceCat.Server/ # replaces server/
|
||||
│ ├── VoiceCat.Cli/ # replaces tools/vccli + voicecat-admin
|
||||
│ └── clients/
|
||||
│ ├── VoiceCat.Windows/ # net10.0-windows, WinForms (kept, retargeted)
|
||||
│ ├── VoiceCat.Mac/ # net10.0-macos, AppKit
|
||||
│ ├── VoiceCat.iOS/ # net10.0-ios, UIKit
|
||||
│ └── VoiceCatBroadcast/ # Swift appex — the one non-C# artifact
|
||||
└── tests/VoiceCat.Tests/ # xUnit, ports all 29 ctest cases
|
||||
```
|
||||
|
||||
**Target frameworks.** `VoiceCat.Core` and everything below it target plain `net10.0` — no
|
||||
platform TFM — so the same assembly loads into the server, the WinForms app, the macOS app,
|
||||
the iOS app, and the test host. Only the four leaf client projects carry a platform TFM.
|
||||
|
||||
**Why not one big assembly:** the layering is what keeps the "core owns audio, UI is thin"
|
||||
rule enforceable. It also lets the server reference `VoiceCat.Protocol` + `VoiceCat.Crypto`
|
||||
without dragging in miniaudio.
|
||||
|
||||
---
|
||||
|
||||
## 3. The TLS problem — read this before anything else
|
||||
|
||||
### 3.1 What breaks
|
||||
|
||||
`docs/security.md` §2 is built on one mbedTLS call:
|
||||
|
||||
```c
|
||||
mbedtls_ssl_export_keying_material("voicecat media v1", ...) → media keys
|
||||
```
|
||||
|
||||
**.NET has no equivalent.** `SslStream` exposes no RFC 5705 exporter. The API proposal
|
||||
([dotnet/runtime#112529](https://github.com/dotnet/runtime/issues/112529)) is labelled
|
||||
`api-suggestion` ("NOT ready for implementation"), milestone *Future*, and the platform notes
|
||||
on it are discouraging: *"Windows – needs verification"* (SChannel runs TLS in a separate
|
||||
privileged process and deliberately refuses to hand back secrets), *"OSX – Not implemented
|
||||
for Secure Transport"*. Do not plan around this shipping.
|
||||
|
||||
This is not a small dependency swap. The media key derivation is the root of the entire UDP
|
||||
path: AEAD keys, nonce discipline, anti-replay, and the UDP binding token all hang off it.
|
||||
|
||||
### 3.2 Option A (recommended) — BouncyCastle managed TLS
|
||||
|
||||
[BouncyCastle for .NET](https://www.nuget.org/packages/BouncyCastle.Cryptography) (MIT,
|
||||
actively maintained) ships a **complete managed TLS 1.3 client and server** in
|
||||
`Org.BouncyCastle.Tls`, and `TlsContext` exposes exactly the method we need:
|
||||
|
||||
```csharp
|
||||
byte[] ExportKeyingMaterial(string asciiLabel, byte[] contextValue, int length);
|
||||
// "Export keying material according to RFC 5705" — TLS 1.3 (RFC 8446 §7.5) aware
|
||||
```
|
||||
|
||||
*(verified against [bc-csharp `crypto/src/tls/TlsContext.cs`](https://github.com/bcgit/bc-csharp/blob/master/crypto/src/tls/TlsContext.cs); the C# port is feature-matched to bc-java here.)*
|
||||
|
||||
**Consequences — mostly good:**
|
||||
|
||||
- ✅ **Byte-identical wire compatibility with the existing C++ implementation.** This is the
|
||||
single biggest de-risking factor in the whole project: it means a .NET client can talk to
|
||||
the shipped C++ server (and vice versa) at *every* step of the port, and the C++ side
|
||||
becomes a conformance oracle. See §11.
|
||||
- ✅ **Identical TLS behaviour on all five platforms.** No SChannel-vs-OpenSSL-vs-
|
||||
SecureTransport variance, no per-OS cipher-suite policy surprises, no "TLS 1.3 on macOS
|
||||
only since .NET 10" caveat. For a self-hosted product shipping to unknown machines this is
|
||||
worth a lot on its own.
|
||||
- ✅ MIT, permissive — satisfies the hard no-GPL rule.
|
||||
- ✅ Also gives us **Ed25519** and **BLAKE2b** (see §4), which .NET lacks.
|
||||
|
||||
**Costs:**
|
||||
|
||||
- ⚠️ Pure-managed TLS is slower than SChannel/OpenSSL. **This does not matter here** — the
|
||||
TLS channel carries only control messages (a handshake plus a few KB/s of protobuf). Media
|
||||
is UDP + ChaCha20-Poly1305, which uses the fast built-in .NET AEAD, not BouncyCastle.
|
||||
- ⚠️ You implement `TlsClient` / `TlsServer` callback subclasses yourself (~200–300 lines for
|
||||
both sides): cipher-suite selection, certificate handling, `TlsCrypto` provider. Well-trodden
|
||||
— BC ships `DefaultTlsClient`/`DefaultTlsServer` bases and `BcTlsCrypto`.
|
||||
- ⚠️ You own the cert-validation logic (that's actually a *plus* for TOFU — see §3.4).
|
||||
- ⚠️ One more external dependency in the trust base. It's Bouncy Castle; acceptable.
|
||||
|
||||
### 3.3 Option B — `SslStream` + in-band media keys (protocol v3)
|
||||
|
||||
Abandon the exporter. Since the TLS 1.3 channel is already confidential, authenticated and
|
||||
forward-secret, the server can simply **generate the media keys and send them inside it**:
|
||||
|
||||
```proto
|
||||
message AuthResult {
|
||||
// ...
|
||||
bytes udp_token = 6;
|
||||
bytes media_key_c2s = 7; // 32 bytes, server-generated CSPRNG (NEW, v3)
|
||||
bytes media_key_s2c = 8; // 32 bytes (NEW, v3)
|
||||
}
|
||||
```
|
||||
|
||||
This is what Mumble does (its OCB2/AES key exchange happens inside its TLS control channel),
|
||||
and it is what SDES-SRTP-over-TLS does. Security posture is equivalent: an attacker who can
|
||||
read these can already read everything.
|
||||
|
||||
- ✅ Uses only built-in `System.Net.Security.SslStream` — zero TLS dependencies.
|
||||
- ✅ Simplest possible code; best per-platform TLS performance.
|
||||
- ❌ **Breaks wire compatibility** → no cross-testing against the C++ implementation during
|
||||
the port, which throws away the best safety net available.
|
||||
- ❌ Protocol version bump to 3, forced-update for the shipped iOS/macOS/Windows clients.
|
||||
- ❌ Loses the exporter's nice property that media keys are never *transmitted* at all.
|
||||
- ⚠️ Server-side `SslStream` on Windows has a real footgun: a cert created with
|
||||
`CertificateRequest.CreateSelfSigned` must be round-tripped through
|
||||
`X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pfx), null)` before SChannel
|
||||
will accept it — an ephemeral/CNG-only key produces a confusing handshake failure.
|
||||
|
||||
### 3.4 Recommendation
|
||||
|
||||
**Take Option A (BouncyCastle) for the port. Keep Option B as an optional later
|
||||
simplification** once the C++ tree is retired and you no longer need it as an oracle — at
|
||||
which point it's a contained protocol-v3 change, not a rewrite.
|
||||
|
||||
TOFU is *easier* under Option A: BouncyCastle hands you the peer's DER certificate chain
|
||||
directly in `TlsAuthentication.NotifyServerCertificate`, so
|
||||
`SHA256.HashData(leafDer)` — the exact value `docs/security.md` §1.1 says is pinned — falls
|
||||
out with no ceremony. (Under `SslStream` you'd get it from
|
||||
`RemoteCertificateValidationCallback` via `cert.GetRawCertData()`; also fine, just less
|
||||
direct.)
|
||||
|
||||
**Worth fixing while you're in here:** `security.md` §1.1 documents a known limitation — the
|
||||
Ed25519 identity key is not bound to the TLS cert, so it's display-only. When generating the
|
||||
self-signed cert in C#, **embed the Ed25519 public key as a X.509 extension or SAN URI**.
|
||||
`CertificateRequest.CertificateExtensions` makes this trivial, and it closes the gap the doc
|
||||
has been carrying.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dependency map — C++ → .NET
|
||||
|
||||
| Concern | Today | .NET replacement | License | Notes / pitfalls |
|
||||
|---------|-------|------------------|---------|------------------|
|
||||
| Sockets, timers | Asio | **`System.Net.Sockets`** + `System.IO.Pipelines` + `PeriodicTimer` | built-in | Strictly better. `Socket.ReceiveFromAsync(SocketAddress)` (net8+) is the allocation-free UDP receive path — use it, not `UdpClient`. |
|
||||
| Control framing | hand-rolled `[u32 len]` | `System.IO.Pipelines` `SequenceReader` | built-in | Removes a class of bugs. Keep the same 16 MiB frame cap. |
|
||||
| TLS 1.3 | mbedTLS | **BouncyCastle `Org.BouncyCastle.Tls`** | MIT | See §3. **Not `SslStream`.** |
|
||||
| Media AEAD | libsodium ChaCha20-Poly1305 | **`System.Security.Cryptography.ChaCha20Poly1305`** | built-in | ⚠️ Check `ChaCha20Poly1305.IsSupported` at startup — it is OS-backed (Windows 10 1903+ / OpenSSL 1.1+). Fall back to BouncyCastle's `ChaCha20Poly1305` if false. Same 12-byte nonce, 16-byte tag → identical wire bytes. |
|
||||
| Anti-replay window | hand-rolled 64-bit | port verbatim | — | ~40 lines. Keep the RFC 3711 §3.3 ordering (replay-check → authenticate → *then* advance). This ordering is load-bearing; `test_media_aead.cpp` covers it. |
|
||||
| Argon2id | libsodium `crypto_pwhash` | **`Konscious.Security.Cryptography.Argon2`** | MIT | Pure managed. ⚠️ **Existing password hashes will not verify** — libsodium emits `$argon2id$...` PHC strings with its own tuned m/t/p. Either implement a PHC-string parser and feed those params to Konscious (doable, recommended), or force a password reset on migration. Decide early; `db.cpp` migration depends on it. |
|
||||
| BLAKE2b (channel passwords) | libsodium `crypto_generichash` | **`Blake2Fast`** (MIT) or BouncyCastle `Blake2bDigest` | MIT | Salted BLAKE2b-256, must produce identical digests to keep existing channel passwords working. Blake2Fast is SIMD and fast enough for the net thread, preserving the reason BLAKE2b was chosen over Argon2 here. |
|
||||
| Ed25519 identity | libsodium | **BouncyCastle `Ed25519Signer`** | MIT | ⚠️ **Not in .NET 10.** [dotnet/runtime#63174](https://github.com/dotnet/runtime/issues/63174) is api-approved but milestoned **11.0.0**. Since Option A already pulls in BouncyCastle, this is free. |
|
||||
| Self-signed cert gen | mbedTLS x509write | **`CertificateRequest.CreateSelfSigned`** | built-in | Much nicer than the C++ version. ECDSA-P256, same as today. Add the Ed25519 SAN (§3.4). |
|
||||
| CSPRNG | libsodium | **`RandomNumberGenerator`** | built-in | — |
|
||||
| Opus codec | libopus 1.6 | **P/Invoke libopus 1.6** | BSD | **Keep native.** [Concentus](https://github.com/lostromb/concentus) is a pure-C# Opus port but it tracks **Opus 1.1** — it has no DRED, no 1.6 features. `docs/voice.md` §4 and `test_dred_toggle.cpp` depend on DRED. Concentus is a viable *fallback* for a future platform where native linking is impossible, not the primary. |
|
||||
| Noise suppression | RNNoise (vendored) | **P/Invoke RNNoise** | BSD-3 + CC0 | **Keep native.** No managed port exists. It's ~5 exported functions; the binding is trivial. Already vendored at `third_party/rnnoise/`. |
|
||||
| Audio capture/playback | miniaudio | **P/Invoke miniaudio via a shim** | MIT-0/PD | **Keep native**, see §5.2. Managed alternatives exist ([SoundFlow](https://www.nuget.org/packages/SoundFlow), [MiniaudioSharp](https://www.nuget.org/packages/MiniaudioSharp), NAudio/CSCore for Windows-only) but auto-generated bindings marshal the callback into managed code, which is exactly what you must avoid (§5.1). Write the shim yourself. |
|
||||
| Energy VAD | hand-rolled | port verbatim | — | ~60 lines. Trivial. |
|
||||
| Protobuf | protobuf-lite (C++) | **`Google.Protobuf`** + `Grpc.Tools` | BSD | ⚠️ Reference `Grpc.Tools` for the `protoc` MSBuild integration even though there is no gRPC here — it is the standard way to codegen `.proto` in a `.csproj`. `<Protobuf Include="../../proto/voicecat.proto" GrpcServices="None" />`. The `.proto` needs **zero changes**. |
|
||||
| SQLite | sqlite3 | **`Microsoft.Data.Sqlite`** | MIT | Bundles SQLitePCLRaw; works with NativeAOT. Same schema, same file — an existing `voicecat.db` opens unchanged. |
|
||||
| Logging | spdlog | **`Microsoft.Extensions.Logging`** (+ Serilog console sink) | MIT/Apache | Use `LoggerMessage` source generators on any path near the hot loop. Never log from an audio path. |
|
||||
| Server config | `server.toml` | **`Tomlyn`** (MIT) or switch to JSON + `System.Text.Json` | MIT | Tomlyn keeps `server.toml` compatible; recommended, since operator-facing config shouldn't churn. |
|
||||
| CLI arg parsing | hand-rolled | **`System.CommandLine`** | MIT | For `VoiceCat.Cli` and the server. |
|
||||
| Build | CMake + vcpkg | **`dotnet build`** + a small native build script | — | vcpkg disappears entirely except for the 3 native libs, which you can vendor as sources and build with a 20-line CMakeLists or even `cl`/`gcc` directly. |
|
||||
|
||||
### 4.1 License check
|
||||
|
||||
Every replacement is MIT / BSD / Apache-2.0 / built-in. **The hard no-GPL/LGPL rule in
|
||||
`docs/tech-stack.md` §5 holds.** BouncyCastle is MIT. Konscious is MIT. Blake2Fast is MIT.
|
||||
Tomlyn is MIT. The .NET runtime itself is MIT.
|
||||
|
||||
---
|
||||
|
||||
## 5. Real-time audio — the hard part
|
||||
|
||||
This is where a naive port fails. `docs/architecture.md` §3 states the rule: *"Audio
|
||||
(real-time) threads must not allocate, lock, log, or do syscalls."* A managed runtime adds a
|
||||
second rule: **they must not be subject to GC pauses, and they must not be managed threads at
|
||||
all if avoidable.**
|
||||
|
||||
### 5.1 Why you cannot just P/Invoke miniaudio and use `[UnmanagedCallersOnly]`
|
||||
|
||||
miniaudio calls your `ma_device_data_proc` on an OS-owned real-time audio thread (WASAPI's
|
||||
MMCSS thread, a CoreAudio IOThread, an ALSA thread). If that callback is a managed method:
|
||||
|
||||
1. **The thread must attach to the runtime.** First entry does thread registration; every
|
||||
entry does a managed↔native transition.
|
||||
2. **The thread becomes GC-suspendable.** A gen-0 collection anywhere in the process can
|
||||
suspend it mid-callback. WASAPI in exclusive/low-latency mode will glitch on a 2 ms stall;
|
||||
a gen-2 blocking collection is fatal to the audio.
|
||||
3. **Delegate lifetime.** Even with `[UnmanagedCallersOnly]` (which correctly avoids the
|
||||
marshalling stub and the `GCHandle` dance) the *reachability* problem is solved but the
|
||||
suspension problem is not.
|
||||
|
||||
The existing Windows client sidesteps all of this by keeping the entire audio pipeline in
|
||||
C++. A pure-.NET port has to solve it deliberately.
|
||||
|
||||
### 5.2 Recommended design: a native shim that owns the RT thread
|
||||
|
||||
Write **one small C file** (`native/miniaudio/voicecat_audio_shim.c`, est. 300–400 lines)
|
||||
that compiles miniaudio and exposes a *pull/push ring-buffer API* instead of a callback API:
|
||||
|
||||
```c
|
||||
// The audio callback lives entirely in C. It only ever touches lock-free ring buffers.
|
||||
// Managed code polls. No managed frame is ever on an RT stack.
|
||||
|
||||
vcsh_device* vcsh_capture_open (const char* device_id, uint32_t channels, uint32_t rate);
|
||||
size_t vcsh_capture_read (vcsh_device*, int16_t* dst, size_t frames); // non-blocking
|
||||
vcsh_device* vcsh_playback_open(const char* device_id, uint32_t channels, uint32_t rate);
|
||||
size_t vcsh_playback_write(vcsh_device*, const int16_t* src, size_t frames);
|
||||
int vcsh_playback_wait(vcsh_device*, int timeout_ms); // eventfd/Event, wakes the mixer
|
||||
void vcsh_enumerate(int capture, vcsh_device_info** out, size_t* n);
|
||||
```
|
||||
|
||||
Managed side then runs a **normal, dedicated, non-RT `Thread`** at
|
||||
`ThreadPriority.Highest`, woken by `vcsh_playback_wait`, that does: drain jitter buffers →
|
||||
Opus decode → NR → gain/mute → mix → `vcsh_playback_write`. The ring absorbs GC pauses; size
|
||||
it for ~120 ms (6 × 20 ms frames), which is well within the latency budget the jitter buffer
|
||||
already targets (`target_depth_ms_` starts at 40).
|
||||
|
||||
This is *the same architecture the code already has* — `AudioEngine` already runs a
|
||||
`mixer_timer_thread_` for the iOS external-playback path and already has per-stream ring
|
||||
buffers (`RemoteStream::ring`). You are generalising the iOS path to every platform. That is
|
||||
a pleasing simplification, and it means the iOS design needs no special case at all.
|
||||
|
||||
**Bonus:** it makes `vc_set_external_playback` / `vc_set_mixed_output_sink` disappear as
|
||||
special modes. Everything is external playback; the shim is just one more sink.
|
||||
|
||||
### 5.3 GC and allocation discipline in the managed audio path
|
||||
|
||||
Even off the RT thread, the decode/mix loop runs 50×/second per stream and must not churn:
|
||||
|
||||
- `<ServerGarbageCollector>false</ServerGarbageCollector>` and
|
||||
`<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>` on client apps.
|
||||
Set `GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency` while a call is active.
|
||||
- **Preallocate everything at stream init**, exactly as `RemoteStream::init_ring` does today.
|
||||
Use `int16[]` fields, not `new` per frame.
|
||||
- Use `Span<short>` / `ReadOnlySpan<short>` throughout the DSP; `ArrayPool<short>.Shared` for
|
||||
the rare variable-size case. Never LINQ, never `IEnumerable`, never `List<T>` growth on
|
||||
this path.
|
||||
- Marshal to native with `fixed` + raw pointers, or declare P/Invokes as
|
||||
`[LibraryImport]` taking `ref short` / `ReadOnlySpan<short>` — the source generator emits
|
||||
pinning without a marshalling stub. **Do not** use `Marshal.Copy` per frame.
|
||||
- **Add an allocation regression test.** `GC.GetAllocatedBytesForCurrentThread()` before/after
|
||||
1000 simulated mix cycles must be ~0. This is a cheap, high-value test the C++ code can't
|
||||
even express.
|
||||
- The mixer's soft limiter, the RMS level meter, and the RNNoise call are all
|
||||
fixed-work-per-frame — they port directly.
|
||||
|
||||
### 5.4 Jitter buffer
|
||||
|
||||
`JitterBuffer` uses `std::map<uint32_t, Frame>` keyed by timestamp with wraparound handling,
|
||||
plus `try_lock` everywhere so the RT thread never blocks. In C#:
|
||||
|
||||
- `SortedDictionary<uint,Frame>` allocates per insert. Prefer a **fixed-capacity circular
|
||||
array of pre-allocated frame slots** indexed by `(ts / frameSamples) % capacity` — the
|
||||
buffer is bounded at 500 ms anyway (`kLateDropSamples`), so a ring is the natural shape and
|
||||
removes all allocation. This is a genuine improvement over the current C++.
|
||||
- Replace `try_lock` with `Monitor.TryEnter` or, better, make the ring single-producer
|
||||
(net thread) / single-consumer (mixer thread) with `Volatile`/`Interlocked` indices and drop
|
||||
the lock entirely.
|
||||
- Keep the EWMA jitter estimation, the leading-edge reseed, and the frame-skip catch-up logic
|
||||
**verbatim** — that logic is subtle, hard-won, and covered by `test_jitter_depth.cpp`.
|
||||
|
||||
### 5.5 Opus P/Invoke
|
||||
|
||||
```csharp
|
||||
[LibraryImport("opus")]
|
||||
internal static partial int opus_encode(IntPtr st, ReadOnlySpan<short> pcm, int frameSize,
|
||||
Span<byte> data, int maxDataBytes);
|
||||
```
|
||||
|
||||
- `opus_encoder_ctl` is **varargs** — P/Invoke cannot do C varargs portably. Declare one
|
||||
overload per argument shape (`int`, `out int`) with `EntryPoint = "opus_encoder_ctl"`. This
|
||||
works on all the ABIs we target (x64 SysV, x64 Win, arm64 AAPCS) because all the CTLs we use
|
||||
take a single `int`/`int*`. **Note this explicitly in code comments** — it's a real
|
||||
portability caveat if a future CTL takes a different shape.
|
||||
- DRED (`opus_dred_alloc`, `opus_dred_parse`, `opus_decoder_dred_decode`) binds the same way.
|
||||
Guard with a runtime feature check as `opus_codec.cpp` does today.
|
||||
- **iOS requires static linking**: use `[LibraryImport("__Internal")]` and link
|
||||
`libopus.a` via `<NativeReference>` in the `.csproj`. Multi-target the DllImport name with a
|
||||
`const string` behind `#if IOS`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Core client port — `VoiceCat.Core`
|
||||
|
||||
`vc_client` (107 KB) is the biggest single unit. It becomes `VoiceCatClient : IAsyncDisposable`.
|
||||
|
||||
### 6.1 The C ABI goes away — and the API gets much better
|
||||
|
||||
The 60-odd `vc_*` functions were shaped by C ABI constraints. In C#:
|
||||
|
||||
| C ABI pattern | C# replacement |
|
||||
|---------------|----------------|
|
||||
| `vc_result` enum returns | Exceptions for programmer errors; `VoiceCatResult` for protocol outcomes |
|
||||
| `vc_callbacks.on_event` + `vc_event` union-ish struct | **`IAsyncEnumerable<VoiceCatEvent>`** or typed `event` handlers per event type. Kill the `u32a` generic-payload field — use a discriminated hierarchy (`record UserJoined(uint UserId, uint ChannelId, string Nick)`). |
|
||||
| `VC_EVENT_JOIN_RESULT` correlating with `vc_join_channel` | **`Task<JoinResult> JoinChannelAsync(uint id, string? pw, CancellationToken ct)`** — request/response correlation via `TaskCompletionSource` keyed on `Envelope.request_id`. This removes an entire class of "which reply was mine" bugs and shrinks every client's code. |
|
||||
| `vc_list_channels` + `vc_free_channel_list` | `IReadOnlyList<Channel> Channels { get; }` — no ownership contract at all |
|
||||
| `vc_get_server_identity_display(buf, cap, out len)` two-call idiom | `string ServerIdentityDisplay { get; }` |
|
||||
| `vc_set_pcm_sink` / `vc_set_mixed_output_sink` / `vc_stream_feed_pcm` | Keep as-is conceptually — they're the bot/extension API. `Action<PcmFrame>` or a `ChannelWriter<T>`. Document the no-blocking rule just as loudly. |
|
||||
| `vc_test_inject_capture` | `internal` test hook, not public API |
|
||||
|
||||
**Do this deliberately, not accidentally.** Write `docs/api-dotnet.md` as the successor to
|
||||
`voicecat.h`, and keep the same rule from `CLAUDE.md`: changing it is a versioned act.
|
||||
|
||||
### 6.2 Threading model in C#
|
||||
|
||||
| C++ | C# |
|
||||
|-----|-----|
|
||||
| Asio `io_context` on `io_thread_` | A single `async` read loop over `PipeReader` per connection; no explicit thread |
|
||||
| `WorkerPool` (blocking work) | Default `ThreadPool` — `Task.Run` for Argon2id, SQLite, DNS |
|
||||
| Event queue drained by UI | `System.Threading.Channels.Channel<VoiceCatEvent>` (already what the Windows client does) |
|
||||
| Mixer timer thread | Dedicated `Thread` (§5.2) — **not** a `Task`, the thread pool is not for this |
|
||||
| Capture/encode thread | Dedicated `Thread`, fed by the shim's capture ring |
|
||||
|
||||
`WorkerPool` (861 bytes) simply deletes.
|
||||
|
||||
### 6.3 State model
|
||||
|
||||
`client.cpp` holds channel/user/stream maps guarded by mutexes and exposes them through
|
||||
pull-based `vc_list_*`. In C#, hold them as immutable snapshots swapped with
|
||||
`Volatile.Write` — readers get a consistent view with no locking, and the UI can bind to it
|
||||
directly. `VC_EVENT_CHANNEL_LIST` becomes "a new snapshot is available", which is what it
|
||||
already means.
|
||||
|
||||
---
|
||||
|
||||
## 7. Server port — `VoiceCat.Server`
|
||||
|
||||
The most mechanical part of the project. Straight `async`/`await` network code.
|
||||
|
||||
| Component | Port notes |
|
||||
|-----------|-----------|
|
||||
| `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 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`. |
|
||||
| `server.toml` | Tomlyn, unchanged format. |
|
||||
|
||||
### 7.1 Deployment — keeping the "single static binary" promise
|
||||
|
||||
`docs/deployment.md` promises a single statically-linked executable with no runtime to
|
||||
install. **NativeAOT preserves this:**
|
||||
|
||||
```xml
|
||||
<PublishAot>true</PublishAot>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<StripSymbols>true</StripSymbols>
|
||||
```
|
||||
|
||||
- ✅ SQLitePCLRaw, Google.Protobuf, and BouncyCastle are all AOT-compatible.
|
||||
- ✅ Startup drops to ~5 ms; binary lands around 15–25 MB (vs. the current C++ static binary
|
||||
— comparable order of magnitude).
|
||||
- ⚠️ **No reflection-based JSON/config.** Use `System.Text.Json` source generators
|
||||
(`JsonSerializerContext`) if you use JSON anywhere. Tomlyn's model binding uses reflection —
|
||||
either use its low-level `DocumentSyntax` API or add trim descriptors.
|
||||
- ⚠️ Cross-compilation is per-RID; you need a build machine per target (`linux-x64`,
|
||||
`linux-arm64`, `win-x64`, `osx-arm64`). Same as today with vcpkg, so no regression.
|
||||
- The Docker image gets *simpler*: `FROM scratch`-ish with a NativeAOT binary, or
|
||||
`mcr.microsoft.com/dotnet/runtime-deps:10.0-noble`.
|
||||
|
||||
**Alternative if AOT fights you:** self-contained single-file publish
|
||||
(`PublishSingleFile` + `SelfContained`) — bigger (~70 MB) and slower to start, but no AOT
|
||||
constraints. Keep as a fallback per-RID, not the default.
|
||||
|
||||
---
|
||||
|
||||
## 8. Clients
|
||||
|
||||
### 8.1 Windows — the easy one
|
||||
|
||||
The `net10.0-windows` WinForms app is already C# and already structured around
|
||||
`Channel<VoiceCatEvent>` + a 30 ms UI-thread pump. The port is:
|
||||
|
||||
1. Delete `VoiceCat.Interop` (the P/Invoke layer) and `VoiceCat.Interop.Tests`.
|
||||
2. `<ProjectReference Include="VoiceCat.Core" />`.
|
||||
3. Update ~40 call sites from `VcResult r = Native.vc_join_channel(...)` to
|
||||
`await client.JoinChannelAsync(...)`. Mostly a find/replace plus `async void` →
|
||||
`async Task` hygiene on event handlers.
|
||||
4. `Audio/ProcessLoopbackCapture.cs`, `ProcessAudioMixer.cs`, `AudioSessionEnumerator.cs`
|
||||
(WASAPI process loopback, `AUDIOCLIENT_ACTIVATION_PARAMS`) — **unchanged**. They already
|
||||
feed `vc_stream_feed_pcm`; they'll feed `client.FeedPcm(...)`.
|
||||
5. `Native/RawInput.cs` (PTT hotkeys), `Models/PasswordProtector.cs` (DPAPI),
|
||||
`Notifications/*` (SAPI announcer, sound pool) — **unchanged**.
|
||||
|
||||
**WinForms accessibility (the reason it was chosen over WinUI 3) is unaffected.** Keep it.
|
||||
|
||||
**Estimated effort: 1–2 weeks.** This client is nearly free.
|
||||
|
||||
### 8.2 macOS — AppKit in C#
|
||||
|
||||
`net10.0-macos` gives full AppKit bindings via [dotnet/macios](https://github.com/dotnet/macios).
|
||||
The Swift AppKit code maps almost line-for-line:
|
||||
|
||||
| Swift | C# |
|
||||
|-------|-----|
|
||||
| `NSWindowController`, `NSOutlineView`, `NSTableViewDataSource` | Same types, same selectors, PascalCase |
|
||||
| `@objc func handleClick(_ sender: Any)` | `[Export("handleClick:")] void HandleClick(NSObject sender)` |
|
||||
| `accessibilityLabel`, `NSAccessibility.post(.announcement)` | `AccessibilityLabel`, `NSAccessibility.PostNotification(...)` — **full VoiceOver parity, the reason AppKit was chosen holds** |
|
||||
| `ScreenAudioCapture.swift` — ScreenCaptureKit | ✅ **ScreenCaptureKit is bound** in `net10.0-macos` (`SCStream`, `SCContentFilter`, `SCStreamConfiguration` incl. `CapturesAudio` / `ExcludesCurrentProcessAudio`). Per-app include/exclude filters and the VoiceOver-exclusion set port directly. |
|
||||
| `InputDeviceCapture.swift` — CoreAudio | AVFoundation/CoreAudio bound; or just use the miniaudio shim on macOS |
|
||||
|
||||
`MainWindowController.swift` is 70 KB — this is the single largest UI rewrite. Budget for it.
|
||||
|
||||
⚠️ **Distribution:** a `net10.0-macos` app bundle needs codesigning + notarization, and
|
||||
NativeAOT for macOS app bundles is supported but adds a step. Nothing blocking; just not
|
||||
free.
|
||||
|
||||
### 8.3 iOS — the SwiftUI gap
|
||||
|
||||
This is the only client with no mechanical path, because **SwiftUI has no C# equivalent.**
|
||||
Three options:
|
||||
|
||||
| Option | Pros | Cons |
|
||||
|--------|------|------|
|
||||
| **A. UIKit in C#** (`net10.0-ios`, hand-written) | Full API access, best accessibility control, matches the macOS/AppKit approach, no extra framework | The ~150 KB of SwiftUI views (`SettingsView`, `ChannelTreeView`, `ChatView`, …) must be re-authored as UIKit — a real rewrite, not a translation |
|
||||
| **B. .NET MAUI** | Fastest to write; XAML declarative style is closest in spirit to SwiftUI; one codebase could later cover macOS too (Mac Catalyst) | ⚠️ Accessibility is weaker than native UIKit — and the project *explicitly* chose native toolkits for screen-reader quality (`roadmap.md` §2). Extra abstraction layer over the audio-sensitive app lifecycle. |
|
||||
| **C. Avalonia** | One UI codebase for Windows + macOS + iOS | ⚠️ Same accessibility objection as MAUI, *and* it would mean abandoning WinForms/AppKit — contradicts two settled decisions |
|
||||
|
||||
**Recommendation: Option A (UIKit).** It's more work but it is the only choice consistent
|
||||
with the accessibility commitments already made twice in the docs. Budget it as the largest
|
||||
single client task.
|
||||
|
||||
What ports cleanly regardless:
|
||||
- `IOSAudioRouter.swift` (31 KB) — `AVAudioSession` is fully bound. `SetPreferredDataSource`,
|
||||
`SetPreferredPolarPattern`, `AllowBluetoothA2DP`, `MeasurementMode` all exist in C#. The
|
||||
re-entrancy guards and route-change filtering (`.categoryChange` / `.routeConfigurationChange`
|
||||
/ `.override`) port verbatim. **Keep the invariants in `voice.md` §8 exactly.**
|
||||
- `IOSVoiceProcessingEngine.swift` (24 KB) — `AVAudioEngine`, `AVAudioSourceNode`,
|
||||
`SetVoiceProcessingEnabled(true)`, `VoiceProcessingAgcEnabled` are all bound.
|
||||
- Under §5.2's design, iOS stops being a special case: the core is *always* externally
|
||||
driven, and `AVAudioEngine` is simply the iOS "shim" implementation.
|
||||
|
||||
⚠️ **iOS + NativeAOT:** .NET for iOS ships Mono AOT by default; NativeAOT for iOS is
|
||||
[still experimental](https://learn.microsoft.com/en-us/dotnet/maui/deployment/nativeaot).
|
||||
Mono AOT is fine for the host app (it's what every Xamarin/MAUI app ships). Do not depend on
|
||||
NativeAOT on iOS.
|
||||
|
||||
⚠️ **App Store:** you already ship `me.iamtalon.voicecat`. A runtime change is invisible to
|
||||
review, but re-validate background-audio behaviour (`UIBackgroundModes: audio`) under Mono —
|
||||
managed finalizers and the GC must not stall the audio render callback while backgrounded.
|
||||
§5.2's native-ring design is what protects you here.
|
||||
|
||||
### 8.4 iOS screen sharing — **keep this in Swift**
|
||||
|
||||
You anticipated this correctly. The ReplayKit Broadcast Upload Extension should **not** be
|
||||
ported.
|
||||
|
||||
**Why:**
|
||||
1. **The 50 MB jetsam cap.** A managed runtime (Mono AOT + metadata + GC heap) inside a
|
||||
separate appex process eats a meaningful fraction of that before your code runs. The
|
||||
current Swift `SampleHandler` is 4.4 KB and does one `AVAudioConverter` call per buffer.
|
||||
2. **.NET for iOS does not officially support broadcast upload extensions.** Microsoft's own
|
||||
guidance is that this is a [known gap with no documentation](https://learn.microsoft.com/en-sg/answers/questions/2006706/issues-with-bundling-ios-broadcast-extension-in-ne);
|
||||
the supported extension types are enumerated and this isn't reliably among them.
|
||||
3. **There is nothing to gain.** The extension deliberately does *not* link the core
|
||||
(`voice.md` §9) — it converts PCM and writes to a shared ring. It is already a
|
||||
language-agnostic boundary.
|
||||
|
||||
**The boundary is already clean.** `BroadcastAudioRing.swift` is an mmap'd file in an App
|
||||
Group with an SPSC ring layout. C# reads it with `MemoryMappedFile.CreateFromFile` +
|
||||
`MemoryMappedViewAccessor`, and `CFNotificationCenter` (Darwin notifications) is bound in
|
||||
`net10.0-ios`. **Action: freeze `BroadcastAudioRing`'s binary layout as a documented struct**
|
||||
(magic, version, capacity, head, tail, activeFlag, sample format) in
|
||||
`docs/broadcast-ring-format.md`, so the Swift writer and the C# reader are contractually
|
||||
pinned. The C# `BroadcastAudioPump` is then ~150 lines.
|
||||
|
||||
This leaves the repo with exactly **one Swift file plus one shared Swift ring** — an
|
||||
acceptable, well-justified exception to "pure C#", and dramatically less than the current
|
||||
three Swift codebases.
|
||||
|
||||
### 8.5 What about a Linux client?
|
||||
|
||||
Not in scope today, but worth noting: once the core is `net10.0` with a miniaudio shim
|
||||
(which has ALSA/PulseAudio backends), a Linux client becomes a UI-only problem for the first
|
||||
time. Avalonia would be the natural choice *there specifically*, without disturbing the
|
||||
Windows/macOS/iOS decisions. Mention it in `roadmap.md`; don't build it now.
|
||||
|
||||
---
|
||||
|
||||
## 9. Tests
|
||||
|
||||
The 29 ctest cases **are** the specification. Port every one to xUnit in
|
||||
`tests/VoiceCat.Tests/`. Grouping:
|
||||
|
||||
| Group | Tests | Notes |
|
||||
|-------|-------|-------|
|
||||
| Wire format | `test_envelope`, `test_voice_frame`, `test_frame_codec`, `test_frame_ms_reframe` | Port first. These are pure functions — fastest possible feedback on the protobuf + framing layers. |
|
||||
| Crypto | `test_media_aead`, `test_tls_loopback`, `test_tofu_flow` | The AEAD test must produce **byte-identical** ciphertext to the C++ for a fixed key+nonce+AAD. Add that as a golden-vector test — it's your proof the port is wire-compatible. |
|
||||
| Codec/DSP | `test_opus_codec`, `test_dred_toggle`, `test_noise_suppression`, `test_recv_noise_reduction`, `test_plc_cap` | Depend on the native P/Invokes; run them as soon as those exist. |
|
||||
| Audio engine | `test_jitter_depth`, `test_channel_samplerate`, `test_external_pcm`, `test_external_playback`, `test_vad_ptt_devices` | The subtle ones. `test_jitter_depth` guards the bounded-depth invariant — do not weaken it. |
|
||||
| Integration | `test_m1_integration`, `test_m2_voice`, `test_m3_multistream`, `test_tcp_loopback`, `test_disconnect_left`, `test_reaper_timeout` | Real client + real server in-process. |
|
||||
| Moderation/admin | `test_m5_permissions`, `test_m5_kick_ban_move_mute`, `test_m5_channel_crud`, `test_m5_admin_accounts` | Server-side; port with `VoiceCat.Server`. |
|
||||
| ABI surface | `test_voice_client_abi`, `test_channel_user_list_abi` | These test the C ABI specifically — **rewrite as API-shape tests** against the new C# surface, don't port literally. |
|
||||
|
||||
**New tests the port should add:**
|
||||
- Allocation regression on the mix loop (§5.3).
|
||||
- AEAD golden vectors vs. C++ output.
|
||||
- A **cross-implementation test**: C# client ↔ C++ server, and C++ `vccli` ↔ C# server, run
|
||||
in CI for as long as both trees exist (§11).
|
||||
|
||||
`ctest --preset dev` → `dotnet test`. The house rule in `CLAUDE.md` ("every commit builds and
|
||||
passes") carries over unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 10. Documentation changes
|
||||
|
||||
Per the `CLAUDE.md` rule that docs and code stay in sync:
|
||||
|
||||
| Doc | Change |
|
||||
|-----|--------|
|
||||
| `docs/architecture.md` | Rewrite §1 (shared-core model — it's now a shared *assembly*), §4 (C ABI → C# API), §3 (threading — the shim design). Keep §5 (server) and §2 (layers) nearly as-is. |
|
||||
| `docs/tech-stack.md` | Replace the whole dependency table. Re-run the license audit (§5) — the no-GPL rule still passes. |
|
||||
| `docs/security.md` | ⚠️ **§2 needs rewriting** to describe BouncyCastle's exporter rather than mbedTLS's, and §1.1 should be updated when the Ed25519↔cert binding lands (§3.4). §3–§6 unchanged. |
|
||||
| `docs/voice.md` | §5 (jitter), §8 (pipeline), §10 (NR) get implementation-detail updates. The *protocol* sections (§2 frame format, §3 config, §4 loss resilience) are **unchanged** — that's the point. |
|
||||
| `docs/protocol.md` | Unchanged unless you take Option B (§3.3), which adds two `AuthResult` fields and bumps to v3. |
|
||||
| `docs/building.md` | Full rewrite: `dotnet build` + the native build script replace the CMake preset matrix. **Delete the "run ctest in PowerShell not Git Bash" warning** — that MinGW pathology disappears with the toolchain. |
|
||||
| `docs/deployment.md` | §1.B/C update for NativeAOT publish; Docker base image changes. The zero-config promises hold. |
|
||||
| `docs/roadmap.md` | Add the port as its own milestone; note the Linux-client possibility (§8.5). |
|
||||
| `CLAUDE.md` | New build commands, new subsystem map, new house rules (no allocation in the audio path becomes explicit). |
|
||||
| **new** `docs/api-dotnet.md` | The successor to `voicecat.h` — the versioned client API contract. |
|
||||
| **new** `docs/broadcast-ring-format.md` | The frozen Swift↔C# App Group ring layout (§8.4). |
|
||||
|
||||
---
|
||||
|
||||
## 11. Migration strategy — the actual step-by-step
|
||||
|
||||
The guiding principle: **the C++ tree stays working and becomes the conformance oracle.** Do
|
||||
not delete anything until the C# equivalent passes the same test against it. This is only
|
||||
possible because Option A (§3.2) preserves wire compatibility — which is the main reason to
|
||||
choose it.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
### Phase 0 — Foundations (est. 1 week)
|
||||
|
||||
1. Create the solution skeleton from §2. `Directory.Build.props` with
|
||||
`net10.0`, `<Nullable>enable</Nullable>`, `<TreatWarningsAsErrors>true</TreatWarningsAsErrors>`,
|
||||
`<AnalysisLevel>latest-all</AnalysisLevel>`, `<InvariantGlobalization>true</InvariantGlobalization>`.
|
||||
2. `native/build-native.{ps1,sh}`: build libopus 1.6, RNNoise, and the (empty for now)
|
||||
miniaudio shim into `runtimes/{rid}/native/`. Vendor the sources — drop vcpkg.
|
||||
3. `VoiceCat.Protocol`: add `Google.Protobuf` + `Grpc.Tools`, point at the **existing**
|
||||
`core/proto/voicecat.proto`, verify generated C# types compile.
|
||||
4. CI: GitHub Actions matrix building both trees (C++ and C#) side by side.
|
||||
|
||||
**Exit criterion:** `dotnet build` produces empty-but-real assemblies; generated protobuf
|
||||
types are present; native libs land in the right RID folders.
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 — Wire format, provably compatible (est. 1 week)
|
||||
|
||||
1. Port `envelope.cpp` (frame `[u32 len][payload]`) using `System.IO.Pipelines`.
|
||||
2. Port `voice_frame.h` — the 20-byte big-endian header. Use
|
||||
`BinaryPrimitives.WriteUInt64BigEndian` etc.
|
||||
3. Port `SodiumMediaCrypto` → `MediaCrypto` on `System.Security.Cryptography.ChaCha20Poly1305`,
|
||||
including the nonce scheme and the sliding replay window. **Preserve the RFC 3711 §3.3
|
||||
ordering.**
|
||||
4. Port `test_envelope`, `test_voice_frame`, `test_frame_codec`, `test_media_aead`.
|
||||
5. **Generate golden vectors from the C++ build** (a throwaway C++ main dumping sealed frames
|
||||
for fixed inputs) and assert the C# produces identical bytes.
|
||||
|
||||
**Exit criterion:** golden-vector tests green. From here on, byte-compatibility is measured,
|
||||
not assumed.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — TLS + the exporter (est. 1.5 weeks — highest-risk phase, do it early)
|
||||
|
||||
1. Spike first, in isolation: a BouncyCastle `TlsClientProtocol` ↔ `TlsServerProtocol`
|
||||
loopback over a `Socket` pair, both calling
|
||||
`ExportKeyingMaterial("voicecat media v1", ...)` and asserting the two sides agree.
|
||||
2. **Then the real proof:** a C# BouncyCastle client handshaking against the **existing C++
|
||||
mbedTLS server**, both exporting with the same label, and asserting the derived media keys
|
||||
are identical. *This is the single most important checkpoint in the whole project.* If it
|
||||
fails, stop and reconsider Option B before writing anything else.
|
||||
3. Port `ServerCert`/`ServerIdentity` (`CertificateRequest` + BC Ed25519), the TOFU pin store
|
||||
(`tofu_store.cpp` — a trivial text file), and cert-fingerprint pinning.
|
||||
4. Port `test_tls_loopback`, `test_tofu_flow`, `test_tcp_loopback`.
|
||||
|
||||
**Exit criterion:** C# client completes a TLS 1.3 handshake with the C++ server, derives
|
||||
matching media keys, and pins the leaf fingerprint.
|
||||
|
||||
**Checkpoint (2026-09-15):** implemented nonblocking managed TLS, handshake-time
|
||||
exporters, explicit certificate acceptance, persisted TOFU, and native-compatible
|
||||
credentials. The C++ TLS oracle authenticates a media challenge in both directions
|
||||
over an actual socket, proving exporter compatibility. Tests also cover managed
|
||||
fragmented loopback, first-connect acceptance, changed-pin rejection, TLS 1.2 rejection,
|
||||
close_notify/abrupt EOF, restart persistence, and import of C++ credential files.
|
||||
Socket orchestration remains a transport-owner responsibility; the complete managed
|
||||
server and client are later phases. See `dotnet/README.md` for the required native
|
||||
interoperability test command.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Codec + DSP (est. 1 week)
|
||||
|
||||
1. `VoiceCat.Codec`: libopus `[LibraryImport]`, `OpusEncoder`/`OpusDecoder`, the varargs-CTL
|
||||
workaround (§5.5), DRED.
|
||||
2. `VoiceCat.Dsp`: RNNoise binding, `EnergyVadProcessor`.
|
||||
3. Port `test_opus_codec`, `test_dred_toggle`, `test_noise_suppression`.
|
||||
|
||||
**Exit criterion:** encode→decode round-trip at every supported frame size; DRED recovery
|
||||
test green; RNNoise output matches the C++ within tolerance.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Server (est. 3–4 weeks)
|
||||
|
||||
Do the server before the client: it lets you point the **existing, trusted C++ `vccli`** at
|
||||
it, which is a far better test client than a half-built C# one.
|
||||
|
||||
1. `VoiceCat.Server`: accept loop, `ConnSession` protocol handling, session registry.
|
||||
2. `Db` on `Microsoft.Data.Sqlite` — same schema. **Resolve the Argon2id hash-compat
|
||||
question here** (§4).
|
||||
3. `MediaRelay` — the allocation-free SFU fan-out.
|
||||
4. Keepalive reaper, moderation/admin handlers.
|
||||
5. Port `test_m1_integration`, `test_m5_*`, `test_disconnect_left`, `test_reaper_timeout`.
|
||||
|
||||
**Exit criterion:** ▶ **C++ `vccli` connects to the C# server, authenticates, joins a
|
||||
channel, sends text, and exchanges voice with a second C++ `vccli`.** That is the M1+M2 exit
|
||||
criterion from `roadmap.md`, re-proven against the new server.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 — Audio engine (est. 4–5 weeks — the hardest phase)
|
||||
|
||||
1. Write and validate `voicecat_audio_shim.c` standalone (a C test that loops mic→speaker
|
||||
through the rings, no .NET involved).
|
||||
2. `VoiceCat.Audio`: device enumeration, the allocation-free jitter buffer (§5.4), per-ssrc
|
||||
decode with the **DRED → FEC → PLC** ladder, per-stream NR/gain/mute, the mixer, level
|
||||
meters, talk-state edge detection.
|
||||
3. The dedicated mixer thread + capture thread.
|
||||
4. External feed/tap/mixed-sink — now the *normal* path, not special modes.
|
||||
5. Port `test_jitter_depth`, `test_external_pcm`, `test_external_playback`,
|
||||
`test_channel_samplerate`, `test_plc_cap`, `test_recv_noise_reduction`,
|
||||
`test_frame_ms_reframe`.
|
||||
6. **Add the allocation-regression test.**
|
||||
|
||||
**Exit criterion:** `test_jitter_depth`'s bounded-depth invariant holds; zero allocations per
|
||||
mix cycle; a manual listen test on Windows and macOS with no audible glitching over 10
|
||||
minutes.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6 — Client core (est. 3–4 weeks)
|
||||
|
||||
1. `VoiceCatClient`: connect/TOFU/auth state machine, request/response correlation via
|
||||
`TaskCompletionSource`, channel/user/stream snapshots, stream lifecycle, reframing, the
|
||||
send path, the event stream.
|
||||
2. `VoiceCat.Cli` — the `vccli` replacement, plus `voicecat-admin`.
|
||||
3. Port `test_m2_voice`, `test_m3_multistream`, `test_vad_ptt_devices`; rewrite the two ABI
|
||||
tests as API-shape tests.
|
||||
|
||||
**Exit criterion:** ▶ **Two C# `vccli` instances hold a multi-channel voice + text
|
||||
conversation through the C# server**, and a C# `vccli` interoperates with a C++ `vccli` on
|
||||
the same server. This is the full M0–M3 criterion re-proven end to end.
|
||||
|
||||
---
|
||||
|
||||
### Phase 7 — Windows client (est. 1–2 weeks)
|
||||
|
||||
Per §8.1. Ship this first of the three GUIs — it's the cheapest and it validates the C# API
|
||||
shape against a real, complete UI before you commit to two rewrites.
|
||||
|
||||
**Exit criterion:** feature parity with the current WinForms build, NVDA smoke-tested.
|
||||
|
||||
---
|
||||
|
||||
### Phase 8 — macOS client (est. 4–5 weeks)
|
||||
|
||||
Per §8.2. AppKit port, ScreenCaptureKit per-app audio selection, VoiceOver parity.
|
||||
|
||||
**Exit criterion:** feature parity with `VoiceCatMac`, VoiceOver smoke-tested, notarized
|
||||
build produced.
|
||||
|
||||
---
|
||||
|
||||
### Phase 9 — iOS client (est. 5–7 weeks)
|
||||
|
||||
Per §8.3/§8.4. UIKit rewrite, `AVAudioSession` router port, `AVAudioEngine` VPIO path, and
|
||||
the C# `BroadcastAudioPump` reading the **unchanged Swift** extension's ring.
|
||||
|
||||
**Exit criterion:** feature parity with `VoiceCatiOS`; screen-audio sharing works with the
|
||||
Swift extension untouched; A2DP/stereo/VPIO preset matrix re-verified (this is where the
|
||||
known stereo-A2DP class of bug lives — re-test it explicitly).
|
||||
|
||||
---
|
||||
|
||||
### Phase 10 — Cutover (est. 1–2 weeks)
|
||||
|
||||
1. Run both trees in CI for one full release cycle.
|
||||
2. Delete `core/`, `server/`, `tools/`, `clients/apple/` (except `VoiceCatBroadcast/` and
|
||||
`Shared/BroadcastAudioRing.swift`), `vcpkg/`, `CMakePresets.json`, root `CMakeLists.txt`.
|
||||
3. Update every doc per §10.
|
||||
4. Tag the last C++ commit so the oracle stays reachable.
|
||||
|
||||
---
|
||||
|
||||
### 11.5 Total estimate
|
||||
|
||||
**~7–9 months of focused single-developer work**, front-loaded with risk (Phase 2) and
|
||||
back-loaded with volume (Phases 8–9). The server + core (Phases 0–6) is roughly 4 months and
|
||||
is the part that removes the most complexity; the three GUIs are roughly half the calendar
|
||||
time and almost none of the difficulty.
|
||||
|
||||
---
|
||||
|
||||
## 12. Risk register
|
||||
|
||||
| # | Risk | Severity | Mitigation |
|
||||
|---|------|----------|------------|
|
||||
| 1 | **BouncyCastle's exporter doesn't interoperate with mbedTLS's** | 🔴 Critical | Prove it in Phase 2 step 2, before any other work depends on it. Both implement RFC 8446 §7.5, so it should — but *verify*, don't assume. Fallback: Option B (§3.3). |
|
||||
| 2 | **GC pauses cause audio glitches** | 🔴 High | Native shim owns the RT thread (§5.2); ~120 ms ring; allocation-regression test; `SustainedLowLatency`. This is the design's whole answer. |
|
||||
| 3 | **iOS audio regressions under Mono AOT** | 🟠 Medium-High | The iOS audio path is already the most delicate part of the product (see the A2DP/VPIO invariants in `voice.md` §8). Re-test the full preset × route matrix. Do not port the invariants "roughly". |
|
||||
| 4 | **Argon2id hashes don't verify → existing accounts locked out** | 🟠 Medium | Decide in Phase 4. Preferred: parse libsodium's PHC string and pass m/t/p to Konscious; verify against real hashes from an existing `voicecat.db` *before* writing the rest of `Db`. |
|
||||
| 5 | **SFU relay throughput regression** | 🟠 Medium | Benchmark early (Phase 4): N=50 subscribers × 50 pps. Allocation-free `SocketAddress` receive + pooled buffers. .NET's socket layer is good; this should be fine, but measure. |
|
||||
| 6 | **`ChaCha20Poly1305.IsSupported == false`** on some target | 🟡 Low | Startup check + BouncyCastle fallback. One-line risk. |
|
||||
| 7 | **`opus_encoder_ctl` varargs breaks on a future ABI** | 🟡 Low | Only single-`int` CTLs are used; document it, add a test that exercises every CTL used. |
|
||||
| 8 | **NativeAOT trimming breaks protobuf/SQLite reflection** | 🟡 Low | All three are AOT-tested upstream. Add an AOT-published smoke test to CI from Phase 4. |
|
||||
| 9 | **macOS/iOS bindings lag a new Xcode** | 🟡 Low | dotnet/macios tracks Xcode closely (bindings exist through Xcode 26). Pin the workload version. |
|
||||
| 10 | **Scope creep — "while we're rewriting, let's also…"** | 🟠 Medium | The port is a *translation*. The API-shape improvements in §6.1 are the only sanctioned redesign. Everything else goes in `roadmap.md`. |
|
||||
|
||||
---
|
||||
|
||||
## 13. What you gain
|
||||
|
||||
Worth being explicit, since this is 7+ months:
|
||||
|
||||
- **One language, one toolchain, one debugger.** No more CMake presets, vcpkg triplets,
|
||||
MinGW-vs-PowerShell execution pathologies, XCFramework fat-static-lib packaging, or a C ABI
|
||||
that has to be hand-mirrored into both Swift and C#.
|
||||
- **Three UI codebases instead of three UI codebases *plus* a core plus two binding layers.**
|
||||
The `VoiceCat.Interop` P/Invoke layer, the `VoiceCatCore` Swift wrapper, the module map, and
|
||||
the whole `voicecat.h` ABI surface all cease to exist.
|
||||
- **Better API.** `await client.JoinChannelAsync()` instead of "call this, then wait for
|
||||
`VC_EVENT_JOIN_RESULT`, and hope it's yours."
|
||||
- **Memory safety** across the entire protocol-parsing surface — the part most exposed to
|
||||
hostile input.
|
||||
- **8 native dependencies → 3**, each small and vendored.
|
||||
- **Tests that can assert things C++ couldn't**, notably zero-allocation invariants.
|
||||
|
||||
And what you keep: the protocol, the wire format, the security model, the audio design, the
|
||||
accessibility-first UI toolkit choices, and every single one of the 29 behavioural tests.
|
||||
@@ -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.
|
||||
|
||||
|
||||
+23
-10
@@ -49,6 +49,16 @@ This is a known limitation of the current design. Closing it properly requires b
|
||||
Ed25519 key into the TLS cert (e.g. as a SubjectAltName or extension), which is a planned
|
||||
future improvement. Until then, clients display both values but gate on the cert fingerprint.
|
||||
|
||||
**Managed rewrite checkpoint:** `dotnet/` uses nonblocking BouncyCastle TLS 1.3 and
|
||||
captures directional exporters during handshake completion. Its client requires an
|
||||
explicit leaf-fingerprint acceptance callback; PKI validation remains unimplemented.
|
||||
New managed server certificates include the Ed25519 public key in SAN URI
|
||||
`urn:voicecat:identity:ed25519:<lowercase-public-key-hex>`. Existing C++ credentials
|
||||
are imported unchanged. Verifying that URI against the declared ServerHello identity
|
||||
is still deferred to the managed session layer; leaf-certificate TOFU remains the
|
||||
trust gate. Missing members of a persisted credential set cause startup rejection
|
||||
rather than automatic identity rotation. See [api-dotnet.md](api-dotnet.md).
|
||||
|
||||
Client certificates are reserved for a future "key-based identity" option (see roadmap) but
|
||||
are not required in v1.
|
||||
|
||||
@@ -67,13 +77,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 +96,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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AnalysisLevel>latest</AnalysisLevel>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,71 @@
|
||||
# VoiceCat .NET rewrite
|
||||
|
||||
The first slice targets .NET 10: protobuf, control framing, voice headers, and media
|
||||
encryption, TLS 1.3, persisted TOFU pins, and server credentials. 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.
|
||||
|
||||
## TLS interoperability
|
||||
|
||||
The optional TLS oracle uses the existing mbedTLS context and libsodium media crypto.
|
||||
The test authenticates an encrypted challenge in both directions, proving exporter
|
||||
compatibility without sending raw keys. It also loads the C++ server's credential files.
|
||||
|
||||
```powershell
|
||||
cmake --build --preset dev --target voicecat-dotnet-tls-oracle
|
||||
$env:VOICECAT_TLS_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-tls-oracle.exe).Path
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
```
|
||||
|
||||
On Linux/macOS, set `VOICECAT_TLS_ORACLE` to the absolute executable path without
|
||||
`.exe`. Without that variable, only this native interoperability test is skipped;
|
||||
managed TLS loopback, rejection, persistence, and wire tests still run. CI's C++
|
||||
conformance job requires the native test. See `docs/api-dotnet.md` for ownership
|
||||
and certificate acceptance requirements.
|
||||
|
||||
## Next checkpoint
|
||||
|
||||
Port codec/DSP wrappers and their native packaging per Phase 3 of the porting plan.
|
||||
The managed server follows, tested first with the existing C++ CLI.
|
||||
@@ -0,0 +1,9 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
|
||||
<Project Path="src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/VoiceCat.Tests/VoiceCat.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -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."
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"sdk": { "version": "10.0.203", "rollForward": "latestFeature" }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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)
|
||||
|
||||
add_executable(voicecat-dotnet-tls-oracle tls.cpp)
|
||||
target_link_libraries(voicecat-dotnet-tls-oracle PRIVATE voicecat::voicecat)
|
||||
target_include_directories(voicecat-dotnet-tls-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
|
||||
target_compile_features(voicecat-dotnet-tls-oracle PRIVATE cxx_std_20)
|
||||
@@ -0,0 +1,56 @@
|
||||
#include "crypto/crypto.h"
|
||||
#include "net/voice_frame.h"
|
||||
#include "protocol/envelope.h"
|
||||
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
|
||||
static std::string hex(const std::vector<uint8_t>& 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<uint8_t> framed;
|
||||
if (!voicecat::protocol::encode_envelope(envelope, framed)) return 1;
|
||||
output << "{\n \"envelope\": \"" << hex(framed) << "\",\n \"media\": [\n";
|
||||
std::array<uint8_t, 32> 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<uint8_t> plaintext(length);
|
||||
for (size_t i = 0; i < length; ++i) plaintext[i] = uint8_t(i);
|
||||
std::vector<uint8_t> 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<uint8_t>(key.begin(), key.end()))
|
||||
<< "\", \"plaintext\": \"" << hex(plaintext)
|
||||
<< "\", \"packet\": \"" << hex(packet) << "\"}";
|
||||
}
|
||||
}
|
||||
output << "\n ]\n}\n";
|
||||
return output ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
#ifdef _WIN32
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
using socket_type = SOCKET;
|
||||
static void close_socket(socket_type socket) { closesocket(socket); }
|
||||
#else
|
||||
#include <arpa/inet.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
using socket_type = int;
|
||||
static void close_socket(socket_type socket) { close(socket); }
|
||||
#endif
|
||||
|
||||
#include "crypto/crypto.h"
|
||||
#include "net/voice_frame.h"
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
|
||||
static bool transfer(voicecat::crypto::TlsContext& tls, uint8_t* data, size_t size, bool writing) {
|
||||
while (size != 0) {
|
||||
int count = writing ? tls.write(data, size) : tls.read(data, size);
|
||||
if (count <= 0) return false;
|
||||
data += count;
|
||||
size -= count;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 2 || sodium_init() < 0) return 1;
|
||||
#ifdef _WIN32
|
||||
WSADATA data{};
|
||||
if (WSAStartup(MAKEWORD(2, 2), &data) != 0) return 1;
|
||||
#endif
|
||||
try {
|
||||
auto certificate = voicecat::crypto::ServerCert::generate("dotnet-tls-oracle");
|
||||
auto directory = std::filesystem::path(argv[1]);
|
||||
socket_type listener = socket(AF_INET, SOCK_STREAM, 0);
|
||||
sockaddr_in address{};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
if (bind(listener, reinterpret_cast<sockaddr*>(&address), sizeof(address)) != 0 || listen(listener, 1) != 0) return 1;
|
||||
socklen_t length = sizeof(address);
|
||||
if (getsockname(listener, reinterpret_cast<sockaddr*>(&address), &length) != 0) return 1;
|
||||
certificate.save(directory / "server.crt", directory / "server.key");
|
||||
voicecat::crypto::ServerIdentity::generate().save(directory / "identity.key");
|
||||
std::ofstream(directory / "port.txt") << ntohs(address.sin_port);
|
||||
socket_type peer = accept(listener, nullptr, nullptr);
|
||||
close_socket(listener);
|
||||
if (peer == static_cast<socket_type>(-1)) return 1;
|
||||
voicecat::crypto::TlsContext tls(voicecat::crypto::TlsContext::Role::Server, &certificate);
|
||||
tls.set_read_timeout(10000);
|
||||
std::string error;
|
||||
if (!tls.handshake(static_cast<int>(peer), error)) { std::cerr << error; return 1; }
|
||||
auto sender = voicecat::crypto::SodiumMediaCrypto::derive_send(tls, false);
|
||||
auto receiver = voicecat::crypto::SodiumMediaCrypto::derive_recv(tls, false);
|
||||
if (!sender || !receiver) return 1;
|
||||
voicecat::net::VoiceFrame header;
|
||||
header.ssrc = 42;
|
||||
header.seq = sender->peek_send_counter();
|
||||
std::array<uint8_t, 41> packet{};
|
||||
voicecat::net::serialize_header(header, packet.data());
|
||||
const std::array<uint8_t, 5> message{ 'h', 'e', 'l', 'l', 'o' };
|
||||
if (sender->seal(message.data(), message.size(), packet.data(), 20, packet.data() + 20, 21) != 21) return 1;
|
||||
if (!transfer(tls, packet.data(), packet.size(), true) || !transfer(tls, packet.data(), packet.size(), false)) return 1;
|
||||
std::array<uint8_t, 5> recovered{};
|
||||
if (receiver->open(packet.data() + 20, 21, packet.data(), 20, recovered.data(), recovered.size()) != 5 || recovered != message) return 1;
|
||||
uint8_t acknowledgement = 1;
|
||||
if (!transfer(tls, &acknowledgement, 1, true)) return 1;
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -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<byte> 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<byte> plaintext, ReadOnlySpan<byte> aad, Span<byte> output)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
Span<byte> 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<byte> sealedPayload, ReadOnlySpan<byte> aad, Span<byte> output)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
Span<byte> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<byte> key) : this(key, false) { }
|
||||
|
||||
internal MediaDecryptor(ReadOnlySpan<byte> key, bool useManaged) => cipher = new(key, useManaged);
|
||||
|
||||
public bool TryDecrypt(ReadOnlySpan<byte> packet, Span<byte> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<byte> key) : this(key, false) { }
|
||||
|
||||
internal MediaEncryptor(ReadOnlySpan<byte> key, bool useManaged, ulong initialSequence = 0)
|
||||
{
|
||||
cipher = new(key, useManaged);
|
||||
nextSequence = initialSequence;
|
||||
}
|
||||
|
||||
public int Encrypt(VoiceFrameHeader header, ReadOnlySpan<byte> plaintext, Span<byte> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace VoiceCat.Crypto;
|
||||
|
||||
internal static class PrivateFiles
|
||||
{
|
||||
public static void Write(string path, ReadOnlySpan<byte> data)
|
||||
{
|
||||
string destination = Path.GetFullPath(path);
|
||||
string temporary = destination + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
|
||||
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
|
||||
using (var stream = new FileStream(temporary, options))
|
||||
{
|
||||
stream.Write(data);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
File.Move(temporary, destination, overwrite: true);
|
||||
}
|
||||
finally { if (File.Exists(temporary)) File.Delete(temporary); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
|
||||
namespace VoiceCat.Crypto;
|
||||
|
||||
public sealed class ServerCredentials : IDisposable
|
||||
{
|
||||
private readonly X509Certificate2 certificate;
|
||||
private bool disposed;
|
||||
|
||||
private ServerCredentials(ServerIdentity identity, X509Certificate2 certificate)
|
||||
{
|
||||
Identity = identity;
|
||||
this.certificate = certificate;
|
||||
}
|
||||
|
||||
public ServerIdentity Identity { get; }
|
||||
public string CertificateFingerprint => Convert.ToHexString(SHA256.HashData(certificate.RawData));
|
||||
|
||||
public static ServerCredentials LoadOrCreate(string directory, string serverName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
|
||||
Directory.CreateDirectory(directory);
|
||||
string identityPath = Path.Combine(directory, "identity.key");
|
||||
string certificatePath = Path.Combine(directory, "server.crt");
|
||||
string keyPath = Path.Combine(directory, "server.key");
|
||||
bool hasIdentity = File.Exists(identityPath);
|
||||
bool hasCertificate = File.Exists(certificatePath);
|
||||
bool hasKey = File.Exists(keyPath);
|
||||
if (hasIdentity && hasCertificate && hasKey)
|
||||
{
|
||||
var identity = ServerIdentity.Load(identityPath);
|
||||
try { return new(identity, X509Certificate2.CreateFromPemFile(certificatePath, keyPath)); }
|
||||
catch { identity.Dispose(); throw; }
|
||||
}
|
||||
if (hasIdentity || hasCertificate || hasKey)
|
||||
throw new InvalidDataException("Server credentials are incomplete; restore the missing files before starting.");
|
||||
var generated = ServerIdentity.Generate();
|
||||
try
|
||||
{
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var name = new X500DistinguishedNameBuilder();
|
||||
name.AddCommonName(serverName);
|
||||
var request = new CertificateRequest(name.Build(), key, HashAlgorithmName.SHA256);
|
||||
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true));
|
||||
var san = new SubjectAlternativeNameBuilder();
|
||||
san.AddUri(new Uri("urn:voicecat:identity:ed25519:" + Convert.ToHexString(generated.PublicKey).ToLowerInvariant()));
|
||||
request.CertificateExtensions.Add(san.Build());
|
||||
using var created = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddYears(10));
|
||||
string certificatePem = created.ExportCertificatePem();
|
||||
string privateKeyPem = key.ExportPkcs8PrivateKeyPem();
|
||||
generated.Save(identityPath);
|
||||
PrivateFiles.Write(certificatePath, Encoding.UTF8.GetBytes(certificatePem));
|
||||
PrivateFiles.Write(keyPath, Encoding.UTF8.GetBytes(privateKeyPem));
|
||||
return new(generated, X509Certificate2.CreateFromPem(certificatePem, privateKeyPem));
|
||||
}
|
||||
catch { generated.Dispose(); throw; }
|
||||
}
|
||||
|
||||
public TlsSession CreateTlsSession()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
using var key = certificate.GetECDsaPrivateKey() ?? throw new InvalidDataException("Server TLS certificate requires an ECDSA key.");
|
||||
return TlsSession.CreateServer(certificate.ExportCertificatePem(), key.ExportPkcs8PrivateKeyPem());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
Identity.Dispose();
|
||||
certificate.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Security.Cryptography;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
|
||||
namespace VoiceCat.Crypto;
|
||||
|
||||
public sealed class ServerIdentity : IDisposable
|
||||
{
|
||||
private readonly byte[] seed;
|
||||
private readonly byte[] publicKey;
|
||||
private bool disposed;
|
||||
|
||||
private ServerIdentity(byte[] seed)
|
||||
{
|
||||
this.seed = seed;
|
||||
publicKey = new Ed25519PrivateKeyParameters(seed, 0).GeneratePublicKey().GetEncoded();
|
||||
}
|
||||
|
||||
public byte[] PublicKey => (byte[])publicKey.Clone();
|
||||
public string Fingerprint => Convert.ToHexString(SHA256.HashData(publicKey));
|
||||
|
||||
public static ServerIdentity Generate() => new(RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
public static ServerIdentity Load(string path)
|
||||
{
|
||||
byte[] data = File.ReadAllBytes(path);
|
||||
try
|
||||
{
|
||||
if (data.Length != 96) throw new InvalidDataException("Server identity must contain 96 bytes.");
|
||||
var identity = new ServerIdentity(data.AsSpan(32, 32).ToArray());
|
||||
if (!CryptographicOperations.FixedTimeEquals(identity.publicKey, data.AsSpan(0, 32)) ||
|
||||
!CryptographicOperations.FixedTimeEquals(identity.publicKey, data.AsSpan(64, 32)))
|
||||
{
|
||||
identity.Dispose();
|
||||
throw new InvalidDataException("Server identity public key does not match its seed.");
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
finally { CryptographicOperations.ZeroMemory(data); }
|
||||
}
|
||||
|
||||
public void Save(string path)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
byte[] data = new byte[96];
|
||||
publicKey.CopyTo(data, 0);
|
||||
seed.CopyTo(data, 32);
|
||||
publicKey.CopyTo(data, 64);
|
||||
try { PrivateFiles.Write(path, data); }
|
||||
finally { CryptographicOperations.ZeroMemory(data); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
CryptographicOperations.ZeroMemory(seed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using System.Security.Cryptography;
|
||||
using Org.BouncyCastle.Crypto;
|
||||
using Org.BouncyCastle.OpenSsl;
|
||||
using Org.BouncyCastle.Tls;
|
||||
using Org.BouncyCastle.Tls.Crypto;
|
||||
using Org.BouncyCastle.Tls.Crypto.Impl.BC;
|
||||
|
||||
namespace VoiceCat.Crypto;
|
||||
|
||||
public sealed class TlsSession : IDisposable
|
||||
{
|
||||
private readonly TlsProtocol protocol;
|
||||
private readonly bool isClient;
|
||||
private readonly byte[] scratch = new byte[16384];
|
||||
private byte[]? clientToServerKey;
|
||||
private byte[]? serverToClientKey;
|
||||
private bool disposed;
|
||||
|
||||
private TlsSession(TlsProtocol protocol, bool isClient)
|
||||
{
|
||||
this.protocol = protocol;
|
||||
this.isClient = isClient;
|
||||
}
|
||||
|
||||
public bool IsReady => !disposed && clientToServerKey is not null && serverToClientKey is not null && !protocol.IsClosed;
|
||||
public string? PeerCertificateFingerprint { get; private set; }
|
||||
public int PendingCiphertextBytes => protocol.GetAvailableOutputBytes();
|
||||
|
||||
public void Close()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
protocol.Close();
|
||||
}
|
||||
|
||||
public void CompleteInput()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
protocol.CloseInput();
|
||||
}
|
||||
|
||||
public static TlsSession CreateClient(Func<string, bool> acceptCertificate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(acceptCertificate);
|
||||
var protocol = new TlsClientProtocol();
|
||||
var session = new TlsSession(protocol, true);
|
||||
protocol.Connect(new ClientPeer(session, acceptCertificate));
|
||||
return session;
|
||||
}
|
||||
|
||||
public static TlsSession CreateServer(string certificatePem, string privateKeyPem)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(certificatePem);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(privateKeyPem);
|
||||
var protocol = new TlsServerProtocol();
|
||||
var session = new TlsSession(protocol, false);
|
||||
protocol.Accept(new ServerPeer(session, certificatePem, privateKeyPem));
|
||||
return session;
|
||||
}
|
||||
|
||||
public void ReceiveCiphertext(ReadOnlySpan<byte> input)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
while (!input.IsEmpty)
|
||||
{
|
||||
int count = Math.Min(input.Length, scratch.Length);
|
||||
input[..count].CopyTo(scratch);
|
||||
protocol.OfferInput(scratch, 0, count);
|
||||
input = input[count..];
|
||||
}
|
||||
}
|
||||
|
||||
public int DrainCiphertext(Span<byte> output)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
int count = protocol.ReadOutput(scratch, 0, Math.Min(output.Length, scratch.Length));
|
||||
scratch.AsSpan(0, count).CopyTo(output);
|
||||
return count;
|
||||
}
|
||||
|
||||
public int ReadPlaintext(Span<byte> output)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
int count = protocol.ReadInput(scratch, 0, Math.Min(output.Length, scratch.Length));
|
||||
scratch.AsSpan(0, count).CopyTo(output);
|
||||
CryptographicOperations.ZeroMemory(scratch.AsSpan(0, count));
|
||||
return count;
|
||||
}
|
||||
|
||||
public void WritePlaintext(ReadOnlySpan<byte> input)
|
||||
{
|
||||
RequireReady();
|
||||
protocol.WriteApplicationData(input);
|
||||
}
|
||||
|
||||
public MediaEncryptor CreateMediaEncryptor()
|
||||
{
|
||||
byte[] key = ExportMediaKey(isClient ? (byte)0 : (byte)1);
|
||||
try { return new(key); }
|
||||
finally { CryptographicOperations.ZeroMemory(key); }
|
||||
}
|
||||
|
||||
public MediaDecryptor CreateMediaDecryptor()
|
||||
{
|
||||
byte[] key = ExportMediaKey(isClient ? (byte)1 : (byte)0);
|
||||
try { return new(key); }
|
||||
finally { CryptographicOperations.ZeroMemory(key); }
|
||||
}
|
||||
|
||||
internal byte[] ExportMediaKey(byte direction)
|
||||
{
|
||||
RequireReady();
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThan(direction, (byte)1);
|
||||
return (byte[])(direction == 0 ? clientToServerKey! : serverToClientKey!).Clone();
|
||||
}
|
||||
|
||||
private void CompleteHandshake(TlsContext context)
|
||||
{
|
||||
// BouncyCastle destroys exporter secrets after this callback returns.
|
||||
clientToServerKey = context.ExportKeyingMaterial("voicecat media v1", [0], 32);
|
||||
serverToClientKey = context.ExportKeyingMaterial("voicecat media v1", [1], 32);
|
||||
}
|
||||
|
||||
private void RequireReady()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
if (!IsReady) throw new InvalidOperationException("TLS handshake has not completed or the session is closed.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
try { protocol.Close(); }
|
||||
finally
|
||||
{
|
||||
if (clientToServerKey is not null) CryptographicOperations.ZeroMemory(clientToServerKey);
|
||||
if (serverToClientKey is not null) CryptographicOperations.ZeroMemory(serverToClientKey);
|
||||
CryptographicOperations.ZeroMemory(scratch);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ClientPeer(TlsSession session, Func<string, bool> acceptCertificate)
|
||||
: DefaultTlsClient(new BcTlsCrypto())
|
||||
{
|
||||
protected override ProtocolVersion[] GetSupportedVersions() => [ProtocolVersion.TLSv13];
|
||||
protected override int[] GetSupportedCipherSuites() => CipherSuites;
|
||||
public override TlsAuthentication GetAuthentication() => new Authentication(session, acceptCertificate);
|
||||
public override void NotifyHandshakeComplete()
|
||||
{
|
||||
base.NotifyHandshakeComplete();
|
||||
session.CompleteHandshake(m_context);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Authentication(TlsSession session, Func<string, bool> acceptCertificate) : TlsAuthentication
|
||||
{
|
||||
public void NotifyServerCertificate(TlsServerCertificate serverCertificate)
|
||||
{
|
||||
var chain = serverCertificate.Certificate.GetCertificateList();
|
||||
if (chain.Length == 0) throw new TlsFatalAlert(AlertDescription.bad_certificate);
|
||||
string fingerprint = Convert.ToHexString(SHA256.HashData(chain[0].GetEncoded()));
|
||||
session.PeerCertificateFingerprint = fingerprint;
|
||||
if (!acceptCertificate(fingerprint)) throw new TlsFatalAlert(AlertDescription.bad_certificate);
|
||||
}
|
||||
|
||||
public TlsCredentials? GetClientCredentials(Org.BouncyCastle.Tls.CertificateRequest certificateRequest) => null;
|
||||
}
|
||||
|
||||
private sealed class ServerPeer : DefaultTlsServer
|
||||
{
|
||||
private readonly TlsSession session;
|
||||
private readonly byte[] certificateDer;
|
||||
private readonly AsymmetricKeyParameter privateKey;
|
||||
|
||||
public ServerPeer(TlsSession session, string certificatePem, string privateKeyPem) : base(new BcTlsCrypto())
|
||||
{
|
||||
this.session = session;
|
||||
using var certificate = System.Security.Cryptography.X509Certificates.X509Certificate2.CreateFromPem(certificatePem);
|
||||
certificateDer = certificate.RawData;
|
||||
using var reader = new StringReader(privateKeyPem);
|
||||
privateKey = (AsymmetricKeyParameter)new PemReader(reader).ReadObject();
|
||||
if (privateKey is not Org.BouncyCastle.Crypto.Parameters.ECPrivateKeyParameters)
|
||||
throw new ArgumentException("Server TLS credentials require an ECDSA key.", nameof(privateKeyPem));
|
||||
}
|
||||
|
||||
protected override ProtocolVersion[] GetSupportedVersions() => [ProtocolVersion.TLSv13];
|
||||
protected override int[] GetSupportedCipherSuites() => CipherSuites;
|
||||
public override TlsCredentials GetCredentials()
|
||||
{
|
||||
var certificate = new Certificate([], [new CertificateEntry(Crypto.CreateCertificate(certificateDer), null)]);
|
||||
return new BcDefaultTlsCredentialedSigner(new TlsCryptoParameters(m_context), (BcTlsCrypto)Crypto,
|
||||
privateKey, certificate, new SignatureAndHashAlgorithm(Org.BouncyCastle.Tls.HashAlgorithm.sha256, SignatureAlgorithm.ecdsa));
|
||||
}
|
||||
|
||||
public override void NotifyHandshakeComplete()
|
||||
{
|
||||
base.NotifyHandshakeComplete();
|
||||
session.CompleteHandshake(m_context);
|
||||
}
|
||||
}
|
||||
|
||||
private static int[] CipherSuites =>
|
||||
[
|
||||
CipherSuite.TLS_AES_128_GCM_SHA256,
|
||||
CipherSuite.TLS_AES_256_GCM_SHA384,
|
||||
CipherSuite.TLS_CHACHA20_POLY1305_SHA256
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Text;
|
||||
|
||||
namespace VoiceCat.Crypto;
|
||||
|
||||
public enum TofuStatus { FirstConnect, Matched, Mismatch }
|
||||
|
||||
public sealed class TofuStore
|
||||
{
|
||||
private readonly string path;
|
||||
private readonly Dictionary<string, string> pins = new(StringComparer.Ordinal);
|
||||
|
||||
public TofuStore(string path)
|
||||
{
|
||||
this.path = Path.GetFullPath(path);
|
||||
if (!File.Exists(this.path)) return;
|
||||
foreach (string line in File.ReadLines(this.path))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#')) continue;
|
||||
string[] parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 2) throw new InvalidDataException("Malformed TOFU pin entry.");
|
||||
pins[parts[0]] = NormalizeFingerprint(parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
public TofuStatus Check(string host, ushort port, string fingerprint)
|
||||
{
|
||||
string key = Endpoint(host, port);
|
||||
string normalized = NormalizeFingerprint(fingerprint);
|
||||
return !pins.TryGetValue(key, out var pin) ? TofuStatus.FirstConnect :
|
||||
pin == normalized ? TofuStatus.Matched : TofuStatus.Mismatch;
|
||||
}
|
||||
|
||||
public void Pin(string host, ushort port, string fingerprint)
|
||||
{
|
||||
string key = Endpoint(host, port);
|
||||
string value = NormalizeFingerprint(fingerprint);
|
||||
var updated = new Dictionary<string, string>(pins, StringComparer.Ordinal) { [key] = value };
|
||||
Save(updated);
|
||||
pins[key] = value;
|
||||
}
|
||||
|
||||
public void Remove(string host, ushort port)
|
||||
{
|
||||
string key = Endpoint(host, port);
|
||||
var updated = new Dictionary<string, string>(pins, StringComparer.Ordinal);
|
||||
updated.Remove(key);
|
||||
Save(updated);
|
||||
pins.Remove(key);
|
||||
}
|
||||
|
||||
private void Save(Dictionary<string, string> updated)
|
||||
{
|
||||
string contents = string.Concat(updated.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key} {pair.Value}\n"));
|
||||
PrivateFiles.Write(path, Encoding.UTF8.GetBytes(contents));
|
||||
}
|
||||
|
||||
private static string Endpoint(string host, ushort port)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(host);
|
||||
if (host.Any(char.IsWhiteSpace)) throw new ArgumentException("Host cannot contain whitespace.", nameof(host));
|
||||
ArgumentOutOfRangeException.ThrowIfZero(port);
|
||||
return $"{host}:{port}";
|
||||
}
|
||||
|
||||
private static string NormalizeFingerprint(string fingerprint)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fingerprint);
|
||||
if (fingerprint.Length != 64 || !fingerprint.All(Uri.IsHexDigit))
|
||||
throw new InvalidDataException("TLS certificate fingerprints must contain 64 hexadecimal characters.");
|
||||
return fingerprint.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="VoiceCat.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<byte> input, out ReadOnlySequence<byte> payload)
|
||||
{
|
||||
payload = default;
|
||||
if (input.Length < 4) return false;
|
||||
Span<byte> 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<byte> output, ReadOnlySpan<byte> 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<byte> 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<Envelope> 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<bool> ReadExactlyAsync(PipeReader reader, Memory<byte> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Google.Protobuf" Version="3.36.1" />
|
||||
<PackageReference Include="Grpc.Tools" Version="2.83.0" PrivateAssets="all" />
|
||||
<Protobuf Include="../../../core/proto/voicecat.proto" GrpcServices="None" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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<byte> 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<byte> 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;
|
||||
}
|
||||
}
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -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<byte>();
|
||||
ControlFraming.WriteFrame(output, payload);
|
||||
var input = new ReadOnlySequence<byte>(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<byte>(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<byte>(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<byte>(new byte[] { 1, 0, 0, 1 });
|
||||
Assert.Throws<InvalidDataException>(() => ControlFraming.TryReadFrame(ref input, out _));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => ControlFraming.WriteFrame(new ArrayBufferWriter<byte>(), 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<Envelope>();
|
||||
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<InvalidDataException>(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<InvalidProtocolBufferException>(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<OperationCanceledException>(() => 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<byte>();
|
||||
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<Envelope>();
|
||||
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<byte>
|
||||
{
|
||||
public Segment(ReadOnlyMemory<byte> memory) => Memory = memory;
|
||||
|
||||
public Segment Append(ReadOnlyMemory<byte> memory)
|
||||
{
|
||||
var segment = new Segment(memory) { RunningIndex = RunningIndex + Memory.Length };
|
||||
Next = segment;
|
||||
return segment;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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<byte>();
|
||||
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")));
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Formats.Asn1;
|
||||
using VoiceCat.Crypto;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class IdentityTests
|
||||
{
|
||||
[Fact]
|
||||
public void CredentialsSurviveRestartAndBindIdentityIntoCertificate()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-credentials-" + Guid.NewGuid());
|
||||
try
|
||||
{
|
||||
string identityFingerprint, certificateFingerprint;
|
||||
using (var credentials = ServerCredentials.LoadOrCreate(directory, "Server, with punctuation"))
|
||||
{
|
||||
identityFingerprint = credentials.Identity.Fingerprint;
|
||||
certificateFingerprint = credentials.CertificateFingerprint;
|
||||
using var tls = credentials.CreateTlsSession();
|
||||
Assert.False(tls.IsReady);
|
||||
byte[] identity = File.ReadAllBytes(Path.Combine(directory, "identity.key"));
|
||||
Assert.Equal(96, identity.Length);
|
||||
Assert.Equal(identity[..32], identity[64..]);
|
||||
using var certificate = X509Certificate2.CreateFromPem(File.ReadAllText(Path.Combine(directory, "server.crt")));
|
||||
var san = new AsnReader(certificate.Extensions["2.5.29.17"]!.RawData, AsnEncodingRules.DER).ReadSequence();
|
||||
Assert.Equal("urn:voicecat:identity:ed25519:" + Convert.ToHexString(credentials.Identity.PublicKey).ToLowerInvariant(),
|
||||
san.ReadCharacterString(UniversalTagNumber.IA5String, new Asn1Tag(TagClass.ContextSpecific, 6)));
|
||||
Assert.False(san.HasData);
|
||||
}
|
||||
using var restored = ServerCredentials.LoadOrCreate(directory, "ignored after creation");
|
||||
Assert.Equal(identityFingerprint, restored.Identity.Fingerprint);
|
||||
Assert.Equal(certificateFingerprint, restored.CertificateFingerprint);
|
||||
File.Delete(Path.Combine(directory, "server.key"));
|
||||
Assert.Throws<InvalidDataException>(() => ServerCredentials.LoadOrCreate(directory, "unchanged"));
|
||||
using var stillPresent = ServerIdentity.Load(Path.Combine(directory, "identity.key"));
|
||||
Assert.Equal(identityFingerprint, stillPresent.Fingerprint);
|
||||
}
|
||||
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TofuRequiresExplicitPinAndPreservesCppFileFormat()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-pins-" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(directory);
|
||||
string path = Path.Combine(directory, "pins.txt");
|
||||
string fingerprint = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
||||
try
|
||||
{
|
||||
var store = new TofuStore(path);
|
||||
Assert.Equal(TofuStatus.FirstConnect, store.Check("localhost", 9987, fingerprint));
|
||||
Assert.False(File.Exists(path));
|
||||
store.Pin("localhost", 9987, fingerprint);
|
||||
Assert.Equal($"localhost:9987 {fingerprint.ToLowerInvariant()}\n", File.ReadAllText(path));
|
||||
store = new(path);
|
||||
Assert.Equal(TofuStatus.Matched, store.Check("localhost", 9987, fingerprint));
|
||||
Assert.Equal(TofuStatus.Mismatch, store.Check("localhost", 9987, new string('0', 64)));
|
||||
Assert.Equal(TofuStatus.Matched, new TofuStore(path).Check("localhost", 9987, fingerprint));
|
||||
store.Remove("localhost", 9987);
|
||||
Assert.Equal(TofuStatus.FirstConnect, new TofuStore(path).Check("localhost", 9987, fingerprint));
|
||||
File.WriteAllText(path, "localhost:9987 " + new string('g', 64));
|
||||
Assert.Throws<InvalidDataException>(() => new TofuStore(path));
|
||||
}
|
||||
finally { Directory.Delete(directory, true); }
|
||||
}
|
||||
}
|
||||
@@ -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<InvalidOperationException>(() => Seal(sender, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidArgumentsAndDisposedInstancesAreRejected()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new MediaEncryptor(new byte[31]));
|
||||
using var sender = new MediaEncryptor(Key);
|
||||
using var receiver = new MediaDecryptor(Key);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => 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<ArgumentOutOfRangeException>(() => receiver.TryDecrypt(packet, [], out _, out _));
|
||||
sender.Dispose();
|
||||
receiver.Dispose();
|
||||
Assert.Throws<ObjectDisposedException>(() => Seal(sender, []));
|
||||
Assert.Throws<ObjectDisposedException>(() => 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Protocol;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class TlsInteropTests
|
||||
{
|
||||
[TlsOracleFact]
|
||||
public async Task ManagedClientAndCppServerAgreeOnExporterKeysAndCertificate()
|
||||
{
|
||||
string? oracle = Environment.GetEnvironmentVariable("VOICECAT_TLS_ORACLE");
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-tls-" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(directory);
|
||||
var start = new ProcessStartInfo(oracle!) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true };
|
||||
start.ArgumentList.Add(directory);
|
||||
using var process = Process.Start(start)!;
|
||||
var error = process.StandardError.ReadToEndAsync();
|
||||
var stdout = process.StandardOutput.ReadToEndAsync();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
try
|
||||
{
|
||||
int port = 0;
|
||||
while (!int.TryParse(File.Exists(Path.Combine(directory, "port.txt")) ? await File.ReadAllTextAsync(Path.Combine(directory, "port.txt"), timeout.Token) : "", out port))
|
||||
{
|
||||
Assert.False(process.HasExited, "C++ TLS oracle exited before listening.");
|
||||
await Task.Delay(20, timeout.Token);
|
||||
}
|
||||
using var certificate = X509Certificate2.CreateFromPem(await File.ReadAllTextAsync(Path.Combine(directory, "server.crt"), timeout.Token));
|
||||
string fingerprint = Convert.ToHexString(SHA256.HashData(certificate.RawData));
|
||||
using var credentials = ServerCredentials.LoadOrCreate(directory, "existing C++ identity");
|
||||
Assert.Equal(fingerprint, credentials.CertificateFingerprint);
|
||||
Assert.Equal(32, credentials.Identity.PublicKey.Length);
|
||||
using var client = TlsSession.CreateClient(value => value == fingerprint);
|
||||
using var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync("127.0.0.1", port, timeout.Token);
|
||||
byte[] buffer = new byte[16384];
|
||||
async Task Flush()
|
||||
{
|
||||
while (client.PendingCiphertextBytes > 0)
|
||||
{
|
||||
int count = client.DrainCiphertext(buffer);
|
||||
int sent = 0;
|
||||
while (sent < count) sent += await socket.SendAsync(buffer.AsMemory(sent, count - sent), SocketFlags.None, timeout.Token);
|
||||
}
|
||||
}
|
||||
async Task Receive()
|
||||
{
|
||||
int count = await socket.ReceiveAsync(buffer, SocketFlags.None, timeout.Token);
|
||||
Assert.True(count > 0, "TLS oracle closed unexpectedly.");
|
||||
client.ReceiveCiphertext(buffer.AsSpan(0, count));
|
||||
}
|
||||
while (!client.IsReady) { await Flush(); await Receive(); }
|
||||
await Flush();
|
||||
byte[] packet = new byte[41];
|
||||
int received = 0;
|
||||
while (received < packet.Length)
|
||||
{
|
||||
int count = client.ReadPlaintext(packet.AsSpan(received));
|
||||
received += count;
|
||||
if (count == 0) { await Flush(); await Receive(); }
|
||||
}
|
||||
using var decryptor = client.CreateMediaDecryptor();
|
||||
byte[] plaintext = new byte[5];
|
||||
Assert.True(decryptor.TryDecrypt(packet, plaintext, out var header, out _));
|
||||
Assert.Equal("hello"u8.ToArray(), plaintext);
|
||||
Assert.Equal(fingerprint, client.PeerCertificateFingerprint);
|
||||
using var encryptor = client.CreateMediaEncryptor();
|
||||
encryptor.Encrypt(header, plaintext, packet);
|
||||
client.WritePlaintext(packet);
|
||||
await Flush();
|
||||
byte[] ack = new byte[1];
|
||||
while (client.ReadPlaintext(ack) == 0) { await Flush(); await Receive(); }
|
||||
Assert.Equal(1, ack[0]);
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
Assert.True(process.ExitCode == 0, await error);
|
||||
await stdout;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!process.HasExited) { process.Kill(entireProcessTree: true); await process.WaitForExitAsync(); }
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TlsOracleFactAttribute : FactAttribute
|
||||
{
|
||||
public TlsOracleFactAttribute()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_TLS_ORACLE")))
|
||||
Skip = "Build the native TLS oracle and set VOICECAT_TLS_ORACLE to its executable path.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Protocol;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class TlsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ManagedTlsHandshakeExportsMatchingDirectionalKeys()
|
||||
{
|
||||
var (pem, key, fingerprint) = Credentials();
|
||||
using var server = TlsSession.CreateServer(pem, key);
|
||||
using var client = TlsSession.CreateClient(value => value == fingerprint);
|
||||
Assert.Throws<InvalidOperationException>(() => client.CreateMediaEncryptor());
|
||||
Handshake(client, server);
|
||||
Assert.Equal(fingerprint, client.PeerCertificateFingerprint);
|
||||
Assert.Equal(server.ExportMediaKey(0), client.ExportMediaKey(0));
|
||||
Assert.Equal(server.ExportMediaKey(1), client.ExportMediaKey(1));
|
||||
Assert.NotEqual(client.ExportMediaKey(0), client.ExportMediaKey(1));
|
||||
client.WritePlaintext("hello"u8);
|
||||
Pump(client, server);
|
||||
byte[] output = new byte[5];
|
||||
Assert.Equal(5, server.ReadPlaintext(output));
|
||||
Assert.Equal("hello"u8.ToArray(), output);
|
||||
using var encryptor = server.CreateMediaEncryptor();
|
||||
using var decryptor = client.CreateMediaDecryptor();
|
||||
byte[] packet = new byte[41];
|
||||
encryptor.Encrypt(new(MediaFrameType.Voice, 0, 0, 42, 0, 960), "hello"u8, packet);
|
||||
Assert.True(decryptor.TryDecrypt(packet, output, out _, out _));
|
||||
Assert.Equal("hello"u8.ToArray(), output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CertificateRejectionPreventsApplicationDataAndMediaKeys()
|
||||
{
|
||||
var (pem, key, _) = Credentials();
|
||||
using var server = TlsSession.CreateServer(pem, key);
|
||||
using var client = TlsSession.CreateClient(_ => false);
|
||||
Assert.ThrowsAny<IOException>(() => Handshake(client, server));
|
||||
Assert.False(client.IsReady);
|
||||
Assert.Throws<InvalidOperationException>(() => client.CreateMediaDecryptor());
|
||||
Assert.Throws<InvalidOperationException>(() => client.WritePlaintext("secret"u8));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseNotifyEndsSessionAndAbruptEofIsRejected()
|
||||
{
|
||||
var (pem, key, fingerprint) = Credentials();
|
||||
using var server = TlsSession.CreateServer(pem, key);
|
||||
using var client = TlsSession.CreateClient(value => value == fingerprint);
|
||||
Handshake(client, server);
|
||||
client.Close();
|
||||
Pump(client, server);
|
||||
Assert.False(client.IsReady);
|
||||
Assert.False(server.IsReady);
|
||||
server.CompleteInput();
|
||||
using var incomplete = TlsSession.CreateClient(_ => true);
|
||||
Assert.ThrowsAny<IOException>(() => incomplete.CompleteInput());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TlsTwelveCannotNegotiateWithManagedServer()
|
||||
{
|
||||
var (pem, key, _) = Credentials();
|
||||
using var server = TlsSession.CreateServer(pem, key);
|
||||
var legacy = new Org.BouncyCastle.Tls.TlsClientProtocol();
|
||||
legacy.Connect(new LegacyPeer());
|
||||
byte[] hello = new byte[legacy.GetAvailableOutputBytes()];
|
||||
legacy.ReadOutput(hello, 0, hello.Length);
|
||||
Assert.ThrowsAny<IOException>(() => server.ReceiveCiphertext(hello));
|
||||
Assert.False(server.IsReady);
|
||||
Assert.Throws<InvalidOperationException>(() => server.CreateMediaEncryptor());
|
||||
}
|
||||
|
||||
private sealed class LegacyPeer() : Org.BouncyCastle.Tls.DefaultTlsClient(new Org.BouncyCastle.Tls.Crypto.Impl.BC.BcTlsCrypto())
|
||||
{
|
||||
protected override Org.BouncyCastle.Tls.ProtocolVersion[] GetSupportedVersions() => [Org.BouncyCastle.Tls.ProtocolVersion.TLSv12];
|
||||
public override Org.BouncyCastle.Tls.TlsAuthentication GetAuthentication() => throw new InvalidOperationException("TLS 1.2 must be rejected before authentication.");
|
||||
}
|
||||
|
||||
internal static (string Certificate, string Key, string Fingerprint) Credentials()
|
||||
{
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var request = new System.Security.Cryptography.X509Certificates.CertificateRequest("CN=VoiceCat TLS test", key, HashAlgorithmName.SHA256);
|
||||
using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(1));
|
||||
return (certificate.ExportCertificatePem(), key.ExportPkcs8PrivateKeyPem(), Convert.ToHexString(SHA256.HashData(certificate.RawData)));
|
||||
}
|
||||
|
||||
internal static void Handshake(TlsSession client, TlsSession server)
|
||||
{
|
||||
for (int i = 0; i < 100 && (!client.IsReady || !server.IsReady); i++)
|
||||
{
|
||||
Pump(client, server);
|
||||
Pump(server, client);
|
||||
}
|
||||
Assert.True(client.IsReady);
|
||||
Assert.True(server.IsReady);
|
||||
}
|
||||
|
||||
private static void Pump(TlsSession sender, TlsSession receiver)
|
||||
{
|
||||
byte[] buffer = new byte[17];
|
||||
while (sender.PendingCiphertextBytes > 0)
|
||||
{
|
||||
int count = sender.DrainCiphertext(buffer);
|
||||
receiver.ReceiveCiphertext(buffer.AsSpan(0, count));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using VoiceCat.Crypto;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class TofuTlsTests
|
||||
{
|
||||
[Fact]
|
||||
public void RealHandshakesRequireAcceptanceAndRejectChangedCertificatesAfterRestart()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-tofu-tls-" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(directory);
|
||||
string path = Path.Combine(directory, "pins.txt");
|
||||
try
|
||||
{
|
||||
using var credentials = ServerCredentials.LoadOrCreate(Path.Combine(directory, "server"), "server");
|
||||
var store = new TofuStore(path);
|
||||
using (var server = credentials.CreateTlsSession())
|
||||
using (var rejected = TlsSession.CreateClient(fingerprint =>
|
||||
{
|
||||
Assert.Equal(TofuStatus.FirstConnect, store.Check("localhost", 9987, fingerprint));
|
||||
return false;
|
||||
}))
|
||||
Assert.ThrowsAny<IOException>(() => TlsTests.Handshake(rejected, server));
|
||||
Assert.False(File.Exists(path));
|
||||
using (var server = credentials.CreateTlsSession())
|
||||
using (var accepted = TlsSession.CreateClient(fingerprint =>
|
||||
{
|
||||
Assert.Equal(TofuStatus.FirstConnect, store.Check("localhost", 9987, fingerprint));
|
||||
store.Pin("localhost", 9987, fingerprint);
|
||||
return true;
|
||||
}))
|
||||
TlsTests.Handshake(accepted, server);
|
||||
store = new(path);
|
||||
using (var server = credentials.CreateTlsSession())
|
||||
using (var returning = TlsSession.CreateClient(fingerprint => store.Check("localhost", 9987, fingerprint) == TofuStatus.Matched))
|
||||
TlsTests.Handshake(returning, server);
|
||||
using var rotated = ServerCredentials.LoadOrCreate(Path.Combine(directory, "rotated"), "server");
|
||||
using (var server = rotated.CreateTlsSession())
|
||||
using (var mismatch = TlsSession.CreateClient(fingerprint =>
|
||||
{
|
||||
Assert.Equal(TofuStatus.Mismatch, store.Check("localhost", 9987, fingerprint));
|
||||
return false;
|
||||
}))
|
||||
Assert.ThrowsAny<IOException>(() => TlsTests.Handshake(mismatch, server));
|
||||
Assert.Equal(TofuStatus.Matched, new TofuStore(path).Check("localhost", 9987, credentials.CertificateFingerprint));
|
||||
}
|
||||
finally { Directory.Delete(directory, true); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1" PrivateAssets="all" />
|
||||
<ProjectReference Include="../../src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
|
||||
<ProjectReference Include="../../src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
|
||||
<Using Include="Xunit" />
|
||||
<None Update="Fixtures/*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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<ArgumentOutOfRangeException>(() => header.Write(new byte[19]));
|
||||
}
|
||||
}
|
||||
@@ -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, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user