diff --git a/src/Disqord.Core/Library/Library.Constants.cs b/src/Disqord.Core/Library/Library.Constants.cs index 8b74fae36..a6f7f8e66 100644 --- a/src/Disqord.Core/Library/Library.Constants.cs +++ b/src/Disqord.Core/Library/Library.Constants.cs @@ -8,6 +8,6 @@ public static class Constants public const int RestApiVersion = 10; - public const int VoiceVersion = 4; + public const int VoiceVersion = 8; } } diff --git a/src/Disqord.Voice.Api/Default/DefaultVoiceGateway.cs b/src/Disqord.Voice.Api/Default/DefaultVoiceGateway.cs index 391bbb580..0199cac5f 100644 --- a/src/Disqord.Voice.Api/Default/DefaultVoiceGateway.cs +++ b/src/Disqord.Voice.Api/Default/DefaultVoiceGateway.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers.Binary; using System.IO; using System.Text; using System.Threading; @@ -6,7 +7,6 @@ using Disqord.Serialization.Json; using Disqord.Voice.Api.Models; using Disqord.WebSocket; -using Disqord.WebSocket.Default.Discord; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Qommon.Binding; @@ -27,7 +27,7 @@ public class DefaultVoiceGateway : IVoiceGateway public IWebSocketClientFactory WebSocketClientFactory { get; } - private DiscordWebSocket _ws = null!; + private VoiceWebSocket _ws = null!; private readonly Binder _binder; public DefaultVoiceGateway( @@ -48,7 +48,7 @@ public void Bind(IVoiceGatewayClient value) { _binder.Bind(value); - _ws = new DiscordWebSocket(Logger, WebSocketClientFactory, false); + _ws = new VoiceWebSocket(Logger, WebSocketClientFactory); } public ValueTask ConnectAsync(Uri uri, CancellationToken cancellationToken = default) @@ -73,22 +73,54 @@ public ValueTask SendAsync(VoiceGatewayPayloadJsonModel payload, CancellationTok if (json.Length > 4096) throw new ArgumentException("Voice cannot send payloads longer than 4096 bytes.", nameof(payload)); - return _ws.SendAsync(json, cancellationToken); + return _ws.SendAsync(json, WebSocketMessageType.Text, cancellationToken); } - public async ValueTask ReceiveAsync(CancellationToken cancellationToken = default) + public ValueTask SendBinaryAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) { - var jsonStream = await _ws.ReceiveAsync(cancellationToken).ConfigureAwait(false); + if (LogsPayloads && data.Length > 0) + Logger.LogTrace("Voice sending binary payload: op {0}, {1} bytes", data.Span[0], data.Length); + + return _ws.SendAsync(data, WebSocketMessageType.Binary, cancellationToken); + } + + public async ValueTask ReceiveAsync(CancellationToken cancellationToken = default) + { + var (stream, isBinary) = await _ws.ReceiveAsync(cancellationToken).ConfigureAwait(false); + if (isBinary) + { + return ParseBinaryMessage(stream); + } + if (LogsPayloads) { - var stream = new MemoryStream(); - await jsonStream.CopyToAsync(stream, cancellationToken).ConfigureAwait(false); - Logger.LogTrace("Voice received payload: {0}", Encoding.UTF8.GetString(stream.GetBuffer())); - stream.Position = 0; - jsonStream = stream; + var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); + Logger.LogTrace("Voice received payload: {0}", Encoding.UTF8.GetString(memoryStream.GetBuffer())); + memoryStream.Position = 0; + stream = memoryStream; } - return Serializer.Deserialize(jsonStream)!; + var payload = Serializer.Deserialize(stream)!; + return VoiceGatewayMessage.FromJson(payload); + } + + private static VoiceGatewayMessage ParseBinaryMessage(MemoryStream stream) + { + // Binary format (server → client): [2-byte big-endian seq] [1-byte opcode] [payload] + stream.TryGetBuffer(out var buffer); + var span = buffer.AsSpan(); + + if (span.Length < 3) + { + throw new InvalidDataException("Binary voice gateway message is too short."); + } + + var sequenceNumber = (int) BinaryPrimitives.ReadUInt16BigEndian(span); + var op = (VoiceGatewayPayloadOperation) span[2]; + var payload = buffer.AsMemory(3); + + return VoiceGatewayMessage.FromBinary(op, sequenceNumber, payload); } public ValueTask DisposeAsync() diff --git a/src/Disqord.Voice.Api/Default/DefaultVoiceGatewayClient.cs b/src/Disqord.Voice.Api/Default/DefaultVoiceGatewayClient.cs index 0294abcf5..08e0ae86e 100644 --- a/src/Disqord.Voice.Api/Default/DefaultVoiceGatewayClient.cs +++ b/src/Disqord.Voice.Api/Default/DefaultVoiceGatewayClient.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Disqord.Serialization.Json; @@ -32,9 +33,23 @@ public class DefaultVoiceGatewayClient : IVoiceGatewayClient public CancellationToken StoppingToken { get; private set; } + /// + public IReadOnlySet ConnectedUserIds => _connectedUserIds; + + /// + public int LastSequenceNumber { get; private set; } + + /// + public int MaxDaveProtocolVersion { get; } + + private Func? _daveMessageHandler; + private readonly object _stateLock = new(); - private Tcs _readyTcs; - private Tcs _sessionDescriptionTcs; + private readonly Tcs _readyTcs; + private readonly Tcs _sessionDescriptionTcs; + private Tcs? _postSessionDescriptionTcs; + + private readonly HashSet _connectedUserIds = []; public DefaultVoiceGatewayClient( Snowflake guildId, @@ -42,6 +57,7 @@ public DefaultVoiceGatewayClient( string sessionId, string token, string endpoint, + int maxDaveProtocolVersion, ILogger logger, IVoiceGatewayHeartbeater heartbeater, IVoiceGateway gateway, @@ -49,6 +65,7 @@ public DefaultVoiceGatewayClient( { GuildId = guildId; CurrentMemberId = currentMemberId; + MaxDaveProtocolVersion = maxDaveProtocolVersion; Logger = logger; Heartbeater = heartbeater; Heartbeater.Bind(this); @@ -93,7 +110,7 @@ public async Task SendAsync(VoiceGatewayPayloadJsonModel payload, CancellationTo { Guard.IsNotNull(payload); - var sent = false; + bool sent; do { try @@ -105,7 +122,9 @@ public async Task SendAsync(VoiceGatewayPayloadJsonModel payload, CancellationTo catch (Exception ex) { if (ex is not OperationCanceledException) + { Logger.LogError(ex, "An exception occurred while sending voice payload: {0}.", payload.Op); + } throw; } @@ -113,12 +132,45 @@ public async Task SendAsync(VoiceGatewayPayloadJsonModel payload, CancellationTo while (!sent); } + /// + public async Task SendBinaryAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) + { + try + { + if (data.Length > 0) + Logger.LogTrace("Sending binary voice payload: op {0}.", (VoiceGatewayPayloadOperation) data.Span[0]); + + await Gateway.SendBinaryAsync(data, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + if (ex is not OperationCanceledException) + Logger.LogError(ex, "An exception occurred while sending binary voice payload."); + + throw; + } + } + public Task RunAsync(CancellationToken stoppingToken) { StoppingToken = stoppingToken; return InternalRunAsync(stoppingToken); } + /// + public void SuspendAfterSessionDescription() + { + _postSessionDescriptionTcs = new Tcs(); + } + + /// + public void ResumeAfterSessionDescription(Func? daveMessageHandler) + { + _daveMessageHandler = daveMessageHandler; + _postSessionDescriptionTcs?.Complete(); + _postSessionDescriptionTcs = null; + } + private async Task InternalConnectAsync(CancellationToken stoppingToken) { var attempt = 0; @@ -168,15 +220,20 @@ private async Task InternalRunAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { - var payload = await Gateway.ReceiveAsync(stoppingToken).ConfigureAwait(false); - Logger.LogTrace("Received voice payload: {0}.", payload.Op); + var message = await Gateway.ReceiveAsync(stoppingToken).ConfigureAwait(false); + Logger.LogTrace("Received voice payload: {0}.", message.Op); + + if (message.SequenceNumber != null) + { + LastSequenceNumber = message.SequenceNumber.Value; + } - switch (payload.Op) + switch (message.Op) { case VoiceGatewayPayloadOperation.Ready: { Logger.LogDebug("Successfully identified. The voice gateway is ready."); - var model = payload.D!.ToType()!; + var model = message.JsonPayload!.D!.ToType()!; Logger.LogTrace("SSRC: {Ssrc}, IP: {Ip}:{Port}, Modes: {Modes}.", model.Ssrc, model.Ip, model.Port, model.Modes); @@ -195,9 +252,16 @@ private async Task InternalRunAsync(CancellationToken stoppingToken) } case VoiceGatewayPayloadOperation.SessionDescription: { - var model = payload.D!.ToType()!; - Logger.LogDebug("The voice gateway sent a session description with mode {0}.", model.Mode); + var model = message.JsonPayload!.D!.ToType()!; + Logger.LogDebug("The voice gateway sent a session description with mode {0}, DAVE protocol version {1}.", model.Mode, model.DaveProtocolVersion); _sessionDescriptionTcs.Complete(model); + + // Wait for the connection to finish setting up (e.g., DAVE handler) before processing further messages. + if (_postSessionDescriptionTcs != null) + { + await _postSessionDescriptionTcs.Task.ConfigureAwait(false); + } + break; } case VoiceGatewayPayloadOperation.Speaking: @@ -223,7 +287,7 @@ private async Task InternalRunAsync(CancellationToken stoppingToken) Logger.LogDebug("The voice gateway said hello."); try { - var model = payload.D!.ToType()!; + var model = message.JsonPayload!.D!.ToType()!; var interval = TimeSpan.FromMilliseconds(model.HeartbeatInterval); await Heartbeater.StartAsync(interval).ConfigureAwait(false); } @@ -253,17 +317,67 @@ private async Task InternalRunAsync(CancellationToken stoppingToken) } case VoiceGatewayPayloadOperation.ClientConnect: { - // TODO: client connect + var model = message.JsonPayload!.D!.ToType()!; + foreach (var userId in model.UserIds) + { + _connectedUserIds.Add(userId); + } + + Logger.LogTrace("Client connect: {0} user(s).", model.UserIds.Length); + + if (_daveMessageHandler != null) + { + await _daveMessageHandler(message, stoppingToken).ConfigureAwait(false); + } + break; } case VoiceGatewayPayloadOperation.ClientDisconnect: { - // TODO: client disconnect + var model = message.JsonPayload!.D!.ToType()!; + _connectedUserIds.Remove(model.UserId); + Logger.LogTrace("Client disconnect: {0}.", model.UserId); + + if (_daveMessageHandler != null) + await _daveMessageHandler(message, stoppingToken).ConfigureAwait(false); + + break; + } + case VoiceGatewayPayloadOperation.DaveProtocolPrepareTransition: + case VoiceGatewayPayloadOperation.DaveProtocolExecuteTransition: + case VoiceGatewayPayloadOperation.DaveProtocolPrepareEpoch: + { + Logger.LogTrace("Received DAVE opcode {0}.", message.Op); + if (_daveMessageHandler != null) + { + await _daveMessageHandler(message, stoppingToken).ConfigureAwait(false); + } + + break; + } + case VoiceGatewayPayloadOperation.DaveMlsExternalSenderPackage: + case VoiceGatewayPayloadOperation.DaveMlsProposals: + case VoiceGatewayPayloadOperation.DaveMlsAnnounceCommitTransition: + case VoiceGatewayPayloadOperation.DaveMlsWelcome: + { + Logger.LogTrace("Received DAVE binary opcode {0} ({1} bytes).", message.Op, message.BinaryPayload.Length); + if (_daveMessageHandler != null) + { + await _daveMessageHandler(message, stoppingToken).ConfigureAwait(false); + } + + break; + } + case VoiceGatewayPayloadOperation.MediaSinkWants: + case VoiceGatewayPayloadOperation.ClientFlags: + case VoiceGatewayPayloadOperation.ChannelOptionsUpdate: + { + // Ignored known opcodes break; } default: { - Logger.LogWarning("Unknown voice gateway operation: {0}.", payload.Op); + Logger.LogWarning("Unknown voice gateway operation: {0}.", message.Op); break; } } @@ -280,6 +394,14 @@ private async Task InternalRunAsync(CancellationToken stoppingToken) } else { + if (stoppingToken.IsCancellationRequested) + { + // The connection is already being stopped (e.g. due to a VOICE_SERVER_UPDATE). + // The non-recoverable close code is expected — treat it as a cancellation. + Logger.LogDebug("The voice gateway was closed with code {0} while already stopping.", closeCode); + throw new OperationCanceledException(stoppingToken); + } + var level = closeCode == VoiceGatewayCloseCode.ForciblyDisconnected ? LogLevel.Information : LogLevel.Warning; @@ -303,6 +425,10 @@ private async Task InternalRunAsync(CancellationToken stoppingToken) { await Gateway.CloseAsync(1000, null, default).ConfigureAwait(false); } + catch (ObjectDisposedException) + { + // Expected during shutdown — the WebSocket may already be disposed. + } catch (Exception exc) { Logger.LogError(exc, "An exception occurred while closing the voice gateway connection after cancellation."); @@ -319,6 +445,10 @@ private async Task InternalRunAsync(CancellationToken stoppingToken) { await Gateway.CloseAsync(1000, null, default).ConfigureAwait(false); } + catch (ObjectDisposedException) + { + // Expected during shutdown — the WebSocket may already be disposed. + } catch (Exception exc) { Logger.LogError(exc, "An exception occurred while closing the voice gateway connection after cancellation."); @@ -353,7 +483,8 @@ private Task IdentifyAsync(CancellationToken cancellationToken) ServerId = GuildId, UserId = CurrentMemberId, SessionId = SessionId, - Token = Token + Token = Token, + MaxDaveProtocolVersion = MaxDaveProtocolVersion } }, cancellationToken); } diff --git a/src/Disqord.Voice.Api/Default/DefaultVoiceGatewayHeartbeater.cs b/src/Disqord.Voice.Api/Default/DefaultVoiceGatewayHeartbeater.cs index 233711a71..ced48bec0 100644 --- a/src/Disqord.Voice.Api/Default/DefaultVoiceGatewayHeartbeater.cs +++ b/src/Disqord.Voice.Api/Default/DefaultVoiceGatewayHeartbeater.cs @@ -20,7 +20,6 @@ public class DefaultVoiceGatewayHeartbeater : IVoiceGatewayHeartbeater private DateTime? _lastSend; private DateTime? _lastAcknowledge; - private uint _sequence; private Cts? _cts; private Task? _task; @@ -41,7 +40,6 @@ public ValueTask StartAsync(TimeSpan interval) Interval = interval; _lastSend = null; _lastAcknowledge = null; - _sequence = 0; _cts = new Cts(); _task = Task.Run(InternalRunAsync, _cts.Token); return default; @@ -80,7 +78,11 @@ protected virtual VoiceGatewayPayloadJsonModel GetPayload() return new() { Op = VoiceGatewayPayloadOperation.Heartbeat, - D = Client.Serializer.GetJsonNode(_sequence++) + D = Client.Serializer.GetJsonNode(new HeartbeatJsonModel + { + T = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + SeqAck = Client.LastSequenceNumber + }) }; } diff --git a/src/Disqord.Voice.Api/Default/VoiceWebSocket.cs b/src/Disqord.Voice.Api/Default/VoiceWebSocket.cs new file mode 100644 index 000000000..a48b052bb --- /dev/null +++ b/src/Disqord.Voice.Api/Default/VoiceWebSocket.cs @@ -0,0 +1,171 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Disqord.Utilities.Threading; +using Disqord.WebSocket; +using Microsoft.Extensions.Logging; +using Qommon; +using Qommon.Threading; + +namespace Disqord.Voice.Api.Default; + +internal sealed class VoiceWebSocket( + ILogger logger, + IWebSocketClientFactory webSocketClientFactory) : IAsyncDisposable +{ + public const int ReceiveBufferSize = 8192; + + public ILogger Logger { get; } = logger; + + private IWebSocketClient? _ws; + + /// + /// This is a fix for the ClientWebSocket being garbage and aborting itself on a cancelled ReceiveAsync (and possibly SendAsync) + /// rendering us unable to close the connection gracefully. + /// 1. We create an infinite task that runs alongside the Send/ReceiveAsync() tasks and pass it the actual cancellation token just to signal cancellation. + /// 2. We pass the Send/ReceiveAsync() task an essentially bogus cancellation token that gets cancelled when we close the connection, + /// allowing us to gracefully close and then have the websocket abort or whatever as we don't care about the state of it anymore. + /// + private Cts? _limboCts; + + private readonly SemaphoreSlim _sendSemaphore = new(1, 1); + + private readonly SemaphoreSlim _receiveSemaphore = new(1, 1); + private readonly byte[] _receiveBuffer = new byte[ReceiveBufferSize]; + private readonly MemoryStream _receiveStream = new(ReceiveBufferSize * 2); + + private bool _isDisposed; + + private void ThrowIfDisposed() + { + if (_isDisposed) + throw new ObjectDisposedException(null, "The voice web socket client has been disposed."); + } + + public async ValueTask ConnectAsync(Uri url, CancellationToken cancellationToken) + { + using (await _sendSemaphore.EnterAsync(cancellationToken).ConfigureAwait(false)) + using (await _receiveSemaphore.EnterAsync(cancellationToken).ConfigureAwait(false)) + { + ThrowIfDisposed(); + + _limboCts?.Cancel(); + _limboCts?.Dispose(); + _limboCts = new Cts(); + _ws?.Dispose(); + _ws = webSocketClientFactory.CreateClient(); + + await _ws.ConnectAsync(url, cancellationToken).ConfigureAwait(false); + } + } + + public async ValueTask SendAsync(ReadOnlyMemory memory, WebSocketMessageType messageType, CancellationToken cancellationToken) + { + using (await _sendSemaphore.EnterAsync(cancellationToken).ConfigureAwait(false)) + { + ThrowIfDisposed(); + + // See _limboCts for more info on cancellation. + var sendTask = _ws!.SendAsync(memory, messageType, true, _limboCts!.Token).AsTask(); + using (var infiniteCts = Cts.Linked(cancellationToken)) + { + var infiniteTask = Task.Delay(Timeout.Infinite, infiniteCts.Token); + await Task.WhenAny(infiniteTask, sendTask).ConfigureAwait(false); + infiniteCts.Cancel(); + } + + cancellationToken.ThrowIfCancellationRequested(); + } + } + + public async ValueTask<(MemoryStream Stream, bool IsBinary)> ReceiveAsync(CancellationToken cancellationToken) + { + using (await _receiveSemaphore.EnterAsync(cancellationToken).ConfigureAwait(false)) + { + ThrowIfDisposed(); + + Guard.IsNotNull(_ws); + Guard.IsNotNull(_limboCts); + + _receiveStream.Position = 0; + _receiveStream.SetLength(0); + do + { + // See _limboCts for more info on cancellation. + var receiveTask = _ws.ReceiveAsync(_receiveBuffer, _limboCts.Token).AsTask(); + using (var infiniteCts = Cts.Linked(cancellationToken)) + { + var infiniteTask = Task.Delay(Timeout.Infinite, infiniteCts.Token); + await Task.WhenAny(infiniteTask, receiveTask).ConfigureAwait(false); + infiniteCts.Cancel(); + } + + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + + var result = await receiveTask.ConfigureAwait(false); + if (result.MessageType == WebSocketMessageType.Close) + { + var closeStatus = _ws.CloseStatus; + var closeMessage = _ws.CloseMessage; + try + { + await _ws.CloseOutputAsync(closeStatus.GetValueOrDefault(), closeMessage, default).ConfigureAwait(false); + } + catch { } + + throw new WebSocketClosedException(closeStatus, closeMessage); + } + + _receiveStream.Write(_receiveBuffer.AsSpan(0, result.Count)); + if (!result.EndOfMessage) + continue; + + _receiveStream.Position = 0; + return (_receiveStream, result.MessageType == WebSocketMessageType.Binary); + } + while (!cancellationToken.IsCancellationRequested); + + throw new OperationCanceledException(cancellationToken); + } + } + + public async ValueTask CloseAsync(int closeStatus, string? closeMessage = null, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + using (await _sendSemaphore.EnterAsync(cancellationToken).ConfigureAwait(false)) + using (await _receiveSemaphore.EnterAsync(cancellationToken).ConfigureAwait(false)) + { + if (_ws!.State != WebSocketState.Aborted) + { + try + { + await _ws.CloseAsync(closeStatus, closeMessage, cancellationToken).ConfigureAwait(false); + } + catch { } + } + + _limboCts?.Cancel(); + _limboCts?.Dispose(); + } + } + + public async ValueTask DisposeAsync() + { + if (_isDisposed) + return; + + using (await _sendSemaphore.EnterAsync().ConfigureAwait(false)) + using (await _receiveSemaphore.EnterAsync().ConfigureAwait(false)) + { + if (_isDisposed) + return; + + _isDisposed = true; + _receiveStream.Dispose(); + _ws?.Dispose(); + } + } +} diff --git a/src/Disqord.Voice.Api/Factory/Default/DefaultVoiceGatewayClientFactory.cs b/src/Disqord.Voice.Api/Factory/Default/DefaultVoiceGatewayClientFactory.cs index 4c32859e6..30df12366 100644 --- a/src/Disqord.Voice.Api/Factory/Default/DefaultVoiceGatewayClientFactory.cs +++ b/src/Disqord.Voice.Api/Factory/Default/DefaultVoiceGatewayClientFactory.cs @@ -5,38 +5,31 @@ namespace Disqord.Voice.Api.Default; -public class DefaultVoiceGatewayClientFactory : IVoiceGatewayClientFactory +public class DefaultVoiceGatewayClientFactory(IServiceProvider services) : IVoiceGatewayClientFactory { - private readonly IServiceProvider _services; - - public DefaultVoiceGatewayClientFactory(IServiceProvider services) - { - _services = services; - } - protected virtual IVoiceGatewayHeartbeater CreateHeartbeater() { - return Unsafe.As(HeartbeaterFactory(_services, null)); + return Unsafe.As(HeartbeaterFactory(services, null)); } protected virtual IVoiceGateway CreateGateway() { - return Unsafe.As(GatewayFactory(_services, null)); + return Unsafe.As(GatewayFactory(services, null)); } protected virtual IVoiceGatewayClient CreateClient(Snowflake guildId, Snowflake currentMemberId, - string sessionId, string token, string endpoint, + string sessionId, string token, string endpoint, int maxDaveProtocolVersion, ILogger logger, IVoiceGatewayHeartbeater heartbeater, IVoiceGateway gateway) { - var apiClient = ClientFactory(_services, new object?[] { guildId, currentMemberId, sessionId, token, endpoint, logger, heartbeater, gateway }); + var apiClient = ClientFactory(services, [guildId, currentMemberId, sessionId, token, endpoint, maxDaveProtocolVersion, logger, heartbeater, gateway]); return Unsafe.As(apiClient); } - public virtual IVoiceGatewayClient Create(Snowflake guildId, Snowflake currentMemberId, string sessionId, string token, string endpoint, ILogger logger) + public virtual IVoiceGatewayClient Create(Snowflake guildId, Snowflake currentMemberId, string sessionId, string token, string endpoint, int maxDaveProtocolVersion, ILogger logger) { var heartbeater = CreateHeartbeater(); var gateway = CreateGateway(); - var shard = CreateClient(guildId, currentMemberId, sessionId, token, endpoint, logger, heartbeater, gateway); + var shard = CreateClient(guildId, currentMemberId, sessionId, token, endpoint, maxDaveProtocolVersion, logger, heartbeater, gateway); return shard; } @@ -46,15 +39,15 @@ public virtual IVoiceGatewayClient Create(Snowflake guildId, Snowflake currentMe static DefaultVoiceGatewayClientFactory() { - HeartbeaterFactory = ActivatorUtilities.CreateFactory(typeof(DefaultVoiceGatewayHeartbeater), - Array.Empty()); + HeartbeaterFactory = ActivatorUtilities.CreateFactory(typeof(DefaultVoiceGatewayHeartbeater), []); - GatewayFactory = ActivatorUtilities.CreateFactory(typeof(DefaultVoiceGateway), - Array.Empty()); + GatewayFactory = ActivatorUtilities.CreateFactory(typeof(DefaultVoiceGateway), []); ClientFactory = ActivatorUtilities.CreateFactory(typeof(DefaultVoiceGatewayClient), - new[] { typeof(Snowflake), typeof(Snowflake), - typeof(string), typeof(string), typeof(string), - typeof(ILogger), typeof(IVoiceGatewayHeartbeater), typeof(IVoiceGateway) }); + [ + typeof(Snowflake), typeof(Snowflake), + typeof(string), typeof(string), typeof(string), + typeof(int), typeof(ILogger), typeof(IVoiceGatewayHeartbeater), typeof(IVoiceGateway) + ]); } } diff --git a/src/Disqord.Voice.Api/Factory/IVoiceGatewayClientFactory.cs b/src/Disqord.Voice.Api/Factory/IVoiceGatewayClientFactory.cs index 499e15ee8..d26443138 100644 --- a/src/Disqord.Voice.Api/Factory/IVoiceGatewayClientFactory.cs +++ b/src/Disqord.Voice.Api/Factory/IVoiceGatewayClientFactory.cs @@ -4,5 +4,5 @@ namespace Disqord.Voice.Api; public interface IVoiceGatewayClientFactory { - IVoiceGatewayClient Create(Snowflake guildId, Snowflake currentMemberId, string sessionId, string token, string endpoint, ILogger logger); + IVoiceGatewayClient Create(Snowflake guildId, Snowflake currentMemberId, string sessionId, string token, string endpoint, int maxDaveProtocolVersion, ILogger logger); } diff --git a/src/Disqord.Voice.Api/IVoiceGateway.cs b/src/Disqord.Voice.Api/IVoiceGateway.cs index 5aa651793..e31306aff 100644 --- a/src/Disqord.Voice.Api/IVoiceGateway.cs +++ b/src/Disqord.Voice.Api/IVoiceGateway.cs @@ -25,5 +25,7 @@ public interface IVoiceGateway : IBindable, ILogging, IAsyn ValueTask SendAsync(VoiceGatewayPayloadJsonModel payload, CancellationToken cancellationToken = default); - ValueTask ReceiveAsync(CancellationToken cancellationToken = default); + ValueTask SendBinaryAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default); + + ValueTask ReceiveAsync(CancellationToken cancellationToken = default); } diff --git a/src/Disqord.Voice.Api/IVoiceGatewayClient.cs b/src/Disqord.Voice.Api/IVoiceGatewayClient.cs index 956152be1..252c977f1 100644 --- a/src/Disqord.Voice.Api/IVoiceGatewayClient.cs +++ b/src/Disqord.Voice.Api/IVoiceGatewayClient.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Disqord.Logging; @@ -25,12 +26,44 @@ public interface IVoiceGatewayClient : ILogging, IAsyncDisposable IJsonSerializer Serializer { get; } + /// + /// Gets the set of currently connected user IDs in the voice session. + /// + IReadOnlySet ConnectedUserIds { get; } + + /// + /// Gets the sequence number of the last numbered message received from the gateway. + /// Used for seq_ack in v8 heartbeats. + /// + int LastSequenceNumber { get; } + + /// + /// Gets the maximum DAVE protocol version advertised during identification. + /// + int MaxDaveProtocolVersion { get; } + + /// + /// Suspends the gateway dispatch loop after the next session description is received, + /// allowing the caller to finish setup (e.g., DAVE handler initialization) before + /// subsequent messages are processed. Call + /// to resume the loop. + /// + void SuspendAfterSessionDescription(); + + /// + /// Resumes the gateway dispatch loop after it was suspended by . + /// + /// An optional handler for DAVE protocol messages, invoked inline in the dispatch loop. + void ResumeAfterSessionDescription(Func? daveMessageHandler); + Task WaitForReadyAsync(CancellationToken cancellationToken); Task WaitForSessionDescriptionAsync(CancellationToken cancellationToken); Task SendAsync(VoiceGatewayPayloadJsonModel payload, CancellationToken cancellationToken = default); + Task SendBinaryAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default); + /// /// Runs this . /// diff --git a/src/Disqord.Voice.Api/Models/Payloads/ClientConnectJsonModel.cs b/src/Disqord.Voice.Api/Models/Payloads/ClientConnectJsonModel.cs new file mode 100644 index 000000000..067b050ab --- /dev/null +++ b/src/Disqord.Voice.Api/Models/Payloads/ClientConnectJsonModel.cs @@ -0,0 +1,9 @@ +using Disqord.Serialization.Json; + +namespace Disqord.Voice.Api.Models; + +public class ClientConnectJsonModel : JsonModel +{ + [JsonProperty("user_ids")] + public Snowflake[] UserIds = null!; +} diff --git a/src/Disqord.Voice.Api/Models/Payloads/ClientDisconnectJsonModel.cs b/src/Disqord.Voice.Api/Models/Payloads/ClientDisconnectJsonModel.cs new file mode 100644 index 000000000..02dadc03e --- /dev/null +++ b/src/Disqord.Voice.Api/Models/Payloads/ClientDisconnectJsonModel.cs @@ -0,0 +1,9 @@ +using Disqord.Serialization.Json; + +namespace Disqord.Voice.Api.Models; + +public class ClientDisconnectJsonModel : JsonModel +{ + [JsonProperty("user_id")] + public Snowflake UserId; +} diff --git a/src/Disqord.Voice.Api/Models/Payloads/DaveExecuteTransitionJsonModel.cs b/src/Disqord.Voice.Api/Models/Payloads/DaveExecuteTransitionJsonModel.cs new file mode 100644 index 000000000..b9e74fa0f --- /dev/null +++ b/src/Disqord.Voice.Api/Models/Payloads/DaveExecuteTransitionJsonModel.cs @@ -0,0 +1,9 @@ +using Disqord.Serialization.Json; + +namespace Disqord.Voice.Api.Models; + +public class DaveExecuteTransitionJsonModel : JsonModel +{ + [JsonProperty("transition_id")] + public int TransitionId; +} diff --git a/src/Disqord.Voice.Api/Models/Payloads/DaveInvalidCommitWelcomeJsonModel.cs b/src/Disqord.Voice.Api/Models/Payloads/DaveInvalidCommitWelcomeJsonModel.cs new file mode 100644 index 000000000..910bbb7de --- /dev/null +++ b/src/Disqord.Voice.Api/Models/Payloads/DaveInvalidCommitWelcomeJsonModel.cs @@ -0,0 +1,9 @@ +using Disqord.Serialization.Json; + +namespace Disqord.Voice.Api.Models; + +public class DaveInvalidCommitWelcomeJsonModel : JsonModel +{ + [JsonProperty("transition_id")] + public int TransitionId; +} diff --git a/src/Disqord.Voice.Api/Models/Payloads/DavePrepareEpochJsonModel.cs b/src/Disqord.Voice.Api/Models/Payloads/DavePrepareEpochJsonModel.cs new file mode 100644 index 000000000..feeb92e8e --- /dev/null +++ b/src/Disqord.Voice.Api/Models/Payloads/DavePrepareEpochJsonModel.cs @@ -0,0 +1,12 @@ +using Disqord.Serialization.Json; + +namespace Disqord.Voice.Api.Models; + +public class DavePrepareEpochJsonModel : JsonModel +{ + [JsonProperty("protocol_version")] + public int ProtocolVersion; + + [JsonProperty("epoch")] + public int Epoch; +} diff --git a/src/Disqord.Voice.Api/Models/Payloads/DavePrepareTransitionJsonModel.cs b/src/Disqord.Voice.Api/Models/Payloads/DavePrepareTransitionJsonModel.cs new file mode 100644 index 000000000..a46ea71a2 --- /dev/null +++ b/src/Disqord.Voice.Api/Models/Payloads/DavePrepareTransitionJsonModel.cs @@ -0,0 +1,12 @@ +using Disqord.Serialization.Json; + +namespace Disqord.Voice.Api.Models; + +public class DavePrepareTransitionJsonModel : JsonModel +{ + [JsonProperty("protocol_version")] + public int ProtocolVersion; + + [JsonProperty("transition_id")] + public int TransitionId; +} diff --git a/src/Disqord.Voice.Api/Models/Payloads/DaveTransitionReadyJsonModel.cs b/src/Disqord.Voice.Api/Models/Payloads/DaveTransitionReadyJsonModel.cs new file mode 100644 index 000000000..a2613e254 --- /dev/null +++ b/src/Disqord.Voice.Api/Models/Payloads/DaveTransitionReadyJsonModel.cs @@ -0,0 +1,9 @@ +using Disqord.Serialization.Json; + +namespace Disqord.Voice.Api.Models; + +public class DaveTransitionReadyJsonModel : JsonModel +{ + [JsonProperty("transition_id")] + public int TransitionId; +} diff --git a/src/Disqord.Voice.Api/Models/Payloads/HeartbeatJsonModel.cs b/src/Disqord.Voice.Api/Models/Payloads/HeartbeatJsonModel.cs new file mode 100644 index 000000000..163b6e540 --- /dev/null +++ b/src/Disqord.Voice.Api/Models/Payloads/HeartbeatJsonModel.cs @@ -0,0 +1,12 @@ +using Disqord.Serialization.Json; + +namespace Disqord.Voice.Api.Models; + +public class HeartbeatJsonModel : JsonModel +{ + [JsonProperty("t")] + public long T; + + [JsonProperty("seq_ack")] + public int SeqAck; +} diff --git a/src/Disqord.Voice.Api/Models/Payloads/IdentifyJsonModel.cs b/src/Disqord.Voice.Api/Models/Payloads/IdentifyJsonModel.cs index f12d4f782..57b16c66a 100644 --- a/src/Disqord.Voice.Api/Models/Payloads/IdentifyJsonModel.cs +++ b/src/Disqord.Voice.Api/Models/Payloads/IdentifyJsonModel.cs @@ -15,4 +15,7 @@ public class IdentifyJsonModel : JsonModel [JsonProperty("token")] public string Token = null!; + + [JsonProperty("max_dave_protocol_version")] + public int? MaxDaveProtocolVersion; } diff --git a/src/Disqord.Voice.Api/Models/Payloads/SessionDescriptionJsonModel.cs b/src/Disqord.Voice.Api/Models/Payloads/SessionDescriptionJsonModel.cs index d0b670978..6936963a2 100644 --- a/src/Disqord.Voice.Api/Models/Payloads/SessionDescriptionJsonModel.cs +++ b/src/Disqord.Voice.Api/Models/Payloads/SessionDescriptionJsonModel.cs @@ -9,4 +9,7 @@ public class SessionDescriptionJsonModel : JsonModel [JsonProperty("mode")] public string Mode = null!; + + [JsonProperty("dave_protocol_version")] + public int? DaveProtocolVersion; } diff --git a/src/Disqord.Voice.Api/Models/VoiceGatewayMessage.cs b/src/Disqord.Voice.Api/Models/VoiceGatewayMessage.cs new file mode 100644 index 000000000..de04d947c --- /dev/null +++ b/src/Disqord.Voice.Api/Models/VoiceGatewayMessage.cs @@ -0,0 +1,59 @@ +using System; + +namespace Disqord.Voice.Api.Models; + +/// +/// Represents a voice gateway message that can be either a JSON payload or a binary DAVE payload. +/// +public readonly struct VoiceGatewayMessage +{ + /// + /// Gets the operation code of this message. + /// + public VoiceGatewayPayloadOperation Op { get; } + + /// + /// Gets the sequence number of this message, if present. + /// + public int? SequenceNumber { get; } + + /// + /// Gets whether this is a binary message. + /// + public bool IsBinary => JsonPayload == null; + + /// + /// Gets the JSON payload, if this is a JSON message. + /// + public VoiceGatewayPayloadJsonModel? JsonPayload { get; } + + /// + /// Gets the binary payload data (excluding the opcode/sequence header), if this is a binary message. + /// + public ReadOnlyMemory BinaryPayload { get; } + + private VoiceGatewayMessage(VoiceGatewayPayloadOperation op, int? sequenceNumber, + VoiceGatewayPayloadJsonModel? jsonPayload, ReadOnlyMemory binaryPayload) + { + Op = op; + SequenceNumber = sequenceNumber; + JsonPayload = jsonPayload; + BinaryPayload = binaryPayload; + } + + /// + /// Creates a JSON voice gateway message. + /// + public static VoiceGatewayMessage FromJson(VoiceGatewayPayloadJsonModel payload) + { + return new VoiceGatewayMessage(payload.Op, payload.S, payload, binaryPayload: default); + } + + /// + /// Creates a binary voice gateway message. + /// + public static VoiceGatewayMessage FromBinary(VoiceGatewayPayloadOperation op, int? sequenceNumber, ReadOnlyMemory payload) + { + return new VoiceGatewayMessage(op, sequenceNumber, jsonPayload: null, payload); + } +} diff --git a/src/Disqord.Voice.Api/VoiceGatewayPayloadOperation.cs b/src/Disqord.Voice.Api/VoiceGatewayPayloadOperation.cs index c3c1bdc7b..b1021acca 100644 --- a/src/Disqord.Voice.Api/VoiceGatewayPayloadOperation.cs +++ b/src/Disqord.Voice.Api/VoiceGatewayPayloadOperation.cs @@ -22,7 +22,35 @@ public enum VoiceGatewayPayloadOperation : byte Resumed = 9, - ClientConnect = 12, + ClientConnect = 11, - ClientDisconnect = 13 + ClientDisconnect = 13, + + MediaSinkWants = 15, + + ClientFlags = 18, + + ChannelOptionsUpdate = 20, + + DaveProtocolPrepareTransition = 21, + + DaveProtocolExecuteTransition = 22, + + DaveProtocolTransitionReady = 23, + + DaveProtocolPrepareEpoch = 24, + + DaveMlsExternalSenderPackage = 25, + + DaveMlsKeyPackage = 26, + + DaveMlsProposals = 27, + + DaveMlsCommitWelcome = 28, + + DaveMlsAnnounceCommitTransition = 29, + + DaveMlsWelcome = 30, + + DaveMlsInvalidCommitWelcome = 31 } diff --git a/src/Disqord.Voice/Default/DaveProtocolHandler.cs b/src/Disqord.Voice/Default/DaveProtocolHandler.cs new file mode 100644 index 000000000..3e61e75ba --- /dev/null +++ b/src/Disqord.Voice/Default/DaveProtocolHandler.cs @@ -0,0 +1,545 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Disqord.Voice.Api; +using Disqord.Voice.Api.Models; +using Microsoft.Extensions.Logging; +using Qommon.Pooling; + +namespace Disqord.Voice.Default; + +/// +/// Handles the DAVE (Discord Audio/Video E2EE) protocol state machine. +/// Based on Discord's official DaveSessionManager reference implementation. +/// +internal sealed class DaveProtocolHandler : IDisposable +{ + private const int InitTransitionId = 0; + + public DaveEncryptor Encryptor => _encryptor; + + public DaveDecryptor Decryptor => _decryptor; + + private readonly ILogger _logger; + private readonly IVoiceGatewayClient _gateway; + private readonly Snowflake _guildId; + private readonly Snowflake _selfUserId; + + private DaveSession? _session; + private readonly Dave.MlsFailureCallback _mlsFailureCallback; + private readonly DaveEncryptor _encryptor; + private readonly DaveDecryptor _decryptor; + private ushort _protocolVersion; + + /// + /// Whether the bot has joined the MLS group (via Welcome or Commit). + /// Proposals cannot be processed before joining. + /// + private bool _hasJoinedGroup; + + /// + /// Maps transition ID → protocol version for pending transitions. + /// Multiple transitions can be in flight simultaneously. + /// + private readonly Dictionary _pendingTransitions = new(); + + /// + /// Tracks the latest protocol version that was prepared, used to set up + /// key ratchets for newly connecting users mid-transition. + /// + private ushort _latestPreparedTransitionVersion; + + public unsafe DaveProtocolHandler( + IVoiceGatewayClient gateway, + ushort protocolVersion, + Snowflake guildId, + Snowflake selfUserId, + ILoggerFactory loggerFactory) + { + _gateway = gateway; + _protocolVersion = protocolVersion; + _guildId = guildId; + _selfUserId = selfUserId; + _logger = loggerFactory.CreateLogger($"Voice {guildId} DAVE"); + + Dave.SetLoggerFactory(loggerFactory); + + _mlsFailureCallback = (source, reason, _) => + { + var sourceStr = Marshal.PtrToStringUTF8((nint) source); + var reasonStr = Marshal.PtrToStringUTF8((nint) reason); + _logger.LogError("MLS failure: {Source} {Reason}", sourceStr, reasonStr); + }; + + _encryptor = new DaveEncryptor(); + _encryptor.SetPassthroughMode(true); + + _decryptor = new DaveDecryptor(); + _decryptor.TransitionToPassthroughMode(true); + } + + public Task InitializeAsync(CancellationToken cancellationToken) + { + _logger.LogDebug("DAVE protocol handler initializing (version {0}).", _protocolVersion); + + if (_protocolVersion > 0) + { + HandlePrepareEpochCore(_protocolVersion, epoch: 1); + return SendKeyPackageAsync(cancellationToken); + } + + // Protocol version 0 means DAVE is disabled. + PrepareDaveProtocolRatchets(InitTransitionId, 0); + ExecuteTransitionCore(InitTransitionId); + return Task.CompletedTask; + } + + /// + /// Handles a DAVE protocol message inline within the gateway dispatch loop. + /// + public async Task HandleMessageAsync(VoiceGatewayMessage message, CancellationToken cancellationToken) + { + switch (message.Op) + { + case VoiceGatewayPayloadOperation.ClientConnect: + { + HandleClientConnect(message); + break; + } + + case VoiceGatewayPayloadOperation.ClientDisconnect: + { + HandleClientDisconnect(message); + break; + } + + case VoiceGatewayPayloadOperation.DaveMlsExternalSenderPackage: + { + HandleExternalSenderPackage(message.BinaryPayload.Span); + break; + } + + case VoiceGatewayPayloadOperation.DaveProtocolPrepareEpoch: + { + await HandlePrepareEpochAsync(message, cancellationToken).ConfigureAwait(false); + break; + } + + case VoiceGatewayPayloadOperation.DaveMlsProposals: + { + await HandleProposalsAsync(message.BinaryPayload, cancellationToken).ConfigureAwait(false); + break; + } + + case VoiceGatewayPayloadOperation.DaveMlsAnnounceCommitTransition: + { + await HandleAnnounceCommitTransitionAsync(message.BinaryPayload, cancellationToken).ConfigureAwait(false); + break; + } + + case VoiceGatewayPayloadOperation.DaveMlsWelcome: + { + await HandleWelcomeAsync(message.BinaryPayload, cancellationToken).ConfigureAwait(false); + break; + } + + case VoiceGatewayPayloadOperation.DaveProtocolPrepareTransition: + { + await HandlePrepareTransitionAsync(message, cancellationToken).ConfigureAwait(false); + break; + } + + case VoiceGatewayPayloadOperation.DaveProtocolExecuteTransition: + { + HandleExecuteTransition(message); + break; + } + + default: + { + _logger.LogWarning("Unexpected DAVE opcode {0}.", message.Op); + break; + } + } + } + + private void HandleClientConnect(VoiceGatewayMessage message) + { + var model = message.JsonPayload!.D!.ToType()!; + foreach (var userId in model.UserIds) + { + SetupKeyRatchetForUser(userId, _latestPreparedTransitionVersion); + } + } + + private void HandleClientDisconnect(VoiceGatewayMessage message) + { } + + private void HandleExternalSenderPackage(ReadOnlySpan payload) + { + _logger.LogDebug("Received DAVE external sender package ({0} bytes).", payload.Length); + _session!.SetExternalSender(payload); + } + + private async Task HandlePrepareEpochAsync(VoiceGatewayMessage message, CancellationToken cancellationToken) + { + var model = message.JsonPayload!.D!.ToType()!; + _logger.LogDebug("Preparing DAVE epoch {0} with protocol version {1}.", model.Epoch, model.ProtocolVersion); + + if (model.Epoch == 1) + { + _protocolVersion = (ushort) model.ProtocolVersion; + HandlePrepareEpochCore(_protocolVersion, model.Epoch); + await SendKeyPackageAsync(cancellationToken).ConfigureAwait(false); + } + } + + private void HandlePrepareEpochCore(ushort protocolVersion, int epoch) + { + if (epoch == 1) + { + _hasJoinedGroup = false; + + if (_session != null) + { + _session.Reset(); + _session.SetProtocolVersion(protocolVersion); + } + else + { + _session = DaveSession.Create(_mlsFailureCallback); + } + + _session.Init(protocolVersion, _guildId, _selfUserId); + } + } + + private async Task HandleProposalsAsync(ReadOnlyMemory payload, CancellationToken cancellationToken) + { + if (!_hasJoinedGroup) + { + _logger.LogDebug("Skipping DAVE MLS proposals ({0} bytes) - not yet joined the MLS group.", payload.Length); + return; + } + + _logger.LogDebug("Processing DAVE MLS proposals ({0} bytes).", payload.Length); + + using var recognizedUserIds = GetRecognizedUserIds(); + + var sendBuffer = default(RentedArray); + using (var commitWelcome = _session!.ProcessProposals(payload.Span, recognizedUserIds.AsSpan())) + { + if (commitWelcome.Length > 0) + { + sendBuffer = RentedArray.Rent(1 + commitWelcome.Length); + sendBuffer[0] = (byte) VoiceGatewayPayloadOperation.DaveMlsCommitWelcome; + commitWelcome.Span.CopyTo(sendBuffer.AsSpan(1)); + } + } + + if (sendBuffer.Length > 0) + { + try + { + _logger.LogDebug("Sending DAVE commit/welcome ({0} bytes).", sendBuffer.Length - 1); + await _gateway.SendBinaryAsync(sendBuffer.AsMemory(), cancellationToken).ConfigureAwait(false); + } + finally + { + sendBuffer.Dispose(); + } + } + } + + private async Task HandleAnnounceCommitTransitionAsync(ReadOnlyMemory payload, CancellationToken cancellationToken) + { + var span = payload.Span; + if (span.Length < 2) + { + _logger.LogWarning("DAVE announce commit transition payload too short."); + return; + } + + var transitionId = (int) BinaryPrimitives.ReadUInt16BigEndian(span); + var commitData = span[2..]; + + _logger.LogDebug("Received DAVE commit for transition {0} ({1} bytes).", transitionId, commitData.Length); + + var result = default(DaveCommitResult); + try + { + result = _session!.ProcessCommit(commitData); + + if (result.IsFailed) + { + _logger.LogWarning("DAVE commit processing failed for transition {0}.", transitionId); + await RecoverFromInvalidTransitionAsync(transitionId, cancellationToken).ConfigureAwait(false); + return; + } + + if (result.IsIgnored) + { + _logger.LogDebug("DAVE commit ignored (we were the committer) for transition {0}.", transitionId); + return; + } + + _hasJoinedGroup = true; + + PrepareDaveProtocolRatchets(transitionId, _session!.GetProtocolVersion()); + + if (transitionId == InitTransitionId) + { + ExecuteTransitionCore(InitTransitionId); + } + else + { + await SendTransitionReadyAsync(transitionId, cancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "DAVE commit processing failed for transition {0}.", transitionId); + await RecoverFromInvalidTransitionAsync(transitionId, cancellationToken).ConfigureAwait(false); + } + finally + { + result.Dispose(); + } + } + + private async Task HandleWelcomeAsync(ReadOnlyMemory payload, CancellationToken cancellationToken) + { + var span = payload.Span; + if (span.Length < 2) + { + _logger.LogWarning("DAVE welcome payload too short."); + return; + } + + var transitionId = (int) BinaryPrimitives.ReadUInt16BigEndian(span); + var welcomeData = span[2..]; + + _logger.LogDebug("Received DAVE welcome for transition {0} ({1} bytes).", transitionId, welcomeData.Length); + + using var recognizedUserIds = GetRecognizedUserIds(); + var result = default(DaveWelcomeResult); + try + { + result = _session!.ProcessWelcome(welcomeData, recognizedUserIds.AsSpan()); + + if (!result.IsValid) + { + _logger.LogWarning("DAVE welcome processing failed for transition {0} (did not join group).", transitionId); + await SendInvalidCommitWelcomeAsync(transitionId, cancellationToken).ConfigureAwait(false); + await SendKeyPackageAsync(cancellationToken).ConfigureAwait(false); + return; + } + + _hasJoinedGroup = true; + + PrepareDaveProtocolRatchets(transitionId, _session!.GetProtocolVersion()); + + if (transitionId == InitTransitionId) + { + ExecuteTransitionCore(InitTransitionId); + } + else + { + await SendTransitionReadyAsync(transitionId, cancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "DAVE welcome processing failed for transition {0}.", transitionId); + await RecoverFromInvalidTransitionAsync(transitionId, cancellationToken).ConfigureAwait(false); + } + finally + { + result.Dispose(); + } + } + + private async Task HandlePrepareTransitionAsync(VoiceGatewayMessage message, CancellationToken cancellationToken) + { + var model = message.JsonPayload!.D!.ToType()!; + _logger.LogDebug("Preparing DAVE transition {0} with protocol version {1}.", model.TransitionId, model.ProtocolVersion); + + PrepareDaveProtocolRatchets(model.TransitionId, (ushort) model.ProtocolVersion); + + if (model.TransitionId != InitTransitionId) + { + await SendTransitionReadyAsync(model.TransitionId, cancellationToken).ConfigureAwait(false); + } + } + + private void HandleExecuteTransition(VoiceGatewayMessage message) + { + var model = message.JsonPayload!.D!.ToType()!; + _logger.LogDebug("Executing DAVE transition {0}.", model.TransitionId); + + ExecuteTransitionCore(model.TransitionId); + } + + private void PrepareDaveProtocolRatchets(int transitionId, ushort protocolVersion) + { + if (protocolVersion > 0 && _session != null) + { + foreach (var userId in _gateway.ConnectedUserIds) + { + if (userId == _selfUserId) + continue; + + SetupKeyRatchetForUser(userId, protocolVersion); + } + } + + if (transitionId == InitTransitionId) + { + SetupKeyRatchetForUser(_selfUserId, protocolVersion); + } + else + { + _pendingTransitions[transitionId] = protocolVersion; + } + + _latestPreparedTransitionVersion = protocolVersion; + _logger.LogDebug("Prepared DAVE ratchets for transition {0}, version {1}.", transitionId, protocolVersion); + } + + private void ExecuteTransitionCore(int transitionId) + { + if (!_pendingTransitions.Remove(transitionId, out var protocolVersion)) + { + if (transitionId != InitTransitionId) + { + _logger.LogWarning("No pending transition found for transition {0}.", transitionId); + return; + } + + protocolVersion = _latestPreparedTransitionVersion; + } + + var oldVersion = _protocolVersion; + _protocolVersion = protocolVersion; + + if (protocolVersion == 0) + { + _session?.Reset(); + _encryptor?.SetPassthroughMode(true); + } + else + { + _encryptor?.SetPassthroughMode(false); + _decryptor?.TransitionToPassthroughMode(false); + } + + SetupKeyRatchetForUser(_selfUserId, protocolVersion); + + _logger.LogInformation("DAVE transition {0} executed, version {1} -> {2}.", transitionId, oldVersion, _protocolVersion); + } + + private void SetupKeyRatchetForUser(Snowflake userId, ushort protocolVersion) + { + if (protocolVersion == 0 || _session == null) + return; + + using var ratchet = _session.GetKeyRatchet(userId); + if (userId == _selfUserId) + { + _encryptor?.SetKeyRatchet(ratchet); + } + else + { + _decryptor?.TransitionToKeyRatchet(ratchet); + } + } + + private async Task RecoverFromInvalidTransitionAsync(int transitionId, CancellationToken cancellationToken) + { + _logger.LogWarning("Recovering from invalid transition {0}.", transitionId); + + _hasJoinedGroup = false; + + await SendInvalidCommitWelcomeAsync(transitionId, cancellationToken).ConfigureAwait(false); + + _session?.Reset(); + _session?.Init(_protocolVersion, _guildId, _selfUserId); + + await SendKeyPackageAsync(cancellationToken).ConfigureAwait(false); + } + + private RentedArray GetRecognizedUserIds() + { + var connectedUsers = _gateway.ConnectedUserIds; + var result = RentedArray.Rent(connectedUsers.Count + 1); + var i = 0; + foreach (var userId in connectedUsers) + { + result[i++] = userId; + } + + result[i] = _selfUserId; + return result; + } + + private async Task SendKeyPackageAsync(CancellationToken cancellationToken) + { + RentedArray buffer; + using (var keyPackage = _session!.GetMarshalledKeyPackage()) + { + _logger.LogDebug("Sending DAVE key package ({0} bytes).", keyPackage.Span.Length); + + buffer = RentedArray.Rent(1 + keyPackage.Span.Length); + buffer[0] = (byte) VoiceGatewayPayloadOperation.DaveMlsKeyPackage; + keyPackage.Span.CopyTo(buffer.AsSpan(1)); + } + + try + { + await _gateway.SendBinaryAsync(buffer.AsMemory(), cancellationToken).ConfigureAwait(false); + } + finally + { + buffer.Dispose(); + } + } + + private Task SendTransitionReadyAsync(int transitionId, CancellationToken cancellationToken) + { + _logger.LogDebug("Sending DAVE transition ready for transition {0}.", transitionId); + + return _gateway.SendAsync(new VoiceGatewayPayloadJsonModel + { + Op = VoiceGatewayPayloadOperation.DaveProtocolTransitionReady, + D = new DaveTransitionReadyJsonModel + { + TransitionId = transitionId + } + }, cancellationToken); + } + + private Task SendInvalidCommitWelcomeAsync(int transitionId, CancellationToken cancellationToken) + { + _logger.LogWarning("Sending DAVE invalid commit/welcome for transition {0}.", transitionId); + + return _gateway.SendAsync(new VoiceGatewayPayloadJsonModel + { + Op = VoiceGatewayPayloadOperation.DaveMlsInvalidCommitWelcome, + D = new DaveInvalidCommitWelcomeJsonModel + { + TransitionId = transitionId + } + }, cancellationToken); + } + + public void Dispose() + { + _session?.Dispose(); + _session = null; + _encryptor?.Dispose(); + _decryptor?.Dispose(); + } +} diff --git a/src/Disqord.Voice/Default/DefaultVoiceConnection.cs b/src/Disqord.Voice/Default/DefaultVoiceConnection.cs index 4706fd6ad..8a2013626 100644 --- a/src/Disqord.Voice/Default/DefaultVoiceConnection.cs +++ b/src/Disqord.Voice/Default/DefaultVoiceConnection.cs @@ -37,6 +37,7 @@ public Snowflake ChannelId public Snowflake CurrentMemberId { get; } private readonly SetVoiceStateDelegate _setVoiceStateDelegate; + private readonly ILoggerFactory _loggerFactory; private readonly IVoiceGatewayClientFactory _gatewayFactory; private readonly IVoiceUdpClientFactory _udpFactory; private readonly IVoiceSynchronizer _synchronizer; @@ -44,6 +45,7 @@ public Snowflake ChannelId private IVoiceGatewayClient? _gateway; private IVoiceUdpClient? _udp; + private DaveProtocolHandler? _daveHandler; private Snowflake _channelId; private SpeakingFlags? _lastSpeakingFlags; @@ -66,6 +68,7 @@ public DefaultVoiceConnection( IVoiceEncryptionProvider encryptionProvider) { Logger = loggerFactory.CreateLogger($"Voice {guildId}"); + _loggerFactory = loggerFactory; _gatewayFactory = gatewayFactory; _udpFactory = udpFactory; _synchronizer = synchronizer; @@ -105,7 +108,7 @@ public void OnVoiceStateUpdate(Snowflake? channelId, string sessionId) } else { - if (_gateway != null && _udp != null + if (_gateway != null && !_stateUpdateCts.IsCancellationRequested) { // Notifies RunAsync we've been disconnected. @@ -137,7 +140,7 @@ public void OnVoiceServerUpdate(string token, string? endpoint) _voiceServerUpdateTcs.Complete(voiceServer); } - if (_gateway != null && _udp != null + if (_gateway != null && !_stateUpdateCts.IsCancellationRequested) { // Notifies RunAsync the connection data has changed. @@ -287,8 +290,9 @@ public async Task RunAsync(CancellationToken stoppingToken) var voiceState = voiceStateUpdateTask.Result; Logger.LogDebug("Created voice gateway: Session ID: {0}, Token: {1}", voiceState.SessionId, voiceServer.Token); - _gateway = _gatewayFactory.Create(GuildId, CurrentMemberId, voiceState.SessionId, voiceServer.Token, voiceServer.Endpoint, Logger); + _gateway = _gatewayFactory.Create(GuildId, CurrentMemberId, voiceState.SessionId, voiceServer.Token, voiceServer.Endpoint, Dave.IsAvailable ? Dave.MaxSupportedVersion : 0, Logger); + Gateway.SuspendAfterSessionDescription(); var gatewayRunTask = Gateway.RunAsync(linkedCancellationToken); var readyModel = await Gateway.WaitForReadyAsync(linkedCancellationToken).ConfigureAwait(false); @@ -301,6 +305,9 @@ public async Task RunAsync(CancellationToken stoppingToken) return; } + _udp = _udpFactory.Create(readyModel.Ssrc, readyModel.Ip, readyModel.Port, Logger, encryption); + await Udp.ConnectAsync(linkedCancellationToken).ConfigureAwait(false); + await Gateway.SendAsync(new VoiceGatewayPayloadJsonModel { Op = VoiceGatewayPayloadOperation.SelectProtocol, @@ -309,19 +316,36 @@ await Gateway.SendAsync(new VoiceGatewayPayloadJsonModel Protocol = "udp", Data = new SelectProtocolDataJsonModel { - Address = readyModel.Ip, - Port = readyModel.Port, + Address = Udp.RemoteHostName!, + Port = Udp.RemotePort!.Value, Mode = encryption.ModeName } } }, linkedCancellationToken).ConfigureAwait(false); var sessionDescriptionModel = await Gateway.WaitForSessionDescriptionAsync(linkedCancellationToken).ConfigureAwait(false); + if (sessionDescriptionModel.DaveProtocolVersion is > 0) + { + if (!Dave.IsAvailable) + { + var exception = new VoiceConnectionException( + $"The voice server requires DAVE end-to-end encryption (protocol version {sessionDescriptionModel.DaveProtocolVersion}), " + + "but the native 'libdave' library could not be found. " + + "Ensure the native library is available in the application's search path."); - _udp = _udpFactory.Create(readyModel.Ssrc, sessionDescriptionModel.SecretKey, readyModel.Ip, readyModel.Port, encryption); - _synchronizer.Subscribe(_udp); + _readyTcs.Throw(exception); + return; + } - await Udp.ConnectAsync(linkedCancellationToken).ConfigureAwait(false); + _daveHandler = new DaveProtocolHandler(Gateway, (ushort) sessionDescriptionModel.DaveProtocolVersion, GuildId, CurrentMemberId, _loggerFactory); + await _daveHandler.InitializeAsync(linkedCancellationToken).ConfigureAwait(false); + } + + Gateway.ResumeAfterSessionDescription(_daveHandler != null ? _daveHandler.HandleMessageAsync : null); + + Udp.Initialize(sessionDescriptionModel.SecretKey, _daveHandler?.Encryptor); + + _synchronizer.Subscribe(_udp); if (_lastSpeakingFlags != null) { @@ -335,13 +359,13 @@ await Gateway.SendAsync(new VoiceGatewayPayloadJsonModel await gatewayRunTask.ConfigureAwait(false); } - catch (OperationCanceledException ex) when (ex.CancellationToken == linkedCancellationToken && stoppingToken.IsCancellationRequested) + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { await _setVoiceStateDelegate(GuildId, null, default).ConfigureAwait(false); - _readyTcs.Cancel(ex.CancellationToken); + _readyTcs.Cancel(stoppingToken); return; } - catch (OperationCanceledException ex) when (ex.CancellationToken == linkedCancellationToken && stateCancellationToken.IsCancellationRequested) + catch (OperationCanceledException) when (stateCancellationToken.IsCancellationRequested) { lastCloseCode = VoiceGatewayCloseCode.ForciblyDisconnected; } @@ -369,6 +393,9 @@ await Gateway.SendAsync(new VoiceGatewayPayloadJsonModel _udp = null; } + _daveHandler?.Dispose(); + _daveHandler = null; + if (_readyTcs.Task.IsCompleted) { _readyTcs = new Tcs(); @@ -416,6 +443,8 @@ public ValueTask DisposeAsync() _udp.Dispose(); } + _daveHandler?.Dispose(); + if (_gateway != null) { return Gateway.DisposeAsync(); diff --git a/src/Disqord.Voice/Default/DefaultVoiceUdpClient.cs b/src/Disqord.Voice/Default/DefaultVoiceUdpClient.cs index 2f959dafe..ec74147a7 100644 --- a/src/Disqord.Voice/Default/DefaultVoiceUdpClient.cs +++ b/src/Disqord.Voice/Default/DefaultVoiceUdpClient.cs @@ -1,9 +1,12 @@ -using System; +using System; +using System.Buffers; using System.Buffers.Binary; +using System.Text; using System.Threading; using System.Threading.Tasks; using System.Threading.Tasks.Sources; using Disqord.Udp; +using Microsoft.Extensions.Logging; using Qommon; using Qommon.Pooling; @@ -17,8 +20,6 @@ public class DefaultVoiceUdpClient : IVoiceUdpClient, IValueTaskSource public ReadOnlyMemory EncryptionKey => _encryptionKey; - private readonly byte[] _encryptionKey; - public string HostName { get; } public int Port { get; } @@ -33,6 +34,10 @@ public class DefaultVoiceUdpClient : IVoiceUdpClient, IValueTaskSource public uint Timestamp => _timestamp; + private readonly ILogger _logger; + private byte[] _encryptionKey = Array.Empty(); + private DaveEncryptor? _daveEncryptor; + private ushort _sequence; private uint _timestamp; @@ -49,21 +54,28 @@ public class DefaultVoiceUdpClient : IVoiceUdpClient, IValueTaskSource public DefaultVoiceUdpClient( uint ssrc, - byte[] encryptionKey, string hostName, int port, + ILogger logger, IVoiceEncryption encryption, IUdpClientFactory udpClientFactory) { Ssrc = ssrc; - _encryptionKey = encryptionKey; HostName = hostName; Port = port; + _logger = logger; Encryption = encryption; UdpClient = udpClientFactory.CreateClient(); } + public void Initialize(byte[] encryptionKey, DaveEncryptor? daveEncryptor) + { + _encryptionKey = encryptionKey; + _daveEncryptor = daveEncryptor; + daveEncryptor?.AssignSsrcToCodec(Ssrc, Dave.Codec.Opus); + } + /// public void OnSynchronizerTick() { @@ -83,8 +95,11 @@ public async ValueTask ConnectAsync(CancellationToken cancellationToken = defaul WriteDiscovery(packetArray); await UdpClient.SendAsync(packetArray, cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Awaiting discovery packet..."); await UdpClient.ReceiveAsync(packetArray, cancellationToken).ConfigureAwait(false); ReadDiscovery(packetArray); + + _logger.LogDebug("Discovery packet received: {RemoteHostName}:{RemotePort}.", RemoteHostName, RemotePort); } } @@ -109,53 +124,110 @@ public async ValueTask SendAsync(ReadOnlyMemory opus, CancellationToken ca _mrvtsc.Reset(); await new ValueTask(this, _mrvtsc.Version).ConfigureAwait(false); - var encryptedLength = Encryption.GetEncryptedLength(opus.Length); - using (var packetArray = RentedArray.Rent(VoiceConstants.RtpHeaderSize + encryptedLength)) + using (var packet = CreateVoicePacket(opus.Span)) { - WriteVoicePacket(packetArray, opus.Span); - - await UdpClient.SendAsync(packetArray, cancellationToken).ConfigureAwait(false); - - _sequence++; - _timestamp += VoiceConstants.AudioSize; + await UdpClient.SendAsync(packet, cancellationToken).ConfigureAwait(false); } - } - private void WriteDiscovery(Span packet) - { - BinaryPrimitives.WriteInt16BigEndian(packet, 1); - BinaryPrimitives.WriteInt16BigEndian(packet[2..], 70); - BinaryPrimitives.WriteUInt32BigEndian(packet[4..], Ssrc); + _sequence++; + _timestamp += VoiceConstants.AudioSize; } - private unsafe void ReadDiscovery(ReadOnlySpan packet) + private RentedArray CreateVoicePacket(ReadOnlySpan opus) { - fixed (byte* ptr = packet[8..]) + byte[]? rentedDaveBuffer = null; + RentedArray packetArray = default; + try { - RemoteHostName = new string((sbyte*) ptr, 0, 64); + // With DAVE, opus is first encrypted before voice encryption. + if (_daveEncryptor != null) + { + var maxDaveSize = (int) _daveEncryptor.GetMaxCiphertextByteSize(Dave.MediaType.Audio, (nuint) opus.Length); + var daveBuffer = maxDaveSize <= 4096 + ? stackalloc byte[maxDaveSize] + : rentedDaveBuffer = ArrayPool.Shared.Rent(maxDaveSize); + + var result = _daveEncryptor.Encrypt(Dave.MediaType.Audio, Ssrc, opus, daveBuffer, out var bytesWritten); + if (result != Dave.EncryptorResultCode.Success) + { + throw new VoiceEncryptionException($"DAVE encryption failed with result: {result}."); + } + + var ciphertext = daveBuffer[..(int) bytesWritten]; + packetArray = RentPacketAndEncrypt(ciphertext); + } + else + { + packetArray = RentPacketAndEncrypt(opus); + } + + return packetArray; + } + catch (VoiceEncryptionException) + { + packetArray.Dispose(); + throw; + } + catch (Exception ex) + { + packetArray.Dispose(); + throw new VoiceEncryptionException("Failed to encrypt the voice packet.", ex); + } + finally + { + if (rentedDaveBuffer != null) + { + ArrayPool.Shared.Return(rentedDaveBuffer); + } } - - RemotePort = BinaryPrimitives.ReadUInt16BigEndian(packet[72..]); } - private void WriteVoicePacket(Span packet, ReadOnlySpan opus) + private RentedArray RentPacketAndEncrypt(ReadOnlySpan audio) { - packet[0] = 0x80; - packet[1] = 0x78; - BinaryPrimitives.WriteUInt16BigEndian(packet[2..], _sequence); - BinaryPrimitives.WriteUInt32BigEndian(packet[4..], _timestamp); - BinaryPrimitives.WriteUInt32BigEndian(packet[8..], Ssrc); - + var packetSize = VoiceConstants.RtpHeaderSize + Encryption.GetEncryptedLength(audio.Length); + var packetArray = RentedArray.Rent(packetSize); try { - Encryption.Encrypt(packet[..VoiceConstants.RtpHeaderSize], packet[VoiceConstants.RtpHeaderSize..], opus, _encryptionKey); + var packet = packetArray.AsSpan(); + packet[0] = 0x80; + packet[1] = 0x78; + BinaryPrimitives.WriteUInt16BigEndian(packet[2..], _sequence); + BinaryPrimitives.WriteUInt32BigEndian(packet[4..], _timestamp); + BinaryPrimitives.WriteUInt32BigEndian(packet[8..], Ssrc); + + Encryption.Encrypt( + packet[..VoiceConstants.RtpHeaderSize], + packet[VoiceConstants.RtpHeaderSize..], + audio, _encryptionKey); + + return packetArray; } - catch (Exception ex) + catch { - throw new VoiceEncryptionException("Failed to encrypt the voice packet.", ex); + packetArray.Dispose(); + throw; } } + private void WriteDiscovery(Span packet) + { + packet.Clear(); + BinaryPrimitives.WriteInt16BigEndian(packet, 1); + BinaryPrimitives.WriteInt16BigEndian(packet[2..], 70); + BinaryPrimitives.WriteUInt32BigEndian(packet[4..], Ssrc); + } + + private void ReadDiscovery(ReadOnlySpan packet) + { + var addressSpan = packet.Slice(8, 64); + var nullIndex = addressSpan.IndexOf((byte) 0); + if (nullIndex >= 0) + addressSpan = addressSpan[..nullIndex]; + + RemoteHostName = Encoding.UTF8.GetString(addressSpan); + RemotePort = BinaryPrimitives.ReadUInt16BigEndian(packet[72..]); + } + public void Dispose() { if (_isDisposed) diff --git a/src/Disqord.Voice/Disqord.Voice.csproj b/src/Disqord.Voice/Disqord.Voice.csproj index dd0fb92f4..3c4a8f6c1 100644 --- a/src/Disqord.Voice/Disqord.Voice.csproj +++ b/src/Disqord.Voice/Disqord.Voice.csproj @@ -8,4 +8,7 @@ + + + diff --git a/src/Disqord.Voice/Encryption/Dave/Dave.Native.cs b/src/Disqord.Voice/Encryption/Dave/Dave.Native.cs new file mode 100644 index 000000000..1a6a455ae --- /dev/null +++ b/src/Disqord.Voice/Encryption/Dave/Dave.Native.cs @@ -0,0 +1,191 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace Disqord.Voice; + +public static unsafe partial class Dave +{ + [LibraryImport(LibraryName, EntryPoint = "daveMaxSupportedProtocolVersion")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial ushort MaxSupportedProtocolVersion(); + + [LibraryImport(LibraryName, EntryPoint = "daveFree")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void Free(void* ptr); + + [LibraryImport(LibraryName, EntryPoint = "daveSessionCreate")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial nint SessionCreate(nint context, byte* authSessionId, MlsFailureCallback callback, nint userData); + + [LibraryImport(LibraryName, EntryPoint = "daveSessionDestroy")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void SessionDestroy(nint session); + + [LibraryImport(LibraryName, EntryPoint = "daveSessionInit")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void SessionInit(nint session, ushort version, ulong groupId, byte* selfUserId); + + [LibraryImport(LibraryName, EntryPoint = "daveSessionReset")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void SessionReset(nint session); + + [LibraryImport(LibraryName, EntryPoint = "daveSessionSetProtocolVersion")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void SessionSetProtocolVersion(nint session, ushort version); + + [LibraryImport(LibraryName, EntryPoint = "daveSessionGetProtocolVersion")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial ushort SessionGetProtocolVersion(nint session); + + [LibraryImport(LibraryName, EntryPoint = "daveSessionGetLastEpochAuthenticator")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void SessionGetLastEpochAuthenticator(nint session, out byte* authenticator, out nuint length); + + [LibraryImport(LibraryName, EntryPoint = "daveSessionSetExternalSender")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void SessionSetExternalSender(nint session, byte* externalSender, nuint length); + + [LibraryImport(LibraryName, EntryPoint = "daveSessionProcessProposals")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void SessionProcessProposals(nint session, byte* proposals, nuint length, byte** recognizedUserIds, nuint recognizedUserIdsLength, out byte* commitWelcomeBytes, out nuint commitWelcomeBytesLength); + + [LibraryImport(LibraryName, EntryPoint = "daveSessionProcessCommit")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial nint SessionProcessCommit(nint session, byte* commit, nuint length); + + [LibraryImport(LibraryName, EntryPoint = "daveSessionProcessWelcome")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial nint SessionProcessWelcome(nint session, byte* welcome, nuint length, byte** recognizedUserIds, nuint recognizedUserIdsLength); + + [LibraryImport(LibraryName, EntryPoint = "daveSessionGetMarshalledKeyPackage")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void SessionGetMarshalledKeyPackage(nint session, out byte* keyPackage, out nuint length); + + [LibraryImport(LibraryName, EntryPoint = "daveSessionGetKeyRatchet")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial nint SessionGetKeyRatchet(nint session, byte* userId); + + [LibraryImport(LibraryName, EntryPoint = "daveSessionGetPairwiseFingerprint")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void SessionGetPairwiseFingerprint(nint session, ushort version, byte* userId, PairwiseFingerprintCallback callback, nint userData); + + [LibraryImport(LibraryName, EntryPoint = "daveKeyRatchetDestroy")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void KeyRatchetDestroy(nint keyRatchet); + + [LibraryImport(LibraryName, EntryPoint = "daveCommitResultIsFailed")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + [return: MarshalAs(UnmanagedType.U1)] + public static partial bool CommitResultIsFailed(nint commitResultHandle); + + [LibraryImport(LibraryName, EntryPoint = "daveCommitResultIsIgnored")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + [return: MarshalAs(UnmanagedType.U1)] + public static partial bool CommitResultIsIgnored(nint commitResultHandle); + + [LibraryImport(LibraryName, EntryPoint = "daveCommitResultGetRosterMemberIds")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void CommitResultGetRosterMemberIds(nint commitResultHandle, out ulong* rosterIds, out nuint rosterIdsLength); + + [LibraryImport(LibraryName, EntryPoint = "daveCommitResultGetRosterMemberSignature")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void CommitResultGetRosterMemberSignature(nint commitResultHandle, ulong rosterId, out byte* signature, out nuint signatureLength); + + [LibraryImport(LibraryName, EntryPoint = "daveCommitResultDestroy")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void CommitResultDestroy(nint commitResultHandle); + + [LibraryImport(LibraryName, EntryPoint = "daveWelcomeResultGetRosterMemberIds")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void WelcomeResultGetRosterMemberIds(nint welcomeResultHandle, out ulong* rosterIds, out nuint rosterIdsLength); + + [LibraryImport(LibraryName, EntryPoint = "daveWelcomeResultGetRosterMemberSignature")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void WelcomeResultGetRosterMemberSignature(nint welcomeResultHandle, ulong rosterId, out byte* signature, out nuint signatureLength); + + [LibraryImport(LibraryName, EntryPoint = "daveWelcomeResultDestroy")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void WelcomeResultDestroy(nint welcomeResultHandle); + + [LibraryImport(LibraryName, EntryPoint = "daveEncryptorCreate")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial nint EncryptorCreate(); + + [LibraryImport(LibraryName, EntryPoint = "daveEncryptorDestroy")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void EncryptorDestroy(nint encryptor); + + [LibraryImport(LibraryName, EntryPoint = "daveEncryptorSetKeyRatchet")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void EncryptorSetKeyRatchet(nint encryptor, nint keyRatchet); + + [LibraryImport(LibraryName, EntryPoint = "daveEncryptorSetPassthroughMode")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void EncryptorSetPassthroughMode(nint encryptor, [MarshalAs(UnmanagedType.U1)] bool passthroughMode); + + [LibraryImport(LibraryName, EntryPoint = "daveEncryptorAssignSsrcToCodec")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void EncryptorAssignSsrcToCodec(nint encryptor, uint ssrc, Codec codecType); + + [LibraryImport(LibraryName, EntryPoint = "daveEncryptorGetProtocolVersion")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial ushort EncryptorGetProtocolVersion(nint encryptor); + + [LibraryImport(LibraryName, EntryPoint = "daveEncryptorGetMaxCiphertextByteSize")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial nuint EncryptorGetMaxCiphertextByteSize(nint encryptor, MediaType mediaType, nuint frameSize); + + [LibraryImport(LibraryName, EntryPoint = "daveEncryptorHasKeyRatchet")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + [return: MarshalAs(UnmanagedType.U1)] + public static partial bool EncryptorHasKeyRatchet(nint encryptor); + + [LibraryImport(LibraryName, EntryPoint = "daveEncryptorIsPassthroughMode")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + [return: MarshalAs(UnmanagedType.U1)] + public static partial bool EncryptorIsPassthroughMode(nint encryptor); + + [LibraryImport(LibraryName, EntryPoint = "daveEncryptorEncrypt")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial EncryptorResultCode EncryptorEncrypt(nint encryptor, MediaType mediaType, uint ssrc, byte* frame, nuint frameLength, byte* encryptedFrame, nuint encryptedFrameCapacity, out nuint bytesWritten); + + [LibraryImport(LibraryName, EntryPoint = "daveEncryptorSetProtocolVersionChangedCallback")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void EncryptorSetProtocolVersionChangedCallback(nint encryptor, EncryptorProtocolVersionChangedCallback callback, nint userData); + + [LibraryImport(LibraryName, EntryPoint = "daveEncryptorGetStats")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void EncryptorGetStats(nint encryptor, MediaType mediaType, out EncryptorStats stats); + + [LibraryImport(LibraryName, EntryPoint = "daveDecryptorCreate")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial nint DecryptorCreate(); + + [LibraryImport(LibraryName, EntryPoint = "daveDecryptorDestroy")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void DecryptorDestroy(nint decryptor); + + [LibraryImport(LibraryName, EntryPoint = "daveDecryptorTransitionToKeyRatchet")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void DecryptorTransitionToKeyRatchet(nint decryptor, nint keyRatchet); + + [LibraryImport(LibraryName, EntryPoint = "daveDecryptorTransitionToPassthroughMode")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void DecryptorTransitionToPassthroughMode(nint decryptor, [MarshalAs(UnmanagedType.U1)] bool passthroughMode); + + [LibraryImport(LibraryName, EntryPoint = "daveDecryptorDecrypt")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial DecryptorResultCode DecryptorDecrypt(nint decryptor, MediaType mediaType, byte* encryptedFrame, nuint encryptedFrameLength, byte* frame, nuint frameCapacity, out nuint bytesWritten); + + [LibraryImport(LibraryName, EntryPoint = "daveDecryptorGetMaxPlaintextByteSize")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial nuint DecryptorGetMaxPlaintextByteSize(nint decryptor, MediaType mediaType, nuint encryptedFrameSize); + + [LibraryImport(LibraryName, EntryPoint = "daveDecryptorGetStats")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void DecryptorGetStats(nint decryptor, MediaType mediaType, out DecryptorStats stats); + + [LibraryImport(LibraryName, EntryPoint = "daveSetLogSinkCallback")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void SetLogSinkCallback(LogSinkCallback callback); +} diff --git a/src/Disqord.Voice/Encryption/Dave/Dave.cs b/src/Disqord.Voice/Encryption/Dave/Dave.cs new file mode 100644 index 000000000..2a67b87aa --- /dev/null +++ b/src/Disqord.Voice/Encryption/Dave/Dave.cs @@ -0,0 +1,155 @@ +using System; +using System.Runtime.InteropServices; +using System.Threading; +using Microsoft.Extensions.Logging; + +namespace Disqord.Voice; + +/// +/// Represents DAVE (Discord Audio/Video Encryption) interoperability. +/// +/// DAVE Protocol documentation +public static unsafe partial class Dave +{ + private const string LibraryName = "libdave"; + + /// + /// Gets whether the native DAVE library is available. + /// + public static bool IsAvailable { get; } + + /// + /// Gets the maximum supported DAVE protocol version from the native library. + /// + public static ushort MaxSupportedVersion { get; } + + private static readonly LogSinkCallback? _logSinkCallback; + private static ILogger? _nativeLogger; + + static Dave() + { + try + { + MaxSupportedVersion = MaxSupportedProtocolVersion(); + IsAvailable = true; + } + catch (DllNotFoundException) + { + IsAvailable = false; + } + + _logSinkCallback = OnNativeLog; + SetLogSinkCallback(_logSinkCallback); + } + + internal static void SetLoggerFactory(ILoggerFactory loggerFactory) + { + if (Volatile.Read(ref _nativeLogger) != null) + return; + + var logger = loggerFactory.CreateLogger("Voice DAVE"); + Interlocked.CompareExchange(ref _nativeLogger, logger, null); + } + + private static void OnNativeLog(LoggingSeverity severity, byte* file, int line, byte* message) + { + var logger = _nativeLogger; + if (logger == null) + return; + + var logLevel = severity switch + { + LoggingSeverity.Verbose => LogLevel.Trace, + LoggingSeverity.Info => LogLevel.Debug, + LoggingSeverity.Warning => LogLevel.Warning, + LoggingSeverity.Error => LogLevel.Error, + _ => LogLevel.None + }; + + if (!logger.IsEnabled(logLevel)) + return; + + var messageStr = Marshal.PtrToStringUTF8((nint) message); + logger.Log(logLevel, "{Message}", messageStr); + } + + public enum Codec + { + Unknown = 0, + Opus = 1, + VP8 = 2, + VP9 = 3, + H264 = 4, + H265 = 5, + AV1 = 6 + } + + public enum MediaType + { + Audio = 0, + Video = 1 + } + + public enum EncryptorResultCode + { + Success = 0, + EncryptionFailure = 1, + MissingKeyRatchet = 2, + MissingCryptor = 3, + TooManyAttempts = 4 + } + + public enum DecryptorResultCode + { + Success = 0, + DecryptionFailure = 1, + MissingKeyRatchet = 2, + InvalidNonce = 3, + MissingCryptor = 4 + } + + public enum LoggingSeverity + { + Verbose = 0, + Info = 1, + Warning = 2, + Error = 3, + None = 4 + } + + [StructLayout(LayoutKind.Sequential)] + public struct EncryptorStats + { + public ulong PassthroughCount; + public ulong EncryptSuccessCount; + public ulong EncryptFailureCount; + public ulong EncryptDuration; + public ulong EncryptAttempts; + public ulong EncryptMaxAttempts; + public ulong EncryptMissingKeyCount; + } + + [StructLayout(LayoutKind.Sequential)] + public struct DecryptorStats + { + public ulong PassthroughCount; + public ulong DecryptSuccessCount; + public ulong DecryptFailureCount; + public ulong DecryptDuration; + public ulong DecryptAttempts; + public ulong DecryptMissingKeyCount; + public ulong DecryptInvalidNonceCount; + } + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate void MlsFailureCallback(byte* source, byte* reason, nint userData); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate void PairwiseFingerprintCallback(byte* fingerprint, nuint length, nint userData); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate void EncryptorProtocolVersionChangedCallback(nint userData); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate void LogSinkCallback(LoggingSeverity severity, byte* file, int line, byte* message); +} diff --git a/src/Disqord.Voice/Encryption/Dave/DaveCommitResult.cs b/src/Disqord.Voice/Encryption/Dave/DaveCommitResult.cs new file mode 100644 index 000000000..61acd8a76 --- /dev/null +++ b/src/Disqord.Voice/Encryption/Dave/DaveCommitResult.cs @@ -0,0 +1,68 @@ +using System; + +namespace Disqord.Voice; + +/// +/// Represents a managed wrapper over a native DAVE commit result handle. +/// +public unsafe struct DaveCommitResult : IDisposable +{ + /// + /// Gets the native handle of this commit result. + /// + public readonly nint Handle => _handle; + + private nint _handle; + + internal DaveCommitResult(nint handle) + { + _handle = handle; + } + + /// + /// Gets whether processing the commit failed. + /// + public readonly bool IsFailed => Dave.CommitResultIsFailed(_handle); + + /// + /// Gets whether the commit should be ignored. + /// + public readonly bool IsIgnored => Dave.CommitResultIsIgnored(_handle); + + /// + /// Gets the list of member IDs in the roster after the commit. + /// + public readonly Snowflake[] GetRosterMemberIds() + { + Dave.CommitResultGetRosterMemberIds(_handle, out var rosterIds, out var length); + try + { + // Snowflake has the same layout as ulong. + return new ReadOnlySpan(rosterIds, (int) length).ToArray(); + } + finally + { + Dave.Free(rosterIds); + } + } + + /// + /// Gets the signature for a specific roster member. + /// + public readonly DaveNativeBuffer GetRosterMemberSignature(ulong rosterId) + { + Dave.CommitResultGetRosterMemberSignature(_handle, rosterId, out var signature, out var signatureLength); + return new DaveNativeBuffer(signature, (int) signatureLength); + } + + /// + public void Dispose() + { + var handle = _handle; + if (handle == 0) + return; + + _handle = 0; + Dave.CommitResultDestroy(handle); + } +} diff --git a/src/Disqord.Voice/Encryption/Dave/DaveDecryptor.cs b/src/Disqord.Voice/Encryption/Dave/DaveDecryptor.cs new file mode 100644 index 000000000..a3437f43c --- /dev/null +++ b/src/Disqord.Voice/Encryption/Dave/DaveDecryptor.cs @@ -0,0 +1,114 @@ +using System; + +namespace Disqord.Voice; + +/// +/// Represents a managed wrapper over a native DAVE decryptor handle. +/// +public sealed unsafe class DaveDecryptor : IDisposable +{ + /// + /// Gets the native handle of this decryptor. + /// + public nint Handle + { + get + { + ThrowIfDisposed(); + return _handle; + } + } + + private nint _handle; + private bool _isDisposed; + + /// + /// Creates a new DAVE decryptor. + /// + public DaveDecryptor() + : this(Dave.DecryptorCreate()) + { } + + private DaveDecryptor(nint handle) + { + _handle = handle; + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_isDisposed, this); + } + + /// + /// Transitions the decryptor to use a new key ratchet. + /// + public void TransitionToKeyRatchet(DaveKeyRatchet keyRatchet) + { + ThrowIfDisposed(); + Dave.DecryptorTransitionToKeyRatchet(_handle, keyRatchet.Handle); + } + + /// + /// Transitions to or from passthrough mode. + /// + public void TransitionToPassthroughMode(bool passthroughMode) + { + ThrowIfDisposed(); + Dave.DecryptorTransitionToPassthroughMode(_handle, passthroughMode); + } + + /// + /// Decrypts an encrypted media frame. + /// + /// The media type (audio or video). + /// The encrypted frame data. + /// The output buffer for the decrypted frame. + /// The number of bytes written to the output buffer. + /// The result code indicating success or failure. + public Dave.DecryptorResultCode Decrypt(Dave.MediaType mediaType, + ReadOnlySpan encryptedFrame, Span frame, out nuint bytesWritten) + { + ThrowIfDisposed(); + + fixed (byte* encryptedFramePtr = encryptedFrame) + fixed (byte* framePtr = frame) + { + var result = Dave.DecryptorDecrypt(_handle, mediaType, + encryptedFramePtr, (nuint) encryptedFrame.Length, + framePtr, (nuint) frame.Length, out bytesWritten); + + return result; + } + } + + /// + /// Calculates the maximum plaintext size for a given ciphertext frame size. + /// + public nuint GetMaxPlaintextByteSize(Dave.MediaType mediaType, nuint encryptedFrameSize) + { + ThrowIfDisposed(); + return Dave.DecryptorGetMaxPlaintextByteSize(_handle, mediaType, encryptedFrameSize); + } + + /// + /// Gets decryption statistics. + /// + public Dave.DecryptorStats GetStats(Dave.MediaType mediaType) + { + ThrowIfDisposed(); + + Dave.DecryptorGetStats(_handle, mediaType, out var stats); + return stats; + } + + /// + public void Dispose() + { + if (_isDisposed) + return; + + _isDisposed = true; + Dave.DecryptorDestroy(_handle); + _handle = 0; + } +} diff --git a/src/Disqord.Voice/Encryption/Dave/DaveEncryptor.cs b/src/Disqord.Voice/Encryption/Dave/DaveEncryptor.cs new file mode 100644 index 000000000..8f5dfb160 --- /dev/null +++ b/src/Disqord.Voice/Encryption/Dave/DaveEncryptor.cs @@ -0,0 +1,157 @@ +using System; + +namespace Disqord.Voice; + +/// +/// Represents a managed wrapper over a native DAVE encryptor handle. +/// +public sealed unsafe class DaveEncryptor : IDisposable +{ + /// + /// Gets the native handle of this encryptor. + /// + public nint Handle + { + get + { + ThrowIfDisposed(); + return _handle; + } + } + + private nint _handle; + private bool _isDisposed; + + /// + /// Creates a new DAVE encryptor. + /// + public DaveEncryptor() + : this(Dave.EncryptorCreate()) + { } + + private DaveEncryptor(nint handle) + { + _handle = handle; + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_isDisposed, this); + } + + /// + /// Gets whether the encryptor has a key ratchet. + /// + public bool HasKeyRatchet + { + get + { + ThrowIfDisposed(); + return Dave.EncryptorHasKeyRatchet(_handle); + } + } + + /// + /// Gets whether the encryptor is in passthrough mode. + /// + public bool IsPassthroughMode + { + get + { + ThrowIfDisposed(); + return Dave.EncryptorIsPassthroughMode(_handle); + } + } + + /// + /// Sets the key ratchet for encryption. + /// + public void SetKeyRatchet(DaveKeyRatchet keyRatchet) + { + ThrowIfDisposed(); + Dave.EncryptorSetKeyRatchet(_handle, keyRatchet.Handle); + } + + /// + /// Enables or disables passthrough mode. + /// + public void SetPassthroughMode(bool passthroughMode) + { + ThrowIfDisposed(); + Dave.EncryptorSetPassthroughMode(_handle, passthroughMode); + } + + /// + /// Associates an SSRC with a specific codec. + /// + public void AssignSsrcToCodec(uint ssrc, Dave.Codec codecType) + { + ThrowIfDisposed(); + Dave.EncryptorAssignSsrcToCodec(_handle, ssrc, codecType); + } + + /// + /// Gets the current protocol version used by the encryptor. + /// + public ushort GetProtocolVersion() + { + ThrowIfDisposed(); + return Dave.EncryptorGetProtocolVersion(_handle); + } + + /// + /// Calculates the maximum ciphertext size for a given plaintext frame size. + /// + public nuint GetMaxCiphertextByteSize(Dave.MediaType mediaType, nuint frameSize) + { + ThrowIfDisposed(); + return Dave.EncryptorGetMaxCiphertextByteSize(_handle, mediaType, frameSize); + } + + /// + /// Encrypts a media frame. + /// + /// The media type (audio or video). + /// The SSRC of the stream. + /// The plaintext frame data. + /// The output buffer for the encrypted frame. + /// The number of bytes written to the output buffer. + /// The result code indicating success or failure. + public Dave.EncryptorResultCode Encrypt(Dave.MediaType mediaType, uint ssrc, + ReadOnlySpan frame, Span encryptedFrame, out nuint bytesWritten) + { + ThrowIfDisposed(); + + fixed (byte* framePtr = frame) + fixed (byte* encryptedFramePtr = encryptedFrame) + { + var result = Dave.EncryptorEncrypt(_handle, mediaType, ssrc, + framePtr, (nuint) frame.Length, + encryptedFramePtr, (nuint) encryptedFrame.Length, out bytesWritten); + + return result; + } + } + + /// + /// Gets encryption statistics. + /// + public Dave.EncryptorStats GetStats(Dave.MediaType mediaType) + { + ThrowIfDisposed(); + + Dave.EncryptorGetStats(_handle, mediaType, out var stats); + return stats; + } + + /// + public void Dispose() + { + if (_isDisposed) + return; + + _isDisposed = true; + Dave.EncryptorDestroy(_handle); + _handle = 0; + } +} diff --git a/src/Disqord.Voice/Encryption/Dave/DaveKeyRatchet.cs b/src/Disqord.Voice/Encryption/Dave/DaveKeyRatchet.cs new file mode 100644 index 000000000..899890214 --- /dev/null +++ b/src/Disqord.Voice/Encryption/Dave/DaveKeyRatchet.cs @@ -0,0 +1,32 @@ +using System; + +namespace Disqord.Voice; + +/// +/// Represents a managed wrapper over a native DAVE key ratchet handle. +/// +public struct DaveKeyRatchet : IDisposable +{ + /// + /// Gets the native handle of this key ratchet. + /// + public readonly nint Handle => _handle; + + private nint _handle; + + internal DaveKeyRatchet(nint handle) + { + _handle = handle; + } + + /// + public void Dispose() + { + var handle = _handle; + if (handle == 0) + return; + + _handle = 0; + Dave.KeyRatchetDestroy(handle); + } +} diff --git a/src/Disqord.Voice/Encryption/Dave/DaveNativeBuffer.cs b/src/Disqord.Voice/Encryption/Dave/DaveNativeBuffer.cs new file mode 100644 index 000000000..2489b40ac --- /dev/null +++ b/src/Disqord.Voice/Encryption/Dave/DaveNativeBuffer.cs @@ -0,0 +1,40 @@ +using System; + +namespace Disqord.Voice; + +/// +/// Represents a disposable view over native DAVE-allocated memory. +/// The underlying memory is freed when this value is disposed. +/// +public unsafe ref struct DaveNativeBuffer +{ + /// + /// Gets a read-only span over the native memory. + /// + public readonly ReadOnlySpan Span => new(_ptr, Length); + + /// + /// Gets the length of the buffer in bytes. + /// + public int Length { get; } + + private byte* _ptr; + + internal DaveNativeBuffer(byte* ptr, int length) + { + _ptr = ptr; + Length = length; + } + + /// + /// Frees the underlying native memory. + /// + public void Dispose() + { + if (_ptr != null) + { + Dave.Free(_ptr); + _ptr = null; + } + } +} diff --git a/src/Disqord.Voice/Encryption/Dave/DaveSession.cs b/src/Disqord.Voice/Encryption/Dave/DaveSession.cs new file mode 100644 index 000000000..2d5bfba19 --- /dev/null +++ b/src/Disqord.Voice/Encryption/Dave/DaveSession.cs @@ -0,0 +1,247 @@ +using System; +using System.Buffers.Text; +using System.Diagnostics; + +namespace Disqord.Voice; + +/// +/// Represents a managed wrapper over a native DAVE session handle. +/// +public sealed unsafe class DaveSession : IDisposable +{ + // Max 20 decimal digits + null terminator for a ulong/Snowflake. + private const int MaxSnowflakeUtf8Length = 21; + + /// + /// Gets the native handle of this session. + /// + public nint Handle + { + get + { + ThrowIfDisposed(); + return _handle; + } + } + + private nint _handle; + private Dave.MlsFailureCallback? _failureCallback; + private bool _isDisposed; + + private DaveSession(nint handle, Dave.MlsFailureCallback? failureCallback) + { + _handle = handle; + _failureCallback = failureCallback; + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_isDisposed, this); + } + + /// + /// Creates a new DAVE session. + /// + /// The callback invoked on MLS failures. + /// A new . + public static DaveSession Create(Dave.MlsFailureCallback? failureCallback = null) + { + var handle = Dave.SessionCreate(0, null, failureCallback!, 0); + return new DaveSession(handle, failureCallback); + } + + /// + /// Initializes the session with protocol version and group information. + /// + /// The protocol version to use. + /// The group identifier (guild ID). + /// The user ID of the local user. + public void Init(ushort version, Snowflake groupId, Snowflake selfUserId) + { + ThrowIfDisposed(); + + var userIdBuffer = stackalloc byte[MaxSnowflakeUtf8Length]; + FormatSnowflake(selfUserId, userIdBuffer); + Dave.SessionInit(_handle, version, groupId, userIdBuffer); + } + + /// + /// Resets the session state. + /// + public void Reset() + { + ThrowIfDisposed(); + Dave.SessionReset(_handle); + } + + /// + /// Sets the protocol version for the session. + /// + public void SetProtocolVersion(ushort version) + { + ThrowIfDisposed(); + Dave.SessionSetProtocolVersion(_handle, version); + } + + /// + /// Gets the current protocol version of the session. + /// + public ushort GetProtocolVersion() + { + ThrowIfDisposed(); + return Dave.SessionGetProtocolVersion(_handle); + } + + /// + /// Retrieves the authenticator from the last MLS epoch. + /// + /// A that must be disposed. + public DaveNativeBuffer GetLastEpochAuthenticator() + { + ThrowIfDisposed(); + + Dave.SessionGetLastEpochAuthenticator(_handle, out var authenticator, out var length); + return new DaveNativeBuffer(authenticator, (int) length); + } + + /// + /// Sets the external sender credentials for the session. + /// + public void SetExternalSender(ReadOnlySpan externalSender) + { + ThrowIfDisposed(); + + fixed (byte* ptr = externalSender) + { + Dave.SessionSetExternalSender(_handle, ptr, (nuint) externalSender.Length); + } + } + + /// + /// Processes MLS proposals and generates commit/welcome messages. + /// + /// The serialized proposal bytes. + /// The recognized user IDs. + /// A that must be disposed. + public DaveNativeBuffer ProcessProposals(ReadOnlySpan proposals, ReadOnlySpan recognizedUserIds) + { + ThrowIfDisposed(); + + var stringBuffer = stackalloc byte[recognizedUserIds.Length * MaxSnowflakeUtf8Length]; + var pointerBuffer = stackalloc byte*[recognizedUserIds.Length]; + FormatSnowflakes(recognizedUserIds, stringBuffer, pointerBuffer); + + fixed (byte* proposalsPtr = proposals) + { + Dave.SessionProcessProposals(_handle, proposalsPtr, (nuint) proposals.Length, + pointerBuffer, (nuint) recognizedUserIds.Length, + out var commitWelcomeBytes, out var commitWelcomeBytesLength); + + return new DaveNativeBuffer(commitWelcomeBytes, (int) commitWelcomeBytesLength); + } + } + + /// + /// Processes an incoming MLS commit message. + /// + /// A that must be disposed. + public DaveCommitResult ProcessCommit(ReadOnlySpan commit) + { + ThrowIfDisposed(); + + fixed (byte* commitPtr = commit) + { + var handle = Dave.SessionProcessCommit(_handle, commitPtr, (nuint) commit.Length); + return new DaveCommitResult(handle); + } + } + + /// + /// Processes an incoming MLS welcome message to join a group. + /// + /// A that must be disposed. Check . + public DaveWelcomeResult ProcessWelcome(ReadOnlySpan welcome, ReadOnlySpan recognizedUserIds) + { + ThrowIfDisposed(); + + var stringBuffer = stackalloc byte[recognizedUserIds.Length * MaxSnowflakeUtf8Length]; + var pointerBuffer = stackalloc byte*[recognizedUserIds.Length]; + FormatSnowflakes(recognizedUserIds, stringBuffer, pointerBuffer); + + fixed (byte* welcomePtr = welcome) + { + var handle = Dave.SessionProcessWelcome(_handle, welcomePtr, (nuint) welcome.Length, + pointerBuffer, (nuint) recognizedUserIds.Length); + + return new DaveWelcomeResult(handle); + } + } + + /// + /// Gets the marshalled MLS key package for this session. + /// + /// A that must be disposed. + public DaveNativeBuffer GetMarshalledKeyPackage() + { + ThrowIfDisposed(); + + Dave.SessionGetMarshalledKeyPackage(_handle, out var keyPackage, out var length); + return new DaveNativeBuffer(keyPackage, (int) length); + } + + /// + /// Gets a key ratchet for a specific user in the session. + /// + /// A that must be disposed. + public DaveKeyRatchet GetKeyRatchet(Snowflake userId) + { + ThrowIfDisposed(); + + var userIdBuffer = stackalloc byte[MaxSnowflakeUtf8Length]; + FormatSnowflake(userId, userIdBuffer); + var handle = Dave.SessionGetKeyRatchet(_handle, userIdBuffer); + return new DaveKeyRatchet(handle); + } + + /// + /// Computes a pairwise fingerprint for identity verification with another user. + /// + public void GetPairwiseFingerprint(ushort version, Snowflake userId, + Dave.PairwiseFingerprintCallback callback) + { + ThrowIfDisposed(); + + var userIdBuffer = stackalloc byte[MaxSnowflakeUtf8Length]; + FormatSnowflake(userId, userIdBuffer); + Dave.SessionGetPairwiseFingerprint(_handle, version, userIdBuffer, callback, 0); + } + + /// + public void Dispose() + { + if (_isDisposed) + return; + + _isDisposed = true; + Dave.SessionDestroy(_handle); + _handle = 0; + _failureCallback = null; + } + + private static void FormatSnowflake(Snowflake snowflake, byte* destination) + { + var isFormatSuccessful = Utf8Formatter.TryFormat(snowflake, new Span(destination, MaxSnowflakeUtf8Length - 1), out var bytesWritten); + Debug.Assert(isFormatSuccessful); + destination[bytesWritten] = 0; + } + + private static void FormatSnowflakes(ReadOnlySpan snowflakes, byte* stringBuffer, byte** pointerBuffer) + { + for (var i = 0; i < snowflakes.Length; i++) + { + var strStart = stringBuffer + i * MaxSnowflakeUtf8Length; + pointerBuffer[i] = strStart; + FormatSnowflake(snowflakes[i], strStart); + } + } +} diff --git a/src/Disqord.Voice/Encryption/Dave/DaveWelcomeResult.cs b/src/Disqord.Voice/Encryption/Dave/DaveWelcomeResult.cs new file mode 100644 index 000000000..2909eadab --- /dev/null +++ b/src/Disqord.Voice/Encryption/Dave/DaveWelcomeResult.cs @@ -0,0 +1,63 @@ +using System; + +namespace Disqord.Voice; + +/// +/// Represents a managed wrapper over a native DAVE welcome result handle. +/// +public unsafe struct DaveWelcomeResult : IDisposable +{ + /// + /// Gets the native handle of this welcome result. + /// + public readonly nint Handle => _handle; + + /// + /// Gets whether the welcome result is valid (i.e., the client successfully joined the group). + /// + public readonly bool IsValid => _handle != 0; + + private nint _handle; + + internal DaveWelcomeResult(nint handle) + { + _handle = handle; + } + + /// + /// Gets the list of member IDs in the roster from the welcome message. + /// + public readonly Snowflake[] GetRosterMemberIds() + { + Dave.WelcomeResultGetRosterMemberIds(_handle, out var rosterIds, out var length); + try + { + // Snowflake has the same layout as ulong. + return new ReadOnlySpan(rosterIds, (int) length).ToArray(); + } + finally + { + Dave.Free(rosterIds); + } + } + + /// + /// Gets the signature for a specific roster member. + /// + public readonly DaveNativeBuffer GetRosterMemberSignature(ulong rosterId) + { + Dave.WelcomeResultGetRosterMemberSignature(_handle, rosterId, out var signature, out var signatureLength); + return new DaveNativeBuffer(signature, (int) signatureLength); + } + + /// + public void Dispose() + { + var handle = _handle; + if (handle == 0) + return; + + _handle = 0; + Dave.WelcomeResultDestroy(handle); + } +} diff --git a/src/Disqord.Voice/Factory/Default/DefaultVoiceUdpClientFactory.cs b/src/Disqord.Voice/Factory/Default/DefaultVoiceUdpClientFactory.cs index 129406c64..4373bd76d 100644 --- a/src/Disqord.Voice/Factory/Default/DefaultVoiceUdpClientFactory.cs +++ b/src/Disqord.Voice/Factory/Default/DefaultVoiceUdpClientFactory.cs @@ -1,21 +1,15 @@ using System; using System.Runtime.CompilerServices; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Disqord.Voice.Default; -public class DefaultVoiceUdpClientFactory : IVoiceUdpClientFactory +public class DefaultVoiceUdpClientFactory(IServiceProvider services) : IVoiceUdpClientFactory { - private readonly IServiceProvider _services; - - public DefaultVoiceUdpClientFactory(IServiceProvider services) - { - _services = services; - } - - public IVoiceUdpClient Create(uint ssrc, byte[] encryptionKey, string hostName, int port, IVoiceEncryption encryption) + public IVoiceUdpClient Create(uint ssrc, string hostName, int port, ILogger logger, IVoiceEncryption encryption) { - var udp = Factory(_services, new object[] { ssrc, encryptionKey, hostName, port, encryption }); + var udp = Factory(services, [ssrc, hostName, port, logger, encryption]); return Unsafe.As(udp); } @@ -24,6 +18,6 @@ public IVoiceUdpClient Create(uint ssrc, byte[] encryptionKey, string hostName, static DefaultVoiceUdpClientFactory() { Factory = ActivatorUtilities.CreateFactory(typeof(DefaultVoiceUdpClient), - new[] { typeof(uint), typeof(byte[]), typeof(string), typeof(int), typeof(IVoiceEncryption) }); + [typeof(uint), typeof(string), typeof(int), typeof(ILogger), typeof(IVoiceEncryption)]); } } diff --git a/src/Disqord.Voice/Factory/IVoiceUdpClientFactory.cs b/src/Disqord.Voice/Factory/IVoiceUdpClientFactory.cs index a260f8d90..436320b24 100644 --- a/src/Disqord.Voice/Factory/IVoiceUdpClientFactory.cs +++ b/src/Disqord.Voice/Factory/IVoiceUdpClientFactory.cs @@ -1,6 +1,8 @@ -namespace Disqord.Voice; +using Microsoft.Extensions.Logging; + +namespace Disqord.Voice; public interface IVoiceUdpClientFactory { - IVoiceUdpClient Create(uint ssrc, byte[] encryptionKey, string hostName, int port, IVoiceEncryption encryption); + IVoiceUdpClient Create(uint ssrc, string hostName, int port, ILogger logger, IVoiceEncryption encryption); } diff --git a/src/Disqord.Voice/IVoiceUdpClient.cs b/src/Disqord.Voice/IVoiceUdpClient.cs index 660d25efb..39f7c23ba 100644 --- a/src/Disqord.Voice/IVoiceUdpClient.cs +++ b/src/Disqord.Voice/IVoiceUdpClient.cs @@ -27,6 +27,8 @@ public interface IVoiceUdpClient : IDisposable uint Timestamp { get; } + void Initialize(byte[] encryptionKey, DaveEncryptor? daveEncryptor); + void OnSynchronizerTick(); ValueTask ConnectAsync(CancellationToken cancellationToken = default);