Start .NET rewrite with wire and media crypto conformance
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
name: .NET port
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths: ['dotnet/**', 'core/**', 'server/**', 'tests/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml']
|
||||||
|
pull_request:
|
||||||
|
paths: ['dotnet/**', 'core/**', 'server/**', 'tests/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [windows-latest, ubuntu-24.04, macos-latest]
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
global-json-file: dotnet/global.json
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: dotnet/**/packages.lock.json
|
||||||
|
- run: dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||||
|
- run: dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
|
||||||
|
- run: dotnet test dotnet/VoiceCat.slnx -c Release --no-build
|
||||||
|
- shell: pwsh
|
||||||
|
run: ./dotnet/check-licenses.ps1
|
||||||
|
|
||||||
|
cpp-conformance:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: true
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
global-json-file: dotnet/global.json
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/vcpkg
|
||||||
|
key: dotnet-oracle-linux-${{ hashFiles('vcpkg.json', 'vcpkg') }}
|
||||||
|
- name: Install C++ build dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y build-essential cmake ninja-build curl zip unzip tar pkg-config autoconf autoconf-archive automake libtool nasm python3
|
||||||
|
./vcpkg/bootstrap-vcpkg.sh -disableMetrics
|
||||||
|
- name: Build and verify both implementations
|
||||||
|
run: |
|
||||||
|
cmake --preset dev -DVOICECAT_BUILD_DOTNET_ORACLE=ON
|
||||||
|
cmake --build --preset dev
|
||||||
|
ctest --preset dev
|
||||||
|
./build/dev/bin/voicecat-dotnet-oracle build/dev/cpp-wire.json
|
||||||
|
diff -u dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json build/dev/cpp-wire.json
|
||||||
|
dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||||
|
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
# Build output
|
# Build output
|
||||||
|
/dotnet/**/bin/
|
||||||
|
/dotnet/**/obj/
|
||||||
|
/dotnet/**/TestResults/
|
||||||
/build/
|
/build/
|
||||||
/out/
|
/out/
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ native clients (Swift on macOS/iOS, C# on Windows) and the server.
|
|||||||
|
|
||||||
## Build & test commands
|
## 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)
|
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
|
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.
|
[`docs/building.md`](docs/building.md) for the full preset matrix.
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ endif()
|
|||||||
# ── Targets ───────────────────────────────────────────────────────────────────
|
# ── Targets ───────────────────────────────────────────────────────────────────
|
||||||
add_subdirectory(core)
|
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)
|
if(VOICECAT_BUILD_SERVER)
|
||||||
add_subdirectory(server)
|
add_subdirectory(server)
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
+11
@@ -10,6 +10,17 @@ up instantly. Newest status at the top.
|
|||||||
|
|
||||||
## ▶ Where we left off / next action
|
## ▶ Where we left off / next action
|
||||||
|
|
||||||
|
- **Done (2026-09-15): Initial .NET wire/crypto port** on `dotnet/foundations`, from `cs-port`.
|
||||||
|
Added `dotnet/` solution, schema code generation, pipe framing, immutable voice headers,
|
||||||
|
directional media encryption/decryption, and xUnit conformance tests. Both platform
|
||||||
|
and managed crypto paths are tested. Added optional C++ fixture oracle, managed CI,
|
||||||
|
dependency lock files, license audit, and `docs/api-dotnet.md`. **Verified:** managed
|
||||||
|
Release build, 34/34 tests including C++ golden bytes, and 16 permissive package
|
||||||
|
licenses. Fresh `cmake --build --preset dev` and `ctest --preset dev` green (29/29);
|
||||||
|
regenerating the C++ fixtures produces identical bytes. Native codec/audio packaging
|
||||||
|
is deferred to its implementation phase. Next checkpoint: BouncyCastle TLS 1.3
|
||||||
|
exporter interoperability with the existing server.
|
||||||
|
|
||||||
- **Done (2026-07-23):** **First comment-density cleanup across core, server, and native
|
- **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
|
clients.** Condensed comments in the highest-noise audio, reconnect, registry, and binding
|
||||||
files; removed implementation history and narration; retained ABI ownership, threading,
|
files; removed implementation history and narration; retained ABI ownership, threading,
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Initial managed API contract
|
||||||
|
|
||||||
|
Status: initial port slice, API revision 1. No change to protobuf or media wire formats.
|
||||||
|
These are shared infrastructure APIs; the client-facing API follows with the client core.
|
||||||
|
|
||||||
|
## Protocol
|
||||||
|
|
||||||
|
`VoiceCat.Protocol` generates `Voicecat.V1` protobuf messages from the existing schema.
|
||||||
|
|
||||||
|
`ControlFraming.TryReadFrame(ref ReadOnlySequence<byte>, out ReadOnlySequence<byte>)`
|
||||||
|
extracts a payload and advances input only when a full frame exists. Returned memory
|
||||||
|
borrows the input's lifetime. Lengths above 16 MiB throw `InvalidDataException`.
|
||||||
|
Empty payloads are valid. `WriteFrame` and `WriteEnvelope` target `IBufferWriter<byte>`;
|
||||||
|
oversized outgoing payloads throw before output is written.
|
||||||
|
|
||||||
|
`ReadEnvelopesAsync(PipeReader, CancellationToken)` produces parsed envelopes and
|
||||||
|
advances consumed pipe data. It does not complete or dispose the caller's reader.
|
||||||
|
Clean EOF ends enumeration; partial EOF and oversized frames throw
|
||||||
|
`InvalidDataException`; malformed protobuf throws `InvalidProtocolBufferException`.
|
||||||
|
Cancellation propagates. A connection owner must close on protocol errors or
|
||||||
|
cancellation partway through a frame; partial frame bytes may already be consumed.
|
||||||
|
Fragments are consumed as they arrive so frames larger than pipe backpressure
|
||||||
|
thresholds make progress. Stopping enumeration between envelopes preserves the next frame.
|
||||||
|
|
||||||
|
`VoiceFrameHeader` is an immutable value with type, flags, codec, SSRC, sequence,
|
||||||
|
and timestamp. `Write(Span<byte>)` writes its 20-byte big-endian representation;
|
||||||
|
`TryRead` accepts at least 20 bytes and preserves unknown type/flag/codec values.
|
||||||
|
Higher layers decide which values they support.
|
||||||
|
|
||||||
|
## Media encryption
|
||||||
|
|
||||||
|
`MediaEncryptor` and `MediaDecryptor` each own one directional 32-byte session key
|
||||||
|
and mutable packet state. Use one owner at a time; they provide no synchronization.
|
||||||
|
Production constructs them from TLS exporter keys when TLS is implemented. Raw-key
|
||||||
|
constructors support conformance tests and the future TLS integration.
|
||||||
|
|
||||||
|
`MediaEncryptor.Encrypt(VoiceFrameHeader, ReadOnlySpan<byte>, Span<byte>)` writes
|
||||||
|
the full header plus ciphertext and 16-byte tag and returns packet length. It replaces
|
||||||
|
the supplied sequence with its own counter, starting at zero. Capacity and overlap
|
||||||
|
errors throw before reserving a counter. Reserved counters are never reused after
|
||||||
|
encryption failure. At `ulong.MaxValue`, encryption throws and requires a new session.
|
||||||
|
|
||||||
|
`MediaDecryptor.TryDecrypt(ReadOnlySpan<byte>, Span<byte>, out VoiceFrameHeader,
|
||||||
|
out int)` authenticates and decrypts a complete packet. Short packets, failed tags,
|
||||||
|
replays, and packets outside the 64-packet window return false with default header
|
||||||
|
and zero bytes written. Authentication failure clears the attempted plaintext region;
|
||||||
|
structural/replay rejection leaves storage untouched. Callers must only consume
|
||||||
|
output after success. Invalid storage capacity and overlapping buffers throw.
|
||||||
|
|
||||||
|
The nonce is four zero bytes plus the big-endian header counter. All 20 header bytes
|
||||||
|
are authenticated associated data. The replay window advances after authentication.
|
||||||
|
The platform ChaCha20-Poly1305 implementation is preferred; BouncyCastle is used when
|
||||||
|
platform support is absent. Both produce the same wire bytes. The fallback currently
|
||||||
|
allocates per packet; audio and relay allocation guarantees are later checkpoints.
|
||||||
|
|
||||||
|
Dispose both objects to clear their owned key arrays and release platform crypto
|
||||||
|
resources. Use after disposal throws `ObjectDisposedException`.
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# Architecture
|
# 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
|
## 1. The shared-core model
|
||||||
|
|
||||||
All non-UI logic lives in one C++ library, **`libvoicecat`**. The same library is linked
|
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
|
- **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
|
sender's channel and forwards the *unmodified encoded Opus bytes* to other members.
|
||||||
user id) to every other subscribed member. No server-side decode/transcode → low CPU,
|
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
|
low latency, and end-to-content is just Opus. Per-channel Opus params are enforced so all
|
||||||
members are mutually decodable.
|
members are mutually decodable.
|
||||||
- **Subscriptions.** Clients implicitly subscribe to their current channel's voice; text
|
- **Subscriptions.** Clients implicitly subscribe to their current channel's voice; text
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
# Building & Manual Testing
|
# 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
|
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 +
|
*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
|
`vccli` clients against each other for manual testing. For the one-paragraph quick-start see
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Porting VoiceCat to pure .NET / C#
|
# Porting VoiceCat to pure .NET / C#
|
||||||
|
|
||||||
**Status:** proposal / plan. Nothing here is implemented yet.
|
**Status:** initial wire/crypto slice implemented under `dotnet/`; later phases remain planned.
|
||||||
|
See `dotnet/README.md`, `docs/api-dotnet.md`, and `PROGRESS.md` for verification and next steps.
|
||||||
**Target runtime:** .NET 10 LTS (in-service to Nov 2028), with .NET 11 as the follow-on.
|
**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
|
**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#
|
Swift macOS/iOS clients with a single C# codebase. The Windows WinForms client is already C#
|
||||||
@@ -418,7 +419,7 @@ The most mechanical part of the project. Straight `async`/`await` network code.
|
|||||||
| `server.cpp` — accept loop | `Socket.AcceptAsync` loop + `Task` per connection. Trivial. |
|
| `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. |
|
| `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++. |
|
| `session_registry.cpp` | `ConcurrentDictionary<ulong, Session>` + a channel-membership index. Simpler than the C++. |
|
||||||
| `media_relay.cpp` — the SFU | ⚠️ **The one hot path on the server.** Per inbound datagram: parse 20-byte header → look up ssrc → fan out unmodified to N subscribers. Must be allocation-free: `Socket.ReceiveFromAsync(Memory<byte>, SocketAddress)` into a pooled buffer, `SendToAsync` per subscriber. Do **not** decrypt — the design already forbids it, which is what keeps this cheap. Benchmark this specifically (§11.5). |
|
| `media_relay.cpp` — the SFU | **The server hot path.** Authenticate/decrypt using the sender's directional key, then reseal for each recipient with its directional key and next counter. Preserve SSRC, timestamp, flags, and encoded Opus bytes; replace sequence and ciphertext/tag. Use pooled buffers and `Socket.ReceiveFromAsync(Memory<byte>, SocketAddress)`. Never decode audio. Benchmark fan-out and allocations. |
|
||||||
| `db.cpp` (26 K) — SQLite | `Microsoft.Data.Sqlite`, same schema, same file. Keep raw SQL — do not introduce EF Core; the schema is 4 tables and EF's startup cost hurts the "single binary, instant start" goal. |
|
| `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. |
|
| `identity.cpp` | `CertificateRequest` + BouncyCastle Ed25519. Reads the same on-disk files. |
|
||||||
| Keepalive reaper | `PeriodicTimer` — cleaner than the `asio::steady_timer`. |
|
| Keepalive reaper | `PeriodicTimer` — cleaner than the `asio::steady_timer`. |
|
||||||
@@ -618,7 +619,10 @@ not delete anything until the C# equivalent passes the same test against it. Thi
|
|||||||
possible because Option A (§3.2) preserves wire compatibility — which is the main reason to
|
possible because Option A (§3.2) preserves wire compatibility — which is the main reason to
|
||||||
choose it.
|
choose it.
|
||||||
|
|
||||||
Work on a long-lived branch (`cs-port` already exists). Each phase ends with a green build,
|
The rewrite lives under `dotnet/`; initial implementation branch: `dotnet/foundations`,
|
||||||
|
created from `cs-port`. Keep the existing schema at `core/proto/voicecat.proto` during migration.
|
||||||
|
Native packaging is deferred until the codec/audio phase rather than blocking the wire slice.
|
||||||
|
Each phase ends with a green build,
|
||||||
green tests, and an updated `PROGRESS.md` entry.
|
green tests, and an updated `PROGRESS.md` entry.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -2,6 +2,20 @@
|
|||||||
|
|
||||||
## 1. Milestones
|
## 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`)
|
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.
|
exists from M1 so the protocol can be exercised long before any GUI.
|
||||||
|
|
||||||
|
|||||||
+13
-10
@@ -67,13 +67,15 @@ mandatory from the first build. This was chosen over DTLS after weighing two fin
|
|||||||
|
|
||||||
### How it works
|
### How it works
|
||||||
|
|
||||||
1. During the TLS 1.3 control handshake, both sides call the keying-material exporter with a
|
1. After the TLS 1.3 control handshake, both sides call the keying-material exporter with
|
||||||
fixed label (`"voicecat media v1"`) to derive independent **send/recv media keys** and a
|
label `"voicecat media v1"` and a one-byte context: `0x00` for client→server,
|
||||||
salt. No second handshake, no certificates on the UDP path — the UDP channel inherits the
|
`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.
|
authenticated, MITM-resistant TLS session's trust.
|
||||||
2. Each UDP voice frame is sealed with **ChaCha20-Poly1305** (libsodium, ISC license).
|
2. Each UDP voice frame is sealed with **ChaCha20-Poly1305** (libsodium, ISC license).
|
||||||
3. The readable routing field (`ssrc`) is passed as AEAD **associated data** so the relay can
|
3. The full 20-byte header is AEAD **associated data**. The server authenticates/decrypts
|
||||||
route without decrypting and an attacker cannot tamper with it undetected.
|
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
|
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
|
no handshake latency to voice startup, and is small enough to audit fully. It is abstracted
|
||||||
@@ -84,11 +86,12 @@ the design depends on that.
|
|||||||
### Per-frame protections
|
### Per-frame protections
|
||||||
|
|
||||||
- **AEAD** (ChaCha20-Poly1305) over each voice frame — confidentiality + integrity.
|
- **AEAD** (ChaCha20-Poly1305) over each voice frame — confidentiality + integrity.
|
||||||
- **Associated data:** the `ssrc` (and version/flags) are authenticated-but-visible so the
|
- **Associated data:** all 20 header bytes remain visible and authenticated; the Opus
|
||||||
relay routes without decrypting; everything else is encrypted.
|
payload is encrypted and followed by a 16-byte tag.
|
||||||
- **Nonce discipline:** `nonce = direction_bit ‖ ssrc ‖ monotonic_packet_counter`. The
|
- **Nonce discipline:** `nonce = four_zero_bytes ‖ counter_u64_big_endian`. Counters are
|
||||||
counter never repeats under one key; the session **rekeys** (re-derives via the exporter
|
per directional session key, shared across its streams. Direction separation comes
|
||||||
with a bumped epoch) well before counter exhaustion or on a time/byte budget.
|
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
|
- **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:
|
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
|
replay-check → authenticate → update). The counter is read from the unauthenticated
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
# Tech Stack & Dependencies
|
# 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++
|
Concrete library choices with versions and rationale. Everything in the **core** is C++
|
||||||
(C++20). UIs are Swift and C#. Build is CMake + vcpkg.
|
(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.
|
> interoperate; the `Hello` handshake rejects on `proto_version` mismatch.
|
||||||
|
|
||||||
This is intentionally RTP-shaped (familiar semantics: ssrc/seq/timestamp) without RTP's
|
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
|
full machinery. The server authenticates/decrypts each incoming packet and reseals its
|
||||||
route by ssrc→channel and may restamp nothing (the client's ssrc is globally unique once
|
encoded Opus bytes for each recipient using that recipient's directional key and send
|
||||||
assigned at `StreamAnnounce`). No server-side decode.
|
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
|
### 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
|
- 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
|
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
|
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
|
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
|
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,54 @@
|
|||||||
|
# VoiceCat .NET rewrite
|
||||||
|
|
||||||
|
The first slice targets .NET 10: protobuf, control framing, voice headers, and media
|
||||||
|
encryption. TLS, server/client state, audio, and UI migration are next. The existing
|
||||||
|
C++ implementation remains the conformance oracle.
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||||
|
dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
|
||||||
|
dotnet test dotnet/VoiceCat.slnx -c Release --no-build
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependencies are pinned in project files and lock files. Generated protobuf is build
|
||||||
|
output; the schema remains `core/proto/voicecat.proto`. Production dependencies are
|
||||||
|
Google.Protobuf (BSD-3-Clause), BouncyCastle.Cryptography (MIT), and the build-only
|
||||||
|
Grpc.Tools (Apache-2.0). No GPL/LGPL dependencies are permitted.
|
||||||
|
|
||||||
|
## C# conventions
|
||||||
|
|
||||||
|
Use file-scoped namespaces, standard .NET naming, immutable values where useful, and
|
||||||
|
spans for binary data. Invalid arguments throw; invalid network packets use parsing
|
||||||
|
results or protocol exceptions. Async APIs accept cancellation tokens.
|
||||||
|
|
||||||
|
Comments explain constraints that cannot be made clear in code. Avoid banners,
|
||||||
|
implementation history, and narration. Keep durable design explanations in `docs/`.
|
||||||
|
|
||||||
|
## Regenerating C++ fixtures
|
||||||
|
|
||||||
|
The optional oracle target calls the existing C++ protobuf, header serializer, and
|
||||||
|
libsodium media implementation. From the root, with the development dependencies:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cmake --preset dev -DVOICECAT_BUILD_DOTNET_ORACLE=ON
|
||||||
|
cmake --build --preset dev --target voicecat-dotnet-oracle
|
||||||
|
New-Item -ItemType Directory -Force dotnet/tests/VoiceCat.Tests/Fixtures
|
||||||
|
./build/dev/bin/voicecat-dotnet-oracle.exe dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json
|
||||||
|
git diff -- dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json
|
||||||
|
```
|
||||||
|
|
||||||
|
On Linux/macOS, omit `.exe` and create the directory with `mkdir -p`.
|
||||||
|
The oracle writes deterministic JSON directly, avoiding shell output encoding.
|
||||||
|
Fixtures contain a framed ClientHello and media packets at counters 0, 1, 65535,
|
||||||
|
and 65536. Keys contain bytes 0–31; payload bytes count upward from zero. The
|
||||||
|
20-byte header has type 1, marker flag, codec 0, SSRC `0xcafebabe`, timestamp 960.
|
||||||
|
Both managed crypto backends must match these bytes.
|
||||||
|
|
||||||
|
## Next checkpoint
|
||||||
|
|
||||||
|
Prove BouncyCastle TLS 1.3 loopback and interoperability with the C++ mbedTLS server,
|
||||||
|
including exporter label `voicecat media v1`, one-byte direction contexts 0/1,
|
||||||
|
and TLS leaf certificate fingerprint pinning. Then implement the managed server,
|
||||||
|
tested first with the existing C++ CLI.
|
||||||
@@ -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,4 @@
|
|||||||
|
add_executable(voicecat-dotnet-oracle main.cpp)
|
||||||
|
target_link_libraries(voicecat-dotnet-oracle PRIVATE voicecat::voicecat)
|
||||||
|
target_include_directories(voicecat-dotnet-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
|
||||||
|
target_compile_features(voicecat-dotnet-oracle PRIVATE cxx_std_20)
|
||||||
@@ -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,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,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,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,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