using System.Buffers; using System.Buffers.Binary; using System.Net.Sockets; using System.Threading.Channels; using Google.Protobuf; using VoiceCat.Crypto; using VoiceCat.Protocol; using Voicecat.V1; namespace VoiceCat.Server.Transport; internal sealed class TlsControlConnection : IAsyncDisposable { internal const int MaximumPayloadLength = 65536; private readonly Socket socket; private readonly TlsSession tls; private readonly CancellationTokenSource lifetime; private readonly Channel outgoing = System.Threading.Channels.Channel.CreateBounded(64); private readonly Channel incoming = System.Threading.Channels.Channel.CreateBounded(32); private readonly byte[] prefix = new byte[4]; private int prefixBytes; private byte[]? payload; private int payloadBytes; private readonly TaskCompletionSource mediaReady = new(TaskCreationOptions.RunContinuationsAsynchronously); private MediaSessionCrypto? mediaCrypto; public Task Completion { get; } public CancellationToken CancellationToken => lifetime.Token; internal TlsControlConnection(Socket socket, TlsSession tls, CancellationToken cancellationToken, TimeSpan? handshakeTimeout = null) { this.socket = socket; this.tls = tls; lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); lifetime.CancelAfter(handshakeTimeout ?? TimeSpan.FromSeconds(15)); Completion = RunAsync(); } public IAsyncEnumerable ReadAsync(CancellationToken cancellationToken) => incoming.Reader.ReadAllAsync(cancellationToken); public bool TrySend(Envelope envelope) { if (envelope.CalculateSize() > MaximumPayloadLength) throw new InvalidDataException("Server control payload exceeds 64 KiB."); var framed = new ArrayBufferWriter(); ControlFraming.WriteEnvelope(framed, envelope); if (outgoing.Writer.TryWrite(framed.WrittenSpan.ToArray())) return true; lifetime.Cancel(); return false; } public void CompleteWrites() => outgoing.Writer.TryComplete(); internal async Task TakeMediaCryptoAsync(CancellationToken cancellationToken) { await mediaReady.Task.WaitAsync(cancellationToken).ConfigureAwait(false); return Interlocked.Exchange(ref mediaCrypto, null) ?? throw new InvalidOperationException("Media crypto already has an owner."); } private async Task RunAsync() { byte[] ciphertext = new byte[16384]; byte[] plaintext = new byte[16384]; byte[] sendBuffer = new byte[16384]; CancellationToken cancellationToken = lifetime.Token; Task? receive = null; Task? ready = null; Exception? error = null; try { await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false); receive = socket.ReceiveAsync(ciphertext, SocketFlags.None, cancellationToken).AsTask(); while (true) { if (tls.IsReady) { while (outgoing.Reader.TryRead(out byte[]? frame)) tls.WritePlaintext(frame); await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false); ready ??= outgoing.Reader.WaitToReadAsync(cancellationToken).AsTask(); } Task winner = ready is null ? receive : await Task.WhenAny(receive, ready).ConfigureAwait(false); if (winner == receive) { int count = await receive.ConfigureAwait(false); if (count == 0) { tls.CompleteInput(); if (prefixBytes != 0 || payload is not null) throw new InvalidDataException("Truncated control frame."); break; } tls.ReceiveCiphertext(ciphertext.AsSpan(0, count)); if (tls.IsReady && !mediaReady.Task.IsCompleted) { var encryptor = tls.CreateMediaEncryptor(); try { mediaCrypto = new(encryptor, tls.CreateMediaDecryptor()); } catch { encryptor.Dispose(); throw; } mediaReady.SetResult(); lifetime.CancelAfter(Timeout.InfiniteTimeSpan); } while ((count = tls.ReadPlaintext(plaintext)) > 0) Parse(plaintext.AsSpan(0, count)); await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false); receive = socket.ReceiveAsync(ciphertext, SocketFlags.None, cancellationToken).AsTask(); } else { bool hasOutgoing = await ready!.ConfigureAwait(false); ready = null; if (!hasOutgoing) { tls.Close(); await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false); break; } } } } catch (Exception exception) when (exception is IOException or SocketException or OperationCanceledException or ObjectDisposedException) { if (!cancellationToken.IsCancellationRequested) error = exception; } finally { mediaReady.TrySetCanceled(); lifetime.Cancel(); socket.Dispose(); if (receive is not null) { try { await receive.ConfigureAwait(false); } catch (Exception exception) when (exception is SocketException or OperationCanceledException or ObjectDisposedException) { } } tls.Dispose(); incoming.Writer.TryComplete(error); outgoing.Writer.TryComplete(error); } } private async Task FlushAsync(byte[] buffer, CancellationToken cancellationToken) { int count; while ((count = tls.DrainCiphertext(buffer)) > 0) { int sent = 0; while (sent < count) { int written = await socket.SendAsync(buffer.AsMemory(sent, count - sent), SocketFlags.None, cancellationToken).ConfigureAwait(false); if (written == 0) throw new IOException("Socket closed during TLS send."); sent += written; } } } private void Parse(ReadOnlySpan input) { while (!input.IsEmpty) { if (payload is null) { int count = Math.Min(4 - prefixBytes, input.Length); input[..count].CopyTo(prefix.AsSpan(prefixBytes)); prefixBytes += count; input = input[count..]; if (prefixBytes != 4) continue; uint length = BinaryPrimitives.ReadUInt32BigEndian(prefix); if (length > MaximumPayloadLength) throw new InvalidDataException("Server control payload exceeds 64 KiB."); payload = new byte[length]; prefixBytes = 0; } int consumed = Math.Min(payload.Length - payloadBytes, input.Length); input[..consumed].CopyTo(payload.AsSpan(payloadBytes)); payloadBytes += consumed; input = input[consumed..]; if (payloadBytes != payload.Length) continue; Envelope envelope = Envelope.Parser.ParseFrom(payload); payload = null; payloadBytes = 0; if (!incoming.Writer.TryWrite(envelope)) throw new IOException("Control consumer exceeded its bounded queue."); } } public async ValueTask DisposeAsync() { lifetime.Cancel(); try { await Completion.ConfigureAwait(false); } finally { Interlocked.Exchange(ref mediaCrypto, null)?.Dispose(); lifetime.Dispose(); } } }