2 Commits
Author SHA1 Message Date
Talon 2df79cdd4c Add managed TLS interoperability and persisted credentials
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-15 18:04:20 +02:00
Talon b76181d9fb Start .NET rewrite with wire and media crypto conformance
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
2026-09-15 17:54:16 +02:00
47 changed files with 2281 additions and 19 deletions
+56
View File
@@ -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
+3
View File
@@ -1,4 +1,7 @@
# Build output # Build output
/dotnet/**/bin/
/dotnet/**/obj/
/dotnet/**/TestResults/
/build/ /build/
/out/ /out/
+13
View File
@@ -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.
+5
View File
@@ -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()
+24
View File
@@ -10,6 +10,30 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action ## ▶ 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 - **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,
+117
View File
@@ -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.
+9 -2
View File
@@ -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
+13
View File
@@ -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
+19 -3
View File
@@ -1,6 +1,9 @@
# Porting VoiceCat to pure .NET / C# # Porting VoiceCat to pure .NET / C#
**Status:** proposal / plan. Nothing here is implemented yet. **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. **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 +421,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 +621,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.
--- ---
@@ -672,6 +678,16 @@ not assumed.
**Exit criterion:** C# client completes a TLS 1.3 handshake with the C++ server, derives **Exit criterion:** C# client completes a TLS 1.3 handshake with the C++ server, derives
matching media keys, and pins the leaf fingerprint. 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) ### Phase 3 — Codec + DSP (est. 1 week)
+14
View File
@@ -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.
+23 -10
View File
@@ -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 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. 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 Client certificates are reserved for a future "key-based identity" option (see roadmap) but
are not required in v1. 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 ### 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 +96,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
+13
View File
@@ -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
View File
@@ -66,9 +66,10 @@ payload one Opus packet (the encoder's output for one frame)
> interoperate; the `Hello` handshake rejects on `proto_version` mismatch. > 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
+7
View File
@@ -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
+10
View File
@@ -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>
+71
View File
@@ -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.
+9
View File
@@ -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>
+30
View File
@@ -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."
+3
View File
@@ -0,0 +1,3 @@
{
"sdk": { "version": "10.0.203", "rollForward": "latestFeature" }
}
+9
View File
@@ -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)
+56
View File
@@ -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;
}
+76
View File
@@ -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;
}
}
+72
View File
@@ -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);
}
}
+208
View File
@@ -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
];
}
+72
View File
@@ -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"}
]
}
+181
View File
@@ -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); }
}
}
+166
View File
@@ -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.";
}
}
+111
View File
@@ -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, )"
}
}
}
}
}