Compare commits
6
Commits
cs-port
...
653131b876
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
653131b876 | ||
|
|
274b85025c | ||
|
|
05eacb3092 | ||
|
|
4067bab7c2 | ||
|
|
2df79cdd4c | ||
|
|
b76181d9fb |
@@ -0,0 +1,64 @@
|
|||||||
|
name: .NET port
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths: ['dotnet/**', 'core/**', 'server/**', 'tests/**', 'third_party/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml']
|
||||||
|
pull_request:
|
||||||
|
paths: ['dotnet/**', 'core/**', 'server/**', 'tests/**', 'third_party/**', '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
|
||||||
|
- name: Build and stage native codec/DSP
|
||||||
|
shell: pwsh
|
||||||
|
run: ./dotnet/build-native.ps1
|
||||||
|
- 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
|
||||||
|
./build/dev/bin/voicecat-dotnet-password-oracle build/dev/cpp-passwords.json
|
||||||
|
diff -u dotnet/tests/VoiceCat.Tests/Fixtures/cpp-passwords.json build/dev/cpp-passwords.json
|
||||||
|
./build/dev/bin/voicecat-dotnet-dsp-oracle build/dev/cpp-noise.json
|
||||||
|
pwsh -File dotnet/compare-dsp-fixtures.ps1 dotnet/tests/VoiceCat.Tests/Fixtures/cpp-noise.json build/dev/cpp-noise.json
|
||||||
|
pwsh -File dotnet/build-native.ps1
|
||||||
|
dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||||
|
VOICECAT_TLS_ORACLE="$PWD/build/dev/bin/voicecat-dotnet-tls-oracle" VOICECAT_DATABASE_ORACLE="$PWD/build/dev/bin/voicecat-dotnet-database-oracle" VOICECAT_VOICE_ORACLE="$PWD/build/dev/bin/voicecat-dotnet-voice-oracle" VOICECAT_VCCLI="$PWD/build/dev/bin/vccli" dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
# Build output
|
# Build output
|
||||||
|
/dotnet/**/bin/
|
||||||
|
/dotnet/**/obj/
|
||||||
|
/dotnet/**/TestResults/
|
||||||
|
/dotnet/artifacts/
|
||||||
/build/
|
/build/
|
||||||
/out/
|
/out/
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`](
|
|||||||
on all three clients (receive NR now denoises stereo mic streams too — fixed 2026-06-23).
|
on all three clients (receive NR now denoises stereo mic streams too — fixed 2026-06-23).
|
||||||
> See [`PROGRESS.md`](PROGRESS.md).
|
> See [`PROGRESS.md`](PROGRESS.md).
|
||||||
|
|
||||||
VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). Plain TCP (control)
|
VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). TLS over TCP (control)
|
||||||
+ UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives
|
+ UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives
|
||||||
native clients (Swift on macOS/iOS, C# on Windows) and the server.
|
native clients (Swift on macOS/iOS, C# on Windows) and the server.
|
||||||
|
|
||||||
@@ -25,6 +25,21 @@ native clients (Swift on macOS/iOS, C# on Windows) and the server.
|
|||||||
|
|
||||||
## Build & test commands
|
## Build & test commands
|
||||||
|
|
||||||
|
The .NET rewrite lives under `dotnet/`. Build and test its wire/crypto, TLS, codec/DSP, and managed control/UDP server slices
|
||||||
|
alongside the existing C++ tree:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./dotnet/build-native.ps1 # CMake + C compiler; pinned Opus with DRED + RNNoise
|
||||||
|
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 required native voice/CLI conformance,
|
||||||
|
and `docs/api-dotnet.md` for managed interfaces. Phase 4 remains in progress;
|
||||||
|
media-aware reaping is implemented; server administration and audio/client/UI phases remain pending.
|
||||||
|
|
||||||
The default development preset is **`dev`** — it builds everything (server + tools + tests)
|
The default development preset is **`dev`** — it builds everything (server + tools + tests)
|
||||||
with real vcpkg deps. The `skeleton` preset (no deps, stubs only) is a fast smoke check; see
|
with real vcpkg deps. The `skeleton` preset (no deps, stubs only) is a fast smoke check; see
|
||||||
[`docs/building.md`](docs/building.md) for the full preset matrix.
|
[`docs/building.md`](docs/building.md) for the full preset matrix.
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ endif()
|
|||||||
# ── Targets ───────────────────────────────────────────────────────────────────
|
# ── Targets ───────────────────────────────────────────────────────────────────
|
||||||
add_subdirectory(core)
|
add_subdirectory(core)
|
||||||
|
|
||||||
|
option(VOICECAT_BUILD_DOTNET_NATIVE "Build native codec/DSP bindings for the .NET rewrite" OFF)
|
||||||
|
if(VOICECAT_BUILD_DOTNET_NATIVE)
|
||||||
|
add_subdirectory(dotnet/native)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
option(VOICECAT_BUILD_DOTNET_ORACLE "Build the .NET port conformance fixture generator" OFF)
|
||||||
|
if(VOICECAT_BUILD_DOTNET_ORACLE)
|
||||||
|
add_subdirectory(dotnet/oracle)
|
||||||
|
endif()
|
||||||
|
|
||||||
if(VOICECAT_BUILD_SERVER)
|
if(VOICECAT_BUILD_SERVER)
|
||||||
add_subdirectory(server)
|
add_subdirectory(server)
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
+220
@@ -10,6 +10,226 @@ up instantly. Newest status at the top.
|
|||||||
|
|
||||||
## ▶ Where we left off / next action
|
## ▶ Where we left off / next action
|
||||||
|
|
||||||
|
- **Done (2026-09-15): Managed channel and administration checkpoint.** Reaper committed
|
||||||
|
as `274b850`. Added protected joins and capacity checks, leave-to-Lobby, persisted channel
|
||||||
|
create/edit/delete with events, parent/cycle validation and Lobby protection. Native
|
||||||
|
salted BLAKE2b channel hashes work in both directions; empty edit passwords preserve
|
||||||
|
protection. Channel edits, moves and deletion clear streams before further media routing.
|
||||||
|
Session permissions gate kick/ban/move/server-mute/deafen and account create/reset/delete/
|
||||||
|
list. Only administrators grant permissions; temporary-channel permission cannot create
|
||||||
|
permanent channels. Kick/ban retire media and send one reason-bearing LEFT. Account bans
|
||||||
|
persist by username, guest bans by address; Unix-millisecond expiry converts to database
|
||||||
|
seconds, fixing the native handler's unit mismatch. Bounded Argon2 work remains outside
|
||||||
|
the session lock; account lists exclude hashes and respect the frame limit. The existing
|
||||||
|
C++ CLI successfully creates protected channels and creates/lists accounts against the
|
||||||
|
managed server. Fixed its temporary channel-string pointers and zero audio defaults.
|
||||||
|
A native sample-rate regression intermittently measured host microphone audio alongside
|
||||||
|
its injected tone; changed that test to external capture/playback and kept callback state
|
||||||
|
alive through client shutdown, with synchronized energy reads.
|
||||||
|
**Verified:** 169/169 managed tests with all native conformance enabled, zero skips;
|
||||||
|
warning-free managed Release build, native dev build and 29/29 CTest tests; diff check.
|
||||||
|
**Next:** production configuration, administrator provisioning/publishing and remaining
|
||||||
|
server readiness checks (including auth rate limiting). Phase 4 is still in progress.
|
||||||
|
Then managed audio/core/CLI, Windows cutover, C# AppKit and UIKit clients; preserve Swift
|
||||||
|
ReplayKit extension and freeze the shared-ring contract before the iOS cutover.
|
||||||
|
|
||||||
|
- **Done (2026-09-15): Managed media-aware reaper.** Voice checkpoint committed as
|
||||||
|
`05eacb3`. Added `VoiceServerOptions` (name/guests/capacity, handshake deadline,
|
||||||
|
idle timeout and sweep interval), preserving the previous constructor overload.
|
||||||
|
Default expiry/sweep are 45 s / 15 s; zero idle timeout disables reaping. Parsed TCP
|
||||||
|
envelopes, authenticated owned-stream voice and exact bound-endpoint UDP keepalives
|
||||||
|
refresh one monotonic session timestamp. Invalid media does not refresh it. Removed
|
||||||
|
the independent 60-second TCP-only timeout so media-active sessions remain connected.
|
||||||
|
Reaping sends a fatal disconnect and retires presence/media routing with one LEFT
|
||||||
|
event. Shutdown awaits active control/media/reaper loops and unfinished handshakes.
|
||||||
|
Tests inject `TimeProvider` timestamps to verify TCP/UDP activity, rejected media,
|
||||||
|
single departure events, disabled reaping and shutdown. **Verified:** 160/160 managed
|
||||||
|
tests with all native conformance enabled; managed Release build has zero warnings;
|
||||||
|
native dev build and 29/29 CTest tests green; `git diff --check` passes.
|
||||||
|
**Next:** protected channel joins and channel CRUD, permissions/moderation/account
|
||||||
|
administration, then production configuration/publishing. Phase 4 remains in progress.
|
||||||
|
Follow with audio/core/managed CLI, switch Windows to the managed library, then C#
|
||||||
|
AppKit/UIKit clients. Keep the Swift broadcast extension and frozen shared-ring boundary.
|
||||||
|
|
||||||
|
- **Done (2026-09-15): Phase 4 encrypted voice checkpoint.** Existing pending codec/DSP
|
||||||
|
and initial server work committed as `4067bab`. Managed server now binds UDP on the
|
||||||
|
TCP port number, issues 16-byte session tokens, supports subscription and multi-stream
|
||||||
|
signaling, and authenticates/reseals encoded Opus to eligible channel subscribers.
|
||||||
|
Crypto and endpoints have one UDP-loop owner; control handlers publish immutable
|
||||||
|
routing snapshots. Rejects invalid tokens, malformed/forged/replayed media and SSRCs
|
||||||
|
not owned by the sender. Stop, unsubscribe, channel movement and disconnect update
|
||||||
|
routing; retired keys are cleared without requiring subsequent UDP traffic.
|
||||||
|
First endpoint binding is fixed for the session (reconnect to change it), unlike
|
||||||
|
the C++ oracle's permissive rebinding policy. Packet formats/protocol v2 are unchanged.
|
||||||
|
Two actual C++ `vccli` processes authenticate, join, chat and exchange mono/stereo
|
||||||
|
voice through the managed server. Native voice oracle additionally verifies three
|
||||||
|
concurrent streams with bidirectional decoded PCM energy/metadata, without hardware.
|
||||||
|
Added finite `vccli --test-tone-ms` and fixed normal `--voice` to subscribe first.
|
||||||
|
**Verified:** 154/154 managed tests, no skips with TLS/database/voice/CLI variables;
|
||||||
|
native dev build and 29/29 CTest tests; independent native media staging; warning-free
|
||||||
|
managed Release build; identical regenerated wire/password fixtures; C++ DSP within
|
||||||
|
one PCM unit; 22 permissive package licenses; `git diff --check` passes. Fan-out core
|
||||||
|
allocates zero managed bytes for 50 subscribers; transport scheduling and crypto
|
||||||
|
fallback are excluded. Transport test delivers all 2,500 packets at a paced 50 pps.
|
||||||
|
**Next:** media-aware keepalive/reaper, then protected channel joins, channel CRUD,
|
||||||
|
permissions/moderation/account administration and production configuration. Phase 4
|
||||||
|
remains in progress. Audio, managed client/CLI, Windows cutover and C# AppKit/UIKit
|
||||||
|
follow; keep Swift ReplayKit extension and freeze its ring contract before iOS.
|
||||||
|
|
||||||
|
- **In progress (2026-09-15): Phase 4 managed server control plane.** Added TLS socket
|
||||||
|
orchestration, bounded framing/queues, guest and password authentication, persisted
|
||||||
|
channels, state snapshots, channel joins, text routing, ping and disconnect events.
|
||||||
|
The existing C++ CLI authenticates and sends text through the managed server.
|
||||||
|
Managed Argon2id verification passes libsodium fixtures, including UTF-8 and embedded
|
||||||
|
NUL passwords. The C++ database oracle proves existing account/channel import and
|
||||||
|
C++ verification of managed-created accounts without password resets. **Verified:**
|
||||||
|
142/142 managed tests with all native interoperability checks enabled, warning-free
|
||||||
|
Release build, regenerated password fixtures identical, and 22 permissive package
|
||||||
|
licenses; native dev build and 29/29 CTest tests green. Locked restore passes.
|
||||||
|
CI requires CLI/database checks in its C++ conformance job. Codec/DSP and the first
|
||||||
|
server slice are committed together on `dotnet/foundations` as a validated checkpoint.
|
||||||
|
**Next:** encrypted UDP binding/SFU relay and stream signaling.
|
||||||
|
UDP voice, streams, administration, protected channel joins and production configuration
|
||||||
|
remain pending; this is the first control-plane checkpoint, not Phase 4 completion.
|
||||||
|
|
||||||
|
### .NET control-plane checkpoint handoff / discoveries (2026-09-15)
|
||||||
|
|
||||||
|
- **Working tree:** stay on `dotnet/foundations`, tracking `origin/dotnet/foundations`.
|
||||||
|
Foundation `b76181d` and TLS checkpoint `2df79cd` were committed and pushed.
|
||||||
|
The codec/DSP port and first managed server checkpoint were subsequently committed
|
||||||
|
together, including new projects, native bindings/oracles, tests and docs.
|
||||||
|
See the latest checkpoint commit; no push is requested for this session.
|
||||||
|
- **Style/scope:** write idiomatic .NET in `dotnet/`; do not copy C++ code or comment
|
||||||
|
style. The existing implementation is the behavior/wire oracle. No wire changes
|
||||||
|
were made. Read `docs/porting-to-dotnet.md`, `docs/api-dotnet.md`, `dotnet/README.md`
|
||||||
|
and the relevant protocol/security/voice sections before the next subsystem.
|
||||||
|
- **Implemented projects:** `VoiceCat.Protocol` (existing protobuf + framing),
|
||||||
|
`VoiceCat.Crypto` (media crypto/replay, TLS/exporters, identity/TOFU, password hashing),
|
||||||
|
`VoiceCat.Codec` (Opus/PLC/DRED), `VoiceCat.Dsp` (RNNoise/energy VAD), and
|
||||||
|
`VoiceCat.Server` (real TLS control server + compatible SQLite account/channel store).
|
||||||
|
`dotnet/oracle/` contains optional native wire, TLS, DSP, password and database
|
||||||
|
conformance executables. `ServerTests` exercises real sockets and the existing CLI;
|
||||||
|
`AccountStoreTests` proves native database import and password verification both ways.
|
||||||
|
- **TLS discovery:** BouncyCastle destroys exporter secrets after its handshake
|
||||||
|
callback. Export keys inside `NotifyHandshakeComplete`, not after the socket loop
|
||||||
|
notices readiness. Preserve label `voicecat media v1` and contexts `[0]` / `[1]`.
|
||||||
|
A `TlsSession` has one owner; the control connection loop owns all TLS calls.
|
||||||
|
Certificate acceptance is a synchronous leaf-SHA256 pin gate, not normal PKI.
|
||||||
|
New certificates carry the Ed25519 public key in their SAN, but verification of
|
||||||
|
the ServerHello identity against that SAN remains pending. Partial credential
|
||||||
|
sets must fail rather than silently generate a new server identity.
|
||||||
|
- **Native codec discoveries:** the actual pinned Opus is **1.5.2**, despite older
|
||||||
|
design comments referring to 1.6. Standalone builds use checksum-pinned upstream
|
||||||
|
sources with DRED/Deep PLC enabled. DRED needs a **30 ms minimum** in this release;
|
||||||
|
20 ms produces no redundancy. DRED encoding at 8/12 kHz is explicitly unsupported;
|
||||||
|
decoding works at all five rates. Recovery offset defaults to one missing frame's
|
||||||
|
samples before the next packet's start (the older C++ zero offset is not a guide).
|
||||||
|
Fixed-signature C wrappers avoid the Apple ARM64 varargs ABI issue with Opus CTLs.
|
||||||
|
Windows DLL staging must omit the MinGW `lib` prefix. MinGW and MSVC builds pass;
|
||||||
|
iOS needs later static packaging. Device audio callbacks/rings are not implemented.
|
||||||
|
- **DSP behavior:** RNNoise processes complete 480-sample mono chunks at 48 kHz;
|
||||||
|
other rates pass through, and partial chunks at 48 kHz are rejected. Native C++
|
||||||
|
conformance allows one PCM unit for rounding. VAD hang time uses monotonic
|
||||||
|
`TimeProvider` timestamps, starts closed and does not replace noise suppression.
|
||||||
|
The combined allocation test proves zero managed allocations across 1,000 cycles.
|
||||||
|
- **Password/database discoveries:** use the existing BouncyCastle Argon2 engine
|
||||||
|
with strict libsodium PHC parsing; no additional Konscious dependency or password
|
||||||
|
reset is needed. Keep UTF-8 bytes unchanged, including embedded NUL. New hashes use
|
||||||
|
Argon2id v19, 64 MiB, two iterations, parallelism one, salt 16/output 32 bytes.
|
||||||
|
Verification is bounded to 128 MiB, ten iterations, parallelism four and 1024 UTF-8
|
||||||
|
password bytes; excessive imported costs fail closed. Two per-store password
|
||||||
|
workers bound CPU/memory use. Failed login does not update `last_login`.
|
||||||
|
Keep SQLite schema v2; accept v1 migration and reject unknown versions.
|
||||||
|
**Seed both default channels only when the entire channel table is empty**;
|
||||||
|
an existing single Lobby is an intentional configuration and must be preserved.
|
||||||
|
- **SQLite dependency discovery:** the initial `Microsoft.Data.Sqlite` 10.0.5 bundle
|
||||||
|
pulled an older vulnerable SQLite native dependency, rejected by warnings-as-errors
|
||||||
|
restore. The implementation uses `Microsoft.Data.Sqlite.Core` 10.0.5,
|
||||||
|
SQLitePCLRaw bundle 3.0.2 and explicitly pinned SourceGear SQLite 3.50.4.2 instead.
|
||||||
|
SourceGear's native package lacks a NuGet license expression; the audit has an
|
||||||
|
exact-version/repository-identity exception for its public-domain SQLite build.
|
||||||
|
NativeAOT publishing/trimming has not been verified for this solution.
|
||||||
|
- **Previous control-plane checkpoint limits:** CLI binds loopback; positional arguments are data
|
||||||
|
directory and TCP port. Guests are enabled there, and the hosting API can disable
|
||||||
|
them. Accounts can be provisioned through `AccountStore` or native administration;
|
||||||
|
automatic bootstrap/admin CLI is pending. Authentication enters unprotected Lobby
|
||||||
|
id 1 subject to capacity. Server owns text sender ids/timestamps. Connections cap
|
||||||
|
at 64; queues cap at 32 incoming/64 outgoing envelopes, payloads at 64 KiB (shared
|
||||||
|
framer allows 16 MiB). Slow consumers disconnect. TLS handshake timeout is 15 s;
|
||||||
|
receive-idle timeout after handshake is 60 s. No UDP port/media features are
|
||||||
|
advertised, and voice subscription fails explicitly. Protected joins, channel CRUD,
|
||||||
|
streams, SFU, moderation/admin handlers, configuration compatibility and full reaper
|
||||||
|
behavior remain pending. **Do not mark Phase 4 or voice interoperability complete.**
|
||||||
|
|
||||||
|
To reproduce the last successful validation on Windows, run in **PowerShell**:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./dotnet/build-native.ps1 -Generator Ninja -CCompiler C:/tools/msys64/ucrt64/bin/cc.exe
|
||||||
|
cmake --preset dev -DVOICECAT_BUILD_DOTNET_ORACLE=ON
|
||||||
|
cmake --build --preset dev
|
||||||
|
ctest --preset dev
|
||||||
|
dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||||
|
$env:VOICECAT_TLS_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-tls-oracle.exe).Path
|
||||||
|
$env:VOICECAT_DATABASE_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-database-oracle.exe).Path
|
||||||
|
$env:VOICECAT_VCCLI = (Resolve-Path build/dev/bin/vccli.exe).Path
|
||||||
|
$env:VOICECAT_VOICE_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-voice-oracle.exe).Path
|
||||||
|
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
|
||||||
|
./dotnet/check-licenses.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Last results: **160/160 managed tests, no skips with those variables set; 29/29 native
|
||||||
|
CTest tests; warning-free Release build; 22 package licenses approved; locked restore
|
||||||
|
and `git diff --check` passed.** Without the variables, native interoperability tests
|
||||||
|
skip; that is not equivalent verification. Desktop CI stages codec/DSP bindings on
|
||||||
|
Windows/Linux/macOS. Its Linux C++ job requires TLS, CLI and database interoperability
|
||||||
|
and regenerates wire/password/DSP fixtures. Only Windows was run locally this session.
|
||||||
|
|
||||||
|
**Voice behavior now verified:** real clients bind UDP and exchange encrypted Opus,
|
||||||
|
preserving SSRC/timestamp/flags while resealing with recipient-specific counters.
|
||||||
|
Never decode audio on the SFU. The two-C++-client voice/text criterion passes;
|
||||||
|
the remaining Phase 4 server behaviors still need implementation and conformance tests.
|
||||||
|
|
||||||
|
- **Done (2026-09-15): Codec/DSP desktop port.** TLS checkpoint `2df79cd` committed
|
||||||
|
and pushed to `origin/dotnet/foundations`. Added span-based Opus wrappers, safe native
|
||||||
|
handle ownership, RNNoise processing, monotonic energy VAD, and fixed-signature
|
||||||
|
native bindings. Round-trip/PLC behavior passes across 40 supported formats with
|
||||||
|
the existing Opus build. Independent native staging builds checksum-pinned upstream
|
||||||
|
Opus 1.5.2 with DRED enabled and the existing RNNoise model. Actual dropped-frame
|
||||||
|
DRED recovery passes across 40 decoder formats; DRED encoding at 8/12 kHz is
|
||||||
|
explicitly rejected (tests encode those packets at 16 kHz). This release requires
|
||||||
|
a 30 ms redundancy floor; the older 20 ms setting emits no DRED. C++ denoising
|
||||||
|
conformance is within one PCM unit, and 1,000 codec/DSP cycles allocate zero managed
|
||||||
|
bytes. **Verified:** Release build with no warnings; 127/127 managed tests with
|
||||||
|
native TLS interoperability enabled; native dev build and 29/29 CTest tests green;
|
||||||
|
16 permissive NuGet licenses. MinGW and Visual Studio native builds pass. Desktop
|
||||||
|
CI now builds/stages the bindings before testing. iOS static packaging and device
|
||||||
|
audio remain later phases. **Next:** Phase 4 managed server; prove persisted
|
||||||
|
libsodium Argon2id hash compatibility before account/database implementation.
|
||||||
|
|
||||||
|
- **Done (2026-09-15): TLS exporter interoperability and persisted credentials.** Foundation commit
|
||||||
|
`b76181d` pushed to `origin/dotnet/foundations`. Added a nonblocking managed TLS 1.3
|
||||||
|
session with certificate acceptance gate and directional media factories. Managed
|
||||||
|
loopback and C++ interoperability pass; exporter keys are captured inside BouncyCastle's
|
||||||
|
handshake callback before its exporter secrets are destroyed. The C++ TLS oracle
|
||||||
|
authenticates encrypted challenges in both directions against the existing mbedTLS
|
||||||
|
context. Added explicit persisted TOFU pins, compatible Ed25519 identity/PEM import,
|
||||||
|
new certificate identity SAN, and rejection of incomplete credential sets.
|
||||||
|
**Verified:** 42/42 managed tests with the native TLS oracle enabled; native dev build
|
||||||
|
and 29/29 CTest tests green; 16 permissive package licenses verified. Complete socket
|
||||||
|
orchestration and managed server/client state remain pending.
|
||||||
|
**Next:** Phase 3 codec/DSP wrappers and native packaging.
|
||||||
|
|
||||||
|
- **Done (2026-09-15): Initial .NET wire/crypto port** on `dotnet/foundations`, from `cs-port`.
|
||||||
|
Added `dotnet/` solution, schema code generation, pipe framing, immutable voice headers,
|
||||||
|
directional media encryption/decryption, and xUnit conformance tests. Both platform
|
||||||
|
and managed crypto paths are tested. Added optional C++ fixture oracle, managed CI,
|
||||||
|
dependency lock files, license audit, and `docs/api-dotnet.md`. **Verified:** managed
|
||||||
|
Release build, 34/34 tests including C++ golden bytes, and 16 permissive package
|
||||||
|
licenses. Fresh `cmake --build --preset dev` and `ctest --preset dev` green (29/29);
|
||||||
|
regenerating the C++ fixtures produces identical bytes. Native codec/audio packaging
|
||||||
|
is deferred to its implementation phase. Next checkpoint: BouncyCastle TLS 1.3
|
||||||
|
exporter interoperability with the existing server.
|
||||||
|
|
||||||
- **Done (2026-07-23):** **First comment-density cleanup across core, server, and native
|
- **Done (2026-07-23):** **First comment-density cleanup across core, server, and native
|
||||||
clients.** Condensed comments in the highest-noise audio, reconnect, registry, and binding
|
clients.** Condensed comments in the highest-noise audio, reconnect, registry, and binding
|
||||||
files; removed implementation history and narration; retained ABI ownership, threading,
|
files; removed implementation history and narration; retained ABI ownership, threading,
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Codec and DSP
|
||||||
|
|
||||||
|
`VoiceCat.Codec` and `VoiceCat.Dsp` call the desktop `voicecat_media` native library
|
||||||
|
through source-generated `LibraryImport`. It links pinned Opus 1.5.2 and the existing
|
||||||
|
vendored RNNoise; it has no dependency on libvoicecat or its C ABI. Fixed C signatures
|
||||||
|
wrap Opus controls so P/Invoke never calls C varargs. SafeHandle owns every native
|
||||||
|
encoder, decoder, DRED parser/state, and denoiser, including failed initialization.
|
||||||
|
|
||||||
|
`OpusOptions` is an immutable record. Supported PCM rates are 8/12/16/24/48 kHz,
|
||||||
|
one or two interleaved channels, and integral 10/20/40/60 ms frames. These match the
|
||||||
|
current VoiceCat protocol's integer frame duration; fractional Opus frame durations
|
||||||
|
are not exposed. Low-delay application mode requires at most 20 ms. Channel capture
|
||||||
|
bandwidth is controlled separately by `MaximumBandwidthHz`; the production audio
|
||||||
|
clock will remain 48 kHz. Options are validated before native creation, and native
|
||||||
|
control failures throw `OpusException` with the libopus error code.
|
||||||
|
|
||||||
|
`OpusEncoder.Encode(ReadOnlySpan<short>, Span<byte>)` accepts exactly one frame
|
||||||
|
and returns encoded bytes. `OpusDecoder.Decode(packet, pcm, samplesPerChannel,
|
||||||
|
recoverPreviousFrame)` returns samples **per channel**, not total interleaved samples.
|
||||||
|
An empty packet requests PLC. Passing the next packet with `recoverPreviousFrame`
|
||||||
|
requests in-band FEC; absence of FEC permits libopus's PLC fallback. Decode that next
|
||||||
|
packet normally afterward. Capacity/overlap errors throw before native processing.
|
||||||
|
|
||||||
|
DRED is explicit. Unsupported native builds reject `DeepRedundancy = true` rather
|
||||||
|
than silently disabling it. With pinned Opus 1.5.2, DRED encoding requires PCM at
|
||||||
|
16/24/48 kHz; its activity analysis cannot emit DRED at 8/12 kHz. Such configurations
|
||||||
|
are rejected. DRED packets can still be decoded at all five rates. The encoder uses
|
||||||
|
a 30 ms minimum redundancy duration because this release needs two redundancy chunks;
|
||||||
|
the old 20 ms setting produces no DRED packets. Actual redundancy remains adaptive
|
||||||
|
to bitrate, loss estimate, and activity; it is not guaranteed in every packet.
|
||||||
|
|
||||||
|
`OpusDeepRedundancy.TryRecover(audioDecoder, nextPacket, pcm, samplesPerChannel,
|
||||||
|
offset)` parses the next packet and reconstructs a missing frame. Default offset is
|
||||||
|
one missing frame's samples per channel before the next packet's start, matching
|
||||||
|
libopus's offset convention. A packet without DRED returns false; then the owner
|
||||||
|
can try FEC/PLC. Only consume recovery output on success. Parse/native errors throw.
|
||||||
|
|
||||||
|
`RnnoiseProcessor.Process(Span<short>, sampleRate)` operates in place on complete
|
||||||
|
480-sample mono chunks at 48 kHz. Other rates pass through unchanged; partial chunks
|
||||||
|
at 48 kHz throw instead of leaving a tail silently untreated. Float scratch is
|
||||||
|
preallocated, and rounding/clipping matches the C++ processor. Use distinct instances
|
||||||
|
for stereo channels when the later pipeline supports stereo microphone denoising.
|
||||||
|
Noise reduction does not gate speech.
|
||||||
|
|
||||||
|
`EnergyVadProcessor.Process(ReadOnlySpan<short>)` compares normalized RMS against
|
||||||
|
`Threshold`, retains speech for `HangTime`, and starts closed. It uses monotonic
|
||||||
|
`TimeProvider` timestamps; tests inject a clock. Threshold changes are atomic; all
|
||||||
|
processing state otherwise has one owner. Codec/DSP processing methods allocate no
|
||||||
|
managed memory after initialization, verified across 1,000 combined cycles. They run
|
||||||
|
on a managed worker, never the native real-time device callback. Native device rings,
|
||||||
|
jitter, mixer, and audio scheduling remain later work.
|
||||||
|
|
||||||
|
## Initial managed server
|
||||||
|
|
||||||
|
`VoiceServer(directory, endpoint, allowGuests, name)` owns credentials, the SQLite
|
||||||
|
store, a TCP listener and its connection tasks. `EndPoint` reports the actual bound
|
||||||
|
port (zero requests an ephemeral port). Dispose asynchronously to stop the listener
|
||||||
|
and wait for all connections. The CLI currently binds loopback and accepts optional
|
||||||
|
data-directory/port positional arguments.
|
||||||
|
|
||||||
|
All control traffic uses TLS 1.3 with the existing v2 protobuf. A single async loop
|
||||||
|
owns each `TlsSession`; handlers exchange envelopes through bounded queues.
|
||||||
|
This checkpoint caps connections at 64, queued input at 32 envelopes, queued output
|
||||||
|
at 64 envelopes, and each control payload at 64 KiB (stricter than the shared framer's
|
||||||
|
16 MiB limit). Queue exhaustion disconnects slow consumers. Handshake timeout is
|
||||||
|
15 seconds by default. Completed TLS connections use the server's media-aware reaper.
|
||||||
|
|
||||||
|
The existing `VoiceServer(directory, endpoint, allowGuests, name)` constructor remains
|
||||||
|
available. An overload accepts `VoiceServerOptions` and an optional `TimeProvider`.
|
||||||
|
Options configure server name, guest access, connection limit (default 64), handshake
|
||||||
|
timeout (15 seconds), idle timeout (45 seconds) and reaper interval (15 seconds).
|
||||||
|
Zero idle timeout disables reaping; active reaping requires a positive interval.
|
||||||
|
Invalid options fail before creating credentials, databases or sockets.
|
||||||
|
|
||||||
|
Authentication starts users in unprotected Lobby (id 1), subject to its capacity.
|
||||||
|
Success returns permissions, then a cloned snapshot; peers receive joined/updated/left
|
||||||
|
events. Server-authoritative text replaces supplied sender ids/timestamps, limits
|
||||||
|
bodies to 4096 UTF-8 bytes, and acknowledges valid or rejected routing. Channel text
|
||||||
|
requires membership; private text echoes to sender and recipient. Protected joins enforce
|
||||||
|
the supplied password and capacity; `LeaveChannel` returns to Lobby. Passwords use the
|
||||||
|
native salted, keyed BLAKE2b-256 `salt_hex:hash_hex` format, verified in both directions.
|
||||||
|
|
||||||
|
Channel create/edit/delete persist before broadcasting events. Administrators can manage
|
||||||
|
all channels; `CanCreateTempChannel` permits creation of temporary channels only. Edit with
|
||||||
|
an empty password preserves the existing hash, matching native behavior; password removal
|
||||||
|
has no v2 request representation. Lobby cannot be deleted, protected or nested. Missing
|
||||||
|
parents, tree cycles and deletion of parents with children fail without mutation. Deletion
|
||||||
|
moves members to Lobby (even if full), clearing their streams. Edits stop existing streams
|
||||||
|
so clients must negotiate the updated audio configuration. Channel names/topics/passwords
|
||||||
|
are limited to 128/4096/1024 UTF-8 bytes. Audio requires Opus, 48 kHz, mono/stereo,
|
||||||
|
500–512000 bps, integral 5/10/20/40/60 ms frames and valid application/loss/complexity.
|
||||||
|
Database v2 has no DRED column; CRUD rejects DRED rather than silently losing it on restart.
|
||||||
|
|
||||||
|
Session permissions gate kick/ban/move/mute and account operations. Only administrators
|
||||||
|
can grant permissions; account-administration permission cannot grant administrator status.
|
||||||
|
These two permission restrictions are stricter than the C++ oracle. Moves bypass channel
|
||||||
|
passwords but respect capacity and clear streams. Server mute/deafen immediately updates
|
||||||
|
encrypted routing. Kick/ban retire routing before closure and emit one LEFT with the reason.
|
||||||
|
Account bans persist by username; guest bans persist by address because nicknames are not
|
||||||
|
identities. Ban wire expiry is Unix milliseconds, converted to database seconds rounded up;
|
||||||
|
zero means permanent. This fixes the native handler's millisecond/second mismatch.
|
||||||
|
Existing sessions on the same address/account are not swept by a target-user ban.
|
||||||
|
|
||||||
|
Create/reset/delete/list accounts require administrator or `CanAdminAccounts`. New accounts
|
||||||
|
are non-admin. Bounded Argon2 work runs outside the server state lock; authority is checked
|
||||||
|
when accepting the operation, and cancellation is checked before password writes. Reset
|
||||||
|
and deletion affect future authentication; existing sessions retain their permissions.
|
||||||
|
Lists omit password hashes and return millisecond timestamps. Oversized lists fail instead
|
||||||
|
of truncating or exceeding the 64 KiB frame limit. Privileged responses echo request ids;
|
||||||
|
generic codes are 6 for permission denied and 3 for invalid/missing/duplicate input.
|
||||||
|
|
||||||
|
`VoiceServer.MediaEndPoint` exposes the bound UDP endpoint; UDP uses the same address
|
||||||
|
and port number as TCP, and `ServerHello.udp_port` advertises it. Successful authentication
|
||||||
|
issues a 16-byte binding token. TLS confirmation echoes an acknowledgement; a protocol-v2
|
||||||
|
bootstrap packet binds the first UDP endpoint. Tokens cannot replace an established
|
||||||
|
endpoint; reconnect to change endpoints. Invalid tokens and malformed packets are ignored.
|
||||||
|
|
||||||
|
Voice subscription, unsubscribe, stream announce/stop and stream-state signaling are
|
||||||
|
implemented. Announces require subscription and support microphone, screen audio and
|
||||||
|
auxiliary device streams, with at most 16 streams per user and labels up to 128 characters.
|
||||||
|
Stream ids are monotonically assigned per user; SSRCs are assigned server-wide.
|
||||||
|
Channel audio settings are authoritative; requested bitrate may lower the channel ceiling
|
||||||
|
(nonzero requests below 500 bps fail). User updates include the actor. Stream-state updates
|
||||||
|
use the authenticated sender id and ignore unknown stream ids.
|
||||||
|
|
||||||
|
Channel movement clears active streams; joining the current channel preserves them.
|
||||||
|
Unsubscribe clears streams. Disconnect removes routing and retires media resources,
|
||||||
|
even if no UDP traffic follows. Senders must own the SSRC and be subscribed; recipients
|
||||||
|
must be subscribed, bound, in the same channel and not deafened. Server-muted senders
|
||||||
|
cannot relay. Every voice packet is authenticated with the sender's directional key;
|
||||||
|
the SFU reseals encoded bytes for each recipient without decoding, replacing only the
|
||||||
|
sequence and ciphertext/tag. Replay rejection precedes authentication; successful
|
||||||
|
authentication advances the replay window.
|
||||||
|
|
||||||
|
The UDP loop exclusively owns media crypto, endpoint mutation and packet buffers.
|
||||||
|
Control handlers publish immutable routing snapshots. Crypto is created within the
|
||||||
|
TLS owner loop and transferred once. A coalesced notification wakes retired-key cleanup.
|
||||||
|
The synchronous fan-out core allocates zero managed bytes with platform ChaCha20-Poly1305;
|
||||||
|
socket scheduling and the allocating BouncyCastle fallback are outside that guarantee.
|
||||||
|
UDP keepalives are echoed for bound endpoints. Any parsed control envelope, authenticated
|
||||||
|
voice from an active owned stream, or exact header-only keepalive from a bound endpoint
|
||||||
|
refreshes a shared monotonic activity timestamp. Invalid media does not refresh it.
|
||||||
|
The reaper sends a fatal disconnect, removes presence/routing and broadcasts one LEFT
|
||||||
|
event. Valid UDP activity keeps a TCP-idle client alive. Shutdown cancels and awaits
|
||||||
|
the accept, reaper, control and media loops before disposing credentials/storage.
|
||||||
|
|
||||||
|
`AccountStore(path)` retains the C++ schema version 2, accepts version 1 migration,
|
||||||
|
and rejects unknown revisions. Opening an existing channel table does not reseed it.
|
||||||
|
Account creation/authentication uses parameterized SQL; two password workers bound
|
||||||
|
per-store Argon2 work. Failed authentication leaves `last_login` unchanged. Dispose
|
||||||
|
after its operations finish. `ResetPasswordAsync`, `DeleteAccount` and `ListAccounts`
|
||||||
|
also expose administration to hosts. Initial administrator provisioning uses this API or
|
||||||
|
the native administration CLI; there is no automatic bootstrap account.
|
||||||
|
|
||||||
|
`PasswordHasher` uses strict UTF-8 without normalization and libsodium-compatible
|
||||||
|
Argon2id v19 PHC strings: 16-byte salt, 32-byte output, new-hash parameters
|
||||||
|
64 MiB memory, two iterations, parallelism one. Verification supports up to 128 MiB,
|
||||||
|
ten iterations, parallelism four and 1024 UTF-8 password bytes; malformed or excessive
|
||||||
|
hashes fail closed. Standard C++ interactive-cost accounts are preserved. These
|
||||||
|
bounds intentionally reject imported hashes above those costs. Native fixtures cover
|
||||||
|
ASCII, Unicode and embedded NUL; the database oracle verifies cross-implementation
|
||||||
|
authentication in both directions.
|
||||||
|
|
||||||
|
SQLite's MIT provider/bundle uses the pinned public-domain SourceGear SQLite build.
|
||||||
|
The license audit checks that exact package version and repository identity because
|
||||||
|
the native package lacks a NuGet license expression; other dependencies still require
|
||||||
|
an approved permissive expression.
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
|
The parallel .NET rewrite under `dotnet/` currently implements shared protocol framing,
|
||||||
|
voice headers, and media crypto. Existing server/client/audio behavior remains in C++.
|
||||||
|
See `docs/api-dotnet.md` for the initial managed contract and
|
||||||
|
`docs/porting-to-dotnet.md` for subsequent migration phases.
|
||||||
|
|
||||||
## 1. The shared-core model
|
## 1. The shared-core model
|
||||||
|
|
||||||
All non-UI logic lives in one C++ library, **`libvoicecat`**. The same library is linked
|
All non-UI logic lives in one C++ library, **`libvoicecat`**. The same library is linked
|
||||||
@@ -205,8 +210,10 @@ callback: no allocations, no blocking calls.
|
|||||||
```
|
```
|
||||||
|
|
||||||
- **Voice router is a relay, not a mixer.** For each incoming voice frame it looks up the
|
- **Voice router is a relay, not a mixer.** For each incoming voice frame it looks up the
|
||||||
sender's channel and forwards the *unmodified Opus payload* (restamped with the sender's
|
sender's channel and forwards the *unmodified encoded Opus bytes* to other members.
|
||||||
user id) to every other subscribed member. No server-side decode/transcode → low CPU,
|
It authenticates/decrypts incoming media, then reseals with each recipient's directional
|
||||||
|
key and counter. SSRC/timestamp/flags/codec pass through; sequence and ciphertext/tag change.
|
||||||
|
No server-side decode/transcode → low CPU,
|
||||||
low latency, and end-to-content is just Opus. Per-channel Opus params are enforced so all
|
low latency, and end-to-content is just Opus. Per-channel Opus params are enforced so all
|
||||||
members are mutually decodable.
|
members are mutually decodable.
|
||||||
- **Subscriptions.** Clients implicitly subscribe to their current channel's voice; text
|
- **Subscriptions.** Clients implicitly subscribe to their current channel's voice; text
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
# Building & Manual Testing
|
# Building & Manual Testing
|
||||||
|
|
||||||
|
## .NET rewrite
|
||||||
|
|
||||||
|
The managed wire/crypto, TLS, and codec/DSP slices are under `dotnet/`, targeting .NET 10.
|
||||||
|
From the root (CMake and a C compiler are required for codec/DSP):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./dotnet/build-native.ps1
|
||||||
|
dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||||
|
dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
|
||||||
|
dotnet test dotnet/VoiceCat.slnx -c Release --no-build
|
||||||
|
```
|
||||||
|
|
||||||
|
See `dotnet/README.md` for conformance fixtures and conventions. The C++ commands
|
||||||
|
below remain required while the existing implementation is the migration oracle.
|
||||||
|
|
||||||
This doc explains what each CMake preset in [`CMakePresets.json`](../CMakePresets.json) is
|
This doc explains what each CMake preset in [`CMakePresets.json`](../CMakePresets.json) is
|
||||||
*for*, which one to actually use day-to-day, and the commands to stand up a real server +
|
*for*, which one to actually use day-to-day, and the commands to stand up a real server +
|
||||||
`vccli` clients against each other for manual testing. For the one-paragraph quick-start see
|
`vccli` clients against each other for manual testing. For the one-paragraph quick-start see
|
||||||
|
|||||||
+82
-10
@@ -1,6 +1,11 @@
|
|||||||
# Porting VoiceCat to pure .NET / C#
|
# Porting VoiceCat to pure .NET / C#
|
||||||
|
|
||||||
**Status:** proposal / plan. Nothing here is implemented yet.
|
**Status:** wire/media crypto and TLS/exporter foundations implemented under `dotnet/`,
|
||||||
|
including C++ interoperability, persisted TOFU, compatible server credentials,
|
||||||
|
codec/DSP wrappers, desktop native staging, and the initial managed TLS control server.
|
||||||
|
Phase 4 remains in progress; complete session administration, device audio,
|
||||||
|
managed client state, and UI phases remain planned.
|
||||||
|
See `dotnet/README.md`, `docs/api-dotnet.md`, and `PROGRESS.md` for verification and next steps.
|
||||||
**Target runtime:** .NET 10 LTS (in-service to Nov 2028), with .NET 11 as the follow-on.
|
**Target runtime:** .NET 10 LTS (in-service to Nov 2028), with .NET 11 as the follow-on.
|
||||||
**Scope:** replace the C++ core (`libvoicecat`), the C++ server, the C++ `vccli`, and the
|
**Scope:** replace the C++ core (`libvoicecat`), the C++ server, the C++ `vccli`, and the
|
||||||
Swift macOS/iOS clients with a single C# codebase. The Windows WinForms client is already C#
|
Swift macOS/iOS clients with a single C# codebase. The Windows WinForms client is already C#
|
||||||
@@ -230,7 +235,7 @@ has been carrying.
|
|||||||
| TLS 1.3 | mbedTLS | **BouncyCastle `Org.BouncyCastle.Tls`** | MIT | See §3. **Not `SslStream`.** |
|
| TLS 1.3 | mbedTLS | **BouncyCastle `Org.BouncyCastle.Tls`** | MIT | See §3. **Not `SslStream`.** |
|
||||||
| Media AEAD | libsodium ChaCha20-Poly1305 | **`System.Security.Cryptography.ChaCha20Poly1305`** | built-in | ⚠️ Check `ChaCha20Poly1305.IsSupported` at startup — it is OS-backed (Windows 10 1903+ / OpenSSL 1.1+). Fall back to BouncyCastle's `ChaCha20Poly1305` if false. Same 12-byte nonce, 16-byte tag → identical wire bytes. |
|
| Media AEAD | libsodium ChaCha20-Poly1305 | **`System.Security.Cryptography.ChaCha20Poly1305`** | built-in | ⚠️ Check `ChaCha20Poly1305.IsSupported` at startup — it is OS-backed (Windows 10 1903+ / OpenSSL 1.1+). Fall back to BouncyCastle's `ChaCha20Poly1305` if false. Same 12-byte nonce, 16-byte tag → identical wire bytes. |
|
||||||
| Anti-replay window | hand-rolled 64-bit | port verbatim | — | ~40 lines. Keep the RFC 3711 §3.3 ordering (replay-check → authenticate → *then* advance). This ordering is load-bearing; `test_media_aead.cpp` covers it. |
|
| Anti-replay window | hand-rolled 64-bit | port verbatim | — | ~40 lines. Keep the RFC 3711 §3.3 ordering (replay-check → authenticate → *then* advance). This ordering is load-bearing; `test_media_aead.cpp` covers it. |
|
||||||
| Argon2id | libsodium `crypto_pwhash` | **`Konscious.Security.Cryptography.Argon2`** | MIT | Pure managed. ⚠️ **Existing password hashes will not verify** — libsodium emits `$argon2id$...` PHC strings with its own tuned m/t/p. Either implement a PHC-string parser and feed those params to Konscious (doable, recommended), or force a password reset on migration. Decide early; `db.cpp` migration depends on it. |
|
| Argon2id | libsodium `crypto_pwhash` | **BouncyCastle `Argon2BytesGenerator`** | MIT | Implemented with a strict libsodium PHC parser and original costs; native database tests prove existing-account import and managed-account verification by C++. See `docs/api-dotnet.md` for cost bounds. |
|
||||||
| BLAKE2b (channel passwords) | libsodium `crypto_generichash` | **`Blake2Fast`** (MIT) or BouncyCastle `Blake2bDigest` | MIT | Salted BLAKE2b-256, must produce identical digests to keep existing channel passwords working. Blake2Fast is SIMD and fast enough for the net thread, preserving the reason BLAKE2b was chosen over Argon2 here. |
|
| BLAKE2b (channel passwords) | libsodium `crypto_generichash` | **`Blake2Fast`** (MIT) or BouncyCastle `Blake2bDigest` | MIT | Salted BLAKE2b-256, must produce identical digests to keep existing channel passwords working. Blake2Fast is SIMD and fast enough for the net thread, preserving the reason BLAKE2b was chosen over Argon2 here. |
|
||||||
| Ed25519 identity | libsodium | **BouncyCastle `Ed25519Signer`** | MIT | ⚠️ **Not in .NET 10.** [dotnet/runtime#63174](https://github.com/dotnet/runtime/issues/63174) is api-approved but milestoned **11.0.0**. Since Option A already pulls in BouncyCastle, this is free. |
|
| Ed25519 identity | libsodium | **BouncyCastle `Ed25519Signer`** | MIT | ⚠️ **Not in .NET 10.** [dotnet/runtime#63174](https://github.com/dotnet/runtime/issues/63174) is api-approved but milestoned **11.0.0**. Since Option A already pulls in BouncyCastle, this is free. |
|
||||||
| Self-signed cert gen | mbedTLS x509write | **`CertificateRequest.CreateSelfSigned`** | built-in | Much nicer than the C++ version. ECDSA-P256, same as today. Add the Ed25519 SAN (§3.4). |
|
| Self-signed cert gen | mbedTLS x509write | **`CertificateRequest.CreateSelfSigned`** | built-in | Much nicer than the C++ version. ECDSA-P256, same as today. Add the Ed25519 SAN (§3.4). |
|
||||||
@@ -240,7 +245,7 @@ has been carrying.
|
|||||||
| Audio capture/playback | miniaudio | **P/Invoke miniaudio via a shim** | MIT-0/PD | **Keep native**, see §5.2. Managed alternatives exist ([SoundFlow](https://www.nuget.org/packages/SoundFlow), [MiniaudioSharp](https://www.nuget.org/packages/MiniaudioSharp), NAudio/CSCore for Windows-only) but auto-generated bindings marshal the callback into managed code, which is exactly what you must avoid (§5.1). Write the shim yourself. |
|
| Audio capture/playback | miniaudio | **P/Invoke miniaudio via a shim** | MIT-0/PD | **Keep native**, see §5.2. Managed alternatives exist ([SoundFlow](https://www.nuget.org/packages/SoundFlow), [MiniaudioSharp](https://www.nuget.org/packages/MiniaudioSharp), NAudio/CSCore for Windows-only) but auto-generated bindings marshal the callback into managed code, which is exactly what you must avoid (§5.1). Write the shim yourself. |
|
||||||
| Energy VAD | hand-rolled | port verbatim | — | ~60 lines. Trivial. |
|
| Energy VAD | hand-rolled | port verbatim | — | ~60 lines. Trivial. |
|
||||||
| Protobuf | protobuf-lite (C++) | **`Google.Protobuf`** + `Grpc.Tools` | BSD | ⚠️ Reference `Grpc.Tools` for the `protoc` MSBuild integration even though there is no gRPC here — it is the standard way to codegen `.proto` in a `.csproj`. `<Protobuf Include="../../proto/voicecat.proto" GrpcServices="None" />`. The `.proto` needs **zero changes**. |
|
| Protobuf | protobuf-lite (C++) | **`Google.Protobuf`** + `Grpc.Tools` | BSD | ⚠️ Reference `Grpc.Tools` for the `protoc` MSBuild integration even though there is no gRPC here — it is the standard way to codegen `.proto` in a `.csproj`. `<Protobuf Include="../../proto/voicecat.proto" GrpcServices="None" />`. The `.proto` needs **zero changes**. |
|
||||||
| SQLite | sqlite3 | **`Microsoft.Data.Sqlite`** | MIT | Bundles SQLitePCLRaw; works with NativeAOT. Same schema, same file — an existing `voicecat.db` opens unchanged. |
|
| SQLite | sqlite3 | **`Microsoft.Data.Sqlite.Core` + SQLitePCLRaw** | MIT / public domain | Implemented with provider 10.0.5, bundle 3.0.2 and pinned SQLite 3.50.4.2. Same schema/file; native database import is tested. NativeAOT publishing remains to be validated. |
|
||||||
| Logging | spdlog | **`Microsoft.Extensions.Logging`** (+ Serilog console sink) | MIT/Apache | Use `LoggerMessage` source generators on any path near the hot loop. Never log from an audio path. |
|
| Logging | spdlog | **`Microsoft.Extensions.Logging`** (+ Serilog console sink) | MIT/Apache | Use `LoggerMessage` source generators on any path near the hot loop. Never log from an audio path. |
|
||||||
| Server config | `server.toml` | **`Tomlyn`** (MIT) or switch to JSON + `System.Text.Json` | MIT | Tomlyn keeps `server.toml` compatible; recommended, since operator-facing config shouldn't churn. |
|
| Server config | `server.toml` | **`Tomlyn`** (MIT) or switch to JSON + `System.Text.Json` | MIT | Tomlyn keeps `server.toml` compatible; recommended, since operator-facing config shouldn't churn. |
|
||||||
| CLI arg parsing | hand-rolled | **`System.CommandLine`** | MIT | For `VoiceCat.Cli` and the server. |
|
| CLI arg parsing | hand-rolled | **`System.CommandLine`** | MIT | For `VoiceCat.Cli` and the server. |
|
||||||
@@ -353,11 +358,10 @@ internal static partial int opus_encode(IntPtr st, ReadOnlySpan<short> pcm, int
|
|||||||
Span<byte> data, int maxDataBytes);
|
Span<byte> data, int maxDataBytes);
|
||||||
```
|
```
|
||||||
|
|
||||||
- `opus_encoder_ctl` is **varargs** — P/Invoke cannot do C varargs portably. Declare one
|
- `opus_encoder_ctl` is **varargs** — P/Invoke cannot do C varargs portably. The implemented
|
||||||
overload per argument shape (`int`, `out int`) with `EntryPoint = "opus_encoder_ctl"`. This
|
desktop binding uses fixed C entry points in `dotnet/native/media.c`; C calls the
|
||||||
works on all the ABIs we target (x64 SysV, x64 Win, arm64 AAPCS) because all the CTLs we use
|
varargs function with the correct ABI. This also handles Apple arm64's different
|
||||||
take a single `int`/`int*`. **Note this explicitly in code comments** — it's a real
|
varargs calling convention. Only whitelisted single-int controls are accepted.
|
||||||
portability caveat if a future CTL takes a different shape.
|
|
||||||
- DRED (`opus_dred_alloc`, `opus_dred_parse`, `opus_decoder_dred_decode`) binds the same way.
|
- DRED (`opus_dred_alloc`, `opus_dred_parse`, `opus_decoder_dred_decode`) binds the same way.
|
||||||
Guard with a runtime feature check as `opus_codec.cpp` does today.
|
Guard with a runtime feature check as `opus_codec.cpp` does today.
|
||||||
- **iOS requires static linking**: use `[LibraryImport("__Internal")]` and link
|
- **iOS requires static linking**: use `[LibraryImport("__Internal")]` and link
|
||||||
@@ -418,7 +422,7 @@ The most mechanical part of the project. Straight `async`/`await` network code.
|
|||||||
| `server.cpp` — accept loop | `Socket.AcceptAsync` loop + `Task` per connection. Trivial. |
|
| `server.cpp` — accept loop | `Socket.AcceptAsync` loop + `Task` per connection. Trivial. |
|
||||||
| `conn_session.cpp` (34 K) — per-conn protocol | The bulk. A big `switch` on `Envelope.BodyCase`. Mechanical; write it against the ported xUnit tests. |
|
| `conn_session.cpp` (34 K) — per-conn protocol | The bulk. A big `switch` on `Envelope.BodyCase`. Mechanical; write it against the ported xUnit tests. |
|
||||||
| `session_registry.cpp` | `ConcurrentDictionary<ulong, Session>` + a channel-membership index. Simpler than the C++. |
|
| `session_registry.cpp` | `ConcurrentDictionary<ulong, Session>` + a channel-membership index. Simpler than the C++. |
|
||||||
| `media_relay.cpp` — the SFU | ⚠️ **The one hot path on the server.** Per inbound datagram: parse 20-byte header → look up ssrc → fan out unmodified to N subscribers. Must be allocation-free: `Socket.ReceiveFromAsync(Memory<byte>, SocketAddress)` into a pooled buffer, `SendToAsync` per subscriber. Do **not** decrypt — the design already forbids it, which is what keeps this cheap. Benchmark this specifically (§11.5). |
|
| `media_relay.cpp` — the SFU | **The server hot path.** Authenticate/decrypt using the sender's directional key, then reseal for each recipient with its directional key and next counter. Preserve SSRC, timestamp, flags, and encoded Opus bytes; replace sequence and ciphertext/tag. Use pooled buffers and `Socket.ReceiveFromAsync(Memory<byte>, SocketAddress)`. Never decode audio. Benchmark fan-out and allocations. |
|
||||||
| `db.cpp` (26 K) — SQLite | `Microsoft.Data.Sqlite`, same schema, same file. Keep raw SQL — do not introduce EF Core; the schema is 4 tables and EF's startup cost hurts the "single binary, instant start" goal. |
|
| `db.cpp` (26 K) — SQLite | `Microsoft.Data.Sqlite`, same schema, same file. Keep raw SQL — do not introduce EF Core; the schema is 4 tables and EF's startup cost hurts the "single binary, instant start" goal. |
|
||||||
| `identity.cpp` | `CertificateRequest` + BouncyCastle Ed25519. Reads the same on-disk files. |
|
| `identity.cpp` | `CertificateRequest` + BouncyCastle Ed25519. Reads the same on-disk files. |
|
||||||
| Keepalive reaper | `PeriodicTimer` — cleaner than the `asio::steady_timer`. |
|
| Keepalive reaper | `PeriodicTimer` — cleaner than the `asio::steady_timer`. |
|
||||||
@@ -618,7 +622,10 @@ not delete anything until the C# equivalent passes the same test against it. Thi
|
|||||||
possible because Option A (§3.2) preserves wire compatibility — which is the main reason to
|
possible because Option A (§3.2) preserves wire compatibility — which is the main reason to
|
||||||
choose it.
|
choose it.
|
||||||
|
|
||||||
Work on a long-lived branch (`cs-port` already exists). Each phase ends with a green build,
|
The rewrite lives under `dotnet/`; initial implementation branch: `dotnet/foundations`,
|
||||||
|
created from `cs-port`. Keep the existing schema at `core/proto/voicecat.proto` during migration.
|
||||||
|
Native packaging is deferred until the codec/audio phase rather than blocking the wire slice.
|
||||||
|
Each phase ends with a green build,
|
||||||
green tests, and an updated `PROGRESS.md` entry.
|
green tests, and an updated `PROGRESS.md` entry.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -672,6 +679,16 @@ not assumed.
|
|||||||
**Exit criterion:** C# client completes a TLS 1.3 handshake with the C++ server, derives
|
**Exit criterion:** C# client completes a TLS 1.3 handshake with the C++ server, derives
|
||||||
matching media keys, and pins the leaf fingerprint.
|
matching media keys, and pins the leaf fingerprint.
|
||||||
|
|
||||||
|
**Checkpoint (2026-09-15):** implemented nonblocking managed TLS, handshake-time
|
||||||
|
exporters, explicit certificate acceptance, persisted TOFU, and native-compatible
|
||||||
|
credentials. The C++ TLS oracle authenticates a media challenge in both directions
|
||||||
|
over an actual socket, proving exporter compatibility. Tests also cover managed
|
||||||
|
fragmented loopback, first-connect acceptance, changed-pin rejection, TLS 1.2 rejection,
|
||||||
|
close_notify/abrupt EOF, restart persistence, and import of C++ credential files.
|
||||||
|
Socket orchestration remains a transport-owner responsibility; the complete managed
|
||||||
|
server and client are later phases. See `dotnet/README.md` for the required native
|
||||||
|
interoperability test command.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 3 — Codec + DSP (est. 1 week)
|
### Phase 3 — Codec + DSP (est. 1 week)
|
||||||
@@ -684,6 +701,19 @@ matching media keys, and pins the leaf fingerprint.
|
|||||||
**Exit criterion:** encode→decode round-trip at every supported frame size; DRED recovery
|
**Exit criterion:** encode→decode round-trip at every supported frame size; DRED recovery
|
||||||
test green; RNNoise output matches the C++ within tolerance.
|
test green; RNNoise output matches the C++ within tolerance.
|
||||||
|
|
||||||
|
**Checkpoint (2026-09-15):** implemented `VoiceCat.Codec`, `VoiceCat.Dsp`, safe native
|
||||||
|
handles, fixed-signature C bindings, and independent desktop native staging. The native
|
||||||
|
build pins the upstream Opus 1.5.2 release/checksum (matching the actual vcpkg baseline,
|
||||||
|
despite older code comments referring to 1.6), enables DRED, and shares the existing
|
||||||
|
vendored RNNoise sources/model. Tests cover 40 rate/channel/frame-size round trips,
|
||||||
|
PLC, 40 dropped-frame DRED recovery formats, C++ denoising within one PCM unit, VAD
|
||||||
|
hang time, and zero managed allocations over 1,000 combined processing cycles.
|
||||||
|
At 8/12 kHz, DRED tests encode at 16 kHz and decode at the requested rate: this pinned
|
||||||
|
encoder's activity analysis cannot emit DRED at 8/12 kHz. These encoding configurations
|
||||||
|
are explicitly rejected. Its redundancy floor is 30 ms because two chunks are required
|
||||||
|
to emit DRED; actual redundancy remains adaptive. Desktop CI builds/stages the library
|
||||||
|
before testing. iOS static native packaging and device audio remain later phases.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 4 — Server (est. 3–4 weeks)
|
### Phase 4 — Server (est. 3–4 weeks)
|
||||||
@@ -691,6 +721,48 @@ test green; RNNoise output matches the C++ within tolerance.
|
|||||||
Do the server before the client: it lets you point the **existing, trusted C++ `vccli`** at
|
Do the server before the client: it lets you point the **existing, trusted C++ `vccli`** at
|
||||||
it, which is a far better test client than a half-built C# one.
|
it, which is a far better test client than a half-built C# one.
|
||||||
|
|
||||||
|
**Implemented checkpoint (2026-09-15):** bounded async TLS socket orchestration,
|
||||||
|
guest/password authentication, existing SQLite account/channel import, snapshots,
|
||||||
|
unprotected channel joins, channel/private/server text, ping and disconnect events.
|
||||||
|
The existing C++ CLI authenticates and sends text through this server. Argon2id uses
|
||||||
|
the existing BouncyCastle dependency with a strict libsodium PHC parser, not a new
|
||||||
|
Konscious dependency. Native database tests prove password compatibility in both
|
||||||
|
directions without resets. SQLite uses `Microsoft.Data.Sqlite.Core` 10.0.5,
|
||||||
|
SQLitePCLRaw bundle 3.0.2 and explicitly pinned SourceGear SQLite 3.50.4.2.
|
||||||
|
See `docs/api-dotnet.md` for limits. This first checkpoint did not include UDP voice,
|
||||||
|
streams, protected joins, moderation, admin handlers or production configuration.
|
||||||
|
The subsequent voice checkpoint is described below.
|
||||||
|
|
||||||
|
**Voice checkpoint:** the managed server now advertises UDP, issues session-bound
|
||||||
|
tokens, implements voice subscription and multi-stream signaling, and relays encrypted
|
||||||
|
Opus with recipient-specific counters. The first UDP endpoint is fixed for the session;
|
||||||
|
reconnect for endpoint changes. Immutable routing snapshots separate control handlers
|
||||||
|
from the UDP crypto owner. Real-socket tests cover replay/forgery/SSRC rejection,
|
||||||
|
channel/subscription isolation, stream stop and disconnect. A native client oracle
|
||||||
|
exercises bidirectional microphone and screen audio in mono and stereo. The fan-out
|
||||||
|
core has a 50-subscriber allocation regression test; transport scheduling and the
|
||||||
|
BouncyCastle crypto fallback are excluded from its zero-allocation guarantee.
|
||||||
|
Two real C++ `vccli` processes also pass join/text/bidirectional voice tests using
|
||||||
|
finite `--test-tone-ms` external capture/playback. The transport load test delivers
|
||||||
|
all 2,500 recipient packets from a sender paced at 50 pps to 50 subscribers.
|
||||||
|
**Reaper checkpoint:** configurable 45-second idle expiry / 15-second sweep replaces
|
||||||
|
the TCP-only idle timeout. Control envelopes, authenticated voice and bound-endpoint
|
||||||
|
keepalives refresh shared monotonic activity; invalid media does not. Reaping removes
|
||||||
|
presence and media routing, and can be disabled. Tests inject a clock to cover silent
|
||||||
|
clients, UDP-only activity, forged media, single departure events and disabled expiry.
|
||||||
|
**Channel/administration checkpoint:** protected joins and channel CRUD now persist using
|
||||||
|
the native BLAKE2b password format (native verification in both directions). Permissions
|
||||||
|
gate moderation and account create/reset/delete/list. Mute/deafen/move update encrypted
|
||||||
|
routing; kick/ban retire media and emit one reason-bearing departure. Guest bans use
|
||||||
|
addresses, account bans use usernames, and wire milliseconds convert to database seconds.
|
||||||
|
Temporary-channel permission only creates temporary channels and only administrators
|
||||||
|
grant permissions; these deliberately tighten native policy. Tree validation and Lobby
|
||||||
|
protection prevent invalid mutations. Existing streams stop on channel edits/moves/deletion.
|
||||||
|
The C++ CLI creates protected channels and administers accounts against the managed server;
|
||||||
|
its channel argument lifetimes and default audio config were corrected. See api-dotnet.md
|
||||||
|
for limits, persistence and policy differences. Production configuration/publishing and
|
||||||
|
the remaining server readiness checks still precede Phase 4 completion.
|
||||||
|
|
||||||
1. `VoiceCat.Server`: accept loop, `ConnSession` protocol handling, session registry.
|
1. `VoiceCat.Server`: accept loop, `ConnSession` protocol handling, session registry.
|
||||||
2. `Db` on `Microsoft.Data.Sqlite` — same schema. **Resolve the Argon2id hash-compat
|
2. `Db` on `Microsoft.Data.Sqlite` — same schema. **Resolve the Argon2id hash-compat
|
||||||
question here** (§4).
|
question here** (§4).
|
||||||
|
|||||||
@@ -305,6 +305,10 @@ message TextMessage {
|
|||||||
laptop sleep) that never produce a TCP EOF are cleaned up, and peers' audio engines
|
laptop sleep) that never produce a TCP EOF are cleaned up, and peers' audio engines
|
||||||
`remove_stream` and stop PLC. The timeout and sweep interval are configurable via
|
`remove_stream` and stop PLC. The timeout and sweep interval are configurable via
|
||||||
`server::Config::reaper_timeout_ms` / `reaper_sweep_ms` (set to 0 to disable).
|
`server::Config::reaper_timeout_ms` / `reaper_sweep_ms` (set to 0 to disable).
|
||||||
|
The managed server uses `VoiceServerOptions.IdleTimeout` / `ReaperInterval` with the
|
||||||
|
same 45-second / 15-second defaults (zero idle timeout disables reaping). It refreshes
|
||||||
|
activity on parsed control envelopes, authenticated owned-stream voice, and exact
|
||||||
|
bound-endpoint keepalives; rejected media does not refresh activity. Timing is monotonic.
|
||||||
- **UDP:** a separate lightweight keepalive on the media channel (voice.md §6) keeps NAT
|
- **UDP:** a separate lightweight keepalive on the media channel (voice.md §6) keeps NAT
|
||||||
bindings alive and detects media-path failure independently of the control channel.
|
bindings alive and detects media-path failure independently of the control channel.
|
||||||
- **Graceful disconnect.** A client ending its session sends `Disconnect { code = 0;
|
- **Graceful disconnect.** A client ending its session sends `Disconnect { code = 0;
|
||||||
|
|||||||
@@ -2,6 +2,27 @@
|
|||||||
|
|
||||||
## 1. Milestones
|
## 1. Milestones
|
||||||
|
|
||||||
|
### .NET port — initial slice
|
||||||
|
|
||||||
|
**Complete 2026-09-15:** managed Release build and 34/34 xUnit tests, C++ golden
|
||||||
|
fixtures for both crypto backends, fresh native build and 29/29 CTest tests. Native
|
||||||
|
packaging and TLS/server/client migration remain later checkpoints.
|
||||||
|
|
||||||
|
- `dotnet/` contains .NET 10 protocol and crypto assemblies plus xUnit conformance tests.
|
||||||
|
- Preserve the existing protobuf and 20-byte media wire formats; keep C++ as the oracle.
|
||||||
|
- **Exit:** managed framing, headers, and ciphertext match fixtures generated by C++;
|
||||||
|
managed tests and the existing C++ behavior suite pass.
|
||||||
|
- **Subsequent checkpoints:** TLS/exporter and credential interoperability, codec/DSP
|
||||||
|
desktop packaging, and managed control/UDP server slices are implemented. Two C++
|
||||||
|
`vccli` processes authenticate, join, chat and exchange mono/stereo voice through
|
||||||
|
the managed server. The 50-subscriber fan-out core has an allocation regression test.
|
||||||
|
- **Media-aware reaping:** monotonic control/valid-UDP activity, configurable 45-second
|
||||||
|
idle timeout / 15-second sweep, and graceful shutdown are implemented and tested.
|
||||||
|
- **Next:** finish managed server administration, protected joins and production configuration,
|
||||||
|
then audio/client core, Windows cutover, C# AppKit and UIKit.
|
||||||
|
Keep the Swift ReplayKit extension and its shared ring; defer C++ removal until parity.
|
||||||
|
- See `docs/porting-to-dotnet.md` and `dotnet/README.md`.
|
||||||
|
|
||||||
Each milestone is shippable/testable on its own. The headless C++ test client (`vccli`)
|
Each milestone is shippable/testable on its own. The headless C++ test client (`vccli`)
|
||||||
exists from M1 so the protocol can be exercised long before any GUI.
|
exists from M1 so the protocol can be exercised long before any GUI.
|
||||||
|
|
||||||
|
|||||||
+38
-17
@@ -49,6 +49,16 @@ This is a known limitation of the current design. Closing it properly requires b
|
|||||||
Ed25519 key into the TLS cert (e.g. as a SubjectAltName or extension), which is a planned
|
Ed25519 key into the TLS cert (e.g. as a SubjectAltName or extension), which is a planned
|
||||||
future improvement. Until then, clients display both values but gate on the cert fingerprint.
|
future improvement. Until then, clients display both values but gate on the cert fingerprint.
|
||||||
|
|
||||||
|
**Managed rewrite checkpoint:** `dotnet/` uses nonblocking BouncyCastle TLS 1.3 and
|
||||||
|
captures directional exporters during handshake completion. Its client requires an
|
||||||
|
explicit leaf-fingerprint acceptance callback; PKI validation remains unimplemented.
|
||||||
|
New managed server certificates include the Ed25519 public key in SAN URI
|
||||||
|
`urn:voicecat:identity:ed25519:<lowercase-public-key-hex>`. Existing C++ credentials
|
||||||
|
are imported unchanged. Verifying that URI against the declared ServerHello identity
|
||||||
|
is still deferred to the managed session layer; leaf-certificate TOFU remains the
|
||||||
|
trust gate. Missing members of a persisted credential set cause startup rejection
|
||||||
|
rather than automatic identity rotation. See [api-dotnet.md](api-dotnet.md).
|
||||||
|
|
||||||
Client certificates are reserved for a future "key-based identity" option (see roadmap) but
|
Client certificates are reserved for a future "key-based identity" option (see roadmap) but
|
||||||
are not required in v1.
|
are not required in v1.
|
||||||
|
|
||||||
@@ -67,13 +77,16 @@ mandatory from the first build. This was chosen over DTLS after weighing two fin
|
|||||||
|
|
||||||
### How it works
|
### How it works
|
||||||
|
|
||||||
1. During the TLS 1.3 control handshake, both sides call the keying-material exporter with a
|
1. After the TLS 1.3 control handshake, both sides call the keying-material exporter with
|
||||||
fixed label (`"voicecat media v1"`) to derive independent **send/recv media keys** and a
|
label `"voicecat media v1"` and a one-byte context: `0x00` for client→server,
|
||||||
salt. No second handshake, no certificates on the UDP path — the UDP channel inherits the
|
`0x01` for server→client. Each export yields a 32-byte directional media key.
|
||||||
|
No second handshake, no certificates on the UDP path — the UDP channel inherits the
|
||||||
authenticated, MITM-resistant TLS session's trust.
|
authenticated, MITM-resistant TLS session's trust.
|
||||||
2. Each UDP voice frame is sealed with **ChaCha20-Poly1305** (libsodium, ISC license).
|
2. Each UDP voice frame is sealed with **ChaCha20-Poly1305** (libsodium in C++;
|
||||||
3. The readable routing field (`ssrc`) is passed as AEAD **associated data** so the relay can
|
platform cryptography with a BouncyCastle fallback in .NET).
|
||||||
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
|
This keeps the entire crypto surface on two permissive libraries (mbedTLS + libsodium), adds
|
||||||
no handshake latency to voice startup, and is small enough to audit fully. It is abstracted
|
no handshake latency to voice startup, and is small enough to audit fully. It is abstracted
|
||||||
@@ -84,11 +97,12 @@ the design depends on that.
|
|||||||
### Per-frame protections
|
### Per-frame protections
|
||||||
|
|
||||||
- **AEAD** (ChaCha20-Poly1305) over each voice frame — confidentiality + integrity.
|
- **AEAD** (ChaCha20-Poly1305) over each voice frame — confidentiality + integrity.
|
||||||
- **Associated data:** the `ssrc` (and version/flags) are authenticated-but-visible so the
|
- **Associated data:** all 20 header bytes remain visible and authenticated; the Opus
|
||||||
relay routes without decrypting; everything else is encrypted.
|
payload is encrypted and followed by a 16-byte tag.
|
||||||
- **Nonce discipline:** `nonce = direction_bit ‖ ssrc ‖ monotonic_packet_counter`. The
|
- **Nonce discipline:** `nonce = four_zero_bytes ‖ counter_u64_big_endian`. Counters are
|
||||||
counter never repeats under one key; the session **rekeys** (re-derives via the exporter
|
per directional session key, shared across its streams. Direction separation comes
|
||||||
with a bumped epoch) well before counter exhaustion or on a time/byte budget.
|
from exporter contexts, not nonce bits. Automatic epoch rekeying is not implemented;
|
||||||
|
the .NET encryptor refuses counter exhaustion and requires a new session.
|
||||||
- **Anti-replay:** a 64-bit sliding-window replay filter keyed on the packet counter (à la
|
- **Anti-replay:** a 64-bit sliding-window replay filter keyed on the packet counter (à la
|
||||||
IPsec). The window is **advanced only after the AEAD tag verifies** (RFC 3711 §3.3 order:
|
IPsec). The window is **advanced only after the AEAD tag verifies** (RFC 3711 §3.3 order:
|
||||||
replay-check → authenticate → update). The counter is read from the unauthenticated
|
replay-check → authenticate → update). The counter is read from the unauthenticated
|
||||||
@@ -102,14 +116,21 @@ the design depends on that.
|
|||||||
UDP packets are not individually authenticated to a *user* beyond the transport session.
|
UDP packets are not individually authenticated to a *user* beyond the transport session.
|
||||||
Binding works as:
|
Binding works as:
|
||||||
|
|
||||||
1. `AuthResult.udp_token` (issued over TLS) is a short-lived, single-use, random token tied
|
1. `AuthResult.udp_token` (issued over TLS) is a random 16-byte token tied to the
|
||||||
to `session_id`.
|
authenticated session. The client confirms it with `UdpBinding` over TLS.
|
||||||
2. Client's first UDP message is `UdpBinding{udp_token}`, sent as the first AEAD media frame
|
2. Protocol v2 bootstraps UDP with a **plaintext** `UDP_BINDING` packet: the 20-byte
|
||||||
using the keys exported from the TLS session.
|
binary header followed by the token. This is not a protobuf or an AEAD voice frame.
|
||||||
3. Server validates the token, binds the **5-tuple → session_id**, and discards the token.
|
3. Server validates the token and binds the **5-tuple → session_id**. The managed server
|
||||||
|
accepts the first endpoint only; further bootstrap packets cannot replace it.
|
||||||
|
Endpoint changes require a new authenticated session. The token remains available
|
||||||
|
for TLS confirmation but cannot establish a second binding. Session removal retires
|
||||||
|
its endpoint, token and directional keys. The C++ oracle currently permits rebinding
|
||||||
|
with the same token; this differs in policy, not in the packet format.
|
||||||
4. Thereafter, frames are accepted only on that bound tuple; ssrcs are checked against the
|
4. Thereafter, frames are accepted only on that bound tuple; ssrcs are checked against the
|
||||||
streams the session announced. Source-address spoofing can't hijack a session because the
|
streams the session announced. Source-address spoofing can't hijack a session because the
|
||||||
attacker lacks the media key and the token.
|
attacker lacks the media key. The bootstrap token is visible on UDP, so it is not
|
||||||
|
a substitute for AEAD authentication and SSRC ownership checks. Header-only keepalives
|
||||||
|
are echoed only for bound endpoints; they provide liveness, not authenticated content.
|
||||||
|
|
||||||
## 4. Authentication & accounts (settled: guests + local accounts)
|
## 4. Authentication & accounts (settled: guests + local accounts)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
# Tech Stack & Dependencies
|
# Tech Stack & Dependencies
|
||||||
|
|
||||||
|
## Initial .NET rewrite
|
||||||
|
|
||||||
|
The parallel rewrite under `dotnet/` targets .NET 10. Its initial dependencies are
|
||||||
|
Google.Protobuf 3.36.1 (BSD-3-Clause), build-only Grpc.Tools 2.83.0 (Apache-2.0), and
|
||||||
|
BouncyCastle.Cryptography 2.6.2 (MIT). Media AEAD prefers the platform implementation;
|
||||||
|
BouncyCastle provides the managed fallback and is the planned TLS/exporter provider.
|
||||||
|
No managed server or audio replacement is shipped yet.
|
||||||
|
|
||||||
|
Project files and NuGet lock files pin versions. `dotnet/check-licenses.ps1` checks
|
||||||
|
all restored direct/transitive packages against a permissive license allowlist in CI;
|
||||||
|
unknown or copyleft licenses fail. See `dotnet/README.md` for build and test commands.
|
||||||
|
The existing implementation's dependency choices follow below.
|
||||||
|
|
||||||
Concrete library choices with versions and rationale. Everything in the **core** is C++
|
Concrete library choices with versions and rationale. Everything in the **core** is C++
|
||||||
(C++20). UIs are Swift and C#. Build is CMake + vcpkg.
|
(C++20). UIs are Swift and C#. Build is CMake + vcpkg.
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -66,9 +66,10 @@ payload one Opus packet (the encoder's output for one frame)
|
|||||||
> interoperate; the `Hello` handshake rejects on `proto_version` mismatch.
|
> interoperate; the `Hello` handshake rejects on `proto_version` mismatch.
|
||||||
|
|
||||||
This is intentionally RTP-shaped (familiar semantics: ssrc/seq/timestamp) without RTP's
|
This is intentionally RTP-shaped (familiar semantics: ssrc/seq/timestamp) without RTP's
|
||||||
full machinery. The **server relays the payload unmodified** — it only reads the header to
|
full machinery. The server authenticates/decrypts each incoming packet and reseals its
|
||||||
route by ssrc→channel and may restamp nothing (the client's ssrc is globally unique once
|
encoded Opus bytes for each recipient using that recipient's directional key and send
|
||||||
assigned at `StreamAnnounce`). No server-side decode.
|
counter. SSRC, timestamp, flags, and codec pass through; sequence and ciphertext/tag change.
|
||||||
|
There is no server-side audio decoding or transcoding.
|
||||||
|
|
||||||
### Why client-sends-ssrc is safe
|
### Why client-sends-ssrc is safe
|
||||||
|
|
||||||
@@ -183,7 +184,7 @@ Each receiver keeps an **adaptive jitter buffer per ssrc** with **bounded-depth
|
|||||||
|
|
||||||
- A `KEEPALIVE` (type 2) frame flows both directions on the media channel every ~5 s to
|
- A `KEEPALIVE` (type 2) frame flows both directions on the media channel every ~5 s to
|
||||||
hold NAT bindings and measure media-path RTT/loss independent of TCP. The frame is
|
hold NAT bindings and measure media-path RTT/loss independent of TCP. The frame is
|
||||||
plaintext (14-byte header, no payload, no AEAD) — the server identifies the sender by
|
plaintext (20-byte header, no payload, no AEAD) — the server identifies the sender by
|
||||||
its already-verified UDP endpoint (established during the `UdpBinding` handshake). On
|
its already-verified UDP endpoint (established during the `UdpBinding` handshake). On
|
||||||
receipt the server bumps the sender's `last_seen` (so media activity defers the TCP
|
receipt the server bumps the sender's `last_seen` (so media activity defers the TCP
|
||||||
reaper independently of control-channel traffic) and echoes the frame back so the
|
reaper independently of control-channel traffic) and echoes the frame back so the
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*.cs]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
csharp_style_namespace_declarations = file_scoped:warning
|
||||||
|
dotnet_sort_system_directives_first = true
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<AnalysisLevel>latest</AnalysisLevel>
|
||||||
|
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<VoiceCatNativeRid Condition="'$(VoiceCatNativeRid)' == '' and '$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</VoiceCatNativeRid>
|
||||||
|
<VoiceCatNativeRid Condition="'$(VoiceCatNativeRid)' == ''">$(NETCoreSdkRuntimeIdentifier)</VoiceCatNativeRid>
|
||||||
|
<VoiceCatNativeDirectory Condition="'$(VoiceCatNativeDirectory)' == ''">$(MSBuildThisFileDirectory)artifacts/native/runtimes/$(VoiceCatNativeRid)/native</VoiceCatNativeDirectory>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="$(MSBuildThisFileDirectory)artifacts/native/licenses/*.txt" Link="licenses/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
<None Include="$(VoiceCatNativeDirectory)/voicecat_media.dll" Condition="Exists('$(VoiceCatNativeDirectory)/voicecat_media.dll')" Link="%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
<None Include="$(VoiceCatNativeDirectory)/libvoicecat_media.so" Condition="Exists('$(VoiceCatNativeDirectory)/libvoicecat_media.so')" Link="%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
<None Include="$(VoiceCatNativeDirectory)/libvoicecat_media.dylib" Condition="Exists('$(VoiceCatNativeDirectory)/libvoicecat_media.dylib')" Link="%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# VoiceCat .NET rewrite
|
||||||
|
|
||||||
|
The first slice targets .NET 10: protobuf, control framing, voice headers, and media
|
||||||
|
encryption, TLS 1.3, persisted TOFU pins, server credentials, and an initial managed
|
||||||
|
control server. Media relay, client state, audio, and UI migration are next. The existing
|
||||||
|
C++ implementation remains the conformance oracle.
|
||||||
|
|
||||||
|
Codec/DSP wrappers now cover Opus, DRED recovery, RNNoise, and energy VAD. Build
|
||||||
|
the desktop native library before running their tests (CMake and a C compiler required):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./dotnet/build-native.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
The script downloads upstream Opus 1.5.2 with a pinned SHA-256, builds DRED-enabled
|
||||||
|
Opus and the existing vendored RNNoise model, and stages `voicecat_media` plus license
|
||||||
|
notices under `dotnet/artifacts/native/`. It builds independently of the C++ core and
|
||||||
|
vcpkg. On Windows, Visual Studio's C++ workload works with the default generator;
|
||||||
|
for this repository's MinGW toolchain use:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./dotnet/build-native.ps1 -Generator Ninja -CCompiler C:/tools/msys64/ucrt64/bin/cc.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux/macOS can run the same script with PowerShell, or use CMake directly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake -S dotnet/native -B dotnet/artifacts/native-build -DCMAKE_BUILD_TYPE=Release
|
||||||
|
cmake --build dotnet/artifacts/native-build --target voicecat_media --parallel 2
|
||||||
|
cmake --install dotnet/artifacts/native-build --component DotnetMedia --prefix dotnet/artifacts/native
|
||||||
|
```
|
||||||
|
|
||||||
|
MSBuild copies the staged library into managed build/publish output for the selected
|
||||||
|
RID. Override `VoiceCatNativeRid` or `VoiceCatNativeDirectory` for explicit staging;
|
||||||
|
`RuntimeIdentifier` takes priority over the SDK's host RID. Cross-compilation is not
|
||||||
|
automatic. iOS static linking and audio-device shims belong to later client phases.
|
||||||
|
Native codec/DSP tests require this library; they do not silently skip.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The DSP oracle calls the existing C++ `ApmProcessor` with 200 deterministic noise
|
||||||
|
frames and records the final 960 samples. Regenerate its fixture with:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cmake --build --preset dev --target voicecat-dotnet-dsp-oracle
|
||||||
|
./build/dev/bin/voicecat-dotnet-dsp-oracle.exe dotnet/tests/VoiceCat.Tests/Fixtures/cpp-noise.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The managed test allows a one-unit PCM difference for floating-point rounding.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Managed server checkpoint
|
||||||
|
|
||||||
|
Run the TLS control server on loopback (optional arguments: data directory, TCP port):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project dotnet/src/VoiceCat.Server -c Release -- ./voicecat-data 7443
|
||||||
|
./build/dev/bin/vccli.exe --host 127.0.0.1 --port 7443 --nick Guest --text "hello"
|
||||||
|
```
|
||||||
|
|
||||||
|
It creates or imports `server_identity.key`, `server.crt`, `server.key`, and
|
||||||
|
`voicecat.db`. An empty channel table gets Lobby and Music Room; existing channels
|
||||||
|
are preserved. Guests are enabled by the CLI; hosting `VoiceServer` directly can
|
||||||
|
disable them. Existing accounts authenticate without resetting passwords. Account
|
||||||
|
creation is currently available through `AccountStore`; bootstrap/admin CLI and
|
||||||
|
wire administration are pending.
|
||||||
|
|
||||||
|
Tests cover real TLS sockets, authentication retries, snapshots, channel moves,
|
||||||
|
text routing, sender attribution, ping, and disconnect events. Enable native checks:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cmake --build --preset dev --target voicecat-dotnet-password-oracle voicecat-dotnet-database-oracle vccli
|
||||||
|
$env:VOICECAT_DATABASE_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-database-oracle.exe).Path
|
||||||
|
$env:VOICECAT_VCCLI = (Resolve-Path build/dev/bin/vccli.exe).Path
|
||||||
|
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
|
||||||
|
```
|
||||||
|
|
||||||
|
The database oracle creates an account/channel using the shipped C++ database code;
|
||||||
|
managed code imports and authenticates it, then C++ authenticates a managed-created
|
||||||
|
account. CI also regenerates the libsodium password fixture. Native checks require
|
||||||
|
the optional `VOICECAT_BUILD_DOTNET_ORACLE=ON` configure flag and a real-deps build.
|
||||||
|
|
||||||
|
The server also advertises UDP on the TCP port number, supports voice subscription
|
||||||
|
and stream signaling, and reseals encoded audio for subscribers in the same channel.
|
||||||
|
UDP binding fixes the first endpoint for the session; reconnect after endpoint changes.
|
||||||
|
Protected joins, administration, moderation and production configuration remain
|
||||||
|
before Phase 4 completion. The server's media-aware reaper defaults to 45 seconds
|
||||||
|
of inactivity with a 15-second sweep. Parsed control envelopes, valid encrypted
|
||||||
|
voice and keepalives from bound endpoints refresh activity; invalid media does not.
|
||||||
|
`VoiceServerOptions` configures timeouts and capacity; zero idle timeout disables
|
||||||
|
reaping. The constructor overload accepts `TimeProvider` for deterministic expiry tests.
|
||||||
|
|
||||||
|
Enable deterministic native voice interoperability (no audio hardware required):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cmake --build --preset dev --target voicecat-dotnet-voice-oracle
|
||||||
|
$env:VOICECAT_VOICE_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-voice-oracle.exe).Path
|
||||||
|
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
|
||||||
|
```
|
||||||
|
|
||||||
|
Two existing C++ clients authenticate, join Lobby or Music Room, publish three
|
||||||
|
concurrent streams, feed PCM, and verify decoded energy and metadata in both directions.
|
||||||
|
The native clients use external capture/playback to avoid device dependencies in CI.
|
||||||
|
`MediaFanoutTests` separately verifies 50-subscriber routing/resealing without managed
|
||||||
|
allocations after warm-up and reports throughput; socket scheduling is excluded.
|
||||||
|
The transport load test delivers all 2,500 recipient packets from a paced 50 pps sender.
|
||||||
|
Native `vccli --test-tone-ms 4000` runs finite external capture/playback, feeds a tone,
|
||||||
|
and fails without decoded remote audio. Tests start two CLI processes in mono/stereo
|
||||||
|
channels and also verify channel text. Normal `--voice` now explicitly subscribes before
|
||||||
|
announcing its microphone stream. No C ABI or wire changes were needed.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
|
||||||
|
<Project Path="src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
|
||||||
|
<Project Path="src/VoiceCat.Codec/VoiceCat.Codec.csproj" />
|
||||||
|
<Project Path="src/VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
|
||||||
|
<Project Path="src/VoiceCat.Server/VoiceCat.Server.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/VoiceCat.Tests/VoiceCat.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
param(
|
||||||
|
[string]$BuildDirectory = "$PSScriptRoot/artifacts/native-build",
|
||||||
|
[string]$RuntimeIdentifier = [System.Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier,
|
||||||
|
[string]$Generator,
|
||||||
|
[string]$CCompiler
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$configure = @('-S', "$PSScriptRoot/native", '-B', $BuildDirectory,
|
||||||
|
'-DCMAKE_BUILD_TYPE=Release', "-DVOICECAT_DOTNET_RID=$RuntimeIdentifier")
|
||||||
|
if ($Generator) { $configure += @('-G', $Generator) }
|
||||||
|
if ($CCompiler) { $configure += "-DCMAKE_C_COMPILER=$CCompiler" }
|
||||||
|
& cmake @configure
|
||||||
|
if ($LASTEXITCODE) { throw "Native configure failed: $LASTEXITCODE" }
|
||||||
|
& cmake --build $BuildDirectory --config Release --target voicecat_media --parallel 2
|
||||||
|
if ($LASTEXITCODE) { throw "Native build failed: $LASTEXITCODE" }
|
||||||
|
& cmake --install $BuildDirectory --config Release --component DotnetMedia --prefix "$PSScriptRoot/artifacts/native"
|
||||||
|
if ($LASTEXITCODE) { throw "Native staging failed: $LASTEXITCODE" }
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
$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 pinned package contains public-domain SQLite builds; no NuGet license metadata.
|
||||||
|
if ($id -eq 'sourcegear.sqlite3' -and $version -eq '3.50.4.2' -and
|
||||||
|
$spec.package.metadata.projectUrl -eq 'https://sqlite.org/' -and
|
||||||
|
$spec.package.metadata.repository.commit -eq '9a2d8281d8f714fe54f7cbcd122479d17b533e89') { 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,13 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$ExpectedPath,
|
||||||
|
[Parameter(Mandatory)][string]$ActualPath
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$expected = (Get-Content -Raw -LiteralPath $ExpectedPath | ConvertFrom-Json).samples
|
||||||
|
$actual = (Get-Content -Raw -LiteralPath $ActualPath | ConvertFrom-Json).samples
|
||||||
|
if ($expected.Count -ne 960 -or $actual.Count -ne $expected.Count) { throw 'DSP fixture sample counts differ.' }
|
||||||
|
for ($i = 0; $i -lt $expected.Count; $i++) {
|
||||||
|
if ([Math]::Abs($expected[$i] - $actual[$i]) -gt 1) { throw "DSP fixture differs at sample $i." }
|
||||||
|
}
|
||||||
|
Write-Output 'C++ DSP fixture matches within one PCM unit.'
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"sdk": { "version": "10.0.203", "rollForward": "latestFeature" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.24)
|
||||||
|
project(VoiceCatMedia LANGUAGES C)
|
||||||
|
|
||||||
|
if(MSVC)
|
||||||
|
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||||
|
set(OPUS_STATIC_RUNTIME ON CACHE BOOL "" FORCE)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(CMAKE_SYSTEM_NAME STREQUAL "iOS")
|
||||||
|
message(FATAL_ERROR "iOS static NativeReference packaging belongs to the later client phase.")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NOT TARGET Opus::opus)
|
||||||
|
set(bundled_default OFF)
|
||||||
|
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
|
||||||
|
set(bundled_default ON)
|
||||||
|
endif()
|
||||||
|
option(VOICECAT_BUNDLED_OPUS "Build pinned Opus with DRED support" ${bundled_default})
|
||||||
|
if(VOICECAT_BUNDLED_OPUS)
|
||||||
|
include(FetchContent)
|
||||||
|
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
|
||||||
|
set(OPUS_DRED ON CACHE BOOL "" FORCE)
|
||||||
|
set(OPUS_DEEP_PLC ON CACHE BOOL "" FORCE)
|
||||||
|
set(OPUS_BUILD_PROGRAMS OFF CACHE BOOL "" FORCE)
|
||||||
|
set(OPUS_BUILD_TESTING OFF CACHE BOOL "" FORCE)
|
||||||
|
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||||
|
FetchContent_Declare(opus
|
||||||
|
URL https://downloads.xiph.org/releases/opus/opus-1.5.2.tar.gz
|
||||||
|
URL_HASH SHA256=65c1d2f78b9f2fb20082c38cbe47c951ad5839345876e46941612ee87f9a7ce1
|
||||||
|
TIMEOUT 60
|
||||||
|
INACTIVITY_TIMEOUT 30
|
||||||
|
DOWNLOAD_EXTRACT_TIMESTAMP TRUE)
|
||||||
|
FetchContent_MakeAvailable(opus)
|
||||||
|
set(VOICECAT_OPUS_LICENSE "${opus_SOURCE_DIR}/COPYING")
|
||||||
|
else()
|
||||||
|
find_package(Opus CONFIG REQUIRED)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
if(NOT VOICECAT_OPUS_LICENSE)
|
||||||
|
find_file(VOICECAT_OPUS_LICENSE NAMES copyright COPYING HINTS "${Opus_DIR}" NO_DEFAULT_PATH)
|
||||||
|
endif()
|
||||||
|
if(NOT VOICECAT_OPUS_LICENSE)
|
||||||
|
message(FATAL_ERROR "Set VOICECAT_OPUS_LICENSE to the imported Opus copyright file for native staging.")
|
||||||
|
endif()
|
||||||
|
set(RNNOISE_DIR "${CMAKE_CURRENT_LIST_DIR}/../../third_party/rnnoise")
|
||||||
|
if(NOT TARGET rnnoise)
|
||||||
|
add_library(rnnoise STATIC
|
||||||
|
${RNNOISE_DIR}/src/denoise.c ${RNNOISE_DIR}/src/rnn.c
|
||||||
|
${RNNOISE_DIR}/src/pitch.c ${RNNOISE_DIR}/src/kiss_fft.c
|
||||||
|
${RNNOISE_DIR}/src/celt_lpc.c ${RNNOISE_DIR}/src/nnet.c
|
||||||
|
${RNNOISE_DIR}/src/nnet_default.c ${RNNOISE_DIR}/src/parse_lpcnet_weights.c
|
||||||
|
${RNNOISE_DIR}/src/rnnoise_data.c ${RNNOISE_DIR}/src/rnnoise_tables.c)
|
||||||
|
target_include_directories(rnnoise PUBLIC ${RNNOISE_DIR}/include PRIVATE ${RNNOISE_DIR}/src)
|
||||||
|
target_compile_definitions(rnnoise PRIVATE DISABLE_DEBUG_FLOAT)
|
||||||
|
if(MSVC)
|
||||||
|
target_compile_definitions(rnnoise PRIVATE restrict=__restrict)
|
||||||
|
endif()
|
||||||
|
target_compile_features(rnnoise PRIVATE c_std_11)
|
||||||
|
set_target_properties(rnnoise PROPERTIES POSITION_INDEPENDENT_CODE ON C_VISIBILITY_PRESET hidden)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
add_library(voicecat_media SHARED media.c)
|
||||||
|
target_compile_features(voicecat_media PRIVATE c_std_99)
|
||||||
|
target_link_libraries(voicecat_media PRIVATE Opus::opus rnnoise)
|
||||||
|
set_target_properties(voicecat_media PROPERTIES C_VISIBILITY_PRESET hidden)
|
||||||
|
if(WIN32)
|
||||||
|
set_target_properties(voicecat_media PROPERTIES PREFIX "")
|
||||||
|
endif()
|
||||||
|
if(NOT WIN32)
|
||||||
|
target_link_libraries(voicecat_media PRIVATE m)
|
||||||
|
elseif(MINGW)
|
||||||
|
target_link_options(voicecat_media PRIVATE -static-libgcc -static)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NOT VOICECAT_DOTNET_RID)
|
||||||
|
string(TOLOWER "${CMAKE_SYSTEM_PROCESSOR}" architecture)
|
||||||
|
if(architecture MATCHES "^(amd64|x86_64)$")
|
||||||
|
set(architecture x64)
|
||||||
|
elseif(architecture MATCHES "^(aarch64|arm64)$")
|
||||||
|
set(architecture arm64)
|
||||||
|
else()
|
||||||
|
message(FATAL_ERROR "Set VOICECAT_DOTNET_RID for architecture ${architecture}")
|
||||||
|
endif()
|
||||||
|
if(WIN32)
|
||||||
|
set(platform win)
|
||||||
|
elseif(APPLE)
|
||||||
|
set(platform osx)
|
||||||
|
else()
|
||||||
|
set(platform linux)
|
||||||
|
endif()
|
||||||
|
set(VOICECAT_DOTNET_RID "${platform}-${architecture}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
install(TARGETS voicecat_media
|
||||||
|
RUNTIME DESTINATION runtimes/${VOICECAT_DOTNET_RID}/native COMPONENT DotnetMedia
|
||||||
|
LIBRARY DESTINATION runtimes/${VOICECAT_DOTNET_RID}/native COMPONENT DotnetMedia)
|
||||||
|
install(FILES ${RNNOISE_DIR}/COPYING DESTINATION licenses RENAME RNNoise.txt COMPONENT DotnetMedia)
|
||||||
|
install(FILES ${CMAKE_CURRENT_LIST_DIR}/NOTICE.txt DESTINATION licenses COMPONENT DotnetMedia)
|
||||||
|
if(VOICECAT_OPUS_LICENSE)
|
||||||
|
install(FILES ${VOICECAT_OPUS_LICENSE} DESTINATION licenses RENAME Opus.txt COMPONENT DotnetMedia)
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
VoiceCat desktop codec/DSP bindings
|
||||||
|
|
||||||
|
Opus 1.5.2: BSD-3-Clause. See Opus.txt for copyright, license, and patent notices.
|
||||||
|
Upstream: https://opus-codec.org/
|
||||||
|
Release: https://downloads.xiph.org/releases/opus/opus-1.5.2.tar.gz
|
||||||
|
SHA-256: 65c1d2f78b9f2fb20082c38cbe47c951ad5839345876e46941612ee87f9a7ce1
|
||||||
|
|
||||||
|
RNNoise code: BSD-3-Clause. See RNNoise.txt.
|
||||||
|
RNNoise model weights: CC0-1.0, as recorded in third_party/README.md.
|
||||||
|
Upstream: https://github.com/xiph/rnnoise
|
||||||
|
Vendored commit: 70f1d256acd4b34a572f999a05c87bf00b67730d
|
||||||
|
CC0: https://creativecommons.org/publicdomain/zero/1.0/
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#include <opus.h>
|
||||||
|
#include "rnnoise.h"
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
#define VC_EXPORT __declspec(dllexport)
|
||||||
|
#else
|
||||||
|
#define VC_EXPORT __attribute__((visibility("default")))
|
||||||
|
#endif
|
||||||
|
|
||||||
|
VC_EXPORT const char *vcm_opus_version(void) { return opus_get_version_string(); }
|
||||||
|
VC_EXPORT const char *vcm_opus_error(int error) { return opus_strerror(error); }
|
||||||
|
VC_EXPORT OpusEncoder *vcm_encoder_create(int rate, int channels, int application, int *error) {
|
||||||
|
return opus_encoder_create(rate, channels, application, error);
|
||||||
|
}
|
||||||
|
VC_EXPORT void vcm_encoder_destroy(OpusEncoder *encoder) { opus_encoder_destroy(encoder); }
|
||||||
|
/* C varargs are called here, not through P/Invoke: Apple arm64 uses a distinct varargs ABI. */
|
||||||
|
VC_EXPORT int vcm_encoder_set(OpusEncoder *encoder, int request, int value) {
|
||||||
|
switch (request) {
|
||||||
|
case OPUS_SET_BITRATE_REQUEST: case OPUS_SET_MAX_BANDWIDTH_REQUEST:
|
||||||
|
case OPUS_SET_COMPLEXITY_REQUEST: case OPUS_SET_INBAND_FEC_REQUEST:
|
||||||
|
case OPUS_SET_DTX_REQUEST: case OPUS_SET_PACKET_LOSS_PERC_REQUEST:
|
||||||
|
case OPUS_SET_DRED_DURATION_REQUEST:
|
||||||
|
return opus_encoder_ctl(encoder, request, value);
|
||||||
|
default: return OPUS_BAD_ARG;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VC_EXPORT int vcm_encoder_get_dred(OpusEncoder *encoder, int *duration) {
|
||||||
|
return opus_encoder_ctl(encoder, OPUS_GET_DRED_DURATION(duration));
|
||||||
|
}
|
||||||
|
VC_EXPORT int vcm_encode(OpusEncoder *encoder, const short *pcm, int samples, unsigned char *packet, int capacity) {
|
||||||
|
return opus_encode(encoder, pcm, samples, packet, capacity);
|
||||||
|
}
|
||||||
|
VC_EXPORT OpusDecoder *vcm_decoder_create(int rate, int channels, int *error) {
|
||||||
|
return opus_decoder_create(rate, channels, error);
|
||||||
|
}
|
||||||
|
VC_EXPORT void vcm_decoder_destroy(OpusDecoder *decoder) { opus_decoder_destroy(decoder); }
|
||||||
|
VC_EXPORT int vcm_decode(OpusDecoder *decoder, const unsigned char *packet, int length, short *pcm, int samples, int fec) {
|
||||||
|
return opus_decode(decoder, packet, length, pcm, samples, fec);
|
||||||
|
}
|
||||||
|
VC_EXPORT OpusDREDDecoder *vcm_dred_decoder_create(int *error) { return opus_dred_decoder_create(error); }
|
||||||
|
VC_EXPORT void vcm_dred_decoder_destroy(OpusDREDDecoder *decoder) { opus_dred_decoder_destroy(decoder); }
|
||||||
|
VC_EXPORT OpusDRED *vcm_dred_create(int *error) { return opus_dred_alloc(error); }
|
||||||
|
VC_EXPORT void vcm_dred_destroy(OpusDRED *dred) { opus_dred_free(dred); }
|
||||||
|
VC_EXPORT int vcm_dred_parse(OpusDREDDecoder *decoder, OpusDRED *dred, const unsigned char *packet,
|
||||||
|
int length, int samples, int rate, int *end) {
|
||||||
|
return opus_dred_parse(decoder, dred, packet, length, samples, rate, end, 0);
|
||||||
|
}
|
||||||
|
VC_EXPORT int vcm_dred_decode(OpusDecoder *decoder, OpusDRED *dred, int offset, short *pcm, int samples) {
|
||||||
|
return opus_decoder_dred_decode(decoder, dred, offset, pcm, samples);
|
||||||
|
}
|
||||||
|
VC_EXPORT DenoiseState *vcm_rnnoise_create(void) { return rnnoise_create(NULL); }
|
||||||
|
VC_EXPORT void vcm_rnnoise_destroy(DenoiseState *state) { rnnoise_destroy(state); }
|
||||||
|
VC_EXPORT float vcm_rnnoise_process(DenoiseState *state, float *output, const float *input) {
|
||||||
|
return rnnoise_process_frame(state, output, input);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
add_executable(voicecat-dotnet-voice-oracle voice.cpp)
|
||||||
|
target_link_libraries(voicecat-dotnet-voice-oracle PRIVATE voicecat::voicecat)
|
||||||
|
target_compile_features(voicecat-dotnet-voice-oracle PRIVATE cxx_std_20)
|
||||||
|
|
||||||
|
add_executable(voicecat-dotnet-dsp-oracle dsp.cpp)
|
||||||
|
target_link_libraries(voicecat-dotnet-dsp-oracle PRIVATE voicecat::voicecat)
|
||||||
|
target_include_directories(voicecat-dotnet-dsp-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
|
||||||
|
target_compile_features(voicecat-dotnet-dsp-oracle PRIVATE cxx_std_20)
|
||||||
|
|
||||||
|
find_package(unofficial-sodium CONFIG REQUIRED)
|
||||||
|
add_executable(voicecat-dotnet-password-oracle passwords.cpp)
|
||||||
|
target_link_libraries(voicecat-dotnet-password-oracle PRIVATE unofficial-sodium::sodium)
|
||||||
|
target_compile_features(voicecat-dotnet-password-oracle PRIVATE cxx_std_20)
|
||||||
|
|
||||||
|
if(VOICECAT_BUILD_SERVER)
|
||||||
|
add_executable(voicecat-dotnet-database-oracle database.cpp)
|
||||||
|
target_link_libraries(voicecat-dotnet-database-oracle PRIVATE voicecat::server)
|
||||||
|
target_compile_features(voicecat-dotnet-database-oracle PRIVATE cxx_std_20)
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#include "db.h"
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
if (argc != 3) return 1;
|
||||||
|
voicecat::server::Database database(argv[2]);
|
||||||
|
std::string error;
|
||||||
|
if (!database.open(error)) return 1;
|
||||||
|
if (std::string(argv[1]) == "create-protected") {
|
||||||
|
voicecat::server::ChannelRecord channel;
|
||||||
|
channel.name = "Native protected";
|
||||||
|
channel.audio.set_sample_rate(48000);
|
||||||
|
channel.audio.set_bitrate_bps(24000);
|
||||||
|
channel.audio.set_frame_ms(20);
|
||||||
|
return database.create_channel(channel, "channel password", error) ? 0 : 1;
|
||||||
|
}
|
||||||
|
if (std::string(argv[1]) == "verify-protected") {
|
||||||
|
for (const auto& channel : database.list_channels()) {
|
||||||
|
if (channel.name == "Managed protected")
|
||||||
|
return database.check_channel_password(channel.id, "channel password") &&
|
||||||
|
!database.check_channel_password(channel.id, "wrong") ? 0 : 1;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (std::string(argv[1]) == "create") {
|
||||||
|
if (!database.create_account("legacy", "legacy password", true, error)) return 1;
|
||||||
|
voicecat::server::ChannelRecord lobby;
|
||||||
|
lobby.name = "Lobby";
|
||||||
|
lobby.topic = "Preserved native topic";
|
||||||
|
lobby.max_users = 7;
|
||||||
|
lobby.audio.set_sample_rate(48000);
|
||||||
|
lobby.audio.set_bitrate_bps(32000);
|
||||||
|
lobby.audio.set_frame_ms(20);
|
||||||
|
return database.create_channel(lobby, "", error) ? 0 : 1;
|
||||||
|
}
|
||||||
|
if (std::string(argv[1]) == "verify") {
|
||||||
|
auto account = database.authenticate("managed", "managed password");
|
||||||
|
return account && account->is_admin ? 0 : 1;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#include "audio/apm_processor.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <fstream>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
if (argc != 2) return 2;
|
||||||
|
auto processor = voicecat::audio::ApmProcessor::create();
|
||||||
|
if (!processor) return 1;
|
||||||
|
std::vector<int16_t> pcm(960);
|
||||||
|
uint32_t random = 0x12345678;
|
||||||
|
for (int frame = 0; frame < 200; ++frame) {
|
||||||
|
for (auto &sample : pcm) {
|
||||||
|
random ^= random << 13;
|
||||||
|
random ^= random >> 17;
|
||||||
|
random ^= random << 5;
|
||||||
|
sample = static_cast<int16_t>(static_cast<int>(random % 6001) - 3000);
|
||||||
|
}
|
||||||
|
if (!processor->process_capture(pcm.data(), static_cast<int>(pcm.size()), 48000)) return 1;
|
||||||
|
}
|
||||||
|
std::ofstream output(argv[1]);
|
||||||
|
output << "{\"samples\":[";
|
||||||
|
for (size_t i = 0; i < pcm.size(); ++i) {
|
||||||
|
if (i) output << ',';
|
||||||
|
output << pcm[i];
|
||||||
|
}
|
||||||
|
output << "]}\n";
|
||||||
|
return output ? 0 : 1;
|
||||||
|
}
|
||||||
@@ -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,29 @@
|
|||||||
|
#include <sodium.h>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <array>
|
||||||
|
|
||||||
|
static std::string base64(const unsigned char *data, size_t length) {
|
||||||
|
std::array<char, 128> output{};
|
||||||
|
sodium_bin2base64(output.data(), output.size(), data, length, sodium_base64_VARIANT_ORIGINAL_NO_PADDING);
|
||||||
|
return output.data();
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
if (argc != 2 || sodium_init() < 0) return 1;
|
||||||
|
std::ofstream output(argv[1]);
|
||||||
|
output << "{\"hashes\":[";
|
||||||
|
const std::array<std::string, 3> passwords{"voicecat test", "caf\xc3\xa9", std::string("a\0b", 3)};
|
||||||
|
std::array<unsigned char, 16> salt{};
|
||||||
|
for (size_t i = 0; i < salt.size(); ++i) salt[i] = static_cast<unsigned char>(i);
|
||||||
|
for (size_t i = 0; i < passwords.size(); ++i) {
|
||||||
|
std::array<unsigned char, 32> hash{};
|
||||||
|
if (crypto_pwhash(hash.data(), hash.size(), passwords[i].data(), passwords[i].size(), salt.data(), 2,
|
||||||
|
64 * 1024 * 1024, crypto_pwhash_ALG_ARGON2ID13) != 0) return 1;
|
||||||
|
if (i) output << ',';
|
||||||
|
output << "{\"passwordBase64\":\"" << base64(reinterpret_cast<const unsigned char *>(passwords[i].data()), passwords[i].size())
|
||||||
|
<< "\",\"hash\":\"$argon2id$v=19$m=65536,t=2,p=1$" << base64(salt.data(), salt.size()) << '$' << base64(hash.data(), hash.size()) << "\"}";
|
||||||
|
}
|
||||||
|
output << "]}\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,127 @@
|
|||||||
|
#include "voicecat.h"
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
struct ClientState {
|
||||||
|
vc_client* client = nullptr;
|
||||||
|
std::mutex gate;
|
||||||
|
std::condition_variable changed;
|
||||||
|
bool authenticated = false;
|
||||||
|
bool subscribed = false;
|
||||||
|
bool joined = false;
|
||||||
|
uint32_t user = 0;
|
||||||
|
std::vector<std::pair<uint32_t, uint32_t>> streams;
|
||||||
|
std::array<int, 3> received{};
|
||||||
|
long long energy = 0;
|
||||||
|
uint32_t channels = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
static void event(void* context, const vc_event* value) {
|
||||||
|
auto& state = *static_cast<ClientState*>(context);
|
||||||
|
if (value->type == VC_EVENT_SERVER_IDENTITY) {
|
||||||
|
vc_confirm_server_identity(state.client, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::lock_guard lock(state.gate);
|
||||||
|
switch (value->type) {
|
||||||
|
case VC_EVENT_AUTH_RESULT:
|
||||||
|
state.authenticated = value->result == VC_OK;
|
||||||
|
state.user = value->user_id;
|
||||||
|
break;
|
||||||
|
case VC_EVENT_VOICE_STATE: state.subscribed = value->u32a == 1; break;
|
||||||
|
case VC_EVENT_JOIN_RESULT: state.joined = value->result == VC_OK; break;
|
||||||
|
case VC_EVENT_STREAM_STARTED: state.streams.emplace_back(value->user_id, value->stream_id); break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
state.changed.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sink(void* context, uint32_t, uint32_t stream, const int16_t* pcm,
|
||||||
|
size_t samples, uint32_t channels, uint32_t rate) {
|
||||||
|
auto& state = *static_cast<ClientState*>(context);
|
||||||
|
if (rate != 48000 || stream >= state.received.size()) return;
|
||||||
|
std::lock_guard lock(state.gate);
|
||||||
|
++state.received[stream];
|
||||||
|
state.channels = channels;
|
||||||
|
for (size_t index = 0; index < samples * channels; ++index) state.energy += std::abs(static_cast<int>(pcm[index]));
|
||||||
|
state.changed.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
template<class Predicate>
|
||||||
|
static bool wait(ClientState& state, Predicate predicate) {
|
||||||
|
std::unique_lock lock(state.gate);
|
||||||
|
return state.changed.wait_for(lock, std::chrono::seconds(8), predicate);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Destroy {
|
||||||
|
void operator()(vc_client* client) const { vc_disconnect(client); vc_client_destroy(client); }
|
||||||
|
};
|
||||||
|
using Client = std::unique_ptr<vc_client, Destroy>;
|
||||||
|
|
||||||
|
static Client connect(ClientState& state, uint16_t port, uint32_t channel, const char* nickname) {
|
||||||
|
vc_config config{"dotnet-voice-oracle", "1", VC_LOG_OFF};
|
||||||
|
Client client(vc_client_create(&config, {event, nullptr, &state}));
|
||||||
|
state.client = client.get();
|
||||||
|
if (!client || vc_set_external_playback(client.get(), 1) != VC_OK ||
|
||||||
|
vc_connect(client.get(), "127.0.0.1", port) != VC_OK ||
|
||||||
|
vc_authenticate_guest(client.get(), nickname) != VC_OK ||
|
||||||
|
!wait(state, [&] { return state.authenticated; }) ||
|
||||||
|
vc_join_channel(client.get(), channel, nullptr) != VC_OK ||
|
||||||
|
!wait(state, [&] { return state.joined; }) ||
|
||||||
|
vc_join_voice(client.get()) != VC_OK ||
|
||||||
|
!wait(state, [&] { return state.subscribed; }) ||
|
||||||
|
vc_set_pcm_sink(client.get(), sink, &state) != VC_OK) return {};
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
if (argc != 3) return 1;
|
||||||
|
uint16_t port = static_cast<uint16_t>(std::strtoul(argv[1], nullptr, 10));
|
||||||
|
uint32_t channel = static_cast<uint32_t>(std::strtoul(argv[2], nullptr, 10));
|
||||||
|
ClientState alice, bob;
|
||||||
|
Client a = connect(alice, port, channel, "Native Alice");
|
||||||
|
Client b = connect(bob, port, channel, "Native Bob");
|
||||||
|
if (!a || !b) { std::fprintf(stderr, "native authentication/join/subscription failed\n"); return 1; }
|
||||||
|
std::array<uint32_t, 3> ids{};
|
||||||
|
vc_stream_desc mic{};
|
||||||
|
mic.kind = VC_STREAM_MIC;
|
||||||
|
mic.external_feed = 1;
|
||||||
|
vc_stream_desc screen = mic;
|
||||||
|
screen.kind = VC_STREAM_SCREEN_AUDIO;
|
||||||
|
if (vc_stream_start(a.get(), &mic, &ids[0]) != VC_OK ||
|
||||||
|
vc_stream_start(a.get(), &screen, &ids[1]) != VC_OK ||
|
||||||
|
vc_stream_start(b.get(), &mic, &ids[2]) != VC_OK ||
|
||||||
|
!wait(alice, [&] { return alice.streams.size() >= 3; }) ||
|
||||||
|
!wait(bob, [&] { return bob.streams.size() >= 3; })) {
|
||||||
|
std::fprintf(stderr, "native stream signaling failed\n"); return 1;
|
||||||
|
}
|
||||||
|
uint32_t channels = channel == 2 ? 2 : 1;
|
||||||
|
std::vector<int16_t> pcm(960 * channels);
|
||||||
|
for (size_t sample = 0; sample < 960; ++sample)
|
||||||
|
for (uint32_t side = 0; side < channels; ++side)
|
||||||
|
pcm[sample * channels + side] = static_cast<int16_t>(12000 * std::sin(sample * (side == 0 ? 0.058 : 0.083)));
|
||||||
|
for (int frame = 0; frame < 100; ++frame) {
|
||||||
|
if (vc_stream_feed_pcm(a.get(), ids[0], pcm.data(), 960, channels) != VC_OK ||
|
||||||
|
vc_stream_feed_pcm(a.get(), ids[1], pcm.data(), 960, channels) != VC_OK ||
|
||||||
|
vc_stream_feed_pcm(b.get(), ids[2], pcm.data(), 960, channels) != VC_OK) return 1;
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||||
|
}
|
||||||
|
bool received = wait(alice, [&] { return alice.received[ids[2]] >= 5 && alice.energy > 0; }) &&
|
||||||
|
wait(bob, [&] { return bob.received[ids[0]] >= 5 && bob.received[ids[1]] >= 5 && bob.energy > 0; });
|
||||||
|
{
|
||||||
|
std::scoped_lock lock(alice.gate, bob.gate);
|
||||||
|
std::printf("channel=%u channels=%u alice=%d bob-mic=%d bob-screen=%d energy=%lld/%lld\n",
|
||||||
|
channel, channels, alice.received[ids[2]], bob.received[ids[0]], bob.received[ids[1]], alice.energy, bob.energy);
|
||||||
|
received = received && alice.channels == channels && bob.channels == channels;
|
||||||
|
}
|
||||||
|
return received ? 0 : 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.Win32.SafeHandles;
|
||||||
|
|
||||||
|
namespace VoiceCat.Codec;
|
||||||
|
|
||||||
|
internal sealed class OpusEncoderHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||||
|
{
|
||||||
|
public OpusEncoderHandle() : base(true) { }
|
||||||
|
internal OpusEncoderHandle(nint value) : this() => SetHandle(value);
|
||||||
|
protected override bool ReleaseHandle() { NativeMethods.EncoderDestroy(handle); return true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class OpusDecoderHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||||
|
{
|
||||||
|
public OpusDecoderHandle() : base(true) { }
|
||||||
|
internal OpusDecoderHandle(nint value) : this() => SetHandle(value);
|
||||||
|
protected override bool ReleaseHandle() { NativeMethods.DecoderDestroy(handle); return true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class DredDecoderHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||||
|
{
|
||||||
|
public DredDecoderHandle() : base(true) { }
|
||||||
|
internal DredDecoderHandle(nint value) : this() => SetHandle(value);
|
||||||
|
protected override bool ReleaseHandle() { NativeMethods.DredDecoderDestroy(handle); return true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class DredHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||||
|
{
|
||||||
|
public DredHandle() : base(true) { }
|
||||||
|
internal DredHandle(nint value) : this() => SetHandle(value);
|
||||||
|
protected override bool ReleaseHandle() { NativeMethods.DredDestroy(handle); return true; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace VoiceCat.Codec;
|
||||||
|
|
||||||
|
internal static unsafe partial class NativeMethods
|
||||||
|
{
|
||||||
|
private const string Library = "voicecat_media";
|
||||||
|
[LibraryImport(Library, EntryPoint = "vcm_opus_version")]
|
||||||
|
internal static partial nint Version();
|
||||||
|
[LibraryImport(Library, EntryPoint = "vcm_opus_error")]
|
||||||
|
internal static partial nint Error(int error);
|
||||||
|
[LibraryImport(Library, EntryPoint = "vcm_encoder_create")]
|
||||||
|
internal static partial nint EncoderCreate(int rate, int channels, int application, out int error);
|
||||||
|
[LibraryImport(Library, EntryPoint = "vcm_encoder_destroy")]
|
||||||
|
internal static partial void EncoderDestroy(nint encoder);
|
||||||
|
[LibraryImport(Library, EntryPoint = "vcm_encoder_set")]
|
||||||
|
internal static partial int EncoderSet(OpusEncoderHandle encoder, int request, int value);
|
||||||
|
[LibraryImport(Library, EntryPoint = "vcm_encoder_get_dred")]
|
||||||
|
internal static partial int EncoderGetDred(OpusEncoderHandle encoder, out int duration);
|
||||||
|
[LibraryImport(Library, EntryPoint = "vcm_encode")]
|
||||||
|
internal static partial int Encode(OpusEncoderHandle encoder, short* pcm, int samples, byte* packet, int capacity);
|
||||||
|
[LibraryImport(Library, EntryPoint = "vcm_decoder_create")]
|
||||||
|
internal static partial nint DecoderCreate(int rate, int channels, out int error);
|
||||||
|
[LibraryImport(Library, EntryPoint = "vcm_decoder_destroy")]
|
||||||
|
internal static partial void DecoderDestroy(nint decoder);
|
||||||
|
[LibraryImport(Library, EntryPoint = "vcm_decode")]
|
||||||
|
internal static partial int Decode(OpusDecoderHandle decoder, byte* packet, int length, short* pcm, int samples, int fec);
|
||||||
|
[LibraryImport(Library, EntryPoint = "vcm_dred_decoder_create")]
|
||||||
|
internal static partial nint DredDecoderCreate(out int error);
|
||||||
|
[LibraryImport(Library, EntryPoint = "vcm_dred_decoder_destroy")]
|
||||||
|
internal static partial void DredDecoderDestroy(nint decoder);
|
||||||
|
[LibraryImport(Library, EntryPoint = "vcm_dred_create")]
|
||||||
|
internal static partial nint DredCreate(out int error);
|
||||||
|
[LibraryImport(Library, EntryPoint = "vcm_dred_destroy")]
|
||||||
|
internal static partial void DredDestroy(nint dred);
|
||||||
|
[LibraryImport(Library, EntryPoint = "vcm_dred_parse")]
|
||||||
|
internal static partial int DredParse(DredDecoderHandle decoder, DredHandle dred, byte* packet, int length, int samples, int rate, out int end);
|
||||||
|
[LibraryImport(Library, EntryPoint = "vcm_dred_decode")]
|
||||||
|
internal static partial int DredDecode(OpusDecoderHandle decoder, DredHandle dred, int offset, short* pcm, int samples);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace VoiceCat.Codec;
|
||||||
|
|
||||||
|
public sealed class OpusDecoder : IDisposable
|
||||||
|
{
|
||||||
|
private readonly OpusDecoderHandle handle;
|
||||||
|
public int SampleRate { get; }
|
||||||
|
public int Channels { get; }
|
||||||
|
|
||||||
|
public OpusDecoder(int sampleRate = 48000, int channels = 1)
|
||||||
|
{
|
||||||
|
new OpusOptions { SampleRate = sampleRate, Channels = channels }.Validate();
|
||||||
|
SampleRate = sampleRate;
|
||||||
|
Channels = channels;
|
||||||
|
handle = new(NativeMethods.DecoderCreate(sampleRate, channels, out int error));
|
||||||
|
if (error < 0 || handle.IsInvalid)
|
||||||
|
{
|
||||||
|
handle.Dispose();
|
||||||
|
OpusException.Check(error);
|
||||||
|
throw new OutOfMemoryException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal OpusDecoderHandle Handle => handle;
|
||||||
|
|
||||||
|
internal void ValidateOutput(Span<short> pcm, int samplesPerChannel)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(handle.IsClosed, this);
|
||||||
|
if (samplesPerChannel <= 0 || samplesPerChannel > SampleRate * 120 / 1000 || samplesPerChannel % (SampleRate / 400) != 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(samplesPerChannel));
|
||||||
|
if (pcm.Length < samplesPerChannel * Channels) throw new ArgumentException("PCM storage is too small.", nameof(pcm));
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe int Decode(ReadOnlySpan<byte> packet, Span<short> pcm, int samplesPerChannel, bool recoverPreviousFrame = false)
|
||||||
|
{
|
||||||
|
ValidateOutput(pcm, samplesPerChannel);
|
||||||
|
if (packet.Overlaps(MemoryMarshal.AsBytes(pcm))) throw new ArgumentException("Packet and PCM storage must not overlap.");
|
||||||
|
fixed (byte* input = packet)
|
||||||
|
fixed (short* output = pcm)
|
||||||
|
return OpusException.Check(NativeMethods.Decode(handle, input, packet.Length, output, samplesPerChannel, recoverPreviousFrame ? 1 : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => handle.Dispose();
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace VoiceCat.Codec;
|
||||||
|
|
||||||
|
public sealed class OpusDeepRedundancy : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DredDecoderHandle decoder;
|
||||||
|
private readonly DredHandle dred;
|
||||||
|
|
||||||
|
public OpusDeepRedundancy()
|
||||||
|
{
|
||||||
|
decoder = new(NativeMethods.DredDecoderCreate(out int error));
|
||||||
|
if (error < 0 || decoder.IsInvalid)
|
||||||
|
{
|
||||||
|
decoder.Dispose();
|
||||||
|
if (error == -5) throw new NotSupportedException("This libopus build does not include DRED.");
|
||||||
|
OpusException.Check(error);
|
||||||
|
throw new OutOfMemoryException();
|
||||||
|
}
|
||||||
|
dred = new(NativeMethods.DredCreate(out error));
|
||||||
|
if (error < 0 || dred.IsInvalid)
|
||||||
|
{
|
||||||
|
decoder.Dispose();
|
||||||
|
dred.Dispose();
|
||||||
|
if (error == -5) throw new NotSupportedException("This libopus build does not include DRED.");
|
||||||
|
OpusException.Check(error);
|
||||||
|
throw new OutOfMemoryException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe bool TryRecover(OpusDecoder audioDecoder, ReadOnlySpan<byte> nextPacket, Span<short> pcm, int samplesPerChannel, int? offset = null)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(decoder.IsClosed, this);
|
||||||
|
ArgumentNullException.ThrowIfNull(audioDecoder);
|
||||||
|
audioDecoder.ValidateOutput(pcm, samplesPerChannel);
|
||||||
|
int recoveryOffset = offset ?? samplesPerChannel;
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(recoveryOffset);
|
||||||
|
if (nextPacket.IsEmpty) return false;
|
||||||
|
if (nextPacket.Overlaps(MemoryMarshal.AsBytes(pcm))) throw new ArgumentException("Packet and PCM storage must not overlap.");
|
||||||
|
fixed (byte* packet = nextPacket)
|
||||||
|
fixed (short* output = pcm)
|
||||||
|
{
|
||||||
|
int parsed = OpusException.Check(NativeMethods.DredParse(decoder, dred, packet, nextPacket.Length,
|
||||||
|
checked(samplesPerChannel + recoveryOffset), audioDecoder.SampleRate, out _));
|
||||||
|
if (parsed == 0) return false;
|
||||||
|
OpusException.Check(NativeMethods.DredDecode(audioDecoder.Handle, dred, recoveryOffset, output, samplesPerChannel));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { dred.Dispose(); decoder.Dispose(); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace VoiceCat.Codec;
|
||||||
|
|
||||||
|
public sealed class OpusEncoder : IDisposable
|
||||||
|
{
|
||||||
|
private readonly OpusEncoderHandle handle;
|
||||||
|
public OpusOptions Options { get; }
|
||||||
|
public bool SupportsDeepRedundancy { get; }
|
||||||
|
public static string Version => Marshal.PtrToStringUTF8(NativeMethods.Version())!;
|
||||||
|
|
||||||
|
public OpusEncoder(OpusOptions? options = null)
|
||||||
|
{
|
||||||
|
Options = options ?? new();
|
||||||
|
Options.Validate();
|
||||||
|
handle = new(NativeMethods.EncoderCreate(Options.SampleRate, Options.Channels, (int)Options.Application, out int error));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
OpusException.Check(error);
|
||||||
|
if (handle.IsInvalid) throw new OutOfMemoryException();
|
||||||
|
Set(4002, Options.Bitrate);
|
||||||
|
Set(4004, Options.MaximumBandwidthHz switch { 0 => 1105, <= 8000 => 1101, <= 12000 => 1102, <= 16000 => 1103, <= 24000 => 1104, _ => 1105 });
|
||||||
|
Set(4010, Options.Complexity);
|
||||||
|
Set(4012, Options.ForwardErrorCorrection ? 1 : 0);
|
||||||
|
Set(4016, Options.DiscontinuousTransmission ? 1 : 0);
|
||||||
|
Set(4014, Options.ExpectedPacketLossPercent);
|
||||||
|
int support = NativeMethods.EncoderGetDred(handle, out _);
|
||||||
|
if (support != -5) OpusException.Check(support);
|
||||||
|
SupportsDeepRedundancy = support == 0 && Options.SampleRate >= 16000;
|
||||||
|
if (Options.DeepRedundancy && !SupportsDeepRedundancy)
|
||||||
|
throw new NotSupportedException("DRED encoding requires a DRED-enabled libopus build and a PCM rate of at least 16 kHz.");
|
||||||
|
if (SupportsDeepRedundancy)
|
||||||
|
// Opus 1.5.2 requires two redundancy chunks; 20 ms alone cannot produce DRED.
|
||||||
|
Set(4050, Options.DeepRedundancy ? Math.Max(3, (Options.FrameDurationMilliseconds + 9) / 10) : 0);
|
||||||
|
}
|
||||||
|
catch { handle.Dispose(); throw; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Set(int request, int value) => OpusException.Check(NativeMethods.EncoderSet(handle, request, value));
|
||||||
|
|
||||||
|
public unsafe int Encode(ReadOnlySpan<short> pcm, Span<byte> packet)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(handle.IsClosed, this);
|
||||||
|
if (pcm.Length != Options.SamplesPerChannel * Options.Channels) throw new ArgumentException("PCM must contain exactly one interleaved frame.", nameof(pcm));
|
||||||
|
if (packet.IsEmpty) throw new ArgumentException("Packet storage must not be empty.", nameof(packet));
|
||||||
|
if (MemoryMarshal.AsBytes(pcm).Overlaps(packet)) throw new ArgumentException("PCM and packet storage must not overlap.");
|
||||||
|
fixed (short* input = pcm)
|
||||||
|
fixed (byte* output = packet)
|
||||||
|
return OpusException.Check(NativeMethods.Encode(handle, input, Options.SamplesPerChannel, output, packet.Length));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => handle.Dispose();
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace VoiceCat.Codec;
|
||||||
|
|
||||||
|
public sealed class OpusException : Exception
|
||||||
|
{
|
||||||
|
public int ErrorCode { get; }
|
||||||
|
internal OpusException(int error) : base(Marshal.PtrToStringUTF8(NativeMethods.Error(error))) => ErrorCode = error;
|
||||||
|
internal static int Check(int result) => result < 0 ? throw new OpusException(result) : result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace VoiceCat.Codec;
|
||||||
|
|
||||||
|
public enum OpusApplication { Voip = 2048, Audio = 2049, LowDelay = 2051 }
|
||||||
|
|
||||||
|
public sealed record OpusOptions
|
||||||
|
{
|
||||||
|
public int SampleRate { get; init; } = 48000;
|
||||||
|
public int Channels { get; init; } = 1;
|
||||||
|
public int FrameDurationMilliseconds { get; init; } = 20;
|
||||||
|
public int Bitrate { get; init; } = 24000;
|
||||||
|
public int MaximumBandwidthHz { get; init; }
|
||||||
|
public int Complexity { get; init; } = 10;
|
||||||
|
public int ExpectedPacketLossPercent { get; init; }
|
||||||
|
public bool ForwardErrorCorrection { get; init; } = true;
|
||||||
|
public bool DiscontinuousTransmission { get; init; }
|
||||||
|
public bool DeepRedundancy { get; init; }
|
||||||
|
public OpusApplication Application { get; init; } = OpusApplication.Voip;
|
||||||
|
public int SamplesPerChannel => SampleRate / 1000 * FrameDurationMilliseconds;
|
||||||
|
|
||||||
|
internal void Validate()
|
||||||
|
{
|
||||||
|
if (SampleRate is not (8000 or 12000 or 16000 or 24000 or 48000)) throw new ArgumentOutOfRangeException(nameof(SampleRate));
|
||||||
|
if (Channels is not (1 or 2)) throw new ArgumentOutOfRangeException(nameof(Channels));
|
||||||
|
if (FrameDurationMilliseconds is not (10 or 20 or 40 or 60)) throw new ArgumentOutOfRangeException(nameof(FrameDurationMilliseconds));
|
||||||
|
if (Application == OpusApplication.LowDelay && FrameDurationMilliseconds > 20) throw new ArgumentException("Low-delay Opus requires frames of at most 20 ms.");
|
||||||
|
if (!Enum.IsDefined(Application)) throw new ArgumentOutOfRangeException(nameof(Application));
|
||||||
|
if (Bitrate is < 500 or > 512000) throw new ArgumentOutOfRangeException(nameof(Bitrate));
|
||||||
|
if (Complexity is < 0 or > 10) throw new ArgumentOutOfRangeException(nameof(Complexity));
|
||||||
|
if (ExpectedPacketLossPercent is < 0 or > 100) throw new ArgumentOutOfRangeException(nameof(ExpectedPacketLossPercent));
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(MaximumBandwidthHz);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"dependencies": {
|
||||||
|
"net10.0": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,77 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Org.BouncyCastle.Crypto.Generators;
|
||||||
|
using Org.BouncyCastle.Crypto.Parameters;
|
||||||
|
|
||||||
|
namespace VoiceCat.Crypto;
|
||||||
|
|
||||||
|
public sealed class PasswordHasher
|
||||||
|
{
|
||||||
|
private static readonly UTF8Encoding Utf8 = new(false, true);
|
||||||
|
public const int MaximumPasswordBytes = 1024;
|
||||||
|
|
||||||
|
public string Hash(string password)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(password);
|
||||||
|
byte[] salt = RandomNumberGenerator.GetBytes(16);
|
||||||
|
byte[] hash = Derive(password, salt, 65536, 2, 1);
|
||||||
|
try { return $"$argon2id$v=19$m=65536,t=2,p=1${Base64(salt)}${Base64(hash)}"; }
|
||||||
|
finally { CryptographicOperations.ZeroMemory(hash); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Verify(string password, string encodedHash)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(password);
|
||||||
|
ArgumentNullException.ThrowIfNull(encodedHash);
|
||||||
|
if (encodedHash.Length > 256) return false;
|
||||||
|
try { if (Utf8.GetByteCount(password) > MaximumPasswordBytes) return false; }
|
||||||
|
catch (EncoderFallbackException) { return false; }
|
||||||
|
string[] fields = encodedHash.Split('$');
|
||||||
|
if (fields.Length != 6 || fields[0] != "" || fields[1] != "argon2id" || fields[2] != "v=19") return false;
|
||||||
|
string[] costs = fields[3].Split(',');
|
||||||
|
if (costs.Length != 3 || !Cost(costs[0], "m=", out int memory) || !Cost(costs[1], "t=", out int iterations) || !Cost(costs[2], "p=", out int parallelism)) return false;
|
||||||
|
if (memory is < 8 or > 131072 || iterations is < 1 or > 10 || parallelism is < 1 or > 4 || memory < 8 * parallelism) return false;
|
||||||
|
byte[] salt, expected;
|
||||||
|
try { salt = Decode(fields[4]); expected = Decode(fields[5]); }
|
||||||
|
catch (FormatException) { return false; }
|
||||||
|
if (salt.Length != 16 || expected.Length != 32) return false;
|
||||||
|
byte[] actual = Derive(password, salt, memory, iterations, parallelism);
|
||||||
|
try { return CryptographicOperations.FixedTimeEquals(actual, expected); }
|
||||||
|
finally { CryptographicOperations.ZeroMemory(actual); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Cost(string value, string prefix, out int cost)
|
||||||
|
{
|
||||||
|
cost = 0;
|
||||||
|
return value.StartsWith(prefix, StringComparison.Ordinal) && int.TryParse(value.AsSpan(prefix.Length), NumberStyles.None, CultureInfo.InvariantCulture, out cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Derive(string password, byte[] salt, int memory, int iterations, int parallelism)
|
||||||
|
{
|
||||||
|
if (Utf8.GetByteCount(password) > MaximumPasswordBytes) throw new ArgumentException("Password exceeds 1024 UTF-8 bytes.", nameof(password));
|
||||||
|
byte[] bytes = Utf8.GetBytes(password);
|
||||||
|
byte[] output = new byte[32];
|
||||||
|
var parameters = new Argon2Parameters.Builder(Argon2Parameters.Argon2id)
|
||||||
|
.WithVersion(Argon2Parameters.Version13).WithMemoryAsKB(memory)
|
||||||
|
.WithIterations(iterations).WithParallelism(parallelism).WithSalt(salt).Build();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var generator = new Argon2BytesGenerator();
|
||||||
|
generator.Init(parameters);
|
||||||
|
generator.GenerateBytes(bytes, output);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
catch { CryptographicOperations.ZeroMemory(output); throw; }
|
||||||
|
finally { CryptographicOperations.ZeroMemory(bytes); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Base64(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=');
|
||||||
|
private static byte[] Decode(string value)
|
||||||
|
{
|
||||||
|
if (value.Contains('=') || value.Any(character => !char.IsAsciiLetterOrDigit(character) && character is not ('+' or '/'))) throw new FormatException();
|
||||||
|
byte[] bytes = Convert.FromBase64String(value.PadRight((value.Length + 3) / 4 * 4, '='));
|
||||||
|
if (Base64(bytes) != value) throw new FormatException();
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,48 @@
|
|||||||
|
namespace VoiceCat.Dsp;
|
||||||
|
|
||||||
|
public sealed class EnergyVadProcessor
|
||||||
|
{
|
||||||
|
private readonly TimeProvider timeProvider;
|
||||||
|
private long lastVoiceTimestamp;
|
||||||
|
private bool hasVoice;
|
||||||
|
private float threshold;
|
||||||
|
|
||||||
|
public float Threshold
|
||||||
|
{
|
||||||
|
get => Volatile.Read(ref threshold);
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (!float.IsFinite(value) || value is < 0 or > 1) throw new ArgumentOutOfRangeException(nameof(value));
|
||||||
|
Volatile.Write(ref threshold, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public TimeSpan HangTime { get; }
|
||||||
|
|
||||||
|
public EnergyVadProcessor(float threshold = 0.02f, TimeSpan? hangTime = null, TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
Threshold = threshold;
|
||||||
|
HangTime = hangTime ?? TimeSpan.FromMilliseconds(300);
|
||||||
|
if (HangTime < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(hangTime));
|
||||||
|
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Process(ReadOnlySpan<short> pcm)
|
||||||
|
{
|
||||||
|
long now = timeProvider.GetTimestamp();
|
||||||
|
if (!pcm.IsEmpty)
|
||||||
|
{
|
||||||
|
double sum = 0;
|
||||||
|
foreach (short sample in pcm)
|
||||||
|
{
|
||||||
|
double normalized = sample / 32768.0;
|
||||||
|
sum += normalized * normalized;
|
||||||
|
}
|
||||||
|
if (Math.Sqrt(sum / pcm.Length) >= Threshold)
|
||||||
|
{
|
||||||
|
lastVoiceTimestamp = now;
|
||||||
|
hasVoice = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasVoice && timeProvider.GetElapsedTime(lastVoiceTimestamp, now) < HangTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Microsoft.Win32.SafeHandles;
|
||||||
|
|
||||||
|
namespace VoiceCat.Dsp;
|
||||||
|
|
||||||
|
public sealed unsafe partial class RnnoiseProcessor : IDisposable
|
||||||
|
{
|
||||||
|
public const int SampleRate = 48000;
|
||||||
|
public const int FrameSamples = 480;
|
||||||
|
private readonly RnnoiseHandle handle;
|
||||||
|
private readonly float[] input = new float[FrameSamples];
|
||||||
|
private readonly float[] output = new float[FrameSamples];
|
||||||
|
|
||||||
|
public RnnoiseProcessor()
|
||||||
|
{
|
||||||
|
handle = new(Create());
|
||||||
|
if (handle.IsInvalid) { handle.Dispose(); throw new OutOfMemoryException(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Process(Span<short> pcm, int sampleRate = SampleRate)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(handle.IsClosed, this);
|
||||||
|
if (sampleRate != SampleRate) return;
|
||||||
|
if (pcm.Length % FrameSamples != 0) throw new ArgumentException("RNNoise requires complete 480-sample mono chunks.", nameof(pcm));
|
||||||
|
fixed (float* source = input)
|
||||||
|
fixed (float* destination = output)
|
||||||
|
{
|
||||||
|
for (int offset = 0; offset < pcm.Length; offset += FrameSamples)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < FrameSamples; i++) input[i] = pcm[offset + i];
|
||||||
|
ProcessFrame(handle, destination, source);
|
||||||
|
for (int i = 0; i < FrameSamples; i++)
|
||||||
|
pcm[offset + i] = (short)Math.Clamp(MathF.Round(output[i], MidpointRounding.AwayFromZero), short.MinValue, short.MaxValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => handle.Dispose();
|
||||||
|
|
||||||
|
[LibraryImport("voicecat_media", EntryPoint = "vcm_rnnoise_create")]
|
||||||
|
private static partial nint Create();
|
||||||
|
[LibraryImport("voicecat_media", EntryPoint = "vcm_rnnoise_destroy")]
|
||||||
|
private static partial void Destroy(nint state);
|
||||||
|
[LibraryImport("voicecat_media", EntryPoint = "vcm_rnnoise_process")]
|
||||||
|
private static partial float ProcessFrame(RnnoiseHandle state, float* output, float* input);
|
||||||
|
|
||||||
|
private sealed class RnnoiseHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||||
|
{
|
||||||
|
public RnnoiseHandle() : base(true) { }
|
||||||
|
internal RnnoiseHandle(nint value) : this() => SetHandle(value);
|
||||||
|
protected override bool ReleaseHandle() { Destroy(handle); return true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"dependencies": {
|
||||||
|
"net10.0": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,101 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Google.Protobuf;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Voicecat.V1;
|
||||||
|
|
||||||
|
namespace VoiceCat.Server;
|
||||||
|
|
||||||
|
public sealed partial class VoiceServer
|
||||||
|
{
|
||||||
|
private void Moderate(Session actor, Envelope request)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
bool permitted = actor.Permissions.IsAdmin || request.BodyCase switch
|
||||||
|
{
|
||||||
|
Envelope.BodyOneofCase.Kick or Envelope.BodyOneofCase.ServerMute => actor.Permissions.CanKick,
|
||||||
|
Envelope.BodyOneofCase.Ban => actor.Permissions.CanBan,
|
||||||
|
Envelope.BodyOneofCase.MoveUser => actor.Permissions.CanMoveUsers,
|
||||||
|
// Granting arbitrary permissions (including admin) is reserved for administrators.
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
if (!permitted) { SendResult(actor, request.RequestId, false, 6, "Permission denied."); return; }
|
||||||
|
uint id = request.Kick?.UserId ?? request.Ban?.UserId ?? request.MoveUser?.UserId ?? request.ServerMute?.UserId ?? request.SetPermission.UserId;
|
||||||
|
Session? target = sessions.Values.FirstOrDefault(p => !p.Closing && p.User?.Id == id);
|
||||||
|
if (target is null) { SendResult(actor, request.RequestId, false, 3, "User not found."); return; }
|
||||||
|
string reason = request.Kick?.Reason ?? request.Ban?.Reason ?? "";
|
||||||
|
if (Encoding.UTF8.GetByteCount(reason) > 4096 || request.SetPermission is not null && request.SetPermission.Permissions is null)
|
||||||
|
{ SendResult(actor, request.RequestId, false, 3, "Invalid moderation request."); return; }
|
||||||
|
if (request.MoveUser is not null)
|
||||||
|
{
|
||||||
|
Channel? destination = channels.FirstOrDefault(c => c.Id == request.MoveUser.ChannelId);
|
||||||
|
if (destination is null || destination.MaxUsers != 0 && sessions.Values.Count(p => p.Id != target.Id && p.User?.ChannelId == destination.Id) >= destination.MaxUsers)
|
||||||
|
{ SendResult(actor, request.RequestId, false, 3, "Channel unavailable."); return; }
|
||||||
|
target.User!.ChannelId = destination.Id;
|
||||||
|
target.User.Streams.Clear();
|
||||||
|
PublishMedia();
|
||||||
|
BroadcastUser(target);
|
||||||
|
}
|
||||||
|
else if (request.ServerMute is not null)
|
||||||
|
{
|
||||||
|
target.User!.ServerMuted = request.ServerMute.Muted;
|
||||||
|
target.User.ServerDeafened = request.ServerMute.Deafened;
|
||||||
|
PublishMedia();
|
||||||
|
BroadcastUser(target);
|
||||||
|
}
|
||||||
|
else if (request.SetPermission is not null) target.Permissions = request.SetPermission.Permissions.Clone();
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (request.Ban is not null)
|
||||||
|
{
|
||||||
|
// Guest nicknames are not identities; ban their address instead of reserving a nickname.
|
||||||
|
accounts.Ban(target.User!.IsGuest ? "ip" : "username", target.User.IsGuest ? target.Address : target.User.Nickname, reason, request.Ban.ExpiresUnixMs);
|
||||||
|
}
|
||||||
|
target.DepartureReason = reason;
|
||||||
|
target.Closing = true;
|
||||||
|
PublishMedia();
|
||||||
|
Reject(target, reason.Length == 0 ? "Removed by moderator." : reason);
|
||||||
|
}
|
||||||
|
SendResult(actor, request.RequestId, true, 0, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AdministerAccountsAsync(Session actor, Envelope request)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (!actor.Permissions.IsAdmin && !actor.Permissions.CanAdminAccounts)
|
||||||
|
{ SendResult(actor, request.RequestId, false, 6, "Permission denied."); return; }
|
||||||
|
}
|
||||||
|
// Authority is checked when accepting the operation; bounded password work runs off the control loop.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string? username = request.CreateAccount?.Username ?? request.ResetPassword?.Username ?? request.DeleteAccount?.Username;
|
||||||
|
if (username is not null && (string.IsNullOrWhiteSpace(username) || username.Length > 128)) throw new ArgumentException("Invalid username.");
|
||||||
|
bool ok = true;
|
||||||
|
switch (request.BodyCase)
|
||||||
|
{
|
||||||
|
case Envelope.BodyOneofCase.CreateAccount:
|
||||||
|
await accounts.CreateAccountAsync(username!, request.CreateAccount!.Password, cancellationToken: actor.Connection.CancellationToken).ConfigureAwait(false);
|
||||||
|
break;
|
||||||
|
case Envelope.BodyOneofCase.ResetPassword:
|
||||||
|
ok = await accounts.ResetPasswordAsync(username!, request.ResetPassword!.NewPassword, actor.Connection.CancellationToken).ConfigureAwait(false);
|
||||||
|
break;
|
||||||
|
case Envelope.BodyOneofCase.DeleteAccount: ok = accounts.DeleteAccount(username!); break;
|
||||||
|
case Envelope.BodyOneofCase.ListAccounts:
|
||||||
|
var response = new Envelope { RequestId = request.RequestId, ListAccountsResult = new() };
|
||||||
|
foreach (var account in accounts.ListAccounts())
|
||||||
|
{
|
||||||
|
response.ListAccountsResult.Accounts.Add(new AccountEntry { Username = account.Username, IsAdmin = account.IsAdmin,
|
||||||
|
CreatedAtUnixMs = checked((ulong)account.CreatedAt * 1000), LastLoginUnixMs = checked((ulong)account.LastLogin * 1000) });
|
||||||
|
if (response.CalculateSize() > 65536) { SendResult(actor, request.RequestId, false, 3, "Account list exceeds protocol frame limit."); return; }
|
||||||
|
}
|
||||||
|
actor.Connection.TrySend(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SendResult(actor, request.RequestId, ok, ok ? 0U : 3U, ok ? "" : "Account not found.");
|
||||||
|
}
|
||||||
|
catch (ArgumentException) { SendResult(actor, request.RequestId, false, 3, "Invalid username or password."); }
|
||||||
|
catch (SqliteException exception) when (exception.SqliteErrorCode == 19) { SendResult(actor, request.RequestId, false, 3, "Account already exists or is invalid."); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Voicecat.V1;
|
||||||
|
|
||||||
|
namespace VoiceCat.Server;
|
||||||
|
|
||||||
|
public sealed partial class VoiceServer
|
||||||
|
{
|
||||||
|
private void ManageChannel(Session actor, Envelope request)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
bool create = request.CreateChannel is not null;
|
||||||
|
Channel? input = create ? request.CreateChannel!.Channel : request.EditChannel?.Channel;
|
||||||
|
bool permitted = actor.Permissions.IsAdmin || create && actor.Permissions.CanCreateTempChannel && input?.Type == ChannelType.ChannelTemporary;
|
||||||
|
if (!permitted) { SendResult(actor, request.RequestId, false, 6, "Permission denied."); return; }
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (request.DeleteChannel is not null)
|
||||||
|
{
|
||||||
|
uint id = request.DeleteChannel.ChannelId;
|
||||||
|
if (id == 1 || !channels.Any(c => c.Id == id) || channels.Any(c => c.ParentId == id))
|
||||||
|
throw new ArgumentException("Cannot delete Lobby, a missing channel, or a channel with children.");
|
||||||
|
accounts.DeleteChannel(id);
|
||||||
|
channels.RemoveAll(c => c.Id == id);
|
||||||
|
foreach (Session peer in sessions.Values.Where(p => p.User?.ChannelId == id))
|
||||||
|
{
|
||||||
|
peer.User!.ChannelId = 1;
|
||||||
|
peer.User.Streams.Clear();
|
||||||
|
BroadcastUser(peer);
|
||||||
|
}
|
||||||
|
PublishMedia();
|
||||||
|
Broadcast(new() { ChannelEvent = new() { Kind = ChannelEvent.Types.Kind.Deleted, DeletedId = id } });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
string password = create ? request.CreateChannel!.Password : request.EditChannel!.Password;
|
||||||
|
ValidateChannel(input, password, create);
|
||||||
|
Channel saved = accounts.SaveChannel(input!, password, create);
|
||||||
|
if (create) channels.Add(saved);
|
||||||
|
else channels[channels.FindIndex(c => c.Id == saved.Id)] = saved;
|
||||||
|
// Existing encoders negotiated the previous configuration. Stop their streams on edits.
|
||||||
|
if (!create)
|
||||||
|
{
|
||||||
|
foreach (Session peer in sessions.Values.Where(p => p.User?.ChannelId == saved.Id))
|
||||||
|
{
|
||||||
|
peer.User!.Streams.Clear();
|
||||||
|
BroadcastUser(peer);
|
||||||
|
}
|
||||||
|
PublishMedia();
|
||||||
|
}
|
||||||
|
Broadcast(new() { ChannelEvent = new() { Kind = create ? ChannelEvent.Types.Kind.Created : ChannelEvent.Types.Kind.Updated, Channel = saved.Clone() } });
|
||||||
|
}
|
||||||
|
SendResult(actor, request.RequestId, true, 0, "");
|
||||||
|
}
|
||||||
|
catch (ArgumentException exception) { SendResult(actor, request.RequestId, false, 3, exception.Message); }
|
||||||
|
catch (SqliteException exception) when (exception.SqliteErrorCode == 19) { SendResult(actor, request.RequestId, false, 3, "Channel name already exists or channel is invalid."); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateChannel(Channel? channel, string password, bool create)
|
||||||
|
{
|
||||||
|
var a = channel?.Audio;
|
||||||
|
if (channel is null || string.IsNullOrWhiteSpace(channel.Name) || Encoding.UTF8.GetByteCount(channel.Name) > 128 ||
|
||||||
|
Encoding.UTF8.GetByteCount(channel.Topic) > 4096 || Encoding.UTF8.GetByteCount(password) > 1024 || !Enum.IsDefined(channel.Type) ||
|
||||||
|
channel.MaxUsers > int.MaxValue || a is null || a.Codec != 0 || !Enum.IsDefined(a.Mode) || !Enum.IsDefined(a.Application) ||
|
||||||
|
a.SampleRate != 48000 || a.BitrateBps is < 500 or > 512000 || a.FrameMs is not (5 or 10 or 20 or 40 or 60) ||
|
||||||
|
a.Complexity > 10 || a.ExpectedPacketLoss > 100 || a.Dred ||
|
||||||
|
!create && !channels.Any(c => c.Id == channel.Id) || channel.ParentId != 0 && !channels.Any(c => c.Id == channel.ParentId))
|
||||||
|
throw new ArgumentException("Invalid channel or audio configuration (database v2 cannot persist DRED).");
|
||||||
|
if (channel.Id == 1 && !create && (password.Length != 0 || channel.ParentId != 0)) throw new ArgumentException("Lobby must remain an unprotected root channel.");
|
||||||
|
uint parent = channel.ParentId;
|
||||||
|
var visited = new HashSet<uint>();
|
||||||
|
while (parent != 0)
|
||||||
|
{
|
||||||
|
if (!visited.Add(parent) || !create && parent == channel.Id) throw new ArgumentException("Channel tree cannot contain cycles.");
|
||||||
|
parent = channels.First(c => c.Id == parent).ParentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SendResult(Session actor, ulong id, bool ok, uint code, string message) =>
|
||||||
|
actor.Connection.TrySend(new() { RequestId = id, GenericResult = new() { Ok = ok, Code = code, Message = message } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
namespace VoiceCat.Server.Data;
|
||||||
|
|
||||||
|
public sealed partial class AccountStore
|
||||||
|
{
|
||||||
|
public async Task<bool> ResetPasswordAsync(string username, string password, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
string hash = await PasswordWorkAsync(() => hasher.Hash(password), cancellationToken).ConfigureAwait(false);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
using var connection = Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "UPDATE accounts SET pw_hash=$hash WHERE username=$user";
|
||||||
|
command.Parameters.AddWithValue("$hash", hash);
|
||||||
|
command.Parameters.AddWithValue("$user", username);
|
||||||
|
return command.ExecuteNonQuery() == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool DeleteAccount(string username)
|
||||||
|
{
|
||||||
|
using var connection = Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "DELETE FROM accounts WHERE username=$user";
|
||||||
|
command.Parameters.AddWithValue("$user", username);
|
||||||
|
return command.ExecuteNonQuery() == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<Account> ListAccounts()
|
||||||
|
{
|
||||||
|
using var connection = Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT id,username,is_admin,created_at,last_login FROM accounts ORDER BY username";
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
var result = new List<Account>();
|
||||||
|
while (reader.Read()) result.Add(new(reader.GetInt64(0), reader.GetString(1), reader.GetBoolean(2), reader.GetInt64(3), reader.GetInt64(4)));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Ban(string type, string subject, string reason, ulong expiresUnixMs)
|
||||||
|
{
|
||||||
|
using var connection = Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "INSERT INTO bans (subject_type,subject,reason,expires_at,created_at) VALUES ($type,$subject,$reason,$expires,$created)";
|
||||||
|
command.Parameters.AddWithValue("$type", type);
|
||||||
|
command.Parameters.AddWithValue("$subject", subject);
|
||||||
|
command.Parameters.AddWithValue("$reason", reason);
|
||||||
|
// Native schema timestamps are seconds; round upwards to avoid expiring early.
|
||||||
|
command.Parameters.AddWithValue("$expires", checked((long)(expiresUnixMs / 1000 + (expiresUnixMs % 1000 == 0 ? 0UL : 1UL))));
|
||||||
|
command.Parameters.AddWithValue("$created", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using VoiceCat.Crypto;
|
||||||
|
|
||||||
|
namespace VoiceCat.Server.Data;
|
||||||
|
|
||||||
|
public sealed record Account(long Id, string Username, bool IsAdmin, long CreatedAt, long LastLogin);
|
||||||
|
|
||||||
|
public sealed partial class AccountStore : IDisposable
|
||||||
|
{
|
||||||
|
static AccountStore() => SQLitePCL.Batteries_V2.Init();
|
||||||
|
private readonly string connectionString;
|
||||||
|
private readonly PasswordHasher hasher = new();
|
||||||
|
private readonly SemaphoreSlim passwordWorkers = new(2);
|
||||||
|
private bool disposed;
|
||||||
|
private const string DummyHash = "$argon2id$v=19$m=65536,t=2,p=1$AAECAwQFBgcICQoLDA0ODw$Ki9tdSYqOtze3s3LAS6gv6I0buTIh2abdjWzY3GeLiE";
|
||||||
|
|
||||||
|
public AccountStore(string path)
|
||||||
|
{
|
||||||
|
connectionString = new SqliteConnectionStringBuilder { DataSource = Path.GetFullPath(path), Pooling = false, DefaultTimeout = 5 }.ToString();
|
||||||
|
using var connection = Open();
|
||||||
|
using var setup = connection.CreateCommand();
|
||||||
|
setup.CommandText = "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; CREATE TABLE IF NOT EXISTS server_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);";
|
||||||
|
setup.ExecuteNonQuery();
|
||||||
|
using var transaction = connection.BeginTransaction();
|
||||||
|
using var version = connection.CreateCommand();
|
||||||
|
version.Transaction = transaction;
|
||||||
|
version.CommandText = "SELECT value FROM server_meta WHERE key='schema_version'";
|
||||||
|
object? stored = version.ExecuteScalar();
|
||||||
|
if (stored is not null && (!int.TryParse((string)stored, NumberStyles.None, CultureInfo.InvariantCulture, out int revision) || revision is < 1 or > 2))
|
||||||
|
throw new InvalidDataException("Unsupported server database schema version.");
|
||||||
|
using var resource = typeof(AccountStore).Assembly.GetManifestResourceStream("VoiceCat.Server.Data.schema.sql")!;
|
||||||
|
using var reader = new StreamReader(resource);
|
||||||
|
using var migrate = connection.CreateCommand();
|
||||||
|
migrate.Transaction = transaction;
|
||||||
|
migrate.CommandText = reader.ReadToEnd() + "INSERT INTO server_meta (key,value) VALUES ('schema_version','2') ON CONFLICT(key) DO UPDATE SET value='2';";
|
||||||
|
migrate.ExecuteNonQuery();
|
||||||
|
transaction.Commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SqliteConnection Open()
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
var connection = new SqliteConnection(connectionString);
|
||||||
|
try { connection.Open(); return connection; }
|
||||||
|
catch { connection.Dispose(); throw; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Account> CreateAccountAsync(string username, string password, bool isAdmin = false, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(username);
|
||||||
|
if (username.Length > 128) throw new ArgumentException("Username exceeds 128 characters.", nameof(username));
|
||||||
|
string hash = await PasswordWorkAsync(() => hasher.Hash(password), cancellationToken).ConfigureAwait(false);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
using var connection = Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
long created = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
|
command.CommandText = "INSERT INTO accounts (username,pw_hash,is_admin,created_at) VALUES ($user,$hash,$admin,$created) RETURNING id";
|
||||||
|
command.Parameters.AddWithValue("$user", username);
|
||||||
|
command.Parameters.AddWithValue("$hash", hash);
|
||||||
|
command.Parameters.AddWithValue("$admin", isAdmin ? 1 : 0);
|
||||||
|
command.Parameters.AddWithValue("$created", created);
|
||||||
|
return new((long)command.ExecuteScalar()!, username, isAdmin, created, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Account?> AuthenticateAsync(string username, string password, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
string? hash = null;
|
||||||
|
Account? account = null;
|
||||||
|
using (var connection = Open())
|
||||||
|
using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.CommandText = "SELECT id,pw_hash,is_admin,created_at,last_login FROM accounts WHERE username=$user";
|
||||||
|
command.Parameters.AddWithValue("$user", username);
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
if (reader.Read())
|
||||||
|
{
|
||||||
|
hash = reader.GetString(1);
|
||||||
|
account = new(reader.GetInt64(0), username, reader.GetInt64(2) != 0, reader.GetInt64(3), reader.GetInt64(4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bool verified = await PasswordWorkAsync(() => hasher.Verify(password, hash ?? DummyHash), cancellationToken).ConfigureAwait(false);
|
||||||
|
if (hash is null || !verified) return null;
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
using var updated = Open();
|
||||||
|
using var update = updated.CreateCommand();
|
||||||
|
long login = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
|
update.CommandText = "UPDATE accounts SET last_login=$login WHERE id=$id AND pw_hash=$hash";
|
||||||
|
update.Parameters.AddWithValue("$login", login);
|
||||||
|
update.Parameters.AddWithValue("$id", account!.Id);
|
||||||
|
update.Parameters.AddWithValue("$hash", hash);
|
||||||
|
return update.ExecuteNonQuery() == 1 ? account with { LastLogin = login } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> PasswordWorkAsync<T>(Func<T> work, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
await passwordWorkers.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try { return await Task.Run(work, cancellationToken).ConfigureAwait(false); }
|
||||||
|
finally { passwordWorkers.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => disposed = true;
|
||||||
|
|
||||||
|
public IReadOnlyList<Voicecat.V1.Channel> LoadChannels()
|
||||||
|
{
|
||||||
|
using var connection = Open();
|
||||||
|
using var transaction = connection.BeginTransaction();
|
||||||
|
using var seed = connection.CreateCommand();
|
||||||
|
seed.Transaction = transaction;
|
||||||
|
seed.CommandText = "SELECT COUNT(*) FROM channels";
|
||||||
|
bool empty = (long)seed.ExecuteScalar()! == 0;
|
||||||
|
seed.CommandText = """
|
||||||
|
INSERT INTO channels (id,name,max_users) VALUES (1,'Lobby',20);
|
||||||
|
INSERT INTO channels (id,name,audio_mode,audio_bitrate_bps,audio_application,audio_fec,audio_expected_packet_loss,audio_dtx,audio_complexity,sort_order)
|
||||||
|
VALUES (2,'Music Room',1,128000,1,0,0,0,8,1);
|
||||||
|
""";
|
||||||
|
if (empty) seed.ExecuteNonQuery();
|
||||||
|
transaction.Commit();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
SELECT id,parent_id,name,topic,password_hash,max_users,type,sort_order,
|
||||||
|
audio_codec,audio_mode,audio_sample_rate,audio_bitrate_bps,audio_frame_ms,
|
||||||
|
audio_application,audio_fec,audio_expected_packet_loss,audio_dtx,audio_complexity
|
||||||
|
FROM channels ORDER BY sort_order,id
|
||||||
|
""";
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
var channels = new List<Voicecat.V1.Channel>();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
channels.Add(new()
|
||||||
|
{
|
||||||
|
Id = checked((uint)reader.GetInt64(0)), ParentId = checked((uint)reader.GetInt64(1)),
|
||||||
|
Name = reader.GetString(2), Topic = reader.GetString(3), PasswordProtected = reader.GetString(4).Length != 0,
|
||||||
|
MaxUsers = checked((uint)reader.GetInt64(5)), Type = (Voicecat.V1.ChannelType)reader.GetInt32(6), Order = reader.GetInt32(7),
|
||||||
|
Audio = new()
|
||||||
|
{
|
||||||
|
Codec = checked((uint)reader.GetInt64(8)), Mode = (Voicecat.V1.ChannelMode)reader.GetInt32(9),
|
||||||
|
SampleRate = checked((uint)reader.GetInt64(10)), BitrateBps = checked((uint)reader.GetInt64(11)),
|
||||||
|
FrameMs = checked((uint)reader.GetInt64(12)), Application = (Voicecat.V1.OpusApplication)reader.GetInt32(13),
|
||||||
|
Fec = reader.GetInt32(14) != 0, ExpectedPacketLoss = checked((uint)reader.GetInt64(15)),
|
||||||
|
Dtx = reader.GetInt32(16) != 0, Complexity = checked((uint)reader.GetInt64(17))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsBanned(string subjectType, string subject)
|
||||||
|
{
|
||||||
|
using var connection = Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT 1 FROM bans WHERE subject_type=$type AND subject=$subject AND (expires_at=0 OR expires_at>$now) LIMIT 1";
|
||||||
|
command.Parameters.AddWithValue("$type", subjectType);
|
||||||
|
command.Parameters.AddWithValue("$subject", subject);
|
||||||
|
command.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
||||||
|
return command.ExecuteScalar() is not null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Org.BouncyCastle.Crypto.Digests;
|
||||||
|
using Voicecat.V1;
|
||||||
|
|
||||||
|
namespace VoiceCat.Server.Data;
|
||||||
|
|
||||||
|
public sealed partial class AccountStore
|
||||||
|
{
|
||||||
|
private static byte[] ChannelDigest(string password, byte[] salt)
|
||||||
|
{
|
||||||
|
var digest = new Blake2bDigest(salt, 32, null, null);
|
||||||
|
byte[] bytes = Encoding.UTF8.GetBytes(password);
|
||||||
|
byte[] hash = new byte[32];
|
||||||
|
try { digest.BlockUpdate(bytes, 0, bytes.Length); digest.DoFinal(hash, 0); return hash; }
|
||||||
|
finally { CryptographicOperations.ZeroMemory(bytes); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CheckChannelPassword(uint id, string password)
|
||||||
|
{
|
||||||
|
using var connection = Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT password_hash FROM channels WHERE id=$id";
|
||||||
|
command.Parameters.AddWithValue("$id", id);
|
||||||
|
if (command.ExecuteScalar() is not string stored) return false;
|
||||||
|
if (stored.Length == 0) return true;
|
||||||
|
if (stored.Length != 97 || stored[32] != ':') return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] salt = Convert.FromHexString(stored[..32]);
|
||||||
|
return CryptographicOperations.FixedTimeEquals(ChannelDigest(password, salt), Convert.FromHexString(stored[33..]));
|
||||||
|
}
|
||||||
|
catch (FormatException) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Channel SaveChannel(Channel channel, string password, bool create)
|
||||||
|
{
|
||||||
|
using var connection = Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
string hash = "";
|
||||||
|
if (password.Length != 0)
|
||||||
|
{
|
||||||
|
byte[] salt = RandomNumberGenerator.GetBytes(16);
|
||||||
|
hash = Convert.ToHexString(salt).ToLowerInvariant() + ":" + Convert.ToHexString(ChannelDigest(password, salt)).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
string[] columns = ["parent_id", "name", "topic", "max_users", "type", "sort_order", "audio_codec", "audio_mode", "audio_sample_rate", "audio_bitrate_bps", "audio_frame_ms", "audio_application", "audio_fec", "audio_expected_packet_loss", "audio_dtx", "audio_complexity"];
|
||||||
|
var a = channel.Audio;
|
||||||
|
object[] values = [channel.ParentId, channel.Name, channel.Topic, channel.MaxUsers, (int)channel.Type, channel.Order, a.Codec, (int)a.Mode, a.SampleRate, a.BitrateBps, a.FrameMs, (int)a.Application, a.Fec, a.ExpectedPacketLoss, a.Dtx, a.Complexity];
|
||||||
|
for (int i = 0; i < columns.Length; i++) command.Parameters.AddWithValue("$" + columns[i], values[i]);
|
||||||
|
command.Parameters.AddWithValue("$hash", hash);
|
||||||
|
command.Parameters.AddWithValue("$id", channel.Id);
|
||||||
|
command.CommandText = create
|
||||||
|
? $"INSERT INTO channels ({string.Join(',', columns)},password_hash) VALUES ({string.Join(',', columns.Select(c => "$" + c))},$hash) RETURNING id"
|
||||||
|
: $"UPDATE channels SET {string.Join(',', columns.Select(c => c + "=$" + c))},password_hash=CASE WHEN $hash='' THEN password_hash ELSE $hash END WHERE id=$id RETURNING id";
|
||||||
|
var saved = channel.Clone();
|
||||||
|
saved.Id = checked((uint)(long)(command.ExecuteScalar() ?? throw new InvalidDataException("Channel not found.")));
|
||||||
|
saved.PasswordProtected = hash.Length != 0 || !create && CheckChannelPasswordPresent(saved.Id);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CheckChannelPasswordPresent(uint id)
|
||||||
|
{
|
||||||
|
using var connection = Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT length(password_hash)>0 FROM channels WHERE id=$id";
|
||||||
|
command.Parameters.AddWithValue("$id", id);
|
||||||
|
return (long)command.ExecuteScalar()! != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void DeleteChannel(uint id)
|
||||||
|
{
|
||||||
|
using var connection = Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "DELETE FROM channels WHERE id=$id";
|
||||||
|
command.Parameters.AddWithValue("$id", id);
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
pw_hash TEXT NOT NULL,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_login INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS server_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS channels (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
parent_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
topic TEXT NOT NULL DEFAULT '',
|
||||||
|
password_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
max_users INTEGER NOT NULL DEFAULT 0,
|
||||||
|
type INTEGER NOT NULL DEFAULT 0,
|
||||||
|
audio_codec INTEGER NOT NULL DEFAULT 0,
|
||||||
|
audio_mode INTEGER NOT NULL DEFAULT 0,
|
||||||
|
audio_sample_rate INTEGER NOT NULL DEFAULT 48000,
|
||||||
|
audio_bitrate_bps INTEGER NOT NULL DEFAULT 24000,
|
||||||
|
audio_frame_ms INTEGER NOT NULL DEFAULT 20,
|
||||||
|
audio_application INTEGER NOT NULL DEFAULT 0,
|
||||||
|
audio_fec INTEGER NOT NULL DEFAULT 1,
|
||||||
|
audio_expected_packet_loss INTEGER NOT NULL DEFAULT 10,
|
||||||
|
audio_dtx INTEGER NOT NULL DEFAULT 1,
|
||||||
|
audio_complexity INTEGER NOT NULL DEFAULT 5,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS bans (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
subject_type TEXT NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
expires_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bans_subject ON bans(subject_type, subject);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using System.Net;
|
||||||
|
using VoiceCat.Server;
|
||||||
|
|
||||||
|
string directory = args.Length > 0 ? args[0] : "voicecat-data";
|
||||||
|
int port = args.Length > 1 ? int.Parse(args[1], System.Globalization.CultureInfo.InvariantCulture) : 7443;
|
||||||
|
using var stop = new CancellationTokenSource();
|
||||||
|
Console.CancelKeyPress += (_, eventArgs) => { eventArgs.Cancel = true; stop.Cancel(); };
|
||||||
|
await using var server = new VoiceServer(directory, new IPEndPoint(IPAddress.Loopback, port));
|
||||||
|
server.ConnectionFailed += exception => Console.Error.WriteLine($"Connection closed: {exception.Message}");
|
||||||
|
Console.WriteLine($"VoiceCat managed control server listening on {server.EndPoint}");
|
||||||
|
try { await Task.Delay(Timeout.Infinite, stop.Token); }
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using VoiceCat.Protocol;
|
||||||
|
|
||||||
|
namespace VoiceCat.Server.Transport;
|
||||||
|
|
||||||
|
// One packet at a time. Each returned buffer must be sent before preparing the next recipient.
|
||||||
|
internal sealed class MediaFanout : IDisposable
|
||||||
|
{
|
||||||
|
private readonly byte[] plaintext = new byte[65535];
|
||||||
|
private readonly byte[] output = new byte[65535];
|
||||||
|
private MediaRoute[] routes = [];
|
||||||
|
private MediaRoute? source;
|
||||||
|
private VoiceFrameHeader header;
|
||||||
|
private int length;
|
||||||
|
private int index;
|
||||||
|
|
||||||
|
public bool TryStart(ReadOnlySpan<byte> packet, MediaRoute sender, MediaRoute[] recipients)
|
||||||
|
{
|
||||||
|
source = null;
|
||||||
|
if (!VoiceFrameHeader.TryRead(packet, out var candidate) || candidate.Type != MediaFrameType.Voice || candidate.Codec != 0 ||
|
||||||
|
!sender.Subscribed || sender.Muted || !sender.Sources.Contains(candidate.Ssrc) ||
|
||||||
|
packet.Length <= VoiceFrameHeader.Size + 16 || packet.Length > output.Length) return false;
|
||||||
|
if (!sender.Peer.Crypto.Decryptor.TryDecrypt(packet, plaintext, out header, out length)) return false;
|
||||||
|
source = sender;
|
||||||
|
routes = recipients;
|
||||||
|
index = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryNext(out ReadOnlyMemory<byte> packet, out SocketAddress? endpoint)
|
||||||
|
{
|
||||||
|
packet = default;
|
||||||
|
endpoint = null;
|
||||||
|
if (source is null) return false;
|
||||||
|
while (index < routes.Length)
|
||||||
|
{
|
||||||
|
MediaRoute recipient = routes[index++];
|
||||||
|
if (ReferenceEquals(recipient.Peer, source.Peer) || recipient.ChannelId != source.ChannelId ||
|
||||||
|
!recipient.Subscribed || recipient.Deafened || recipient.Peer.Endpoint is null) continue;
|
||||||
|
int size = recipient.Peer.Crypto.Encryptor.Encrypt(header, plaintext.AsSpan(0, length), output);
|
||||||
|
packet = output.AsMemory(0, size);
|
||||||
|
endpoint = recipient.Peer.Endpoint;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => CryptographicOperations.ZeroMemory(plaintext);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using VoiceCat.Protocol;
|
||||||
|
|
||||||
|
namespace VoiceCat.Server.Transport;
|
||||||
|
|
||||||
|
internal sealed class MediaPeer(byte[] token, MediaSessionCrypto crypto, SessionActivity? activity = null)
|
||||||
|
{
|
||||||
|
public byte[] Token { get; } = token;
|
||||||
|
public MediaSessionCrypto Crypto { get; } = crypto;
|
||||||
|
public SessionActivity Activity { get; } = activity ?? new(TimeProvider.System);
|
||||||
|
// Only the UDP loop reads or changes the endpoint and binding state.
|
||||||
|
public SocketAddress? Endpoint { get; set; }
|
||||||
|
public void Dispose() { Crypto.Dispose(); CryptographicOperations.ZeroMemory(Token); }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record MediaRoute(MediaPeer Peer, uint ChannelId, bool Subscribed, bool Muted, bool Deafened, uint[] Sources);
|
||||||
|
|
||||||
|
internal sealed class MediaRelay : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly Socket socket;
|
||||||
|
private readonly CancellationTokenSource shutdown = new();
|
||||||
|
private readonly ConcurrentQueue<MediaPeer> retired = new();
|
||||||
|
private readonly Channel<byte> changed = Channel.CreateBounded<byte>(1);
|
||||||
|
private MediaRoute[] routes = [];
|
||||||
|
private readonly byte[] input = new byte[65535];
|
||||||
|
private readonly MediaFanout fanout = new();
|
||||||
|
private readonly Task receiving;
|
||||||
|
public IPEndPoint EndPoint { get; }
|
||||||
|
public event Action<Exception>? Failed;
|
||||||
|
|
||||||
|
public MediaRelay(IPEndPoint endpoint)
|
||||||
|
{
|
||||||
|
socket = new(endpoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
|
||||||
|
try { socket.Bind(endpoint); EndPoint = (IPEndPoint)socket.LocalEndPoint!; }
|
||||||
|
catch { socket.Dispose(); shutdown.Dispose(); throw; }
|
||||||
|
receiving = ReceiveAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publications are serialized by the server's session gate. Crypto ownership transfers here.
|
||||||
|
public void Publish(MediaRoute[] next)
|
||||||
|
{
|
||||||
|
MediaRoute[] previous = Volatile.Read(ref routes);
|
||||||
|
Volatile.Write(ref routes, next);
|
||||||
|
foreach (MediaRoute route in previous)
|
||||||
|
if (!next.Any(candidate => ReferenceEquals(candidate.Peer, route.Peer))) retired.Enqueue(route.Peer);
|
||||||
|
changed.Writer.TryWrite(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrainRetired()
|
||||||
|
{
|
||||||
|
while (retired.TryDequeue(out MediaPeer? peer)) peer.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReceiveAsync()
|
||||||
|
{
|
||||||
|
var sender = new SocketAddress(socket.AddressFamily);
|
||||||
|
Task<int>? receive = null;
|
||||||
|
Task<bool>? update = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
receive ??= socket.ReceiveFromAsync(input, SocketFlags.None, sender, shutdown.Token).AsTask();
|
||||||
|
update ??= changed.Reader.WaitToReadAsync(shutdown.Token).AsTask();
|
||||||
|
await Task.WhenAny(receive, update).ConfigureAwait(false);
|
||||||
|
if (update.IsCompleted)
|
||||||
|
{
|
||||||
|
await update.ConfigureAwait(false);
|
||||||
|
while (changed.Reader.TryRead(out _)) { }
|
||||||
|
update = null;
|
||||||
|
DrainRetired();
|
||||||
|
}
|
||||||
|
if (!receive.IsCompleted) continue;
|
||||||
|
int length;
|
||||||
|
try { length = await receive.ConfigureAwait(false); }
|
||||||
|
catch (SocketException exception) when (exception.SocketErrorCode is SocketError.MessageSize or SocketError.ConnectionReset) { continue; }
|
||||||
|
finally { receive = null; }
|
||||||
|
DrainRetired();
|
||||||
|
MediaRoute[] current = Volatile.Read(ref routes);
|
||||||
|
if (!VoiceFrameHeader.TryRead(input.AsSpan(0, length), out var header)) continue;
|
||||||
|
MediaRoute? source = null;
|
||||||
|
foreach (MediaRoute route in current)
|
||||||
|
if (route.Peer.Endpoint?.Equals(sender) == true) { source = route; break; }
|
||||||
|
|
||||||
|
if (header.Type == MediaFrameType.UdpBinding)
|
||||||
|
{
|
||||||
|
if (length != VoiceFrameHeader.Size + 16 || source is not null) continue;
|
||||||
|
foreach (MediaRoute route in current)
|
||||||
|
{
|
||||||
|
if (route.Peer.Endpoint is not null || !CryptographicOperations.FixedTimeEquals(route.Peer.Token, input.AsSpan(VoiceFrameHeader.Size, 16))) continue;
|
||||||
|
var bound = new SocketAddress(sender.Family, sender.Size);
|
||||||
|
for (int index = 0; index < sender.Size; index++) bound[index] = sender[index];
|
||||||
|
route.Peer.Endpoint = bound;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (source is null) continue;
|
||||||
|
if (header.Type == MediaFrameType.Keepalive)
|
||||||
|
{
|
||||||
|
if (length == VoiceFrameHeader.Size)
|
||||||
|
{
|
||||||
|
source.Peer.Activity.Touch();
|
||||||
|
await SendAsync(input.AsMemory(0, length), sender).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!fanout.TryStart(input.AsSpan(0, length), source, current)) continue;
|
||||||
|
source.Peer.Activity.Touch();
|
||||||
|
while (fanout.TryNext(out ReadOnlyMemory<byte> packet, out SocketAddress? endpoint))
|
||||||
|
await SendAsync(packet, endpoint!).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (shutdown.IsCancellationRequested && exception is OperationCanceledException or SocketException or ObjectDisposedException) { }
|
||||||
|
catch (Exception exception) { Failed?.Invoke(exception); throw; }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
shutdown.Cancel();
|
||||||
|
socket.Dispose();
|
||||||
|
fanout.Dispose();
|
||||||
|
if (receive is not null)
|
||||||
|
{
|
||||||
|
try { await receive.ConfigureAwait(false); }
|
||||||
|
catch (Exception exception) when (exception is OperationCanceledException or SocketException or ObjectDisposedException) { }
|
||||||
|
}
|
||||||
|
if (update is not null)
|
||||||
|
{
|
||||||
|
try { await update.ConfigureAwait(false); }
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask SendAsync(ReadOnlyMemory<byte> packet, SocketAddress endpoint)
|
||||||
|
{
|
||||||
|
try { await socket.SendToAsync(packet, SocketFlags.None, endpoint, shutdown.Token).ConfigureAwait(false); }
|
||||||
|
catch (SocketException exception) when (exception.SocketErrorCode is SocketError.ConnectionReset or SocketError.HostUnreachable or SocketError.NetworkUnreachable) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
shutdown.Cancel();
|
||||||
|
socket.Dispose();
|
||||||
|
try { await receiving.ConfigureAwait(false); }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DrainRetired();
|
||||||
|
foreach (MediaRoute route in Volatile.Read(ref routes)) route.Peer.Dispose();
|
||||||
|
shutdown.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using VoiceCat.Crypto;
|
||||||
|
|
||||||
|
namespace VoiceCat.Server.Transport;
|
||||||
|
|
||||||
|
internal sealed class MediaSessionCrypto(MediaEncryptor encryptor, MediaDecryptor decryptor) : IDisposable
|
||||||
|
{
|
||||||
|
public MediaEncryptor Encryptor { get; } = encryptor;
|
||||||
|
public MediaDecryptor Decryptor { get; } = decryptor;
|
||||||
|
public void Dispose() { Encryptor.Dispose(); Decryptor.Dispose(); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace VoiceCat.Server.Transport;
|
||||||
|
|
||||||
|
internal sealed class SessionActivity(TimeProvider clock)
|
||||||
|
{
|
||||||
|
private long lastSeen = clock.GetTimestamp();
|
||||||
|
public void Touch()
|
||||||
|
{
|
||||||
|
long now = clock.GetTimestamp();
|
||||||
|
long previous = Volatile.Read(ref lastSeen);
|
||||||
|
while (now > previous)
|
||||||
|
{
|
||||||
|
long observed = Interlocked.CompareExchange(ref lastSeen, now, previous);
|
||||||
|
if (observed == previous) return;
|
||||||
|
previous = observed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public bool IsExpired(TimeSpan timeout) => clock.GetElapsedTime(Volatile.Read(ref lastSeen)) >= timeout;
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
using System.Buffers;
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using Google.Protobuf;
|
||||||
|
using VoiceCat.Crypto;
|
||||||
|
using VoiceCat.Protocol;
|
||||||
|
using Voicecat.V1;
|
||||||
|
|
||||||
|
namespace VoiceCat.Server.Transport;
|
||||||
|
|
||||||
|
internal sealed class TlsControlConnection : IAsyncDisposable
|
||||||
|
{
|
||||||
|
internal const int MaximumPayloadLength = 65536;
|
||||||
|
private readonly Socket socket;
|
||||||
|
private readonly TlsSession tls;
|
||||||
|
private readonly CancellationTokenSource lifetime;
|
||||||
|
private readonly Channel<byte[]> outgoing = System.Threading.Channels.Channel.CreateBounded<byte[]>(64);
|
||||||
|
private readonly Channel<Envelope> incoming = System.Threading.Channels.Channel.CreateBounded<Envelope>(32);
|
||||||
|
private readonly byte[] prefix = new byte[4];
|
||||||
|
private int prefixBytes;
|
||||||
|
private byte[]? payload;
|
||||||
|
private int payloadBytes;
|
||||||
|
private readonly TaskCompletionSource mediaReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
private MediaSessionCrypto? mediaCrypto;
|
||||||
|
|
||||||
|
public Task Completion { get; }
|
||||||
|
public CancellationToken CancellationToken => lifetime.Token;
|
||||||
|
|
||||||
|
internal TlsControlConnection(Socket socket, TlsSession tls, CancellationToken cancellationToken, TimeSpan? handshakeTimeout = null)
|
||||||
|
{
|
||||||
|
this.socket = socket;
|
||||||
|
this.tls = tls;
|
||||||
|
lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
lifetime.CancelAfter(handshakeTimeout ?? TimeSpan.FromSeconds(15));
|
||||||
|
Completion = RunAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<Envelope> ReadAsync(CancellationToken cancellationToken) => incoming.Reader.ReadAllAsync(cancellationToken);
|
||||||
|
|
||||||
|
public bool TrySend(Envelope envelope)
|
||||||
|
{
|
||||||
|
if (envelope.CalculateSize() > MaximumPayloadLength) throw new InvalidDataException("Server control payload exceeds 64 KiB.");
|
||||||
|
var framed = new ArrayBufferWriter<byte>();
|
||||||
|
ControlFraming.WriteEnvelope(framed, envelope);
|
||||||
|
if (outgoing.Writer.TryWrite(framed.WrittenSpan.ToArray())) return true;
|
||||||
|
lifetime.Cancel();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CompleteWrites() => outgoing.Writer.TryComplete();
|
||||||
|
|
||||||
|
internal async Task<MediaSessionCrypto> TakeMediaCryptoAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await mediaReady.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return Interlocked.Exchange(ref mediaCrypto, null) ?? throw new InvalidOperationException("Media crypto already has an owner.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunAsync()
|
||||||
|
{
|
||||||
|
byte[] ciphertext = new byte[16384];
|
||||||
|
byte[] plaintext = new byte[16384];
|
||||||
|
byte[] sendBuffer = new byte[16384];
|
||||||
|
CancellationToken cancellationToken = lifetime.Token;
|
||||||
|
Task<int>? receive = null;
|
||||||
|
Task<bool>? ready = null;
|
||||||
|
Exception? error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false);
|
||||||
|
receive = socket.ReceiveAsync(ciphertext, SocketFlags.None, cancellationToken).AsTask();
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (tls.IsReady)
|
||||||
|
{
|
||||||
|
while (outgoing.Reader.TryRead(out byte[]? frame)) tls.WritePlaintext(frame);
|
||||||
|
await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false);
|
||||||
|
ready ??= outgoing.Reader.WaitToReadAsync(cancellationToken).AsTask();
|
||||||
|
}
|
||||||
|
Task winner = ready is null ? receive : await Task.WhenAny(receive, ready).ConfigureAwait(false);
|
||||||
|
if (winner == receive)
|
||||||
|
{
|
||||||
|
int count = await receive.ConfigureAwait(false);
|
||||||
|
if (count == 0)
|
||||||
|
{
|
||||||
|
tls.CompleteInput();
|
||||||
|
if (prefixBytes != 0 || payload is not null) throw new InvalidDataException("Truncated control frame.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tls.ReceiveCiphertext(ciphertext.AsSpan(0, count));
|
||||||
|
if (tls.IsReady && !mediaReady.Task.IsCompleted)
|
||||||
|
{
|
||||||
|
var encryptor = tls.CreateMediaEncryptor();
|
||||||
|
try { mediaCrypto = new(encryptor, tls.CreateMediaDecryptor()); }
|
||||||
|
catch { encryptor.Dispose(); throw; }
|
||||||
|
mediaReady.SetResult();
|
||||||
|
lifetime.CancelAfter(Timeout.InfiniteTimeSpan);
|
||||||
|
}
|
||||||
|
while ((count = tls.ReadPlaintext(plaintext)) > 0) Parse(plaintext.AsSpan(0, count));
|
||||||
|
await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false);
|
||||||
|
receive = socket.ReceiveAsync(ciphertext, SocketFlags.None, cancellationToken).AsTask();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bool hasOutgoing = await ready!.ConfigureAwait(false);
|
||||||
|
ready = null;
|
||||||
|
if (!hasOutgoing)
|
||||||
|
{
|
||||||
|
tls.Close();
|
||||||
|
await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException or SocketException or OperationCanceledException or ObjectDisposedException)
|
||||||
|
{
|
||||||
|
if (!cancellationToken.IsCancellationRequested) error = exception;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
mediaReady.TrySetCanceled();
|
||||||
|
lifetime.Cancel();
|
||||||
|
socket.Dispose();
|
||||||
|
if (receive is not null)
|
||||||
|
{
|
||||||
|
try { await receive.ConfigureAwait(false); }
|
||||||
|
catch (Exception exception) when (exception is SocketException or OperationCanceledException or ObjectDisposedException) { }
|
||||||
|
}
|
||||||
|
tls.Dispose();
|
||||||
|
incoming.Writer.TryComplete(error);
|
||||||
|
outgoing.Writer.TryComplete(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FlushAsync(byte[] buffer, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
int count;
|
||||||
|
while ((count = tls.DrainCiphertext(buffer)) > 0)
|
||||||
|
{
|
||||||
|
int sent = 0;
|
||||||
|
while (sent < count)
|
||||||
|
{
|
||||||
|
int written = await socket.SendAsync(buffer.AsMemory(sent, count - sent), SocketFlags.None, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (written == 0) throw new IOException("Socket closed during TLS send.");
|
||||||
|
sent += written;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Parse(ReadOnlySpan<byte> input)
|
||||||
|
{
|
||||||
|
while (!input.IsEmpty)
|
||||||
|
{
|
||||||
|
if (payload is null)
|
||||||
|
{
|
||||||
|
int count = Math.Min(4 - prefixBytes, input.Length);
|
||||||
|
input[..count].CopyTo(prefix.AsSpan(prefixBytes));
|
||||||
|
prefixBytes += count;
|
||||||
|
input = input[count..];
|
||||||
|
if (prefixBytes != 4) continue;
|
||||||
|
uint length = BinaryPrimitives.ReadUInt32BigEndian(prefix);
|
||||||
|
if (length > MaximumPayloadLength) throw new InvalidDataException("Server control payload exceeds 64 KiB.");
|
||||||
|
payload = new byte[length];
|
||||||
|
prefixBytes = 0;
|
||||||
|
}
|
||||||
|
int consumed = Math.Min(payload.Length - payloadBytes, input.Length);
|
||||||
|
input[..consumed].CopyTo(payload.AsSpan(payloadBytes));
|
||||||
|
payloadBytes += consumed;
|
||||||
|
input = input[consumed..];
|
||||||
|
if (payloadBytes != payload.Length) continue;
|
||||||
|
Envelope envelope = Envelope.Parser.ParseFrom(payload);
|
||||||
|
payload = null;
|
||||||
|
payloadBytes = 0;
|
||||||
|
if (!incoming.Writer.TryWrite(envelope)) throw new IOException("Control consumer exceeded its bounded queue.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
lifetime.Cancel();
|
||||||
|
try { await Completion.ConfigureAwait(false); }
|
||||||
|
finally { Interlocked.Exchange(ref mediaCrypto, null)?.Dispose(); lifetime.Dispose(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite.Core" Version="10.0.5" />
|
||||||
|
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.2" />
|
||||||
|
<PackageReference Include="SourceGear.sqlite3" Version="3.50.4.2" />
|
||||||
|
<EmbeddedResource Include="Data/schema.sql" />
|
||||||
|
<InternalsVisibleTo Include="VoiceCat.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Google.Protobuf;
|
||||||
|
using VoiceCat.Crypto;
|
||||||
|
using VoiceCat.Server.Data;
|
||||||
|
using VoiceCat.Server.Transport;
|
||||||
|
using Voicecat.V1;
|
||||||
|
|
||||||
|
namespace VoiceCat.Server;
|
||||||
|
|
||||||
|
public sealed partial class VoiceServer : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly Socket listener;
|
||||||
|
private readonly MediaRelay media;
|
||||||
|
private readonly ServerCredentials credentials;
|
||||||
|
private readonly AccountStore accounts;
|
||||||
|
private readonly List<Voicecat.V1.Channel> channels;
|
||||||
|
private readonly bool allowGuests;
|
||||||
|
private readonly string name;
|
||||||
|
private readonly VoiceServerOptions options;
|
||||||
|
private readonly TimeProvider clock;
|
||||||
|
private readonly CancellationTokenSource shutdown = new();
|
||||||
|
private readonly object gate = new();
|
||||||
|
private readonly Dictionary<ulong, Session> sessions = [];
|
||||||
|
private readonly List<Task> connections = [];
|
||||||
|
private ulong nextSession;
|
||||||
|
private uint nextUser;
|
||||||
|
private uint nextSsrc;
|
||||||
|
private readonly Task accepting;
|
||||||
|
private readonly Task reaping;
|
||||||
|
private int disposed;
|
||||||
|
|
||||||
|
public IPEndPoint EndPoint => (IPEndPoint)listener.LocalEndPoint!;
|
||||||
|
public IPEndPoint MediaEndPoint => media.EndPoint;
|
||||||
|
public event Action<Exception>? ConnectionFailed;
|
||||||
|
|
||||||
|
public VoiceServer(string directory, IPEndPoint endpoint, bool allowGuests = true, string name = "VoiceCat Server")
|
||||||
|
: this(directory, endpoint, new VoiceServerOptions { AllowGuests = allowGuests, Name = name }) { }
|
||||||
|
|
||||||
|
public VoiceServer(string directory, IPEndPoint endpoint, VoiceServerOptions options, TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
options.Validate();
|
||||||
|
this.options = options;
|
||||||
|
clock = timeProvider ?? TimeProvider.System;
|
||||||
|
allowGuests = options.AllowGuests;
|
||||||
|
name = options.Name;
|
||||||
|
credentials = ServerCredentials.LoadOrCreate(directory, name);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
accounts = new AccountStore(Path.Combine(directory, "voicecat.db"));
|
||||||
|
channels = accounts.LoadChannels().ToList();
|
||||||
|
listener = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
|
||||||
|
listener.Bind(endpoint);
|
||||||
|
listener.Listen(options.MaximumConnections);
|
||||||
|
media = new((IPEndPoint)listener.LocalEndPoint!);
|
||||||
|
media.Failed += exception => ConnectionFailed?.Invoke(exception);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
listener?.Dispose();
|
||||||
|
accounts?.Dispose();
|
||||||
|
credentials.Dispose();
|
||||||
|
shutdown.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
accepting = AcceptAsync();
|
||||||
|
reaping = ReapAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AcceptAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!shutdown.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
Socket socket = await listener.AcceptAsync(shutdown.Token).ConfigureAwait(false);
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (sessions.Count >= options.MaximumConnections) { socket.Dispose(); continue; }
|
||||||
|
socket.NoDelay = true;
|
||||||
|
string address = ((IPEndPoint)socket.RemoteEndPoint!).Address.ToString();
|
||||||
|
var connection = new TlsControlConnection(socket, credentials.CreateTlsSession(), shutdown.Token, options.HandshakeTimeout);
|
||||||
|
var session = new Session(++nextSession, connection, address, new(clock));
|
||||||
|
sessions.Add(session.Id, session);
|
||||||
|
connections.RemoveAll(task => task.IsCompleted);
|
||||||
|
connections.Add(HandleAsync(session));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (shutdown.IsCancellationRequested && exception is OperationCanceledException or SocketException or ObjectDisposedException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleAsync(Session session)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (Envelope envelope in session.Connection.ReadAsync(shutdown.Token).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (session.Closing) break;
|
||||||
|
session.Activity.Touch();
|
||||||
|
if (envelope.Ping is not null)
|
||||||
|
{
|
||||||
|
session.Connection.TrySend(new() { RequestId = envelope.RequestId, Pong = new() { Nonce = envelope.Ping.Nonce } });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (envelope.Disconnect is not null) { session.Connection.CompleteWrites(); break; }
|
||||||
|
if (!session.HelloReceived)
|
||||||
|
{
|
||||||
|
if (envelope.ClientHello?.ProtoVersion != 2 || accounts.IsBanned("ip", session.Address))
|
||||||
|
{
|
||||||
|
Reject(session, "Unsupported protocol version or banned address.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
session.Media = new(RandomNumberGenerator.GetBytes(16), await session.Connection.TakeMediaCryptoAsync(shutdown.Token).ConfigureAwait(false), session.Activity);
|
||||||
|
var hello = new ServerHello { ProtoVersion = 2, ServerName = name, ServerVersion = "0.1.0-dotnet", UdpPort = checked((uint)media.EndPoint.Port), ServerIdentityFingerprint = ByteString.CopyFrom(SHA256.HashData(credentials.Identity.PublicKey)) };
|
||||||
|
if (allowGuests) hello.AuthMethods.Add("guest");
|
||||||
|
hello.AuthMethods.Add("password");
|
||||||
|
session.Connection.TrySend(new() { RequestId = envelope.RequestId, ServerHello = hello });
|
||||||
|
session.HelloReceived = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (session.User is null)
|
||||||
|
{
|
||||||
|
if (envelope.AuthRequest is null) { Reject(session, "Authentication required."); break; }
|
||||||
|
await AuthenticateAsync(session, envelope.RequestId, envelope.AuthRequest).ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
switch (envelope.BodyCase)
|
||||||
|
{
|
||||||
|
case Envelope.BodyOneofCase.TextMessage: RelayText(session, envelope.TextMessage); break;
|
||||||
|
case Envelope.BodyOneofCase.Subscribe: SendSnapshot(session); break;
|
||||||
|
case Envelope.BodyOneofCase.JoinChannel: Join(session, envelope.RequestId, envelope.JoinChannel.ChannelId, envelope.JoinChannel.Password); break;
|
||||||
|
case Envelope.BodyOneofCase.LeaveChannel: Join(session, envelope.RequestId, 1); break;
|
||||||
|
case Envelope.BodyOneofCase.CreateChannel:
|
||||||
|
case Envelope.BodyOneofCase.EditChannel:
|
||||||
|
case Envelope.BodyOneofCase.DeleteChannel: ManageChannel(session, envelope); break;
|
||||||
|
case Envelope.BodyOneofCase.Kick:
|
||||||
|
case Envelope.BodyOneofCase.Ban:
|
||||||
|
case Envelope.BodyOneofCase.MoveUser:
|
||||||
|
case Envelope.BodyOneofCase.ServerMute:
|
||||||
|
case Envelope.BodyOneofCase.SetPermission: Moderate(session, envelope); break;
|
||||||
|
case Envelope.BodyOneofCase.CreateAccount:
|
||||||
|
case Envelope.BodyOneofCase.ResetPassword:
|
||||||
|
case Envelope.BodyOneofCase.DeleteAccount:
|
||||||
|
case Envelope.BodyOneofCase.ListAccounts: await AdministerAccountsAsync(session, envelope).ConfigureAwait(false); break;
|
||||||
|
case Envelope.BodyOneofCase.SubscribeVoice: SubscribeVoice(session, envelope.RequestId, true); break;
|
||||||
|
case Envelope.BodyOneofCase.UnsubscribeVoice: SubscribeVoice(session, envelope.RequestId, false); break;
|
||||||
|
case Envelope.BodyOneofCase.StreamAnnounce: AnnounceStream(session, envelope.RequestId, envelope.StreamAnnounce); break;
|
||||||
|
case Envelope.BodyOneofCase.StreamStop: StopStream(session, envelope.StreamStop.StreamId); break;
|
||||||
|
case Envelope.BodyOneofCase.StreamState: UpdateStream(session, envelope.StreamState); break;
|
||||||
|
case Envelope.BodyOneofCase.UdpBinding:
|
||||||
|
if (!envelope.UdpBinding.Ack && CryptographicOperations.FixedTimeEquals(envelope.UdpBinding.UdpToken.Span, session.Media!.Token))
|
||||||
|
session.Connection.TrySend(new() { RequestId = envelope.RequestId, UdpBinding = new() { Ack = true } });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
session.Connection.TrySend(new() { RequestId = envelope.RequestId, GenericResult = new() { Code = 1, Message = "Operation is not implemented by this server checkpoint." } });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await session.Connection.Completion.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException or SocketException or OperationCanceledException or ObjectDisposedException)
|
||||||
|
{
|
||||||
|
if (!shutdown.IsCancellationRequested && exception is not OperationCanceledException) ConnectionFailed?.Invoke(exception);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
sessions.Remove(session.Id);
|
||||||
|
if (session.User is null) session.Media?.Dispose();
|
||||||
|
else PublishMedia();
|
||||||
|
if (session.User is not null) Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Left, LeftId = session.User.Id, Reason = session.DepartureReason } });
|
||||||
|
}
|
||||||
|
await session.Connection.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Reject(Session session, string reason)
|
||||||
|
{
|
||||||
|
session.Connection.TrySend(new() { Disconnect = new() { Code = 1, Reason = reason } });
|
||||||
|
session.Connection.CompleteWrites();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReapAsync()
|
||||||
|
{
|
||||||
|
if (options.IdleTimeout == TimeSpan.Zero) return;
|
||||||
|
using var timer = new PeriodicTimer(options.ReaperInterval, clock);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (await timer.WaitForNextTickAsync(shutdown.Token).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
foreach (Session session in sessions.Values)
|
||||||
|
{
|
||||||
|
if (session.Closing || !session.Activity.IsExpired(options.IdleTimeout)) continue;
|
||||||
|
session.Closing = true;
|
||||||
|
Reject(session, "Receive idle timeout.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (shutdown.IsCancellationRequested) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AuthenticateAsync(Session session, ulong requestId, AuthRequest request)
|
||||||
|
{
|
||||||
|
User? user = null;
|
||||||
|
bool admin = false;
|
||||||
|
if (request.Guest is not null && allowGuests && request.Guest.Nickname.Length <= 128)
|
||||||
|
user = new() { Nickname = request.Guest.Nickname.Length == 0 ? "Guest" : request.Guest.Nickname, IsGuest = true, ChannelId = 1 };
|
||||||
|
else if (request.Password is not null && request.Password.Username.Length <= 128 && request.Password.Password.Length <= 1024 && !accounts.IsBanned("username", request.Password.Username))
|
||||||
|
{
|
||||||
|
Account? account = await accounts.AuthenticateAsync(request.Password.Username, request.Password.Password, session.Connection.CancellationToken).ConfigureAwait(false);
|
||||||
|
if (account is not null) { user = new() { Nickname = account.Username, ChannelId = 1 }; admin = account.IsAdmin; }
|
||||||
|
}
|
||||||
|
shutdown.Token.ThrowIfCancellationRequested();
|
||||||
|
session.Connection.CancellationToken.ThrowIfCancellationRequested();
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (session.Closing) return;
|
||||||
|
var lobby = channels.FirstOrDefault(channel => channel.Id == 1);
|
||||||
|
if (user is null || lobby is null || lobby.PasswordProtected || lobby.MaxUsers != 0 && sessions.Values.Count(peer => peer.User?.ChannelId == 1) >= lobby.MaxUsers)
|
||||||
|
{
|
||||||
|
session.Connection.TrySend(new() { RequestId = requestId, AuthResult = new() { Error = "Invalid credentials or lobby unavailable." } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
user.Id = checked(++nextUser);
|
||||||
|
session.User = user;
|
||||||
|
session.Permissions = new() { IsAdmin = admin, CanAdminAccounts = admin, CanBan = admin, CanKick = admin, CanMoveUsers = admin, CanCreateTempChannel = admin };
|
||||||
|
session.Connection.TrySend(new() { RequestId = requestId, AuthResult = new()
|
||||||
|
{
|
||||||
|
Ok = true, SessionId = session.Id, Self = user.Clone(), UdpToken = ByteString.CopyFrom(session.Media!.Token),
|
||||||
|
Permissions = session.Permissions.Clone()
|
||||||
|
} });
|
||||||
|
PublishMedia();
|
||||||
|
Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Joined, User = user.Clone() } }, session.Id);
|
||||||
|
SendSnapshot(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SendSnapshot(Session session)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var snapshot = new ServerStateSnapshot();
|
||||||
|
snapshot.Channels.Add(channels.Select(channel => channel.Clone()));
|
||||||
|
snapshot.Users.Add(sessions.Values.Where(peer => peer.User is not null).Select(peer => peer.User!.Clone()));
|
||||||
|
session.Connection.TrySend(new() { ServerState = snapshot });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Join(Session session, ulong requestId, uint channelId, string password = "")
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var channel = channels.FirstOrDefault(candidate => candidate.Id == channelId);
|
||||||
|
if (channel is null || Encoding.UTF8.GetByteCount(password) > 1024 || !accounts.CheckChannelPassword(channelId, password) || channel.MaxUsers != 0 && sessions.Values.Count(peer => peer.Id != session.Id && peer.User?.ChannelId == channelId) >= channel.MaxUsers)
|
||||||
|
{
|
||||||
|
session.Connection.TrySend(new() { RequestId = requestId, JoinChannelResult = new() { Error = "Channel unavailable." } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session.User!.ChannelId != channelId) session.User.Streams.Clear();
|
||||||
|
session.User.ChannelId = channelId;
|
||||||
|
PublishMedia();
|
||||||
|
var result = new JoinChannelResult { Ok = true, ChannelId = channelId, Audio = channel.Audio.Clone() };
|
||||||
|
result.Members.Add(sessions.Values.Where(peer => peer.User?.ChannelId == channelId).Select(peer => peer.User!.Clone()));
|
||||||
|
session.Connection.TrySend(new() { RequestId = requestId, JoinChannelResult = result });
|
||||||
|
Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Updated, User = session.User.Clone() } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RelayText(Session sender, TextMessage message)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
bool permitted = Encoding.UTF8.GetByteCount(message.Body) <= 4096 && message.ClientMsgId.Length <= 128 &&
|
||||||
|
(message.Scope == TextScope.TextServer || message.Scope == TextScope.TextChannel && message.TargetId == sender.User!.ChannelId ||
|
||||||
|
message.Scope == TextScope.TextPrivate && sessions.Values.Any(peer => peer.User?.Id == message.TargetId));
|
||||||
|
if (permitted)
|
||||||
|
{
|
||||||
|
var relay = message.Clone();
|
||||||
|
relay.SenderId = sender.User!.Id;
|
||||||
|
relay.SentAtUnixMs = checked((ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||||
|
var envelope = new Envelope { TextMessage = relay };
|
||||||
|
foreach (Session recipient in sessions.Values.Where(peer => peer.User is not null))
|
||||||
|
if (message.Scope == TextScope.TextServer || message.Scope == TextScope.TextChannel && recipient.User!.ChannelId == message.TargetId ||
|
||||||
|
message.Scope == TextScope.TextPrivate && (recipient.User!.Id == message.TargetId || recipient.Id == sender.Id))
|
||||||
|
recipient.Connection.TrySend(envelope);
|
||||||
|
}
|
||||||
|
sender.Connection.TrySend(new() { TextMessageAck = new() { ClientMsgId = message.ClientMsgId, Ok = permitted } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PublishMedia()
|
||||||
|
{
|
||||||
|
media.Publish(sessions.Values.Where(peer => peer.User is not null && !peer.Closing).Select(peer => new MediaRoute(
|
||||||
|
peer.Media!, peer.User!.ChannelId, peer.User.VoiceSubscribed, peer.User.ServerMuted, peer.User.SelfDeafened || peer.User.ServerDeafened,
|
||||||
|
peer.User.Streams.Select(stream => stream.Ssrc).ToArray())).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BroadcastUser(Session session) => Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Updated, User = session.User!.Clone() } });
|
||||||
|
|
||||||
|
private void SubscribeVoice(Session session, ulong requestId, bool subscribed)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
session.User!.VoiceSubscribed = subscribed;
|
||||||
|
if (!subscribed) session.User.Streams.Clear();
|
||||||
|
PublishMedia();
|
||||||
|
session.Connection.TrySend(new() { RequestId = requestId, VoiceSubscriptionResult = new() { Ok = true, Subscribed = subscribed } });
|
||||||
|
BroadcastUser(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AnnounceStream(Session session, ulong requestId, StreamAnnounce request)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (!session.User!.VoiceSubscribed || !Enum.IsDefined(request.Kind) || request.Label.Length > 128 || session.User.Streams.Count >= 16 ||
|
||||||
|
nextSsrc == uint.MaxValue || session.NextStream == uint.MaxValue || request.RequestedAudio?.BitrateBps is > 0 and < 500)
|
||||||
|
{
|
||||||
|
session.Connection.TrySend(new() { RequestId = requestId, StreamAnnounceResult = new() { Error = "Voice subscription required, invalid stream, or stream limit reached." } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AudioConfig audio = channels.First(channel => channel.Id == session.User.ChannelId).Audio.Clone();
|
||||||
|
if (request.RequestedAudio?.BitrateBps > 0) audio.BitrateBps = Math.Min(audio.BitrateBps, request.RequestedAudio.BitrateBps);
|
||||||
|
var stream = new StreamInfo { StreamId = ++session.NextStream, Ssrc = ++nextSsrc, Kind = request.Kind, Label = request.Label, Audio = audio };
|
||||||
|
session.User.Streams.Add(stream);
|
||||||
|
PublishMedia();
|
||||||
|
session.Connection.TrySend(new() { RequestId = requestId, StreamAnnounceResult = new() { Ok = true, StreamId = stream.StreamId, Ssrc = stream.Ssrc, EffectiveAudio = audio.Clone() } });
|
||||||
|
BroadcastUser(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopStream(Session session, uint streamId)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
StreamInfo? stream = session.User!.Streams.FirstOrDefault(candidate => candidate.StreamId == streamId);
|
||||||
|
if (stream is null) return;
|
||||||
|
session.User.Streams.Remove(stream);
|
||||||
|
PublishMedia();
|
||||||
|
BroadcastUser(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateStream(Session session, StreamStateUpdate update)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
StreamInfo? stream = session.User!.Streams.FirstOrDefault(candidate => candidate.StreamId == update.StreamId);
|
||||||
|
if (stream is null) return;
|
||||||
|
Broadcast(new() { StreamState = new() { UserId = session.User.Id, StreamId = stream.StreamId, Muted = update.Muted, Talking = update.Talking } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Broadcast(Envelope envelope, ulong excluded = 0)
|
||||||
|
{
|
||||||
|
foreach (Session recipient in sessions.Values.Where(peer => peer.Id != excluded && peer.User is not null)) recipient.Connection.TrySend(envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref disposed, 1) != 0) return;
|
||||||
|
shutdown.Cancel();
|
||||||
|
listener.Dispose();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.WhenAll(accepting, reaping).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Task[] pending;
|
||||||
|
lock (gate) pending = connections.ToArray();
|
||||||
|
await Task.WhenAll(pending).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { await media.DisposeAsync().ConfigureAwait(false); }
|
||||||
|
finally { accounts.Dispose(); credentials.Dispose(); shutdown.Dispose(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Session(ulong id, TlsControlConnection connection, string address, SessionActivity activity)
|
||||||
|
{
|
||||||
|
public ulong Id { get; } = id;
|
||||||
|
public TlsControlConnection Connection { get; } = connection;
|
||||||
|
public string Address { get; } = address;
|
||||||
|
public SessionActivity Activity { get; } = activity;
|
||||||
|
public bool Closing { get; set; }
|
||||||
|
public string DepartureReason { get; set; } = "";
|
||||||
|
public bool HelloReceived { get; set; }
|
||||||
|
public User? User { get; set; }
|
||||||
|
public Permissions Permissions { get; set; } = new();
|
||||||
|
public MediaPeer? Media { get; set; }
|
||||||
|
public uint NextStream;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace VoiceCat.Server;
|
||||||
|
|
||||||
|
public sealed record VoiceServerOptions
|
||||||
|
{
|
||||||
|
public string Name { get; init; } = "VoiceCat Server";
|
||||||
|
public bool AllowGuests { get; init; } = true;
|
||||||
|
public int MaximumConnections { get; init; } = 64;
|
||||||
|
public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(15);
|
||||||
|
public TimeSpan IdleTimeout { get; init; } = TimeSpan.FromSeconds(45);
|
||||||
|
public TimeSpan ReaperInterval { get; init; } = TimeSpan.FromSeconds(15);
|
||||||
|
|
||||||
|
internal void Validate()
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(Name);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfLessThan(MaximumConnections, 1);
|
||||||
|
if (HandshakeTimeout <= TimeSpan.Zero || HandshakeTimeout.TotalMilliseconds > uint.MaxValue - 1) throw new ArgumentOutOfRangeException(nameof(HandshakeTimeout));
|
||||||
|
if (IdleTimeout < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(IdleTimeout));
|
||||||
|
if (ReaperInterval < TimeSpan.Zero || ReaperInterval.TotalMilliseconds > uint.MaxValue - 1 || IdleTimeout > TimeSpan.Zero && ReaperInterval == TimeSpan.Zero)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(ReaperInterval));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"dependencies": {
|
||||||
|
"net10.0": {
|
||||||
|
"Microsoft.Data.Sqlite.Core": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[10.0.5, )",
|
||||||
|
"resolved": "10.0.5",
|
||||||
|
"contentHash": "jFYXnh7s0RShCw6Vkf+ReGCw+mVi7ISg1YaEzYCJcXnUifmbW+aqvCsRJuSRj2ZuQ+oqetpjxlZtbpMmk5FKqQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"SQLitePCLRaw.core": "2.1.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SourceGear.sqlite3": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[3.50.4.2, )",
|
||||||
|
"resolved": "3.50.4.2",
|
||||||
|
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
|
||||||
|
},
|
||||||
|
"SQLitePCLRaw.bundle_e_sqlite3": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[3.0.2, )",
|
||||||
|
"resolved": "3.0.2",
|
||||||
|
"contentHash": "nzPPFpELY9U1scLvQpA1k1GIgR9ror83DCPmirT2/i5NCPdTBfhTDA6MZqFZonGDayye5mUQRQLOVyEiJNYr0g==",
|
||||||
|
"dependencies": {
|
||||||
|
"SQLitePCLRaw.config.e_sqlite3": "3.0.2",
|
||||||
|
"SourceGear.sqlite3": "3.50.4.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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=="
|
||||||
|
},
|
||||||
|
"SQLitePCLRaw.config.e_sqlite3": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "3.0.2",
|
||||||
|
"contentHash": "QPHR1Axs8YCCapb0TnmT7PxY9DX3sg4I4T9HOSKeFBiT5l482mjrOIxuyt+xOCwEQ2Enq5h0tgDOXMnJi+i0sw==",
|
||||||
|
"dependencies": {
|
||||||
|
"SQLitePCLRaw.provider.e_sqlite3": "3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SQLitePCLRaw.core": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "3.0.2",
|
||||||
|
"contentHash": "tnbRf0muOOSJK1RLCfyYK13jynFScgL4xMj7yC3oy8lrrGKXTKmOoWjfdV+cFfBRdppm4qST31hvp8ihgIgvMQ=="
|
||||||
|
},
|
||||||
|
"SQLitePCLRaw.provider.e_sqlite3": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "3.0.2",
|
||||||
|
"contentHash": "RQIliDp47mQxGYNcBB6W+ezHbegkImrSZVTuWjQCSTTl3pQ37Q3rALkkkdTAMEmcIz71PEOCqNZMp7lXCnVqEQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"SQLitePCLRaw.core": "3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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, )"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using VoiceCat.Server.Data;
|
||||||
|
|
||||||
|
namespace VoiceCat.Tests;
|
||||||
|
|
||||||
|
public sealed class AccountStoreTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void UnsupportedSchemaIsRejectedWithoutCreatingAccountTables()
|
||||||
|
{
|
||||||
|
string path = Path.Combine(Path.GetTempPath(), "voicecat-future-" + Guid.NewGuid().ToString("N") + ".db");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SQLitePCL.Batteries_V2.Init();
|
||||||
|
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ToString());
|
||||||
|
connection.Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "CREATE TABLE server_meta (key TEXT PRIMARY KEY,value TEXT NOT NULL); INSERT INTO server_meta VALUES ('schema_version','99');";
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
Assert.Throws<InvalidDataException>(() => new AccountStore(path));
|
||||||
|
command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE name='accounts'";
|
||||||
|
Assert.Equal(0L, command.ExecuteScalar());
|
||||||
|
command.CommandText = "SELECT value FROM server_meta WHERE key='schema_version'";
|
||||||
|
Assert.Equal("99", command.ExecuteScalar());
|
||||||
|
}
|
||||||
|
finally { File.Delete(path); File.Delete(path + "-wal"); File.Delete(path + "-shm"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[NativeDatabaseFact]
|
||||||
|
public async Task ExistingCppDatabaseAndManagedAccountsWorkInBothImplementations()
|
||||||
|
{
|
||||||
|
string directory = Path.Combine(Path.GetTempPath(), "voicecat-import-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
string path = Path.Combine(directory, "voicecat.db");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RunOracleAsync("create", path);
|
||||||
|
using (var store = new AccountStore(path))
|
||||||
|
{
|
||||||
|
Account account = Assert.IsType<Account>(await store.AuthenticateAsync("legacy", "legacy password"));
|
||||||
|
Assert.True(account.IsAdmin);
|
||||||
|
var channel = Assert.Single(store.LoadChannels());
|
||||||
|
Assert.Equal("Preserved native topic", channel.Topic);
|
||||||
|
Assert.Equal(7U, channel.MaxUsers);
|
||||||
|
Assert.Equal(32000U, channel.Audio.BitrateBps);
|
||||||
|
await store.CreateAccountAsync("managed", "managed password", true);
|
||||||
|
}
|
||||||
|
await RunOracleAsync("verify", path);
|
||||||
|
}
|
||||||
|
finally { Directory.Delete(directory, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task RunOracleAsync(string mode, string path)
|
||||||
|
{
|
||||||
|
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_DATABASE_ORACLE")!) { UseShellExecute = false, CreateNoWindow = true };
|
||||||
|
start.ArgumentList.Add(mode);
|
||||||
|
start.ArgumentList.Add(path);
|
||||||
|
using var process = Process.Start(start)!;
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||||
|
try { await process.WaitForExitAsync(timeout.Token); Assert.Equal(0, process.ExitCode); }
|
||||||
|
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
[NativeDatabaseFact]
|
||||||
|
public async Task ChannelPasswordHashesWorkInBothImplementations()
|
||||||
|
{
|
||||||
|
string directory = Path.Combine(Path.GetTempPath(), "voicecat-channels-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
string path = Path.Combine(directory, "voicecat.db");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RunOracleAsync("create-protected", path);
|
||||||
|
using (var store = new AccountStore(path))
|
||||||
|
{
|
||||||
|
var channel = Assert.Single(store.LoadChannels());
|
||||||
|
Assert.True(store.CheckChannelPassword(channel.Id, "channel password"));
|
||||||
|
Assert.False(store.CheckChannelPassword(channel.Id, "wrong"));
|
||||||
|
channel.Name = "Managed protected";
|
||||||
|
store.SaveChannel(channel, "channel password", true);
|
||||||
|
}
|
||||||
|
await RunOracleAsync("verify-protected", path);
|
||||||
|
}
|
||||||
|
finally { Directory.Delete(directory, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NativeDatabaseFactAttribute : FactAttribute
|
||||||
|
{
|
||||||
|
public NativeDatabaseFactAttribute()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_DATABASE_ORACLE"))) Skip = "Set VOICECAT_DATABASE_ORACLE to the native database oracle.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AccountsSurviveRestartAndFailedAuthDoesNotChangeLastLogin()
|
||||||
|
{
|
||||||
|
string directory = Path.Combine(Path.GetTempPath(), "voicecat-db-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
string path = Path.Combine(directory, "voicecat.db");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Account account;
|
||||||
|
using (var store = new AccountStore(path)) account = await store.CreateAccountAsync("admin'", "secret", true);
|
||||||
|
using (var store = new AccountStore(path))
|
||||||
|
{
|
||||||
|
Assert.Null(await store.AuthenticateAsync("admin'", "wrong"));
|
||||||
|
Assert.Null(await store.AuthenticateAsync("missing", "secret"));
|
||||||
|
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ToString());
|
||||||
|
connection.Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT last_login FROM accounts WHERE id=$id";
|
||||||
|
command.Parameters.AddWithValue("$id", account.Id);
|
||||||
|
Assert.Equal(0L, command.ExecuteScalar());
|
||||||
|
Account authenticated = Assert.IsType<Account>(await store.AuthenticateAsync("admin'", "secret"));
|
||||||
|
Assert.Equal(account.Id, authenticated.Id);
|
||||||
|
Assert.True(authenticated.IsAdmin);
|
||||||
|
Assert.True(authenticated.LastLogin > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { Directory.Delete(directory, true); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using VoiceCat.Server.Data;
|
||||||
|
using Voicecat.V1;
|
||||||
|
using static VoiceCat.Tests.ServerTests;
|
||||||
|
using static VoiceCat.Tests.ChannelManagementTests;
|
||||||
|
using static VoiceCat.Tests.MediaRelayTests;
|
||||||
|
|
||||||
|
namespace VoiceCat.Tests;
|
||||||
|
|
||||||
|
public class AdministrationTests
|
||||||
|
{
|
||||||
|
[NativeCliFact]
|
||||||
|
public async Task ExistingCppCliCreatesProtectedChannelsAndAdministersAccounts()
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
await using var admin = await AdminAsync(fixture);
|
||||||
|
var start = new System.Diagnostics.ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VCCLI")!)
|
||||||
|
{
|
||||||
|
WorkingDirectory = fixture.Directory, UseShellExecute = false, CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true, RedirectStandardError = true
|
||||||
|
};
|
||||||
|
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--username", "Admin", "--password", "secret",
|
||||||
|
"--create-channel", "--new-channel-name", "Native room", "--new-channel-password", "protected", "--create-account", "native", "secret", "--list-accounts", "--wait-ms", "10000" })
|
||||||
|
start.ArgumentList.Add(argument);
|
||||||
|
using var process = System.Diagnostics.Process.Start(start)!;
|
||||||
|
Task<string> stdout = process.StandardOutput.ReadToEndAsync(), stderr = process.StandardError.ReadToEndAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await process.WaitForExitAsync(admin.Timeout.Token);
|
||||||
|
Assert.True(process.ExitCode == 0, await stdout + await stderr);
|
||||||
|
using var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
|
||||||
|
var room = store.LoadChannels().Single(c => c.Name == "Native room");
|
||||||
|
Assert.True(store.CheckChannelPassword(room.Id, "protected"));
|
||||||
|
Assert.NotNull(await store.AuthenticateAsync("native", "secret"));
|
||||||
|
}
|
||||||
|
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NativeCliFactAttribute : FactAttribute
|
||||||
|
{
|
||||||
|
public NativeCliFactAttribute()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AccountAdministrationIsPermissionGatedAndPersistsPasswordChanges()
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
await using var guest = await fixture.ConnectAsync();
|
||||||
|
User user = await guest.LoginAsync("Guest");
|
||||||
|
await using var admin = await AdminAsync(fixture);
|
||||||
|
Assert.False(await ResultAsync(guest, new() { ListAccounts = new() }));
|
||||||
|
Assert.False(await ResultAsync(guest, new() { CreateAccount = new() { Username = "new", Password = "secret" } }));
|
||||||
|
Assert.False(await ResultAsync(guest, new() { SetPermission = new() { UserId = user.Id, Permissions = new() { IsAdmin = true } } }));
|
||||||
|
Assert.True(await ResultAsync(admin, new() { SetPermission = new() { UserId = user.Id, Permissions = new() { CanAdminAccounts = true, CanCreateTempChannel = true } } }));
|
||||||
|
Assert.True(await ResultAsync(guest, new() { CreateAccount = new() { Username = "new", Password = "first" } }));
|
||||||
|
Assert.False(await ResultAsync(guest, new() { CreateAccount = new() { Username = "new", Password = "first" } }));
|
||||||
|
Assert.False(await ResultAsync(guest, new() { SetPermission = new() { UserId = user.Id, Permissions = new() { IsAdmin = true } } }));
|
||||||
|
Assert.False(await ResultAsync(guest, new() { CreateChannel = new() { Channel = new() { Name = "Permanent" } } }));
|
||||||
|
Assert.True(await ResultAsync(guest, new() { CreateChannel = new() { Channel = new() { Name = "Temporary", Type = ChannelType.ChannelTemporary,
|
||||||
|
Audio = new() { SampleRate = 48000, BitrateBps = 24000, FrameMs = 20 } } } }));
|
||||||
|
Assert.True(await ResultAsync(guest, new() { ResetPassword = new() { Username = "new", NewPassword = "second" } }));
|
||||||
|
Assert.False(await ResultAsync(guest, new() { ResetPassword = new() { Username = "missing", NewPassword = "second" } }));
|
||||||
|
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
|
||||||
|
{
|
||||||
|
Assert.Null(await store.AuthenticateAsync("new", "first"));
|
||||||
|
Assert.NotNull(await store.AuthenticateAsync("new", "second"));
|
||||||
|
}
|
||||||
|
guest.Send(new() { RequestId = 50, ListAccounts = new() });
|
||||||
|
Envelope list = await guest.ReadUntilAsync(e => e.ListAccountsResult is not null);
|
||||||
|
Assert.Equal(50UL, list.RequestId);
|
||||||
|
Assert.Equal(2, list.ListAccountsResult.Accounts.Count);
|
||||||
|
var entry = list.ListAccountsResult.Accounts.Single(a => a.Username == "new");
|
||||||
|
Assert.False(entry.IsAdmin);
|
||||||
|
Assert.True(entry.CreatedAtUnixMs > 1_000_000_000_000);
|
||||||
|
Assert.True(entry.LastLoginUnixMs > 1_000_000_000_000);
|
||||||
|
Assert.True(await ResultAsync(guest, new() { DeleteAccount = new() { Username = "new" } }));
|
||||||
|
Assert.False(await ResultAsync(guest, new() { DeleteAccount = new() { Username = "new" } }));
|
||||||
|
using var reopened = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
|
||||||
|
Assert.Null(await reopened.AuthenticateAsync("new", "second"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AccountBanPersistsByUsernameAndBlocksNewAuthentication()
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
await using var admin = await AdminAsync(fixture);
|
||||||
|
using var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
|
||||||
|
await store.CreateAccountAsync("Member", "password");
|
||||||
|
await using var member = await fixture.ConnectAsync();
|
||||||
|
member.Send(new() { ClientHello = new() { ProtoVersion = 2 } });
|
||||||
|
await member.ReadUntilAsync(e => e.ServerHello is not null);
|
||||||
|
member.Send(new() { AuthRequest = new() { Password = new() { Username = "Member", Password = "password" } } });
|
||||||
|
AuthResult auth = (await member.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult;
|
||||||
|
Assert.True(auth.Ok);
|
||||||
|
Assert.True(await ResultAsync(admin, new() { Ban = new() { UserId = auth.Self.Id, Reason = "account banned" } }));
|
||||||
|
Assert.NotNull((await member.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect);
|
||||||
|
Assert.True(store.IsBanned("username", "Member"));
|
||||||
|
Assert.False(store.IsBanned("ip", "127.0.0.1"));
|
||||||
|
await using var retry = await fixture.ConnectAsync();
|
||||||
|
retry.Send(new() { ClientHello = new() { ProtoVersion = 2 } });
|
||||||
|
await retry.ReadUntilAsync(e => e.ServerHello is not null);
|
||||||
|
retry.Send(new() { AuthRequest = new() { Password = new() { Username = "Member", Password = "password" } } });
|
||||||
|
Assert.False((await retry.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok);
|
||||||
|
Assert.False(await ResultAsync(admin, new() { Kick = new() { UserId = uint.MaxValue } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ServerMuteDeafenAndMoveImmediatelyChangeEncryptedMediaRouting()
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||||
|
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||||
|
await using var admin = await AdminAsync(fixture);
|
||||||
|
uint a = alice.Client.Authentication!.Self.Id, b = bob.Client.Authentication!.Self.Id;
|
||||||
|
var stream = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||||
|
Assert.False(await ResultAsync(bob.Client, new() { ServerMute = new() { UserId = a, Muted = true } }));
|
||||||
|
Assert.True(await ResultAsync(admin, new() { ServerMute = new() { UserId = a, Muted = true } }));
|
||||||
|
await alice.SendAsync(alice.Seal(stream.Ssrc, [1])); await bob.AssertNoVoiceAsync();
|
||||||
|
Assert.True(await ResultAsync(admin, new() { ServerMute = new() { UserId = a } }));
|
||||||
|
Assert.True(await ResultAsync(admin, new() { ServerMute = new() { UserId = b, Deafened = true } }));
|
||||||
|
await alice.SendAsync(alice.Seal(stream.Ssrc, [2])); await bob.AssertNoVoiceAsync();
|
||||||
|
Assert.True(await ResultAsync(admin, new() { ServerMute = new() { UserId = b } }));
|
||||||
|
await alice.SendAsync(alice.Seal(stream.Ssrc, [3])); Assert.Equal(new byte[] { 3 }, (await bob.ReceiveVoiceAsync()).Payload);
|
||||||
|
Assert.True(await ResultAsync(admin, new() { MoveUser = new() { UserId = a, ChannelId = 2 } }));
|
||||||
|
await alice.SendAsync(alice.Seal(stream.Ssrc, [4])); await bob.AssertNoVoiceAsync();
|
||||||
|
Assert.True(await ResultAsync(admin, new() { MoveUser = new() { UserId = a, ChannelId = 1 } }));
|
||||||
|
await alice.SendAsync(alice.Seal(stream.Ssrc, [5])); await bob.AssertNoVoiceAsync();
|
||||||
|
var replacement = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||||
|
await alice.SendAsync(alice.Seal(replacement.Ssrc, [6])); Assert.Equal(new byte[] { 6 }, (await bob.ReceiveVoiceAsync()).Payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public async Task KickAndGuestBanDisconnectWithOneDepartureAndRetireMedia(bool ban)
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||||
|
await using var observer = await VoicePeer.ConnectAsync(fixture, "Observer");
|
||||||
|
await using var admin = await AdminAsync(fixture);
|
||||||
|
uint id = alice.Client.Authentication!.Self.Id;
|
||||||
|
var stream = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||||
|
Envelope request = ban ? new() { Ban = new() { UserId = id, Reason = "removed", ExpiresUnixMs = (ulong)DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeMilliseconds() } }
|
||||||
|
: new() { Kick = new() { UserId = id, Reason = "removed" } };
|
||||||
|
Assert.True(await ResultAsync(admin, request));
|
||||||
|
Assert.Equal("removed", (await alice.Client.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect.Reason);
|
||||||
|
var left = (await observer.Client.ReadUntilAsync(e => e.UserEvent?.LeftId == id)).UserEvent;
|
||||||
|
Assert.Equal("removed", left.Reason);
|
||||||
|
observer.Client.Send(new() { Ping = new() { Nonce = 99 } });
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
Envelope message = await observer.Client.ReadUntilAsync(_ => true);
|
||||||
|
Assert.False(message.UserEvent?.LeftId == id);
|
||||||
|
if (message.Pong?.Nonce == 99) break;
|
||||||
|
}
|
||||||
|
await alice.SendAsync(alice.Seal(stream.Ssrc, [1])); await observer.AssertNoVoiceAsync();
|
||||||
|
await using var reconnect = await fixture.ConnectAsync();
|
||||||
|
if (ban)
|
||||||
|
{
|
||||||
|
reconnect.Send(new() { ClientHello = new() { ProtoVersion = 2 } });
|
||||||
|
Assert.NotNull((await reconnect.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect);
|
||||||
|
using var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
|
||||||
|
Assert.True(store.IsBanned("ip", "127.0.0.1"));
|
||||||
|
store.Ban("username", "expired", "", 1);
|
||||||
|
Assert.False(store.IsBanned("username", "expired"));
|
||||||
|
}
|
||||||
|
else await reconnect.LoginAsync("Alice");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using VoiceCat.Server.Data;
|
||||||
|
using Voicecat.V1;
|
||||||
|
using static VoiceCat.Tests.ServerTests;
|
||||||
|
|
||||||
|
namespace VoiceCat.Tests;
|
||||||
|
|
||||||
|
public class ChannelManagementTests
|
||||||
|
{
|
||||||
|
internal static async Task<Client> AdminAsync(ServerFixture fixture)
|
||||||
|
{
|
||||||
|
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
|
||||||
|
await store.CreateAccountAsync("Admin", "secret", true);
|
||||||
|
Client client = await fixture.ConnectAsync();
|
||||||
|
client.Send(new() { ClientHello = new() { ProtoVersion = 2 } });
|
||||||
|
await client.ReadUntilAsync(e => e.ServerHello is not null);
|
||||||
|
client.Send(new() { AuthRequest = new() { Password = new() { Username = "Admin", Password = "secret" } } });
|
||||||
|
Assert.True((await client.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok);
|
||||||
|
await client.ReadUntilAsync(e => e.ServerState is not null);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Channel Room(string name = "Protected") => new()
|
||||||
|
{
|
||||||
|
Name = name, MaxUsers = 1,
|
||||||
|
Audio = new() { SampleRate = 48000, BitrateBps = 24000, FrameMs = 20, Complexity = 5, Fec = true }
|
||||||
|
};
|
||||||
|
|
||||||
|
internal static async Task<bool> ResultAsync(Client client, Envelope request)
|
||||||
|
{
|
||||||
|
request.RequestId = 42;
|
||||||
|
client.Send(request);
|
||||||
|
Envelope result = await client.ReadUntilAsync(e => e.GenericResult is not null);
|
||||||
|
Assert.Equal(42UL, result.RequestId);
|
||||||
|
return result.GenericResult.Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProtectedChannelCrudEnforcesPasswordCapacityAndMovesMembersToLobby()
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
await using var guest = await fixture.ConnectAsync();
|
||||||
|
User user = await guest.LoginAsync("Guest");
|
||||||
|
await using var admin = await AdminAsync(fixture);
|
||||||
|
Assert.False(await ResultAsync(guest, new() { CreateChannel = new() { Channel = Room() } }));
|
||||||
|
Assert.True(await ResultAsync(admin, new() { CreateChannel = new() { Channel = Room(), Password = "pāssword" } }));
|
||||||
|
Channel room = (await guest.ReadUntilAsync(e => e.ChannelEvent?.Kind == ChannelEvent.Types.Kind.Created)).ChannelEvent.Channel;
|
||||||
|
Assert.True(room.PasswordProtected);
|
||||||
|
foreach (string password in new[] { "", "wrong", "pāssword" })
|
||||||
|
{
|
||||||
|
guest.Send(new() { RequestId = 7, JoinChannel = new() { ChannelId = room.Id, Password = password } });
|
||||||
|
Envelope result = await guest.ReadUntilAsync(e => e.JoinChannelResult is not null);
|
||||||
|
Assert.Equal(7UL, result.RequestId);
|
||||||
|
Assert.Equal(password == "pāssword", result.JoinChannelResult.Ok);
|
||||||
|
}
|
||||||
|
admin.Send(new() { JoinChannel = new() { ChannelId = room.Id, Password = "pāssword" } });
|
||||||
|
Assert.False((await admin.ReadUntilAsync(e => e.JoinChannelResult is not null)).JoinChannelResult.Ok);
|
||||||
|
room.Name = "Renamed";
|
||||||
|
Assert.False(await ResultAsync(guest, new() { EditChannel = new() { Channel = room } }));
|
||||||
|
Assert.True(await ResultAsync(admin, new() { EditChannel = new() { Channel = room } }));
|
||||||
|
Assert.Equal("Renamed", (await guest.ReadUntilAsync(e => e.ChannelEvent is not null)).ChannelEvent.Channel.Name);
|
||||||
|
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
|
||||||
|
{
|
||||||
|
Assert.Equal("Renamed", store.LoadChannels().Single(c => c.Id == room.Id).Name);
|
||||||
|
Assert.True(store.CheckChannelPassword(room.Id, "pāssword"));
|
||||||
|
Assert.False(store.CheckChannelPassword(room.Id, "wrong"));
|
||||||
|
}
|
||||||
|
Assert.True(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = room.Id } }));
|
||||||
|
Assert.Equal(room.Id, (await guest.ReadUntilAsync(e => e.ChannelEvent?.Kind == ChannelEvent.Types.Kind.Deleted)).ChannelEvent.DeletedId);
|
||||||
|
guest.Send(new() { Subscribe = new() });
|
||||||
|
var snapshot = (await guest.ReadUntilAsync(e => e.ServerState is not null)).ServerState;
|
||||||
|
Assert.Equal(1U, snapshot.Users.Single(u => u.Id == user.Id).ChannelId);
|
||||||
|
Assert.DoesNotContain(snapshot.Channels, c => c.Id == room.Id);
|
||||||
|
using var reopened = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
|
||||||
|
Assert.DoesNotContain(reopened.LoadChannels(), c => c.Id == room.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvalidChangesCannotCorruptChannelTreeOrLobby()
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
await using var admin = await AdminAsync(fixture);
|
||||||
|
Assert.True(await ResultAsync(admin, new() { CreateChannel = new() { Channel = Room("Parent") } }));
|
||||||
|
using var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
|
||||||
|
Channel parent = store.LoadChannels().Single(c => c.Name == "Parent");
|
||||||
|
Channel child = Room("Child"); child.ParentId = parent.Id;
|
||||||
|
Assert.True(await ResultAsync(admin, new() { CreateChannel = new() { Channel = child } }));
|
||||||
|
child = store.LoadChannels().Single(c => c.Name == "Child");
|
||||||
|
parent.ParentId = child.Id;
|
||||||
|
Assert.False(await ResultAsync(admin, new() { EditChannel = new() { Channel = parent } }));
|
||||||
|
Assert.False(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = parent.Id } }));
|
||||||
|
Assert.False(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = 1 } }));
|
||||||
|
Channel lobby = store.LoadChannels().Single(c => c.Id == 1);
|
||||||
|
Assert.False(await ResultAsync(admin, new() { EditChannel = new() { Channel = lobby, Password = "lockout" } }));
|
||||||
|
Assert.False(await ResultAsync(admin, new() { CreateChannel = new() { Channel = Room("Child") } }));
|
||||||
|
var invalid = Room("Invalid"); invalid.Audio.SampleRate = 123;
|
||||||
|
Assert.False(await ResultAsync(admin, new() { CreateChannel = new() { Channel = invalid } }));
|
||||||
|
Assert.False(await ResultAsync(admin, new() { CreateChannel = new() }));
|
||||||
|
Assert.Equal(4, store.LoadChannels().Count);
|
||||||
|
Assert.True(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = child.Id } }));
|
||||||
|
Assert.True(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = parent.Id } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using VoiceCat.Codec;
|
||||||
|
|
||||||
|
namespace VoiceCat.Tests;
|
||||||
|
|
||||||
|
public sealed class CodecTests
|
||||||
|
{
|
||||||
|
public static IEnumerable<object[]> Formats()
|
||||||
|
{
|
||||||
|
foreach (int rate in new[] { 8000, 12000, 16000, 24000, 48000 })
|
||||||
|
foreach (int channels in new[] { 1, 2 })
|
||||||
|
foreach (int duration in new[] { 10, 20, 40, 60 })
|
||||||
|
yield return [rate, channels, duration];
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(Formats))]
|
||||||
|
public void RoundTripAndLossConcealment(int sampleRate, int channels, int duration)
|
||||||
|
{
|
||||||
|
var options = new OpusOptions { SampleRate = sampleRate, Channels = channels, FrameDurationMilliseconds = duration, Bitrate = 64000 };
|
||||||
|
using var encoder = new OpusEncoder(options);
|
||||||
|
using var decoder = new OpusDecoder(sampleRate, channels);
|
||||||
|
short[] input = new short[options.SamplesPerChannel * channels];
|
||||||
|
short[] output = new short[input.Length];
|
||||||
|
byte[] packet = new byte[4000];
|
||||||
|
for (int frame = 0; frame < 12; frame++)
|
||||||
|
{
|
||||||
|
FillTone(input, options.SamplesPerChannel, channels, sampleRate, frame);
|
||||||
|
int bytes = encoder.Encode(input, packet);
|
||||||
|
Assert.InRange(bytes, 1, packet.Length);
|
||||||
|
Assert.Equal(options.SamplesPerChannel, decoder.Decode(packet.AsSpan(0, bytes), output, options.SamplesPerChannel));
|
||||||
|
}
|
||||||
|
double rms = Rms(output);
|
||||||
|
Assert.InRange(rms, 2000, 12000);
|
||||||
|
Assert.Equal(options.SamplesPerChannel, decoder.Decode([], output, options.SamplesPerChannel));
|
||||||
|
Assert.True(Rms(output) > 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RejectsInvalidStorageAndOptionsBeforeNativeCalls()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => new OpusEncoder(new() { Channels = 3 }));
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => new OpusEncoder(new() { FrameDurationMilliseconds = 30 }));
|
||||||
|
using var encoder = new OpusEncoder();
|
||||||
|
using var decoder = new OpusDecoder();
|
||||||
|
Assert.Throws<ArgumentException>(() => encoder.Encode(new short[959], new byte[4000]));
|
||||||
|
Assert.Throws<ArgumentException>(() => decoder.Decode([], new short[959], 960));
|
||||||
|
encoder.Dispose();
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => encoder.Encode(new short[960], new byte[4000]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DredIsExplicitlySupportedOrRejected()
|
||||||
|
{
|
||||||
|
using var probe = new OpusEncoder();
|
||||||
|
Assert.Contains("libopus", OpusEncoder.Version);
|
||||||
|
if (!probe.SupportsDeepRedundancy)
|
||||||
|
{
|
||||||
|
Assert.Throws<NotSupportedException>(() => new OpusEncoder(new() { DeepRedundancy = true }));
|
||||||
|
Assert.Throws<NotSupportedException>(() => new OpusDeepRedundancy());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
VerifyDredRecovery(new() { DeepRedundancy = true, ExpectedPacketLossPercent = 20, Bitrate = 64000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(Formats))]
|
||||||
|
public void DredRecoversDroppedFrames(int sampleRate, int channels, int duration)
|
||||||
|
{
|
||||||
|
using var probe = new OpusEncoder();
|
||||||
|
Assert.True(probe.SupportsDeepRedundancy, "Build native bindings with dotnet/build-native.ps1 for DRED recovery tests.");
|
||||||
|
if (sampleRate < 16000)
|
||||||
|
Assert.Throws<NotSupportedException>(() => new OpusEncoder(new() { SampleRate = sampleRate, DeepRedundancy = true }));
|
||||||
|
VerifyDredRecovery(new() { SampleRate = sampleRate, Channels = channels,
|
||||||
|
FrameDurationMilliseconds = duration, DeepRedundancy = true, ExpectedPacketLossPercent = 20, Bitrate = 64000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void VerifyDredRecovery(OpusOptions options)
|
||||||
|
{
|
||||||
|
// The pinned encoder cannot emit DRED at 8/12 kHz; packets can still be decoded at those rates.
|
||||||
|
var encoderOptions = options with { SampleRate = Math.Max(16000, options.SampleRate) };
|
||||||
|
using var encoder = new OpusEncoder(encoderOptions);
|
||||||
|
using var decoder = new OpusDecoder(options.SampleRate, options.Channels);
|
||||||
|
using var recovery = new OpusDeepRedundancy();
|
||||||
|
short[] input = new short[encoderOptions.SamplesPerChannel * options.Channels];
|
||||||
|
short[] output = new short[options.SamplesPerChannel * options.Channels];
|
||||||
|
byte[] packet = new byte[4000];
|
||||||
|
bool missing = false;
|
||||||
|
int recovered = 0;
|
||||||
|
for (int frame = 0; frame < 40; frame++)
|
||||||
|
{
|
||||||
|
FillTone(input, encoderOptions.SamplesPerChannel, options.Channels, encoderOptions.SampleRate, frame);
|
||||||
|
int bytes = encoder.Encode(input, packet);
|
||||||
|
if (missing)
|
||||||
|
{
|
||||||
|
Assert.True(recovery.TryRecover(decoder, packet.AsSpan(0, bytes), output, options.SamplesPerChannel));
|
||||||
|
Assert.True(Rms(output) > 10);
|
||||||
|
recovered++;
|
||||||
|
missing = false;
|
||||||
|
}
|
||||||
|
if (frame > 20 && frame % 5 == 0)
|
||||||
|
{
|
||||||
|
missing = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
decoder.Decode(packet.AsSpan(0, bytes), output, options.SamplesPerChannel);
|
||||||
|
}
|
||||||
|
Assert.Equal(3, recovered);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void FillTone(Span<short> pcm, int samples, int channels, int rate, int frame)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < samples; i++)
|
||||||
|
for (int channel = 0; channel < channels; channel++)
|
||||||
|
pcm[i * channels + channel] = (short)(8000 * Math.Sin(2 * Math.PI * (440 + 220 * channel) * (frame * samples + i) / rate));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static double Rms(ReadOnlySpan<short> pcm)
|
||||||
|
{
|
||||||
|
double sum = 0;
|
||||||
|
foreach (short value in pcm) sum += (double)value * value;
|
||||||
|
return Math.Sqrt(sum / pcm.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using VoiceCat.Dsp;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace VoiceCat.Tests;
|
||||||
|
|
||||||
|
public sealed class DspTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SuppressesNoiseAndPreservesUnsupportedSampleRates()
|
||||||
|
{
|
||||||
|
using var processor = new RnnoiseProcessor();
|
||||||
|
short[] pcm = new short[960];
|
||||||
|
uint random = 0x12345678;
|
||||||
|
double inputEnergy = 0, outputEnergy = 0;
|
||||||
|
for (int frame = 0; frame < 200; frame++)
|
||||||
|
{
|
||||||
|
FillNoise(pcm, ref random);
|
||||||
|
if (frame >= 60) foreach (short value in pcm) inputEnergy += (double)value * value;
|
||||||
|
processor.Process(pcm);
|
||||||
|
if (frame >= 60) foreach (short value in pcm) outputEnergy += (double)value * value;
|
||||||
|
}
|
||||||
|
Assert.True(Math.Sqrt(outputEnergy / inputEnergy) < 0.2);
|
||||||
|
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "cpp-noise.json")));
|
||||||
|
short[] expected = fixture.RootElement.GetProperty("samples").EnumerateArray().Select(value => value.GetInt16()).ToArray();
|
||||||
|
Assert.Equal(pcm.Length, expected.Length);
|
||||||
|
for (int i = 0; i < pcm.Length; i++) Assert.InRange(Math.Abs(pcm[i] - expected[i]), 0, 1);
|
||||||
|
FillNoise(pcm, ref random);
|
||||||
|
short[] original = (short[])pcm.Clone();
|
||||||
|
processor.Process(pcm, 16000);
|
||||||
|
Assert.Equal(original, pcm);
|
||||||
|
Assert.Throws<ArgumentException>(() => processor.Process(new short[481]));
|
||||||
|
processor.Dispose();
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => processor.Process(pcm));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VadStartsClosedAndUsesMonotonicHangTime()
|
||||||
|
{
|
||||||
|
var clock = new ManualTimeProvider();
|
||||||
|
var processor = new EnergyVadProcessor(0.02f, TimeSpan.FromMilliseconds(300), clock);
|
||||||
|
Assert.False(processor.Process(new short[480]));
|
||||||
|
Assert.True(processor.Process(new short[] { 32767 }));
|
||||||
|
clock.Advance(299);
|
||||||
|
Assert.True(processor.Process([]));
|
||||||
|
clock.Advance(1);
|
||||||
|
Assert.False(processor.Process(new short[480]));
|
||||||
|
processor.Threshold = 0.5f;
|
||||||
|
Assert.False(processor.Process(new short[] { 1000 }));
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => processor.Threshold = float.NaN);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void FillNoise(Span<short> pcm, ref uint random)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < pcm.Length; i++)
|
||||||
|
{
|
||||||
|
random ^= random << 13;
|
||||||
|
random ^= random >> 17;
|
||||||
|
random ^= random << 5;
|
||||||
|
pcm[i] = (short)((int)(random % 6001) - 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ManualTimeProvider : TimeProvider
|
||||||
|
{
|
||||||
|
private long timestamp;
|
||||||
|
public override long TimestampFrequency => 1000;
|
||||||
|
public override long GetTimestamp() => timestamp;
|
||||||
|
public void Advance(int milliseconds) => timestamp += milliseconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"samples":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"hashes":[{"passwordBase64":"dm9pY2VjYXQgdGVzdA","hash":"$argon2id$v=19$m=65536,t=2,p=1$AAECAwQFBgcICQoLDA0ODw$Ki9tdSYqOtze3s3LAS6gv6I0buTIh2abdjWzY3GeLiE"},{"passwordBase64":"Y2Fmw6k","hash":"$argon2id$v=19$m=65536,t=2,p=1$AAECAwQFBgcICQoLDA0ODw$lEpmh4tmC0xaD5DhMboQo/3Hw7JqT3VThdqq0n1pImc"},{"passwordBase64":"YQBi","hash":"$argon2id$v=19$m=65536,t=2,p=1$AAECAwQFBgcICQoLDA0ODw$XZZGeWLqPMYfYmkPOuDe9dOMu0w7kVG9WS8/Dl6sVI0"}]}
|
||||||
@@ -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,34 @@
|
|||||||
|
using VoiceCat.Codec;
|
||||||
|
using VoiceCat.Dsp;
|
||||||
|
|
||||||
|
namespace VoiceCat.Tests;
|
||||||
|
|
||||||
|
public sealed class MediaAllocationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SteadyStateCodecAndDspDoNotAllocateManagedMemory()
|
||||||
|
{
|
||||||
|
using var encoder = new OpusEncoder();
|
||||||
|
using var decoder = new OpusDecoder();
|
||||||
|
using var denoiser = new RnnoiseProcessor();
|
||||||
|
var vad = new EnergyVadProcessor();
|
||||||
|
short[] pcm = new short[960];
|
||||||
|
short[] decoded = new short[960];
|
||||||
|
byte[] packet = new byte[4000];
|
||||||
|
CodecTests.FillTone(pcm, 960, 1, 48000, 0);
|
||||||
|
for (int i = 0; i < 100; i++) Cycle(encoder, decoder, denoiser, vad, pcm, decoded, packet);
|
||||||
|
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||||
|
for (int i = 0; i < 1000; i++) Cycle(encoder, decoder, denoiser, vad, pcm, decoded, packet);
|
||||||
|
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||||
|
Assert.Equal(0, allocated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Cycle(OpusEncoder encoder, OpusDecoder decoder, RnnoiseProcessor denoiser,
|
||||||
|
EnergyVadProcessor vad, short[] pcm, short[] decoded, byte[] packet)
|
||||||
|
{
|
||||||
|
int bytes = encoder.Encode(pcm, packet);
|
||||||
|
decoder.Decode(packet.AsSpan(0, bytes), decoded, 960);
|
||||||
|
denoiser.Process(decoded);
|
||||||
|
vad.Process(decoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using VoiceCat.Crypto;
|
||||||
|
using VoiceCat.Protocol;
|
||||||
|
using VoiceCat.Server.Transport;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace VoiceCat.Tests;
|
||||||
|
|
||||||
|
public sealed class MediaFanoutTests(ITestOutputHelper output)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task UdpRelayDeliversFiftyPacketsPerSecondToFiftySubscribers()
|
||||||
|
{
|
||||||
|
await using var relay = new MediaRelay(new(IPAddress.Loopback, 0));
|
||||||
|
byte[] key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
|
||||||
|
Socket[] sockets = Enumerable.Range(0, 51).Select(_ => new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp)).ToArray();
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (Socket socket in sockets)
|
||||||
|
{
|
||||||
|
socket.ReceiveBufferSize = 1024 * 1024;
|
||||||
|
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||||
|
}
|
||||||
|
MediaRoute[] routes = sockets.Select(socket => new MediaRoute(
|
||||||
|
new(new byte[16], new(new(key), new(key))) { Endpoint = ((IPEndPoint)socket.LocalEndPoint!).Serialize() },
|
||||||
|
1, true, false, false, [42])).ToArray();
|
||||||
|
relay.Publish(routes);
|
||||||
|
byte[] payload = Enumerable.Range(0, 120).Select(i => (byte)i).ToArray();
|
||||||
|
Task[] receivers = sockets.Skip(1).Select(async socket =>
|
||||||
|
{
|
||||||
|
using var decryptor = new MediaDecryptor(key);
|
||||||
|
byte[] packet = new byte[4000];
|
||||||
|
byte[] decoded = new byte[4000];
|
||||||
|
for (ulong sequence = 0; sequence < 50; sequence++)
|
||||||
|
{
|
||||||
|
int length = await socket.ReceiveAsync(packet, SocketFlags.None, timeout.Token);
|
||||||
|
Assert.True(decryptor.TryDecrypt(packet.AsSpan(0, length), decoded, out var header, out int bytes));
|
||||||
|
Assert.Equal(sequence, header.Sequence);
|
||||||
|
Assert.Equal(payload, decoded[..bytes]);
|
||||||
|
}
|
||||||
|
}).ToArray();
|
||||||
|
using var encryptor = new MediaEncryptor(key);
|
||||||
|
byte[] outgoing = new byte[VoiceFrameHeader.Size + payload.Length + 16];
|
||||||
|
var elapsed = Stopwatch.StartNew();
|
||||||
|
for (uint index = 0; index < 50; index++)
|
||||||
|
{
|
||||||
|
encryptor.Encrypt(new(MediaFrameType.Voice, 0, 0, 42, 0, index * 960), payload, outgoing);
|
||||||
|
await sockets[0].SendToAsync(outgoing, SocketFlags.None, relay.EndPoint, timeout.Token);
|
||||||
|
await Task.Delay(20, timeout.Token);
|
||||||
|
}
|
||||||
|
await Task.WhenAll(receivers);
|
||||||
|
output.WriteLine($"Delivered all 2,500 recipient packets in {elapsed.Elapsed.TotalMilliseconds:F1} ms at a paced 50 pps input.");
|
||||||
|
}
|
||||||
|
finally { foreach (Socket socket in sockets) socket.Dispose(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[PlatformCipherFact]
|
||||||
|
public void FiftySubscriberFanoutAllocatesNoManagedMemoryAndPreservesPayload()
|
||||||
|
{
|
||||||
|
byte[] key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
|
||||||
|
MediaRoute[] routes = Enumerable.Range(0, 51).Select(i => new MediaRoute(
|
||||||
|
new(new byte[16], new(new(key), new(key))) { Endpoint = new IPEndPoint(IPAddress.Loopback, 10000 + i).Serialize() },
|
||||||
|
1, true, false, false, [42])).ToArray();
|
||||||
|
using var sender = new MediaEncryptor(key);
|
||||||
|
using var receiver = new MediaDecryptor(key);
|
||||||
|
using var fanout = new MediaFanout();
|
||||||
|
byte[] payload = Enumerable.Range(0, 120).Select(i => (byte)i).ToArray();
|
||||||
|
byte[] packet = new byte[VoiceFrameHeader.Size + payload.Length + 16];
|
||||||
|
byte[] decoded = new byte[payload.Length];
|
||||||
|
ReadOnlyMemory<byte> last = default;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (int i = 0; i < 100; i++) Cycle();
|
||||||
|
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||||
|
long started = Stopwatch.GetTimestamp();
|
||||||
|
for (int i = 0; i < 1000; i++) Cycle();
|
||||||
|
TimeSpan elapsed = Stopwatch.GetElapsedTime(started);
|
||||||
|
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||||
|
Assert.Equal(0, allocated);
|
||||||
|
Assert.True(receiver.TryDecrypt(last.Span, decoded, out var header, out int length));
|
||||||
|
Assert.Equal(payload.Length, length);
|
||||||
|
Assert.Equal(payload, decoded);
|
||||||
|
Assert.Equal(42U, header.Ssrc);
|
||||||
|
Assert.Equal(1099UL, header.Sequence);
|
||||||
|
output.WriteLine($"50,000 recipient seals in {elapsed.TotalMilliseconds:F1} ms; {allocated} managed bytes. Transport scheduling is excluded.");
|
||||||
|
}
|
||||||
|
finally { foreach (MediaRoute route in routes) route.Peer.Dispose(); }
|
||||||
|
|
||||||
|
void Cycle()
|
||||||
|
{
|
||||||
|
sender.Encrypt(new(MediaFrameType.Voice, 0, 0, 42, 0, 960), payload, packet);
|
||||||
|
if (!fanout.TryStart(packet, routes[0], routes)) throw new InvalidOperationException("Valid packet rejected.");
|
||||||
|
int recipients = 0;
|
||||||
|
while (fanout.TryNext(out var next, out _)) { last = next; recipients++; }
|
||||||
|
if (recipients != 50) throw new InvalidOperationException("Incorrect fanout.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PlatformCipherFactAttribute : FactAttribute
|
||||||
|
{
|
||||||
|
public PlatformCipherFactAttribute()
|
||||||
|
{
|
||||||
|
if (!ChaCha20Poly1305.IsSupported) Skip = "The allocation guarantee requires platform ChaCha20-Poly1305; fallback conformance is tested separately.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using VoiceCat.Protocol;
|
||||||
|
using VoiceCat.Server.Transport;
|
||||||
|
using Voicecat.V1;
|
||||||
|
using static VoiceCat.Tests.ServerTests;
|
||||||
|
|
||||||
|
namespace VoiceCat.Tests;
|
||||||
|
|
||||||
|
public sealed class MediaRelayTests
|
||||||
|
{
|
||||||
|
[CppCliVoiceTheory]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(2)]
|
||||||
|
public async Task TwoCppCliProcessesJoinChatAndExchangeVoice(int channel)
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
await using var observer = await fixture.ConnectAsync();
|
||||||
|
await observer.LoginAsync("Observer");
|
||||||
|
observer.Send(new() { JoinChannel = new() { ChannelId = checked((uint)channel) } });
|
||||||
|
Assert.True((await observer.ReadUntilAsync(e => e.JoinChannelResult is not null)).JoinChannelResult.Ok);
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||||
|
await Task.WhenAll(RunAsync("Cli Alice"), RunAsync("Cli Bob"));
|
||||||
|
var first = (await observer.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
|
||||||
|
var second = (await observer.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
|
||||||
|
Assert.Equal("CLI voice checkpoint", first.Body);
|
||||||
|
Assert.Equal(first.Body, second.Body);
|
||||||
|
Assert.NotEqual(first.SenderId, second.SenderId);
|
||||||
|
|
||||||
|
async Task RunAsync(string nickname)
|
||||||
|
{
|
||||||
|
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VCCLI")!)
|
||||||
|
{
|
||||||
|
WorkingDirectory = fixture.Directory, UseShellExecute = false, CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true, RedirectStandardError = true
|
||||||
|
};
|
||||||
|
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
"--nick", nickname, "--channel", channel.ToString(System.Globalization.CultureInfo.InvariantCulture), "--text", "CLI voice checkpoint", "--test-tone-ms", "4000" })
|
||||||
|
start.ArgumentList.Add(argument);
|
||||||
|
using var process = Process.Start(start)!;
|
||||||
|
Task<string> stdout = process.StandardOutput.ReadToEndAsync();
|
||||||
|
Task<string> stderr = process.StandardError.ReadToEndAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await process.WaitForExitAsync(timeout.Token);
|
||||||
|
string log = await stdout + await stderr;
|
||||||
|
Assert.True(process.ExitCode == 0, log);
|
||||||
|
Assert.Contains("[test-tone] received=", log);
|
||||||
|
}
|
||||||
|
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CppCliVoiceTheoryAttribute : TheoryAttribute
|
||||||
|
{
|
||||||
|
public CppCliVoiceTheoryAttribute()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[VoiceOracleTheory]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(2)]
|
||||||
|
public async Task ExistingCppClientsExchangeBidirectionalVoiceThroughManagedServer(int channel)
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VOICE_ORACLE")!)
|
||||||
|
{
|
||||||
|
WorkingDirectory = fixture.Directory, UseShellExecute = false, CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true, RedirectStandardError = true
|
||||||
|
};
|
||||||
|
start.ArgumentList.Add(fixture.Server.EndPoint.Port.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
start.ArgumentList.Add(channel.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
using var process = Process.Start(start)!;
|
||||||
|
Task<string> output = process.StandardOutput.ReadToEndAsync();
|
||||||
|
Task<string> error = process.StandardError.ReadToEndAsync();
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(45));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await process.WaitForExitAsync(timeout.Token);
|
||||||
|
Assert.True(process.ExitCode == 0, await output + await error);
|
||||||
|
}
|
||||||
|
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class VoiceOracleTheoryAttribute : TheoryAttribute
|
||||||
|
{
|
||||||
|
public VoiceOracleTheoryAttribute()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VOICE_ORACLE"))) Skip = "Set VOICECAT_VOICE_ORACLE to the native voice conformance executable.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DisconnectInvalidatesBothBindingAndActiveStreams()
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||||
|
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||||
|
var stream = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||||
|
alice.Client.Send(new() { Disconnect = new() });
|
||||||
|
await bob.Client.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left && e.UserEvent.LeftId == alice.Client.Authentication!.Self.Id);
|
||||||
|
await alice.SendAsync(alice.Seal(stream.Ssrc, [1]));
|
||||||
|
await bob.AssertNoVoiceAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnnounceRequiresSubscriptionAndUsesAuthoritativeMusicSettings()
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
await using var client = await fixture.ConnectAsync();
|
||||||
|
await client.LoginAsync("Alice");
|
||||||
|
client.Send(new() { StreamAnnounce = new() { Kind = StreamKind.StreamMic } });
|
||||||
|
Assert.False((await client.ReadUntilAsync(e => e.StreamAnnounceResult is not null)).StreamAnnounceResult.Ok);
|
||||||
|
client.Send(new() { SubscribeVoice = new() });
|
||||||
|
await client.ReadUntilAsync(e => e.VoiceSubscriptionResult is not null);
|
||||||
|
client.Send(new() { JoinChannel = new() { ChannelId = 2 } });
|
||||||
|
await client.ReadUntilAsync(e => e.JoinChannelResult is not null);
|
||||||
|
client.Send(new() { RequestId = 21, StreamAnnounce = new() { Kind = StreamKind.StreamScreenAudio, RequestedAudio = new() { SampleRate = 8000, BitrateBps = 64000 } } });
|
||||||
|
var announced = await client.ReadUntilAsync(e => e.StreamAnnounceResult is not null);
|
||||||
|
Assert.Equal(21UL, announced.RequestId);
|
||||||
|
Assert.True(announced.StreamAnnounceResult.Ok);
|
||||||
|
Assert.Equal(64000U, announced.StreamAnnounceResult.EffectiveAudio.BitrateBps);
|
||||||
|
Assert.Equal(48000U, announced.StreamAnnounceResult.EffectiveAudio.SampleRate);
|
||||||
|
Assert.Equal(ChannelMode.ModeStereo, announced.StreamAnnounceResult.EffectiveAudio.Mode);
|
||||||
|
client.Send(new() { StreamAnnounce = new() { Kind = (StreamKind)99 } });
|
||||||
|
Assert.False((await client.ReadUntilAsync(e => e.StreamAnnounceResult is not null)).StreamAnnounceResult.Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EncryptedOpusIsResealedWithRecipientCountersAcrossMultipleStreamsAndSenders()
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||||
|
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||||
|
await using var carol = await VoicePeer.ConnectAsync(fixture, "Carol");
|
||||||
|
StreamAnnounceResult mic = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||||
|
StreamAnnounceResult screen = await alice.AnnounceAsync(StreamKind.StreamScreenAudio);
|
||||||
|
StreamAnnounceResult other = await carol.AnnounceAsync(StreamKind.StreamMic);
|
||||||
|
Assert.NotEqual(mic.StreamId, screen.StreamId);
|
||||||
|
Assert.NotEqual(mic.Ssrc, screen.Ssrc);
|
||||||
|
Assert.Equal(48000U, screen.EffectiveAudio.SampleRate);
|
||||||
|
Assert.Equal(24000U, screen.EffectiveAudio.BitrateBps);
|
||||||
|
using var encoder = new Codec.OpusEncoder(new());
|
||||||
|
short[] samples = Enumerable.Range(0, 960).Select(i => (short)(8000 * Math.Sin(i * 0.1))).ToArray();
|
||||||
|
byte[] payload = new byte[4000];
|
||||||
|
int length = encoder.Encode(samples, payload);
|
||||||
|
payload = payload[..length];
|
||||||
|
foreach (var (sender, stream) in new[] { (alice, mic), (carol, other), (alice, screen) })
|
||||||
|
{
|
||||||
|
byte[] packet = sender.Seal(stream.Ssrc, payload, 960, VoiceFrameFlags.Marker | VoiceFrameFlags.FecPresent);
|
||||||
|
await sender.SendAsync(packet);
|
||||||
|
var received = await bob.ReceiveVoiceAsync();
|
||||||
|
Assert.Equal(payload, received.Payload);
|
||||||
|
Assert.Equal(stream.Ssrc, received.Header.Ssrc);
|
||||||
|
Assert.Equal(960U, received.Header.Timestamp);
|
||||||
|
Assert.Equal(VoiceFrameFlags.Marker | VoiceFrameFlags.FecPresent, received.Header.Flags);
|
||||||
|
}
|
||||||
|
Assert.Equal(2UL, bob.LastSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReplayForgeryAndSpoofedStreamsAreDroppedWithoutBreakingValidMedia()
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||||
|
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||||
|
StreamAnnounceResult stream = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||||
|
byte[] packet = alice.Seal(stream.Ssrc, [1, 2, 3]);
|
||||||
|
await alice.SendAsync(packet);
|
||||||
|
Assert.Equal(new byte[] { 1, 2, 3 }, (await bob.ReceiveVoiceAsync()).Payload);
|
||||||
|
await alice.SendAsync(packet);
|
||||||
|
byte[] forged = alice.Seal(stream.Ssrc, [4]);
|
||||||
|
forged[^1] ^= 1;
|
||||||
|
await alice.SendAsync(forged);
|
||||||
|
await alice.SendAsync(alice.Seal(stream.Ssrc + 1000, [5]));
|
||||||
|
await alice.SendAsync([1]);
|
||||||
|
await alice.SendAsync(alice.Seal(stream.Ssrc, [6]));
|
||||||
|
Assert.Equal(new byte[] { 6 }, (await bob.ReceiveVoiceAsync()).Payload);
|
||||||
|
Assert.Equal(1UL, bob.LastSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SubscriptionChannelMovementAndStreamStopIsolateMedia()
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||||
|
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||||
|
var mic = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||||
|
await bob.SubscribeAsync(false);
|
||||||
|
await alice.SendAsync(alice.Seal(mic.Ssrc, [1]));
|
||||||
|
await bob.AssertNoVoiceAsync();
|
||||||
|
await bob.SubscribeAsync(true);
|
||||||
|
bob.Client.Send(new() { JoinChannel = new() { ChannelId = 2 } });
|
||||||
|
Assert.True((await bob.Client.ReadUntilAsync(e => e.JoinChannelResult is not null)).JoinChannelResult.Ok);
|
||||||
|
await alice.SendAsync(alice.Seal(mic.Ssrc, [2]));
|
||||||
|
await bob.AssertNoVoiceAsync();
|
||||||
|
bob.Client.Send(new() { JoinChannel = new() { ChannelId = 1 } });
|
||||||
|
await bob.Client.ReadUntilAsync(e => e.JoinChannelResult is not null);
|
||||||
|
alice.Client.Send(new() { StreamStop = new() { StreamId = mic.StreamId } });
|
||||||
|
await alice.Client.ReadUntilAsync(e => e.UserEvent?.User?.Id == alice.Client.Authentication!.Self.Id && e.UserEvent.User.Streams.Count == 0);
|
||||||
|
await alice.SendAsync(alice.Seal(mic.Ssrc, [3]));
|
||||||
|
await bob.AssertNoVoiceAsync();
|
||||||
|
var replacement = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||||
|
await alice.SendAsync(alice.Seal(replacement.Ssrc, [4]));
|
||||||
|
Assert.Equal(new byte[] { 4 }, (await bob.ReceiveVoiceAsync()).Payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BadTokensCannotBindAndExistingBindingCannotBeStolen()
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||||
|
using var rogue = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||||
|
rogue.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||||
|
byte[] binding = new byte[VoiceFrameHeader.Size + 16];
|
||||||
|
new VoiceFrameHeader(MediaFrameType.UdpBinding, 0, 0, 0, 0, 0).Write(binding);
|
||||||
|
await rogue.SendToAsync(binding, SocketFlags.None, fixture.Server.MediaEndPoint);
|
||||||
|
alice.Client.Authentication!.UdpToken.Span.CopyTo(binding.AsSpan(VoiceFrameHeader.Size));
|
||||||
|
await rogue.SendToAsync(binding, SocketFlags.None, fixture.Server.MediaEndPoint);
|
||||||
|
byte[] keepalive = new byte[VoiceFrameHeader.Size];
|
||||||
|
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
|
||||||
|
await rogue.SendToAsync(keepalive, SocketFlags.None, fixture.Server.MediaEndPoint);
|
||||||
|
using var timeout = new CancellationTokenSource(200);
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await rogue.ReceiveAsync(new byte[100], SocketFlags.None, timeout.Token));
|
||||||
|
await alice.SendAsync(keepalive);
|
||||||
|
Assert.Equal(keepalive, await alice.ReceivePacketAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class VoicePeer : IAsyncDisposable
|
||||||
|
{
|
||||||
|
public Client Client { get; }
|
||||||
|
private readonly Socket udp = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||||
|
private readonly IPEndPoint endpoint;
|
||||||
|
private readonly MediaSessionCrypto crypto;
|
||||||
|
public ulong LastSequence { get; private set; }
|
||||||
|
private VoicePeer(Client client, IPEndPoint endpoint, MediaSessionCrypto crypto)
|
||||||
|
{
|
||||||
|
Client = client; this.endpoint = endpoint; this.crypto = crypto;
|
||||||
|
udp.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||||
|
}
|
||||||
|
public static async Task<VoicePeer> ConnectAsync(ServerFixture fixture, string nickname)
|
||||||
|
{
|
||||||
|
Client client = await fixture.ConnectAsync();
|
||||||
|
await client.LoginAsync(nickname);
|
||||||
|
var peer = new VoicePeer(client, fixture.Server.MediaEndPoint, await client.TakeMediaCryptoAsync());
|
||||||
|
client.Send(new() { UdpBinding = new() { UdpToken = client.Authentication!.UdpToken } });
|
||||||
|
Assert.True((await client.ReadUntilAsync(e => e.UdpBinding is not null)).UdpBinding.Ack);
|
||||||
|
byte[] binding = new byte[VoiceFrameHeader.Size + 16];
|
||||||
|
new VoiceFrameHeader(MediaFrameType.UdpBinding, 0, 0, 0, 0, 0).Write(binding);
|
||||||
|
client.Authentication.UdpToken.Span.CopyTo(binding.AsSpan(VoiceFrameHeader.Size));
|
||||||
|
await peer.SendAsync(binding);
|
||||||
|
byte[] keepalive = new byte[VoiceFrameHeader.Size];
|
||||||
|
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
|
||||||
|
await peer.SendAsync(keepalive);
|
||||||
|
Assert.Equal(keepalive, await peer.ReceivePacketAsync());
|
||||||
|
await peer.SubscribeAsync(true);
|
||||||
|
return peer;
|
||||||
|
}
|
||||||
|
public async Task SubscribeAsync(bool subscribed)
|
||||||
|
{
|
||||||
|
Client.Send(subscribed ? new() { SubscribeVoice = new() } : new() { UnsubscribeVoice = new() });
|
||||||
|
var result = (await Client.ReadUntilAsync(e => e.VoiceSubscriptionResult is not null)).VoiceSubscriptionResult;
|
||||||
|
Assert.True(result.Ok); Assert.Equal(subscribed, result.Subscribed);
|
||||||
|
}
|
||||||
|
public async Task<StreamAnnounceResult> AnnounceAsync(StreamKind kind)
|
||||||
|
{
|
||||||
|
Client.Send(new() { StreamAnnounce = new() { Kind = kind, RequestedAudio = new() { SampleRate = 8000, BitrateBps = 900000 } } });
|
||||||
|
var result = (await Client.ReadUntilAsync(e => e.StreamAnnounceResult is not null)).StreamAnnounceResult;
|
||||||
|
Assert.True(result.Ok, result.Error);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
public byte[] Seal(uint ssrc, byte[] payload, uint timestamp = 0, VoiceFrameFlags flags = 0)
|
||||||
|
{
|
||||||
|
byte[] packet = new byte[VoiceFrameHeader.Size + payload.Length + 16];
|
||||||
|
crypto.Encryptor.Encrypt(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload, packet);
|
||||||
|
return packet;
|
||||||
|
}
|
||||||
|
public async Task SendAsync(byte[] packet) => await udp.SendToAsync(packet, SocketFlags.None, endpoint, Client.Timeout.Token);
|
||||||
|
public async Task<byte[]> ReceivePacketAsync()
|
||||||
|
{
|
||||||
|
byte[] buffer = new byte[65535];
|
||||||
|
int size = await udp.ReceiveAsync(buffer, SocketFlags.None, Client.Timeout.Token);
|
||||||
|
return buffer[..size];
|
||||||
|
}
|
||||||
|
public async Task<(VoiceFrameHeader Header, byte[] Payload)> ReceiveVoiceAsync()
|
||||||
|
{
|
||||||
|
byte[] packet = await ReceivePacketAsync();
|
||||||
|
byte[] plain = new byte[65535];
|
||||||
|
Assert.True(crypto.Decryptor.TryDecrypt(packet, plain, out var header, out int length));
|
||||||
|
LastSequence = header.Sequence;
|
||||||
|
return (header, plain[..length]);
|
||||||
|
}
|
||||||
|
public async Task AssertNoVoiceAsync()
|
||||||
|
{
|
||||||
|
using var timeout = new CancellationTokenSource(200);
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await udp.ReceiveAsync(new byte[65535], SocketFlags.None, timeout.Token));
|
||||||
|
}
|
||||||
|
public async ValueTask DisposeAsync() { udp.Dispose(); crypto.Dispose(); await Client.DisposeAsync(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,42 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using VoiceCat.Crypto;
|
||||||
|
|
||||||
|
namespace VoiceCat.Tests;
|
||||||
|
|
||||||
|
public sealed class PasswordTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void VerifiesLibsodiumHashesWithoutPasswordNormalization()
|
||||||
|
{
|
||||||
|
var hasher = new PasswordHasher();
|
||||||
|
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "cpp-passwords.json")));
|
||||||
|
foreach (var item in fixture.RootElement.GetProperty("hashes").EnumerateArray())
|
||||||
|
{
|
||||||
|
string encodedPassword = item.GetProperty("passwordBase64").GetString()!;
|
||||||
|
string password = Encoding.UTF8.GetString(Convert.FromBase64String(encodedPassword.PadRight((encodedPassword.Length + 3) / 4 * 4, '=')));
|
||||||
|
string hash = item.GetProperty("hash").GetString()!;
|
||||||
|
Assert.True(hasher.Verify(password, hash));
|
||||||
|
Assert.False(hasher.Verify(password + "!", hash));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FreshHashesUseRandomSaltAndNativePhcFormat()
|
||||||
|
{
|
||||||
|
var hasher = new PasswordHasher();
|
||||||
|
string first = hasher.Hash("hello");
|
||||||
|
string second = hasher.Hash("hello");
|
||||||
|
Assert.NotEqual(first, second);
|
||||||
|
Assert.StartsWith("$argon2id$v=19$m=65536,t=2,p=1$", first);
|
||||||
|
Assert.True(hasher.Verify("hello", first));
|
||||||
|
Assert.False(hasher.Verify("wrong", first));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("$argon2id$v=19$m=999999999,t=2,p=1$c2FsdA$aGFzaA")]
|
||||||
|
[InlineData("$argon2id$v=19$m=65536,t=99999,p=1$c2FsdA$aGFzaA")]
|
||||||
|
[InlineData("$argon2id$v=16$m=65536,t=2,p=1$c2FsdA$aGFzaA")]
|
||||||
|
[InlineData("$argon2id$v=19$m=65536,t=2,p=1$!!!$!!!")]
|
||||||
|
public void MalformedOrExcessiveHashesFailClosed(string hash) => Assert.False(new PasswordHasher().Verify("hello", hash));
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using VoiceCat.Protocol;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using VoiceCat.Server;
|
||||||
|
using Voicecat.V1;
|
||||||
|
using static VoiceCat.Tests.ServerTests;
|
||||||
|
using static VoiceCat.Tests.MediaRelayTests;
|
||||||
|
|
||||||
|
namespace VoiceCat.Tests;
|
||||||
|
|
||||||
|
public sealed class ReaperTests
|
||||||
|
{
|
||||||
|
private static readonly VoiceServerOptions Options = new()
|
||||||
|
{
|
||||||
|
IdleTimeout = TimeSpan.FromSeconds(10), ReaperInterval = TimeSpan.FromMilliseconds(20)
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SilentPeerIsReapedWhileTcpActivityKeepsObserverAlive()
|
||||||
|
{
|
||||||
|
var clock = new ManualClock();
|
||||||
|
await using var fixture = new ServerFixture(options: Options, timeProvider: clock);
|
||||||
|
await using var alice = await fixture.ConnectAsync();
|
||||||
|
await alice.LoginAsync("Alice");
|
||||||
|
await using var bob = await fixture.ConnectAsync();
|
||||||
|
User self = await bob.LoginAsync("Bob");
|
||||||
|
clock.Advance(9);
|
||||||
|
alice.Send(new() { Ping = new() { Nonce = 99 } });
|
||||||
|
await alice.ReadUntilAsync(e => e.Pong?.Nonce == 99);
|
||||||
|
clock.Advance(2);
|
||||||
|
Assert.Equal(self.Id, (await alice.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left)).UserEvent.LeftId);
|
||||||
|
Assert.Equal("Receive idle timeout.", (await bob.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect.Reason);
|
||||||
|
alice.Send(new() { Subscribe = new() });
|
||||||
|
int additionalDepartures = 0;
|
||||||
|
var snapshot = await alice.ReadUntilAsync(e =>
|
||||||
|
{
|
||||||
|
if (e.UserEvent?.Kind == UserEvent.Types.Kind.Left) additionalDepartures++;
|
||||||
|
return e.ServerState is not null;
|
||||||
|
});
|
||||||
|
Assert.Equal(0, additionalDepartures);
|
||||||
|
Assert.DoesNotContain(snapshot.ServerState.Users, user => user.Id == self.Id);
|
||||||
|
alice.Send(new() { Ping = new() { Nonce = 100 } });
|
||||||
|
await alice.ReadUntilAsync(e => e.Pong?.Nonce == 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public async Task ValidUdpActivityKeepsTcpIdleClientAlive(bool voice)
|
||||||
|
{
|
||||||
|
var clock = new ManualClock();
|
||||||
|
await using var fixture = new ServerFixture(options: Options, timeProvider: clock);
|
||||||
|
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||||
|
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||||
|
uint ssrc = voice ? (await alice.AnnounceAsync(StreamKind.StreamMic)).Ssrc : 0;
|
||||||
|
clock.Advance(9);
|
||||||
|
if (voice)
|
||||||
|
{
|
||||||
|
await alice.SendAsync(alice.Seal(ssrc, [1, 2, 3]));
|
||||||
|
await bob.ReceiveVoiceAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
byte[] keepalive = new byte[VoiceFrameHeader.Size];
|
||||||
|
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
|
||||||
|
await alice.SendAsync(keepalive);
|
||||||
|
Assert.Equal(keepalive, await alice.ReceivePacketAsync());
|
||||||
|
}
|
||||||
|
clock.Advance(2);
|
||||||
|
Assert.Equal(bob.Client.Authentication!.Self.Id,
|
||||||
|
(await alice.Client.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left)).UserEvent.LeftId);
|
||||||
|
alice.Client.Send(new() { Ping = new() { Nonce = 42 } });
|
||||||
|
await alice.Client.ReadUntilAsync(e => e.Pong?.Nonce == 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvalidVoiceCannotKeepSilentSessionAlive()
|
||||||
|
{
|
||||||
|
var clock = new ManualClock();
|
||||||
|
await using var fixture = new ServerFixture(options: Options, timeProvider: clock);
|
||||||
|
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||||
|
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||||
|
var stream = await bob.AnnounceAsync(StreamKind.StreamMic);
|
||||||
|
clock.Advance(9);
|
||||||
|
byte[] forged = bob.Seal(stream.Ssrc, [1]);
|
||||||
|
forged[^1] ^= 1;
|
||||||
|
await bob.SendAsync(forged);
|
||||||
|
alice.Client.Send(new() { Ping = new() { Nonce = 1 } });
|
||||||
|
await alice.Client.ReadUntilAsync(e => e.Pong is not null);
|
||||||
|
clock.Advance(2);
|
||||||
|
Assert.Equal(bob.Client.Authentication!.Self.Id,
|
||||||
|
(await alice.Client.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left)).UserEvent.LeftId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ShutdownAwaitsActiveVoiceAndUnfinishedHandshake()
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture(options: Options);
|
||||||
|
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||||
|
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||||
|
var stream = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||||
|
await alice.SendAsync(alice.Seal(stream.Ssrc, [1, 2]));
|
||||||
|
await bob.ReceiveVoiceAsync();
|
||||||
|
using var unfinished = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||||
|
await unfinished.ConnectAsync(fixture.Server.EndPoint);
|
||||||
|
await fixture.Server.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10));
|
||||||
|
await fixture.Server.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReaperCanBeDisabled()
|
||||||
|
{
|
||||||
|
var clock = new ManualClock();
|
||||||
|
await using var fixture = new ServerFixture(options: Options with { IdleTimeout = TimeSpan.Zero, ReaperInterval = TimeSpan.Zero }, timeProvider: clock);
|
||||||
|
await using var client = await fixture.ConnectAsync();
|
||||||
|
await client.LoginAsync("Alice");
|
||||||
|
clock.Advance(1000);
|
||||||
|
await Task.Delay(100);
|
||||||
|
client.Send(new() { Ping = new() { Nonce = 1 } });
|
||||||
|
await client.ReadUntilAsync(e => e.Pong is not null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ManualClock : TimeProvider
|
||||||
|
{
|
||||||
|
private long timestamp;
|
||||||
|
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
|
||||||
|
public override long GetTimestamp() => Volatile.Read(ref timestamp);
|
||||||
|
public void Advance(int seconds) => Interlocked.Add(ref timestamp, seconds * TimeSpan.TicksPerSecond);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using VoiceCat.Crypto;
|
||||||
|
using VoiceCat.Server;
|
||||||
|
using VoiceCat.Server.Transport;
|
||||||
|
using Voicecat.V1;
|
||||||
|
|
||||||
|
namespace VoiceCat.Tests;
|
||||||
|
|
||||||
|
public sealed class ServerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ControlFramesCanSpanMultipleTlsRecordsAndPingEchoesCorrelation()
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
await using var client = await fixture.ConnectAsync();
|
||||||
|
client.Send(new() { ClientHello = new() { ProtoVersion = 2, ClientName = new string('x', 48000) } });
|
||||||
|
await client.ReadUntilAsync(e => e.ServerHello is not null);
|
||||||
|
client.Send(new() { RequestId = 45, Ping = new() { Nonce = 123456 } });
|
||||||
|
Envelope pong = await client.ReadUntilAsync(e => e.Pong is not null);
|
||||||
|
Assert.Equal(45UL, pong.RequestId);
|
||||||
|
Assert.Equal(123456UL, pong.Pong.Nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GuestsChatJoinChannelsAndDisconnectOverTls()
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
await using var alice = await fixture.ConnectAsync();
|
||||||
|
User a = await alice.LoginAsync("Alice");
|
||||||
|
await using var bob = await fixture.ConnectAsync();
|
||||||
|
User b = await bob.LoginAsync("Bob");
|
||||||
|
Assert.NotEqual(a.Id, b.Id);
|
||||||
|
Envelope joined = await alice.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Joined);
|
||||||
|
Assert.Equal(b.Id, joined.UserEvent.User.Id);
|
||||||
|
|
||||||
|
alice.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = 1, SenderId = b.Id, Body = "hello", ClientMsgId = "one" } });
|
||||||
|
TextMessage text = (await bob.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
|
||||||
|
Assert.Equal("hello", text.Body);
|
||||||
|
Assert.Equal(a.Id, text.SenderId);
|
||||||
|
Assert.True(text.SentAtUnixMs > 0);
|
||||||
|
Assert.True((await alice.ReadUntilAsync(e => e.TextMessageAck is not null)).TextMessageAck.Ok);
|
||||||
|
|
||||||
|
bob.Send(new() { RequestId = 10, JoinChannel = new() { ChannelId = 2 } });
|
||||||
|
Envelope moved = await bob.ReadUntilAsync(e => e.JoinChannelResult is not null);
|
||||||
|
Assert.Equal(10UL, moved.RequestId);
|
||||||
|
Assert.True(moved.JoinChannelResult.Ok);
|
||||||
|
Assert.Equal(128000U, moved.JoinChannelResult.Audio.BitrateBps);
|
||||||
|
alice.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = 2, Body = "unauthorized", ClientMsgId = "two" } });
|
||||||
|
Assert.False((await alice.ReadUntilAsync(e => e.TextMessageAck is not null)).TextMessageAck.Ok);
|
||||||
|
alice.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = 1, Body = "isolated" } });
|
||||||
|
alice.Send(new() { TextMessage = new() { Scope = TextScope.TextPrivate, TargetId = b.Id, Body = "private" } });
|
||||||
|
Assert.Equal("private", (await bob.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage.Body);
|
||||||
|
|
||||||
|
bob.Send(new() { Disconnect = new() });
|
||||||
|
Envelope left = await alice.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left);
|
||||||
|
Assert.Equal(b.Id, left.UserEvent.LeftId);
|
||||||
|
alice.Send(new() { RequestId = 11, Subscribe = new() });
|
||||||
|
ServerStateSnapshot snapshot = (await alice.ReadUntilAsync(e => e.ServerState is not null)).ServerState;
|
||||||
|
Assert.Equal(a.Id, Assert.Single(snapshot.Users).Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PasswordAuthenticationCanRetryAndGuestAccessCanBeDisabled()
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture(false);
|
||||||
|
using (var accounts = new VoiceCat.Server.Data.AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
|
||||||
|
await accounts.CreateAccountAsync("Admin", "secret", true);
|
||||||
|
await using var client = await fixture.ConnectAsync();
|
||||||
|
client.Send(new() { ClientHello = new() { ProtoVersion = 2 } });
|
||||||
|
ServerHello hello = (await client.ReadUntilAsync(e => e.ServerHello is not null)).ServerHello;
|
||||||
|
Assert.Equal(["password"], hello.AuthMethods);
|
||||||
|
client.Send(new() { AuthRequest = new() { Guest = new() { Nickname = "Guest" } } });
|
||||||
|
Assert.False((await client.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok);
|
||||||
|
client.Send(new() { AuthRequest = new() { Password = new() { Username = "Admin", Password = "wrong" } } });
|
||||||
|
Assert.False((await client.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok);
|
||||||
|
client.Send(new() { RequestId = 3, AuthRequest = new() { Password = new() { Username = "Admin", Password = "secret" } } });
|
||||||
|
Envelope authenticated = await client.ReadUntilAsync(e => e.AuthResult is not null);
|
||||||
|
Assert.True(authenticated.AuthResult.Ok);
|
||||||
|
Assert.Equal(3UL, authenticated.RequestId);
|
||||||
|
Assert.True(authenticated.AuthResult.Permissions.IsAdmin);
|
||||||
|
Assert.False(authenticated.AuthResult.Self.IsGuest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public async Task InvalidVersionAndUnauthenticatedTextAreDisconnected(bool invalidVersion)
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
await using var client = await fixture.ConnectAsync();
|
||||||
|
client.Send(invalidVersion ? new() { ClientHello = new() { ProtoVersion = 1 } } : new() { TextMessage = new() { Body = "pre-auth" } });
|
||||||
|
Assert.NotEqual(0U, (await client.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[CppCliFact]
|
||||||
|
public async Task ExistingCppCliAuthenticatesAndChatsThroughManagedServer()
|
||||||
|
{
|
||||||
|
await using var fixture = new ServerFixture();
|
||||||
|
await using var receiver = await fixture.ConnectAsync();
|
||||||
|
User self = await receiver.LoginAsync("Managed");
|
||||||
|
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VCCLI")!)
|
||||||
|
{
|
||||||
|
WorkingDirectory = fixture.Directory, UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true
|
||||||
|
};
|
||||||
|
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--nick", "Cpp", "--text", "native interoperability", "--wait-ms", "10000" })
|
||||||
|
start.ArgumentList.Add(argument);
|
||||||
|
using var process = Process.Start(start)!;
|
||||||
|
Task<string> output = process.StandardOutput.ReadToEndAsync();
|
||||||
|
Task<string> error = process.StandardError.ReadToEndAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await process.WaitForExitAsync(receiver.Timeout.Token);
|
||||||
|
string log = await output + await error;
|
||||||
|
Assert.True(process.ExitCode == 0, log);
|
||||||
|
TextMessage text = (await receiver.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
|
||||||
|
Assert.Equal("native interoperability", text.Body);
|
||||||
|
Assert.NotEqual(self.Id, text.SenderId);
|
||||||
|
Assert.Contains("native interoperability", log);
|
||||||
|
}
|
||||||
|
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CppCliFactAttribute : FactAttribute
|
||||||
|
{
|
||||||
|
public CppCliFactAttribute()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ServerFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
public string Directory { get; } = Path.Combine(Path.GetTempPath(), "voicecat-server-" + Guid.NewGuid().ToString("N"));
|
||||||
|
public VoiceServer Server { get; }
|
||||||
|
private readonly string fingerprint;
|
||||||
|
public ServerFixture(bool guests = true, VoiceServerOptions? options = null, TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
System.IO.Directory.CreateDirectory(Directory);
|
||||||
|
Server = new(Directory, new(IPAddress.Loopback, 0), options ?? new() { AllowGuests = guests }, timeProvider);
|
||||||
|
using var credentials = ServerCredentials.LoadOrCreate(Directory, "VoiceCat Server");
|
||||||
|
fingerprint = credentials.CertificateFingerprint;
|
||||||
|
}
|
||||||
|
public async Task<Client> ConnectAsync()
|
||||||
|
{
|
||||||
|
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||||
|
await socket.ConnectAsync(Server.EndPoint);
|
||||||
|
return new(new(socket, TlsSession.CreateClient(value => value == fingerprint), CancellationToken.None));
|
||||||
|
}
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await Server.DisposeAsync();
|
||||||
|
System.IO.Directory.Delete(Directory, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class Client : IAsyncDisposable
|
||||||
|
{
|
||||||
|
public CancellationTokenSource Timeout { get; } = new(TimeSpan.FromSeconds(30));
|
||||||
|
private readonly TlsControlConnection connection;
|
||||||
|
private readonly IAsyncEnumerator<Envelope> messages;
|
||||||
|
public Client(TlsControlConnection connection)
|
||||||
|
{
|
||||||
|
this.connection = connection;
|
||||||
|
messages = connection.ReadAsync(Timeout.Token).GetAsyncEnumerator();
|
||||||
|
}
|
||||||
|
public void Send(Envelope envelope) => Assert.True(connection.TrySend(envelope));
|
||||||
|
public AuthResult? Authentication { get; private set; }
|
||||||
|
public Task<MediaSessionCrypto> TakeMediaCryptoAsync() => connection.TakeMediaCryptoAsync(Timeout.Token);
|
||||||
|
public async Task<Envelope> ReadUntilAsync(Func<Envelope, bool> predicate)
|
||||||
|
{
|
||||||
|
while (await messages.MoveNextAsync()) if (predicate(messages.Current)) return messages.Current;
|
||||||
|
throw new IOException("Connection ended before the expected message.");
|
||||||
|
}
|
||||||
|
public async Task<User> LoginAsync(string nickname)
|
||||||
|
{
|
||||||
|
Send(new() { RequestId = 1, ClientHello = new() { ProtoVersion = 2, ClientName = "Managed test" } });
|
||||||
|
Assert.Equal(1UL, (await ReadUntilAsync(e => e.ServerHello is not null)).RequestId);
|
||||||
|
Send(new() { RequestId = 2, AuthRequest = new() { Guest = new() { Nickname = nickname } } });
|
||||||
|
AuthResult auth = (await ReadUntilAsync(e => e.AuthResult is not null)).AuthResult;
|
||||||
|
Authentication = auth;
|
||||||
|
Assert.True(auth.Ok, auth.Error);
|
||||||
|
ServerStateSnapshot state = (await ReadUntilAsync(e => e.ServerState is not null)).ServerState;
|
||||||
|
Assert.Equal(2, state.Channels.Count);
|
||||||
|
Assert.Contains(state.Users, user => user.Id == auth.Self.Id);
|
||||||
|
return auth.Self;
|
||||||
|
}
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await messages.DisposeAsync();
|
||||||
|
await connection.DisposeAsync();
|
||||||
|
Timeout.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,18 @@
|
|||||||
|
<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" />
|
||||||
|
<ProjectReference Include="../../src/VoiceCat.Codec/VoiceCat.Codec.csproj" />
|
||||||
|
<ProjectReference Include="../../src/VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
|
||||||
|
<ProjectReference Include="../../src/VoiceCat.Server/VoiceCat.Server.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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user