From 4067bab7c2c534341d921f6047b4a7be804a1358 Mon Sep 17 00:00:00 2001 From: Talon Date: Tue, 15 Sep 2026 22:51:33 +0200 Subject: [PATCH] Add managed codec DSP and initial control server --- .github/workflows/dotnet.yml | 14 +- .gitignore | 1 + CLAUDE.md | 5 +- CMakeLists.txt | 5 + PROGRESS.md | 133 +++++++++ docs/api-dotnet.md | 96 +++++++ docs/building.md | 4 +- docs/porting-to-dotnet.md | 44 ++- dotnet/Directory.Build.targets | 13 + dotnet/README.md | 80 +++++- dotnet/VoiceCat.slnx | 3 + dotnet/build-native.ps1 | 18 ++ dotnet/check-licenses.ps1 | 4 + dotnet/compare-dsp-fixtures.ps1 | 13 + dotnet/native/CMakeLists.txt | 101 +++++++ dotnet/native/NOTICE.txt | 12 + dotnet/native/media.c | 55 ++++ dotnet/oracle/CMakeLists.txt | 16 ++ dotnet/oracle/database.cpp | 25 ++ dotnet/oracle/dsp.cpp | 30 ++ dotnet/oracle/passwords.cpp | 29 ++ dotnet/src/VoiceCat.Codec/NativeHandles.cs | 31 ++ dotnet/src/VoiceCat.Codec/NativeMethods.cs | 40 +++ dotnet/src/VoiceCat.Codec/OpusDecoder.cs | 45 +++ .../src/VoiceCat.Codec/OpusDeepRedundancy.cs | 52 ++++ dotnet/src/VoiceCat.Codec/OpusEncoder.cs | 53 ++++ dotnet/src/VoiceCat.Codec/OpusException.cs | 10 + dotnet/src/VoiceCat.Codec/OpusOptions.cs | 32 +++ .../src/VoiceCat.Codec/VoiceCat.Codec.csproj | 5 + dotnet/src/VoiceCat.Codec/packages.lock.json | 6 + dotnet/src/VoiceCat.Crypto/PasswordHasher.cs | 77 +++++ dotnet/src/VoiceCat.Dsp/EnergyVadProcessor.cs | 48 ++++ dotnet/src/VoiceCat.Dsp/RnnoiseProcessor.cs | 53 ++++ dotnet/src/VoiceCat.Dsp/VoiceCat.Dsp.csproj | 5 + dotnet/src/VoiceCat.Dsp/packages.lock.json | 6 + .../src/VoiceCat.Server/Data/AccountStore.cs | 159 +++++++++++ dotnet/src/VoiceCat.Server/Data/schema.sql | 38 +++ dotnet/src/VoiceCat.Server/Program.cs | 12 + .../Transport/TlsControlConnection.cs | 169 +++++++++++ .../VoiceCat.Server/VoiceCat.Server.csproj | 13 + dotnet/src/VoiceCat.Server/VoiceServer.cs | 265 ++++++++++++++++++ dotnet/src/VoiceCat.Server/packages.lock.json | 76 +++++ .../tests/VoiceCat.Tests/AccountStoreTests.cs | 101 +++++++ dotnet/tests/VoiceCat.Tests/CodecTests.cs | 123 ++++++++ dotnet/tests/VoiceCat.Tests/DspTests.cs | 70 +++++ .../VoiceCat.Tests/Fixtures/cpp-noise.json | 1 + .../Fixtures/cpp-passwords.json | 1 + .../VoiceCat.Tests/MediaAllocationTests.cs | 34 +++ dotnet/tests/VoiceCat.Tests/PasswordTests.cs | 42 +++ dotnet/tests/VoiceCat.Tests/ServerTests.cs | 194 +++++++++++++ .../VoiceCat.Tests/VoiceCat.Tests.csproj | 3 + .../tests/VoiceCat.Tests/packages.lock.json | 58 ++++ 52 files changed, 2503 insertions(+), 20 deletions(-) create mode 100644 dotnet/Directory.Build.targets create mode 100644 dotnet/build-native.ps1 create mode 100644 dotnet/compare-dsp-fixtures.ps1 create mode 100644 dotnet/native/CMakeLists.txt create mode 100644 dotnet/native/NOTICE.txt create mode 100644 dotnet/native/media.c create mode 100644 dotnet/oracle/database.cpp create mode 100644 dotnet/oracle/dsp.cpp create mode 100644 dotnet/oracle/passwords.cpp create mode 100644 dotnet/src/VoiceCat.Codec/NativeHandles.cs create mode 100644 dotnet/src/VoiceCat.Codec/NativeMethods.cs create mode 100644 dotnet/src/VoiceCat.Codec/OpusDecoder.cs create mode 100644 dotnet/src/VoiceCat.Codec/OpusDeepRedundancy.cs create mode 100644 dotnet/src/VoiceCat.Codec/OpusEncoder.cs create mode 100644 dotnet/src/VoiceCat.Codec/OpusException.cs create mode 100644 dotnet/src/VoiceCat.Codec/OpusOptions.cs create mode 100644 dotnet/src/VoiceCat.Codec/VoiceCat.Codec.csproj create mode 100644 dotnet/src/VoiceCat.Codec/packages.lock.json create mode 100644 dotnet/src/VoiceCat.Crypto/PasswordHasher.cs create mode 100644 dotnet/src/VoiceCat.Dsp/EnergyVadProcessor.cs create mode 100644 dotnet/src/VoiceCat.Dsp/RnnoiseProcessor.cs create mode 100644 dotnet/src/VoiceCat.Dsp/VoiceCat.Dsp.csproj create mode 100644 dotnet/src/VoiceCat.Dsp/packages.lock.json create mode 100644 dotnet/src/VoiceCat.Server/Data/AccountStore.cs create mode 100644 dotnet/src/VoiceCat.Server/Data/schema.sql create mode 100644 dotnet/src/VoiceCat.Server/Program.cs create mode 100644 dotnet/src/VoiceCat.Server/Transport/TlsControlConnection.cs create mode 100644 dotnet/src/VoiceCat.Server/VoiceCat.Server.csproj create mode 100644 dotnet/src/VoiceCat.Server/VoiceServer.cs create mode 100644 dotnet/src/VoiceCat.Server/packages.lock.json create mode 100644 dotnet/tests/VoiceCat.Tests/AccountStoreTests.cs create mode 100644 dotnet/tests/VoiceCat.Tests/CodecTests.cs create mode 100644 dotnet/tests/VoiceCat.Tests/DspTests.cs create mode 100644 dotnet/tests/VoiceCat.Tests/Fixtures/cpp-noise.json create mode 100644 dotnet/tests/VoiceCat.Tests/Fixtures/cpp-passwords.json create mode 100644 dotnet/tests/VoiceCat.Tests/MediaAllocationTests.cs create mode 100644 dotnet/tests/VoiceCat.Tests/PasswordTests.cs create mode 100644 dotnet/tests/VoiceCat.Tests/ServerTests.cs diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index dd41afe..8d962bd 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -2,9 +2,9 @@ name: .NET port on: push: - paths: ['dotnet/**', 'core/**', 'server/**', 'tests/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml'] + paths: ['dotnet/**', 'core/**', 'server/**', 'tests/**', 'third_party/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml'] pull_request: - paths: ['dotnet/**', 'core/**', 'server/**', 'tests/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml'] + paths: ['dotnet/**', 'core/**', 'server/**', 'tests/**', 'third_party/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml'] workflow_dispatch: jobs: @@ -21,6 +21,9 @@ jobs: 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 @@ -52,5 +55,10 @@ jobs: 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" dotnet test dotnet/VoiceCat.slnx -c Release --no-restore + VOICECAT_TLS_ORACLE="$PWD/build/dev/bin/voicecat-dotnet-tls-oracle" VOICECAT_DATABASE_ORACLE="$PWD/build/dev/bin/voicecat-dotnet-database-oracle" VOICECAT_VCCLI="$PWD/build/dev/bin/vccli" dotnet test dotnet/VoiceCat.slnx -c Release --no-restore diff --git a/.gitignore b/.gitignore index 2b83c0e..9fc810f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /dotnet/**/bin/ /dotnet/**/obj/ /dotnet/**/TestResults/ +/dotnet/artifacts/ /build/ /out/ diff --git a/CLAUDE.md b/CLAUDE.md index 110038b..77f7d11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). > 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 native clients (Swift on macOS/iOS, C# on Windows) and the server. @@ -25,10 +25,11 @@ native clients (Swift on macOS/iOS, C# on Windows) and the server. ## Build & test commands -The .NET rewrite lives under `dotnet/`. Build and test its initial wire/crypto slice +The .NET rewrite lives under `dotnet/`. Build and test its wire/crypto, TLS, codec/DSP, and initial control 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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 658b11c..b5affa6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,6 +49,11 @@ endif() # ── Targets ─────────────────────────────────────────────────────────────────── 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) diff --git a/PROGRESS.md b/PROGRESS.md index 099a988..1233dc7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,6 +10,139 @@ up instantly. Newest status at the top. ## ▶ Where we left off / next action +- **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 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. +- **Server 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 +dotnet test dotnet/VoiceCat.slnx -c Release --no-restore +./dotnet/check-licenses.ps1 +``` + +Last results: **142/142 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. + +**Next behavior to implement:** read `docs/protocol.md`, `docs/security.md` and the +existing UDP binding/relay/session handlers. Write a real-client test for authenticated +UDP binding and encrypted relay between two clients, preserving SSRC/timestamp/flags +and encoded Opus while resealing with each recipient's directional key/counter. +Then implement binding tokens, stream announce/stop/subscription and channel routing. +Reject unauthenticated/replayed media and verify channel/subscription isolation. +Never decode audio on the SFU. Phase 4 exits only when two existing C++ clients can +exchange voice through the managed server as well as authenticate, join and chat. + +- **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 diff --git a/docs/api-dotnet.md b/docs/api-dotnet.md index 24fde33..1e97f72 100644 --- a/docs/api-dotnet.md +++ b/docs/api-dotnet.md @@ -115,3 +115,99 @@ On Unix new files use owner read/write permissions; Windows inherits directory A 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, Span)` 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, 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)` 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; completed TLS connections have a 60-second receive-idle timeout. + +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 channel +joins and all admin/moderation handlers are pending. No UDP port or media features +are advertised; voice subscription explicitly fails until the SFU is implemented. + +`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. Account provisioning currently uses this API or the +existing native administration path; 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. diff --git a/docs/building.md b/docs/building.md index 1e2a837..2558571 100644 --- a/docs/building.md +++ b/docs/building.md @@ -2,9 +2,11 @@ ## .NET rewrite -The initial managed wire/crypto slice is under `dotnet/`, targeting .NET 10. From the root: +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 diff --git a/docs/porting-to-dotnet.md b/docs/porting-to-dotnet.md index 1ea9c2e..615a5d3 100644 --- a/docs/porting-to-dotnet.md +++ b/docs/porting-to-dotnet.md @@ -1,8 +1,10 @@ # Porting VoiceCat to pure .NET / C# **Status:** wire/media crypto and TLS/exporter foundations implemented under `dotnet/`, -including C++ interoperability, persisted TOFU, and compatible server credentials. -Codec/audio, managed server/client state, and UI phases remain planned. +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; UDP relay, 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. **Scope:** replace the C++ core (`libvoicecat`), the C++ server, the C++ `vccli`, and the @@ -233,7 +235,7 @@ has been carrying. | TLS 1.3 | mbedTLS | **BouncyCastle `Org.BouncyCastle.Tls`** | MIT | See §3. **Not `SslStream`.** | | Media AEAD | libsodium ChaCha20-Poly1305 | **`System.Security.Cryptography.ChaCha20Poly1305`** | built-in | ⚠️ Check `ChaCha20Poly1305.IsSupported` at startup — it is OS-backed (Windows 10 1903+ / OpenSSL 1.1+). Fall back to BouncyCastle's `ChaCha20Poly1305` if false. Same 12-byte nonce, 16-byte tag → identical wire bytes. | | Anti-replay window | hand-rolled 64-bit | port verbatim | — | ~40 lines. Keep the RFC 3711 §3.3 ordering (replay-check → authenticate → *then* advance). This ordering is load-bearing; `test_media_aead.cpp` covers it. | -| Argon2id | libsodium `crypto_pwhash` | **`Konscious.Security.Cryptography.Argon2`** | MIT | Pure managed. ⚠️ **Existing password hashes will not verify** — libsodium emits `$argon2id$...` PHC strings with its own tuned m/t/p. Either implement a PHC-string parser and feed those params to Konscious (doable, recommended), or force a password reset on migration. Decide early; `db.cpp` migration depends on it. | +| 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. | | 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). | @@ -243,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. | | 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`. ``. 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. | | 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. | @@ -356,11 +358,10 @@ internal static partial int opus_encode(IntPtr st, ReadOnlySpan pcm, int Span data, int maxDataBytes); ``` -- `opus_encoder_ctl` is **varargs** — P/Invoke cannot do C varargs portably. Declare one - overload per argument shape (`int`, `out int`) with `EntryPoint = "opus_encoder_ctl"`. This - works on all the ABIs we target (x64 SysV, x64 Win, arm64 AAPCS) because all the CTLs we use - take a single `int`/`int*`. **Note this explicitly in code comments** — it's a real - portability caveat if a future CTL takes a different shape. +- `opus_encoder_ctl` is **varargs** — P/Invoke cannot do C varargs portably. The implemented + desktop binding uses fixed C entry points in `dotnet/native/media.c`; C calls the + varargs function with the correct ABI. This also handles Apple arm64's different + varargs calling convention. Only whitelisted single-int controls are accepted. - DRED (`opus_dred_alloc`, `opus_dred_parse`, `opus_decoder_dred_decode`) binds the same way. Guard with a runtime feature check as `opus_codec.cpp` does today. - **iOS requires static linking**: use `[LibraryImport("__Internal")]` and link @@ -700,6 +701,19 @@ interoperability test command. **Exit criterion:** encode→decode round-trip at every supported frame size; DRED recovery 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) @@ -707,6 +721,18 @@ 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 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. UDP voice, streams, protected joins, moderation, +admin handlers and production configuration remain pending. The exit criterion +below has not yet been met. + 1. `VoiceCat.Server`: accept loop, `ConnSession` protocol handling, session registry. 2. `Db` on `Microsoft.Data.Sqlite` — same schema. **Resolve the Argon2id hash-compat question here** (§4). diff --git a/dotnet/Directory.Build.targets b/dotnet/Directory.Build.targets new file mode 100644 index 0000000..14835ff --- /dev/null +++ b/dotnet/Directory.Build.targets @@ -0,0 +1,13 @@ + + + $(RuntimeIdentifier) + $(NETCoreSdkRuntimeIdentifier) + $(MSBuildThisFileDirectory)artifacts/native/runtimes/$(VoiceCatNativeRid)/native + + + + + + + + diff --git a/dotnet/README.md b/dotnet/README.md index 61dfecb..35a4afd 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -1,10 +1,41 @@ # VoiceCat .NET rewrite The first slice targets .NET 10: protobuf, control framing, voice headers, and media -encryption, TLS 1.3, persisted TOFU pins, and server credentials. Server/client state, -audio, and UI migration are next. The existing +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 @@ -47,6 +78,16 @@ 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. @@ -65,7 +106,36 @@ managed TLS loopback, rejection, persistence, and wire tests still run. CI's C++ conformance job requires the native test. See `docs/api-dotnet.md` for ownership and certificate acceptance requirements. -## Next checkpoint +## Managed server checkpoint -Port codec/DSP wrappers and their native packaging per Phase 3 of the porting plan. -The managed server follows, tested first with the existing C++ CLI. +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. + +Phase 4 remains in progress: UDP/SFU relay, streams, protected channel joins, +administration, moderation, and production configuration are the next server work. diff --git a/dotnet/VoiceCat.slnx b/dotnet/VoiceCat.slnx index 112203a..9d42d0f 100644 --- a/dotnet/VoiceCat.slnx +++ b/dotnet/VoiceCat.slnx @@ -2,6 +2,9 @@ + + + diff --git a/dotnet/build-native.ps1 b/dotnet/build-native.ps1 new file mode 100644 index 0000000..3bb9ee6 --- /dev/null +++ b/dotnet/build-native.ps1 @@ -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" } diff --git a/dotnet/check-licenses.ps1 b/dotnet/check-licenses.ps1 index e141b0f..88d80f8 100644 --- a/dotnet/check-licenses.ps1 +++ b/dotnet/check-licenses.ps1 @@ -20,6 +20,10 @@ foreach ($lockPath in (Get-ChildItem -LiteralPath $PSScriptRoot -Filter packages [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 } diff --git a/dotnet/compare-dsp-fixtures.ps1 b/dotnet/compare-dsp-fixtures.ps1 new file mode 100644 index 0000000..7cd7356 --- /dev/null +++ b/dotnet/compare-dsp-fixtures.ps1 @@ -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.' diff --git a/dotnet/native/CMakeLists.txt b/dotnet/native/CMakeLists.txt new file mode 100644 index 0000000..7737494 --- /dev/null +++ b/dotnet/native/CMakeLists.txt @@ -0,0 +1,101 @@ +cmake_minimum_required(VERSION 3.24) +project(VoiceCatMedia LANGUAGES C) + +if(MSVC) + set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$: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() diff --git a/dotnet/native/NOTICE.txt b/dotnet/native/NOTICE.txt new file mode 100644 index 0000000..f1c7426 --- /dev/null +++ b/dotnet/native/NOTICE.txt @@ -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/ diff --git a/dotnet/native/media.c b/dotnet/native/media.c new file mode 100644 index 0000000..5f46040 --- /dev/null +++ b/dotnet/native/media.c @@ -0,0 +1,55 @@ +#include +#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); +} diff --git a/dotnet/oracle/CMakeLists.txt b/dotnet/oracle/CMakeLists.txt index cc9e7bc..6050f93 100644 --- a/dotnet/oracle/CMakeLists.txt +++ b/dotnet/oracle/CMakeLists.txt @@ -7,3 +7,19 @@ 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-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() diff --git a/dotnet/oracle/database.cpp b/dotnet/oracle/database.cpp new file mode 100644 index 0000000..11b1b38 --- /dev/null +++ b/dotnet/oracle/database.cpp @@ -0,0 +1,25 @@ +#include "db.h" +#include + +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") { + 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; +} diff --git a/dotnet/oracle/dsp.cpp b/dotnet/oracle/dsp.cpp new file mode 100644 index 0000000..43a177e --- /dev/null +++ b/dotnet/oracle/dsp.cpp @@ -0,0 +1,30 @@ +#include "audio/apm_processor.h" + +#include +#include +#include + +int main(int argc, char **argv) { + if (argc != 2) return 2; + auto processor = voicecat::audio::ApmProcessor::create(); + if (!processor) return 1; + std::vector 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(static_cast(random % 6001) - 3000); + } + if (!processor->process_capture(pcm.data(), static_cast(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; +} diff --git a/dotnet/oracle/passwords.cpp b/dotnet/oracle/passwords.cpp new file mode 100644 index 0000000..b0d6105 --- /dev/null +++ b/dotnet/oracle/passwords.cpp @@ -0,0 +1,29 @@ +#include +#include +#include +#include + +static std::string base64(const unsigned char *data, size_t length) { + std::array 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 passwords{"voicecat test", "caf\xc3\xa9", std::string("a\0b", 3)}; + std::array salt{}; + for (size_t i = 0; i < salt.size(); ++i) salt[i] = static_cast(i); + for (size_t i = 0; i < passwords.size(); ++i) { + std::array 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(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; +} diff --git a/dotnet/src/VoiceCat.Codec/NativeHandles.cs b/dotnet/src/VoiceCat.Codec/NativeHandles.cs new file mode 100644 index 0000000..e3c97b9 --- /dev/null +++ b/dotnet/src/VoiceCat.Codec/NativeHandles.cs @@ -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; } +} diff --git a/dotnet/src/VoiceCat.Codec/NativeMethods.cs b/dotnet/src/VoiceCat.Codec/NativeMethods.cs new file mode 100644 index 0000000..e292aa0 --- /dev/null +++ b/dotnet/src/VoiceCat.Codec/NativeMethods.cs @@ -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); +} diff --git a/dotnet/src/VoiceCat.Codec/OpusDecoder.cs b/dotnet/src/VoiceCat.Codec/OpusDecoder.cs new file mode 100644 index 0000000..e9caaf6 --- /dev/null +++ b/dotnet/src/VoiceCat.Codec/OpusDecoder.cs @@ -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 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 packet, Span 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(); +} diff --git a/dotnet/src/VoiceCat.Codec/OpusDeepRedundancy.cs b/dotnet/src/VoiceCat.Codec/OpusDeepRedundancy.cs new file mode 100644 index 0000000..102fe3e --- /dev/null +++ b/dotnet/src/VoiceCat.Codec/OpusDeepRedundancy.cs @@ -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 nextPacket, Span 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(); } +} diff --git a/dotnet/src/VoiceCat.Codec/OpusEncoder.cs b/dotnet/src/VoiceCat.Codec/OpusEncoder.cs new file mode 100644 index 0000000..afe8a29 --- /dev/null +++ b/dotnet/src/VoiceCat.Codec/OpusEncoder.cs @@ -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 pcm, Span 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(); +} diff --git a/dotnet/src/VoiceCat.Codec/OpusException.cs b/dotnet/src/VoiceCat.Codec/OpusException.cs new file mode 100644 index 0000000..6e2c4bd --- /dev/null +++ b/dotnet/src/VoiceCat.Codec/OpusException.cs @@ -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; +} diff --git a/dotnet/src/VoiceCat.Codec/OpusOptions.cs b/dotnet/src/VoiceCat.Codec/OpusOptions.cs new file mode 100644 index 0000000..66ba6b1 --- /dev/null +++ b/dotnet/src/VoiceCat.Codec/OpusOptions.cs @@ -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); + } +} diff --git a/dotnet/src/VoiceCat.Codec/VoiceCat.Codec.csproj b/dotnet/src/VoiceCat.Codec/VoiceCat.Codec.csproj new file mode 100644 index 0000000..caf1d4e --- /dev/null +++ b/dotnet/src/VoiceCat.Codec/VoiceCat.Codec.csproj @@ -0,0 +1,5 @@ + + + true + + diff --git a/dotnet/src/VoiceCat.Codec/packages.lock.json b/dotnet/src/VoiceCat.Codec/packages.lock.json new file mode 100644 index 0000000..4a91a8c --- /dev/null +++ b/dotnet/src/VoiceCat.Codec/packages.lock.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "dependencies": { + "net10.0": {} + } +} \ No newline at end of file diff --git a/dotnet/src/VoiceCat.Crypto/PasswordHasher.cs b/dotnet/src/VoiceCat.Crypto/PasswordHasher.cs new file mode 100644 index 0000000..ed16b0c --- /dev/null +++ b/dotnet/src/VoiceCat.Crypto/PasswordHasher.cs @@ -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; + } +} diff --git a/dotnet/src/VoiceCat.Dsp/EnergyVadProcessor.cs b/dotnet/src/VoiceCat.Dsp/EnergyVadProcessor.cs new file mode 100644 index 0000000..ccf2cc9 --- /dev/null +++ b/dotnet/src/VoiceCat.Dsp/EnergyVadProcessor.cs @@ -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 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; + } +} diff --git a/dotnet/src/VoiceCat.Dsp/RnnoiseProcessor.cs b/dotnet/src/VoiceCat.Dsp/RnnoiseProcessor.cs new file mode 100644 index 0000000..2b50558 --- /dev/null +++ b/dotnet/src/VoiceCat.Dsp/RnnoiseProcessor.cs @@ -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 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; } + } +} diff --git a/dotnet/src/VoiceCat.Dsp/VoiceCat.Dsp.csproj b/dotnet/src/VoiceCat.Dsp/VoiceCat.Dsp.csproj new file mode 100644 index 0000000..caf1d4e --- /dev/null +++ b/dotnet/src/VoiceCat.Dsp/VoiceCat.Dsp.csproj @@ -0,0 +1,5 @@ + + + true + + diff --git a/dotnet/src/VoiceCat.Dsp/packages.lock.json b/dotnet/src/VoiceCat.Dsp/packages.lock.json new file mode 100644 index 0000000..4a91a8c --- /dev/null +++ b/dotnet/src/VoiceCat.Dsp/packages.lock.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "dependencies": { + "net10.0": {} + } +} \ No newline at end of file diff --git a/dotnet/src/VoiceCat.Server/Data/AccountStore.cs b/dotnet/src/VoiceCat.Server/Data/AccountStore.cs new file mode 100644 index 0000000..449975c --- /dev/null +++ b/dotnet/src/VoiceCat.Server/Data/AccountStore.cs @@ -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 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 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 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 PasswordWorkAsync(Func 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 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(); + 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; + } +} diff --git a/dotnet/src/VoiceCat.Server/Data/schema.sql b/dotnet/src/VoiceCat.Server/Data/schema.sql new file mode 100644 index 0000000..f5a0113 --- /dev/null +++ b/dotnet/src/VoiceCat.Server/Data/schema.sql @@ -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); diff --git a/dotnet/src/VoiceCat.Server/Program.cs b/dotnet/src/VoiceCat.Server/Program.cs new file mode 100644 index 0000000..dc7913d --- /dev/null +++ b/dotnet/src/VoiceCat.Server/Program.cs @@ -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) { } diff --git a/dotnet/src/VoiceCat.Server/Transport/TlsControlConnection.cs b/dotnet/src/VoiceCat.Server/Transport/TlsControlConnection.cs new file mode 100644 index 0000000..64804ca --- /dev/null +++ b/dotnet/src/VoiceCat.Server/Transport/TlsControlConnection.cs @@ -0,0 +1,169 @@ +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 outgoing = System.Threading.Channels.Channel.CreateBounded(64); + private readonly Channel incoming = System.Threading.Channels.Channel.CreateBounded(32); + private readonly byte[] prefix = new byte[4]; + private int prefixBytes; + private byte[]? payload; + private int payloadBytes; + + public Task Completion { get; } + public CancellationToken CancellationToken => lifetime.Token; + + internal TlsControlConnection(Socket socket, TlsSession tls, CancellationToken cancellationToken) + { + this.socket = socket; + this.tls = tls; + lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + lifetime.CancelAfter(TimeSpan.FromSeconds(15)); + Completion = RunAsync(); + } + + public IAsyncEnumerable 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(); + ControlFraming.WriteEnvelope(framed, envelope); + if (outgoing.Writer.TryWrite(framed.WrittenSpan.ToArray())) return true; + lifetime.Cancel(); + return false; + } + + public void CompleteWrites() => outgoing.Writer.TryComplete(); + + private async Task RunAsync() + { + byte[] ciphertext = new byte[16384]; + byte[] plaintext = new byte[16384]; + byte[] sendBuffer = new byte[16384]; + CancellationToken cancellationToken = lifetime.Token; + Task? receive = null; + Task? 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) lifetime.CancelAfter(TimeSpan.FromSeconds(60)); + 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 + { + 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 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(); + await Completion.ConfigureAwait(false); + lifetime.Dispose(); + } +} diff --git a/dotnet/src/VoiceCat.Server/VoiceCat.Server.csproj b/dotnet/src/VoiceCat.Server/VoiceCat.Server.csproj new file mode 100644 index 0000000..b1fe16a --- /dev/null +++ b/dotnet/src/VoiceCat.Server/VoiceCat.Server.csproj @@ -0,0 +1,13 @@ + + + Exe + + + + + + + + + + diff --git a/dotnet/src/VoiceCat.Server/VoiceServer.cs b/dotnet/src/VoiceCat.Server/VoiceServer.cs new file mode 100644 index 0000000..858886d --- /dev/null +++ b/dotnet/src/VoiceCat.Server/VoiceServer.cs @@ -0,0 +1,265 @@ +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 class VoiceServer : IAsyncDisposable +{ + private readonly Socket listener; + private readonly ServerCredentials credentials; + private readonly AccountStore accounts; + private readonly IReadOnlyList channels; + private readonly bool allowGuests; + private readonly string name; + private readonly CancellationTokenSource shutdown = new(); + private readonly object gate = new(); + private readonly Dictionary sessions = []; + private readonly List connections = []; + private ulong nextSession; + private uint nextUser; + private readonly Task accepting; + private int disposed; + + public IPEndPoint EndPoint => (IPEndPoint)listener.LocalEndPoint!; + public event Action? ConnectionFailed; + + public VoiceServer(string directory, IPEndPoint endpoint, bool allowGuests = true, string name = "VoiceCat Server") + { + this.allowGuests = allowGuests; + this.name = name; + credentials = ServerCredentials.LoadOrCreate(directory, name); + try + { + accounts = new AccountStore(Path.Combine(directory, "voicecat.db")); + channels = accounts.LoadChannels(); + listener = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + listener.Bind(endpoint); + listener.Listen(64); + } + catch + { + listener?.Dispose(); + accounts?.Dispose(); + credentials.Dispose(); + shutdown.Dispose(); + throw; + } + accepting = AcceptAsync(); + } + + private async Task AcceptAsync() + { + try + { + while (!shutdown.IsCancellationRequested) + { + Socket socket = await listener.AcceptAsync(shutdown.Token).ConfigureAwait(false); + lock (gate) + { + if (sessions.Count >= 64) { socket.Dispose(); continue; } + socket.NoDelay = true; + string address = ((IPEndPoint)socket.RemoteEndPoint!).Address.ToString(); + var connection = new TlsControlConnection(socket, credentials.CreateTlsSession(), shutdown.Token); + var session = new Session(++nextSession, connection, address); + 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 (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; + } + var hello = new ServerHello { ProtoVersion = 2, ServerName = name, ServerVersion = "0.1.0-dotnet", 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); break; + case Envelope.BodyOneofCase.SubscribeVoice: + session.Connection.TrySend(new() { RequestId = envelope.RequestId, VoiceSubscriptionResult = new() { Error = "Managed media relay is not implemented yet." } }); + 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 not null) Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Left, LeftId = session.User.Id } }); + } + 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 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) + { + 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.Connection.TrySend(new() { RequestId = requestId, AuthResult = new() + { + Ok = true, SessionId = session.Id, Self = user.Clone(), + Permissions = new() { IsAdmin = admin, CanAdminAccounts = admin, CanBan = admin, CanKick = admin, CanMoveUsers = admin, CanCreateTempChannel = admin } + } }); + 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) + { + lock (gate) + { + var channel = channels.FirstOrDefault(candidate => candidate.Id == channelId); + if (channel is null || channel.PasswordProtected || 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; + } + session.User!.ChannelId = channelId; + 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 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 accepting.ConfigureAwait(false); + Task[] pending; + lock (gate) pending = connections.ToArray(); + await Task.WhenAll(pending).ConfigureAwait(false); + } + finally + { + accounts.Dispose(); + credentials.Dispose(); + shutdown.Dispose(); + } + } + + private sealed class Session(ulong id, TlsControlConnection connection, string address) + { + public ulong Id { get; } = id; + public TlsControlConnection Connection { get; } = connection; + public string Address { get; } = address; + public bool HelloReceived { get; set; } + public User? User { get; set; } + } +} diff --git a/dotnet/src/VoiceCat.Server/packages.lock.json b/dotnet/src/VoiceCat.Server/packages.lock.json new file mode 100644 index 0000000..ac771d3 --- /dev/null +++ b/dotnet/src/VoiceCat.Server/packages.lock.json @@ -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, )" + } + } + } + } +} \ No newline at end of file diff --git a/dotnet/tests/VoiceCat.Tests/AccountStoreTests.cs b/dotnet/tests/VoiceCat.Tests/AccountStoreTests.cs new file mode 100644 index 0000000..04ed28a --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/AccountStoreTests.cs @@ -0,0 +1,101 @@ +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(() => 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(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(); } } + } + + 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(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); } + } +} diff --git a/dotnet/tests/VoiceCat.Tests/CodecTests.cs b/dotnet/tests/VoiceCat.Tests/CodecTests.cs new file mode 100644 index 0000000..2d7af89 --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/CodecTests.cs @@ -0,0 +1,123 @@ +using VoiceCat.Codec; + +namespace VoiceCat.Tests; + +public sealed class CodecTests +{ + public static IEnumerable 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(() => new OpusEncoder(new() { Channels = 3 })); + Assert.Throws(() => new OpusEncoder(new() { FrameDurationMilliseconds = 30 })); + using var encoder = new OpusEncoder(); + using var decoder = new OpusDecoder(); + Assert.Throws(() => encoder.Encode(new short[959], new byte[4000])); + Assert.Throws(() => decoder.Decode([], new short[959], 960)); + encoder.Dispose(); + Assert.Throws(() => 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(() => new OpusEncoder(new() { DeepRedundancy = true })); + Assert.Throws(() => 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(() => 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 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 pcm) + { + double sum = 0; + foreach (short value in pcm) sum += (double)value * value; + return Math.Sqrt(sum / pcm.Length); + } +} diff --git a/dotnet/tests/VoiceCat.Tests/DspTests.cs b/dotnet/tests/VoiceCat.Tests/DspTests.cs new file mode 100644 index 0000000..357f635 --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/DspTests.cs @@ -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(() => processor.Process(new short[481])); + processor.Dispose(); + Assert.Throws(() => 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(() => processor.Threshold = float.NaN); + } + + internal static void FillNoise(Span 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; + } +} diff --git a/dotnet/tests/VoiceCat.Tests/Fixtures/cpp-noise.json b/dotnet/tests/VoiceCat.Tests/Fixtures/cpp-noise.json new file mode 100644 index 0000000..3bcb348 --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/Fixtures/cpp-noise.json @@ -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]} diff --git a/dotnet/tests/VoiceCat.Tests/Fixtures/cpp-passwords.json b/dotnet/tests/VoiceCat.Tests/Fixtures/cpp-passwords.json new file mode 100644 index 0000000..dab9f7e --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/Fixtures/cpp-passwords.json @@ -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"}]} diff --git a/dotnet/tests/VoiceCat.Tests/MediaAllocationTests.cs b/dotnet/tests/VoiceCat.Tests/MediaAllocationTests.cs new file mode 100644 index 0000000..7396ef7 --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/MediaAllocationTests.cs @@ -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); + } +} diff --git a/dotnet/tests/VoiceCat.Tests/PasswordTests.cs b/dotnet/tests/VoiceCat.Tests/PasswordTests.cs new file mode 100644 index 0000000..c5decab --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/PasswordTests.cs @@ -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)); +} diff --git a/dotnet/tests/VoiceCat.Tests/ServerTests.cs b/dotnet/tests/VoiceCat.Tests/ServerTests.cs new file mode 100644 index 0000000..8e344c9 --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/ServerTests.cs @@ -0,0 +1,194 @@ +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 output = process.StandardOutput.ReadToEndAsync(); + Task 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."; + } + } + + private 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) + { + System.IO.Directory.CreateDirectory(Directory); + Server = new(Directory, new(IPAddress.Loopback, 0), guests); + using var credentials = ServerCredentials.LoadOrCreate(Directory, "VoiceCat Server"); + fingerprint = credentials.CertificateFingerprint; + } + public async Task 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); + } + } + + private sealed class Client : IAsyncDisposable + { + public CancellationTokenSource Timeout { get; } = new(TimeSpan.FromSeconds(30)); + private readonly TlsControlConnection connection; + private readonly IAsyncEnumerator 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 async Task ReadUntilAsync(Func predicate) + { + while (await messages.MoveNextAsync()) if (predicate(messages.Current)) return messages.Current; + throw new IOException("Connection ended before the expected message."); + } + public async Task 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; + 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(); + } + } +} diff --git a/dotnet/tests/VoiceCat.Tests/VoiceCat.Tests.csproj b/dotnet/tests/VoiceCat.Tests/VoiceCat.Tests.csproj index c4dcb3c..ee028f4 100644 --- a/dotnet/tests/VoiceCat.Tests/VoiceCat.Tests.csproj +++ b/dotnet/tests/VoiceCat.Tests/VoiceCat.Tests.csproj @@ -9,6 +9,9 @@ + + + diff --git a/dotnet/tests/VoiceCat.Tests/packages.lock.json b/dotnet/tests/VoiceCat.Tests/packages.lock.json index 19678e3..efa26be 100644 --- a/dotnet/tests/VoiceCat.Tests/packages.lock.json +++ b/dotnet/tests/VoiceCat.Tests/packages.lock.json @@ -44,6 +44,14 @@ "resolved": "17.14.1", "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" }, + "Microsoft.Data.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "jFYXnh7s0RShCw6Vkf+ReGCw+mVi7ISg1YaEzYCJcXnUifmbW+aqvCsRJuSRj2ZuQ+oqetpjxlZtbpMmk5FKqQ==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", "resolved": "17.14.1", @@ -63,6 +71,41 @@ "resolved": "13.0.3", "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" }, + "SourceGear.sqlite3": { + "type": "Transitive", + "resolved": "3.50.4.2", + "contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g==" + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "3.0.2", + "contentHash": "nzPPFpELY9U1scLvQpA1k1GIgR9ror83DCPmirT2/i5NCPdTBfhTDA6MZqFZonGDayye5mUQRQLOVyEiJNYr0g==", + "dependencies": { + "SQLitePCLRaw.config.e_sqlite3": "3.0.2", + "SourceGear.sqlite3": "3.50.4.2" + } + }, + "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" + } + }, "xunit.abstractions": { "type": "Transitive", "resolved": "2.0.3", @@ -103,6 +146,9 @@ "xunit.extensibility.core": "[2.9.3]" } }, + "voicecat.codec": { + "type": "Project" + }, "voicecat.crypto": { "type": "Project", "dependencies": { @@ -110,11 +156,23 @@ "VoiceCat.Protocol": "[1.0.0, )" } }, + "voicecat.dsp": { + "type": "Project" + }, "voicecat.protocol": { "type": "Project", "dependencies": { "Google.Protobuf": "[3.36.1, )" } + }, + "voicecat.server": { + "type": "Project", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "[10.0.5, )", + "SQLitePCLRaw.bundle_e_sqlite3": "[3.0.2, )", + "SourceGear.sqlite3": "[3.50.4.2, )", + "VoiceCat.Crypto": "[1.0.0, )" + } } } }