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
@@ -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, )"
}
}
}
}
}