using System.Security.Cryptography; using DataTransfer.Client.Shared; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.SignalR.Client; using SpawnDev.BlazorJS.Cryptography; using SpawnDev.BlazorJS.JSObjects; namespace DataTransfer.Client.Services; public enum SessionState { Idle, /// The server is at its connection limit; the connection is created once a slot becomes free. Queued, Joining, Waiting, Connected, Closed } /// /// One end of a transfer connection, running in the browser. Pairs via the relay hub, exchanges keys with the /// commit-then-reveal protocol of , encrypts everything afterwards and keeps the decrypted /// chat history locally. Plaintext never leaves the browser. /// public sealed class TransferSession( NavigationManager navigation, PortableCrypto crypto, BotCheckSolver botCheckSolver, ILogger logger) : IAsyncDisposable { private const string KeyExchangeFailedMessage = "The secure key exchange failed. Please start a new connection."; private const string TooManyConnectionsMessage = "Your network already has the maximum number of open connections on this server. Please close other transfers or try again later."; private const string TransferLimitMessage = "The connection was closed because it reached its transfer limit."; private static readonly string ReceiveLimitMessage = $"The connection was closed because the other person sent more than {TransferProtocol.MaxItemsPerConnection:N0} " + $"messages and files or more than {DisplayFormat.Bytes(TransferProtocol.MaxFileBytesPerConnection)} in total."; private static readonly string SendLimitMessage = $"This connection can transfer at most {TransferProtocol.MaxItemsPerConnection:N0} messages and files " + $"and {DisplayFormat.Bytes(TransferProtocol.MaxFileBytesPerConnection)} in total. Please start a new connection."; // The creator's browser answers the joiner's commitment immediately; waiting longer means it's gone or misbehaving. private static readonly TimeSpan KeyExchangeTimeout = TimeSpan.FromSeconds(30); private readonly List _entries = []; private readonly Dictionary _incomingFiles = []; private readonly List _objectUrls = []; private readonly SemaphoreSlim _sendLock = new(1, 1); private HubConnection? _hub; private SecureChannel? _channel; private KeyExchangeStep _keyExchange; private byte[]? _peerCommitment; private bool _peerJoined; private TaskCompletionSource _keyExchangeCompleted = CreateCompletionSource(); private DateTimeOffset _lastActivity; private int _nextFileId; // Per-connection totals, limited by TransferProtocol.MaxItemsPerConnection and MaxFileBytesPerConnection. private int _sentItems; private long _sentFileBytes; private int _receivedItems; private long _receivedFileBytes; /// Steps of the commit-then-reveal key exchange; see . private enum KeyExchangeStep { None, /// Creator: waits for the joiner's commitment to its public key. AwaitingCommitment, /// Creator: revealed its public key and waits for the joiner's. AwaitingJoinerKey, /// Joiner: committed to its public key and waits for the creator's. AwaitingCreatorKey, Completed } public event Action? Changed; public IReadOnlyList Entries => _entries; public TimeSpan InactivityTimeout { get; private set; } /// A new code expires unless someone joins within this period. public TimeSpan JoinTimeout { get; private set; } /// The timeout counts down: joining for a fresh code, inactivity afterwards. public TimeSpan CountdownDuration => State == SessionState.Waiting && !_peerJoined ? JoinTimeout : InactivityTimeout; /// Time until the server removes the connection unless someone joins or something is transmitted. public TimeSpan? RemainingTime => State is SessionState.Waiting or SessionState.Connected ? _lastActivity + CountdownDuration - DateTimeOffset.UtcNow : null; public SessionState State { get; private set; } /// The normalized connection code. public string? Code { get; private set; } public string? SafetyCode { get; private set; } public string? ClosedMessage { get; private set; } /// 1-based position in the server's queue while . public int QueuePosition { get; private set; } public bool HasPeer { get; private set; } /// Someone joined the creator's connection and the key exchange is in progress. public bool IsPeerJoining => State == SessionState.Waiting && _peerJoined; public bool IsSendingFiles { get; private set; } /// The browser is solving the server's bot check before the connection is created. public bool IsVerifyingBrowser { get; private set; } /// An error message for the user, or null. public async Task CreateAsync() { var (hub, _) = await StartAsync(); var botCheckSolution = await SolveBotCheckAsync(hub); if (!IsCurrent(hub)) { return null; } var result = await hub.InvokeAsync(nameof(ITransferHub.CreateConnection), botCheckSolution); // A ConnectionCreated message may have overtaken this result and already admitted the queued request. if (!IsCurrent(hub) || State != SessionState.Idle) { return null; } switch (result) { case { Status: CreateStatus.Created, Code: not null }: Admit(result); return null; case { Status: CreateStatus.Queued }: UpdateQueuePosition(result.QueuePosition); State = SessionState.Queued; NotifyChanged(); return null; case { Status: CreateStatus.TooManyConnections }: await ResetAsync(); return TooManyConnectionsMessage; case { Status: CreateStatus.BotCheckFailed }: await ResetAsync(); return "The automatic check against bots failed. Please try again."; default: await ResetAsync(); return "The server is at its connection limit and the queue is full. Please try again later."; } } /// Joins a connection and completes the key exchange. /// An error message for the user, or null. public async Task JoinAsync(string? input) { if (!ConnectionCode.TryNormalize(input, out var code)) { return $"Please enter a valid {ConnectionCode.Length}-character code."; } var (hub, channel) = await StartAsync(); var keyExchangeCompleted = _keyExchangeCompleted.Task; Code = code; State = SessionState.Joining; NotifyChanged(); var result = await hub.InvokeAsync(nameof(ITransferHub.JoinConnection), code); if (!IsCurrent(hub) || State != SessionState.Joining) { return null; } if (result.Status != JoinStatus.Joined) { await ResetAsync(); return result.Status switch { JoinStatus.InvalidCode => $"Please enter a valid {ConnectionCode.Length}-character code.", JoinStatus.AlreadyInUse => "This connection already has two participants.", JoinStatus.TooManyConnections => TooManyConnectionsMessage, _ => "No open connection with this code exists. It may have expired." }; } InactivityTimeout = result.InactivityTimeout; // Commit to our key before the creator reveals its key; ours is revealed only after receiving it. _keyExchange = KeyExchangeStep.AwaitingCreatorKey; if (!await SendKeyExchangeMessageAsync(hub, HandshakeCodec.EncodeCommitment(channel.Commitment))) { await CloseAsync(hub, KeyExchangeFailedMessage); return null; } try { // Completed by OnReceiveAsync once the creator's key arrived and ours was revealed. await keyExchangeCompleted.WaitAsync(KeyExchangeTimeout); } catch (TimeoutException) { await CloseAsync(hub, "The other person did not respond. Please ask for a new code."); } catch (OperationCanceledException) { // Closed or reset meanwhile; the closed message tells the user why. } return null; } /// An error message for the user, or null. public async Task SendTextAsync(string? text) { if (string.IsNullOrWhiteSpace(text)) { return null; } if (text.Length > TransferProtocol.MaxMessageLength) { return "The message is too long."; } if (_sentItems >= TransferProtocol.MaxItemsPerConnection) { return SendLimitMessage; } var error = await SendFrameAsync(new TextFrame(text)); if (error is null) { _sentItems++; AddEntry( new ChatEntry { Kind = ChatEntryKind.Outgoing, Text = text }); } NotifyChanged(); return error; } public async Task SendFilesAsync(IReadOnlyList files) { if (State != SessionState.Connected || IsSendingFiles || files.Count == 0) { return; } IsSendingFiles = true; NotifyChanged(); try { foreach (var file in files) { if (State != SessionState.Connected) { break; } if (file.Size > TransferProtocol.MaxFileBytes) { AddSystemEntry($"\"{file.Name}\" is too large (max. {DisplayFormat.Bytes(TransferProtocol.MaxFileBytes)}).", true); continue; } // The peer closes the connection beyond these limits, so stop before reaching them. if (_sentItems >= TransferProtocol.MaxItemsPerConnection || _sentFileBytes + file.Size > TransferProtocol.MaxFileBytesPerConnection) { AddSystemEntry(SendLimitMessage, true); break; } var entry = new ChatEntry { Kind = ChatEntryKind.Outgoing, FileName = file.Name, FileSize = file.Size }; AddEntry(entry); NotifyChanged(); var error = await SendFileAsync(file, entry); if (error is not null) { entry.Error = error; } } } finally { IsSendingFiles = false; NotifyChanged(); } } /// Leaves the connection but keeps the chat history visible. public async Task DisconnectAsync() { if (_hub is not { } hub || State is not (SessionState.Waiting or SessionState.Connected)) { return; } MarkClosed("You closed the connection."); try { await hub.InvokeAsync(nameof(ITransferHub.LeaveConnection)); } catch (Exception ex) { // Includes the cancellation when a reset disposes the hub meanwhile. logger.LogDebug(ex, "Leaving the connection failed"); } } /// Leaves any connection, forgets keys and history and returns to . public async Task ResetAsync() { var hub = _hub; _hub = null; _keyExchangeCompleted.TrySetCanceled(); _keyExchangeCompleted = CreateCompletionSource(); _keyExchange = KeyExchangeStep.None; _peerCommitment = null; _peerJoined = false; _channel?.Dispose(); _channel = null; _incomingFiles.Clear(); _entries.Clear(); RevokeObjectUrls(); State = SessionState.Idle; Code = null; SafetyCode = null; ClosedMessage = null; QueuePosition = 0; HasPeer = false; IsVerifyingBrowser = false; _nextFileId = 0; _sentItems = 0; _sentFileBytes = 0; _receivedItems = 0; _receivedFileBytes = 0; if (hub is not null) { try { // Disconnecting makes the server close the connection and notify the peer. await hub.DisposeAsync(); } catch (Exception ex) { logger.LogDebug(ex, "Disposing the hub connection failed"); } } NotifyChanged(); } public async ValueTask DisposeAsync() { Changed = null; await ResetAsync(); } private static TaskCompletionSource CreateCompletionSource() => new(TaskCreationOptions.RunContinuationsAsynchronously); /// Proof of work against creating connections in bulk; see . /// The solution, or -1 if the server doesn't require the check. private async Task SolveBotCheckAsync(HubConnection hub) { var challenge = await hub.InvokeAsync(nameof(ITransferHub.GetBotCheckChallenge)); if (challenge is null || !IsCurrent(hub)) { return -1; } IsVerifyingBrowser = true; NotifyChanged(); try { return await botCheckSolver.SolveAsync(challenge); } finally { if (IsCurrent(hub)) { IsVerifyingBrowser = false; NotifyChanged(); } } } private async Task<(HubConnection Hub, SecureChannel Channel)> StartAsync() { await ResetAsync(); var channel = await SecureChannel.CreateAsync(crypto); var hub = new HubConnectionBuilder() // Relative to the base URI like all other requests, so the app also works under a path base. .WithUrl(navigation.ToAbsoluteUri(TransferProtocol.HubPath.TrimStart('/'))) .Build(); // SignalR invokes these handlers one after another, in the order the server sent the messages. hub.On(nameof(ITransferHubClient.PeerJoined), () => OnPeerJoined(hub)); hub.On(nameof(ITransferHubClient.Receive), frame => OnReceiveAsync(hub, frame)); hub.On(nameof(ITransferHubClient.ConnectionClosed), reason => OnConnectionClosed(hub, reason)); hub.On(nameof(ITransferHubClient.ConnectionCreated), result => OnConnectionCreated(hub, result)); hub.On(nameof(ITransferHubClient.QueuePositionChanged), position => OnQueuePositionChanged(hub, position)); hub.Closed += error => OnHubClosed(hub, error); _hub = hub; _channel = channel; try { await hub.StartAsync(); } catch { await ResetAsync(); throw; } return (hub, channel); } private void OnPeerJoined(HubConnection hub) { if (IsCurrent(hub) && State == SessionState.Waiting) { // The server counts the join as activity, so the countdown switches to the inactivity timeout. _peerJoined = true; Touch(); NotifyChanged(); } } private async Task OnReceiveAsync(HubConnection hub, byte[] frame) { if (!IsCurrent(hub) || _channel is not { } channel) { return; } if (_keyExchange != KeyExchangeStep.Completed) { await ContinueKeyExchangeAsync(hub, channel, frame); return; } if (State != SessionState.Connected) { return; } try { var decrypted = await channel.OpenAsync(frame); if (!IsCurrent(hub) || State != SessionState.Connected) { return; } Touch(); if (ExceedsReceiveLimits(decrypted)) { logger.LogWarning("The peer exceeded the per-connection limits"); await CloseAsync(hub, ReceiveLimitMessage); } else { Handle(decrypted); } } catch (Exception ex) when (ex is CryptographicException or InvalidDataException) { if (!IsCurrent(hub) || State != SessionState.Connected) { return; } logger.LogWarning(ex, "Rejected an incoming frame"); AddSystemEntry("Received data failed verification and was discarded.", true); await CloseAsync(hub, "The connection was closed because received data failed verification."); } NotifyChanged(); } /// Handles the plaintext key-exchange messages that precede all encrypted frames. private async Task ContinueKeyExchangeAsync(HubConnection hub, SecureChannel channel, byte[] message) { try { switch (_keyExchange) { case KeyExchangeStep.AwaitingCommitment when State == SessionState.Waiting: { _peerCommitment = HandshakeCodec.DecodeCommitment(message); _peerJoined = true; _keyExchange = KeyExchangeStep.AwaitingJoinerKey; NotifyChanged(); // The joiner is bound to its key now, so ours can be revealed. if (!await SendKeyExchangeMessageAsync(hub, HandshakeCodec.EncodePublicKey(channel.PublicKey))) { await CloseAsync(hub, KeyExchangeFailedMessage); } break; } case KeyExchangeStep.AwaitingJoinerKey when State == SessionState.Waiting && _peerCommitment is { } commitment && Code is { } code: { var joinerKey = HandshakeCodec.DecodePublicKey(message); var safetyCode = await channel.EstablishAsCreatorAsync(joinerKey, commitment, code); CompleteKeyExchange(hub, safetyCode); break; } case KeyExchangeStep.AwaitingCreatorKey when State == SessionState.Joining && Code is { } code: { var creatorKey = HandshakeCodec.DecodePublicKey(message); var safetyCode = await channel.EstablishAsJoinerAsync(creatorKey, code); if (!IsCurrent(hub) || State != SessionState.Joining) { break; } // Revealed only now that the creator's key is fixed. if (!await SendKeyExchangeMessageAsync(hub, HandshakeCodec.EncodePublicKey(channel.PublicKey))) { await CloseAsync(hub, KeyExchangeFailedMessage); break; } CompleteKeyExchange(hub, safetyCode); break; } default: throw new InvalidDataException("Unexpected key-exchange message."); } } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogWarning(ex, "The key exchange failed"); await CloseAsync(hub, KeyExchangeFailedMessage); } } private void CompleteKeyExchange(HubConnection hub, string safetyCode) { if (!IsCurrent(hub) || State is not (SessionState.Waiting or SessionState.Joining)) { return; } SafetyCode = safetyCode; _keyExchange = KeyExchangeStep.Completed; _peerCommitment = null; HasPeer = true; State = SessionState.Connected; Touch(); _keyExchangeCompleted.TrySetResult(); NotifyChanged(); } private Task OnConnectionClosed(HubConnection hub, ConnectionClosedReason reason) { if (IsCurrent(hub) && State is SessionState.Joining or SessionState.Waiting or SessionState.Connected) { var inactivity = DisplayFormat.Duration(InactivityTimeout); MarkClosed( reason switch { ConnectionClosedReason.Inactivity when State == SessionState.Waiting && !_peerJoined => $"The code expired because nobody joined within {DisplayFormat.Duration(JoinTimeout)}.", ConnectionClosedReason.Inactivity => $"The connection was closed because nothing was sent for {inactivity}.", ConnectionClosedReason.MaximumLifetime => "The connection was closed because it reached its maximum duration.", ConnectionClosedReason.TransferLimitReached => TransferLimitMessage, _ when !HasPeer => "The other person left before the connection was established.", _ => "The other person closed the connection." }); } return Task.CompletedTask; } // Idle covers messages that arrive while the CreateConnection result is still on its way. private Task OnConnectionCreated(HubConnection hub, CreateConnectionResult result) { if (IsCurrent(hub) && State is SessionState.Idle or SessionState.Queued && result is { Status: CreateStatus.Created, Code: not null }) { Admit(result); } return Task.CompletedTask; } private Task OnQueuePositionChanged(HubConnection hub, int position) { if (IsCurrent(hub) && State is SessionState.Idle or SessionState.Queued && position > 0) { UpdateQueuePosition(position); NotifyChanged(); } return Task.CompletedTask; } private void Admit(CreateConnectionResult result) { Code = result.Code; JoinTimeout = result.JoinTimeout; InactivityTimeout = result.InactivityTimeout; QueuePosition = 0; _keyExchange = KeyExchangeStep.AwaitingCommitment; Touch(); State = SessionState.Waiting; NotifyChanged(); } // The position only ever decreases, so an older value that arrives late must not move the client back. private void UpdateQueuePosition(int position) => QueuePosition = QueuePosition == 0 ? position : Math.Min(QueuePosition, position); private Task OnHubClosed(HubConnection hub, Exception? error) { if (IsCurrent(hub) && State is SessionState.Queued or SessionState.Joining or SessionState.Waiting or SessionState.Connected) { logger.LogWarning(error, "Lost the connection to the server"); MarkClosed( State == SessionState.Queued ? "The connection to the server was lost while waiting in the queue." : "The connection to the server was lost."); } return Task.CompletedTask; } /// /// Everything received stays in memory until the session is reset, so a peer must not be able to make this /// browser keep an unbounded number of entries or files. /// private bool ExceedsReceiveLimits(Frame frame) => frame switch { TextFrame => _receivedItems >= TransferProtocol.MaxItemsPerConnection, FileStartFrame start => _receivedItems >= TransferProtocol.MaxItemsPerConnection || _receivedFileBytes + Math.Max(start.Size, 0) > TransferProtocol.MaxFileBytesPerConnection, _ => false }; private void Handle(Frame frame) { switch (frame) { case TextFrame text: if (text.Text.Length > TransferProtocol.MaxMessageLength) { throw new InvalidDataException("The message is too long."); } _receivedItems++; AddEntry( new ChatEntry { Kind = ChatEntryKind.Incoming, Text = text.Text }); break; case FileStartFrame start: // Senders transmit one file at a time; this also bounds the memory a peer can make us allocate. if (start.Size is < 0 or > TransferProtocol.MaxFileBytes || _incomingFiles.Count > 0) { throw new InvalidDataException("Invalid file announcement."); } _receivedItems++; _receivedFileBytes += start.Size; var fileName = FileNameSanitizer.Sanitize(start.Name); var entry = new ChatEntry { Kind = ChatEntryKind.Incoming, FileName = fileName, FileSize = start.Size, IsRiskyFile = RiskyFileTypes.IsRisky(fileName) }; AddEntry(entry); var incoming = new IncomingFile(entry, new byte[start.Size]); if (start.Size == 0) { CompleteFile(incoming); } else { _incomingFiles.Add(start.FileId, incoming); } break; case FileChunkFrame chunk: if (!_incomingFiles.TryGetValue(chunk.FileId, out var file) || chunk.Data.Length == 0 || chunk.Data.Length > file.Buffer.Length - file.Entry.TransferredBytes) { throw new InvalidDataException("Unexpected file chunk."); } chunk.Data.Span.CopyTo(file.Buffer.AsSpan((int)file.Entry.TransferredBytes)); file.Entry.TransferredBytes += chunk.Data.Length; if (file.Entry.TransferredBytes == file.Buffer.Length) { _incomingFiles.Remove(chunk.FileId); CompleteFile(file); } break; case FileCancelFrame cancel: if (_incomingFiles.Remove(cancel.FileId, out var cancelled)) { cancelled.Entry.Error = "The sender cancelled the transfer."; } break; } } private void CompleteFile(IncomingFile file) { try { // Always offered as a download (octet-stream); never rendered inline in this origin. using var blob = new Blob(new[] { file.Buffer }, new BlobOptions { Type = "application/octet-stream" }); var url = URL.CreateObjectURL(blob); _objectUrls.Add(url); file.Entry.DownloadUrl = url; } catch (Exception ex) { logger.LogError(ex, "Preparing a received file for download failed"); file.Entry.Error = "The file could not be prepared for download."; } } private async Task SendFileAsync(IBrowserFile file, ChatEntry entry) { var fileId = ++_nextFileId; var error = await SendFrameAsync(new FileStartFrame(fileId, file.Name, file.Size)); if (error is not null) { return error; } // Counted like the receiver does: the announced size, even if the transfer is cancelled later. _sentItems++; _sentFileBytes += file.Size; try { await using var stream = file.OpenReadStream(TransferProtocol.MaxFileBytes); var buffer = new byte[TransferProtocol.FileChunkBytes]; while (entry.TransferredBytes < file.Size) { var expected = (int)Math.Min(buffer.Length, file.Size - entry.TransferredBytes); var read = await stream.ReadAtLeastAsync(buffer.AsMemory(0, expected), expected, false); if (read != expected) { throw new IOException("The file changed while it was being sent."); } error = await SendFrameAsync(new FileChunkFrame(fileId, buffer.AsMemory(0, read))); if (error is not null) { return error; } entry.TransferredBytes += read; NotifyChanged(); } return null; } catch (Exception ex) { // SendFrameAsync never throws, so this only covers reading the file (including browser errors). logger.LogWarning(ex, "Reading a file failed"); await SendFrameAsync(new FileCancelFrame(fileId)); return "The file could not be read."; } } /// /// Seals and sends one frame. Serialized, so frames reach the peer in sequence-number order. Never throws. /// /// An error message for the user, or null. private async Task SendFrameAsync(Frame frame) { await _sendLock.WaitAsync(); try { if (State != SessionState.Connected || _hub is not { } hub || _channel is not { } channel) { return "The connection is closed."; } return await SealAndSendAsync(hub, channel, frame); } finally { _sendLock.Release(); } } private async Task SealAndSendAsync(HubConnection hub, SecureChannel channel, Frame frame) { SendStatus status; try { var sealedFrame = await channel.SealAsync(frame); status = await hub.InvokeAsync(nameof(ITransferHub.Send), sealedFrame); } catch (Exception ex) { // Also covers the cancellation of pending invocations when a reset disposes the hub. if (!IsCurrent(hub)) { return "The connection is closed."; } logger.LogWarning(ex, "Sending a frame failed"); await CloseAsync(hub, "The connection was closed because sending failed."); return "Sending failed."; } if (status == SendStatus.Delivered) { Touch(); return null; } // A sealed but unsent frame leaves a gap in the sequence numbers, so the channel can't be used anymore. await CloseAsync(hub, status == SendStatus.TransferLimitReached ? TransferLimitMessage : "The connection was closed."); return status switch { SendStatus.InvalidSize => "The data is too large.", SendStatus.TransferLimitReached => "The transfer limit of this connection was reached.", _ => "The connection is closed." }; } /// Sends a plaintext key-exchange message; only public data is sent this way. private async Task SendKeyExchangeMessageAsync(HubConnection hub, byte[] message) { try { if (await hub.InvokeAsync(nameof(ITransferHub.Send), message) != SendStatus.Delivered) { return false; } Touch(); return true; } catch (Exception ex) { logger.LogDebug(ex, "Sending a key-exchange message failed"); return false; } } /// Closes locally and asks the server to close the connection for the peer, without awaiting a response. private async Task CloseAsync(HubConnection hub, string message) { if (!IsCurrent(hub) || State is SessionState.Idle or SessionState.Closed) { return; } MarkClosed(message); try { await hub.SendAsync(nameof(ITransferHub.LeaveConnection)); } catch (Exception ex) { logger.LogDebug(ex, "Leaving the connection failed"); } } private void MarkClosed(string message) { State = SessionState.Closed; ClosedMessage = message; _keyExchangeCompleted.TrySetCanceled(); foreach (var file in _incomingFiles.Values) { file.Entry.Error ??= "The transfer was interrupted."; } _incomingFiles.Clear(); NotifyChanged(); } private bool IsCurrent(HubConnection hub) => ReferenceEquals(_hub, hub); private void Touch() => _lastActivity = DateTimeOffset.UtcNow; private void AddEntry(ChatEntry entry) => _entries.Add(entry); private void AddSystemEntry(string text, bool isError) => _entries.Add( new ChatEntry { Kind = ChatEntryKind.System, Text = text, Error = isError ? text : null }); private void NotifyChanged() => Changed?.Invoke(); private void RevokeObjectUrls() { foreach (var url in _objectUrls) { try { URL.RevokeObjectURL(url); } catch (Exception ex) { logger.LogDebug(ex, "Revoking an object URL failed"); } } _objectUrls.Clear(); } private sealed record IncomingFile(ChatEntry Entry, byte[] Buffer); }