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
+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=="
}
}
}
}