Add encrypted managed UDP relay and native voice conformance

This commit is contained in:
2026-09-15 22:53:54 +02:00
parent 4067bab7c2
commit 05eacb3092
19 changed files with 1068 additions and 46 deletions
@@ -0,0 +1,110 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
using VoiceCat.Server.Transport;
using Xunit.Abstractions;
namespace VoiceCat.Tests;
public sealed class MediaFanoutTests(ITestOutputHelper output)
{
[Fact]
public async Task UdpRelayDeliversFiftyPacketsPerSecondToFiftySubscribers()
{
await using var relay = new MediaRelay(new(IPAddress.Loopback, 0));
byte[] key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
Socket[] sockets = Enumerable.Range(0, 51).Select(_ => new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp)).ToArray();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
try
{
foreach (Socket socket in sockets)
{
socket.ReceiveBufferSize = 1024 * 1024;
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
}
MediaRoute[] routes = sockets.Select(socket => new MediaRoute(
new(new byte[16], new(new(key), new(key))) { Endpoint = ((IPEndPoint)socket.LocalEndPoint!).Serialize() },
1, true, false, false, [42])).ToArray();
relay.Publish(routes);
byte[] payload = Enumerable.Range(0, 120).Select(i => (byte)i).ToArray();
Task[] receivers = sockets.Skip(1).Select(async socket =>
{
using var decryptor = new MediaDecryptor(key);
byte[] packet = new byte[4000];
byte[] decoded = new byte[4000];
for (ulong sequence = 0; sequence < 50; sequence++)
{
int length = await socket.ReceiveAsync(packet, SocketFlags.None, timeout.Token);
Assert.True(decryptor.TryDecrypt(packet.AsSpan(0, length), decoded, out var header, out int bytes));
Assert.Equal(sequence, header.Sequence);
Assert.Equal(payload, decoded[..bytes]);
}
}).ToArray();
using var encryptor = new MediaEncryptor(key);
byte[] outgoing = new byte[VoiceFrameHeader.Size + payload.Length + 16];
var elapsed = Stopwatch.StartNew();
for (uint index = 0; index < 50; index++)
{
encryptor.Encrypt(new(MediaFrameType.Voice, 0, 0, 42, 0, index * 960), payload, outgoing);
await sockets[0].SendToAsync(outgoing, SocketFlags.None, relay.EndPoint, timeout.Token);
await Task.Delay(20, timeout.Token);
}
await Task.WhenAll(receivers);
output.WriteLine($"Delivered all 2,500 recipient packets in {elapsed.Elapsed.TotalMilliseconds:F1} ms at a paced 50 pps input.");
}
finally { foreach (Socket socket in sockets) socket.Dispose(); }
}
[PlatformCipherFact]
public void FiftySubscriberFanoutAllocatesNoManagedMemoryAndPreservesPayload()
{
byte[] key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
MediaRoute[] routes = Enumerable.Range(0, 51).Select(i => new MediaRoute(
new(new byte[16], new(new(key), new(key))) { Endpoint = new IPEndPoint(IPAddress.Loopback, 10000 + i).Serialize() },
1, true, false, false, [42])).ToArray();
using var sender = new MediaEncryptor(key);
using var receiver = new MediaDecryptor(key);
using var fanout = new MediaFanout();
byte[] payload = Enumerable.Range(0, 120).Select(i => (byte)i).ToArray();
byte[] packet = new byte[VoiceFrameHeader.Size + payload.Length + 16];
byte[] decoded = new byte[payload.Length];
ReadOnlyMemory<byte> last = default;
try
{
for (int i = 0; i < 100; i++) Cycle();
long before = GC.GetAllocatedBytesForCurrentThread();
long started = Stopwatch.GetTimestamp();
for (int i = 0; i < 1000; i++) Cycle();
TimeSpan elapsed = Stopwatch.GetElapsedTime(started);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
Assert.True(receiver.TryDecrypt(last.Span, decoded, out var header, out int length));
Assert.Equal(payload.Length, length);
Assert.Equal(payload, decoded);
Assert.Equal(42U, header.Ssrc);
Assert.Equal(1099UL, header.Sequence);
output.WriteLine($"50,000 recipient seals in {elapsed.TotalMilliseconds:F1} ms; {allocated} managed bytes. Transport scheduling is excluded.");
}
finally { foreach (MediaRoute route in routes) route.Peer.Dispose(); }
void Cycle()
{
sender.Encrypt(new(MediaFrameType.Voice, 0, 0, 42, 0, 960), payload, packet);
if (!fanout.TryStart(packet, routes[0], routes)) throw new InvalidOperationException("Valid packet rejected.");
int recipients = 0;
while (fanout.TryNext(out var next, out _)) { last = next; recipients++; }
if (recipients != 50) throw new InvalidOperationException("Incorrect fanout.");
}
}
private sealed class PlatformCipherFactAttribute : FactAttribute
{
public PlatformCipherFactAttribute()
{
if (!ChaCha20Poly1305.IsSupported) Skip = "The allocation guarantee requires platform ChaCha20-Poly1305; fallback conformance is tested separately.";
}
}
}
@@ -0,0 +1,303 @@
using System.Net;
using System.Diagnostics;
using System.Net.Sockets;
using VoiceCat.Protocol;
using VoiceCat.Server.Transport;
using Voicecat.V1;
using static VoiceCat.Tests.ServerTests;
namespace VoiceCat.Tests;
public sealed class MediaRelayTests
{
[CppCliVoiceTheory]
[InlineData(1)]
[InlineData(2)]
public async Task TwoCppCliProcessesJoinChatAndExchangeVoice(int channel)
{
await using var fixture = new ServerFixture();
await using var observer = await fixture.ConnectAsync();
await observer.LoginAsync("Observer");
observer.Send(new() { JoinChannel = new() { ChannelId = checked((uint)channel) } });
Assert.True((await observer.ReadUntilAsync(e => e.JoinChannelResult is not null)).JoinChannelResult.Ok);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await Task.WhenAll(RunAsync("Cli Alice"), RunAsync("Cli Bob"));
var first = (await observer.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
var second = (await observer.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
Assert.Equal("CLI voice checkpoint", first.Body);
Assert.Equal(first.Body, second.Body);
Assert.NotEqual(first.SenderId, second.SenderId);
async Task RunAsync(string nickname)
{
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VCCLI")!)
{
WorkingDirectory = fixture.Directory, UseShellExecute = false, CreateNoWindow = true,
RedirectStandardOutput = true, RedirectStandardError = true
};
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(System.Globalization.CultureInfo.InvariantCulture),
"--nick", nickname, "--channel", channel.ToString(System.Globalization.CultureInfo.InvariantCulture), "--text", "CLI voice checkpoint", "--test-tone-ms", "4000" })
start.ArgumentList.Add(argument);
using var process = Process.Start(start)!;
Task<string> stdout = process.StandardOutput.ReadToEndAsync();
Task<string> stderr = process.StandardError.ReadToEndAsync();
try
{
await process.WaitForExitAsync(timeout.Token);
string log = await stdout + await stderr;
Assert.True(process.ExitCode == 0, log);
Assert.Contains("[test-tone] received=", log);
}
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
}
}
private sealed class CppCliVoiceTheoryAttribute : TheoryAttribute
{
public CppCliVoiceTheoryAttribute()
{
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI.";
}
}
[VoiceOracleTheory]
[InlineData(1)]
[InlineData(2)]
public async Task ExistingCppClientsExchangeBidirectionalVoiceThroughManagedServer(int channel)
{
await using var fixture = new ServerFixture();
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VOICE_ORACLE")!)
{
WorkingDirectory = fixture.Directory, UseShellExecute = false, CreateNoWindow = true,
RedirectStandardOutput = true, RedirectStandardError = true
};
start.ArgumentList.Add(fixture.Server.EndPoint.Port.ToString(System.Globalization.CultureInfo.InvariantCulture));
start.ArgumentList.Add(channel.ToString(System.Globalization.CultureInfo.InvariantCulture));
using var process = Process.Start(start)!;
Task<string> output = process.StandardOutput.ReadToEndAsync();
Task<string> error = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(45));
try
{
await process.WaitForExitAsync(timeout.Token);
Assert.True(process.ExitCode == 0, await output + await error);
}
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
}
private sealed class VoiceOracleTheoryAttribute : TheoryAttribute
{
public VoiceOracleTheoryAttribute()
{
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VOICE_ORACLE"))) Skip = "Set VOICECAT_VOICE_ORACLE to the native voice conformance executable.";
}
}
[Fact]
public async Task DisconnectInvalidatesBothBindingAndActiveStreams()
{
await using var fixture = new ServerFixture();
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
var stream = await alice.AnnounceAsync(StreamKind.StreamMic);
alice.Client.Send(new() { Disconnect = new() });
await bob.Client.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left && e.UserEvent.LeftId == alice.Client.Authentication!.Self.Id);
await alice.SendAsync(alice.Seal(stream.Ssrc, [1]));
await bob.AssertNoVoiceAsync();
}
[Fact]
public async Task AnnounceRequiresSubscriptionAndUsesAuthoritativeMusicSettings()
{
await using var fixture = new ServerFixture();
await using var client = await fixture.ConnectAsync();
await client.LoginAsync("Alice");
client.Send(new() { StreamAnnounce = new() { Kind = StreamKind.StreamMic } });
Assert.False((await client.ReadUntilAsync(e => e.StreamAnnounceResult is not null)).StreamAnnounceResult.Ok);
client.Send(new() { SubscribeVoice = new() });
await client.ReadUntilAsync(e => e.VoiceSubscriptionResult is not null);
client.Send(new() { JoinChannel = new() { ChannelId = 2 } });
await client.ReadUntilAsync(e => e.JoinChannelResult is not null);
client.Send(new() { RequestId = 21, StreamAnnounce = new() { Kind = StreamKind.StreamScreenAudio, RequestedAudio = new() { SampleRate = 8000, BitrateBps = 64000 } } });
var announced = await client.ReadUntilAsync(e => e.StreamAnnounceResult is not null);
Assert.Equal(21UL, announced.RequestId);
Assert.True(announced.StreamAnnounceResult.Ok);
Assert.Equal(64000U, announced.StreamAnnounceResult.EffectiveAudio.BitrateBps);
Assert.Equal(48000U, announced.StreamAnnounceResult.EffectiveAudio.SampleRate);
Assert.Equal(ChannelMode.ModeStereo, announced.StreamAnnounceResult.EffectiveAudio.Mode);
client.Send(new() { StreamAnnounce = new() { Kind = (StreamKind)99 } });
Assert.False((await client.ReadUntilAsync(e => e.StreamAnnounceResult is not null)).StreamAnnounceResult.Ok);
}
[Fact]
public async Task EncryptedOpusIsResealedWithRecipientCountersAcrossMultipleStreamsAndSenders()
{
await using var fixture = new ServerFixture();
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
await using var carol = await VoicePeer.ConnectAsync(fixture, "Carol");
StreamAnnounceResult mic = await alice.AnnounceAsync(StreamKind.StreamMic);
StreamAnnounceResult screen = await alice.AnnounceAsync(StreamKind.StreamScreenAudio);
StreamAnnounceResult other = await carol.AnnounceAsync(StreamKind.StreamMic);
Assert.NotEqual(mic.StreamId, screen.StreamId);
Assert.NotEqual(mic.Ssrc, screen.Ssrc);
Assert.Equal(48000U, screen.EffectiveAudio.SampleRate);
Assert.Equal(24000U, screen.EffectiveAudio.BitrateBps);
using var encoder = new Codec.OpusEncoder(new());
short[] samples = Enumerable.Range(0, 960).Select(i => (short)(8000 * Math.Sin(i * 0.1))).ToArray();
byte[] payload = new byte[4000];
int length = encoder.Encode(samples, payload);
payload = payload[..length];
foreach (var (sender, stream) in new[] { (alice, mic), (carol, other), (alice, screen) })
{
byte[] packet = sender.Seal(stream.Ssrc, payload, 960, VoiceFrameFlags.Marker | VoiceFrameFlags.FecPresent);
await sender.SendAsync(packet);
var received = await bob.ReceiveVoiceAsync();
Assert.Equal(payload, received.Payload);
Assert.Equal(stream.Ssrc, received.Header.Ssrc);
Assert.Equal(960U, received.Header.Timestamp);
Assert.Equal(VoiceFrameFlags.Marker | VoiceFrameFlags.FecPresent, received.Header.Flags);
}
Assert.Equal(2UL, bob.LastSequence);
}
[Fact]
public async Task ReplayForgeryAndSpoofedStreamsAreDroppedWithoutBreakingValidMedia()
{
await using var fixture = new ServerFixture();
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
StreamAnnounceResult stream = await alice.AnnounceAsync(StreamKind.StreamMic);
byte[] packet = alice.Seal(stream.Ssrc, [1, 2, 3]);
await alice.SendAsync(packet);
Assert.Equal(new byte[] { 1, 2, 3 }, (await bob.ReceiveVoiceAsync()).Payload);
await alice.SendAsync(packet);
byte[] forged = alice.Seal(stream.Ssrc, [4]);
forged[^1] ^= 1;
await alice.SendAsync(forged);
await alice.SendAsync(alice.Seal(stream.Ssrc + 1000, [5]));
await alice.SendAsync([1]);
await alice.SendAsync(alice.Seal(stream.Ssrc, [6]));
Assert.Equal(new byte[] { 6 }, (await bob.ReceiveVoiceAsync()).Payload);
Assert.Equal(1UL, bob.LastSequence);
}
[Fact]
public async Task SubscriptionChannelMovementAndStreamStopIsolateMedia()
{
await using var fixture = new ServerFixture();
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
var mic = await alice.AnnounceAsync(StreamKind.StreamMic);
await bob.SubscribeAsync(false);
await alice.SendAsync(alice.Seal(mic.Ssrc, [1]));
await bob.AssertNoVoiceAsync();
await bob.SubscribeAsync(true);
bob.Client.Send(new() { JoinChannel = new() { ChannelId = 2 } });
Assert.True((await bob.Client.ReadUntilAsync(e => e.JoinChannelResult is not null)).JoinChannelResult.Ok);
await alice.SendAsync(alice.Seal(mic.Ssrc, [2]));
await bob.AssertNoVoiceAsync();
bob.Client.Send(new() { JoinChannel = new() { ChannelId = 1 } });
await bob.Client.ReadUntilAsync(e => e.JoinChannelResult is not null);
alice.Client.Send(new() { StreamStop = new() { StreamId = mic.StreamId } });
await alice.Client.ReadUntilAsync(e => e.UserEvent?.User?.Id == alice.Client.Authentication!.Self.Id && e.UserEvent.User.Streams.Count == 0);
await alice.SendAsync(alice.Seal(mic.Ssrc, [3]));
await bob.AssertNoVoiceAsync();
var replacement = await alice.AnnounceAsync(StreamKind.StreamMic);
await alice.SendAsync(alice.Seal(replacement.Ssrc, [4]));
Assert.Equal(new byte[] { 4 }, (await bob.ReceiveVoiceAsync()).Payload);
}
[Fact]
public async Task BadTokensCannotBindAndExistingBindingCannotBeStolen()
{
await using var fixture = new ServerFixture();
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
using var rogue = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
rogue.Bind(new IPEndPoint(IPAddress.Loopback, 0));
byte[] binding = new byte[VoiceFrameHeader.Size + 16];
new VoiceFrameHeader(MediaFrameType.UdpBinding, 0, 0, 0, 0, 0).Write(binding);
await rogue.SendToAsync(binding, SocketFlags.None, fixture.Server.MediaEndPoint);
alice.Client.Authentication!.UdpToken.Span.CopyTo(binding.AsSpan(VoiceFrameHeader.Size));
await rogue.SendToAsync(binding, SocketFlags.None, fixture.Server.MediaEndPoint);
byte[] keepalive = new byte[VoiceFrameHeader.Size];
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
await rogue.SendToAsync(keepalive, SocketFlags.None, fixture.Server.MediaEndPoint);
using var timeout = new CancellationTokenSource(200);
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await rogue.ReceiveAsync(new byte[100], SocketFlags.None, timeout.Token));
await alice.SendAsync(keepalive);
Assert.Equal(keepalive, await alice.ReceivePacketAsync());
}
internal sealed class VoicePeer : IAsyncDisposable
{
public Client Client { get; }
private readonly Socket udp = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
private readonly IPEndPoint endpoint;
private readonly MediaSessionCrypto crypto;
public ulong LastSequence { get; private set; }
private VoicePeer(Client client, IPEndPoint endpoint, MediaSessionCrypto crypto)
{
Client = client; this.endpoint = endpoint; this.crypto = crypto;
udp.Bind(new IPEndPoint(IPAddress.Loopback, 0));
}
public static async Task<VoicePeer> ConnectAsync(ServerFixture fixture, string nickname)
{
Client client = await fixture.ConnectAsync();
await client.LoginAsync(nickname);
var peer = new VoicePeer(client, fixture.Server.MediaEndPoint, await client.TakeMediaCryptoAsync());
client.Send(new() { UdpBinding = new() { UdpToken = client.Authentication!.UdpToken } });
Assert.True((await client.ReadUntilAsync(e => e.UdpBinding is not null)).UdpBinding.Ack);
byte[] binding = new byte[VoiceFrameHeader.Size + 16];
new VoiceFrameHeader(MediaFrameType.UdpBinding, 0, 0, 0, 0, 0).Write(binding);
client.Authentication.UdpToken.Span.CopyTo(binding.AsSpan(VoiceFrameHeader.Size));
await peer.SendAsync(binding);
byte[] keepalive = new byte[VoiceFrameHeader.Size];
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
await peer.SendAsync(keepalive);
Assert.Equal(keepalive, await peer.ReceivePacketAsync());
await peer.SubscribeAsync(true);
return peer;
}
public async Task SubscribeAsync(bool subscribed)
{
Client.Send(subscribed ? new() { SubscribeVoice = new() } : new() { UnsubscribeVoice = new() });
var result = (await Client.ReadUntilAsync(e => e.VoiceSubscriptionResult is not null)).VoiceSubscriptionResult;
Assert.True(result.Ok); Assert.Equal(subscribed, result.Subscribed);
}
public async Task<StreamAnnounceResult> AnnounceAsync(StreamKind kind)
{
Client.Send(new() { StreamAnnounce = new() { Kind = kind, RequestedAudio = new() { SampleRate = 8000, BitrateBps = 900000 } } });
var result = (await Client.ReadUntilAsync(e => e.StreamAnnounceResult is not null)).StreamAnnounceResult;
Assert.True(result.Ok, result.Error);
return result;
}
public byte[] Seal(uint ssrc, byte[] payload, uint timestamp = 0, VoiceFrameFlags flags = 0)
{
byte[] packet = new byte[VoiceFrameHeader.Size + payload.Length + 16];
crypto.Encryptor.Encrypt(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload, packet);
return packet;
}
public async Task SendAsync(byte[] packet) => await udp.SendToAsync(packet, SocketFlags.None, endpoint, Client.Timeout.Token);
public async Task<byte[]> ReceivePacketAsync()
{
byte[] buffer = new byte[65535];
int size = await udp.ReceiveAsync(buffer, SocketFlags.None, Client.Timeout.Token);
return buffer[..size];
}
public async Task<(VoiceFrameHeader Header, byte[] Payload)> ReceiveVoiceAsync()
{
byte[] packet = await ReceivePacketAsync();
byte[] plain = new byte[65535];
Assert.True(crypto.Decryptor.TryDecrypt(packet, plain, out var header, out int length));
LastSequence = header.Sequence;
return (header, plain[..length]);
}
public async Task AssertNoVoiceAsync()
{
using var timeout = new CancellationTokenSource(200);
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await udp.ReceiveAsync(new byte[65535], SocketFlags.None, timeout.Token));
}
public async ValueTask DisposeAsync() { udp.Dispose(); crypto.Dispose(); await Client.DisposeAsync(); }
}
}
+5 -2
View File
@@ -131,7 +131,7 @@ public sealed class ServerTests
}
}
private sealed class ServerFixture : IAsyncDisposable
internal sealed class ServerFixture : IAsyncDisposable
{
public string Directory { get; } = Path.Combine(Path.GetTempPath(), "voicecat-server-" + Guid.NewGuid().ToString("N"));
public VoiceServer Server { get; }
@@ -156,7 +156,7 @@ public sealed class ServerTests
}
}
private sealed class Client : IAsyncDisposable
internal sealed class Client : IAsyncDisposable
{
public CancellationTokenSource Timeout { get; } = new(TimeSpan.FromSeconds(30));
private readonly TlsControlConnection connection;
@@ -167,6 +167,8 @@ public sealed class ServerTests
messages = connection.ReadAsync(Timeout.Token).GetAsyncEnumerator();
}
public void Send(Envelope envelope) => Assert.True(connection.TrySend(envelope));
public AuthResult? Authentication { get; private set; }
public Task<MediaSessionCrypto> TakeMediaCryptoAsync() => connection.TakeMediaCryptoAsync(Timeout.Token);
public async Task<Envelope> ReadUntilAsync(Func<Envelope, bool> predicate)
{
while (await messages.MoveNextAsync()) if (predicate(messages.Current)) return messages.Current;
@@ -178,6 +180,7 @@ public sealed class ServerTests
Assert.Equal(1UL, (await ReadUntilAsync(e => e.ServerHello is not null)).RequestId);
Send(new() { RequestId = 2, AuthRequest = new() { Guest = new() { Nickname = nickname } } });
AuthResult auth = (await ReadUntilAsync(e => e.AuthResult is not null)).AuthResult;
Authentication = auth;
Assert.True(auth.Ok, auth.Error);
ServerStateSnapshot state = (await ReadUntilAsync(e => e.ServerState is not null)).ServerState;
Assert.Equal(2, state.Channels.Count);