Start .NET rewrite with wire and media crypto conformance
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
root = true
|
||||
|
||||
[*.cs]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
csharp_style_namespace_declarations = file_scoped:warning
|
||||
dotnet_sort_system_directives_first = true
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AnalysisLevel>latest</AnalysisLevel>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,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 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.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,9 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
|
||||
<Project Path="src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/VoiceCat.Tests/VoiceCat.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -0,0 +1,30 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$allowed = @('MIT', 'BSD-2-Clause', 'BSD-3-Clause', 'Apache-2.0', 'ISC', '0BSD')
|
||||
$seen = @{}
|
||||
foreach ($lockPath in (Get-ChildItem -LiteralPath $PSScriptRoot -Filter packages.lock.json -Recurse)) {
|
||||
$lock = Get-Content -Raw -LiteralPath $lockPath.FullName | ConvertFrom-Json
|
||||
$assets = Get-Content -Raw -LiteralPath (Join-Path $lockPath.DirectoryName 'obj/project.assets.json') | ConvertFrom-Json
|
||||
foreach ($framework in $lock.dependencies.PSObject.Properties) {
|
||||
foreach ($package in $framework.Value.PSObject.Properties) {
|
||||
if ($package.Value.type -eq 'Project') { continue }
|
||||
$id = $package.Name.ToLowerInvariant()
|
||||
$version = $package.Value.resolved
|
||||
if ($seen.ContainsKey("$id/$version")) { continue }
|
||||
$seen["$id/$version"] = $true
|
||||
$nuspec = $null
|
||||
foreach ($folder in $assets.packageFolders.PSObject.Properties.Name) {
|
||||
$candidate = Join-Path $folder "$id/$version/$id.nuspec"
|
||||
if (Test-Path -LiteralPath $candidate) { $nuspec = $candidate; break }
|
||||
}
|
||||
if (!$nuspec) { throw "Restore dependencies before auditing $id/$version." }
|
||||
[xml]$spec = Get-Content -Raw -LiteralPath $nuspec
|
||||
$license = $spec.package.metadata.license
|
||||
if ($license.type -eq 'expression' -and $allowed -contains $license.InnerText) { continue }
|
||||
# This legacy pinned package predates NuGet license expressions (Apache-2.0).
|
||||
if ($id -eq 'xunit.abstractions' -and $version -eq '2.0.3' -and
|
||||
$spec.package.metadata.licenseUrl -eq 'https://raw.githubusercontent.com/xunit/xunit/master/license.txt') { continue }
|
||||
throw "Unapproved license for $id/$version. Review before changing the allowlist."
|
||||
}
|
||||
}
|
||||
}
|
||||
Write-Output "Checked $($seen.Count) package licenses: permissive allowlist passed."
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"sdk": { "version": "10.0.203", "rollForward": "latestFeature" }
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
@@ -0,0 +1,56 @@
|
||||
#include "crypto/crypto.h"
|
||||
#include "net/voice_frame.h"
|
||||
#include "protocol/envelope.h"
|
||||
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
|
||||
static std::string hex(const std::vector<uint8_t>& bytes) {
|
||||
std::ostringstream result;
|
||||
result << std::hex << std::setfill('0');
|
||||
for (auto byte : bytes) result << std::setw(2) << unsigned(byte);
|
||||
return result.str();
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 2 || sodium_init() < 0) return 1;
|
||||
std::ofstream output(argv[1], std::ios::binary);
|
||||
if (!output) return 1;
|
||||
voicecat::v1::Envelope envelope;
|
||||
envelope.set_request_id(42);
|
||||
auto* hello = envelope.mutable_client_hello();
|
||||
hello->set_proto_version(1);
|
||||
hello->set_client_name("test-client");
|
||||
hello->set_client_version("0.0.1");
|
||||
hello->add_features("text");
|
||||
std::vector<uint8_t> framed;
|
||||
if (!voicecat::protocol::encode_envelope(envelope, framed)) return 1;
|
||||
output << "{\n \"envelope\": \"" << hex(framed) << "\",\n \"media\": [\n";
|
||||
std::array<uint8_t, 32> key{};
|
||||
for (size_t i = 0; i < key.size(); ++i) key[i] = uint8_t(i);
|
||||
voicecat::crypto::SodiumMediaCrypto sender(key.data());
|
||||
for (uint64_t sequence = 0; sequence <= 65536; ++sequence) {
|
||||
voicecat::net::VoiceFrame header;
|
||||
header.flags = voicecat::net::kFlagMarker;
|
||||
header.ssrc = 0xcafebabe;
|
||||
header.seq = sender.peek_send_counter();
|
||||
header.timestamp = 960;
|
||||
const size_t length = sequence == 0 ? 0 : sequence == 1 ? 100 : 8;
|
||||
std::vector<uint8_t> plaintext(length);
|
||||
for (size_t i = 0; i < length; ++i) plaintext[i] = uint8_t(i);
|
||||
std::vector<uint8_t> packet(voicecat::net::kVoiceHeaderSize + length + 16);
|
||||
voicecat::net::serialize_header(header, packet.data());
|
||||
if (sender.seal(plaintext.data(), length, packet.data(), 20, packet.data() + 20, length + 16) < 0) return 1;
|
||||
if (sequence == 0 || sequence == 1 || sequence == 65535 || sequence == 65536) {
|
||||
if (sequence != 0) output << ",\n";
|
||||
output << " {\"sequence\": " << sequence << ", \"key\": \""
|
||||
<< hex(std::vector<uint8_t>(key.begin(), key.end()))
|
||||
<< "\", \"plaintext\": \"" << hex(plaintext)
|
||||
<< "\", \"packet\": \"" << hex(packet) << "\"}";
|
||||
}
|
||||
}
|
||||
output << "\n ]\n}\n";
|
||||
return output ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,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"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Buffers;
|
||||
using System.IO.Pipelines;
|
||||
using Google.Protobuf;
|
||||
using VoiceCat.Protocol;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class FramingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
[InlineData(65536)]
|
||||
[InlineData(ControlFraming.MaxPayloadLength)]
|
||||
public void PayloadRoundTrips(int size)
|
||||
{
|
||||
byte[] payload = Enumerable.Range(0, size).Select(i => (byte)i).ToArray();
|
||||
var output = new ArrayBufferWriter<byte>();
|
||||
ControlFraming.WriteFrame(output, payload);
|
||||
var input = new ReadOnlySequence<byte>(output.WrittenMemory);
|
||||
Assert.True(ControlFraming.TryReadFrame(ref input, out var actual));
|
||||
Assert.Equal(payload, actual.ToArray());
|
||||
Assert.True(input.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncompleteFramesDoNotConsumeInput()
|
||||
{
|
||||
byte[] frame = [0, 0, 0, 3, 1, 2, 3];
|
||||
for (int size = 0; size < frame.Length; size++)
|
||||
{
|
||||
var input = new ReadOnlySequence<byte>(frame.AsMemory(0, size));
|
||||
Assert.False(ControlFraming.TryReadFrame(ref input, out _));
|
||||
Assert.Equal(size, input.Length);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SegmentsAndBatchedFramesAreHandled()
|
||||
{
|
||||
byte[] bytes = [0, 0, 0, 3, 1, 2, 3, 0, 0, 0, 0];
|
||||
var first = new Segment(bytes.AsMemory(0, 1));
|
||||
var last = first;
|
||||
for (int i = 1; i < bytes.Length; i++) last = last.Append(bytes.AsMemory(i, 1));
|
||||
var input = new ReadOnlySequence<byte>(first, 0, last, last.Memory.Length);
|
||||
Assert.True(ControlFraming.TryReadFrame(ref input, out var payload));
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, payload.ToArray());
|
||||
Assert.True(ControlFraming.TryReadFrame(ref input, out payload));
|
||||
Assert.True(payload.IsEmpty);
|
||||
Assert.True(input.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OversizedLengthsAreRejectedImmediately()
|
||||
{
|
||||
var input = new ReadOnlySequence<byte>(new byte[] { 1, 0, 0, 1 });
|
||||
Assert.Throws<InvalidDataException>(() => ControlFraming.TryReadFrame(ref input, out _));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => ControlFraming.WriteFrame(new ArrayBufferWriter<byte>(), new byte[ControlFraming.MaxPayloadLength + 1]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnvelopesRoundTripThroughPipe()
|
||||
{
|
||||
var expected = new Envelope { RequestId = 42, ClientHello = new() { ProtoVersion = 1, ClientName = "test-client", ClientVersion = "0.0.1" } };
|
||||
expected.ClientHello.Features.Add("text");
|
||||
var pipe = new Pipe();
|
||||
ControlFraming.WriteEnvelope(pipe.Writer, expected);
|
||||
ControlFraming.WriteEnvelope(pipe.Writer, new());
|
||||
await pipe.Writer.CompleteAsync();
|
||||
var actual = new List<Envelope>();
|
||||
await foreach (var envelope in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) actual.Add(envelope);
|
||||
Assert.Equal(new[] { expected, new Envelope() }, actual);
|
||||
await pipe.Reader.CompleteAsync();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(new byte[] { 0 })]
|
||||
[InlineData(new byte[] { 0, 0, 0, 2, 1 })]
|
||||
public async Task TruncatedEndOfStreamIsRejected(byte[] bytes)
|
||||
{
|
||||
var pipe = new Pipe();
|
||||
pipe.Writer.Write(bytes);
|
||||
await pipe.Writer.CompleteAsync();
|
||||
await Assert.ThrowsAsync<InvalidDataException>(async () =>
|
||||
{
|
||||
await foreach (var _ in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) { }
|
||||
});
|
||||
await pipe.Reader.CompleteAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidProtobufIsRejected()
|
||||
{
|
||||
var pipe = new Pipe();
|
||||
ControlFraming.WriteFrame(pipe.Writer, new byte[] { 0xff });
|
||||
await pipe.Writer.CompleteAsync();
|
||||
await Assert.ThrowsAsync<InvalidProtocolBufferException>(async () =>
|
||||
{
|
||||
await foreach (var _ in ControlFraming.ReadEnvelopesAsync(pipe.Reader)) { }
|
||||
});
|
||||
await pipe.Reader.CompleteAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadCanBeCanceled()
|
||||
{
|
||||
var pipe = new Pipe();
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
await using var enumerator = ControlFraming.ReadEnvelopesAsync(pipe.Reader, cancellation.Token).GetAsyncEnumerator();
|
||||
var pending = enumerator.MoveNextAsync().AsTask();
|
||||
cancellation.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => pending);
|
||||
await pipe.Writer.CompleteAsync();
|
||||
await pipe.Reader.CompleteAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownFieldsSurviveParsing()
|
||||
{
|
||||
byte[] bytes = [8, 42, 0xa0, 6, 7];
|
||||
Assert.Equal(bytes, Envelope.Parser.ParseFrom(bytes).ToByteArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FragmentedLargeEnvelopeMakesProgressUnderBackpressure()
|
||||
{
|
||||
var envelope = new Envelope { ClientHello = new() { ClientName = new string('a', 200000) } };
|
||||
var framed = new ArrayBufferWriter<byte>();
|
||||
ControlFraming.WriteEnvelope(framed, envelope);
|
||||
var pipe = new Pipe(new PipeOptions(pauseWriterThreshold: 32, resumeWriterThreshold: 16));
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
async Task Produce()
|
||||
{
|
||||
for (int offset = 0; offset < framed.WrittenCount; offset += 7)
|
||||
await pipe.Writer.WriteAsync(framed.WrittenMemory.Slice(offset, Math.Min(7, framed.WrittenCount - offset)), timeout.Token);
|
||||
await pipe.Writer.CompleteAsync();
|
||||
}
|
||||
var producer = Produce();
|
||||
var actual = new List<Envelope>();
|
||||
await foreach (var item in ControlFraming.ReadEnvelopesAsync(pipe.Reader, timeout.Token)) actual.Add(item);
|
||||
await producer;
|
||||
Assert.Equal(new[] { envelope }, actual);
|
||||
await pipe.Reader.CompleteAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoppingEnumerationLeavesFollowingFramesAvailable()
|
||||
{
|
||||
var pipe = new Pipe();
|
||||
ControlFraming.WriteEnvelope(pipe.Writer, new() { RequestId = 1 });
|
||||
ControlFraming.WriteEnvelope(pipe.Writer, new() { RequestId = 2 });
|
||||
await pipe.Writer.FlushAsync();
|
||||
await using (var first = ControlFraming.ReadEnvelopesAsync(pipe.Reader).GetAsyncEnumerator())
|
||||
{
|
||||
Assert.True(await first.MoveNextAsync());
|
||||
Assert.Equal(1UL, first.Current.RequestId);
|
||||
}
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await using (var second = ControlFraming.ReadEnvelopesAsync(pipe.Reader, timeout.Token).GetAsyncEnumerator())
|
||||
{
|
||||
Assert.True(await second.MoveNextAsync());
|
||||
Assert.Equal(2UL, second.Current.RequestId);
|
||||
}
|
||||
await pipe.Writer.CompleteAsync();
|
||||
await pipe.Reader.CompleteAsync();
|
||||
}
|
||||
|
||||
private sealed class Segment : ReadOnlySequenceSegment<byte>
|
||||
{
|
||||
public Segment(ReadOnlyMemory<byte> memory) => Memory = memory;
|
||||
|
||||
public Segment Append(ReadOnlyMemory<byte> memory)
|
||||
{
|
||||
var segment = new Segment(memory) { RunningIndex = RunningIndex + Memory.Length };
|
||||
Next = segment;
|
||||
return segment;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Buffers;
|
||||
using System.Text.Json;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Protocol;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public class GoldenTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnvelopeMatchesCppFixture()
|
||||
{
|
||||
using var fixture = Load();
|
||||
var expected = Convert.FromHexString(fixture.RootElement.GetProperty("envelope").GetString()!);
|
||||
var envelope = new Envelope { RequestId = 42, ClientHello = new() { ProtoVersion = 1, ClientName = "test-client", ClientVersion = "0.0.1" } };
|
||||
envelope.ClientHello.Features.Add("text");
|
||||
var output = new ArrayBufferWriter<byte>();
|
||||
ControlFraming.WriteEnvelope(output, envelope);
|
||||
Assert.Equal(expected, output.WrittenSpan.ToArray());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void MediaPacketsMatchCppFixtures(bool managed)
|
||||
{
|
||||
using var fixture = Load();
|
||||
foreach (var vector in fixture.RootElement.GetProperty("media").EnumerateArray())
|
||||
{
|
||||
byte[] key = Convert.FromHexString(vector.GetProperty("key").GetString()!);
|
||||
byte[] plaintext = Convert.FromHexString(vector.GetProperty("plaintext").GetString()!);
|
||||
byte[] expected = Convert.FromHexString(vector.GetProperty("packet").GetString()!);
|
||||
ulong sequence = vector.GetProperty("sequence").GetUInt64();
|
||||
using var sender = new MediaEncryptor(key, managed, sequence);
|
||||
using var receiver = new MediaDecryptor(key, managed);
|
||||
var header = new VoiceFrameHeader(MediaFrameType.Voice, VoiceFrameFlags.Marker, 0, 0xcafebabe, 0, 960);
|
||||
byte[] actual = new byte[expected.Length];
|
||||
sender.Encrypt(header, plaintext, actual);
|
||||
Assert.Equal(expected, actual);
|
||||
byte[] decoded = new byte[plaintext.Length];
|
||||
Assert.True(receiver.TryDecrypt(expected, decoded, out var parsed, out int written));
|
||||
Assert.Equal(sequence, parsed.Sequence);
|
||||
Assert.Equal(plaintext.Length, written);
|
||||
Assert.Equal(plaintext, decoded);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonDocument Load() => JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "cpp-wire.json")));
|
||||
}
|
||||
@@ -0,0 +1,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, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user