Start .NET rewrite with wire and media crypto conformance
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s

This commit is contained in:
2026-09-15 17:54:16 +02:00
parent c6c003b8a7
commit b76181d9fb
37 changed files with 1328 additions and 19 deletions
+7
View File
@@ -0,0 +1,7 @@
root = true
[*.cs]
indent_style = space
indent_size = 4
csharp_style_namespace_declarations = file_scoped:warning
dotnet_sort_system_directives_first = true
+10
View File
@@ -0,0 +1,10 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
</Project>
+54
View File
@@ -0,0 +1,54 @@
# VoiceCat .NET rewrite
The first slice targets .NET 10: protobuf, control framing, voice headers, and media
encryption. TLS, server/client state, audio, and UI migration are next. The existing
C++ implementation remains the conformance oracle.
From the repository root:
```powershell
dotnet restore dotnet/VoiceCat.slnx --locked-mode
dotnet build dotnet/VoiceCat.slnx -c Release --no-restore
dotnet test dotnet/VoiceCat.slnx -c Release --no-build
```
Dependencies are pinned in project files and lock files. Generated protobuf is build
output; the schema remains `core/proto/voicecat.proto`. Production dependencies are
Google.Protobuf (BSD-3-Clause), BouncyCastle.Cryptography (MIT), and the build-only
Grpc.Tools (Apache-2.0). No GPL/LGPL dependencies are permitted.
## C# conventions
Use file-scoped namespaces, standard .NET naming, immutable values where useful, and
spans for binary data. Invalid arguments throw; invalid network packets use parsing
results or protocol exceptions. Async APIs accept cancellation tokens.
Comments explain constraints that cannot be made clear in code. Avoid banners,
implementation history, and narration. Keep durable design explanations in `docs/`.
## Regenerating C++ fixtures
The optional oracle target calls the existing C++ protobuf, header serializer, and
libsodium media implementation. From the root, with the development dependencies:
```powershell
cmake --preset dev -DVOICECAT_BUILD_DOTNET_ORACLE=ON
cmake --build --preset dev --target voicecat-dotnet-oracle
New-Item -ItemType Directory -Force dotnet/tests/VoiceCat.Tests/Fixtures
./build/dev/bin/voicecat-dotnet-oracle.exe dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json
git diff -- dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json
```
On Linux/macOS, omit `.exe` and create the directory with `mkdir -p`.
The oracle writes deterministic JSON directly, avoiding shell output encoding.
Fixtures contain a framed ClientHello and media packets at counters 0, 1, 65535,
and 65536. Keys contain bytes 031; 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.
## Next checkpoint
Prove BouncyCastle TLS 1.3 loopback and interoperability with the C++ mbedTLS server,
including exporter label `voicecat media v1`, one-byte direction contexts 0/1,
and TLS leaf certificate fingerprint pinning. Then implement the managed server,
tested first with the existing C++ CLI.
+9
View File
@@ -0,0 +1,9 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
<Project Path="src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/VoiceCat.Tests/VoiceCat.Tests.csproj" />
</Folder>
</Solution>
+30
View File
@@ -0,0 +1,30 @@
$ErrorActionPreference = 'Stop'
$allowed = @('MIT', 'BSD-2-Clause', 'BSD-3-Clause', 'Apache-2.0', 'ISC', '0BSD')
$seen = @{}
foreach ($lockPath in (Get-ChildItem -LiteralPath $PSScriptRoot -Filter packages.lock.json -Recurse)) {
$lock = Get-Content -Raw -LiteralPath $lockPath.FullName | ConvertFrom-Json
$assets = Get-Content -Raw -LiteralPath (Join-Path $lockPath.DirectoryName 'obj/project.assets.json') | ConvertFrom-Json
foreach ($framework in $lock.dependencies.PSObject.Properties) {
foreach ($package in $framework.Value.PSObject.Properties) {
if ($package.Value.type -eq 'Project') { continue }
$id = $package.Name.ToLowerInvariant()
$version = $package.Value.resolved
if ($seen.ContainsKey("$id/$version")) { continue }
$seen["$id/$version"] = $true
$nuspec = $null
foreach ($folder in $assets.packageFolders.PSObject.Properties.Name) {
$candidate = Join-Path $folder "$id/$version/$id.nuspec"
if (Test-Path -LiteralPath $candidate) { $nuspec = $candidate; break }
}
if (!$nuspec) { throw "Restore dependencies before auditing $id/$version." }
[xml]$spec = Get-Content -Raw -LiteralPath $nuspec
$license = $spec.package.metadata.license
if ($license.type -eq 'expression' -and $allowed -contains $license.InnerText) { continue }
# This legacy pinned package predates NuGet license expressions (Apache-2.0).
if ($id -eq 'xunit.abstractions' -and $version -eq '2.0.3' -and
$spec.package.metadata.licenseUrl -eq 'https://raw.githubusercontent.com/xunit/xunit/master/license.txt') { continue }
throw "Unapproved license for $id/$version. Review before changing the allowlist."
}
}
}
Write-Output "Checked $($seen.Count) package licenses: permissive allowlist passed."
+3
View File
@@ -0,0 +1,3 @@
{
"sdk": { "version": "10.0.203", "rollForward": "latestFeature" }
}
+4
View File
@@ -0,0 +1,4 @@
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)
+56
View File
@@ -0,0 +1,56 @@
#include "crypto/crypto.h"
#include "net/voice_frame.h"
#include "protocol/envelope.h"
#include <fstream>
#include <iomanip>
#include <sstream>
#include <stdexcept>
static std::string hex(const std::vector<uint8_t>& bytes) {
std::ostringstream result;
result << std::hex << std::setfill('0');
for (auto byte : bytes) result << std::setw(2) << unsigned(byte);
return result.str();
}
int main(int argc, char** argv) {
if (argc != 2 || sodium_init() < 0) return 1;
std::ofstream output(argv[1], std::ios::binary);
if (!output) return 1;
voicecat::v1::Envelope envelope;
envelope.set_request_id(42);
auto* hello = envelope.mutable_client_hello();
hello->set_proto_version(1);
hello->set_client_name("test-client");
hello->set_client_version("0.0.1");
hello->add_features("text");
std::vector<uint8_t> framed;
if (!voicecat::protocol::encode_envelope(envelope, framed)) return 1;
output << "{\n \"envelope\": \"" << hex(framed) << "\",\n \"media\": [\n";
std::array<uint8_t, 32> key{};
for (size_t i = 0; i < key.size(); ++i) key[i] = uint8_t(i);
voicecat::crypto::SodiumMediaCrypto sender(key.data());
for (uint64_t sequence = 0; sequence <= 65536; ++sequence) {
voicecat::net::VoiceFrame header;
header.flags = voicecat::net::kFlagMarker;
header.ssrc = 0xcafebabe;
header.seq = sender.peek_send_counter();
header.timestamp = 960;
const size_t length = sequence == 0 ? 0 : sequence == 1 ? 100 : 8;
std::vector<uint8_t> plaintext(length);
for (size_t i = 0; i < length; ++i) plaintext[i] = uint8_t(i);
std::vector<uint8_t> packet(voicecat::net::kVoiceHeaderSize + length + 16);
voicecat::net::serialize_header(header, packet.data());
if (sender.seal(plaintext.data(), length, packet.data(), 20, packet.data() + 20, length + 16) < 0) return 1;
if (sequence == 0 || sequence == 1 || sequence == 65535 || sequence == 65536) {
if (sequence != 0) output << ",\n";
output << " {\"sequence\": " << sequence << ", \"key\": \""
<< hex(std::vector<uint8_t>(key.begin(), key.end()))
<< "\", \"plaintext\": \"" << hex(plaintext)
<< "\", \"packet\": \"" << hex(packet) << "\"}";
}
}
output << "\n ]\n}\n";
return output ? 0 : 1;
}
+72
View File
@@ -0,0 +1,72 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Parameters;
namespace VoiceCat.Crypto;
internal sealed class MediaCipher : IDisposable
{
private readonly byte[] key;
private readonly ChaCha20Poly1305? platformCipher;
private bool disposed;
public MediaCipher(ReadOnlySpan<byte> key, bool useManaged)
{
if (key.Length != 32) throw new ArgumentException("Media keys must contain 32 bytes.", nameof(key));
this.key = key.ToArray();
if (!useManaged && ChaCha20Poly1305.IsSupported) platformCipher = new(this.key);
}
public void Encrypt(ulong counter, ReadOnlySpan<byte> plaintext, ReadOnlySpan<byte> aad, Span<byte> output)
{
ObjectDisposedException.ThrowIf(disposed, this);
Span<byte> nonce = stackalloc byte[12];
nonce.Clear();
BinaryPrimitives.WriteUInt64BigEndian(nonce[4..], counter);
if (platformCipher is not null)
{
platformCipher.Encrypt(nonce, plaintext, output[..plaintext.Length], output.Slice(plaintext.Length, 16), aad);
return;
}
var cipher = new Org.BouncyCastle.Crypto.Modes.ChaCha20Poly1305();
cipher.Init(true, new AeadParameters(new KeyParameter(key), 128, nonce.ToArray(), aad.ToArray()));
int written = cipher.ProcessBytes(plaintext, output);
cipher.DoFinal(output[written..]);
}
public bool TryDecrypt(ulong counter, ReadOnlySpan<byte> sealedPayload, ReadOnlySpan<byte> aad, Span<byte> output)
{
ObjectDisposedException.ThrowIf(disposed, this);
Span<byte> nonce = stackalloc byte[12];
nonce.Clear();
BinaryPrimitives.WriteUInt64BigEndian(nonce[4..], counter);
int length = sealedPayload.Length - 16;
try
{
if (platformCipher is not null)
platformCipher.Decrypt(nonce, sealedPayload[..length], sealedPayload[length..], output[..length], aad);
else
{
var cipher = new Org.BouncyCastle.Crypto.Modes.ChaCha20Poly1305();
cipher.Init(false, new AeadParameters(new KeyParameter(key), 128, nonce.ToArray(), aad.ToArray()));
int written = cipher.ProcessBytes(sealedPayload, output);
cipher.DoFinal(output[written..]);
}
return true;
}
catch (Exception exception) when (exception is AuthenticationTagMismatchException or InvalidCipherTextException)
{
CryptographicOperations.ZeroMemory(output[..length]);
return false;
}
}
public void Dispose()
{
if (disposed) return;
disposed = true;
platformCipher?.Dispose();
CryptographicOperations.ZeroMemory(key);
}
}
@@ -0,0 +1,60 @@
using VoiceCat.Protocol;
namespace VoiceCat.Crypto;
public sealed class MediaDecryptor : IDisposable
{
private readonly MediaCipher cipher;
private ulong highestSequence;
private ulong replayWindow;
private bool initialized;
private bool disposed;
public MediaDecryptor(ReadOnlySpan<byte> key) : this(key, false) { }
internal MediaDecryptor(ReadOnlySpan<byte> key, bool useManaged) => cipher = new(key, useManaged);
public bool TryDecrypt(ReadOnlySpan<byte> packet, Span<byte> plaintext, out VoiceFrameHeader header, out int bytesWritten)
{
ObjectDisposedException.ThrowIf(disposed, this);
header = default;
bytesWritten = 0;
if (packet.Length < VoiceFrameHeader.Size + MediaEncryptor.TagSize) return false;
int length = packet.Length - VoiceFrameHeader.Size - MediaEncryptor.TagSize;
ArgumentOutOfRangeException.ThrowIfLessThan(plaintext.Length, length);
if (packet.Overlaps(plaintext)) throw new ArgumentException("Input and output must not overlap.", nameof(plaintext));
VoiceFrameHeader.TryRead(packet, out var candidate);
ulong sequence = candidate.Sequence;
if (initialized && sequence <= highestSequence)
{
ulong offset = highestSequence - sequence;
if (offset >= 64 || (replayWindow & (1UL << (int)offset)) != 0) return false;
}
if (!cipher.TryDecrypt(sequence, packet[VoiceFrameHeader.Size..], packet[..VoiceFrameHeader.Size], plaintext[..length])) return false;
// Only authenticated counters may move the replay window.
if (!initialized)
{
highestSequence = sequence;
replayWindow = 1;
initialized = true;
}
else if (sequence > highestSequence)
{
ulong shift = sequence - highestSequence;
replayWindow = (shift >= 64 ? 0 : replayWindow << (int)shift) | 1;
highestSequence = sequence;
}
else replayWindow |= 1UL << (int)(highestSequence - sequence);
header = candidate;
bytesWritten = length;
return true;
}
public void Dispose()
{
if (disposed) return;
disposed = true;
cipher.Dispose();
}
}
@@ -0,0 +1,40 @@
using VoiceCat.Protocol;
namespace VoiceCat.Crypto;
public sealed class MediaEncryptor : IDisposable
{
private readonly MediaCipher cipher;
private ulong nextSequence;
private bool disposed;
public const int TagSize = 16;
public MediaEncryptor(ReadOnlySpan<byte> key) : this(key, false) { }
internal MediaEncryptor(ReadOnlySpan<byte> key, bool useManaged, ulong initialSequence = 0)
{
cipher = new(key, useManaged);
nextSequence = initialSequence;
}
public int Encrypt(VoiceFrameHeader header, ReadOnlySpan<byte> plaintext, Span<byte> packet)
{
ObjectDisposedException.ThrowIf(disposed, this);
int size = checked(VoiceFrameHeader.Size + plaintext.Length + TagSize);
ArgumentOutOfRangeException.ThrowIfLessThan(packet.Length, size);
if (nextSequence == ulong.MaxValue) throw new InvalidOperationException("Media counter exhausted; establish a new session.");
if (plaintext.Overlaps(packet)) throw new ArgumentException("Input and output must not overlap.", nameof(packet));
header = header with { Sequence = nextSequence++ };
header.Write(packet);
cipher.Encrypt(header.Sequence, plaintext, packet[..VoiceFrameHeader.Size], packet.Slice(VoiceFrameHeader.Size, plaintext.Length + TagSize));
return size;
}
public void Dispose()
{
if (disposed) return;
disposed = true;
cipher.Dispose();
}
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="../VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="VoiceCat.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,24 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"BouncyCastle.Cryptography": {
"type": "Direct",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
}
}
}
@@ -0,0 +1,95 @@
using System.Buffers;
using System.Buffers.Binary;
using System.IO.Pipelines;
using System.Runtime.CompilerServices;
using Google.Protobuf;
using Voicecat.V1;
namespace VoiceCat.Protocol;
public static class ControlFraming
{
public const int MaxPayloadLength = 16 * 1024 * 1024;
public static bool TryReadFrame(ref ReadOnlySequence<byte> input, out ReadOnlySequence<byte> payload)
{
payload = default;
if (input.Length < 4) return false;
Span<byte> prefix = stackalloc byte[4];
input.Slice(0, 4).CopyTo(prefix);
uint length = BinaryPrimitives.ReadUInt32BigEndian(prefix);
if (length > MaxPayloadLength) throw new InvalidDataException("Control frame exceeds 16 MiB.");
if (input.Length < 4L + length) return false;
payload = input.Slice(4, length);
input = input.Slice(4L + length);
return true;
}
public static void WriteFrame(IBufferWriter<byte> output, ReadOnlySpan<byte> payload)
{
ArgumentNullException.ThrowIfNull(output);
ArgumentOutOfRangeException.ThrowIfGreaterThan(payload.Length, MaxPayloadLength);
BinaryPrimitives.WriteUInt32BigEndian(output.GetSpan(4), (uint)payload.Length);
output.Advance(4);
output.Write(payload);
}
public static void WriteEnvelope(IBufferWriter<byte> output, Envelope envelope)
{
ArgumentNullException.ThrowIfNull(envelope);
ArgumentNullException.ThrowIfNull(output);
int length = envelope.CalculateSize();
ArgumentOutOfRangeException.ThrowIfGreaterThan(length, MaxPayloadLength);
BinaryPrimitives.WriteUInt32BigEndian(output.GetSpan(4), (uint)length);
output.Advance(4);
envelope.WriteTo(output);
}
public static async IAsyncEnumerable<Envelope> ReadEnvelopesAsync(
PipeReader reader, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(reader);
byte[] prefix = new byte[4];
while (true)
{
if (!await ReadExactlyAsync(reader, prefix, cancellationToken).ConfigureAwait(false)) yield break;
uint length = BinaryPrimitives.ReadUInt32BigEndian(prefix);
if (length > MaxPayloadLength) throw new InvalidDataException("Control frame exceeds 16 MiB.");
byte[] payload = length == 0 ? [] : new byte[length];
if (length != 0 && !await ReadExactlyAsync(reader, payload, cancellationToken).ConfigureAwait(false))
throw new InvalidDataException("Truncated control frame.");
yield return Envelope.Parser.ParseFrom(payload);
}
}
private static async ValueTask<bool> ReadExactlyAsync(PipeReader reader, Memory<byte> destination, CancellationToken cancellationToken)
{
int written = 0;
while (written < destination.Length)
{
ReadResult result = await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
var buffer = result.Buffer;
var consumed = buffer.Start;
try
{
if (result.IsCanceled) throw new OperationCanceledException(cancellationToken);
int count = (int)Math.Min(buffer.Length, destination.Length - written);
buffer.Slice(0, count).CopyTo(destination.Span[written..]);
consumed = buffer.GetPosition(count);
written += count;
if (written == destination.Length) return true;
if (result.IsCompleted)
{
if (written != 0) throw new InvalidDataException("Truncated control frame.");
return false;
}
}
finally
{
// Consume fragments so pipe backpressure cannot stall a large frame.
reader.AdvanceTo(consumed, consumed);
}
}
return true;
}
}
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Google.Protobuf" Version="3.36.1" />
<PackageReference Include="Grpc.Tools" Version="2.83.0" PrivateAssets="all" />
<Protobuf Include="../../../core/proto/voicecat.proto" GrpcServices="None" />
</ItemGroup>
</Project>
@@ -0,0 +1,49 @@
using System.Buffers.Binary;
namespace VoiceCat.Protocol;
public enum MediaFrameType : byte
{
Voice = 1,
Keepalive = 2,
UdpBinding = 3
}
[Flags]
public enum VoiceFrameFlags : byte
{
None = 0,
Marker = 1,
FecPresent = 2,
Dtx = 4,
Last = 8
}
public readonly record struct VoiceFrameHeader(
MediaFrameType Type, VoiceFrameFlags Flags, ushort Codec, uint Ssrc, ulong Sequence, uint Timestamp)
{
public const int Size = 20;
public void Write(Span<byte> destination)
{
ArgumentOutOfRangeException.ThrowIfLessThan(destination.Length, Size);
destination[0] = (byte)Type;
destination[1] = (byte)Flags;
BinaryPrimitives.WriteUInt16BigEndian(destination[2..], Codec);
BinaryPrimitives.WriteUInt32BigEndian(destination[4..], Ssrc);
BinaryPrimitives.WriteUInt64BigEndian(destination[8..], Sequence);
BinaryPrimitives.WriteUInt32BigEndian(destination[16..], Timestamp);
}
public static bool TryRead(ReadOnlySpan<byte> source, out VoiceFrameHeader header)
{
header = default;
if (source.Length < Size) return false;
header = new((MediaFrameType)source[0], (VoiceFrameFlags)source[1],
BinaryPrimitives.ReadUInt16BigEndian(source[2..]),
BinaryPrimitives.ReadUInt32BigEndian(source[4..]),
BinaryPrimitives.ReadUInt64BigEndian(source[8..]),
BinaryPrimitives.ReadUInt32BigEndian(source[16..]));
return true;
}
}
@@ -0,0 +1,19 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Google.Protobuf": {
"type": "Direct",
"requested": "[3.36.1, )",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"Grpc.Tools": {
"type": "Direct",
"requested": "[2.83.0, )",
"resolved": "2.83.0",
"contentHash": "vK2Go/83W0v2Nn7tTP9fGrX4IjmOa93s3M0SZeFimU1vIIr2wL9yNJlIyK21y85SGm3++JncB8IF751cjoLHuQ=="
}
}
}
}
@@ -0,0 +1,9 @@
{
"envelope": "00000020082a521c08011204746578741a0b746573742d636c69656e742205302e302e31",
"media": [
{"sequence": 0, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "", "packet": "01010000cafebabe0000000000000000000003c032faa61a66270f8b198f47e32e32ca84"},
{"sequence": 1, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f60616263", "packet": "01010000cafebabe0000000000000001000003c0695d7eda350fbe7d25787424bf19191d00e02d53daa4ea625d23af3335f38115f30cce2997de88a40961c10f8ace84e1f5cf7740bd5e62025c022a75532a11465f9322f9867fcf6a35396f86fdca1959d8512ae564c3f09eb1e8e224cd6bdef556a073c12aa45bdae5e77e1f2827b1f3e549f15c"},
{"sequence": 65535, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "0001020304050607", "packet": "01010000cafebabe000000000000ffff000003c096bac906a2d141b97834d57095a62f947529d13f6a74a866"},
{"sequence": 65536, "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", "plaintext": "0001020304050607", "packet": "01010000cafebabe0000000000010000000003c005ecf39e7f89b45accd35e9b5c9b45bde30713a28b8f3183"}
]
}
+181
View File
@@ -0,0 +1,181 @@
using System.Buffers;
using System.IO.Pipelines;
using Google.Protobuf;
using VoiceCat.Protocol;
using Voicecat.V1;
namespace VoiceCat.Tests;
public class FramingTests
{
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(65536)]
[InlineData(ControlFraming.MaxPayloadLength)]
public void PayloadRoundTrips(int size)
{
byte[] payload = Enumerable.Range(0, size).Select(i => (byte)i).ToArray();
var output = new ArrayBufferWriter<byte>();
ControlFraming.WriteFrame(output, payload);
var input = new ReadOnlySequence<byte>(output.WrittenMemory);
Assert.True(ControlFraming.TryReadFrame(ref input, out var actual));
Assert.Equal(payload, actual.ToArray());
Assert.True(input.IsEmpty);
}
[Fact]
public void IncompleteFramesDoNotConsumeInput()
{
byte[] frame = [0, 0, 0, 3, 1, 2, 3];
for (int size = 0; size < frame.Length; size++)
{
var input = new ReadOnlySequence<byte>(frame.AsMemory(0, size));
Assert.False(ControlFraming.TryReadFrame(ref input, out _));
Assert.Equal(size, input.Length);
}
}
[Fact]
public void SegmentsAndBatchedFramesAreHandled()
{
byte[] bytes = [0, 0, 0, 3, 1, 2, 3, 0, 0, 0, 0];
var first = new Segment(bytes.AsMemory(0, 1));
var last = first;
for (int i = 1; i < bytes.Length; i++) last = last.Append(bytes.AsMemory(i, 1));
var input = new ReadOnlySequence<byte>(first, 0, last, last.Memory.Length);
Assert.True(ControlFraming.TryReadFrame(ref input, out var payload));
Assert.Equal(new byte[] { 1, 2, 3 }, payload.ToArray());
Assert.True(ControlFraming.TryReadFrame(ref input, out payload));
Assert.True(payload.IsEmpty);
Assert.True(input.IsEmpty);
}
[Fact]
public void OversizedLengthsAreRejectedImmediately()
{
var input = new ReadOnlySequence<byte>(new byte[] { 1, 0, 0, 1 });
Assert.Throws<InvalidDataException>(() => ControlFraming.TryReadFrame(ref input, out _));
Assert.Throws<ArgumentOutOfRangeException>(() => ControlFraming.WriteFrame(new ArrayBufferWriter<byte>(), new byte[ControlFraming.MaxPayloadLength + 1]));
}
[Fact]
public async Task EnvelopesRoundTripThroughPipe()
{
var expected = new Envelope { RequestId = 42, ClientHello = new() { ProtoVersion = 1, ClientName = "test-client", ClientVersion = "0.0.1" } };
expected.ClientHello.Features.Add("text");
var pipe = new Pipe();
ControlFraming.WriteEnvelope(pipe.Writer, expected);
ControlFraming.WriteEnvelope(pipe.Writer, new());
await pipe.Writer.CompleteAsync();
var actual = new List<Envelope>();
await foreach (var envelope in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) actual.Add(envelope);
Assert.Equal(new[] { expected, new Envelope() }, actual);
await pipe.Reader.CompleteAsync();
}
[Theory]
[InlineData(new byte[] { 0 })]
[InlineData(new byte[] { 0, 0, 0, 2, 1 })]
public async Task TruncatedEndOfStreamIsRejected(byte[] bytes)
{
var pipe = new Pipe();
pipe.Writer.Write(bytes);
await pipe.Writer.CompleteAsync();
await Assert.ThrowsAsync<InvalidDataException>(async () =>
{
await foreach (var _ in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) { }
});
await pipe.Reader.CompleteAsync();
}
[Fact]
public async Task InvalidProtobufIsRejected()
{
var pipe = new Pipe();
ControlFraming.WriteFrame(pipe.Writer, new byte[] { 0xff });
await pipe.Writer.CompleteAsync();
await Assert.ThrowsAsync<InvalidProtocolBufferException>(async () =>
{
await foreach (var _ in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) { }
});
await pipe.Reader.CompleteAsync();
}
[Fact]
public async Task ReadCanBeCanceled()
{
var pipe = new Pipe();
using var cancellation = new CancellationTokenSource();
await using var enumerator = ControlFraming.ReadEnvelopesAsync(pipe.Reader, cancellation.Token).GetAsyncEnumerator();
var pending = enumerator.MoveNextAsync().AsTask();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => pending);
await pipe.Writer.CompleteAsync();
await pipe.Reader.CompleteAsync();
}
[Fact]
public void UnknownFieldsSurviveParsing()
{
byte[] bytes = [8, 42, 0xa0, 6, 7];
Assert.Equal(bytes, Envelope.Parser.ParseFrom(bytes).ToByteArray());
}
[Fact]
public async Task FragmentedLargeEnvelopeMakesProgressUnderBackpressure()
{
var envelope = new Envelope { ClientHello = new() { ClientName = new string('a', 200000) } };
var framed = new ArrayBufferWriter<byte>();
ControlFraming.WriteEnvelope(framed, envelope);
var pipe = new Pipe(new PipeOptions(pauseWriterThreshold: 32, resumeWriterThreshold: 16));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
async Task Produce()
{
for (int offset = 0; offset < framed.WrittenCount; offset += 7)
await pipe.Writer.WriteAsync(framed.WrittenMemory.Slice(offset, Math.Min(7, framed.WrittenCount - offset)), timeout.Token);
await pipe.Writer.CompleteAsync();
}
var producer = Produce();
var actual = new List<Envelope>();
await foreach (var item in ControlFraming.ReadEnvelopesAsync(pipe.Reader, timeout.Token)) actual.Add(item);
await producer;
Assert.Equal(new[] { envelope }, actual);
await pipe.Reader.CompleteAsync();
}
[Fact]
public async Task StoppingEnumerationLeavesFollowingFramesAvailable()
{
var pipe = new Pipe();
ControlFraming.WriteEnvelope(pipe.Writer, new() { RequestId = 1 });
ControlFraming.WriteEnvelope(pipe.Writer, new() { RequestId = 2 });
await pipe.Writer.FlushAsync();
await using (var first = ControlFraming.ReadEnvelopesAsync(pipe.Reader).GetAsyncEnumerator())
{
Assert.True(await first.MoveNextAsync());
Assert.Equal(1UL, first.Current.RequestId);
}
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await using (var second = ControlFraming.ReadEnvelopesAsync(pipe.Reader, timeout.Token).GetAsyncEnumerator())
{
Assert.True(await second.MoveNextAsync());
Assert.Equal(2UL, second.Current.RequestId);
}
await pipe.Writer.CompleteAsync();
await pipe.Reader.CompleteAsync();
}
private sealed class Segment : ReadOnlySequenceSegment<byte>
{
public Segment(ReadOnlyMemory<byte> memory) => Memory = memory;
public Segment Append(ReadOnlyMemory<byte> memory)
{
var segment = new Segment(memory) { RunningIndex = RunningIndex + Memory.Length };
Next = segment;
return segment;
}
}
}
@@ -0,0 +1,50 @@
using System.Buffers;
using System.Text.Json;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
using Voicecat.V1;
namespace VoiceCat.Tests;
public class GoldenTests
{
[Fact]
public void EnvelopeMatchesCppFixture()
{
using var fixture = Load();
var expected = Convert.FromHexString(fixture.RootElement.GetProperty("envelope").GetString()!);
var envelope = new Envelope { RequestId = 42, ClientHello = new() { ProtoVersion = 1, ClientName = "test-client", ClientVersion = "0.0.1" } };
envelope.ClientHello.Features.Add("text");
var output = new ArrayBufferWriter<byte>();
ControlFraming.WriteEnvelope(output, envelope);
Assert.Equal(expected, output.WrittenSpan.ToArray());
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void MediaPacketsMatchCppFixtures(bool managed)
{
using var fixture = Load();
foreach (var vector in fixture.RootElement.GetProperty("media").EnumerateArray())
{
byte[] key = Convert.FromHexString(vector.GetProperty("key").GetString()!);
byte[] plaintext = Convert.FromHexString(vector.GetProperty("plaintext").GetString()!);
byte[] expected = Convert.FromHexString(vector.GetProperty("packet").GetString()!);
ulong sequence = vector.GetProperty("sequence").GetUInt64();
using var sender = new MediaEncryptor(key, managed, sequence);
using var receiver = new MediaDecryptor(key, managed);
var header = new VoiceFrameHeader(MediaFrameType.Voice, VoiceFrameFlags.Marker, 0, 0xcafebabe, 0, 960);
byte[] actual = new byte[expected.Length];
sender.Encrypt(header, plaintext, actual);
Assert.Equal(expected, actual);
byte[] decoded = new byte[plaintext.Length];
Assert.True(receiver.TryDecrypt(expected, decoded, out var parsed, out int written));
Assert.Equal(sequence, parsed.Sequence);
Assert.Equal(plaintext.Length, written);
Assert.Equal(plaintext, decoded);
}
}
private static JsonDocument Load() => JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "cpp-wire.json")));
}
+166
View File
@@ -0,0 +1,166 @@
using System.Buffers.Binary;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
namespace VoiceCat.Tests;
public class MediaTests
{
private static readonly byte[] Key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
private static readonly VoiceFrameHeader Header = new(MediaFrameType.Voice, VoiceFrameFlags.Marker, 0, 0xcafebabe, 0, 960);
[Theory]
[InlineData(false)]
[InlineData(true)]
public void BothBackendsProduceIdenticalPackets(bool managed)
{
using var sender = new MediaEncryptor(Key, managed);
using var receiver = new MediaDecryptor(Key, !managed);
byte[] plaintext = Enumerable.Range(0, 100).Select(i => (byte)i).ToArray();
byte[] packet = Seal(sender, plaintext);
byte[] output = new byte[plaintext.Length];
Assert.True(receiver.TryDecrypt(packet, output, out var header, out int written));
Assert.Equal(Header, header);
Assert.Equal(plaintext.Length, written);
Assert.Equal(plaintext, output);
Assert.False(receiver.TryDecrypt(packet, output, out _, out written));
Assert.Equal(0, written);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ForgedCounterDoesNotPoisonReplayWindow(bool managed)
{
using var sender = new MediaEncryptor(Key, managed);
using var receiver = new MediaDecryptor(Key, managed);
byte[] output = new byte[8];
Assert.True(receiver.TryDecrypt(Seal(sender, new byte[8]), output, out _, out _));
byte[] packet = Seal(sender, new byte[8]);
byte[] forged = (byte[])packet.Clone();
BinaryPrimitives.WriteUInt64BigEndian(forged.AsSpan(8), ulong.MaxValue);
Array.Fill(output, (byte)0xaa);
Assert.False(receiver.TryDecrypt(forged, output, out var header, out int written));
Assert.Equal(default, header);
Assert.Equal(0, written);
Assert.All(output, value => Assert.Equal(0, value));
Assert.True(receiver.TryDecrypt(packet, output, out _, out _));
Assert.True(receiver.TryDecrypt(Seal(sender, new byte[8]), output, out _, out _));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void TamperingEveryPacketRegionFailsAuthentication(bool managed)
{
using var sender = new MediaEncryptor(Key, managed);
byte[] packet = Seal(sender, new byte[80]);
for (int i = 0; i < packet.Length; i++)
{
using var receiver = new MediaDecryptor(Key, managed);
byte[] tampered = (byte[])packet.Clone();
tampered[i] ^= 0x80;
Assert.False(receiver.TryDecrypt(tampered, new byte[80], out _, out _));
Assert.True(receiver.TryDecrypt(packet, new byte[80], out _, out _));
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ReplayWindowAcceptsReorderingAndRejectsOldPackets(bool managed)
{
using var sender = new MediaEncryptor(Key, managed);
using var receiver = new MediaDecryptor(Key, managed);
var packets = Enumerable.Range(0, 130).Select(_ => Seal(sender, new byte[1])).ToArray();
byte[] output = new byte[1];
Assert.True(receiver.TryDecrypt(packets[64], output, out _, out _));
Assert.False(receiver.TryDecrypt(packets[0], output, out _, out _));
Assert.True(receiver.TryDecrypt(packets[1], output, out _, out _));
Assert.False(receiver.TryDecrypt(packets[1], output, out _, out _));
Assert.True(receiver.TryDecrypt(packets[63], output, out _, out _));
Assert.True(receiver.TryDecrypt(packets[129], output, out _, out _));
Assert.False(receiver.TryDecrypt(packets[64], output, out _, out _));
Assert.True(receiver.TryDecrypt(packets[128], output, out _, out _));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void CounterCrossesOldSixteenBitBoundary(bool managed)
{
using var sender = new MediaEncryptor(Key, managed, 65534);
using var receiver = new MediaDecryptor(Key, managed);
for (ulong sequence = 65534; sequence < 65540; sequence++)
{
Assert.True(receiver.TryDecrypt(Seal(sender, new byte[1]), new byte[1], out var header, out _));
Assert.Equal(sequence, header.Sequence);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void InterleavedRelayUsesRecipientCounter(bool managed)
{
byte[] otherKey = Enumerable.Repeat((byte)42, 32).ToArray();
using var a = new MediaEncryptor(Key, managed);
using var b = new MediaEncryptor(otherKey, managed);
using var receiveA = new MediaDecryptor(Key, managed);
using var receiveB = new MediaDecryptor(otherKey, managed);
using var relay = new MediaEncryptor(Key, managed);
using var listener = new MediaDecryptor(Key, managed);
byte[] plaintext = [1, 2, 3];
byte[] decoded = new byte[3];
for (int i = 0; i < 16; i++)
{
var sender = i % 2 == 0 ? a : b;
var receiver = i % 2 == 0 ? receiveA : receiveB;
Assert.True(receiver.TryDecrypt(Seal(sender, plaintext), decoded, out var header, out _));
byte[] packet = new byte[39];
relay.Encrypt(header, decoded, packet);
Assert.True(listener.TryDecrypt(packet, decoded, out var relayedHeader, out _));
Assert.Equal((ulong)i, relayedHeader.Sequence);
Assert.Equal(plaintext, decoded);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void EmptyPayloadAndLargeCountersWork(bool managed)
{
using var sender = new MediaEncryptor(Key, managed, ulong.MaxValue - 1);
using var receiver = new MediaDecryptor(Key, managed);
var packet = Seal(sender, []);
Assert.True(receiver.TryDecrypt(packet, [], out var header, out int written));
Assert.Equal(ulong.MaxValue - 1, header.Sequence);
Assert.Equal(0, written);
Assert.Throws<InvalidOperationException>(() => Seal(sender, []));
}
[Fact]
public void InvalidArgumentsAndDisposedInstancesAreRejected()
{
Assert.Throws<ArgumentException>(() => new MediaEncryptor(new byte[31]));
using var sender = new MediaEncryptor(Key);
using var receiver = new MediaDecryptor(Key);
Assert.Throws<ArgumentOutOfRangeException>(() => sender.Encrypt(Header, new byte[1], new byte[36]));
byte[] packet = Seal(sender, new byte[8]);
Assert.True(receiver.TryDecrypt(packet, new byte[8], out var header, out _));
Assert.Equal(0UL, header.Sequence);
Assert.False(receiver.TryDecrypt(new byte[35], [], out _, out _));
Assert.Throws<ArgumentOutOfRangeException>(() => receiver.TryDecrypt(packet, [], out _, out _));
sender.Dispose();
receiver.Dispose();
Assert.Throws<ObjectDisposedException>(() => Seal(sender, []));
Assert.Throws<ObjectDisposedException>(() => receiver.TryDecrypt(packet, new byte[8], out _, out _));
}
private static byte[] Seal(MediaEncryptor sender, byte[] plaintext)
{
byte[] packet = new byte[VoiceFrameHeader.Size + plaintext.Length + MediaEncryptor.TagSize];
Assert.Equal(packet.Length, sender.Encrypt(Header, plaintext, packet));
return packet;
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1" PrivateAssets="all" />
<ProjectReference Include="../../src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
<ProjectReference Include="../../src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
<Using Include="Xunit" />
<None Update="Fixtures/*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,19 @@
using VoiceCat.Protocol;
namespace VoiceCat.Tests;
public class VoiceHeaderTests
{
[Fact]
public void HeaderUsesBigEndianFieldsAndPreservesUnknownValues()
{
var header = new VoiceFrameHeader((MediaFrameType)255, (VoiceFrameFlags)128, 0x1234, 0x56789abc, 0x0123456789abcdef, 0xfedcba98);
byte[] bytes = new byte[20];
header.Write(bytes);
Assert.Equal("FF80123456789ABC0123456789ABCDEFFEDCBA98", Convert.ToHexString(bytes));
Assert.True(VoiceFrameHeader.TryRead(bytes, out var parsed));
Assert.Equal(header, parsed);
Assert.False(VoiceFrameHeader.TryRead(bytes.AsSpan(0, 19), out _));
Assert.Throws<ArgumentOutOfRangeException>(() => header.Write(new byte[19]));
}
}
@@ -0,0 +1,121 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[17.14.1, )",
"resolved": "17.14.1",
"contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==",
"dependencies": {
"Microsoft.CodeCoverage": "17.14.1",
"Microsoft.TestPlatform.TestHost": "17.14.1"
}
},
"xunit": {
"type": "Direct",
"requested": "[2.9.3, )",
"resolved": "2.9.3",
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
"dependencies": {
"xunit.analyzers": "1.18.0",
"xunit.assert": "2.9.3",
"xunit.core": "[2.9.3]"
}
},
"xunit.runner.visualstudio": {
"type": "Direct",
"requested": "[3.1.1, )",
"resolved": "3.1.1",
"contentHash": "gNu2zhnuwjq5vQlU4S7yK/lfaKZDLmtcu+vTjnhfTlMAUYn+Hmgu8IIX0UCwWepYkk+Szx03DHx1bDnc9Fd+9w=="
},
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.36.1",
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "17.14.1",
"contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg=="
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "17.14.1",
"contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ=="
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "17.14.1",
"contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "17.14.1",
"Newtonsoft.Json": "13.0.3"
}
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"xunit.abstractions": {
"type": "Transitive",
"resolved": "2.0.3",
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.18.0",
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
},
"xunit.assert": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
},
"xunit.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]",
"xunit.extensibility.execution": "[2.9.3]"
}
},
"xunit.extensibility.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
"dependencies": {
"xunit.abstractions": "2.0.3"
}
},
"xunit.extensibility.execution": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]"
}
},
"voicecat.crypto": {
"type": "Project",
"dependencies": {
"BouncyCastle.Cryptography": "[2.6.2, )",
"VoiceCat.Protocol": "[1.0.0, )"
}
},
"voicecat.protocol": {
"type": "Project",
"dependencies": {
"Google.Protobuf": "[3.36.1, )"
}
}
}
}
}