Start .NET rewrite with wire and media crypto conformance
This commit is contained in:
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user