Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2df79cdd4c | ||
|
|
b76181d9fb |
@@ -0,0 +1,56 @@
|
||||
name: .NET port
|
||||
|
||||
on:
|
||||
push:
|
||||
paths: ['dotnet/**', 'core/**', 'server/**', 'tests/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml']
|
||||
pull_request:
|
||||
paths: ['dotnet/**', 'core/**', 'server/**', 'tests/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-24.04, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
global-json-file: dotnet/global.json
|
||||
cache: true
|
||||
cache-dependency-path: dotnet/**/packages.lock.json
|
||||
- run: dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||
- run: dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
- run: dotnet test dotnet/VoiceCat.slnx -c Release --no-build
|
||||
- shell: pwsh
|
||||
run: ./dotnet/check-licenses.ps1
|
||||
|
||||
cpp-conformance:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
global-json-file: dotnet/global.json
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/vcpkg
|
||||
key: dotnet-oracle-linux-${{ hashFiles('vcpkg.json', 'vcpkg') }}
|
||||
- name: Install C++ build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential cmake ninja-build curl zip unzip tar pkg-config autoconf autoconf-archive automake libtool nasm python3
|
||||
./vcpkg/bootstrap-vcpkg.sh -disableMetrics
|
||||
- name: Build and verify both implementations
|
||||
run: |
|
||||
cmake --preset dev -DVOICECAT_BUILD_DOTNET_ORACLE=ON
|
||||
cmake --build --preset dev
|
||||
ctest --preset dev
|
||||
./build/dev/bin/voicecat-dotnet-oracle build/dev/cpp-wire.json
|
||||
diff -u dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json build/dev/cpp-wire.json
|
||||
dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||
VOICECAT_TLS_ORACLE="$PWD/build/dev/bin/voicecat-dotnet-tls-oracle" dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
@@ -1,4 +1,7 @@
|
||||
# Build output
|
||||
/dotnet/**/bin/
|
||||
/dotnet/**/obj/
|
||||
/dotnet/**/TestResults/
|
||||
/build/
|
||||
/out/
|
||||
|
||||
|
||||
@@ -25,6 +25,19 @@ native clients (Swift on macOS/iOS, C# on Windows) and the server.
|
||||
|
||||
## Build & test commands
|
||||
|
||||
The .NET rewrite lives under `dotnet/`. Build and test its initial wire/crypto slice
|
||||
alongside the existing C++ tree:
|
||||
|
||||
```powershell
|
||||
dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||
dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-build
|
||||
./dotnet/check-licenses.ps1
|
||||
```
|
||||
|
||||
See `dotnet/README.md` for C# conventions and C++ fixture regeneration, and
|
||||
`docs/api-dotnet.md` for managed interfaces. Subsequent port phases remain planned.
|
||||
|
||||
The default development preset is **`dev`** — it builds everything (server + tools + tests)
|
||||
with real vcpkg deps. The `skeleton` preset (no deps, stubs only) is a fast smoke check; see
|
||||
[`docs/building.md`](docs/building.md) for the full preset matrix.
|
||||
|
||||
@@ -49,6 +49,11 @@ endif()
|
||||
# ── Targets ───────────────────────────────────────────────────────────────────
|
||||
add_subdirectory(core)
|
||||
|
||||
option(VOICECAT_BUILD_DOTNET_ORACLE "Build the .NET port conformance fixture generator" OFF)
|
||||
if(VOICECAT_BUILD_DOTNET_ORACLE)
|
||||
add_subdirectory(dotnet/oracle)
|
||||
endif()
|
||||
|
||||
if(VOICECAT_BUILD_SERVER)
|
||||
add_subdirectory(server)
|
||||
endif()
|
||||
|
||||
+24
@@ -10,6 +10,30 @@ up instantly. Newest status at the top.
|
||||
|
||||
## ▶ Where we left off / next action
|
||||
|
||||
- **Done (2026-09-15): TLS exporter interoperability and persisted credentials.** Foundation commit
|
||||
`b76181d` pushed to `origin/dotnet/foundations`. Added a nonblocking managed TLS 1.3
|
||||
session with certificate acceptance gate and directional media factories. Managed
|
||||
loopback and C++ interoperability pass; exporter keys are captured inside BouncyCastle's
|
||||
handshake callback before its exporter secrets are destroyed. The C++ TLS oracle
|
||||
authenticates encrypted challenges in both directions against the existing mbedTLS
|
||||
context. Added explicit persisted TOFU pins, compatible Ed25519 identity/PEM import,
|
||||
new certificate identity SAN, and rejection of incomplete credential sets.
|
||||
**Verified:** 42/42 managed tests with the native TLS oracle enabled; native dev build
|
||||
and 29/29 CTest tests green; 16 permissive package licenses verified. Complete socket
|
||||
orchestration and managed server/client state remain pending.
|
||||
**Next:** Phase 3 codec/DSP wrappers and native packaging.
|
||||
|
||||
- **Done (2026-09-15): Initial .NET wire/crypto port** on `dotnet/foundations`, from `cs-port`.
|
||||
Added `dotnet/` solution, schema code generation, pipe framing, immutable voice headers,
|
||||
directional media encryption/decryption, and xUnit conformance tests. Both platform
|
||||
and managed crypto paths are tested. Added optional C++ fixture oracle, managed CI,
|
||||
dependency lock files, license audit, and `docs/api-dotnet.md`. **Verified:** managed
|
||||
Release build, 34/34 tests including C++ golden bytes, and 16 permissive package
|
||||
licenses. Fresh `cmake --build --preset dev` and `ctest --preset dev` green (29/29);
|
||||
regenerating the C++ fixtures produces identical bytes. Native codec/audio packaging
|
||||
is deferred to its implementation phase. Next checkpoint: BouncyCastle TLS 1.3
|
||||
exporter interoperability with the existing server.
|
||||
|
||||
- **Done (2026-07-23):** **First comment-density cleanup across core, server, and native
|
||||
clients.** Condensed comments in the highest-noise audio, reconnect, registry, and binding
|
||||
files; removed implementation history and narration; retained ABI ownership, threading,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Initial managed API contract
|
||||
|
||||
Status: initial port slice, API revision 1. No change to protobuf or media wire formats.
|
||||
These are shared infrastructure APIs; the client-facing API follows with the client core.
|
||||
|
||||
## Protocol
|
||||
|
||||
`VoiceCat.Protocol` generates `Voicecat.V1` protobuf messages from the existing schema.
|
||||
|
||||
`ControlFraming.TryReadFrame(ref ReadOnlySequence<byte>, out ReadOnlySequence<byte>)`
|
||||
extracts a payload and advances input only when a full frame exists. Returned memory
|
||||
borrows the input's lifetime. Lengths above 16 MiB throw `InvalidDataException`.
|
||||
Empty payloads are valid. `WriteFrame` and `WriteEnvelope` target `IBufferWriter<byte>`;
|
||||
oversized outgoing payloads throw before output is written.
|
||||
|
||||
`ReadEnvelopesAsync(PipeReader, CancellationToken)` produces parsed envelopes and
|
||||
advances consumed pipe data. It does not complete or dispose the caller's reader.
|
||||
Clean EOF ends enumeration; partial EOF and oversized frames throw
|
||||
`InvalidDataException`; malformed protobuf throws `InvalidProtocolBufferException`.
|
||||
Cancellation propagates. A connection owner must close on protocol errors or
|
||||
cancellation partway through a frame; partial frame bytes may already be consumed.
|
||||
Fragments are consumed as they arrive so frames larger than pipe backpressure
|
||||
thresholds make progress. Stopping enumeration between envelopes preserves the next frame.
|
||||
|
||||
`VoiceFrameHeader` is an immutable value with type, flags, codec, SSRC, sequence,
|
||||
and timestamp. `Write(Span<byte>)` writes its 20-byte big-endian representation;
|
||||
`TryRead` accepts at least 20 bytes and preserves unknown type/flag/codec values.
|
||||
Higher layers decide which values they support.
|
||||
|
||||
## Media encryption
|
||||
|
||||
`MediaEncryptor` and `MediaDecryptor` each own one directional 32-byte session key
|
||||
and mutable packet state. Use one owner at a time; they provide no synchronization.
|
||||
Production constructs them through `TlsSession` media factories after its handshake.
|
||||
Raw-key constructors support conformance tests.
|
||||
|
||||
`MediaEncryptor.Encrypt(VoiceFrameHeader, ReadOnlySpan<byte>, Span<byte>)` writes
|
||||
the full header plus ciphertext and 16-byte tag and returns packet length. It replaces
|
||||
the supplied sequence with its own counter, starting at zero. Capacity and overlap
|
||||
errors throw before reserving a counter. Reserved counters are never reused after
|
||||
encryption failure. At `ulong.MaxValue`, encryption throws and requires a new session.
|
||||
|
||||
`MediaDecryptor.TryDecrypt(ReadOnlySpan<byte>, Span<byte>, out VoiceFrameHeader,
|
||||
out int)` authenticates and decrypts a complete packet. Short packets, failed tags,
|
||||
replays, and packets outside the 64-packet window return false with default header
|
||||
and zero bytes written. Authentication failure clears the attempted plaintext region;
|
||||
structural/replay rejection leaves storage untouched. Callers must only consume
|
||||
output after success. Invalid storage capacity and overlapping buffers throw.
|
||||
|
||||
The nonce is four zero bytes plus the big-endian header counter. All 20 header bytes
|
||||
are authenticated associated data. The replay window advances after authentication.
|
||||
The platform ChaCha20-Poly1305 implementation is preferred; BouncyCastle is used when
|
||||
platform support is absent. Both produce the same wire bytes. The fallback currently
|
||||
allocates per packet; audio and relay allocation guarantees are later checkpoints.
|
||||
|
||||
Dispose both objects to clear their owned key arrays and release platform crypto
|
||||
resources. Use after disposal throws `ObjectDisposedException`.
|
||||
|
||||
## TLS sessions
|
||||
|
||||
`TlsSession` is a single-owner, nonblocking BouncyCastle TLS 1.3 state machine.
|
||||
It owns no socket or worker thread. The transport owner feeds `ReceiveCiphertext`,
|
||||
fully drains `DrainCiphertext` to its socket (including partial sends), and reads
|
||||
application data through `ReadPlaintext`. Reads and drains return a byte count and
|
||||
may require repeated calls. `WritePlaintext` requires `IsReady`. Socket cancellation,
|
||||
backpressure, and connection lifetime belong to the transport owner.
|
||||
|
||||
`CreateClient(Func<string, bool>)` requires an explicit certificate acceptance
|
||||
callback. It receives the uppercase SHA-256 fingerprint of the leaf certificate's
|
||||
DER bytes during the handshake. Returning false rejects the session before application
|
||||
data or media keys are available. This is TOFU certificate pinning; there is no PKI
|
||||
chain or hostname validation. The synchronous callback must have the trust decision
|
||||
available; an asynchronous first-connect prompt requires a subsequent connection
|
||||
after explicit acceptance. Never automatically accept or persist an unknown pin.
|
||||
|
||||
`CreateServer(certificatePem, privateKeyPem)` supports ECDSA credentials; use
|
||||
`ServerCredentials.CreateTlsSession()` to import persisted credentials. TLS 1.2 is
|
||||
rejected. Handshake completion captures two 32-byte exporter keys using label
|
||||
`voicecat media v1` and one-byte contexts 0 (client to server) and 1 (server to client).
|
||||
BouncyCastle discards its exporter secrets after that callback. Media factories
|
||||
select the correct direction for each role and require a ready session.
|
||||
|
||||
Create one encryptor and decryptor per connection and retain them for the connection's
|
||||
lifetime: constructing a second encryptor resets its counter and would reuse nonces.
|
||||
Dispose media objects separately from the TLS session. `Close()` queues close_notify;
|
||||
drain it before disposal. On socket EOF call `CompleteInput()`; missing close_notify
|
||||
throws `IOException`. TLS/protocol errors require closing the connection. Disposal
|
||||
clears the session's owned exporter arrays and scratch buffer.
|
||||
|
||||
## Persisted trust and credentials
|
||||
|
||||
`TofuStore` uses the existing UTF-8 `host:port lowercase-hex-fingerprint` format.
|
||||
Host matching is ordinal and case sensitive, matching native behavior. `Check`
|
||||
returns `FirstConnect`, `Matched`, or `Mismatch` without changing persistence.
|
||||
Only explicit `Pin` or `Remove` changes the file. Pin replacement requires an
|
||||
explicit caller decision; malformed files fail closed. Changes replace the file
|
||||
atomically before updating memory. Use one owner per store/file.
|
||||
|
||||
`ServerIdentity` reads and writes the native 96-byte Ed25519 format:
|
||||
`public-key[32] || seed[32] || public-key[32]`. Loading verifies both public-key
|
||||
copies against the seed. Disposal clears the owned seed.
|
||||
|
||||
`ServerCredentials.LoadOrCreate(directory, serverName)` imports `identity.key`,
|
||||
`server.crt`, and `server.key` unchanged. If all are absent it creates an ECDSA-P256
|
||||
self-signed certificate and identity. If only some exist it rejects startup rather
|
||||
than rotating identity. Restore the missing files. New certificates include SAN URI
|
||||
`urn:voicecat:identity:ed25519:<lowercase-public-key-hex>`; legacy certificates are
|
||||
accepted unchanged. Checking this URI against ServerHello's identity is deferred
|
||||
until the managed handshake/session layer is implemented; trust currently pins the
|
||||
leaf certificate. Dispose credentials after their TLS sessions are created/finished
|
||||
as required by the application lifetime.
|
||||
|
||||
Private file writes use a same-directory temporary file, flush, and atomic replacement.
|
||||
On Unix new files use owner read/write permissions; Windows inherits directory ACLs.
|
||||
The credential directory must have one provisioning owner. PEM strings and crypto
|
||||
library internal copies are managed memory; owned-array clearing does not promise
|
||||
erasure of every runtime/library copy.
|
||||
@@ -1,5 +1,10 @@
|
||||
# Architecture
|
||||
|
||||
The parallel .NET rewrite under `dotnet/` currently implements shared protocol framing,
|
||||
voice headers, and media crypto. Existing server/client/audio behavior remains in C++.
|
||||
See `docs/api-dotnet.md` for the initial managed contract and
|
||||
`docs/porting-to-dotnet.md` for subsequent migration phases.
|
||||
|
||||
## 1. The shared-core model
|
||||
|
||||
All non-UI logic lives in one C++ library, **`libvoicecat`**. The same library is linked
|
||||
@@ -205,8 +210,10 @@ callback: no allocations, no blocking calls.
|
||||
```
|
||||
|
||||
- **Voice router is a relay, not a mixer.** For each incoming voice frame it looks up the
|
||||
sender's channel and forwards the *unmodified Opus payload* (restamped with the sender's
|
||||
user id) to every other subscribed member. No server-side decode/transcode → low CPU,
|
||||
sender's channel and forwards the *unmodified encoded Opus bytes* to other members.
|
||||
It authenticates/decrypts incoming media, then reseals with each recipient's directional
|
||||
key and counter. SSRC/timestamp/flags/codec pass through; sequence and ciphertext/tag change.
|
||||
No server-side decode/transcode → low CPU,
|
||||
low latency, and end-to-content is just Opus. Per-channel Opus params are enforced so all
|
||||
members are mutually decodable.
|
||||
- **Subscriptions.** Clients implicitly subscribe to their current channel's voice; text
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Building & Manual Testing
|
||||
|
||||
## .NET rewrite
|
||||
|
||||
The initial managed wire/crypto slice is under `dotnet/`, targeting .NET 10. From the root:
|
||||
|
||||
```powershell
|
||||
dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||
dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-build
|
||||
```
|
||||
|
||||
See `dotnet/README.md` for conformance fixtures and conventions. The C++ commands
|
||||
below remain required while the existing implementation is the migration oracle.
|
||||
|
||||
This doc explains what each CMake preset in [`CMakePresets.json`](../CMakePresets.json) is
|
||||
*for*, which one to actually use day-to-day, and the commands to stand up a real server +
|
||||
`vccli` clients against each other for manual testing. For the one-paragraph quick-start see
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# 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.
|
||||
**Scope:** replace the C++ core (`libvoicecat`), the C++ server, the C++ `vccli`, and the
|
||||
Swift macOS/iOS clients with a single C# codebase. The Windows WinForms client is already C#
|
||||
@@ -418,7 +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. |
|
||||
| `conn_session.cpp` (34 K) — per-conn protocol | The bulk. A big `switch` on `Envelope.BodyCase`. Mechanical; write it against the ported xUnit tests. |
|
||||
| `session_registry.cpp` | `ConcurrentDictionary<ulong, Session>` + a channel-membership index. Simpler than the C++. |
|
||||
| `media_relay.cpp` — the SFU | ⚠️ **The one hot path on the server.** Per inbound datagram: parse 20-byte header → look up ssrc → fan out unmodified to N subscribers. Must be allocation-free: `Socket.ReceiveFromAsync(Memory<byte>, SocketAddress)` into a pooled buffer, `SendToAsync` per subscriber. Do **not** decrypt — the design already forbids it, which is what keeps this cheap. Benchmark this specifically (§11.5). |
|
||||
| `media_relay.cpp` — the SFU | **The server hot path.** Authenticate/decrypt using the sender's directional key, then reseal for each recipient with its directional key and next counter. Preserve SSRC, timestamp, flags, and encoded Opus bytes; replace sequence and ciphertext/tag. Use pooled buffers and `Socket.ReceiveFromAsync(Memory<byte>, SocketAddress)`. Never decode audio. Benchmark fan-out and allocations. |
|
||||
| `db.cpp` (26 K) — SQLite | `Microsoft.Data.Sqlite`, same schema, same file. Keep raw SQL — do not introduce EF Core; the schema is 4 tables and EF's startup cost hurts the "single binary, instant start" goal. |
|
||||
| `identity.cpp` | `CertificateRequest` + BouncyCastle Ed25519. Reads the same on-disk files. |
|
||||
| Keepalive reaper | `PeriodicTimer` — cleaner than the `asio::steady_timer`. |
|
||||
@@ -618,7 +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
|
||||
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.
|
||||
|
||||
---
|
||||
@@ -672,6 +678,16 @@ not assumed.
|
||||
**Exit criterion:** C# client completes a TLS 1.3 handshake with the C++ server, derives
|
||||
matching media keys, and pins the leaf fingerprint.
|
||||
|
||||
**Checkpoint (2026-09-15):** implemented nonblocking managed TLS, handshake-time
|
||||
exporters, explicit certificate acceptance, persisted TOFU, and native-compatible
|
||||
credentials. The C++ TLS oracle authenticates a media challenge in both directions
|
||||
over an actual socket, proving exporter compatibility. Tests also cover managed
|
||||
fragmented loopback, first-connect acceptance, changed-pin rejection, TLS 1.2 rejection,
|
||||
close_notify/abrupt EOF, restart persistence, and import of C++ credential files.
|
||||
Socket orchestration remains a transport-owner responsibility; the complete managed
|
||||
server and client are later phases. See `dotnet/README.md` for the required native
|
||||
interoperability test command.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Codec + DSP (est. 1 week)
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
## 1. Milestones
|
||||
|
||||
### .NET port — initial slice
|
||||
|
||||
**Complete 2026-09-15:** managed Release build and 34/34 xUnit tests, C++ golden
|
||||
fixtures for both crypto backends, fresh native build and 29/29 CTest tests. Native
|
||||
packaging and TLS/server/client migration remain later checkpoints.
|
||||
|
||||
- `dotnet/` contains .NET 10 protocol and crypto assemblies plus xUnit conformance tests.
|
||||
- Preserve the existing protobuf and 20-byte media wire formats; keep C++ as the oracle.
|
||||
- **Exit:** managed framing, headers, and ciphertext match fixtures generated by C++;
|
||||
managed tests and the existing C++ behavior suite pass.
|
||||
- **Next:** prove TLS 1.3/exporter interoperability with C++, then port the server before
|
||||
client state/audio/UI migration. Native audio packaging follows with codec/audio work.
|
||||
- See `docs/porting-to-dotnet.md` and `dotnet/README.md`.
|
||||
|
||||
Each milestone is shippable/testable on its own. The headless C++ test client (`vccli`)
|
||||
exists from M1 so the protocol can be exercised long before any GUI.
|
||||
|
||||
|
||||
+23
-10
@@ -49,6 +49,16 @@ This is a known limitation of the current design. Closing it properly requires b
|
||||
Ed25519 key into the TLS cert (e.g. as a SubjectAltName or extension), which is a planned
|
||||
future improvement. Until then, clients display both values but gate on the cert fingerprint.
|
||||
|
||||
**Managed rewrite checkpoint:** `dotnet/` uses nonblocking BouncyCastle TLS 1.3 and
|
||||
captures directional exporters during handshake completion. Its client requires an
|
||||
explicit leaf-fingerprint acceptance callback; PKI validation remains unimplemented.
|
||||
New managed server certificates include the Ed25519 public key in SAN URI
|
||||
`urn:voicecat:identity:ed25519:<lowercase-public-key-hex>`. Existing C++ credentials
|
||||
are imported unchanged. Verifying that URI against the declared ServerHello identity
|
||||
is still deferred to the managed session layer; leaf-certificate TOFU remains the
|
||||
trust gate. Missing members of a persisted credential set cause startup rejection
|
||||
rather than automatic identity rotation. See [api-dotnet.md](api-dotnet.md).
|
||||
|
||||
Client certificates are reserved for a future "key-based identity" option (see roadmap) but
|
||||
are not required in v1.
|
||||
|
||||
@@ -67,13 +77,15 @@ mandatory from the first build. This was chosen over DTLS after weighing two fin
|
||||
|
||||
### How it works
|
||||
|
||||
1. During the TLS 1.3 control handshake, both sides call the keying-material exporter with a
|
||||
fixed label (`"voicecat media v1"`) to derive independent **send/recv media keys** and a
|
||||
salt. No second handshake, no certificates on the UDP path — the UDP channel inherits the
|
||||
1. After the TLS 1.3 control handshake, both sides call the keying-material exporter with
|
||||
label `"voicecat media v1"` and a one-byte context: `0x00` for client→server,
|
||||
`0x01` for server→client. Each export yields a 32-byte directional media key.
|
||||
No second handshake, no certificates on the UDP path — the UDP channel inherits the
|
||||
authenticated, MITM-resistant TLS session's trust.
|
||||
2. Each UDP voice frame is sealed with **ChaCha20-Poly1305** (libsodium, ISC license).
|
||||
3. The readable routing field (`ssrc`) is passed as AEAD **associated data** so the relay can
|
||||
route without decrypting and an attacker cannot tamper with it undetected.
|
||||
3. The full 20-byte header is AEAD **associated data**. The server authenticates/decrypts
|
||||
inbound media and reseals for each recipient, replacing the sequence with that
|
||||
recipient's next send counter. It forwards the encoded Opus bytes without decoding audio.
|
||||
|
||||
This keeps the entire crypto surface on two permissive libraries (mbedTLS + libsodium), adds
|
||||
no handshake latency to voice startup, and is small enough to audit fully. It is abstracted
|
||||
@@ -84,11 +96,12 @@ the design depends on that.
|
||||
### Per-frame protections
|
||||
|
||||
- **AEAD** (ChaCha20-Poly1305) over each voice frame — confidentiality + integrity.
|
||||
- **Associated data:** the `ssrc` (and version/flags) are authenticated-but-visible so the
|
||||
relay routes without decrypting; everything else is encrypted.
|
||||
- **Nonce discipline:** `nonce = direction_bit ‖ ssrc ‖ monotonic_packet_counter`. The
|
||||
counter never repeats under one key; the session **rekeys** (re-derives via the exporter
|
||||
with a bumped epoch) well before counter exhaustion or on a time/byte budget.
|
||||
- **Associated data:** all 20 header bytes remain visible and authenticated; the Opus
|
||||
payload is encrypted and followed by a 16-byte tag.
|
||||
- **Nonce discipline:** `nonce = four_zero_bytes ‖ counter_u64_big_endian`. Counters are
|
||||
per directional session key, shared across its streams. Direction separation comes
|
||||
from exporter contexts, not nonce bits. Automatic epoch rekeying is not implemented;
|
||||
the .NET encryptor refuses counter exhaustion and requires a new session.
|
||||
- **Anti-replay:** a 64-bit sliding-window replay filter keyed on the packet counter (à la
|
||||
IPsec). The window is **advanced only after the AEAD tag verifies** (RFC 3711 §3.3 order:
|
||||
replay-check → authenticate → update). The counter is read from the unauthenticated
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Tech Stack & Dependencies
|
||||
|
||||
## Initial .NET rewrite
|
||||
|
||||
The parallel rewrite under `dotnet/` targets .NET 10. Its initial dependencies are
|
||||
Google.Protobuf 3.36.1 (BSD-3-Clause), build-only Grpc.Tools 2.83.0 (Apache-2.0), and
|
||||
BouncyCastle.Cryptography 2.6.2 (MIT). Media AEAD prefers the platform implementation;
|
||||
BouncyCastle provides the managed fallback and is the planned TLS/exporter provider.
|
||||
No managed server or audio replacement is shipped yet.
|
||||
|
||||
Project files and NuGet lock files pin versions. `dotnet/check-licenses.ps1` checks
|
||||
all restored direct/transitive packages against a permissive license allowlist in CI;
|
||||
unknown or copyleft licenses fail. See `dotnet/README.md` for build and test commands.
|
||||
The existing implementation's dependency choices follow below.
|
||||
|
||||
Concrete library choices with versions and rationale. Everything in the **core** is C++
|
||||
(C++20). UIs are Swift and C#. Build is CMake + vcpkg.
|
||||
|
||||
|
||||
+5
-4
@@ -66,9 +66,10 @@ payload one Opus packet (the encoder's output for one frame)
|
||||
> interoperate; the `Hello` handshake rejects on `proto_version` mismatch.
|
||||
|
||||
This is intentionally RTP-shaped (familiar semantics: ssrc/seq/timestamp) without RTP's
|
||||
full machinery. The **server relays the payload unmodified** — it only reads the header to
|
||||
route by ssrc→channel and may restamp nothing (the client's ssrc is globally unique once
|
||||
assigned at `StreamAnnounce`). No server-side decode.
|
||||
full machinery. The server authenticates/decrypts each incoming packet and reseals its
|
||||
encoded Opus bytes for each recipient using that recipient's directional key and send
|
||||
counter. SSRC, timestamp, flags, and codec pass through; sequence and ciphertext/tag change.
|
||||
There is no server-side audio decoding or transcoding.
|
||||
|
||||
### Why client-sends-ssrc is safe
|
||||
|
||||
@@ -183,7 +184,7 @@ Each receiver keeps an **adaptive jitter buffer per ssrc** with **bounded-depth
|
||||
|
||||
- A `KEEPALIVE` (type 2) frame flows both directions on the media channel every ~5 s to
|
||||
hold NAT bindings and measure media-path RTT/loss independent of TCP. The frame is
|
||||
plaintext (14-byte header, no payload, no AEAD) — the server identifies the sender by
|
||||
plaintext (20-byte header, no payload, no AEAD) — the server identifies the sender by
|
||||
its already-verified UDP endpoint (established during the `UdpBinding` handshake). On
|
||||
receipt the server bumps the sender's `last_seen` (so media activity defers the TCP
|
||||
reaper independently of control-channel traffic) and echoes the frame back so the
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
root = true
|
||||
|
||||
[*.cs]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
csharp_style_namespace_declarations = file_scoped:warning
|
||||
dotnet_sort_system_directives_first = true
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AnalysisLevel>latest</AnalysisLevel>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,71 @@
|
||||
# VoiceCat .NET rewrite
|
||||
|
||||
The first slice targets .NET 10: protobuf, control framing, voice headers, and media
|
||||
encryption, TLS 1.3, persisted TOFU pins, and server credentials. Server/client state,
|
||||
audio, and UI migration are next. The existing
|
||||
C++ implementation remains the conformance oracle.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```powershell
|
||||
dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||
dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-build
|
||||
```
|
||||
|
||||
Dependencies are pinned in project files and lock files. Generated protobuf is build
|
||||
output; the schema remains `core/proto/voicecat.proto`. Production dependencies are
|
||||
Google.Protobuf (BSD-3-Clause), BouncyCastle.Cryptography (MIT), and the build-only
|
||||
Grpc.Tools (Apache-2.0). No GPL/LGPL dependencies are permitted.
|
||||
|
||||
## C# conventions
|
||||
|
||||
Use file-scoped namespaces, standard .NET naming, immutable values where useful, and
|
||||
spans for binary data. Invalid arguments throw; invalid network packets use parsing
|
||||
results or protocol exceptions. Async APIs accept cancellation tokens.
|
||||
|
||||
Comments explain constraints that cannot be made clear in code. Avoid banners,
|
||||
implementation history, and narration. Keep durable design explanations in `docs/`.
|
||||
|
||||
## Regenerating C++ fixtures
|
||||
|
||||
The optional oracle target calls the existing C++ protobuf, header serializer, and
|
||||
libsodium media implementation. From the root, with the development dependencies:
|
||||
|
||||
```powershell
|
||||
cmake --preset dev -DVOICECAT_BUILD_DOTNET_ORACLE=ON
|
||||
cmake --build --preset dev --target voicecat-dotnet-oracle
|
||||
New-Item -ItemType Directory -Force dotnet/tests/VoiceCat.Tests/Fixtures
|
||||
./build/dev/bin/voicecat-dotnet-oracle.exe dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json
|
||||
git diff -- dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json
|
||||
```
|
||||
|
||||
On Linux/macOS, omit `.exe` and create the directory with `mkdir -p`.
|
||||
The oracle writes deterministic JSON directly, avoiding shell output encoding.
|
||||
Fixtures contain a framed ClientHello and media packets at counters 0, 1, 65535,
|
||||
and 65536. Keys contain bytes 0–31; payload bytes count upward from zero. The
|
||||
20-byte header has type 1, marker flag, codec 0, SSRC `0xcafebabe`, timestamp 960.
|
||||
Both managed crypto backends must match these bytes.
|
||||
|
||||
## TLS interoperability
|
||||
|
||||
The optional TLS oracle uses the existing mbedTLS context and libsodium media crypto.
|
||||
The test authenticates an encrypted challenge in both directions, proving exporter
|
||||
compatibility without sending raw keys. It also loads the C++ server's credential files.
|
||||
|
||||
```powershell
|
||||
cmake --build --preset dev --target voicecat-dotnet-tls-oracle
|
||||
$env:VOICECAT_TLS_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-tls-oracle.exe).Path
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
```
|
||||
|
||||
On Linux/macOS, set `VOICECAT_TLS_ORACLE` to the absolute executable path without
|
||||
`.exe`. Without that variable, only this native interoperability test is skipped;
|
||||
managed TLS loopback, rejection, persistence, and wire tests still run. CI's C++
|
||||
conformance job requires the native test. See `docs/api-dotnet.md` for ownership
|
||||
and certificate acceptance requirements.
|
||||
|
||||
## Next checkpoint
|
||||
|
||||
Port codec/DSP wrappers and their native packaging per Phase 3 of the porting plan.
|
||||
The managed server follows, tested first with the existing C++ CLI.
|
||||
@@ -0,0 +1,9 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
|
||||
<Project Path="src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/VoiceCat.Tests/VoiceCat.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -0,0 +1,30 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$allowed = @('MIT', 'BSD-2-Clause', 'BSD-3-Clause', 'Apache-2.0', 'ISC', '0BSD')
|
||||
$seen = @{}
|
||||
foreach ($lockPath in (Get-ChildItem -LiteralPath $PSScriptRoot -Filter packages.lock.json -Recurse)) {
|
||||
$lock = Get-Content -Raw -LiteralPath $lockPath.FullName | ConvertFrom-Json
|
||||
$assets = Get-Content -Raw -LiteralPath (Join-Path $lockPath.DirectoryName 'obj/project.assets.json') | ConvertFrom-Json
|
||||
foreach ($framework in $lock.dependencies.PSObject.Properties) {
|
||||
foreach ($package in $framework.Value.PSObject.Properties) {
|
||||
if ($package.Value.type -eq 'Project') { continue }
|
||||
$id = $package.Name.ToLowerInvariant()
|
||||
$version = $package.Value.resolved
|
||||
if ($seen.ContainsKey("$id/$version")) { continue }
|
||||
$seen["$id/$version"] = $true
|
||||
$nuspec = $null
|
||||
foreach ($folder in $assets.packageFolders.PSObject.Properties.Name) {
|
||||
$candidate = Join-Path $folder "$id/$version/$id.nuspec"
|
||||
if (Test-Path -LiteralPath $candidate) { $nuspec = $candidate; break }
|
||||
}
|
||||
if (!$nuspec) { throw "Restore dependencies before auditing $id/$version." }
|
||||
[xml]$spec = Get-Content -Raw -LiteralPath $nuspec
|
||||
$license = $spec.package.metadata.license
|
||||
if ($license.type -eq 'expression' -and $allowed -contains $license.InnerText) { continue }
|
||||
# This legacy pinned package predates NuGet license expressions (Apache-2.0).
|
||||
if ($id -eq 'xunit.abstractions' -and $version -eq '2.0.3' -and
|
||||
$spec.package.metadata.licenseUrl -eq 'https://raw.githubusercontent.com/xunit/xunit/master/license.txt') { continue }
|
||||
throw "Unapproved license for $id/$version. Review before changing the allowlist."
|
||||
}
|
||||
}
|
||||
}
|
||||
Write-Output "Checked $($seen.Count) package licenses: permissive allowlist passed."
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"sdk": { "version": "10.0.203", "rollForward": "latestFeature" }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
add_executable(voicecat-dotnet-oracle main.cpp)
|
||||
target_link_libraries(voicecat-dotnet-oracle PRIVATE voicecat::voicecat)
|
||||
target_include_directories(voicecat-dotnet-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
|
||||
target_compile_features(voicecat-dotnet-oracle PRIVATE cxx_std_20)
|
||||
|
||||
add_executable(voicecat-dotnet-tls-oracle tls.cpp)
|
||||
target_link_libraries(voicecat-dotnet-tls-oracle PRIVATE voicecat::voicecat)
|
||||
target_include_directories(voicecat-dotnet-tls-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
|
||||
target_compile_features(voicecat-dotnet-tls-oracle PRIVATE cxx_std_20)
|
||||
@@ -0,0 +1,56 @@
|
||||
#include "crypto/crypto.h"
|
||||
#include "net/voice_frame.h"
|
||||
#include "protocol/envelope.h"
|
||||
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
|
||||
static std::string hex(const std::vector<uint8_t>& bytes) {
|
||||
std::ostringstream result;
|
||||
result << std::hex << std::setfill('0');
|
||||
for (auto byte : bytes) result << std::setw(2) << unsigned(byte);
|
||||
return result.str();
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 2 || sodium_init() < 0) return 1;
|
||||
std::ofstream output(argv[1], std::ios::binary);
|
||||
if (!output) return 1;
|
||||
voicecat::v1::Envelope envelope;
|
||||
envelope.set_request_id(42);
|
||||
auto* hello = envelope.mutable_client_hello();
|
||||
hello->set_proto_version(1);
|
||||
hello->set_client_name("test-client");
|
||||
hello->set_client_version("0.0.1");
|
||||
hello->add_features("text");
|
||||
std::vector<uint8_t> framed;
|
||||
if (!voicecat::protocol::encode_envelope(envelope, framed)) return 1;
|
||||
output << "{\n \"envelope\": \"" << hex(framed) << "\",\n \"media\": [\n";
|
||||
std::array<uint8_t, 32> key{};
|
||||
for (size_t i = 0; i < key.size(); ++i) key[i] = uint8_t(i);
|
||||
voicecat::crypto::SodiumMediaCrypto sender(key.data());
|
||||
for (uint64_t sequence = 0; sequence <= 65536; ++sequence) {
|
||||
voicecat::net::VoiceFrame header;
|
||||
header.flags = voicecat::net::kFlagMarker;
|
||||
header.ssrc = 0xcafebabe;
|
||||
header.seq = sender.peek_send_counter();
|
||||
header.timestamp = 960;
|
||||
const size_t length = sequence == 0 ? 0 : sequence == 1 ? 100 : 8;
|
||||
std::vector<uint8_t> plaintext(length);
|
||||
for (size_t i = 0; i < length; ++i) plaintext[i] = uint8_t(i);
|
||||
std::vector<uint8_t> packet(voicecat::net::kVoiceHeaderSize + length + 16);
|
||||
voicecat::net::serialize_header(header, packet.data());
|
||||
if (sender.seal(plaintext.data(), length, packet.data(), 20, packet.data() + 20, length + 16) < 0) return 1;
|
||||
if (sequence == 0 || sequence == 1 || sequence == 65535 || sequence == 65536) {
|
||||
if (sequence != 0) output << ",\n";
|
||||
output << " {\"sequence\": " << sequence << ", \"key\": \""
|
||||
<< hex(std::vector<uint8_t>(key.begin(), key.end()))
|
||||
<< "\", \"plaintext\": \"" << hex(plaintext)
|
||||
<< "\", \"packet\": \"" << hex(packet) << "\"}";
|
||||
}
|
||||
}
|
||||
output << "\n ]\n}\n";
|
||||
return output ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
#ifdef _WIN32
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
using socket_type = SOCKET;
|
||||
static void close_socket(socket_type socket) { closesocket(socket); }
|
||||
#else
|
||||
#include <arpa/inet.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
using socket_type = int;
|
||||
static void close_socket(socket_type socket) { close(socket); }
|
||||
#endif
|
||||
|
||||
#include "crypto/crypto.h"
|
||||
#include "net/voice_frame.h"
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
|
||||
static bool transfer(voicecat::crypto::TlsContext& tls, uint8_t* data, size_t size, bool writing) {
|
||||
while (size != 0) {
|
||||
int count = writing ? tls.write(data, size) : tls.read(data, size);
|
||||
if (count <= 0) return false;
|
||||
data += count;
|
||||
size -= count;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 2 || sodium_init() < 0) return 1;
|
||||
#ifdef _WIN32
|
||||
WSADATA data{};
|
||||
if (WSAStartup(MAKEWORD(2, 2), &data) != 0) return 1;
|
||||
#endif
|
||||
try {
|
||||
auto certificate = voicecat::crypto::ServerCert::generate("dotnet-tls-oracle");
|
||||
auto directory = std::filesystem::path(argv[1]);
|
||||
socket_type listener = socket(AF_INET, SOCK_STREAM, 0);
|
||||
sockaddr_in address{};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
if (bind(listener, reinterpret_cast<sockaddr*>(&address), sizeof(address)) != 0 || listen(listener, 1) != 0) return 1;
|
||||
socklen_t length = sizeof(address);
|
||||
if (getsockname(listener, reinterpret_cast<sockaddr*>(&address), &length) != 0) return 1;
|
||||
certificate.save(directory / "server.crt", directory / "server.key");
|
||||
voicecat::crypto::ServerIdentity::generate().save(directory / "identity.key");
|
||||
std::ofstream(directory / "port.txt") << ntohs(address.sin_port);
|
||||
socket_type peer = accept(listener, nullptr, nullptr);
|
||||
close_socket(listener);
|
||||
if (peer == static_cast<socket_type>(-1)) return 1;
|
||||
voicecat::crypto::TlsContext tls(voicecat::crypto::TlsContext::Role::Server, &certificate);
|
||||
tls.set_read_timeout(10000);
|
||||
std::string error;
|
||||
if (!tls.handshake(static_cast<int>(peer), error)) { std::cerr << error; return 1; }
|
||||
auto sender = voicecat::crypto::SodiumMediaCrypto::derive_send(tls, false);
|
||||
auto receiver = voicecat::crypto::SodiumMediaCrypto::derive_recv(tls, false);
|
||||
if (!sender || !receiver) return 1;
|
||||
voicecat::net::VoiceFrame header;
|
||||
header.ssrc = 42;
|
||||
header.seq = sender->peek_send_counter();
|
||||
std::array<uint8_t, 41> packet{};
|
||||
voicecat::net::serialize_header(header, packet.data());
|
||||
const std::array<uint8_t, 5> message{ 'h', 'e', 'l', 'l', 'o' };
|
||||
if (sender->seal(message.data(), message.size(), packet.data(), 20, packet.data() + 20, 21) != 21) return 1;
|
||||
if (!transfer(tls, packet.data(), packet.size(), true) || !transfer(tls, packet.data(), packet.size(), false)) return 1;
|
||||
std::array<uint8_t, 5> recovered{};
|
||||
if (receiver->open(packet.data() + 20, 21, packet.data(), 20, recovered.data(), recovered.size()) != 5 || recovered != message) return 1;
|
||||
uint8_t acknowledgement = 1;
|
||||
if (!transfer(tls, &acknowledgement, 1, true)) return 1;
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using Org.BouncyCastle.Crypto;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
|
||||
namespace VoiceCat.Crypto;
|
||||
|
||||
internal sealed class MediaCipher : IDisposable
|
||||
{
|
||||
private readonly byte[] key;
|
||||
private readonly ChaCha20Poly1305? platformCipher;
|
||||
private bool disposed;
|
||||
|
||||
public MediaCipher(ReadOnlySpan<byte> key, bool useManaged)
|
||||
{
|
||||
if (key.Length != 32) throw new ArgumentException("Media keys must contain 32 bytes.", nameof(key));
|
||||
this.key = key.ToArray();
|
||||
if (!useManaged && ChaCha20Poly1305.IsSupported) platformCipher = new(this.key);
|
||||
}
|
||||
|
||||
public void Encrypt(ulong counter, ReadOnlySpan<byte> plaintext, ReadOnlySpan<byte> aad, Span<byte> output)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
Span<byte> nonce = stackalloc byte[12];
|
||||
nonce.Clear();
|
||||
BinaryPrimitives.WriteUInt64BigEndian(nonce[4..], counter);
|
||||
if (platformCipher is not null)
|
||||
{
|
||||
platformCipher.Encrypt(nonce, plaintext, output[..plaintext.Length], output.Slice(plaintext.Length, 16), aad);
|
||||
return;
|
||||
}
|
||||
var cipher = new Org.BouncyCastle.Crypto.Modes.ChaCha20Poly1305();
|
||||
cipher.Init(true, new AeadParameters(new KeyParameter(key), 128, nonce.ToArray(), aad.ToArray()));
|
||||
int written = cipher.ProcessBytes(plaintext, output);
|
||||
cipher.DoFinal(output[written..]);
|
||||
}
|
||||
|
||||
public bool TryDecrypt(ulong counter, ReadOnlySpan<byte> sealedPayload, ReadOnlySpan<byte> aad, Span<byte> output)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
Span<byte> nonce = stackalloc byte[12];
|
||||
nonce.Clear();
|
||||
BinaryPrimitives.WriteUInt64BigEndian(nonce[4..], counter);
|
||||
int length = sealedPayload.Length - 16;
|
||||
try
|
||||
{
|
||||
if (platformCipher is not null)
|
||||
platformCipher.Decrypt(nonce, sealedPayload[..length], sealedPayload[length..], output[..length], aad);
|
||||
else
|
||||
{
|
||||
var cipher = new Org.BouncyCastle.Crypto.Modes.ChaCha20Poly1305();
|
||||
cipher.Init(false, new AeadParameters(new KeyParameter(key), 128, nonce.ToArray(), aad.ToArray()));
|
||||
int written = cipher.ProcessBytes(sealedPayload, output);
|
||||
cipher.DoFinal(output[written..]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is AuthenticationTagMismatchException or InvalidCipherTextException)
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(output[..length]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
platformCipher?.Dispose();
|
||||
CryptographicOperations.ZeroMemory(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using VoiceCat.Protocol;
|
||||
|
||||
namespace VoiceCat.Crypto;
|
||||
|
||||
public sealed class MediaDecryptor : IDisposable
|
||||
{
|
||||
private readonly MediaCipher cipher;
|
||||
private ulong highestSequence;
|
||||
private ulong replayWindow;
|
||||
private bool initialized;
|
||||
private bool disposed;
|
||||
|
||||
public MediaDecryptor(ReadOnlySpan<byte> key) : this(key, false) { }
|
||||
|
||||
internal MediaDecryptor(ReadOnlySpan<byte> key, bool useManaged) => cipher = new(key, useManaged);
|
||||
|
||||
public bool TryDecrypt(ReadOnlySpan<byte> packet, Span<byte> plaintext, out VoiceFrameHeader header, out int bytesWritten)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
header = default;
|
||||
bytesWritten = 0;
|
||||
if (packet.Length < VoiceFrameHeader.Size + MediaEncryptor.TagSize) return false;
|
||||
int length = packet.Length - VoiceFrameHeader.Size - MediaEncryptor.TagSize;
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(plaintext.Length, length);
|
||||
if (packet.Overlaps(plaintext)) throw new ArgumentException("Input and output must not overlap.", nameof(plaintext));
|
||||
VoiceFrameHeader.TryRead(packet, out var candidate);
|
||||
ulong sequence = candidate.Sequence;
|
||||
if (initialized && sequence <= highestSequence)
|
||||
{
|
||||
ulong offset = highestSequence - sequence;
|
||||
if (offset >= 64 || (replayWindow & (1UL << (int)offset)) != 0) return false;
|
||||
}
|
||||
if (!cipher.TryDecrypt(sequence, packet[VoiceFrameHeader.Size..], packet[..VoiceFrameHeader.Size], plaintext[..length])) return false;
|
||||
|
||||
// Only authenticated counters may move the replay window.
|
||||
if (!initialized)
|
||||
{
|
||||
highestSequence = sequence;
|
||||
replayWindow = 1;
|
||||
initialized = true;
|
||||
}
|
||||
else if (sequence > highestSequence)
|
||||
{
|
||||
ulong shift = sequence - highestSequence;
|
||||
replayWindow = (shift >= 64 ? 0 : replayWindow << (int)shift) | 1;
|
||||
highestSequence = sequence;
|
||||
}
|
||||
else replayWindow |= 1UL << (int)(highestSequence - sequence);
|
||||
header = candidate;
|
||||
bytesWritten = length;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cipher.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using VoiceCat.Protocol;
|
||||
|
||||
namespace VoiceCat.Crypto;
|
||||
|
||||
public sealed class MediaEncryptor : IDisposable
|
||||
{
|
||||
private readonly MediaCipher cipher;
|
||||
private ulong nextSequence;
|
||||
private bool disposed;
|
||||
|
||||
public const int TagSize = 16;
|
||||
|
||||
public MediaEncryptor(ReadOnlySpan<byte> key) : this(key, false) { }
|
||||
|
||||
internal MediaEncryptor(ReadOnlySpan<byte> key, bool useManaged, ulong initialSequence = 0)
|
||||
{
|
||||
cipher = new(key, useManaged);
|
||||
nextSequence = initialSequence;
|
||||
}
|
||||
|
||||
public int Encrypt(VoiceFrameHeader header, ReadOnlySpan<byte> plaintext, Span<byte> packet)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
int size = checked(VoiceFrameHeader.Size + plaintext.Length + TagSize);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(packet.Length, size);
|
||||
if (nextSequence == ulong.MaxValue) throw new InvalidOperationException("Media counter exhausted; establish a new session.");
|
||||
if (plaintext.Overlaps(packet)) throw new ArgumentException("Input and output must not overlap.", nameof(packet));
|
||||
header = header with { Sequence = nextSequence++ };
|
||||
header.Write(packet);
|
||||
cipher.Encrypt(header.Sequence, plaintext, packet[..VoiceFrameHeader.Size], packet.Slice(VoiceFrameHeader.Size, plaintext.Length + TagSize));
|
||||
return size;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cipher.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace VoiceCat.Crypto;
|
||||
|
||||
internal static class PrivateFiles
|
||||
{
|
||||
public static void Write(string path, ReadOnlySpan<byte> data)
|
||||
{
|
||||
string destination = Path.GetFullPath(path);
|
||||
string temporary = destination + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
|
||||
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
|
||||
using (var stream = new FileStream(temporary, options))
|
||||
{
|
||||
stream.Write(data);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
File.Move(temporary, destination, overwrite: true);
|
||||
}
|
||||
finally { if (File.Exists(temporary)) File.Delete(temporary); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
|
||||
namespace VoiceCat.Crypto;
|
||||
|
||||
public sealed class ServerCredentials : IDisposable
|
||||
{
|
||||
private readonly X509Certificate2 certificate;
|
||||
private bool disposed;
|
||||
|
||||
private ServerCredentials(ServerIdentity identity, X509Certificate2 certificate)
|
||||
{
|
||||
Identity = identity;
|
||||
this.certificate = certificate;
|
||||
}
|
||||
|
||||
public ServerIdentity Identity { get; }
|
||||
public string CertificateFingerprint => Convert.ToHexString(SHA256.HashData(certificate.RawData));
|
||||
|
||||
public static ServerCredentials LoadOrCreate(string directory, string serverName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
|
||||
Directory.CreateDirectory(directory);
|
||||
string identityPath = Path.Combine(directory, "identity.key");
|
||||
string certificatePath = Path.Combine(directory, "server.crt");
|
||||
string keyPath = Path.Combine(directory, "server.key");
|
||||
bool hasIdentity = File.Exists(identityPath);
|
||||
bool hasCertificate = File.Exists(certificatePath);
|
||||
bool hasKey = File.Exists(keyPath);
|
||||
if (hasIdentity && hasCertificate && hasKey)
|
||||
{
|
||||
var identity = ServerIdentity.Load(identityPath);
|
||||
try { return new(identity, X509Certificate2.CreateFromPemFile(certificatePath, keyPath)); }
|
||||
catch { identity.Dispose(); throw; }
|
||||
}
|
||||
if (hasIdentity || hasCertificate || hasKey)
|
||||
throw new InvalidDataException("Server credentials are incomplete; restore the missing files before starting.");
|
||||
var generated = ServerIdentity.Generate();
|
||||
try
|
||||
{
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var name = new X500DistinguishedNameBuilder();
|
||||
name.AddCommonName(serverName);
|
||||
var request = new CertificateRequest(name.Build(), key, HashAlgorithmName.SHA256);
|
||||
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true));
|
||||
var san = new SubjectAlternativeNameBuilder();
|
||||
san.AddUri(new Uri("urn:voicecat:identity:ed25519:" + Convert.ToHexString(generated.PublicKey).ToLowerInvariant()));
|
||||
request.CertificateExtensions.Add(san.Build());
|
||||
using var created = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddYears(10));
|
||||
string certificatePem = created.ExportCertificatePem();
|
||||
string privateKeyPem = key.ExportPkcs8PrivateKeyPem();
|
||||
generated.Save(identityPath);
|
||||
PrivateFiles.Write(certificatePath, Encoding.UTF8.GetBytes(certificatePem));
|
||||
PrivateFiles.Write(keyPath, Encoding.UTF8.GetBytes(privateKeyPem));
|
||||
return new(generated, X509Certificate2.CreateFromPem(certificatePem, privateKeyPem));
|
||||
}
|
||||
catch { generated.Dispose(); throw; }
|
||||
}
|
||||
|
||||
public TlsSession CreateTlsSession()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
using var key = certificate.GetECDsaPrivateKey() ?? throw new InvalidDataException("Server TLS certificate requires an ECDSA key.");
|
||||
return TlsSession.CreateServer(certificate.ExportCertificatePem(), key.ExportPkcs8PrivateKeyPem());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
Identity.Dispose();
|
||||
certificate.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Security.Cryptography;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
|
||||
namespace VoiceCat.Crypto;
|
||||
|
||||
public sealed class ServerIdentity : IDisposable
|
||||
{
|
||||
private readonly byte[] seed;
|
||||
private readonly byte[] publicKey;
|
||||
private bool disposed;
|
||||
|
||||
private ServerIdentity(byte[] seed)
|
||||
{
|
||||
this.seed = seed;
|
||||
publicKey = new Ed25519PrivateKeyParameters(seed, 0).GeneratePublicKey().GetEncoded();
|
||||
}
|
||||
|
||||
public byte[] PublicKey => (byte[])publicKey.Clone();
|
||||
public string Fingerprint => Convert.ToHexString(SHA256.HashData(publicKey));
|
||||
|
||||
public static ServerIdentity Generate() => new(RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
public static ServerIdentity Load(string path)
|
||||
{
|
||||
byte[] data = File.ReadAllBytes(path);
|
||||
try
|
||||
{
|
||||
if (data.Length != 96) throw new InvalidDataException("Server identity must contain 96 bytes.");
|
||||
var identity = new ServerIdentity(data.AsSpan(32, 32).ToArray());
|
||||
if (!CryptographicOperations.FixedTimeEquals(identity.publicKey, data.AsSpan(0, 32)) ||
|
||||
!CryptographicOperations.FixedTimeEquals(identity.publicKey, data.AsSpan(64, 32)))
|
||||
{
|
||||
identity.Dispose();
|
||||
throw new InvalidDataException("Server identity public key does not match its seed.");
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
finally { CryptographicOperations.ZeroMemory(data); }
|
||||
}
|
||||
|
||||
public void Save(string path)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
byte[] data = new byte[96];
|
||||
publicKey.CopyTo(data, 0);
|
||||
seed.CopyTo(data, 32);
|
||||
publicKey.CopyTo(data, 64);
|
||||
try { PrivateFiles.Write(path, data); }
|
||||
finally { CryptographicOperations.ZeroMemory(data); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
CryptographicOperations.ZeroMemory(seed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using System.Security.Cryptography;
|
||||
using Org.BouncyCastle.Crypto;
|
||||
using Org.BouncyCastle.OpenSsl;
|
||||
using Org.BouncyCastle.Tls;
|
||||
using Org.BouncyCastle.Tls.Crypto;
|
||||
using Org.BouncyCastle.Tls.Crypto.Impl.BC;
|
||||
|
||||
namespace VoiceCat.Crypto;
|
||||
|
||||
public sealed class TlsSession : IDisposable
|
||||
{
|
||||
private readonly TlsProtocol protocol;
|
||||
private readonly bool isClient;
|
||||
private readonly byte[] scratch = new byte[16384];
|
||||
private byte[]? clientToServerKey;
|
||||
private byte[]? serverToClientKey;
|
||||
private bool disposed;
|
||||
|
||||
private TlsSession(TlsProtocol protocol, bool isClient)
|
||||
{
|
||||
this.protocol = protocol;
|
||||
this.isClient = isClient;
|
||||
}
|
||||
|
||||
public bool IsReady => !disposed && clientToServerKey is not null && serverToClientKey is not null && !protocol.IsClosed;
|
||||
public string? PeerCertificateFingerprint { get; private set; }
|
||||
public int PendingCiphertextBytes => protocol.GetAvailableOutputBytes();
|
||||
|
||||
public void Close()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
protocol.Close();
|
||||
}
|
||||
|
||||
public void CompleteInput()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
protocol.CloseInput();
|
||||
}
|
||||
|
||||
public static TlsSession CreateClient(Func<string, bool> acceptCertificate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(acceptCertificate);
|
||||
var protocol = new TlsClientProtocol();
|
||||
var session = new TlsSession(protocol, true);
|
||||
protocol.Connect(new ClientPeer(session, acceptCertificate));
|
||||
return session;
|
||||
}
|
||||
|
||||
public static TlsSession CreateServer(string certificatePem, string privateKeyPem)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(certificatePem);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(privateKeyPem);
|
||||
var protocol = new TlsServerProtocol();
|
||||
var session = new TlsSession(protocol, false);
|
||||
protocol.Accept(new ServerPeer(session, certificatePem, privateKeyPem));
|
||||
return session;
|
||||
}
|
||||
|
||||
public void ReceiveCiphertext(ReadOnlySpan<byte> input)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
while (!input.IsEmpty)
|
||||
{
|
||||
int count = Math.Min(input.Length, scratch.Length);
|
||||
input[..count].CopyTo(scratch);
|
||||
protocol.OfferInput(scratch, 0, count);
|
||||
input = input[count..];
|
||||
}
|
||||
}
|
||||
|
||||
public int DrainCiphertext(Span<byte> output)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
int count = protocol.ReadOutput(scratch, 0, Math.Min(output.Length, scratch.Length));
|
||||
scratch.AsSpan(0, count).CopyTo(output);
|
||||
return count;
|
||||
}
|
||||
|
||||
public int ReadPlaintext(Span<byte> output)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
int count = protocol.ReadInput(scratch, 0, Math.Min(output.Length, scratch.Length));
|
||||
scratch.AsSpan(0, count).CopyTo(output);
|
||||
CryptographicOperations.ZeroMemory(scratch.AsSpan(0, count));
|
||||
return count;
|
||||
}
|
||||
|
||||
public void WritePlaintext(ReadOnlySpan<byte> input)
|
||||
{
|
||||
RequireReady();
|
||||
protocol.WriteApplicationData(input);
|
||||
}
|
||||
|
||||
public MediaEncryptor CreateMediaEncryptor()
|
||||
{
|
||||
byte[] key = ExportMediaKey(isClient ? (byte)0 : (byte)1);
|
||||
try { return new(key); }
|
||||
finally { CryptographicOperations.ZeroMemory(key); }
|
||||
}
|
||||
|
||||
public MediaDecryptor CreateMediaDecryptor()
|
||||
{
|
||||
byte[] key = ExportMediaKey(isClient ? (byte)1 : (byte)0);
|
||||
try { return new(key); }
|
||||
finally { CryptographicOperations.ZeroMemory(key); }
|
||||
}
|
||||
|
||||
internal byte[] ExportMediaKey(byte direction)
|
||||
{
|
||||
RequireReady();
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThan(direction, (byte)1);
|
||||
return (byte[])(direction == 0 ? clientToServerKey! : serverToClientKey!).Clone();
|
||||
}
|
||||
|
||||
private void CompleteHandshake(TlsContext context)
|
||||
{
|
||||
// BouncyCastle destroys exporter secrets after this callback returns.
|
||||
clientToServerKey = context.ExportKeyingMaterial("voicecat media v1", [0], 32);
|
||||
serverToClientKey = context.ExportKeyingMaterial("voicecat media v1", [1], 32);
|
||||
}
|
||||
|
||||
private void RequireReady()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
if (!IsReady) throw new InvalidOperationException("TLS handshake has not completed or the session is closed.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
try { protocol.Close(); }
|
||||
finally
|
||||
{
|
||||
if (clientToServerKey is not null) CryptographicOperations.ZeroMemory(clientToServerKey);
|
||||
if (serverToClientKey is not null) CryptographicOperations.ZeroMemory(serverToClientKey);
|
||||
CryptographicOperations.ZeroMemory(scratch);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ClientPeer(TlsSession session, Func<string, bool> acceptCertificate)
|
||||
: DefaultTlsClient(new BcTlsCrypto())
|
||||
{
|
||||
protected override ProtocolVersion[] GetSupportedVersions() => [ProtocolVersion.TLSv13];
|
||||
protected override int[] GetSupportedCipherSuites() => CipherSuites;
|
||||
public override TlsAuthentication GetAuthentication() => new Authentication(session, acceptCertificate);
|
||||
public override void NotifyHandshakeComplete()
|
||||
{
|
||||
base.NotifyHandshakeComplete();
|
||||
session.CompleteHandshake(m_context);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Authentication(TlsSession session, Func<string, bool> acceptCertificate) : TlsAuthentication
|
||||
{
|
||||
public void NotifyServerCertificate(TlsServerCertificate serverCertificate)
|
||||
{
|
||||
var chain = serverCertificate.Certificate.GetCertificateList();
|
||||
if (chain.Length == 0) throw new TlsFatalAlert(AlertDescription.bad_certificate);
|
||||
string fingerprint = Convert.ToHexString(SHA256.HashData(chain[0].GetEncoded()));
|
||||
session.PeerCertificateFingerprint = fingerprint;
|
||||
if (!acceptCertificate(fingerprint)) throw new TlsFatalAlert(AlertDescription.bad_certificate);
|
||||
}
|
||||
|
||||
public TlsCredentials? GetClientCredentials(Org.BouncyCastle.Tls.CertificateRequest certificateRequest) => null;
|
||||
}
|
||||
|
||||
private sealed class ServerPeer : DefaultTlsServer
|
||||
{
|
||||
private readonly TlsSession session;
|
||||
private readonly byte[] certificateDer;
|
||||
private readonly AsymmetricKeyParameter privateKey;
|
||||
|
||||
public ServerPeer(TlsSession session, string certificatePem, string privateKeyPem) : base(new BcTlsCrypto())
|
||||
{
|
||||
this.session = session;
|
||||
using var certificate = System.Security.Cryptography.X509Certificates.X509Certificate2.CreateFromPem(certificatePem);
|
||||
certificateDer = certificate.RawData;
|
||||
using var reader = new StringReader(privateKeyPem);
|
||||
privateKey = (AsymmetricKeyParameter)new PemReader(reader).ReadObject();
|
||||
if (privateKey is not Org.BouncyCastle.Crypto.Parameters.ECPrivateKeyParameters)
|
||||
throw new ArgumentException("Server TLS credentials require an ECDSA key.", nameof(privateKeyPem));
|
||||
}
|
||||
|
||||
protected override ProtocolVersion[] GetSupportedVersions() => [ProtocolVersion.TLSv13];
|
||||
protected override int[] GetSupportedCipherSuites() => CipherSuites;
|
||||
public override TlsCredentials GetCredentials()
|
||||
{
|
||||
var certificate = new Certificate([], [new CertificateEntry(Crypto.CreateCertificate(certificateDer), null)]);
|
||||
return new BcDefaultTlsCredentialedSigner(new TlsCryptoParameters(m_context), (BcTlsCrypto)Crypto,
|
||||
privateKey, certificate, new SignatureAndHashAlgorithm(Org.BouncyCastle.Tls.HashAlgorithm.sha256, SignatureAlgorithm.ecdsa));
|
||||
}
|
||||
|
||||
public override void NotifyHandshakeComplete()
|
||||
{
|
||||
base.NotifyHandshakeComplete();
|
||||
session.CompleteHandshake(m_context);
|
||||
}
|
||||
}
|
||||
|
||||
private static int[] CipherSuites =>
|
||||
[
|
||||
CipherSuite.TLS_AES_128_GCM_SHA256,
|
||||
CipherSuite.TLS_AES_256_GCM_SHA384,
|
||||
CipherSuite.TLS_CHACHA20_POLY1305_SHA256
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Text;
|
||||
|
||||
namespace VoiceCat.Crypto;
|
||||
|
||||
public enum TofuStatus { FirstConnect, Matched, Mismatch }
|
||||
|
||||
public sealed class TofuStore
|
||||
{
|
||||
private readonly string path;
|
||||
private readonly Dictionary<string, string> pins = new(StringComparer.Ordinal);
|
||||
|
||||
public TofuStore(string path)
|
||||
{
|
||||
this.path = Path.GetFullPath(path);
|
||||
if (!File.Exists(this.path)) return;
|
||||
foreach (string line in File.ReadLines(this.path))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#')) continue;
|
||||
string[] parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 2) throw new InvalidDataException("Malformed TOFU pin entry.");
|
||||
pins[parts[0]] = NormalizeFingerprint(parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
public TofuStatus Check(string host, ushort port, string fingerprint)
|
||||
{
|
||||
string key = Endpoint(host, port);
|
||||
string normalized = NormalizeFingerprint(fingerprint);
|
||||
return !pins.TryGetValue(key, out var pin) ? TofuStatus.FirstConnect :
|
||||
pin == normalized ? TofuStatus.Matched : TofuStatus.Mismatch;
|
||||
}
|
||||
|
||||
public void Pin(string host, ushort port, string fingerprint)
|
||||
{
|
||||
string key = Endpoint(host, port);
|
||||
string value = NormalizeFingerprint(fingerprint);
|
||||
var updated = new Dictionary<string, string>(pins, StringComparer.Ordinal) { [key] = value };
|
||||
Save(updated);
|
||||
pins[key] = value;
|
||||
}
|
||||
|
||||
public void Remove(string host, ushort port)
|
||||
{
|
||||
string key = Endpoint(host, port);
|
||||
var updated = new Dictionary<string, string>(pins, StringComparer.Ordinal);
|
||||
updated.Remove(key);
|
||||
Save(updated);
|
||||
pins.Remove(key);
|
||||
}
|
||||
|
||||
private void Save(Dictionary<string, string> updated)
|
||||
{
|
||||
string contents = string.Concat(updated.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key} {pair.Value}\n"));
|
||||
PrivateFiles.Write(path, Encoding.UTF8.GetBytes(contents));
|
||||
}
|
||||
|
||||
private static string Endpoint(string host, ushort port)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(host);
|
||||
if (host.Any(char.IsWhiteSpace)) throw new ArgumentException("Host cannot contain whitespace.", nameof(host));
|
||||
ArgumentOutOfRangeException.ThrowIfZero(port);
|
||||
return $"{host}:{port}";
|
||||
}
|
||||
|
||||
private static string NormalizeFingerprint(string fingerprint)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fingerprint);
|
||||
if (fingerprint.Length != 64 || !fingerprint.All(Uri.IsHexDigit))
|
||||
throw new InvalidDataException("TLS certificate fingerprints must contain 64 hexadecimal characters.");
|
||||
return fingerprint.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="VoiceCat.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"Google.Protobuf": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.36.1",
|
||||
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
|
||||
},
|
||||
"voicecat.protocol": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Google.Protobuf": "[3.36.1, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Pipelines;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Google.Protobuf;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Protocol;
|
||||
|
||||
public static class ControlFraming
|
||||
{
|
||||
public const int MaxPayloadLength = 16 * 1024 * 1024;
|
||||
|
||||
public static bool TryReadFrame(ref ReadOnlySequence<byte> input, out ReadOnlySequence<byte> payload)
|
||||
{
|
||||
payload = default;
|
||||
if (input.Length < 4) return false;
|
||||
Span<byte> prefix = stackalloc byte[4];
|
||||
input.Slice(0, 4).CopyTo(prefix);
|
||||
uint length = BinaryPrimitives.ReadUInt32BigEndian(prefix);
|
||||
if (length > MaxPayloadLength) throw new InvalidDataException("Control frame exceeds 16 MiB.");
|
||||
if (input.Length < 4L + length) return false;
|
||||
payload = input.Slice(4, length);
|
||||
input = input.Slice(4L + length);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void WriteFrame(IBufferWriter<byte> output, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(output);
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThan(payload.Length, MaxPayloadLength);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(output.GetSpan(4), (uint)payload.Length);
|
||||
output.Advance(4);
|
||||
output.Write(payload);
|
||||
}
|
||||
|
||||
public static void WriteEnvelope(IBufferWriter<byte> output, Envelope envelope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
ArgumentNullException.ThrowIfNull(output);
|
||||
int length = envelope.CalculateSize();
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThan(length, MaxPayloadLength);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(output.GetSpan(4), (uint)length);
|
||||
output.Advance(4);
|
||||
envelope.WriteTo(output);
|
||||
}
|
||||
|
||||
public static async IAsyncEnumerable<Envelope> ReadEnvelopesAsync(
|
||||
PipeReader reader, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(reader);
|
||||
byte[] prefix = new byte[4];
|
||||
while (true)
|
||||
{
|
||||
if (!await ReadExactlyAsync(reader, prefix, cancellationToken).ConfigureAwait(false)) yield break;
|
||||
uint length = BinaryPrimitives.ReadUInt32BigEndian(prefix);
|
||||
if (length > MaxPayloadLength) throw new InvalidDataException("Control frame exceeds 16 MiB.");
|
||||
byte[] payload = length == 0 ? [] : new byte[length];
|
||||
if (length != 0 && !await ReadExactlyAsync(reader, payload, cancellationToken).ConfigureAwait(false))
|
||||
throw new InvalidDataException("Truncated control frame.");
|
||||
yield return Envelope.Parser.ParseFrom(payload);
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask<bool> ReadExactlyAsync(PipeReader reader, Memory<byte> destination, CancellationToken cancellationToken)
|
||||
{
|
||||
int written = 0;
|
||||
while (written < destination.Length)
|
||||
{
|
||||
ReadResult result = await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
var buffer = result.Buffer;
|
||||
var consumed = buffer.Start;
|
||||
try
|
||||
{
|
||||
if (result.IsCanceled) throw new OperationCanceledException(cancellationToken);
|
||||
int count = (int)Math.Min(buffer.Length, destination.Length - written);
|
||||
buffer.Slice(0, count).CopyTo(destination.Span[written..]);
|
||||
consumed = buffer.GetPosition(count);
|
||||
written += count;
|
||||
if (written == destination.Length) return true;
|
||||
if (result.IsCompleted)
|
||||
{
|
||||
if (written != 0) throw new InvalidDataException("Truncated control frame.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Consume fragments so pipe backpressure cannot stall a large frame.
|
||||
reader.AdvanceTo(consumed, consumed);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Google.Protobuf" Version="3.36.1" />
|
||||
<PackageReference Include="Grpc.Tools" Version="2.83.0" PrivateAssets="all" />
|
||||
<Protobuf Include="../../../core/proto/voicecat.proto" GrpcServices="None" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace VoiceCat.Protocol;
|
||||
|
||||
public enum MediaFrameType : byte
|
||||
{
|
||||
Voice = 1,
|
||||
Keepalive = 2,
|
||||
UdpBinding = 3
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum VoiceFrameFlags : byte
|
||||
{
|
||||
None = 0,
|
||||
Marker = 1,
|
||||
FecPresent = 2,
|
||||
Dtx = 4,
|
||||
Last = 8
|
||||
}
|
||||
|
||||
public readonly record struct VoiceFrameHeader(
|
||||
MediaFrameType Type, VoiceFrameFlags Flags, ushort Codec, uint Ssrc, ulong Sequence, uint Timestamp)
|
||||
{
|
||||
public const int Size = 20;
|
||||
|
||||
public void Write(Span<byte> destination)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(destination.Length, Size);
|
||||
destination[0] = (byte)Type;
|
||||
destination[1] = (byte)Flags;
|
||||
BinaryPrimitives.WriteUInt16BigEndian(destination[2..], Codec);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(destination[4..], Ssrc);
|
||||
BinaryPrimitives.WriteUInt64BigEndian(destination[8..], Sequence);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(destination[16..], Timestamp);
|
||||
}
|
||||
|
||||
public static bool TryRead(ReadOnlySpan<byte> source, out VoiceFrameHeader header)
|
||||
{
|
||||
header = default;
|
||||
if (source.Length < Size) return false;
|
||||
header = new((MediaFrameType)source[0], (VoiceFrameFlags)source[1],
|
||||
BinaryPrimitives.ReadUInt16BigEndian(source[2..]),
|
||||
BinaryPrimitives.ReadUInt32BigEndian(source[4..]),
|
||||
BinaryPrimitives.ReadUInt64BigEndian(source[8..]),
|
||||
BinaryPrimitives.ReadUInt32BigEndian(source[16..]));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Google.Protobuf": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.36.1, )",
|
||||
"resolved": "3.36.1",
|
||||
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
|
||||
},
|
||||
"Grpc.Tools": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.83.0, )",
|
||||
"resolved": "2.83.0",
|
||||
"contentHash": "vK2Go/83W0v2Nn7tTP9fGrX4IjmOa93s3M0SZeFimU1vIIr2wL9yNJlIyK21y85SGm3++JncB8IF751cjoLHuQ=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"envelope": "00000020082a521c08011204746578741a0b746573742d636c69656e742205302e302e31",
|
||||
"media": [
|
||||
{"sequence": 0, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "", "packet": "01010000cafebabe0000000000000000000003c032faa61a66270f8b198f47e32e32ca84"},
|
||||
{"sequence": 1, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f60616263", "packet": "01010000cafebabe0000000000000001000003c0695d7eda350fbe7d25787424bf19191d00e02d53daa4ea625d23af3335f38115f30cce2997de88a40961c10f8ace84e1f5cf7740bd5e62025c022a75532a11465f9322f9867fcf6a35396f86fdca1959d8512ae564c3f09eb1e8e224cd6bdef556a073c12aa45bdae5e77e1f2827b1f3e549f15c"},
|
||||
{"sequence": 65535, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "0001020304050607", "packet": "01010000cafebabe000000000000ffff000003c096bac906a2d141b97834d57095a62f947529d13f6a74a866"},
|
||||
{"sequence": 65536, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "0001020304050607", "packet": "01010000cafebabe0000000000010000000003c005ecf39e7f89b45accd35e9b5c9b45bde30713a28b8f3183"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Buffers;
|
||||
using System.IO.Pipelines;
|
||||
using Google.Protobuf;
|
||||
using VoiceCat.Protocol;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class FramingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
[InlineData(65536)]
|
||||
[InlineData(ControlFraming.MaxPayloadLength)]
|
||||
public void PayloadRoundTrips(int size)
|
||||
{
|
||||
byte[] payload = Enumerable.Range(0, size).Select(i => (byte)i).ToArray();
|
||||
var output = new ArrayBufferWriter<byte>();
|
||||
ControlFraming.WriteFrame(output, payload);
|
||||
var input = new ReadOnlySequence<byte>(output.WrittenMemory);
|
||||
Assert.True(ControlFraming.TryReadFrame(ref input, out var actual));
|
||||
Assert.Equal(payload, actual.ToArray());
|
||||
Assert.True(input.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncompleteFramesDoNotConsumeInput()
|
||||
{
|
||||
byte[] frame = [0, 0, 0, 3, 1, 2, 3];
|
||||
for (int size = 0; size < frame.Length; size++)
|
||||
{
|
||||
var input = new ReadOnlySequence<byte>(frame.AsMemory(0, size));
|
||||
Assert.False(ControlFraming.TryReadFrame(ref input, out _));
|
||||
Assert.Equal(size, input.Length);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SegmentsAndBatchedFramesAreHandled()
|
||||
{
|
||||
byte[] bytes = [0, 0, 0, 3, 1, 2, 3, 0, 0, 0, 0];
|
||||
var first = new Segment(bytes.AsMemory(0, 1));
|
||||
var last = first;
|
||||
for (int i = 1; i < bytes.Length; i++) last = last.Append(bytes.AsMemory(i, 1));
|
||||
var input = new ReadOnlySequence<byte>(first, 0, last, last.Memory.Length);
|
||||
Assert.True(ControlFraming.TryReadFrame(ref input, out var payload));
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, payload.ToArray());
|
||||
Assert.True(ControlFraming.TryReadFrame(ref input, out payload));
|
||||
Assert.True(payload.IsEmpty);
|
||||
Assert.True(input.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OversizedLengthsAreRejectedImmediately()
|
||||
{
|
||||
var input = new ReadOnlySequence<byte>(new byte[] { 1, 0, 0, 1 });
|
||||
Assert.Throws<InvalidDataException>(() => ControlFraming.TryReadFrame(ref input, out _));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => ControlFraming.WriteFrame(new ArrayBufferWriter<byte>(), new byte[ControlFraming.MaxPayloadLength + 1]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnvelopesRoundTripThroughPipe()
|
||||
{
|
||||
var expected = new Envelope { RequestId = 42, ClientHello = new() { ProtoVersion = 1, ClientName = "test-client", ClientVersion = "0.0.1" } };
|
||||
expected.ClientHello.Features.Add("text");
|
||||
var pipe = new Pipe();
|
||||
ControlFraming.WriteEnvelope(pipe.Writer, expected);
|
||||
ControlFraming.WriteEnvelope(pipe.Writer, new());
|
||||
await pipe.Writer.CompleteAsync();
|
||||
var actual = new List<Envelope>();
|
||||
await foreach (var envelope in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) actual.Add(envelope);
|
||||
Assert.Equal(new[] { expected, new Envelope() }, actual);
|
||||
await pipe.Reader.CompleteAsync();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(new byte[] { 0 })]
|
||||
[InlineData(new byte[] { 0, 0, 0, 2, 1 })]
|
||||
public async Task TruncatedEndOfStreamIsRejected(byte[] bytes)
|
||||
{
|
||||
var pipe = new Pipe();
|
||||
pipe.Writer.Write(bytes);
|
||||
await pipe.Writer.CompleteAsync();
|
||||
await Assert.ThrowsAsync<InvalidDataException>(async () =>
|
||||
{
|
||||
await foreach (var _ in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) { }
|
||||
});
|
||||
await pipe.Reader.CompleteAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidProtobufIsRejected()
|
||||
{
|
||||
var pipe = new Pipe();
|
||||
ControlFraming.WriteFrame(pipe.Writer, new byte[] { 0xff });
|
||||
await pipe.Writer.CompleteAsync();
|
||||
await Assert.ThrowsAsync<InvalidProtocolBufferException>(async () =>
|
||||
{
|
||||
await foreach (var _ in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) { }
|
||||
});
|
||||
await pipe.Reader.CompleteAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadCanBeCanceled()
|
||||
{
|
||||
var pipe = new Pipe();
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
await using var enumerator = ControlFraming.ReadEnvelopesAsync(pipe.Reader, cancellation.Token).GetAsyncEnumerator();
|
||||
var pending = enumerator.MoveNextAsync().AsTask();
|
||||
cancellation.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => pending);
|
||||
await pipe.Writer.CompleteAsync();
|
||||
await pipe.Reader.CompleteAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownFieldsSurviveParsing()
|
||||
{
|
||||
byte[] bytes = [8, 42, 0xa0, 6, 7];
|
||||
Assert.Equal(bytes, Envelope.Parser.ParseFrom(bytes).ToByteArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FragmentedLargeEnvelopeMakesProgressUnderBackpressure()
|
||||
{
|
||||
var envelope = new Envelope { ClientHello = new() { ClientName = new string('a', 200000) } };
|
||||
var framed = new ArrayBufferWriter<byte>();
|
||||
ControlFraming.WriteEnvelope(framed, envelope);
|
||||
var pipe = new Pipe(new PipeOptions(pauseWriterThreshold: 32, resumeWriterThreshold: 16));
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
async Task Produce()
|
||||
{
|
||||
for (int offset = 0; offset < framed.WrittenCount; offset += 7)
|
||||
await pipe.Writer.WriteAsync(framed.WrittenMemory.Slice(offset, Math.Min(7, framed.WrittenCount - offset)), timeout.Token);
|
||||
await pipe.Writer.CompleteAsync();
|
||||
}
|
||||
var producer = Produce();
|
||||
var actual = new List<Envelope>();
|
||||
await foreach (var item in ControlFraming.ReadEnvelopesAsync(pipe.Reader, timeout.Token)) actual.Add(item);
|
||||
await producer;
|
||||
Assert.Equal(new[] { envelope }, actual);
|
||||
await pipe.Reader.CompleteAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoppingEnumerationLeavesFollowingFramesAvailable()
|
||||
{
|
||||
var pipe = new Pipe();
|
||||
ControlFraming.WriteEnvelope(pipe.Writer, new() { RequestId = 1 });
|
||||
ControlFraming.WriteEnvelope(pipe.Writer, new() { RequestId = 2 });
|
||||
await pipe.Writer.FlushAsync();
|
||||
await using (var first = ControlFraming.ReadEnvelopesAsync(pipe.Reader).GetAsyncEnumerator())
|
||||
{
|
||||
Assert.True(await first.MoveNextAsync());
|
||||
Assert.Equal(1UL, first.Current.RequestId);
|
||||
}
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await using (var second = ControlFraming.ReadEnvelopesAsync(pipe.Reader, timeout.Token).GetAsyncEnumerator())
|
||||
{
|
||||
Assert.True(await second.MoveNextAsync());
|
||||
Assert.Equal(2UL, second.Current.RequestId);
|
||||
}
|
||||
await pipe.Writer.CompleteAsync();
|
||||
await pipe.Reader.CompleteAsync();
|
||||
}
|
||||
|
||||
private sealed class Segment : ReadOnlySequenceSegment<byte>
|
||||
{
|
||||
public Segment(ReadOnlyMemory<byte> memory) => Memory = memory;
|
||||
|
||||
public Segment Append(ReadOnlyMemory<byte> memory)
|
||||
{
|
||||
var segment = new Segment(memory) { RunningIndex = RunningIndex + Memory.Length };
|
||||
Next = segment;
|
||||
return segment;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Buffers;
|
||||
using System.Text.Json;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Protocol;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class GoldenTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnvelopeMatchesCppFixture()
|
||||
{
|
||||
using var fixture = Load();
|
||||
var expected = Convert.FromHexString(fixture.RootElement.GetProperty("envelope").GetString()!);
|
||||
var envelope = new Envelope { RequestId = 42, ClientHello = new() { ProtoVersion = 1, ClientName = "test-client", ClientVersion = "0.0.1" } };
|
||||
envelope.ClientHello.Features.Add("text");
|
||||
var output = new ArrayBufferWriter<byte>();
|
||||
ControlFraming.WriteEnvelope(output, envelope);
|
||||
Assert.Equal(expected, output.WrittenSpan.ToArray());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void MediaPacketsMatchCppFixtures(bool managed)
|
||||
{
|
||||
using var fixture = Load();
|
||||
foreach (var vector in fixture.RootElement.GetProperty("media").EnumerateArray())
|
||||
{
|
||||
byte[] key = Convert.FromHexString(vector.GetProperty("key").GetString()!);
|
||||
byte[] plaintext = Convert.FromHexString(vector.GetProperty("plaintext").GetString()!);
|
||||
byte[] expected = Convert.FromHexString(vector.GetProperty("packet").GetString()!);
|
||||
ulong sequence = vector.GetProperty("sequence").GetUInt64();
|
||||
using var sender = new MediaEncryptor(key, managed, sequence);
|
||||
using var receiver = new MediaDecryptor(key, managed);
|
||||
var header = new VoiceFrameHeader(MediaFrameType.Voice, VoiceFrameFlags.Marker, 0, 0xcafebabe, 0, 960);
|
||||
byte[] actual = new byte[expected.Length];
|
||||
sender.Encrypt(header, plaintext, actual);
|
||||
Assert.Equal(expected, actual);
|
||||
byte[] decoded = new byte[plaintext.Length];
|
||||
Assert.True(receiver.TryDecrypt(expected, decoded, out var parsed, out int written));
|
||||
Assert.Equal(sequence, parsed.Sequence);
|
||||
Assert.Equal(plaintext.Length, written);
|
||||
Assert.Equal(plaintext, decoded);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonDocument Load() => JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "cpp-wire.json")));
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Formats.Asn1;
|
||||
using VoiceCat.Crypto;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class IdentityTests
|
||||
{
|
||||
[Fact]
|
||||
public void CredentialsSurviveRestartAndBindIdentityIntoCertificate()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-credentials-" + Guid.NewGuid());
|
||||
try
|
||||
{
|
||||
string identityFingerprint, certificateFingerprint;
|
||||
using (var credentials = ServerCredentials.LoadOrCreate(directory, "Server, with punctuation"))
|
||||
{
|
||||
identityFingerprint = credentials.Identity.Fingerprint;
|
||||
certificateFingerprint = credentials.CertificateFingerprint;
|
||||
using var tls = credentials.CreateTlsSession();
|
||||
Assert.False(tls.IsReady);
|
||||
byte[] identity = File.ReadAllBytes(Path.Combine(directory, "identity.key"));
|
||||
Assert.Equal(96, identity.Length);
|
||||
Assert.Equal(identity[..32], identity[64..]);
|
||||
using var certificate = X509Certificate2.CreateFromPem(File.ReadAllText(Path.Combine(directory, "server.crt")));
|
||||
var san = new AsnReader(certificate.Extensions["2.5.29.17"]!.RawData, AsnEncodingRules.DER).ReadSequence();
|
||||
Assert.Equal("urn:voicecat:identity:ed25519:" + Convert.ToHexString(credentials.Identity.PublicKey).ToLowerInvariant(),
|
||||
san.ReadCharacterString(UniversalTagNumber.IA5String, new Asn1Tag(TagClass.ContextSpecific, 6)));
|
||||
Assert.False(san.HasData);
|
||||
}
|
||||
using var restored = ServerCredentials.LoadOrCreate(directory, "ignored after creation");
|
||||
Assert.Equal(identityFingerprint, restored.Identity.Fingerprint);
|
||||
Assert.Equal(certificateFingerprint, restored.CertificateFingerprint);
|
||||
File.Delete(Path.Combine(directory, "server.key"));
|
||||
Assert.Throws<InvalidDataException>(() => ServerCredentials.LoadOrCreate(directory, "unchanged"));
|
||||
using var stillPresent = ServerIdentity.Load(Path.Combine(directory, "identity.key"));
|
||||
Assert.Equal(identityFingerprint, stillPresent.Fingerprint);
|
||||
}
|
||||
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TofuRequiresExplicitPinAndPreservesCppFileFormat()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-pins-" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(directory);
|
||||
string path = Path.Combine(directory, "pins.txt");
|
||||
string fingerprint = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
||||
try
|
||||
{
|
||||
var store = new TofuStore(path);
|
||||
Assert.Equal(TofuStatus.FirstConnect, store.Check("localhost", 9987, fingerprint));
|
||||
Assert.False(File.Exists(path));
|
||||
store.Pin("localhost", 9987, fingerprint);
|
||||
Assert.Equal($"localhost:9987 {fingerprint.ToLowerInvariant()}\n", File.ReadAllText(path));
|
||||
store = new(path);
|
||||
Assert.Equal(TofuStatus.Matched, store.Check("localhost", 9987, fingerprint));
|
||||
Assert.Equal(TofuStatus.Mismatch, store.Check("localhost", 9987, new string('0', 64)));
|
||||
Assert.Equal(TofuStatus.Matched, new TofuStore(path).Check("localhost", 9987, fingerprint));
|
||||
store.Remove("localhost", 9987);
|
||||
Assert.Equal(TofuStatus.FirstConnect, new TofuStore(path).Check("localhost", 9987, fingerprint));
|
||||
File.WriteAllText(path, "localhost:9987 " + new string('g', 64));
|
||||
Assert.Throws<InvalidDataException>(() => new TofuStore(path));
|
||||
}
|
||||
finally { Directory.Delete(directory, true); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Buffers.Binary;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Protocol;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class MediaTests
|
||||
{
|
||||
private static readonly byte[] Key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
|
||||
private static readonly VoiceFrameHeader Header = new(MediaFrameType.Voice, VoiceFrameFlags.Marker, 0, 0xcafebabe, 0, 960);
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void BothBackendsProduceIdenticalPackets(bool managed)
|
||||
{
|
||||
using var sender = new MediaEncryptor(Key, managed);
|
||||
using var receiver = new MediaDecryptor(Key, !managed);
|
||||
byte[] plaintext = Enumerable.Range(0, 100).Select(i => (byte)i).ToArray();
|
||||
byte[] packet = Seal(sender, plaintext);
|
||||
byte[] output = new byte[plaintext.Length];
|
||||
Assert.True(receiver.TryDecrypt(packet, output, out var header, out int written));
|
||||
Assert.Equal(Header, header);
|
||||
Assert.Equal(plaintext.Length, written);
|
||||
Assert.Equal(plaintext, output);
|
||||
Assert.False(receiver.TryDecrypt(packet, output, out _, out written));
|
||||
Assert.Equal(0, written);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void ForgedCounterDoesNotPoisonReplayWindow(bool managed)
|
||||
{
|
||||
using var sender = new MediaEncryptor(Key, managed);
|
||||
using var receiver = new MediaDecryptor(Key, managed);
|
||||
byte[] output = new byte[8];
|
||||
Assert.True(receiver.TryDecrypt(Seal(sender, new byte[8]), output, out _, out _));
|
||||
byte[] packet = Seal(sender, new byte[8]);
|
||||
byte[] forged = (byte[])packet.Clone();
|
||||
BinaryPrimitives.WriteUInt64BigEndian(forged.AsSpan(8), ulong.MaxValue);
|
||||
Array.Fill(output, (byte)0xaa);
|
||||
Assert.False(receiver.TryDecrypt(forged, output, out var header, out int written));
|
||||
Assert.Equal(default, header);
|
||||
Assert.Equal(0, written);
|
||||
Assert.All(output, value => Assert.Equal(0, value));
|
||||
Assert.True(receiver.TryDecrypt(packet, output, out _, out _));
|
||||
Assert.True(receiver.TryDecrypt(Seal(sender, new byte[8]), output, out _, out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void TamperingEveryPacketRegionFailsAuthentication(bool managed)
|
||||
{
|
||||
using var sender = new MediaEncryptor(Key, managed);
|
||||
byte[] packet = Seal(sender, new byte[80]);
|
||||
for (int i = 0; i < packet.Length; i++)
|
||||
{
|
||||
using var receiver = new MediaDecryptor(Key, managed);
|
||||
byte[] tampered = (byte[])packet.Clone();
|
||||
tampered[i] ^= 0x80;
|
||||
Assert.False(receiver.TryDecrypt(tampered, new byte[80], out _, out _));
|
||||
Assert.True(receiver.TryDecrypt(packet, new byte[80], out _, out _));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void ReplayWindowAcceptsReorderingAndRejectsOldPackets(bool managed)
|
||||
{
|
||||
using var sender = new MediaEncryptor(Key, managed);
|
||||
using var receiver = new MediaDecryptor(Key, managed);
|
||||
var packets = Enumerable.Range(0, 130).Select(_ => Seal(sender, new byte[1])).ToArray();
|
||||
byte[] output = new byte[1];
|
||||
Assert.True(receiver.TryDecrypt(packets[64], output, out _, out _));
|
||||
Assert.False(receiver.TryDecrypt(packets[0], output, out _, out _));
|
||||
Assert.True(receiver.TryDecrypt(packets[1], output, out _, out _));
|
||||
Assert.False(receiver.TryDecrypt(packets[1], output, out _, out _));
|
||||
Assert.True(receiver.TryDecrypt(packets[63], output, out _, out _));
|
||||
Assert.True(receiver.TryDecrypt(packets[129], output, out _, out _));
|
||||
Assert.False(receiver.TryDecrypt(packets[64], output, out _, out _));
|
||||
Assert.True(receiver.TryDecrypt(packets[128], output, out _, out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void CounterCrossesOldSixteenBitBoundary(bool managed)
|
||||
{
|
||||
using var sender = new MediaEncryptor(Key, managed, 65534);
|
||||
using var receiver = new MediaDecryptor(Key, managed);
|
||||
for (ulong sequence = 65534; sequence < 65540; sequence++)
|
||||
{
|
||||
Assert.True(receiver.TryDecrypt(Seal(sender, new byte[1]), new byte[1], out var header, out _));
|
||||
Assert.Equal(sequence, header.Sequence);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void InterleavedRelayUsesRecipientCounter(bool managed)
|
||||
{
|
||||
byte[] otherKey = Enumerable.Repeat((byte)42, 32).ToArray();
|
||||
using var a = new MediaEncryptor(Key, managed);
|
||||
using var b = new MediaEncryptor(otherKey, managed);
|
||||
using var receiveA = new MediaDecryptor(Key, managed);
|
||||
using var receiveB = new MediaDecryptor(otherKey, managed);
|
||||
using var relay = new MediaEncryptor(Key, managed);
|
||||
using var listener = new MediaDecryptor(Key, managed);
|
||||
byte[] plaintext = [1, 2, 3];
|
||||
byte[] decoded = new byte[3];
|
||||
for (int i = 0; i < 16; i++)
|
||||
{
|
||||
var sender = i % 2 == 0 ? a : b;
|
||||
var receiver = i % 2 == 0 ? receiveA : receiveB;
|
||||
Assert.True(receiver.TryDecrypt(Seal(sender, plaintext), decoded, out var header, out _));
|
||||
byte[] packet = new byte[39];
|
||||
relay.Encrypt(header, decoded, packet);
|
||||
Assert.True(listener.TryDecrypt(packet, decoded, out var relayedHeader, out _));
|
||||
Assert.Equal((ulong)i, relayedHeader.Sequence);
|
||||
Assert.Equal(plaintext, decoded);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void EmptyPayloadAndLargeCountersWork(bool managed)
|
||||
{
|
||||
using var sender = new MediaEncryptor(Key, managed, ulong.MaxValue - 1);
|
||||
using var receiver = new MediaDecryptor(Key, managed);
|
||||
var packet = Seal(sender, []);
|
||||
Assert.True(receiver.TryDecrypt(packet, [], out var header, out int written));
|
||||
Assert.Equal(ulong.MaxValue - 1, header.Sequence);
|
||||
Assert.Equal(0, written);
|
||||
Assert.Throws<InvalidOperationException>(() => Seal(sender, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidArgumentsAndDisposedInstancesAreRejected()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new MediaEncryptor(new byte[31]));
|
||||
using var sender = new MediaEncryptor(Key);
|
||||
using var receiver = new MediaDecryptor(Key);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => sender.Encrypt(Header, new byte[1], new byte[36]));
|
||||
byte[] packet = Seal(sender, new byte[8]);
|
||||
Assert.True(receiver.TryDecrypt(packet, new byte[8], out var header, out _));
|
||||
Assert.Equal(0UL, header.Sequence);
|
||||
Assert.False(receiver.TryDecrypt(new byte[35], [], out _, out _));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => receiver.TryDecrypt(packet, [], out _, out _));
|
||||
sender.Dispose();
|
||||
receiver.Dispose();
|
||||
Assert.Throws<ObjectDisposedException>(() => Seal(sender, []));
|
||||
Assert.Throws<ObjectDisposedException>(() => receiver.TryDecrypt(packet, new byte[8], out _, out _));
|
||||
}
|
||||
|
||||
private static byte[] Seal(MediaEncryptor sender, byte[] plaintext)
|
||||
{
|
||||
byte[] packet = new byte[VoiceFrameHeader.Size + plaintext.Length + MediaEncryptor.TagSize];
|
||||
Assert.Equal(packet.Length, sender.Encrypt(Header, plaintext, packet));
|
||||
return packet;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Protocol;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class TlsInteropTests
|
||||
{
|
||||
[TlsOracleFact]
|
||||
public async Task ManagedClientAndCppServerAgreeOnExporterKeysAndCertificate()
|
||||
{
|
||||
string? oracle = Environment.GetEnvironmentVariable("VOICECAT_TLS_ORACLE");
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-tls-" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(directory);
|
||||
var start = new ProcessStartInfo(oracle!) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true };
|
||||
start.ArgumentList.Add(directory);
|
||||
using var process = Process.Start(start)!;
|
||||
var error = process.StandardError.ReadToEndAsync();
|
||||
var stdout = process.StandardOutput.ReadToEndAsync();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
try
|
||||
{
|
||||
int port = 0;
|
||||
while (!int.TryParse(File.Exists(Path.Combine(directory, "port.txt")) ? await File.ReadAllTextAsync(Path.Combine(directory, "port.txt"), timeout.Token) : "", out port))
|
||||
{
|
||||
Assert.False(process.HasExited, "C++ TLS oracle exited before listening.");
|
||||
await Task.Delay(20, timeout.Token);
|
||||
}
|
||||
using var certificate = X509Certificate2.CreateFromPem(await File.ReadAllTextAsync(Path.Combine(directory, "server.crt"), timeout.Token));
|
||||
string fingerprint = Convert.ToHexString(SHA256.HashData(certificate.RawData));
|
||||
using var credentials = ServerCredentials.LoadOrCreate(directory, "existing C++ identity");
|
||||
Assert.Equal(fingerprint, credentials.CertificateFingerprint);
|
||||
Assert.Equal(32, credentials.Identity.PublicKey.Length);
|
||||
using var client = TlsSession.CreateClient(value => value == fingerprint);
|
||||
using var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync("127.0.0.1", port, timeout.Token);
|
||||
byte[] buffer = new byte[16384];
|
||||
async Task Flush()
|
||||
{
|
||||
while (client.PendingCiphertextBytes > 0)
|
||||
{
|
||||
int count = client.DrainCiphertext(buffer);
|
||||
int sent = 0;
|
||||
while (sent < count) sent += await socket.SendAsync(buffer.AsMemory(sent, count - sent), SocketFlags.None, timeout.Token);
|
||||
}
|
||||
}
|
||||
async Task Receive()
|
||||
{
|
||||
int count = await socket.ReceiveAsync(buffer, SocketFlags.None, timeout.Token);
|
||||
Assert.True(count > 0, "TLS oracle closed unexpectedly.");
|
||||
client.ReceiveCiphertext(buffer.AsSpan(0, count));
|
||||
}
|
||||
while (!client.IsReady) { await Flush(); await Receive(); }
|
||||
await Flush();
|
||||
byte[] packet = new byte[41];
|
||||
int received = 0;
|
||||
while (received < packet.Length)
|
||||
{
|
||||
int count = client.ReadPlaintext(packet.AsSpan(received));
|
||||
received += count;
|
||||
if (count == 0) { await Flush(); await Receive(); }
|
||||
}
|
||||
using var decryptor = client.CreateMediaDecryptor();
|
||||
byte[] plaintext = new byte[5];
|
||||
Assert.True(decryptor.TryDecrypt(packet, plaintext, out var header, out _));
|
||||
Assert.Equal("hello"u8.ToArray(), plaintext);
|
||||
Assert.Equal(fingerprint, client.PeerCertificateFingerprint);
|
||||
using var encryptor = client.CreateMediaEncryptor();
|
||||
encryptor.Encrypt(header, plaintext, packet);
|
||||
client.WritePlaintext(packet);
|
||||
await Flush();
|
||||
byte[] ack = new byte[1];
|
||||
while (client.ReadPlaintext(ack) == 0) { await Flush(); await Receive(); }
|
||||
Assert.Equal(1, ack[0]);
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
Assert.True(process.ExitCode == 0, await error);
|
||||
await stdout;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!process.HasExited) { process.Kill(entireProcessTree: true); await process.WaitForExitAsync(); }
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TlsOracleFactAttribute : FactAttribute
|
||||
{
|
||||
public TlsOracleFactAttribute()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_TLS_ORACLE")))
|
||||
Skip = "Build the native TLS oracle and set VOICECAT_TLS_ORACLE to its executable path.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Protocol;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class TlsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ManagedTlsHandshakeExportsMatchingDirectionalKeys()
|
||||
{
|
||||
var (pem, key, fingerprint) = Credentials();
|
||||
using var server = TlsSession.CreateServer(pem, key);
|
||||
using var client = TlsSession.CreateClient(value => value == fingerprint);
|
||||
Assert.Throws<InvalidOperationException>(() => client.CreateMediaEncryptor());
|
||||
Handshake(client, server);
|
||||
Assert.Equal(fingerprint, client.PeerCertificateFingerprint);
|
||||
Assert.Equal(server.ExportMediaKey(0), client.ExportMediaKey(0));
|
||||
Assert.Equal(server.ExportMediaKey(1), client.ExportMediaKey(1));
|
||||
Assert.NotEqual(client.ExportMediaKey(0), client.ExportMediaKey(1));
|
||||
client.WritePlaintext("hello"u8);
|
||||
Pump(client, server);
|
||||
byte[] output = new byte[5];
|
||||
Assert.Equal(5, server.ReadPlaintext(output));
|
||||
Assert.Equal("hello"u8.ToArray(), output);
|
||||
using var encryptor = server.CreateMediaEncryptor();
|
||||
using var decryptor = client.CreateMediaDecryptor();
|
||||
byte[] packet = new byte[41];
|
||||
encryptor.Encrypt(new(MediaFrameType.Voice, 0, 0, 42, 0, 960), "hello"u8, packet);
|
||||
Assert.True(decryptor.TryDecrypt(packet, output, out _, out _));
|
||||
Assert.Equal("hello"u8.ToArray(), output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CertificateRejectionPreventsApplicationDataAndMediaKeys()
|
||||
{
|
||||
var (pem, key, _) = Credentials();
|
||||
using var server = TlsSession.CreateServer(pem, key);
|
||||
using var client = TlsSession.CreateClient(_ => false);
|
||||
Assert.ThrowsAny<IOException>(() => Handshake(client, server));
|
||||
Assert.False(client.IsReady);
|
||||
Assert.Throws<InvalidOperationException>(() => client.CreateMediaDecryptor());
|
||||
Assert.Throws<InvalidOperationException>(() => client.WritePlaintext("secret"u8));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseNotifyEndsSessionAndAbruptEofIsRejected()
|
||||
{
|
||||
var (pem, key, fingerprint) = Credentials();
|
||||
using var server = TlsSession.CreateServer(pem, key);
|
||||
using var client = TlsSession.CreateClient(value => value == fingerprint);
|
||||
Handshake(client, server);
|
||||
client.Close();
|
||||
Pump(client, server);
|
||||
Assert.False(client.IsReady);
|
||||
Assert.False(server.IsReady);
|
||||
server.CompleteInput();
|
||||
using var incomplete = TlsSession.CreateClient(_ => true);
|
||||
Assert.ThrowsAny<IOException>(() => incomplete.CompleteInput());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TlsTwelveCannotNegotiateWithManagedServer()
|
||||
{
|
||||
var (pem, key, _) = Credentials();
|
||||
using var server = TlsSession.CreateServer(pem, key);
|
||||
var legacy = new Org.BouncyCastle.Tls.TlsClientProtocol();
|
||||
legacy.Connect(new LegacyPeer());
|
||||
byte[] hello = new byte[legacy.GetAvailableOutputBytes()];
|
||||
legacy.ReadOutput(hello, 0, hello.Length);
|
||||
Assert.ThrowsAny<IOException>(() => server.ReceiveCiphertext(hello));
|
||||
Assert.False(server.IsReady);
|
||||
Assert.Throws<InvalidOperationException>(() => server.CreateMediaEncryptor());
|
||||
}
|
||||
|
||||
private sealed class LegacyPeer() : Org.BouncyCastle.Tls.DefaultTlsClient(new Org.BouncyCastle.Tls.Crypto.Impl.BC.BcTlsCrypto())
|
||||
{
|
||||
protected override Org.BouncyCastle.Tls.ProtocolVersion[] GetSupportedVersions() => [Org.BouncyCastle.Tls.ProtocolVersion.TLSv12];
|
||||
public override Org.BouncyCastle.Tls.TlsAuthentication GetAuthentication() => throw new InvalidOperationException("TLS 1.2 must be rejected before authentication.");
|
||||
}
|
||||
|
||||
internal static (string Certificate, string Key, string Fingerprint) Credentials()
|
||||
{
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var request = new System.Security.Cryptography.X509Certificates.CertificateRequest("CN=VoiceCat TLS test", key, HashAlgorithmName.SHA256);
|
||||
using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(1));
|
||||
return (certificate.ExportCertificatePem(), key.ExportPkcs8PrivateKeyPem(), Convert.ToHexString(SHA256.HashData(certificate.RawData)));
|
||||
}
|
||||
|
||||
internal static void Handshake(TlsSession client, TlsSession server)
|
||||
{
|
||||
for (int i = 0; i < 100 && (!client.IsReady || !server.IsReady); i++)
|
||||
{
|
||||
Pump(client, server);
|
||||
Pump(server, client);
|
||||
}
|
||||
Assert.True(client.IsReady);
|
||||
Assert.True(server.IsReady);
|
||||
}
|
||||
|
||||
private static void Pump(TlsSession sender, TlsSession receiver)
|
||||
{
|
||||
byte[] buffer = new byte[17];
|
||||
while (sender.PendingCiphertextBytes > 0)
|
||||
{
|
||||
int count = sender.DrainCiphertext(buffer);
|
||||
receiver.ReceiveCiphertext(buffer.AsSpan(0, count));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using VoiceCat.Crypto;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class TofuTlsTests
|
||||
{
|
||||
[Fact]
|
||||
public void RealHandshakesRequireAcceptanceAndRejectChangedCertificatesAfterRestart()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-tofu-tls-" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(directory);
|
||||
string path = Path.Combine(directory, "pins.txt");
|
||||
try
|
||||
{
|
||||
using var credentials = ServerCredentials.LoadOrCreate(Path.Combine(directory, "server"), "server");
|
||||
var store = new TofuStore(path);
|
||||
using (var server = credentials.CreateTlsSession())
|
||||
using (var rejected = TlsSession.CreateClient(fingerprint =>
|
||||
{
|
||||
Assert.Equal(TofuStatus.FirstConnect, store.Check("localhost", 9987, fingerprint));
|
||||
return false;
|
||||
}))
|
||||
Assert.ThrowsAny<IOException>(() => TlsTests.Handshake(rejected, server));
|
||||
Assert.False(File.Exists(path));
|
||||
using (var server = credentials.CreateTlsSession())
|
||||
using (var accepted = TlsSession.CreateClient(fingerprint =>
|
||||
{
|
||||
Assert.Equal(TofuStatus.FirstConnect, store.Check("localhost", 9987, fingerprint));
|
||||
store.Pin("localhost", 9987, fingerprint);
|
||||
return true;
|
||||
}))
|
||||
TlsTests.Handshake(accepted, server);
|
||||
store = new(path);
|
||||
using (var server = credentials.CreateTlsSession())
|
||||
using (var returning = TlsSession.CreateClient(fingerprint => store.Check("localhost", 9987, fingerprint) == TofuStatus.Matched))
|
||||
TlsTests.Handshake(returning, server);
|
||||
using var rotated = ServerCredentials.LoadOrCreate(Path.Combine(directory, "rotated"), "server");
|
||||
using (var server = rotated.CreateTlsSession())
|
||||
using (var mismatch = TlsSession.CreateClient(fingerprint =>
|
||||
{
|
||||
Assert.Equal(TofuStatus.Mismatch, store.Check("localhost", 9987, fingerprint));
|
||||
return false;
|
||||
}))
|
||||
Assert.ThrowsAny<IOException>(() => TlsTests.Handshake(mismatch, server));
|
||||
Assert.Equal(TofuStatus.Matched, new TofuStore(path).Check("localhost", 9987, credentials.CertificateFingerprint));
|
||||
}
|
||||
finally { Directory.Delete(directory, true); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1" PrivateAssets="all" />
|
||||
<ProjectReference Include="../../src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
|
||||
<ProjectReference Include="../../src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
|
||||
<Using Include="Xunit" />
|
||||
<None Update="Fixtures/*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,19 @@
|
||||
using VoiceCat.Protocol;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class VoiceHeaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void HeaderUsesBigEndianFieldsAndPreservesUnknownValues()
|
||||
{
|
||||
var header = new VoiceFrameHeader((MediaFrameType)255, (VoiceFrameFlags)128, 0x1234, 0x56789abc, 0x0123456789abcdef, 0xfedcba98);
|
||||
byte[] bytes = new byte[20];
|
||||
header.Write(bytes);
|
||||
Assert.Equal("FF80123456789ABC0123456789ABCDEFFEDCBA98", Convert.ToHexString(bytes));
|
||||
Assert.True(VoiceFrameHeader.TryRead(bytes, out var parsed));
|
||||
Assert.Equal(header, parsed);
|
||||
Assert.False(VoiceFrameHeader.TryRead(bytes.AsSpan(0, 19), out _));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => header.Write(new byte[19]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Microsoft.NET.Test.Sdk": {
|
||||
"type": "Direct",
|
||||
"requested": "[17.14.1, )",
|
||||
"resolved": "17.14.1",
|
||||
"contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.CodeCoverage": "17.14.1",
|
||||
"Microsoft.TestPlatform.TestHost": "17.14.1"
|
||||
}
|
||||
},
|
||||
"xunit": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.9.3, )",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
|
||||
"dependencies": {
|
||||
"xunit.analyzers": "1.18.0",
|
||||
"xunit.assert": "2.9.3",
|
||||
"xunit.core": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"xunit.runner.visualstudio": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.1.1, )",
|
||||
"resolved": "3.1.1",
|
||||
"contentHash": "gNu2zhnuwjq5vQlU4S7yK/lfaKZDLmtcu+vTjnhfTlMAUYn+Hmgu8IIX0UCwWepYkk+Szx03DHx1bDnc9Fd+9w=="
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"Google.Protobuf": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.36.1",
|
||||
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
|
||||
},
|
||||
"Microsoft.CodeCoverage": {
|
||||
"type": "Transitive",
|
||||
"resolved": "17.14.1",
|
||||
"contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg=="
|
||||
},
|
||||
"Microsoft.TestPlatform.ObjectModel": {
|
||||
"type": "Transitive",
|
||||
"resolved": "17.14.1",
|
||||
"contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ=="
|
||||
},
|
||||
"Microsoft.TestPlatform.TestHost": {
|
||||
"type": "Transitive",
|
||||
"resolved": "17.14.1",
|
||||
"contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==",
|
||||
"dependencies": {
|
||||
"Microsoft.TestPlatform.ObjectModel": "17.14.1",
|
||||
"Newtonsoft.Json": "13.0.3"
|
||||
}
|
||||
},
|
||||
"Newtonsoft.Json": {
|
||||
"type": "Transitive",
|
||||
"resolved": "13.0.3",
|
||||
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
|
||||
},
|
||||
"xunit.abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.0.3",
|
||||
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
|
||||
},
|
||||
"xunit.analyzers": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.18.0",
|
||||
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
|
||||
},
|
||||
"xunit.assert": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
|
||||
},
|
||||
"xunit.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
|
||||
"dependencies": {
|
||||
"xunit.extensibility.core": "[2.9.3]",
|
||||
"xunit.extensibility.execution": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"xunit.extensibility.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
|
||||
"dependencies": {
|
||||
"xunit.abstractions": "2.0.3"
|
||||
}
|
||||
},
|
||||
"xunit.extensibility.execution": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.9.3",
|
||||
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
|
||||
"dependencies": {
|
||||
"xunit.extensibility.core": "[2.9.3]"
|
||||
}
|
||||
},
|
||||
"voicecat.crypto": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "[2.6.2, )",
|
||||
"VoiceCat.Protocol": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"voicecat.protocol": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Google.Protobuf": "[3.36.1, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user