Retire legacy sources and verify managed iOS deployment
This commit is contained in:
+46
-166
@@ -1,182 +1,62 @@
|
||||
# VoiceCat .NET rewrite
|
||||
# VoiceCat .NET implementation
|
||||
|
||||
The .NET 10 implementation includes protocol, TLS/TOFU and media crypto, the control and
|
||||
UDP server, client state, real-time audio, a console client and the Windows application.
|
||||
The existing C++ implementation remains the conformance oracle while Apple clients move.
|
||||
This is the supported VoiceCat implementation. It contains the protocol, TLS and media
|
||||
cryptography, server, client state, codec/DSP bindings, audio engine, and headless CLI.
|
||||
|
||||
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):
|
||||
## Build and test
|
||||
|
||||
Stage the small native Opus/RNNoise library, then build the managed solution:
|
||||
|
||||
```powershell
|
||||
./dotnet/build-native.ps1
|
||||
```
|
||||
|
||||
The script downloads upstream Opus 1.5.2 with a pinned SHA-256, builds DRED-enabled
|
||||
Opus and the existing vendored RNNoise model, and stages `voicecat_media` plus license
|
||||
notices under `dotnet/artifacts/native/`. It builds independently of the C++ core and
|
||||
vcpkg. On Windows, Visual Studio's C++ workload works with the default generator;
|
||||
for this repository's MinGW toolchain use:
|
||||
|
||||
```powershell
|
||||
./dotnet/build-native.ps1 -Generator Ninja -CCompiler C:/tools/msys64/ucrt64/bin/cc.exe
|
||||
```
|
||||
|
||||
Linux/macOS can run the same script with PowerShell, or use CMake directly:
|
||||
|
||||
```sh
|
||||
cmake -S dotnet/native -B dotnet/artifacts/native-build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build dotnet/artifacts/native-build --target voicecat_media --parallel 2
|
||||
cmake --install dotnet/artifacts/native-build --component DotnetMedia --prefix dotnet/artifacts/native
|
||||
```
|
||||
|
||||
MSBuild copies the staged library into managed build/publish output for the selected
|
||||
RID. Override `VoiceCatNativeRid` or `VoiceCatNativeDirectory` for explicit staging;
|
||||
`RuntimeIdentifier` takes priority over the SDK's host RID. Cross-compilation is not
|
||||
automatic. iOS static linking and audio-device shims belong to later client phases.
|
||||
Native codec/DSP tests require this library; they do not silently skip.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```powershell
|
||||
dotnet restore dotnet/VoiceCat.slnx --locked-mode
|
||||
dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-build
|
||||
./dotnet/check-licenses.ps1
|
||||
```
|
||||
|
||||
Run the managed console client interactively:
|
||||
The native source lives in `native/media` and `native/rnnoise`; build output is staged under
|
||||
`dotnet/artifacts/native`. The shim exposes only fixed Opus/DRED and RNNoise entry points. It
|
||||
does not contain protocol, networking, cryptography, client state, or server behavior.
|
||||
|
||||
For an explicit native build:
|
||||
|
||||
```bash
|
||||
cmake -S native/media -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
|
||||
```
|
||||
|
||||
## Projects
|
||||
|
||||
- `VoiceCat.Protocol` — generated protobuf types and bounded control framing.
|
||||
- `VoiceCat.Crypto` — BouncyCastle TLS 1.3/exporters, TOFU, identity, AEAD, replay protection,
|
||||
and Argon2id.
|
||||
- `VoiceCat.Codec` / `VoiceCat.Dsp` — managed owners of the narrow native media ABI.
|
||||
- `VoiceCat.Audio` — jitter, loss recovery, mixing, input activation, and PCM rings.
|
||||
- `VoiceCat.Core` — managed client connection and session state.
|
||||
- `VoiceCat.Server` — TLS control, encrypted UDP relay, SQLite state, administration, and CLI.
|
||||
- `VoiceCat.Cli` — supported interactive and deterministic headless client.
|
||||
- `VoiceCat.Tests` — managed unit, integration, allocation, and end-to-end behavior tests.
|
||||
|
||||
`proto/voicecat.proto` is the only protobuf schema. Generated C# is build output.
|
||||
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
dotnet run --project dotnet/src/VoiceCat.Cli -- --host 127.0.0.1 --port 8384 --nickname Alice --trust-first
|
||||
dotnet run --project dotnet/src/VoiceCat.Server -- --data-dir ./voicecat-data
|
||||
dotnet run --project dotnet/src/VoiceCat.Cli -- \
|
||||
--host 127.0.0.1 --port 8384 --nickname Alice --trust-first
|
||||
```
|
||||
|
||||
Plain input sends channel text; `/join ID` changes channel and `/quit` exits. Headless
|
||||
conformance options include `--voice`, `--send-text`, `--expect-text`, `--expect-voice`,
|
||||
`--start-delay-ms` and `--timeout-seconds`; `--help` lists the complete syntax.
|
||||
Use `--help` on either executable for current options. Server publishing is handled by
|
||||
`dotnet/publish-server.ps1` and the platform packaging files under `packaging/`.
|
||||
|
||||
Dependencies are pinned in project files and lock files. Generated protobuf is build
|
||||
output; the schema remains `core/proto/voicecat.proto`. Production dependencies are
|
||||
Google.Protobuf (BSD-3-Clause), BouncyCastle.Cryptography (MIT), and the build-only
|
||||
Grpc.Tools (Apache-2.0). No GPL/LGPL dependencies are permitted.
|
||||
## Compatibility policy
|
||||
|
||||
## C# conventions
|
||||
|
||||
Use file-scoped namespaces, standard .NET naming, immutable values where useful, and
|
||||
spans for binary data. Invalid arguments throw; invalid network packets use parsing
|
||||
results or protocol exceptions. Async APIs accept cancellation tokens.
|
||||
|
||||
Comments explain constraints that cannot be made clear in code. Avoid banners,
|
||||
implementation history, and narration. Keep durable design explanations in `docs/`.
|
||||
|
||||
## Regenerating C++ fixtures
|
||||
|
||||
The optional oracle target calls the existing C++ protobuf, header serializer, and
|
||||
libsodium media implementation. From the root, with the development dependencies:
|
||||
|
||||
```powershell
|
||||
cmake --preset dev -DVOICECAT_BUILD_DOTNET_ORACLE=ON
|
||||
cmake --build --preset dev --target voicecat-dotnet-oracle
|
||||
New-Item -ItemType Directory -Force dotnet/tests/VoiceCat.Tests/Fixtures
|
||||
./build/dev/bin/voicecat-dotnet-oracle.exe dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json
|
||||
git diff -- dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json
|
||||
```
|
||||
|
||||
On Linux/macOS, omit `.exe` and create the directory with `mkdir -p`.
|
||||
The oracle writes deterministic JSON directly, avoiding shell output encoding.
|
||||
Fixtures contain a framed ClientHello and media packets at counters 0, 1, 65535,
|
||||
and 65536. Keys contain bytes 0–31; payload bytes count upward from zero. The
|
||||
20-byte header has type 1, marker flag, codec 0, SSRC `0xcafebabe`, timestamp 960.
|
||||
Both managed crypto backends must match these bytes.
|
||||
|
||||
The DSP oracle calls the existing C++ `ApmProcessor` with 200 deterministic noise
|
||||
frames and records the final 960 samples. Regenerate its fixture with:
|
||||
|
||||
```powershell
|
||||
cmake --build --preset dev --target voicecat-dotnet-dsp-oracle
|
||||
./build/dev/bin/voicecat-dotnet-dsp-oracle.exe dotnet/tests/VoiceCat.Tests/Fixtures/cpp-noise.json
|
||||
```
|
||||
|
||||
The managed test allows a one-unit PCM difference for floating-point rounding.
|
||||
|
||||
## TLS interoperability
|
||||
|
||||
The optional TLS oracle uses the existing mbedTLS context and libsodium media crypto.
|
||||
The test authenticates an encrypted challenge in both directions, proving exporter
|
||||
compatibility without sending raw keys. It also loads the C++ server's credential files.
|
||||
|
||||
```powershell
|
||||
cmake --build --preset dev --target voicecat-dotnet-tls-oracle
|
||||
$env:VOICECAT_TLS_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-tls-oracle.exe).Path
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
```
|
||||
|
||||
On Linux/macOS, set `VOICECAT_TLS_ORACLE` to the absolute executable path without
|
||||
`.exe`. Without that variable, only this native interoperability test is skipped;
|
||||
managed TLS loopback, rejection, persistence, and wire tests still run. CI's C++
|
||||
conformance job requires the native test. See `docs/api-dotnet.md` for ownership
|
||||
and certificate acceptance requirements.
|
||||
|
||||
## Managed server checkpoint
|
||||
|
||||
Run the TLS control server on loopback (optional arguments: data directory, TCP port):
|
||||
|
||||
```powershell
|
||||
dotnet run --project dotnet/src/VoiceCat.Server -c Release -- ./voicecat-data 7443
|
||||
./build/dev/bin/vccli.exe --host 127.0.0.1 --port 7443 --nick Guest --text "hello"
|
||||
```
|
||||
|
||||
It creates or imports `server_identity.key`, `server.crt`, `server.key`, and
|
||||
`voicecat.db`. An empty channel table gets Lobby and Music Room; existing channels
|
||||
are preserved. Guests are enabled by the CLI; hosting `VoiceServer` directly can
|
||||
disable them. Existing accounts authenticate without resetting passwords. Account
|
||||
creation is currently available through `AccountStore`; bootstrap/admin CLI and
|
||||
wire administration are pending.
|
||||
|
||||
Tests cover real TLS sockets, authentication retries, snapshots, channel moves,
|
||||
text routing, sender attribution, ping, and disconnect events. Enable native checks:
|
||||
|
||||
```powershell
|
||||
cmake --build --preset dev --target voicecat-dotnet-password-oracle voicecat-dotnet-database-oracle vccli
|
||||
$env:VOICECAT_DATABASE_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-database-oracle.exe).Path
|
||||
$env:VOICECAT_VCCLI = (Resolve-Path build/dev/bin/vccli.exe).Path
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
```
|
||||
|
||||
The database oracle creates an account/channel using the shipped C++ database code;
|
||||
managed code imports and authenticates it, then C++ authenticates a managed-created
|
||||
account. CI also regenerates the libsodium password fixture. Native checks require
|
||||
the optional `VOICECAT_BUILD_DOTNET_ORACLE=ON` configure flag and a real-deps build.
|
||||
|
||||
The server also advertises UDP on the TCP port number, supports voice subscription
|
||||
and stream signaling, and reseals encoded audio for subscribers in the same channel.
|
||||
UDP binding fixes the first endpoint for the session; reconnect after endpoint changes.
|
||||
Protected joins, administration, moderation and production configuration remain
|
||||
before Phase 4 completion. The server's media-aware reaper defaults to 45 seconds
|
||||
of inactivity with a 15-second sweep. Parsed control envelopes, valid encrypted
|
||||
voice and keepalives from bound endpoints refresh activity; invalid media does not.
|
||||
`VoiceServerOptions` configures timeouts and capacity; zero idle timeout disables
|
||||
reaping. The constructor overload accepts `TimeProvider` for deterministic expiry tests.
|
||||
|
||||
Enable deterministic native voice interoperability (no audio hardware required):
|
||||
|
||||
```powershell
|
||||
cmake --build --preset dev --target voicecat-dotnet-voice-oracle
|
||||
$env:VOICECAT_VOICE_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-voice-oracle.exe).Path
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
```
|
||||
|
||||
Two existing C++ clients authenticate, join Lobby or Music Room, publish three
|
||||
concurrent streams, feed PCM, and verify decoded energy and metadata in both directions.
|
||||
The native clients use external capture/playback to avoid device dependencies in CI.
|
||||
`MediaFanoutTests` separately verifies 50-subscriber routing/resealing without managed
|
||||
allocations after warm-up and reports throughput; socket scheduling is excluded.
|
||||
The transport load test delivers all 2,500 recipient packets from a paced 50 pps sender.
|
||||
Native `vccli --test-tone-ms 4000` runs finite external capture/playback, feeds a tone,
|
||||
and fails without decoded remote audio. Tests start two CLI processes in mono/stereo
|
||||
channels and also verify channel text. Normal `--voice` now explicitly subscribes before
|
||||
announcing its microphone stream. No C ABI or wire changes were needed.
|
||||
|
||||
The managed CLI also accepts `--test-tone-seconds N` (maximum 3600) for a finite, accurately
|
||||
paced peer used during device checks. Use a channel with Opus DTX disabled, such as the seeded
|
||||
Music Room: a steady sine is intentionally classified as non-speech by DTX and becomes comfort
|
||||
noise after its hangover period in Lobby. The finite peer prints received voice energy and exits
|
||||
normally when its requested duration completes.
|
||||
The managed implementation is the source of truth. Frozen vectors under
|
||||
`tests/VoiceCat.Tests/Fixtures` protect concrete wire, Argon2id, and RNNoise behavior, but the
|
||||
repository no longer builds or tests against the retired C++ implementation. Protocol or
|
||||
persistence changes must be versioned when current supported releases need migration; they do
|
||||
not need to retain compatibility with unsupported pre-rewrite releases.
|
||||
|
||||
@@ -10,7 +10,7 @@ build_one() {
|
||||
local compiler
|
||||
compiler=$(xcrun --sdk "$sdk" --find clang)
|
||||
local build_dir="$script_dir/artifacts/native-build-$rid-cmake"
|
||||
cmake -S "$script_dir/native" -B "$build_dir" \
|
||||
cmake -S "$script_dir/../native/media" -B "$build_dir" \
|
||||
-DCMAKE_SYSTEM_NAME=iOS \
|
||||
-DCMAKE_C_COMPILER="$compiler" \
|
||||
-DCMAKE_OSX_SYSROOT="$sdk_path" \
|
||||
|
||||
@@ -6,7 +6,7 @@ param(
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$configure = @('-S', "$PSScriptRoot/native", '-B', $BuildDirectory,
|
||||
$configure = @('-S', "$PSScriptRoot/../native/media", '-B', $BuildDirectory,
|
||||
'-DCMAKE_BUILD_TYPE=Release', "-DVOICECAT_DOTNET_RID=$RuntimeIdentifier")
|
||||
if ($Generator) { $configure += @('-G', $Generator) }
|
||||
if ($CCompiler) { $configure += "-DCMAKE_C_COMPILER=$CCompiler" }
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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.'
|
||||
@@ -1,103 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(VoiceCatMedia LANGUAGES C)
|
||||
|
||||
if(MSVC)
|
||||
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
set(OPUS_STATIC_RUNTIME ON CACHE BOOL "" FORCE)
|
||||
endif()
|
||||
|
||||
set(VOICECAT_MEDIA_LIBRARY_TYPE SHARED)
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "iOS")
|
||||
set(VOICECAT_MEDIA_LIBRARY_TYPE STATIC)
|
||||
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 ${VOICECAT_MEDIA_LIBRARY_TYPE} 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
|
||||
ARCHIVE 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()
|
||||
@@ -1,12 +0,0 @@
|
||||
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/
|
||||
@@ -1,55 +0,0 @@
|
||||
#include <opus.h>
|
||||
#include "rnnoise.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#define VC_EXPORT __declspec(dllexport)
|
||||
#else
|
||||
#define VC_EXPORT __attribute__((visibility("default")))
|
||||
#endif
|
||||
|
||||
VC_EXPORT const char *vcm_opus_version(void) { return opus_get_version_string(); }
|
||||
VC_EXPORT const char *vcm_opus_error(int error) { return opus_strerror(error); }
|
||||
VC_EXPORT OpusEncoder *vcm_encoder_create(int rate, int channels, int application, int *error) {
|
||||
return opus_encoder_create(rate, channels, application, error);
|
||||
}
|
||||
VC_EXPORT void vcm_encoder_destroy(OpusEncoder *encoder) { opus_encoder_destroy(encoder); }
|
||||
/* C varargs are called here, not through P/Invoke: Apple arm64 uses a distinct varargs ABI. */
|
||||
VC_EXPORT int vcm_encoder_set(OpusEncoder *encoder, int request, int value) {
|
||||
switch (request) {
|
||||
case OPUS_SET_BITRATE_REQUEST: case OPUS_SET_MAX_BANDWIDTH_REQUEST:
|
||||
case OPUS_SET_COMPLEXITY_REQUEST: case OPUS_SET_INBAND_FEC_REQUEST:
|
||||
case OPUS_SET_DTX_REQUEST: case OPUS_SET_PACKET_LOSS_PERC_REQUEST:
|
||||
case OPUS_SET_DRED_DURATION_REQUEST:
|
||||
return opus_encoder_ctl(encoder, request, value);
|
||||
default: return OPUS_BAD_ARG;
|
||||
}
|
||||
}
|
||||
VC_EXPORT int vcm_encoder_get_dred(OpusEncoder *encoder, int *duration) {
|
||||
return opus_encoder_ctl(encoder, OPUS_GET_DRED_DURATION(duration));
|
||||
}
|
||||
VC_EXPORT int vcm_encode(OpusEncoder *encoder, const short *pcm, int samples, unsigned char *packet, int capacity) {
|
||||
return opus_encode(encoder, pcm, samples, packet, capacity);
|
||||
}
|
||||
VC_EXPORT OpusDecoder *vcm_decoder_create(int rate, int channels, int *error) {
|
||||
return opus_decoder_create(rate, channels, error);
|
||||
}
|
||||
VC_EXPORT void vcm_decoder_destroy(OpusDecoder *decoder) { opus_decoder_destroy(decoder); }
|
||||
VC_EXPORT int vcm_decode(OpusDecoder *decoder, const unsigned char *packet, int length, short *pcm, int samples, int fec) {
|
||||
return opus_decode(decoder, packet, length, pcm, samples, fec);
|
||||
}
|
||||
VC_EXPORT OpusDREDDecoder *vcm_dred_decoder_create(int *error) { return opus_dred_decoder_create(error); }
|
||||
VC_EXPORT void vcm_dred_decoder_destroy(OpusDREDDecoder *decoder) { opus_dred_decoder_destroy(decoder); }
|
||||
VC_EXPORT OpusDRED *vcm_dred_create(int *error) { return opus_dred_alloc(error); }
|
||||
VC_EXPORT void vcm_dred_destroy(OpusDRED *dred) { opus_dred_free(dred); }
|
||||
VC_EXPORT int vcm_dred_parse(OpusDREDDecoder *decoder, OpusDRED *dred, const unsigned char *packet,
|
||||
int length, int samples, int rate, int *end) {
|
||||
return opus_dred_parse(decoder, dred, packet, length, samples, rate, end, 0);
|
||||
}
|
||||
VC_EXPORT int vcm_dred_decode(OpusDecoder *decoder, OpusDRED *dred, int offset, short *pcm, int samples) {
|
||||
return opus_decoder_dred_decode(decoder, dred, offset, pcm, samples);
|
||||
}
|
||||
VC_EXPORT DenoiseState *vcm_rnnoise_create(void) { return rnnoise_create(NULL); }
|
||||
VC_EXPORT void vcm_rnnoise_destroy(DenoiseState *state) { rnnoise_destroy(state); }
|
||||
VC_EXPORT float vcm_rnnoise_process(DenoiseState *state, float *output, const float *input) {
|
||||
return rnnoise_process_frame(state, output, input);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
add_executable(voicecat-dotnet-oracle main.cpp)
|
||||
target_link_libraries(voicecat-dotnet-oracle PRIVATE voicecat::voicecat)
|
||||
target_include_directories(voicecat-dotnet-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
|
||||
target_compile_features(voicecat-dotnet-oracle PRIVATE cxx_std_20)
|
||||
|
||||
add_executable(voicecat-dotnet-tls-oracle tls.cpp)
|
||||
target_link_libraries(voicecat-dotnet-tls-oracle PRIVATE voicecat::voicecat)
|
||||
target_include_directories(voicecat-dotnet-tls-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
|
||||
target_compile_features(voicecat-dotnet-tls-oracle PRIVATE cxx_std_20)
|
||||
|
||||
add_executable(voicecat-dotnet-voice-oracle voice.cpp)
|
||||
target_link_libraries(voicecat-dotnet-voice-oracle PRIVATE voicecat::voicecat)
|
||||
target_compile_features(voicecat-dotnet-voice-oracle PRIVATE cxx_std_20)
|
||||
|
||||
add_executable(voicecat-dotnet-dsp-oracle dsp.cpp)
|
||||
target_link_libraries(voicecat-dotnet-dsp-oracle PRIVATE voicecat::voicecat)
|
||||
target_include_directories(voicecat-dotnet-dsp-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
|
||||
target_compile_features(voicecat-dotnet-dsp-oracle PRIVATE cxx_std_20)
|
||||
|
||||
find_package(unofficial-sodium CONFIG REQUIRED)
|
||||
add_executable(voicecat-dotnet-password-oracle passwords.cpp)
|
||||
target_link_libraries(voicecat-dotnet-password-oracle PRIVATE unofficial-sodium::sodium)
|
||||
target_compile_features(voicecat-dotnet-password-oracle PRIVATE cxx_std_20)
|
||||
|
||||
if(VOICECAT_BUILD_SERVER)
|
||||
add_executable(voicecat-dotnet-database-oracle database.cpp)
|
||||
target_link_libraries(voicecat-dotnet-database-oracle PRIVATE voicecat::server)
|
||||
target_compile_features(voicecat-dotnet-database-oracle PRIVATE cxx_std_20)
|
||||
endif()
|
||||
@@ -1,41 +0,0 @@
|
||||
#include "db.h"
|
||||
#include <string>
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 3) return 1;
|
||||
voicecat::server::Database database(argv[2]);
|
||||
std::string error;
|
||||
if (!database.open(error)) return 1;
|
||||
if (std::string(argv[1]) == "create-protected") {
|
||||
voicecat::server::ChannelRecord channel;
|
||||
channel.name = "Native protected";
|
||||
channel.audio.set_sample_rate(48000);
|
||||
channel.audio.set_bitrate_bps(24000);
|
||||
channel.audio.set_frame_ms(20);
|
||||
return database.create_channel(channel, "channel password", error) ? 0 : 1;
|
||||
}
|
||||
if (std::string(argv[1]) == "verify-protected") {
|
||||
for (const auto& channel : database.list_channels()) {
|
||||
if (channel.name == "Managed protected")
|
||||
return database.check_channel_password(channel.id, "channel password") &&
|
||||
!database.check_channel_password(channel.id, "wrong") ? 0 : 1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
if (std::string(argv[1]) == "create") {
|
||||
if (!database.create_account("legacy", "legacy password", true, error)) return 1;
|
||||
voicecat::server::ChannelRecord lobby;
|
||||
lobby.name = "Lobby";
|
||||
lobby.topic = "Preserved native topic";
|
||||
lobby.max_users = 7;
|
||||
lobby.audio.set_sample_rate(48000);
|
||||
lobby.audio.set_bitrate_bps(32000);
|
||||
lobby.audio.set_frame_ms(20);
|
||||
return database.create_channel(lobby, "", error) ? 0 : 1;
|
||||
}
|
||||
if (std::string(argv[1]) == "verify") {
|
||||
auto account = database.authenticate("managed", "managed password");
|
||||
return account && account->is_admin ? 0 : 1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
#include "audio/apm_processor.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <vector>
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) return 2;
|
||||
auto processor = voicecat::audio::ApmProcessor::create();
|
||||
if (!processor) return 1;
|
||||
std::vector<int16_t> pcm(960);
|
||||
uint32_t random = 0x12345678;
|
||||
for (int frame = 0; frame < 200; ++frame) {
|
||||
for (auto &sample : pcm) {
|
||||
random ^= random << 13;
|
||||
random ^= random >> 17;
|
||||
random ^= random << 5;
|
||||
sample = static_cast<int16_t>(static_cast<int>(random % 6001) - 3000);
|
||||
}
|
||||
if (!processor->process_capture(pcm.data(), static_cast<int>(pcm.size()), 48000)) return 1;
|
||||
}
|
||||
std::ofstream output(argv[1]);
|
||||
output << "{\"samples\":[";
|
||||
for (size_t i = 0; i < pcm.size(); ++i) {
|
||||
if (i) output << ',';
|
||||
output << pcm[i];
|
||||
}
|
||||
output << "]}\n";
|
||||
return output ? 0 : 1;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
#include "crypto/crypto.h"
|
||||
#include "net/voice_frame.h"
|
||||
#include "protocol/envelope.h"
|
||||
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
|
||||
static std::string hex(const std::vector<uint8_t>& bytes) {
|
||||
std::ostringstream result;
|
||||
result << std::hex << std::setfill('0');
|
||||
for (auto byte : bytes) result << std::setw(2) << unsigned(byte);
|
||||
return result.str();
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 2 || sodium_init() < 0) return 1;
|
||||
std::ofstream output(argv[1], std::ios::binary);
|
||||
if (!output) return 1;
|
||||
voicecat::v1::Envelope envelope;
|
||||
envelope.set_request_id(42);
|
||||
auto* hello = envelope.mutable_client_hello();
|
||||
hello->set_proto_version(1);
|
||||
hello->set_client_name("test-client");
|
||||
hello->set_client_version("0.0.1");
|
||||
hello->add_features("text");
|
||||
std::vector<uint8_t> framed;
|
||||
if (!voicecat::protocol::encode_envelope(envelope, framed)) return 1;
|
||||
output << "{\n \"envelope\": \"" << hex(framed) << "\",\n \"media\": [\n";
|
||||
std::array<uint8_t, 32> key{};
|
||||
for (size_t i = 0; i < key.size(); ++i) key[i] = uint8_t(i);
|
||||
voicecat::crypto::SodiumMediaCrypto sender(key.data());
|
||||
for (uint64_t sequence = 0; sequence <= 65536; ++sequence) {
|
||||
voicecat::net::VoiceFrame header;
|
||||
header.flags = voicecat::net::kFlagMarker;
|
||||
header.ssrc = 0xcafebabe;
|
||||
header.seq = sender.peek_send_counter();
|
||||
header.timestamp = 960;
|
||||
const size_t length = sequence == 0 ? 0 : sequence == 1 ? 100 : 8;
|
||||
std::vector<uint8_t> plaintext(length);
|
||||
for (size_t i = 0; i < length; ++i) plaintext[i] = uint8_t(i);
|
||||
std::vector<uint8_t> packet(voicecat::net::kVoiceHeaderSize + length + 16);
|
||||
voicecat::net::serialize_header(header, packet.data());
|
||||
if (sender.seal(plaintext.data(), length, packet.data(), 20, packet.data() + 20, length + 16) < 0) return 1;
|
||||
if (sequence == 0 || sequence == 1 || sequence == 65535 || sequence == 65536) {
|
||||
if (sequence != 0) output << ",\n";
|
||||
output << " {\"sequence\": " << sequence << ", \"key\": \""
|
||||
<< hex(std::vector<uint8_t>(key.begin(), key.end()))
|
||||
<< "\", \"plaintext\": \"" << hex(plaintext)
|
||||
<< "\", \"packet\": \"" << hex(packet) << "\"}";
|
||||
}
|
||||
}
|
||||
output << "\n ]\n}\n";
|
||||
return output ? 0 : 1;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
#include <sodium.h>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <array>
|
||||
|
||||
static std::string base64(const unsigned char *data, size_t length) {
|
||||
std::array<char, 128> output{};
|
||||
sodium_bin2base64(output.data(), output.size(), data, length, sodium_base64_VARIANT_ORIGINAL_NO_PADDING);
|
||||
return output.data();
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2 || sodium_init() < 0) return 1;
|
||||
std::ofstream output(argv[1]);
|
||||
output << "{\"hashes\":[";
|
||||
const std::array<std::string, 3> passwords{"voicecat test", "caf\xc3\xa9", std::string("a\0b", 3)};
|
||||
std::array<unsigned char, 16> salt{};
|
||||
for (size_t i = 0; i < salt.size(); ++i) salt[i] = static_cast<unsigned char>(i);
|
||||
for (size_t i = 0; i < passwords.size(); ++i) {
|
||||
std::array<unsigned char, 32> hash{};
|
||||
if (crypto_pwhash(hash.data(), hash.size(), passwords[i].data(), passwords[i].size(), salt.data(), 2,
|
||||
64 * 1024 * 1024, crypto_pwhash_ALG_ARGON2ID13) != 0) return 1;
|
||||
if (i) output << ',';
|
||||
output << "{\"passwordBase64\":\"" << base64(reinterpret_cast<const unsigned char *>(passwords[i].data()), passwords[i].size())
|
||||
<< "\",\"hash\":\"$argon2id$v=19$m=65536,t=2,p=1$" << base64(salt.data(), salt.size()) << '$' << base64(hash.data(), hash.size()) << "\"}";
|
||||
}
|
||||
output << "]}\n";
|
||||
return output ? 0 : 1;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
#ifdef _WIN32
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
using socket_type = SOCKET;
|
||||
static void close_socket(socket_type socket) { closesocket(socket); }
|
||||
#else
|
||||
#include <arpa/inet.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
using socket_type = int;
|
||||
static void close_socket(socket_type socket) { close(socket); }
|
||||
#endif
|
||||
|
||||
#include "crypto/crypto.h"
|
||||
#include "net/voice_frame.h"
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
|
||||
static bool transfer(voicecat::crypto::TlsContext& tls, uint8_t* data, size_t size, bool writing) {
|
||||
while (size != 0) {
|
||||
int count = writing ? tls.write(data, size) : tls.read(data, size);
|
||||
if (count <= 0) return false;
|
||||
data += count;
|
||||
size -= count;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 2 || sodium_init() < 0) return 1;
|
||||
#ifdef _WIN32
|
||||
WSADATA data{};
|
||||
if (WSAStartup(MAKEWORD(2, 2), &data) != 0) return 1;
|
||||
#endif
|
||||
try {
|
||||
auto certificate = voicecat::crypto::ServerCert::generate("dotnet-tls-oracle");
|
||||
auto directory = std::filesystem::path(argv[1]);
|
||||
socket_type listener = socket(AF_INET, SOCK_STREAM, 0);
|
||||
sockaddr_in address{};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
if (bind(listener, reinterpret_cast<sockaddr*>(&address), sizeof(address)) != 0 || listen(listener, 1) != 0) return 1;
|
||||
socklen_t length = sizeof(address);
|
||||
if (getsockname(listener, reinterpret_cast<sockaddr*>(&address), &length) != 0) return 1;
|
||||
certificate.save(directory / "server.crt", directory / "server.key");
|
||||
voicecat::crypto::ServerIdentity::generate().save(directory / "identity.key");
|
||||
std::ofstream(directory / "port.txt") << ntohs(address.sin_port);
|
||||
socket_type peer = accept(listener, nullptr, nullptr);
|
||||
close_socket(listener);
|
||||
if (peer == static_cast<socket_type>(-1)) return 1;
|
||||
voicecat::crypto::TlsContext tls(voicecat::crypto::TlsContext::Role::Server, &certificate);
|
||||
tls.set_read_timeout(10000);
|
||||
std::string error;
|
||||
if (!tls.handshake(static_cast<int>(peer), error)) { std::cerr << error; return 1; }
|
||||
auto sender = voicecat::crypto::SodiumMediaCrypto::derive_send(tls, false);
|
||||
auto receiver = voicecat::crypto::SodiumMediaCrypto::derive_recv(tls, false);
|
||||
if (!sender || !receiver) return 1;
|
||||
voicecat::net::VoiceFrame header;
|
||||
header.ssrc = 42;
|
||||
header.seq = sender->peek_send_counter();
|
||||
std::array<uint8_t, 41> packet{};
|
||||
voicecat::net::serialize_header(header, packet.data());
|
||||
const std::array<uint8_t, 5> message{ 'h', 'e', 'l', 'l', 'o' };
|
||||
if (sender->seal(message.data(), message.size(), packet.data(), 20, packet.data() + 20, 21) != 21) return 1;
|
||||
if (!transfer(tls, packet.data(), packet.size(), true) || !transfer(tls, packet.data(), packet.size(), false)) return 1;
|
||||
std::array<uint8_t, 5> recovered{};
|
||||
if (receiver->open(packet.data() + 20, 21, packet.data(), 20, recovered.data(), recovered.size()) != 5 || recovered != message) return 1;
|
||||
uint8_t acknowledgement = 1;
|
||||
if (!transfer(tls, &acknowledgement, 1, true)) return 1;
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
#include "voicecat.h"
|
||||
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
struct ClientState {
|
||||
vc_client* client = nullptr;
|
||||
std::mutex gate;
|
||||
std::condition_variable changed;
|
||||
bool authenticated = false;
|
||||
bool subscribed = false;
|
||||
bool joined = false;
|
||||
uint32_t user = 0;
|
||||
std::vector<std::pair<uint32_t, uint32_t>> streams;
|
||||
std::array<int, 3> received{};
|
||||
long long energy = 0;
|
||||
uint32_t channels = 0;
|
||||
};
|
||||
|
||||
static void event(void* context, const vc_event* value) {
|
||||
auto& state = *static_cast<ClientState*>(context);
|
||||
if (value->type == VC_EVENT_SERVER_IDENTITY) {
|
||||
vc_confirm_server_identity(state.client, 1);
|
||||
return;
|
||||
}
|
||||
std::lock_guard lock(state.gate);
|
||||
switch (value->type) {
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
state.authenticated = value->result == VC_OK;
|
||||
state.user = value->user_id;
|
||||
break;
|
||||
case VC_EVENT_VOICE_STATE: state.subscribed = value->u32a == 1; break;
|
||||
case VC_EVENT_JOIN_RESULT: state.joined = value->result == VC_OK; break;
|
||||
case VC_EVENT_STREAM_STARTED: state.streams.emplace_back(value->user_id, value->stream_id); break;
|
||||
default: break;
|
||||
}
|
||||
state.changed.notify_all();
|
||||
}
|
||||
|
||||
static void sink(void* context, uint32_t, uint32_t stream, const int16_t* pcm,
|
||||
size_t samples, uint32_t channels, uint32_t rate) {
|
||||
auto& state = *static_cast<ClientState*>(context);
|
||||
if (rate != 48000 || stream >= state.received.size()) return;
|
||||
std::lock_guard lock(state.gate);
|
||||
++state.received[stream];
|
||||
state.channels = channels;
|
||||
for (size_t index = 0; index < samples * channels; ++index) state.energy += std::abs(static_cast<int>(pcm[index]));
|
||||
state.changed.notify_all();
|
||||
}
|
||||
|
||||
template<class Predicate>
|
||||
static bool wait(ClientState& state, Predicate predicate) {
|
||||
std::unique_lock lock(state.gate);
|
||||
return state.changed.wait_for(lock, std::chrono::seconds(8), predicate);
|
||||
}
|
||||
|
||||
struct Destroy {
|
||||
void operator()(vc_client* client) const { vc_disconnect(client); vc_client_destroy(client); }
|
||||
};
|
||||
using Client = std::unique_ptr<vc_client, Destroy>;
|
||||
|
||||
static Client connect(ClientState& state, uint16_t port, uint32_t channel, const char* nickname) {
|
||||
vc_config config{"dotnet-voice-oracle", "1", VC_LOG_OFF};
|
||||
Client client(vc_client_create(&config, {event, nullptr, &state}));
|
||||
state.client = client.get();
|
||||
if (!client || vc_set_external_playback(client.get(), 1) != VC_OK ||
|
||||
vc_connect(client.get(), "127.0.0.1", port) != VC_OK ||
|
||||
vc_authenticate_guest(client.get(), nickname) != VC_OK ||
|
||||
!wait(state, [&] { return state.authenticated; }) ||
|
||||
vc_join_channel(client.get(), channel, nullptr) != VC_OK ||
|
||||
!wait(state, [&] { return state.joined; }) ||
|
||||
vc_join_voice(client.get()) != VC_OK ||
|
||||
!wait(state, [&] { return state.subscribed; }) ||
|
||||
vc_set_pcm_sink(client.get(), sink, &state) != VC_OK) return {};
|
||||
return client;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 3) return 1;
|
||||
uint16_t port = static_cast<uint16_t>(std::strtoul(argv[1], nullptr, 10));
|
||||
uint32_t channel = static_cast<uint32_t>(std::strtoul(argv[2], nullptr, 10));
|
||||
ClientState alice, bob;
|
||||
Client a = connect(alice, port, channel, "Native Alice");
|
||||
Client b = connect(bob, port, channel, "Native Bob");
|
||||
if (!a || !b) { std::fprintf(stderr, "native authentication/join/subscription failed\n"); return 1; }
|
||||
std::array<uint32_t, 3> ids{};
|
||||
vc_stream_desc mic{};
|
||||
mic.kind = VC_STREAM_MIC;
|
||||
mic.external_feed = 1;
|
||||
vc_stream_desc screen = mic;
|
||||
screen.kind = VC_STREAM_SCREEN_AUDIO;
|
||||
if (vc_stream_start(a.get(), &mic, &ids[0]) != VC_OK ||
|
||||
vc_stream_start(a.get(), &screen, &ids[1]) != VC_OK ||
|
||||
vc_stream_start(b.get(), &mic, &ids[2]) != VC_OK ||
|
||||
!wait(alice, [&] { return alice.streams.size() >= 3; }) ||
|
||||
!wait(bob, [&] { return bob.streams.size() >= 3; })) {
|
||||
std::fprintf(stderr, "native stream signaling failed\n"); return 1;
|
||||
}
|
||||
uint32_t channels = channel == 2 ? 2 : 1;
|
||||
std::vector<int16_t> pcm(960 * channels);
|
||||
for (size_t sample = 0; sample < 960; ++sample)
|
||||
for (uint32_t side = 0; side < channels; ++side)
|
||||
pcm[sample * channels + side] = static_cast<int16_t>(12000 * std::sin(sample * (side == 0 ? 0.058 : 0.083)));
|
||||
for (int frame = 0; frame < 100; ++frame) {
|
||||
if (vc_stream_feed_pcm(a.get(), ids[0], pcm.data(), 960, channels) != VC_OK ||
|
||||
vc_stream_feed_pcm(a.get(), ids[1], pcm.data(), 960, channels) != VC_OK ||
|
||||
vc_stream_feed_pcm(b.get(), ids[2], pcm.data(), 960, channels) != VC_OK) return 1;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
bool received = wait(alice, [&] { return alice.received[ids[2]] >= 5 && alice.energy > 0; }) &&
|
||||
wait(bob, [&] { return bob.received[ids[0]] >= 5 && bob.received[ids[1]] >= 5 && bob.energy > 0; });
|
||||
{
|
||||
std::scoped_lock lock(alice.gate, bob.gate);
|
||||
std::printf("channel=%u channels=%u alice=%d bob-mic=%d bob-screen=%d energy=%lld/%lld\n",
|
||||
channel, channels, alice.received[ids[2]], bob.received[ids[0]], bob.received[ids[1]], alice.energy, bob.energy);
|
||||
received = received && alice.channels == channels && bob.channels == channels;
|
||||
}
|
||||
return received ? 0 : 1;
|
||||
}
|
||||
@@ -23,4 +23,4 @@
|
||||
"net10.0/ios-arm64": {},
|
||||
"net10.0/iossimulator-arm64": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,4 @@
|
||||
"net10.0/ios-arm64": {},
|
||||
"net10.0/iossimulator-arm64": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,4 +43,4 @@
|
||||
"net10.0/ios-arm64": {},
|
||||
"net10.0/iossimulator-arm64": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,4 +23,4 @@
|
||||
"net10.0/ios-arm64": {},
|
||||
"net10.0/iossimulator-arm64": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,4 @@
|
||||
"net10.0/ios-arm64": {},
|
||||
"net10.0/iossimulator-arm64": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Google.Protobuf" Version="3.36.1" />
|
||||
<PackageReference Include="Grpc.Tools" Version="2.83.0" PrivateAssets="all" />
|
||||
<Protobuf Include="../../../core/proto/voicecat.proto" GrpcServices="None" />
|
||||
<Protobuf Include="../../../proto/voicecat.proto" GrpcServices="None" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -18,4 +18,4 @@
|
||||
"net10.0/ios-arm64": {},
|
||||
"net10.0/iossimulator-arm64": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using System.Diagnostics;
|
||||
using VoiceCat.Server.Data;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
@@ -27,71 +26,6 @@ public sealed class AccountStoreTests
|
||||
finally { File.Delete(path); File.Delete(path + "-wal"); File.Delete(path + "-shm"); }
|
||||
}
|
||||
|
||||
[NativeDatabaseFact]
|
||||
public async Task ExistingCppDatabaseAndManagedAccountsWorkInBothImplementations()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-import-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
string path = Path.Combine(directory, "voicecat.db");
|
||||
try
|
||||
{
|
||||
await RunOracleAsync("create", path);
|
||||
using (var store = new AccountStore(path))
|
||||
{
|
||||
Account account = Assert.IsType<Account>(await store.AuthenticateAsync("legacy", "legacy password"));
|
||||
Assert.True(account.IsAdmin);
|
||||
var channel = Assert.Single(store.LoadChannels());
|
||||
Assert.Equal("Preserved native topic", channel.Topic);
|
||||
Assert.Equal(7U, channel.MaxUsers);
|
||||
Assert.Equal(32000U, channel.Audio.BitrateBps);
|
||||
await store.CreateAccountAsync("managed", "managed password", true);
|
||||
}
|
||||
await RunOracleAsync("verify", path);
|
||||
}
|
||||
finally { Directory.Delete(directory, true); }
|
||||
}
|
||||
|
||||
private static async Task RunOracleAsync(string mode, string path)
|
||||
{
|
||||
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_DATABASE_ORACLE")!) { UseShellExecute = false, CreateNoWindow = true };
|
||||
start.ArgumentList.Add(mode);
|
||||
start.ArgumentList.Add(path);
|
||||
using var process = Process.Start(start)!;
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
try { await process.WaitForExitAsync(timeout.Token); Assert.Equal(0, process.ExitCode); }
|
||||
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
||||
}
|
||||
|
||||
[NativeDatabaseFact]
|
||||
public async Task ChannelPasswordHashesWorkInBothImplementations()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-channels-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
string path = Path.Combine(directory, "voicecat.db");
|
||||
try
|
||||
{
|
||||
await RunOracleAsync("create-protected", path);
|
||||
using (var store = new AccountStore(path))
|
||||
{
|
||||
var channel = Assert.Single(store.LoadChannels());
|
||||
Assert.True(store.CheckChannelPassword(channel.Id, "channel password"));
|
||||
Assert.False(store.CheckChannelPassword(channel.Id, "wrong"));
|
||||
channel.Name = "Managed protected";
|
||||
store.SaveChannel(channel, "channel password", true);
|
||||
}
|
||||
await RunOracleAsync("verify-protected", path);
|
||||
}
|
||||
finally { Directory.Delete(directory, true); }
|
||||
}
|
||||
|
||||
private sealed class NativeDatabaseFactAttribute : FactAttribute
|
||||
{
|
||||
public NativeDatabaseFactAttribute()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_DATABASE_ORACLE"))) Skip = "Set VOICECAT_DATABASE_ORACLE to the native database oracle.";
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AccountsSurviveRestartAndFailedAuthDoesNotChangeLastLogin()
|
||||
{
|
||||
|
||||
@@ -8,41 +8,6 @@ namespace VoiceCat.Tests;
|
||||
|
||||
public class AdministrationTests
|
||||
{
|
||||
[NativeCliFact]
|
||||
public async Task ExistingCppCliCreatesProtectedChannelsAndAdministersAccounts()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var admin = await AdminAsync(fixture);
|
||||
var start = new System.Diagnostics.ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VCCLI")!)
|
||||
{
|
||||
WorkingDirectory = fixture.Directory, UseShellExecute = false, CreateNoWindow = true,
|
||||
RedirectStandardOutput = true, RedirectStandardError = true
|
||||
};
|
||||
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--username", "Admin", "--password", "secret",
|
||||
"--create-channel", "--new-channel-name", "Native room", "--new-channel-password", "protected", "--create-account", "native", "secret", "--list-accounts", "--wait-ms", "10000" })
|
||||
start.ArgumentList.Add(argument);
|
||||
using var process = System.Diagnostics.Process.Start(start)!;
|
||||
Task<string> stdout = process.StandardOutput.ReadToEndAsync(), stderr = process.StandardError.ReadToEndAsync();
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(admin.Timeout.Token);
|
||||
Assert.True(process.ExitCode == 0, await stdout + await stderr);
|
||||
using var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"));
|
||||
var room = store.LoadChannels().Single(c => c.Name == "Native room");
|
||||
Assert.True(store.CheckChannelPassword(room.Id, "protected"));
|
||||
Assert.NotNull(await store.AuthenticateAsync("native", "secret"));
|
||||
}
|
||||
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
||||
}
|
||||
|
||||
private sealed class NativeCliFactAttribute : FactAttribute
|
||||
{
|
||||
public NativeCliFactAttribute()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI.";
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AccountAdministrationIsPermissionGatedAndPersistsPasswordChanges()
|
||||
{
|
||||
|
||||
@@ -20,7 +20,7 @@ public sealed class DspTests
|
||||
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")));
|
||||
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "rnnoise.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);
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace VoiceCat.Tests;
|
||||
public class GoldenTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnvelopeMatchesCppFixture()
|
||||
public void EnvelopeMatchesCanonicalWireVector()
|
||||
{
|
||||
using var fixture = Load();
|
||||
var expected = Convert.FromHexString(fixture.RootElement.GetProperty("envelope").GetString()!);
|
||||
@@ -23,7 +23,7 @@ public class GoldenTests
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void MediaPacketsMatchCppFixtures(bool managed)
|
||||
public void MediaPacketsMatchCanonicalWireVectors(bool managed)
|
||||
{
|
||||
using var fixture = Load();
|
||||
foreach (var vector in fixture.RootElement.GetProperty("media").EnumerateArray())
|
||||
@@ -46,5 +46,5 @@ public class GoldenTests
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonDocument Load() => JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "cpp-wire.json")));
|
||||
private static JsonDocument Load() => JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "wire.json")));
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ public class IdentityTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TofuRequiresExplicitPinAndPreservesCppFileFormat()
|
||||
public void TofuRequiresExplicitPinAndPreservesFileFormat()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-pins-" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
@@ -5,30 +5,6 @@ namespace VoiceCat.Tests;
|
||||
|
||||
public class ManagedCliTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ManagedCliInteroperatesWithExistingCppCli()
|
||||
{
|
||||
string? nativeCli = Environment.GetEnvironmentVariable("VOICECAT_VCCLI");
|
||||
if (string.IsNullOrEmpty(nativeCli)) return; // Full conformance runs set this explicitly.
|
||||
await using var fixture = new ServerFixture();
|
||||
string cli = Path.Combine(FindRoot(), "dotnet", "src", "VoiceCat.Cli", "bin", "Release", "net10.0", "VoiceCat.Cli.dll");
|
||||
using Process managed = Start(cli, fixture, "Managed", "Managed checkpoint", "Native checkpoint", 2);
|
||||
await Task.Delay(750); // Native vccli sends its one-shot text immediately after auth.
|
||||
var nativeStart = new ProcessStartInfo(nativeCli) { WorkingDirectory = fixture.Directory, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true };
|
||||
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--nick", "Native",
|
||||
"--channel", "2", "--text", "Native checkpoint", "--test-tone-ms", "5000" }) nativeStart.ArgumentList.Add(argument);
|
||||
using Process native = Process.Start(nativeStart)!;
|
||||
Task<string> managedOut = managed.StandardOutput.ReadToEndAsync(), managedError = managed.StandardError.ReadToEndAsync();
|
||||
Task<string> nativeOut = native.StandardOutput.ReadToEndAsync(), nativeError = native.StandardError.ReadToEndAsync();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(35));
|
||||
await Task.WhenAll(managed.WaitForExitAsync(timeout.Token), native.WaitForExitAsync(timeout.Token));
|
||||
string mout = await managedOut, nout = await nativeOut;
|
||||
Assert.True(managed.ExitCode == 0, await managedError + Environment.NewLine + mout);
|
||||
Assert.True(native.ExitCode == 0, await nativeError + Environment.NewLine + nout);
|
||||
Assert.Contains("Native checkpoint", mout); Assert.Contains("Managed checkpoint", nout);
|
||||
Assert.Contains("[test-tone] received=", nout); Assert.DoesNotContain("\"voiceEnergy\":0", mout);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1u)]
|
||||
[InlineData(2u)]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using VoiceCat.Transport;
|
||||
using System.Net;
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using VoiceCat.Protocol;
|
||||
using VoiceCat.Server.Transport;
|
||||
@@ -11,89 +10,6 @@ namespace VoiceCat.Tests;
|
||||
|
||||
public sealed class MediaRelayTests
|
||||
{
|
||||
[CppCliVoiceTheory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public async Task TwoCppCliProcessesJoinChatAndExchangeVoice(int channel)
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var observer = await fixture.ConnectAsync();
|
||||
await observer.LoginAsync("Observer");
|
||||
observer.Send(new() { JoinChannel = new() { ChannelId = checked((uint)channel) } });
|
||||
Assert.True((await observer.ReadUntilAsync(e => e.JoinChannelResult is not null)).JoinChannelResult.Ok);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
await Task.WhenAll(RunAsync("Cli Alice"), RunAsync("Cli Bob"));
|
||||
var first = (await observer.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
|
||||
var second = (await observer.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
|
||||
Assert.Equal("CLI voice checkpoint", first.Body);
|
||||
Assert.Equal(first.Body, second.Body);
|
||||
Assert.NotEqual(first.SenderId, second.SenderId);
|
||||
|
||||
async Task RunAsync(string nickname)
|
||||
{
|
||||
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VCCLI")!)
|
||||
{
|
||||
WorkingDirectory = fixture.Directory, UseShellExecute = false, CreateNoWindow = true,
|
||||
RedirectStandardOutput = true, RedirectStandardError = true
|
||||
};
|
||||
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
"--nick", nickname, "--channel", channel.ToString(System.Globalization.CultureInfo.InvariantCulture), "--text", "CLI voice checkpoint", "--test-tone-ms", "4000" })
|
||||
start.ArgumentList.Add(argument);
|
||||
using var process = Process.Start(start)!;
|
||||
Task<string> stdout = process.StandardOutput.ReadToEndAsync();
|
||||
Task<string> stderr = process.StandardError.ReadToEndAsync();
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
string log = await stdout + await stderr;
|
||||
Assert.True(process.ExitCode == 0, log);
|
||||
Assert.Contains("[test-tone] received=", log);
|
||||
}
|
||||
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CppCliVoiceTheoryAttribute : TheoryAttribute
|
||||
{
|
||||
public CppCliVoiceTheoryAttribute()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI.";
|
||||
}
|
||||
}
|
||||
|
||||
[VoiceOracleTheory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public async Task ExistingCppClientsExchangeBidirectionalVoiceThroughManagedServer(int channel)
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VOICE_ORACLE")!)
|
||||
{
|
||||
WorkingDirectory = fixture.Directory, UseShellExecute = false, CreateNoWindow = true,
|
||||
RedirectStandardOutput = true, RedirectStandardError = true
|
||||
};
|
||||
start.ArgumentList.Add(fixture.Server.EndPoint.Port.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
start.ArgumentList.Add(channel.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
using var process = Process.Start(start)!;
|
||||
Task<string> output = process.StandardOutput.ReadToEndAsync();
|
||||
Task<string> error = process.StandardError.ReadToEndAsync();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(45));
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
Assert.True(process.ExitCode == 0, await output + await error);
|
||||
}
|
||||
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
||||
}
|
||||
|
||||
private sealed class VoiceOracleTheoryAttribute : TheoryAttribute
|
||||
{
|
||||
public VoiceOracleTheoryAttribute()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VOICE_ORACLE"))) Skip = "Set VOICECAT_VOICE_ORACLE to the native voice conformance executable.";
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisconnectInvalidatesBothBindingAndActiveStreams()
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ public sealed class PasswordTests
|
||||
public void VerifiesLibsodiumHashesWithoutPasswordNormalization()
|
||||
{
|
||||
var hasher = new PasswordHasher();
|
||||
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "cpp-passwords.json")));
|
||||
using var fixture = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "argon2id.json")));
|
||||
foreach (var item in fixture.RootElement.GetProperty("hashes").EnumerateArray())
|
||||
{
|
||||
string encodedPassword = item.GetProperty("passwordBase64").GetString()!;
|
||||
|
||||
@@ -95,43 +95,6 @@ public sealed class ServerTests
|
||||
Assert.NotEqual(0U, (await client.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect.Code);
|
||||
}
|
||||
|
||||
[CppCliFact]
|
||||
public async Task ExistingCppCliAuthenticatesAndChatsThroughManagedServer()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var receiver = await fixture.ConnectAsync();
|
||||
User self = await receiver.LoginAsync("Managed");
|
||||
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VCCLI")!)
|
||||
{
|
||||
WorkingDirectory = fixture.Directory, UseShellExecute = false,
|
||||
RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true
|
||||
};
|
||||
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--nick", "Cpp", "--text", "native interoperability", "--wait-ms", "10000" })
|
||||
start.ArgumentList.Add(argument);
|
||||
using var process = Process.Start(start)!;
|
||||
Task<string> output = process.StandardOutput.ReadToEndAsync();
|
||||
Task<string> error = process.StandardError.ReadToEndAsync();
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(receiver.Timeout.Token);
|
||||
string log = await output + await error;
|
||||
Assert.True(process.ExitCode == 0, log);
|
||||
TextMessage text = (await receiver.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
|
||||
Assert.Equal("native interoperability", text.Body);
|
||||
Assert.NotEqual(self.Id, text.SenderId);
|
||||
Assert.Contains("native interoperability", log);
|
||||
}
|
||||
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
||||
}
|
||||
|
||||
private sealed class CppCliFactAttribute : FactAttribute
|
||||
{
|
||||
public CppCliFactAttribute()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI.";
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ServerFixture : IAsyncDisposable
|
||||
{
|
||||
public string Directory { get; } = Path.Combine(Path.GetTempPath(), "voicecat-server-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Protocol;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class TlsInteropTests
|
||||
{
|
||||
[TlsOracleFact]
|
||||
public async Task ManagedClientAndCppServerAgreeOnExporterKeysAndCertificate()
|
||||
{
|
||||
string? oracle = Environment.GetEnvironmentVariable("VOICECAT_TLS_ORACLE");
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-tls-" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(directory);
|
||||
var start = new ProcessStartInfo(oracle!) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true };
|
||||
start.ArgumentList.Add(directory);
|
||||
using var process = Process.Start(start)!;
|
||||
var error = process.StandardError.ReadToEndAsync();
|
||||
var stdout = process.StandardOutput.ReadToEndAsync();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
try
|
||||
{
|
||||
int port = 0;
|
||||
while (!int.TryParse(File.Exists(Path.Combine(directory, "port.txt")) ? await File.ReadAllTextAsync(Path.Combine(directory, "port.txt"), timeout.Token) : "", out port))
|
||||
{
|
||||
Assert.False(process.HasExited, "C++ TLS oracle exited before listening.");
|
||||
await Task.Delay(20, timeout.Token);
|
||||
}
|
||||
using var certificate = X509Certificate2.CreateFromPem(await File.ReadAllTextAsync(Path.Combine(directory, "server.crt"), timeout.Token));
|
||||
string fingerprint = Convert.ToHexString(SHA256.HashData(certificate.RawData));
|
||||
using var credentials = ServerCredentials.LoadOrCreate(directory, "existing C++ identity");
|
||||
Assert.Equal(fingerprint, credentials.CertificateFingerprint);
|
||||
Assert.Equal(32, credentials.Identity.PublicKey.Length);
|
||||
using var client = TlsSession.CreateClient(value => value == fingerprint);
|
||||
using var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync("127.0.0.1", port, timeout.Token);
|
||||
byte[] buffer = new byte[16384];
|
||||
async Task Flush()
|
||||
{
|
||||
while (client.PendingCiphertextBytes > 0)
|
||||
{
|
||||
int count = client.DrainCiphertext(buffer);
|
||||
int sent = 0;
|
||||
while (sent < count) sent += await socket.SendAsync(buffer.AsMemory(sent, count - sent), SocketFlags.None, timeout.Token);
|
||||
}
|
||||
}
|
||||
async Task Receive()
|
||||
{
|
||||
int count = await socket.ReceiveAsync(buffer, SocketFlags.None, timeout.Token);
|
||||
Assert.True(count > 0, "TLS oracle closed unexpectedly.");
|
||||
client.ReceiveCiphertext(buffer.AsSpan(0, count));
|
||||
}
|
||||
while (!client.IsReady) { await Flush(); await Receive(); }
|
||||
await Flush();
|
||||
byte[] packet = new byte[41];
|
||||
int received = 0;
|
||||
while (received < packet.Length)
|
||||
{
|
||||
int count = client.ReadPlaintext(packet.AsSpan(received));
|
||||
received += count;
|
||||
if (count == 0) { await Flush(); await Receive(); }
|
||||
}
|
||||
using var decryptor = client.CreateMediaDecryptor();
|
||||
byte[] plaintext = new byte[5];
|
||||
Assert.True(decryptor.TryDecrypt(packet, plaintext, out var header, out _));
|
||||
Assert.Equal("hello"u8.ToArray(), plaintext);
|
||||
Assert.Equal(fingerprint, client.PeerCertificateFingerprint);
|
||||
using var encryptor = client.CreateMediaEncryptor();
|
||||
encryptor.Encrypt(header, plaintext, packet);
|
||||
client.WritePlaintext(packet);
|
||||
await Flush();
|
||||
byte[] ack = new byte[1];
|
||||
while (client.ReadPlaintext(ack) == 0) { await Flush(); await Receive(); }
|
||||
Assert.Equal(1, ack[0]);
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
Assert.True(process.ExitCode == 0, await error);
|
||||
await stdout;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!process.HasExited) { process.Kill(entireProcessTree: true); await process.WaitForExitAsync(); }
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TlsOracleFactAttribute : FactAttribute
|
||||
{
|
||||
public TlsOracleFactAttribute()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_TLS_ORACLE")))
|
||||
Skip = "Build the native TLS oracle and set VOICECAT_TLS_ORACLE to its executable path.";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user