commit e5b6226a85d4a14922d0bdc78398ca2959f558fe Author: Sithis Date: Thu Oct 15 05:46:35 2026 +0200 Initial version diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3d813e6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# Build output and IDE files +**/bin/ +**/obj/ +**/.idea/ +**/.vs/ +**/*.user +**/*.DotSettings.user + +# Local tooling +**/node_modules/ +**/*.log + +# Docker files themselves +Dockerfile +.dockerignore +docker-compose*.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d70738 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Build output +bin/ +obj/ +publish/ +*.tar.gz + +# IDE files +.idea/ +.vs/ +.vscode/ +*.user +*.DotSettings.user + +# Local tooling and logs +node_modules/ +*.log + +# Secrets and server-specific settings +appsettings.Production.json +.env +*.pfx diff --git a/DataTransfer.Client/Components/Icon.razor b/DataTransfer.Client/Components/Icon.razor new file mode 100644 index 0000000..655638b --- /dev/null +++ b/DataTransfer.Client/Components/Icon.razor @@ -0,0 +1,93 @@ +@* Small stroke icon set (24×24 grid) drawn with currentColor, so icons follow the surrounding text color. *@ + + +@code { + [Parameter][EditorRequired] public string Name { get; set; } = string.Empty; + + [Parameter] public string? Class { get; set; } +} diff --git a/DataTransfer.Client/Components/ServerLoad.razor b/DataTransfer.Client/Components/ServerLoad.razor new file mode 100644 index 0000000..037e717 --- /dev/null +++ b/DataTransfer.Client/Components/ServerLoad.razor @@ -0,0 +1,38 @@ +@implements IDisposable +@inject ServerStatusMonitor Monitor + +@if (Monitor.Current is { } status) +{ + + + @status.ActiveConnections/@status.MaxConnections + connections + @if (status.QueuedClients > 0) + { + · @status.QueuedClients queued + } + +} + +@code { + + protected override void OnInitialized() + { + Monitor.Changed += OnMonitorChanged; + Monitor.EnsureStarted(); + } + + public void Dispose() => Monitor.Changed -= OnMonitorChanged; + + private void OnMonitorChanged() => _ = InvokeAsync(StateHasChanged); + + private static string LoadClass(ServerStatus status) => + status.ActiveConnections >= status.MaxConnections ? "is-full" + : status.ActiveConnections * 5 >= status.MaxConnections * 4 ? "is-high" + : "is-ok"; + + private static string Describe(ServerStatus status) => + $"{status.ActiveConnections} of {status.MaxConnections} connections in use" + + (status.QueuedClients > 0 ? $", {status.QueuedClients} waiting in the queue" : null); + +} diff --git a/DataTransfer.Client/Components/ServerLoad.razor.css b/DataTransfer.Client/Components/ServerLoad.razor.css new file mode 100644 index 0000000..e71eb9c --- /dev/null +++ b/DataTransfer.Client/Components/ServerLoad.razor.css @@ -0,0 +1,36 @@ +/* Lives in the always-dark header, so the colors are fixed instead of following the theme tokens. */ +.server-load { + display: inline-flex; + align-items: center; + gap: 0.375rem; + color: var(--header-muted); + font-size: 0.8125rem; + white-space: nowrap; +} + +.server-load-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background-color: #4cb167; +} + +.is-high .server-load-dot { + background-color: #e0a63c; +} + +.is-full .server-load-dot { + background-color: #ef6b65; +} + +.server-load-count { + color: var(--header-text); + font-family: var(--mono); + font-variant-numeric: tabular-nums; +} + +@media (max-width: 560px) { + .server-load-label { + display: none; + } +} diff --git a/DataTransfer.Client/DataTransfer.Client.csproj b/DataTransfer.Client/DataTransfer.Client.csproj new file mode 100644 index 0000000..9d82fe6 --- /dev/null +++ b/DataTransfer.Client/DataTransfer.Client.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + true + Default + true + + + + + + + + + diff --git a/DataTransfer.Client/Pages/Link.razor b/DataTransfer.Client/Pages/Link.razor new file mode 100644 index 0000000..b9713bc --- /dev/null +++ b/DataTransfer.Client/Pages/Link.razor @@ -0,0 +1,446 @@ +@page "/" +@rendermode @(new InteractiveWebAssemblyRenderMode(false)) + +@if (!_cryptoSupported) +{ +
+

Start a transfer

+
+ +} +else +{ + @if (_error is not null) + { + + } + + @switch (Session.State) + { + case SessionState.Idle: + case SessionState.Joining: +
+

Start a transfer

+

Connect two browsers with a one-time code. Messages and files are encrypted on this device before + they are sent.

+
+ +
+
+

Create a connection +

+

+ Generates a one-time code. Share it with the person you want to exchange messages and files + with. +

+ @if (Load is { } load && IsServerFull) + { +

+ + + The server is full (@load.ActiveConnections/@load.MaxConnections connections in use@(load.QueuedClients > 0 ? $", {load.QueuedClients} waiting" : null)). + You will be placed in a queue. + +

+ } +
+ +
+
+ + + +
+

Join a connection +

+

Enter the code you received.

+
+ + +
+
+
+ +
    +
  1. + 01 +
    Create One side creates a connection and shares the code.
    +
  2. +
  3. + 02 +
    Join The other side enters the code to connect.
    +
  4. +
  5. + 03 +
    Verify & transfer Compare the safety code, then send messages and files. +
    +
  6. +
+ break; + + case SessionState.Queued: +
+

Waiting for a free slot

+

+ The server has reached its connection limit. Your connection is created automatically as soon as + another + one closes – keep this page open. +

+
+ +
+
+

Queue

+
+
+
+ @Session.QueuePosition +
+ @QueueAheadText + Position in the queue +
+
+ +
+ + Waiting for a connection to close… + @if (Load is { } queueLoad) + { + + @queueLoad.ActiveConnections/@queueLoad.MaxConnections connections in use + + } +
+
+ +
+ break; + + case SessionState.Waiting: +
+

Share the code

+

Give this code to the person you want to connect with, for example by phone or messenger. It can only + be used once.

+
+ +
+
+

Connection code

+
+
+
+
+ @foreach (var c in DisplayCode ?? string.Empty) + { + @c + } +
+ +
+ +
+ + @(Session.IsPeerJoining ? "The other person joined. Securing the connection…" : "Waiting for the other person to join…") + Expires in @RemainingTime +
+ +
+ +
+ break; + + default: + var connected = Session.State == SessionState.Connected; + + @if (connected) + { +
+
+

Connected

+
+
+
Code
+
@DisplayCode
+
+
+
Safety code
+
@Session.SafetyCode
+
+
+
Idle timeout · @DisplayFormat.Duration(Session.InactivityTimeout)
+
@RemainingTime left
+
+
+ +
+

+ + + Compare the safety code with your partner over another channel (e.g. by phone). If both codes match, + nobody else – not even this server – can read your messages and files. + +

+
+ } + else + { +
+
+

Disconnected

+
+ @Session.ClosedMessage + @if (Session.HasPeer) + { + Received files stay available until you start a new connection. + } +
+ +
+
+ } + + @if (Session.HasPeer) + { +
+ @if (connected) + { +
+
+

Send

+
+
+ + +
+ + +
+ Ctrl + Enter to send + +
+
+
+
+ } + +
+
+

Transfers

+ + @SentCount sent + @ReceivedCount received + +
+ @* column-reverse keeps the log scrolled to the newest entry without any JavaScript. *@ +
+ @if (Session.Entries.Count == 0) + { +
+ + Nothing transferred yet + Messages and files are encrypted in your browser before they leave it. +
+ } + else + { +
    + @foreach (var entry in Session.Entries) + { + @if (entry.Kind == ChatEntryKind.System) + { +
  1. + +
    @entry.Text
    + +
  2. + } + else + { + var outgoing = entry.Kind == ChatEntryKind.Outgoing; +
  3. + + + @(outgoing ? "Sent" : "Received") + +
    + @if (entry.IsFile) + { +
    + + @entry.FileName +
    + @if (entry.IsRiskyFile) + { +
    + + + This file type can run programs or scripts on your device. Only open it if + you know the sender and expected this file. + +
    + } + @if (entry.Error is not null) + { +
    @entry.Error
    + } + else if (entry.IsTransferring) + { +
    +
    +
    + } + } + else + { +
    @entry.Text
    + } +
    + +
    + @if (!entry.IsFile) + { + + } + else if (entry.Error is not null) + { + Failed + } + else if (entry.IsTransferring) + { + @entry.ProgressPercent% + } + else if (entry.DownloadUrl is not null) + { + + + Save + @entry.FileName + + } + else + { + @(outgoing ? "Sent" : "Received") + } +
    +
  4. + } + } +
+ } +
+
+
+ } + + break; + } +} diff --git a/DataTransfer.Client/Pages/Link.razor.cs b/DataTransfer.Client/Pages/Link.razor.cs new file mode 100644 index 0000000..9dd2879 --- /dev/null +++ b/DataTransfer.Client/Pages/Link.razor.cs @@ -0,0 +1,398 @@ +using System.Diagnostics; +using System.Net; +using DataTransfer.Client.Services; +using DataTransfer.Client.Shared; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Components.Web; +using SpawnDev.BlazorJS; +using SpawnDev.BlazorJS.Cryptography; +using SpawnDev.BlazorJS.JSObjects; + +namespace DataTransfer.Client.Pages; + +public sealed partial class Link : ComponentBase, IAsyncDisposable +{ + private const int MaxFilesPerSelection = 20; + + // Frames can arrive ten times per second; re-rendering the whole log for each would make the page sluggish. + private static readonly TimeSpan MinRenderInterval = TimeSpan.FromMilliseconds(250); + + private TransferSession? _session; + private SessionState _observedState; + private string? _documentTitle; + private Window? _window; + private bool _isLeaveGuardActive; + private long _lastRenderTimestamp; + private bool _isRenderScheduled; + private Timer? _ticker; + private bool _cryptoSupported; + private PendingAction _pending; + private bool _copied; + private bool _dragging; + private bool _sendingMessage; + private ChatEntry? _copiedEntry; + private string? _joinCode; + private string? _message; + private string? _error; + + private enum PendingAction + { + None, + Create, + Join + } + + private TransferSession Session => _session!; + + private ServerStatus? Load => Status.Current; + + /// A new connection would be queued; a non-empty queue also means no slot is available to newcomers. + private bool IsServerFull => Load is { } load && (load.ActiveConnections >= load.MaxConnections || load.QueuedClients > 0); + + private string QueueAheadText => (Session.QueuePosition - 1) switch + { + <= 0 => "You are next", + 1 => "1 person ahead of you", + var ahead => $"{ahead} people ahead of you" + }; + + private string Title => _session?.State switch + { + SessionState.Queued => $"Queue #{Session.QueuePosition} · DataTransfer", + SessionState.Waiting => "Waiting for partner · DataTransfer", + SessionState.Connected => "Connected · DataTransfer", + _ => "DataTransfer" + }; + + private string? DisplayCode => Session.Code is { } code ? ConnectionCode.Format(code) : null; + + private string RemainingTime => DisplayFormat.Countdown(Session.RemainingTime ?? TimeSpan.Zero); + + private int RemainingPercent => Session.CountdownDuration > TimeSpan.Zero + ? (int)Math.Clamp((Session.RemainingTime ?? TimeSpan.Zero) / Session.CountdownDuration * 100, 0, 100) + : 0; + + private bool IsBusy => _pending != PendingAction.None; + + /// Reloading or leaving the page now would cancel a queued, pending or established connection. + private bool IsConnectionActive => + _session?.State is SessionState.Queued or SessionState.Joining or SessionState.Waiting or SessionState.Connected; + + private int SentCount => Session.Entries.Count(entry => entry.Kind == ChatEntryKind.Outgoing); + + private int ReceivedCount => Session.Entries.Count(entry => entry.Kind == ChatEntryKind.Incoming); + + [Inject] private NavigationManager Navigation { get; set; } = null!; + + [Inject] private PortableCrypto Crypto { get; set; } = null!; + + [Inject] private BlazorJSRuntime JS { get; set; } = null!; + + [Inject] private ILoggerFactory LoggerFactory { get; set; } = null!; + + [Inject] private ILogger Logger { get; set; } = null!; + + [Inject] private ServerStatusMonitor Status { get; set; } = null!; + + [Inject] private BotCheckSolver BotCheckSolver { get; set; } = null!; + + public async ValueTask DisposeAsync() + { + Status.Changed -= OnStatusChanged; + SetLeaveGuard(false); + _window?.Dispose(); + if (_ticker is not null) + { + await _ticker.DisposeAsync(); + } + + if (_session is not null) + { + _session.Changed -= OnSessionChanged; + await _session.DisposeAsync(); + } + } + + protected override void OnInitialized() + { + _cryptoSupported = JS.Get("isSecureContext") && !JS.IsUndefined("crypto.subtle"); + _session = new TransferSession(Navigation, Crypto, BotCheckSolver, LoggerFactory.CreateLogger()); + _session.Changed += OnSessionChanged; + _ticker = new Timer(_ => OnTick(), null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); + Status.Changed += OnStatusChanged; + Status.EnsureStarted(); + } + + protected override void OnAfterRender(bool firstRender) + { + // The HeadOutlet is rendered statically, so cannot reach it from this non-prerendered page. + var title = Title; + if (title != _documentTitle) + { + _documentTitle = title; + JS.Set("document.title", title); + } + + SetLeaveGuard(IsConnectionActive); + _lastRenderTimestamp = Stopwatch.GetTimestamp(); + } + + /// + /// While enabled, the browser asks before a reload, closing the tab or clicking the title (which reloads the + /// page) cancels the connection. + /// + private void SetLeaveGuard(bool enabled) + { + if (enabled == _isLeaveGuardActive) + { + return; + } + + _window ??= JS.Get("window"); + if (enabled) + { + _window.OnBeforeUnload += OnBeforeUnload; + } + else + { + _window.OnBeforeUnload -= OnBeforeUnload; + } + + _isLeaveGuardActive = enabled; + } + + private static void OnBeforeUnload(BeforeUnloadEvent beforeUnload) + { + beforeUnload.PreventDefault(); + + // Older browsers only show the dialog when a return value is set; its text is no longer displayed. + beforeUnload.ReturnValue = string.Empty; + } + + private Task CreateAsync() => RunBusyAsync( + PendingAction.Create, + () => + { + _copied = false; + return Session.CreateAsync(); + }); + + private Task JoinAsync() => RunBusyAsync( + PendingAction.Join, + async () => + { + var error = await Session.JoinAsync(_joinCode); + if (error is null && Session.State == SessionState.Connected) + { + _joinCode = null; + } + + return error; + }); + + private async Task RunBusyAsync(PendingAction pending, Func> action) + { + if (IsBusy) + { + return; + } + + _pending = pending; + _error = null; + try + { + _error = await action(); + } + catch (Exception ex) + { + if (IsRateLimited(ex)) + { + Logger.LogWarning("Connecting was rate limited by the server"); + _error = "Too many connections or connection attempts from your network. Please wait a minute and try again."; + } + else + { + Logger.LogError(ex, "Connecting failed"); + _error = "Connecting to the server failed. Please try again."; + } + + await Session.ResetAsync(); + } + finally + { + _pending = PendingAction.None; + } + } + + private static bool IsRateLimited(Exception exception) => + exception is HttpRequestException { StatusCode: HttpStatusCode.TooManyRequests } + || (exception is AggregateException aggregate && aggregate.InnerExceptions.Any(IsRateLimited)) + || (exception.InnerException is { } inner && IsRateLimited(inner)); + + private async Task OnJoinKeyDownAsync(KeyboardEventArgs args) + { + if (args.Key == "Enter") + { + await JoinAsync(); + } + } + + private async Task OnMessageKeyDownAsync(KeyboardEventArgs args) + { + if (args.Key == "Enter" && (args.CtrlKey || args.MetaKey)) + { + await SendMessageAsync(); + } + } + + private async Task SendMessageAsync() + { + var text = _message; + if (_sendingMessage || string.IsNullOrWhiteSpace(text)) + { + return; + } + + _sendingMessage = true; + try + { + var error = await Session.SendTextAsync(text); + if (error is not null) + { + _error = error; + } + else if (_message == text) + { + _message = string.Empty; + } + } + finally + { + _sendingMessage = false; + } + } + + private async Task OnFilesSelectedAsync(InputFileChangeEventArgs args) + { + _dragging = false; + IReadOnlyList files; + try + { + files = args.GetMultipleFiles(MaxFilesPerSelection); + } + catch (InvalidOperationException) + { + _error = $"Please select at most {MaxFilesPerSelection} files at once."; + return; + } + + await Session.SendFilesAsync(files); + } + + private async Task CopyCodeAsync() + { + if (DisplayCode is not { } code) + { + return; + } + + _copied = await TryCopyAsync(code); + if (!_copied) + { + _error = "Copying failed. Please copy the code manually."; + } + } + + private async Task CopyEntryAsync(ChatEntry entry) + { + if (entry.Text is not { } text) + { + return; + } + + if (await TryCopyAsync(text)) + { + _copiedEntry = entry; + } + else + { + _error = "Copying failed. Please select and copy the text manually."; + } + } + + private async Task TryCopyAsync(string text) + { + try + { + using var navigator = JS.Get("navigator"); + using var clipboard = navigator.Clipboard; + await clipboard.WriteText(text); + return true; + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Copying to the clipboard failed"); + return false; + } + } + + private Task DisconnectAsync() => Session.DisconnectAsync(); + + private async Task ResetAsync() + { + _error = null; + _copied = false; + _copiedEntry = null; + _dragging = false; + _message = null; + await Session.ResetAsync(); + } + + private void OnSessionChanged() + { + // Connections opening or closing change the server load, so don't wait for the next poll. + if (_session is { } session && session.State != _observedState) + { + _observedState = session.State; + _ = Status.RefreshAsync(); + } + + RequestRender(); + } + + private void OnStatusChanged() => RequestRender(); + + private void OnTick() + { + if (_session?.State is SessionState.Waiting or SessionState.Connected) + { + RequestRender(); + } + } + + /// + /// Renders right away if the last render is long enough ago, otherwise once has + /// passed; changes in between are coalesced into that render. + /// + private void RequestRender() + { + if (_isRenderScheduled) + { + return; + } + + _isRenderScheduled = true; + var wait = MinRenderInterval - Stopwatch.GetElapsedTime(_lastRenderTimestamp); + _ = InvokeAsync(async () => + { + if (wait > TimeSpan.Zero) + { + await Task.Delay(wait); + } + + _isRenderScheduled = false; + StateHasChanged(); + }); + } +} \ No newline at end of file diff --git a/DataTransfer.Client/Pages/Link.razor.css b/DataTransfer.Client/Pages/Link.razor.css new file mode 100644 index 0000000..1f6a097 --- /dev/null +++ b/DataTransfer.Client/Pages/Link.razor.css @@ -0,0 +1,783 @@ +/* ---------- Page heading ---------- */ + +.page-head { + margin-bottom: 1.25rem; +} + +.page-head h1 { + font-size: 1.375rem; + font-weight: 600; + letter-spacing: -0.01em; +} + +.page-head p { + max-width: 46rem; + margin-top: 0.25rem; + color: var(--text-muted); +} + +/* ---------- Create / join ---------- */ + +.connect { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); +} + +.connect-option { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1.5rem; +} + +.connect-title { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 1rem; + font-weight: 600; +} + +.connect-icon { + display: grid; + place-items: center; + width: 2rem; + height: 2rem; + border-radius: var(--radius); + background-color: var(--accent-soft); + color: var(--accent); +} + +.connect-text { + flex: 1; + color: var(--text-muted); +} + +.connect-divider { + position: relative; + width: 1px; + margin: 1.25rem 0; + background-color: var(--border); +} + +.connect-divider span { + position: absolute; + top: 50%; + left: 50%; + padding: 0.25rem 0; + transform: translate(-50%, -50%); + background-color: var(--surface); + color: var(--text-muted); + font-size: 0.75rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.join-row { + display: flex; + gap: 0.5rem; + max-width: 26rem; +} + +.code-input { + font-family: var(--mono); + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.code-input::placeholder { + letter-spacing: 0.12em; +} + +.steps { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem 1.5rem; + margin-top: 1.5rem; + padding: 0; + list-style: none; + color: var(--text-muted); + font-size: 0.875rem; +} + +.steps li { + display: flex; + gap: 0.75rem; + padding-top: 0.75rem; + border-top: 2px solid var(--border); +} + +.steps strong { + display: block; + color: var(--text); + font-weight: 600; +} + +.step-no { + color: var(--accent); + font-family: var(--mono); + font-size: 0.8125rem; + font-weight: 600; + line-height: 1.6; +} + +.capacity-note { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: var(--radius); + background-color: var(--warning-soft); + color: var(--warning); + font-size: 0.875rem; +} + +.capacity-note ::deep .icon { + flex: none; + width: 1rem; + height: 1rem; + margin-top: 0.1875rem; +} + +/* ---------- Queue ---------- */ + +.queue-position { + display: flex; + align-items: center; + gap: 1rem; +} + +.queue-number { + display: grid; + place-items: center; + min-width: 3.5rem; + height: 3.5rem; + padding: 0 0.75rem; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background-color: var(--surface-2); + font-family: var(--mono); + font-size: 1.75rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.queue-text { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.queue-text strong { + font-weight: 600; +} + +.queue-text .muted { + font-size: 0.875rem; +} + +/* ---------- Waiting for the peer ---------- */ + +.waiting { + max-width: 44rem; +} + +.waiting-body { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.5rem; +} + +.code-display { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem 1.25rem; +} + +.code-chars { + display: flex; + align-items: center; + gap: 0.25rem; + font-family: var(--mono); + user-select: all; +} + +.code-char { + display: grid; + place-items: center; + width: 2.25rem; + aspect-ratio: 4 / 5; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background-color: var(--surface-2); + font-size: 1.375rem; + font-weight: 600; +} + +.code-sep { + padding: 0 0.25rem; + color: var(--text-muted); + font-size: 1.25rem; +} + +.waiting-status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.625rem; +} + +.waiting-status .spinner { + color: var(--accent); +} + +.waiting-expiry { + margin-left: auto; + color: var(--text-muted); + font-size: 0.875rem; +} + +.countdown { + margin-top: -0.75rem; +} + +.countdown .progress-bar { + transition: width 1s linear; +} + +.countdown.is-low .progress-bar { + background-color: var(--warning); +} + +/* ---------- Session status ---------- */ + +.session { + margin-bottom: 1rem; +} + +.session-main { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem 2rem; + padding: 0.875rem 1rem; +} + +.session-state { + min-width: 8.5rem; + font-size: 1rem; +} + +.session-facts { + display: flex; + flex: 1; + flex-wrap: wrap; + gap: 0.5rem 2.25rem; +} + +.session-facts dt { + color: var(--text-muted); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.safety-code { + font-weight: 600; + letter-spacing: 0.03em; +} + +.session-hint { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-top: 1px solid var(--border); + border-radius: 0 0 var(--radius-lg) var(--radius-lg); + background-color: var(--surface-2); + color: var(--text-muted); + font-size: 0.8125rem; +} + +.session-hint ::deep .icon { + width: 1rem; + height: 1rem; + margin-top: 0.125rem; + color: var(--success); +} + +.session-closed { + display: flex; + flex: 1; + flex-direction: column; +} + +.session-closed .muted { + font-size: 0.8125rem; +} + +/* ---------- Workspace ---------- */ + +.workspace { + display: grid; + grid-template-columns: minmax(17rem, 21rem) minmax(0, 1fr); + gap: 1rem; + align-items: start; +} + +.workspace.is-closed { + grid-template-columns: minmax(0, 1fr); +} + +/* ---------- Send ---------- */ + +.send-body { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.dropzone { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.125rem; + min-height: 10rem; + padding: 1.25rem 1rem; + border: 1.5px dashed var(--border-strong); + border-radius: var(--radius); + background-color: var(--surface-2); + color: var(--text-muted); + font-size: 0.8125rem; + text-align: center; + transition: border-color 0.12s, background-color 0.12s; +} + +.dropzone strong { + color: var(--text); + font-size: 0.9375rem; + font-weight: 600; +} + +.dropzone.is-dragging { + border-color: var(--accent); + background-color: var(--accent-soft); +} + +@media (hover: hover) { + .dropzone:not(.is-busy):hover { + border-color: var(--accent); + background-color: var(--accent-soft); + } +} + +.dropzone:has(:focus-visible) { + border-color: var(--accent); + box-shadow: var(--focus-ring); +} + +.dropzone.is-busy { + border-style: solid; + border-color: var(--border); + background-color: var(--surface-2); +} + +.dropzone-browse { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 2px; +} + +.dropzone ::deep .dropzone-icon { + width: 1.75rem; + height: 1.75rem; + margin-bottom: 0.5rem; + color: var(--accent); + stroke-width: 1.75; +} + +.dropzone-spinner { + width: 1.5rem; + height: 1.5rem; + margin-bottom: 0.625rem; + color: var(--accent); +} + +/* The native file input covers the whole zone, so clicking and dropping both work without JavaScript. */ +.dropzone ::deep .dropzone-input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} + +.dropzone.is-busy ::deep .dropzone-input { + cursor: progress; +} + +.send-message { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.send-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.hint { + color: var(--text-muted); + font-size: 0.75rem; +} + +/* ---------- Transfer log ---------- */ + +.log { + display: flex; + flex-direction: column; + height: min(70vh, 40rem); + min-height: 22rem; + overflow: hidden; +} + +.log-summary { + display: flex; + gap: 1rem; + color: var(--text-muted); + font-size: 0.75rem; + white-space: nowrap; +} + +.log-summary > span { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.log-summary ::deep .icon { + width: 0.875rem; + height: 0.875rem; +} + +.log-scroll { + display: flex; + flex: 1; + flex-direction: column-reverse; + overflow-y: auto; +} + +.log-entries { + /* Keeps short logs at the top while column-reverse anchors the scroll position at the newest entry. */ + margin-bottom: auto; + padding: 0; + list-style: none; +} + +.log-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + max-width: 22rem; + margin: auto; + padding: 2rem 1.5rem; + color: var(--text-muted); + font-size: 0.875rem; + text-align: center; +} + +.log-empty strong { + color: var(--text); + font-size: 0.9375rem; + font-weight: 600; +} + +.log-empty ::deep .icon { + width: 2rem; + height: 2rem; + margin-bottom: 0.5rem; + color: var(--border-strong); + stroke-width: 1.5; +} + +.entry { + display: grid; + grid-template-columns: 1.75rem minmax(0, 1fr) auto 5.5rem; + grid-template-areas: "dir body meta action"; + align-items: start; + gap: 0.25rem 1rem; + padding: 0.625rem 1rem; + border-bottom: 1px solid var(--border); +} + +@media (hover: hover) { + .entry:not(.entry-system):hover { + background-color: var(--surface-2); + } +} + +.entry-dir { + grid-area: dir; + display: grid; + place-items: center; + width: 1.75rem; + height: 1.75rem; + border-radius: var(--radius); +} + +.entry-dir ::deep .icon { + width: 1rem; + height: 1rem; +} + +.entry-out .entry-dir { + background-color: var(--accent-soft); + color: var(--accent); +} + +.entry-in .entry-dir { + background-color: var(--success-soft); + color: var(--success); +} + +.entry-body { + grid-area: body; + min-width: 0; + padding-top: 0.1875rem; +} + +.entry-file { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 500; +} + +.entry-file ::deep .icon { + width: 1rem; + height: 1rem; + color: var(--text-muted); +} + +.entry-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.entry-text { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.entry-progress { + margin-top: 0.5rem; +} + +.entry-error { + margin-top: 0.125rem; + color: var(--danger); + font-size: 0.8125rem; +} + +.entry-warning { + display: flex; + align-items: flex-start; + gap: 0.375rem; + margin-top: 0.25rem; + padding: 0.375rem 0.5rem; + border: 1px solid var(--warning); + border-radius: var(--radius); + background-color: var(--warning-soft); + color: var(--text); + font-size: 0.8125rem; +} + +.entry-warning ::deep .icon { + flex: none; + width: 1rem; + height: 1rem; + margin-top: 0.0625rem; + color: var(--warning); +} + +.entry-meta { + grid-area: meta; + display: flex; + justify-content: flex-end; + gap: 1rem; + padding-top: 0.3125rem; + color: var(--text-muted); + font-family: var(--mono); + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.entry-size { + min-width: 4.75rem; + text-align: right; +} + +.entry-time { + min-width: 3.25rem; + text-align: right; +} + +.entry-action { + grid-area: action; + display: flex; + justify-content: flex-end; + padding-top: 0.125rem; +} + +.entry-action .tag { + margin-top: 0.0625rem; +} + +.entry-system { + grid-template-columns: 1.75rem minmax(0, 1fr) auto; + grid-template-areas: "dir body meta"; + background-color: var(--surface-2); + color: var(--text-muted); + font-size: 0.875rem; +} + +.entry-system.is-error { + background-color: var(--danger-soft); + color: var(--danger); +} + +/* ---------- Small screens ---------- */ + +@media (max-width: 860px) { + .workspace { + grid-template-columns: minmax(0, 1fr); + } + + .log { + height: auto; + min-height: 16rem; + max-height: 70vh; + } +} + +@media (max-width: 720px) { + .connect { + grid-template-columns: minmax(0, 1fr); + } + + .connect-option { + padding: 1.25rem; + } + + .connect-divider { + width: auto; + height: 1px; + margin: 0 1.25rem; + } + + .connect-divider span { + padding: 0 0.5rem; + } + + .join-row { + max-width: none; + } + + .steps { + grid-template-columns: minmax(0, 1fr); + } + + .waiting-body { + padding: 1.25rem 1rem; + } + + .code-chars { + gap: 0.1875rem; + } + + .code-char { + width: clamp(1.5rem, 6.8vw, 2.25rem); + font-size: clamp(1rem, 4.6vw, 1.375rem); + } + + .code-sep { + padding: 0 0.125rem; + } + + .waiting-expiry { + margin-left: 0; + flex-basis: 100%; + } + + .session-main { + gap: 0.75rem 1.5rem; + } + + .session-facts { + flex-basis: 100%; + order: 1; + gap: 0.5rem 1.5rem; + } + + .session-closed { + flex-basis: 100%; + order: 1; + } + + .session-state { + flex: 1; + } +} + +@media (max-width: 560px) { + .entry { + grid-template-columns: 1.75rem minmax(0, 1fr) auto; + grid-template-areas: + "dir body action" + "dir meta action"; + column-gap: 0.75rem; + padding: 0.625rem 0.75rem; + } + + .entry-system { + grid-template-areas: + "dir body" + "dir meta"; + grid-template-columns: 1.75rem minmax(0, 1fr); + } + + .entry-meta { + justify-content: flex-start; + gap: 0.75rem; + padding-top: 0; + font-size: 0.75rem; + } + + .entry-size, + .entry-time { + min-width: 0; + text-align: left; + } + + .entry-size:empty { + display: none; + } + + .entry-action { + align-self: center; + padding-top: 0; + } +} diff --git a/DataTransfer.Client/Program.cs b/DataTransfer.Client/Program.cs new file mode 100644 index 0000000..2346266 --- /dev/null +++ b/DataTransfer.Client/Program.cs @@ -0,0 +1,15 @@ +using DataTransfer.Client.Services; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using SpawnDev.BlazorJS; +using SpawnDev.BlazorJS.Cryptography; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +builder.Services.AddBlazorJSRuntime(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(services => new ServerStatusMonitor( + new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }, + services.GetRequiredService>())); + +await builder.Build().BlazorJSRunAsync(); \ No newline at end of file diff --git a/DataTransfer.Client/Services/BotCheckSolver.cs b/DataTransfer.Client/Services/BotCheckSolver.cs new file mode 100644 index 0000000..0ff51de --- /dev/null +++ b/DataTransfer.Client/Services/BotCheckSolver.cs @@ -0,0 +1,21 @@ +using DataTransfer.Client.Shared; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace DataTransfer.Client.Services; + +/// Solves the server's proof-of-work bot check in web workers (wwwroot/js/bot-check.js of the server). +public sealed class BotCheckSolver(IJSRuntime js, NavigationManager navigation) +{ + /// The solution, or -1 if none was found. + public async Task SolveAsync(BotCheckChallenge challenge) + { + ArgumentNullException.ThrowIfNull(challenge); + + // An absolute URL, so the module resolves relative to the app's base URI under any path base. + await using var module = await js.InvokeAsync( + "import", + navigation.ToAbsoluteUri("js/bot-check.js").ToString()); + return await module.InvokeAsync("solve", challenge.Salt, challenge.Hash, challenge.MaxNumber); + } +} diff --git a/DataTransfer.Client/Services/ChatEntry.cs b/DataTransfer.Client/Services/ChatEntry.cs new file mode 100644 index 0000000..fd5fdaf --- /dev/null +++ b/DataTransfer.Client/Services/ChatEntry.cs @@ -0,0 +1,36 @@ +namespace DataTransfer.Client.Services; + +public enum ChatEntryKind +{ + Incoming, + Outgoing, + System +} + +public sealed class ChatEntry +{ + public DateTimeOffset Time { get; } = DateTimeOffset.Now; + + public bool IsFile => FileName is not null; + + public bool IsTransferring => IsFile && Error is null && TransferredBytes < FileSize; + + public int ProgressPercent => FileSize == 0 ? 100 : (int)(TransferredBytes * 100 / FileSize); + public required ChatEntryKind Kind { get; init; } + + public string? Text { get; init; } + + public string? FileName { get; init; } + + public long FileSize { get; init; } + + public long TransferredBytes { get; set; } + + /// Object URL of a completely received and decrypted file. + public string? DownloadUrl { get; set; } + + /// A received file whose type can run code or is often used for malware or phishing. + public bool IsRiskyFile { get; init; } + + public string? Error { get; set; } +} \ No newline at end of file diff --git a/DataTransfer.Client/Services/DisplayFormat.cs b/DataTransfer.Client/Services/DisplayFormat.cs new file mode 100644 index 0000000..6306bbc --- /dev/null +++ b/DataTransfer.Client/Services/DisplayFormat.cs @@ -0,0 +1,32 @@ +using System.Globalization; + +namespace DataTransfer.Client.Services; + +public static class DisplayFormat +{ + public static string Bytes(long bytes) => bytes switch + { + >= 1024L * 1024 * 1024 => string.Create(CultureInfo.InvariantCulture, $"{bytes / 1024d / 1024d / 1024d:0.#} GB"), + >= 1024 * 1024 => string.Create(CultureInfo.InvariantCulture, $"{bytes / 1024d / 1024d:0.0} MB"), + >= 1024 => string.Create(CultureInfo.InvariantCulture, $"{bytes / 1024d:0.0} KB"), + _ => $"{bytes} B" + }; + + public static string Duration(TimeSpan duration) => + duration switch + { + { TotalHours: >= 1, Minutes: 0, Seconds: 0 } => string.Create(CultureInfo.InvariantCulture, $"{duration.TotalHours:0} h"), + { TotalMinutes: >= 1, Seconds: 0 } => string.Create(CultureInfo.InvariantCulture, $"{duration.TotalMinutes:0} min"), + _ => string.Create(CultureInfo.InvariantCulture, $"{duration.TotalSeconds:0} s") + }; + + public static string Countdown(TimeSpan remaining) + { + if (remaining < TimeSpan.Zero) + { + remaining = TimeSpan.Zero; + } + + return remaining.ToString(remaining.TotalHours >= 1 ? @"h\:mm\:ss" : @"mm\:ss", CultureInfo.InvariantCulture); + } +} \ No newline at end of file diff --git a/DataTransfer.Client/Services/FileNameSanitizer.cs b/DataTransfer.Client/Services/FileNameSanitizer.cs new file mode 100644 index 0000000..cb6938b --- /dev/null +++ b/DataTransfer.Client/Services/FileNameSanitizer.cs @@ -0,0 +1,54 @@ +using System.Globalization; +using System.Text; + +namespace DataTransfer.Client.Services; + +public static class FileNameSanitizer +{ + private const int MaxLength = 255; + private const string Fallback = "file"; + + // Characters invalid in file names on Windows, macOS or Linux. Path.GetInvalidFileNameChars() returns only the + // Unix set in WebAssembly, but the file is saved on any operating system. + private const string InvalidCharacters = "<>:\"/\\|?*"; + + /// + /// Makes a file name received from the peer safe to display and save. Invisible formatting characters are removed, + /// so for example a right-to-left override can't disguise invoice‮fdp.exe as invoiceexe.pdf. + /// + public static string Sanitize(string? name) + { + if (string.IsNullOrEmpty(name)) + { + return Fallback; + } + + var builder = new StringBuilder(Math.Min(name.Length, MaxLength)); + foreach (var rune in name.EnumerateRunes()) + { + switch (Rune.GetUnicodeCategory(rune)) + { + case UnicodeCategory.Format: + continue; + case UnicodeCategory.Control: + Append(builder, new Rune('_')); + continue; + } + + Append(builder, rune.IsAscii && InvalidCharacters.Contains((char)rune.Value) ? new Rune('_') : rune); + } + + // Windows silently drops trailing dots and spaces, which could change the extension. + var cleaned = builder.ToString().Trim().TrimEnd('.'); + return cleaned.Length == 0 ? Fallback : cleaned; + } + + // Appends whole runes only, so truncation never splits a surrogate pair. + private static void Append(StringBuilder builder, Rune rune) + { + if (builder.Length + rune.Utf16SequenceLength <= MaxLength) + { + builder.Append(rune.ToString()); + } + } +} diff --git a/DataTransfer.Client/Services/Frames.cs b/DataTransfer.Client/Services/Frames.cs new file mode 100644 index 0000000..1ea5bb4 --- /dev/null +++ b/DataTransfer.Client/Services/Frames.cs @@ -0,0 +1,101 @@ +using System.Text; + +namespace DataTransfer.Client.Services; + +/// Application frames. They are encrypted as a whole, so the relay server cannot see their type or content. +public abstract record Frame; + +public sealed record TextFrame(string Text) : Frame; + +public sealed record FileStartFrame(int FileId, string Name, long Size) : Frame; + +public sealed record FileChunkFrame(int FileId, ReadOnlyMemory Data) : Frame; + +public sealed record FileCancelFrame(int FileId) : Frame; + +/// Plaintext layout: version (byte) | type (byte) | sequence number (int64) | type-specific body. +internal static class FrameCodec +{ + private const byte Version = 1; + + private enum FrameType : byte + { + Text = 1, + FileStart = 2, + FileChunk = 3, + FileCancel = 4 + } + + public static byte[] Encode(long sequence, Frame frame) + { + var capacity = frame is FileChunkFrame chunk ? chunk.Data.Length + 32 : 256; + using var stream = new MemoryStream(capacity); + using (var writer = new BinaryWriter(stream, Encoding.UTF8, true)) + { + writer.Write(Version); + switch (frame) + { + case TextFrame text: + WriteHeader(writer, FrameType.Text, sequence); + writer.Write(text.Text); + break; + case FileStartFrame start: + WriteHeader(writer, FrameType.FileStart, sequence); + writer.Write(start.FileId); + writer.Write(start.Name); + writer.Write(start.Size); + break; + case FileChunkFrame fileChunk: + WriteHeader(writer, FrameType.FileChunk, sequence); + writer.Write(fileChunk.FileId); + writer.Write(fileChunk.Data.Span); + break; + case FileCancelFrame cancel: + WriteHeader(writer, FrameType.FileCancel, sequence); + writer.Write(cancel.FileId); + break; + default: + throw new ArgumentOutOfRangeException(nameof(frame), frame, "Unknown frame."); + } + } + + return stream.ToArray(); + } + + /// The plaintext is not a valid frame. + public static (long Sequence, Frame Frame) Decode(byte[] plaintext) + { + try + { + using var reader = new BinaryReader(new MemoryStream(plaintext, false), Encoding.UTF8); + if (reader.ReadByte() != Version) + { + throw new InvalidDataException("Unsupported frame version."); + } + + var type = (FrameType)reader.ReadByte(); + var sequence = reader.ReadInt64(); + Frame frame = type switch + { + FrameType.Text => new TextFrame(reader.ReadString()), + FrameType.FileStart => new FileStartFrame(reader.ReadInt32(), reader.ReadString(), reader.ReadInt64()), + FrameType.FileChunk => new FileChunkFrame(reader.ReadInt32(), plaintext.AsMemory((int)reader.BaseStream.Position)), + FrameType.FileCancel => new FileCancelFrame(reader.ReadInt32()), + _ => throw new InvalidDataException("Unknown frame type.") + }; + + return (sequence, frame); + } + catch (Exception ex) when (ex is IOException or FormatException) + { + // EndOfStreamException (truncated), IOException (negative string length), FormatException (bad 7-bit length). + throw new InvalidDataException("Malformed frame.", ex); + } + } + + private static void WriteHeader(BinaryWriter writer, FrameType type, long sequence) + { + writer.Write((byte)type); + writer.Write(sequence); + } +} \ No newline at end of file diff --git a/DataTransfer.Client/Services/HandshakeCodec.cs b/DataTransfer.Client/Services/HandshakeCodec.cs new file mode 100644 index 0000000..b11f479 --- /dev/null +++ b/DataTransfer.Client/Services/HandshakeCodec.cs @@ -0,0 +1,53 @@ +namespace DataTransfer.Client.Services; + +/// +/// Plaintext messages of the commit-then-reveal key exchange (see ). They are relayed +/// like frames, but before the channel is encrypted. Layout: version (byte) | type (byte) | payload. +/// +internal static class HandshakeCodec +{ + public const int CommitmentBytes = 32; + + // A P-256 SPKI key has 91 bytes; anything much larger is not a key. + private const int MaxPublicKeyBytes = 512; + private const byte Version = 1; + + private enum MessageType : byte + { + Commitment = 1, + PublicKey = 2 + } + + public static byte[] EncodeCommitment(byte[] commitment) => Encode(MessageType.Commitment, commitment); + + public static byte[] EncodePublicKey(byte[] publicKey) => Encode(MessageType.PublicKey, publicKey); + + /// The message is not a commitment. + public static byte[] DecodeCommitment(byte[] message) => + Decode(message, MessageType.Commitment, CommitmentBytes, CommitmentBytes); + + /// The message is not a public key. + public static byte[] DecodePublicKey(byte[] message) => Decode(message, MessageType.PublicKey, 1, MaxPublicKeyBytes); + + private static byte[] Encode(MessageType type, byte[] payload) + { + var message = new byte[payload.Length + 2]; + message[0] = Version; + message[1] = (byte)type; + payload.CopyTo(message, 2); + return message; + } + + private static byte[] Decode(byte[] message, MessageType type, int minPayloadBytes, int maxPayloadBytes) + { + ArgumentNullException.ThrowIfNull(message); + + var payloadBytes = message.Length - 2; + if (payloadBytes < minPayloadBytes || payloadBytes > maxPayloadBytes || message[0] != Version || message[1] != (byte)type) + { + throw new InvalidDataException($"Expected a valid key-exchange message of type {type}."); + } + + return message[2..]; + } +} diff --git a/DataTransfer.Client/Services/RiskyFileTypes.cs b/DataTransfer.Client/Services/RiskyFileTypes.cs new file mode 100644 index 0000000..ce5f25e --- /dev/null +++ b/DataTransfer.Client/Services/RiskyFileTypes.cs @@ -0,0 +1,40 @@ +using System.Collections.Frozen; + +namespace DataTransfer.Client.Services; + +/// +/// File types that can run code when opened, or are commonly used to deliver malware or phishing. Common archives +/// (zip, 7z, rar) and documents are deliberately not listed, so the warning stays meaningful. +/// +public static class RiskyFileTypes +{ + private static readonly FrozenSet Extensions = new[] + { + // Windows programs and installers + "exe", "com", "scr", "pif", "cpl", "msi", "msix", "msixbundle", "msp", "appx", "appxbundle", "appref-ms", + "application", "gadget", "dll", "sys", "drv", "ocx", + + // Scripts + "bat", "cmd", "ps1", "psm1", "vbs", "vbe", "js", "jse", "mjs", "wsf", "wsh", "wsc", "sct", "hta", "jar", "py", + "pyw", "sh", "bash", "command", "applescript", "scpt", "reg", "inf", "msc", + + // Shortcuts and shell files that can launch programs + "lnk", "url", "scf", "library-ms", "search-ms", "settingcontent-ms", + + // macOS and Linux applications and packages + "app", "pkg", "mpkg", "dmg", "workflow", "action", "deb", "rpm", "appimage", "run", "bin", + + // Disk images, often used to bypass download protection + "iso", "img", "vhd", "vhdx", + + // Office documents with macros, add-ins and other formats abused for malware + "docm", "dotm", "xlsm", "xltm", "xlam", "xll", "pptm", "potm", "ppam", "ppsm", "sldm", "one", "chm", + + // Web pages opened from disk, a common way to deliver phishing + "htm", "html", "xhtml", "mht", "mhtml" + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + /// Only the last extension counts, so invoice.pdf.exe is risky and setup.exe.txt is not. + public static bool IsRisky(string? fileName) => + Path.GetExtension(fileName) is { Length: > 1 } extension && Extensions.Contains(extension[1..]); +} diff --git a/DataTransfer.Client/Services/SecureChannel.cs b/DataTransfer.Client/Services/SecureChannel.cs new file mode 100644 index 0000000..eb413f7 --- /dev/null +++ b/DataTransfer.Client/Services/SecureChannel.cs @@ -0,0 +1,264 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using SpawnDev.BlazorJS.Cryptography; +using static SpawnDev.BlazorJS.Cryptography.PortableCrypto; + +namespace DataTransfer.Client.Services; + +/// +/// End-to-end encryption of one connection. Runs in the browser (WebAssembly + WebCrypto); keys never leave it. +/// +/// +/// Key agreement: ephemeral ECDH P-256; the private key is non-extractable and discarded after the +/// agreement. +/// +/// +/// Commit-then-reveal: the joiner first sends a hash commitment to its public key, the creator then reveals +/// its key, and only afterwards the joiner reveals its key, which the creator checks against the commitment. +/// A relay in the middle therefore has to fix the keys it shows each side before it learns the real keys, and +/// can't search for keys that produce matching safety codes. +/// +/// +/// One AES-256-GCM key per direction (PBKDF2-SHA-256 over the shared secret, salted with direction, code and +/// both public keys), so a frame can't be reflected back to its sender. +/// +/// +/// Every frame carries a sequence number inside the ciphertext: replayed, reordered or dropped frames are +/// rejected. +/// +/// +/// The safety code is a hash of both public keys; if both users see the same code, nobody intercepted the +/// agreement. +/// +/// +/// +public sealed class SecureChannel : IDisposable +{ + private const string Protocol = "DataTransfer/v3"; + private const int KeyDerivationIterations = 10_000; + + private readonly PortableCrypto _crypto; + private PortableECDHKey? _keyPair; + private PortableAESGCMKey? _sendKey; + private PortableAESGCMKey? _receiveKey; + private long _sendSequence; + private long _receiveSequence; + + private SecureChannel(PortableCrypto crypto, PortableECDHKey keyPair, byte[] publicKey, byte[] commitment) + { + _crypto = crypto; + _keyPair = keyPair; + PublicKey = publicKey; + Commitment = commitment; + } + + /// Ephemeral ECDH public key (SPKI) to send to the peer. + public byte[] PublicKey { get; } + + /// Hash commitment to ; the joiner sends it before revealing its key. + public byte[] Commitment { get; } + + public bool IsEstablished => _sendKey is not null; + + public static async Task CreateAsync(PortableCrypto crypto) + { + ArgumentNullException.ThrowIfNull(crypto); + + var keyPair = await crypto.GenerateECDHKey(NamedCurve.P256, false); + try + { + var publicKey = await crypto.ExportPublicKeySpki(keyPair); + var commitment = await ComputeCommitmentAsync(crypto, publicKey); + return new SecureChannel(crypto, keyPair, publicKey, commitment); + } + catch + { + keyPair.Dispose(); + throw; + } + } + + /// + /// Completes the key agreement as creator and returns the safety code both users can compare. + /// + /// The commitment the joiner sent before the creator revealed its key. + /// The joiner's key does not match its commitment. + public async Task EstablishAsCreatorAsync(byte[] joinerPublicKey, byte[] joinerCommitment, string code) + { + ArgumentNullException.ThrowIfNull(joinerPublicKey); + ArgumentNullException.ThrowIfNull(joinerCommitment); + + var expected = await ComputeCommitmentAsync(_crypto, joinerPublicKey); + if (!CryptographicOperations.FixedTimeEquals(expected, joinerCommitment)) + { + throw new CryptographicException("The peer's public key does not match its commitment."); + } + + return await EstablishAsync(joinerPublicKey, true, code); + } + + /// + /// Completes the key agreement as joiner and returns the safety code. Must only be called after + /// was sent, and must only be revealed afterwards. + /// + public Task EstablishAsJoinerAsync(byte[] creatorPublicKey, string code) => + EstablishAsync(creatorPublicKey, false, code); + + /// Encrypts the next outgoing frame. Callers must send sealed frames in the order they were sealed. + public Task SealAsync(Frame frame) + { + ArgumentNullException.ThrowIfNull(frame); + + var key = _sendKey ?? throw new InvalidOperationException("The channel is not established."); + return _crypto.Encrypt(key, FrameCodec.Encode(++_sendSequence, frame)); + } + + /// Decrypts and verifies the next incoming frame. + /// The frame was tampered with, replayed, reordered or dropped. + public async Task OpenAsync(byte[] ciphertext) + { + ArgumentNullException.ThrowIfNull(ciphertext); + + var key = _receiveKey ?? throw new InvalidOperationException("The channel is not established."); + + byte[]? plaintext; + try + { + plaintext = await _crypto.Decrypt(key, ciphertext); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw new CryptographicException("The frame could not be decrypted.", ex); + } + + if (plaintext is null) + { + throw new CryptographicException("The frame could not be decrypted."); + } + + long sequence; + Frame frame; + try + { + (sequence, frame) = FrameCodec.Decode(plaintext); + } + catch (InvalidDataException ex) + { + throw new CryptographicException("The frame is malformed.", ex); + } + + if (sequence != _receiveSequence + 1) + { + throw new CryptographicException("A frame was replayed, reordered or dropped."); + } + + _receiveSequence = sequence; + return frame; + } + + public void Dispose() + { + _keyPair?.Dispose(); + _sendKey?.Dispose(); + _receiveKey?.Dispose(); + _keyPair = null; + _sendKey = null; + _receiveKey = null; + } + + private static async Task ComputeCommitmentAsync(PortableCrypto crypto, byte[] publicKey) => + await crypto.Digest(HashName.SHA256, Concat(Encoding.UTF8.GetBytes($"{Protocol}|commitment|"), publicKey)); + + private static string FormatSafetyCode(byte[] digest) + { + var groups = new string[4]; + for (var group = 0; group < groups.Length; group++) + { + ulong value = 0; + for (var i = 0; i < 5; i++) + { + value = (value << 8) | digest[group * 5 + i]; + } + + groups[group] = (value % 100_000).ToString("D5", CultureInfo.InvariantCulture); + } + + return string.Join(' ', groups); + } + + private static byte[] Concat(params byte[][] parts) + { + var result = new byte[parts.Sum(part => part.Length)]; + var offset = 0; + foreach (var part in parts) + { + part.CopyTo(result, offset); + offset += part.Length; + } + + return result; + } + + private async Task EstablishAsync(byte[] peerPublicKey, bool isCreator, string code) + { + ArgumentNullException.ThrowIfNull(peerPublicKey); + ArgumentException.ThrowIfNullOrEmpty(code); + + var keyPair = _keyPair; + if (keyPair is null || IsEstablished) + { + throw new InvalidOperationException("The channel can only be established once."); + } + + if (peerPublicKey.AsSpan().SequenceEqual(PublicKey)) + { + throw new CryptographicException("The peer presented our own public key."); + } + + _keyPair = null; + byte[]? sharedSecret = null; + try + { + using var peerKey = await _crypto.ImportECDHKey(peerPublicKey, NamedCurve.P256); + sharedSecret = await _crypto.DeriveBits(keyPair, peerKey, 256); + + var creatorKey = isCreator ? PublicKey : peerPublicKey; + var joinerKey = isCreator ? peerPublicKey : PublicKey; + var creatorToJoiner = await DeriveKeyAsync(sharedSecret, "creator->joiner", code, creatorKey, joinerKey); + var joinerToCreator = await DeriveKeyAsync(sharedSecret, "joiner->creator", code, creatorKey, joinerKey); + + _sendKey = isCreator ? creatorToJoiner : joinerToCreator; + _receiveKey = isCreator ? joinerToCreator : creatorToJoiner; + + var digest = await _crypto.Digest(HashName.SHA256, Concat(Encoding.UTF8.GetBytes($"{Protocol}|safety-code|"), creatorKey, joinerKey)); + return FormatSafetyCode(digest); + } + finally + { + if (sharedSecret is not null) + { + CryptographicOperations.ZeroMemory(sharedSecret); + } + + keyPair.Dispose(); + } + } + + private async Task DeriveKeyAsync(byte[] sharedSecret, string direction, string code, byte[] creatorKey, byte[] joinerKey) + { + var salt = await _crypto.Digest( + HashName.SHA256, + Concat(Encoding.UTF8.GetBytes($"{Protocol}|{direction}|{code}|"), creatorKey, joinerKey)); + + return await _crypto.GenerateAESGCMKey( + sharedSecret, + salt, + KeyDerivationIterations, + HashName.SHA256, + 32, + 16, + 12, + false); + } +} \ No newline at end of file diff --git a/DataTransfer.Client/Services/ServerStatusMonitor.cs b/DataTransfer.Client/Services/ServerStatusMonitor.cs new file mode 100644 index 0000000..0882733 --- /dev/null +++ b/DataTransfer.Client/Services/ServerStatusMonitor.cs @@ -0,0 +1,63 @@ +using System.Net.Http.Json; +using DataTransfer.Client.Shared; + +namespace DataTransfer.Client.Services; + +/// Periodically fetches the load of the relay server; shared by all components of the page. +public sealed class ServerStatusMonitor(HttpClient http, ILogger logger) : IDisposable +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(5); + + private readonly CancellationTokenSource _stopping = new(); + private Task? _polling; + + public event Action? Changed; + + /// The latest status, or null while it is unknown. + public ServerStatus? Current { get; private set; } + + public void EnsureStarted() => _polling ??= PollAsync(_stopping.Token); + + public async Task RefreshAsync(CancellationToken cancellationToken = default) + { + ServerStatus? status; + try + { + status = await http.GetFromJsonAsync(TransferProtocol.StatusPath.TrimStart('/'), cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + logger.LogDebug(ex, "Fetching the server status failed"); + status = null; + } + + if (status != Current) + { + Current = status; + Changed?.Invoke(); + } + } + + public void Dispose() + { + _stopping.Cancel(); + _stopping.Dispose(); + } + + private async Task PollAsync(CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(PollInterval); + try + { + do + { + await RefreshAsync(cancellationToken); + } while (await timer.WaitForNextTickAsync(cancellationToken)); + } + catch (OperationCanceledException) { } + } +} \ No newline at end of file diff --git a/DataTransfer.Client/Services/TransferSession.cs b/DataTransfer.Client/Services/TransferSession.cs new file mode 100644 index 0000000..50d8b20 --- /dev/null +++ b/DataTransfer.Client/Services/TransferSession.cs @@ -0,0 +1,973 @@ +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); +} diff --git a/DataTransfer.Client/Shared/ConnectionCode.cs b/DataTransfer.Client/Shared/ConnectionCode.cs new file mode 100644 index 0000000..0538bae --- /dev/null +++ b/DataTransfer.Client/Shared/ConnectionCode.cs @@ -0,0 +1,54 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; + +namespace DataTransfer.Client.Shared; + +/// Generates and parses the one-time codes used to pair two participants. +public static class ConnectionCode +{ + public const int Length = 10; + + // Ambiguous characters (0/O, 1/I) are excluded. 32 symbols ^ 10 = 50 bits of entropy. + private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + + public static string Generate() => RandomNumberGenerator.GetString(Alphabet, Length); + + /// Accepts user input case-insensitively, ignoring whitespace and dashes. + public static bool TryNormalize(string? input, [NotNullWhen(true)] out string? code) + { + code = null; + if (string.IsNullOrWhiteSpace(input)) + { + return false; + } + + Span buffer = stackalloc char[Length]; + var count = 0; + foreach (var c in input) + { + if (char.IsWhiteSpace(c) || c == '-') + { + continue; + } + + var upper = char.ToUpperInvariant(c); + if (count == Length || !Alphabet.Contains(upper)) + { + return false; + } + + buffer[count++] = upper; + } + + if (count != Length) + { + return false; + } + + code = new string(buffer); + return true; + } + + public static string Format(string code) => + code.Length == Length ? $"{code[..5]}-{code[5..]}" : code; +} \ No newline at end of file diff --git a/DataTransfer.Client/Shared/TransferProtocol.cs b/DataTransfer.Client/Shared/TransferProtocol.cs new file mode 100644 index 0000000..3386df9 --- /dev/null +++ b/DataTransfer.Client/Shared/TransferProtocol.cs @@ -0,0 +1,150 @@ +namespace DataTransfer.Client.Shared; + +/// Constants shared by the relay server and the browser client. +public static class TransferProtocol +{ + public const string HubPath = "/hubs/transfer"; + + /// HTTP endpoint returning the current . + public const string StatusPath = "/api/status"; + + /// Plaintext size of one file chunk. Files are encrypted and relayed chunk by chunk. + public const int FileChunkBytes = 256 * 1024; + + /// Upper bound of a single encrypted frame (largest chunk plus framing and AES-GCM overhead). + public const int MaxFrameBytes = FileChunkBytes + 1024; + + /// + /// SignalR limit for one incoming message: the largest frame base64 encoded inside JSON, plus room for the + /// envelope. Kept tight because the server may buffer one such message per connected browser. + /// + public const long MaxHubMessageBytes = (MaxFrameBytes + 2) / 3 * 4 + 4 * 1024; + + public const long MaxFileBytes = 100L * 1024 * 1024; + + public const int MaxMessageLength = 10_000; + + /// + /// Maximum number of messages and files one side may send per connection. The receiver keeps all of them until + /// the session is reset and closes the connection when the peer exceeds it. + /// + public const int MaxItemsPerConnection = 1_000; + + /// Maximum total size of the files one side may send per connection; enforced like . + public const long MaxFileBytesPerConnection = 1024L * 1024 * 1024; +} + +/// +/// Methods the browser invokes on the relay hub. Each client may invoke at most +/// Transfer:MaxInvocationsPerSecond methods per second; further invocations are delayed. +/// +public interface ITransferHub +{ + /// + /// Returns a new proof-of-work challenge for , or null if the server doesn't + /// require one. It replaces any previous challenge of the connection. + /// + Task GetBotCheckChallenge(); + + /// The number that solves the latest challenge; any value if none is required. + Task CreateConnection(int botCheckSolution); + + Task JoinConnection(string code); + + /// + /// Relays an opaque frame to the peer: first the plaintext key-exchange messages, then end-to-end encrypted + /// frames. + /// + Task Send(byte[] frame); + + Task LeaveConnection(); +} + +/// Methods the relay hub invokes on the browser. +public interface ITransferHubClient +{ + /// Someone joined the creator's connection; the key exchange follows via . + Task PeerJoined(); + + Task Receive(byte[] frame); + + Task ConnectionClosed(ConnectionClosedReason reason); + + /// A queued create request got a free slot; the connection now exists. + Task ConnectionCreated(CreateConnectionResult result); + + /// The 1-based queue position of a waiting create request changed. + Task QueuePositionChanged(int position); +} + +/// The connection code, if is . +/// The connection is closed unless someone joins within this period. +/// +/// 1-based position in the queue, if is +/// . +/// +public sealed record CreateConnectionResult( + CreateStatus Status, + string? Code, + TimeSpan JoinTimeout, + TimeSpan InactivityTimeout, + int QueuePosition); + +public enum CreateStatus +{ + Created, + + /// The connection limit is reached; the connection is created as soon as a slot becomes free. + Queued, + + /// The connection limit is reached and the queue is full. + ServerBusy, + + /// The network address of the browser already has the maximum number of connections or queued requests. + TooManyConnections, + + /// The proof-of-work solution was missing, wrong, reused or too late. + BotCheckFailed +} + +/// +/// Proof of work against creating connections in bulk: find the number between 0 and +/// for which the lowercase hex SHA-256 of the UTF-8 string Salt + number equals . +/// +public sealed record BotCheckChallenge(string Salt, string Hash, int MaxNumber); + +/// Current load of the relay server. +public sealed record ServerStatus(int ActiveConnections, int MaxConnections, int QueuedClients); + +public sealed record JoinConnectionResult(JoinStatus Status, TimeSpan InactivityTimeout); + +public enum JoinStatus +{ + Joined, + InvalidCode, + NotFound, + AlreadyInUse, + + /// Too many browsers from the same network address are connected to the server. + TooManyConnections +} + +public enum SendStatus +{ + /// The frame was relayed to the peer. + Delivered, + WaitingForPeer, + NotConnected, + InvalidSize, + + /// The connection relayed its maximum amount of data and was closed. + TransferLimitReached +} + +public enum ConnectionClosedReason +{ + PeerLeft, + Inactivity, + MaximumLifetime, + TransferLimitReached +} \ No newline at end of file diff --git a/DataTransfer.Client/_Imports.razor b/DataTransfer.Client/_Imports.razor new file mode 100644 index 0000000..f6c0306 --- /dev/null +++ b/DataTransfer.Client/_Imports.razor @@ -0,0 +1,8 @@ +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using DataTransfer.Client +@using DataTransfer.Client.Components +@using DataTransfer.Client.Services +@using DataTransfer.Client.Shared diff --git a/DataTransfer.slnx b/DataTransfer.slnx new file mode 100644 index 0000000..960b22c --- /dev/null +++ b/DataTransfer.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/DataTransfer/Components/App.razor b/DataTransfer/Components/App.razor new file mode 100644 index 0000000..00f1fe4 --- /dev/null +++ b/DataTransfer/Components/App.razor @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +@code { + + [CascadingParameter] private HttpContext? HttpContext { get; set; } + +} diff --git a/DataTransfer/Components/Layout/MainLayout.razor b/DataTransfer/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..abaea77 --- /dev/null +++ b/DataTransfer/Components/Layout/MainLayout.razor @@ -0,0 +1,67 @@ +@inherits LayoutComponentBase + +
+
+
+ @* A full reload (no enhanced navigation) disconnects from the server, which cancels any connection. *@ + + + DataTransfer + +
+ + + + End-to-end encrypted + +
+
+
+ +
+ @Body +
+ +
+ +
+
+ +
+ An unhandled error has occurred. + Reload + +
+ +@code { + + private static readonly (string Href, string Label, string Icon)[] LegalLinks = + [ + ("imprint", "Imprint", "info"), + ("privacy", "Privacy", "shield"), + ("terms", "Terms of use", "file-text"), + ("abuse", "Report abuse", "flag") + ]; + + [Inject] private NavigationManager Navigation { get; set; } = null!; + + // Leaving the transfer page would cancel the connection without the browser's leave confirmation (Blazor + // navigates within the page), so legal pages open in a new tab there. + private bool IsTransferPage => Navigation.ToBaseRelativePath(Navigation.Uri).Split('?', '#')[0].Length == 0; + +} diff --git a/DataTransfer/Components/Layout/MainLayout.razor.css b/DataTransfer/Components/Layout/MainLayout.razor.css new file mode 100644 index 0000000..63f7270 --- /dev/null +++ b/DataTransfer/Components/Layout/MainLayout.razor.css @@ -0,0 +1,185 @@ +.shell { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.app-header { + background-color: var(--header-bg); + color: var(--header-text); +} + +.app-header-inner, +.app-main, +.app-footer-inner { + width: 100%; + max-width: 72rem; + margin: 0 auto; + padding-left: 1.5rem; + padding-right: 1.5rem; +} + +.app-header-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + height: 3.25rem; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 0.625rem; + font-size: 1rem; + font-weight: 600; + letter-spacing: 0.01em; + color: inherit; + text-decoration: none; + border-radius: var(--radius); +} + +.brand:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.brand-mark { + display: grid; + place-items: center; + width: 1.75rem; + height: 1.75rem; + border-radius: var(--radius); + background-color: var(--primary); + color: #fff; +} + +.brand-mark ::deep .icon { + width: 1.125rem; + height: 1.125rem; + stroke-width: 2.25; +} + +.header-meta { + display: flex; + align-items: center; + gap: 1.25rem; + min-width: 0; +} + +.header-note { + display: inline-flex; + align-items: center; + gap: 0.375rem; + color: var(--header-muted); + font-size: 0.8125rem; + white-space: nowrap; +} + +.header-note ::deep .icon { + width: 0.875rem; + height: 0.875rem; +} + +.app-main { + flex: 1; + padding-top: 1.75rem; + padding-bottom: 2.5rem; +} + +.app-footer { + border-top: 1px solid var(--border); +} + +.footer-links { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + width: 100%; +} + +/* ::deep also reaches the link rendered by SourceCodeLink and the Icon components. */ +.footer-links ::deep a { + display: inline-flex; + align-items: center; + gap: 0.3125rem; + color: inherit; +} + +.footer-links ::deep a:hover { + color: var(--text); +} + +.footer-links ::deep .icon { + width: 0.8125rem; + height: 0.8125rem; +} + +.app-footer-inner { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.25rem 1.5rem; + padding-top: 0.875rem; + padding-bottom: 0.875rem; + color: var(--text-muted); + font-size: 0.75rem; +} + +@media (max-width: 560px) { + .app-header-inner, + .app-main, + .app-footer-inner { + padding-left: 1rem; + padding-right: 1rem; + } + + .app-main { + padding-top: 1.25rem; + } + + .header-meta { + gap: 0.875rem; + } + + /* Only the lock icon remains visible; the text stays available to screen readers. */ + .header-note-text { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } +} + +#blazor-error-ui { + color-scheme: light only; + display: none; + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + padding: 0.75rem 3rem 0.75rem 1.25rem; + border-top: 1px solid #e3c27a; + background-color: #fcf1dc; + color: #4a3300; + font-size: 0.875rem; + box-shadow: 0 -2px 8px rgb(0 0 0 / 0.08); +} + +#blazor-error-ui .reload { + margin-left: 0.5rem; + color: inherit; + font-weight: 600; +} + +#blazor-error-ui .dismiss { + position: absolute; + top: 50%; + right: 1rem; + display: grid; + transform: translateY(-50%); + cursor: pointer; +} diff --git a/DataTransfer/Components/Layout/OperatorContact.razor b/DataTransfer/Components/Layout/OperatorContact.razor new file mode 100644 index 0000000..3e1e23d --- /dev/null +++ b/DataTransfer/Components/Layout/OperatorContact.razor @@ -0,0 +1,21 @@ +@inject IOptions LegalOptions + + + +@code { + + private LegalOptions Legal => LegalOptions.Value; + +} diff --git a/DataTransfer/Components/Layout/ProtectedData.cs b/DataTransfer/Components/Layout/ProtectedData.cs new file mode 100644 index 0000000..e91d484 --- /dev/null +++ b/DataTransfer/Components/Layout/ProtectedData.cs @@ -0,0 +1,18 @@ +using System.Text; + +namespace DataTransfer.Components.Layout; + +/// +/// Encoding for personal data (e-mail addresses, name, postal address) that the server writes into the HTML, so +/// harvesters that scan the HTML find nothing usable. wwwroot/DataTransfer.lib.module.js decodes it in the browser. +/// +public static class ProtectedData +{ + // Reversed UTF-8 bytes in base64: no '@', no readable words and nothing that looks like an address. + public static string Encode(string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + Array.Reverse(bytes); + return Convert.ToBase64String(bytes); + } +} diff --git a/DataTransfer/Components/Layout/ProtectedEmail.razor b/DataTransfer/Components/Layout/ProtectedEmail.razor new file mode 100644 index 0000000..11259bb --- /dev/null +++ b/DataTransfer/Components/Layout/ProtectedEmail.razor @@ -0,0 +1,12 @@ +@* Renders an e-mail address only in encoded form (see ProtectedData), so address harvesters that scan the HTML don't + find it. wwwroot/DataTransfer.lib.module.js turns it into a normal mailto link in the browser. *@ +… + +@code { + + [Parameter] [EditorRequired] public string Address { get; set; } = string.Empty; + + [Parameter] public string? Subject { get; set; } + +} diff --git a/DataTransfer/Components/Layout/ProtectedText.razor b/DataTransfer/Components/Layout/ProtectedText.razor new file mode 100644 index 0000000..a8901f0 --- /dev/null +++ b/DataTransfer/Components/Layout/ProtectedText.razor @@ -0,0 +1,10 @@ +@* Renders personal data such as the operator's name or postal address only in encoded form (see ProtectedData). + wwwroot/DataTransfer.lib.module.js replaces it with the plain text in the browser. *@ +… + +@code { + + [Parameter] [EditorRequired] public string? Value { get; set; } + +} diff --git a/DataTransfer/Components/Layout/SourceCodeLink.razor b/DataTransfer/Components/Layout/SourceCodeLink.razor new file mode 100644 index 0000000..40fa8cf --- /dev/null +++ b/DataTransfer/Components/Layout/SourceCodeLink.razor @@ -0,0 +1,28 @@ +@* Link to the public source code repository, shown as its address without the scheme. Renders nothing if no + Legal:SourceCodeUrl is configured. *@ +@inject IOptions LegalOptions + +@if (LegalOptions.Value.SourceCodeUri is { } uri) +{ + + @if (ChildContent is not null) + { + @ChildContent + } + else + { + @Label(uri) + } + +} + +@code { + + /// Optional link text; defaults to the repository address. + [Parameter] public RenderFragment? ChildContent { get; set; } + + [Parameter] public string? Target { get; set; } + + private static string Label(Uri uri) => (uri.Host + uri.AbsolutePath).TrimEnd('/'); + +} diff --git a/DataTransfer/Components/Pages/Abuse.razor b/DataTransfer/Components/Pages/Abuse.razor new file mode 100644 index 0000000..135eed7 --- /dev/null +++ b/DataTransfer/Components/Pages/Abuse.razor @@ -0,0 +1,75 @@ +@page "/abuse" +@inject IOptions LegalOptions + +Report abuse · DataTransfer + +@* Personal data of the operator: keep the page out of search engines and their caches. *@ + + + + + + +@code { + + private LegalOptions Legal => LegalOptions.Value; + +} diff --git a/DataTransfer/Components/Pages/Error.razor b/DataTransfer/Components/Pages/Error.razor new file mode 100644 index 0000000..ae328c1 --- /dev/null +++ b/DataTransfer/Components/Pages/Error.razor @@ -0,0 +1,31 @@ +@page "/Error" +@using System.Diagnostics + +Error · DataTransfer + +
+ ERROR +

Something went wrong

+

An error occurred while processing your request. Please try again.

+ + @if (ShowRequestId) + { +

Request ID: @RequestId

+ } + + Back to start +
+ +@code{ + [CascadingParameter] private HttpContext? HttpContext { get; set; } + + [PersistentState] public string? RequestId { get; set; } + + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() + { + RequestId ??= Activity.Current?.Id ?? HttpContext?.TraceIdentifier; + } + +} diff --git a/DataTransfer/Components/Pages/Imprint.razor b/DataTransfer/Components/Pages/Imprint.razor new file mode 100644 index 0000000..f42504e --- /dev/null +++ b/DataTransfer/Components/Pages/Imprint.razor @@ -0,0 +1,68 @@ +@page "/imprint" +@inject IOptions LegalOptions + +Imprint · DataTransfer + +@* Personal data of the operator: keep the page out of search engines and their caches. *@ + + + + + + +@code { + + private LegalOptions Legal => LegalOptions.Value; + +} diff --git a/DataTransfer/Components/Pages/NotFound.razor b/DataTransfer/Components/Pages/NotFound.razor new file mode 100644 index 0000000..098d96c --- /dev/null +++ b/DataTransfer/Components/Pages/NotFound.razor @@ -0,0 +1,11 @@ +@page "/not-found" +@layout MainLayout + +Not found · DataTransfer + +
+ 404 +

Page not found

+

The page you are looking for does not exist.

+ Start a transfer +
diff --git a/DataTransfer/Components/Pages/Privacy.razor b/DataTransfer/Components/Pages/Privacy.razor new file mode 100644 index 0000000..17e92bc --- /dev/null +++ b/DataTransfer/Components/Pages/Privacy.razor @@ -0,0 +1,187 @@ +@page "/privacy" +@using System.Globalization +@using DataTransfer.Client.Services +@inject IOptions LegalOptions +@inject IOptions TransferOptions + +Privacy policy · DataTransfer + +@* Personal data of the operator: keep the page out of search engines and their caches. *@ + + + + + + +@code { + + private LegalOptions Legal => LegalOptions.Value; + + private TransferOptions Transfer => TransferOptions.Value; + + private string LastUpdated => Legal.LastUpdated.ToString("d MMMM yyyy", CultureInfo.InvariantCulture); + +} diff --git a/DataTransfer/Components/Pages/Terms.razor b/DataTransfer/Components/Pages/Terms.razor new file mode 100644 index 0000000..9a40a61 --- /dev/null +++ b/DataTransfer/Components/Pages/Terms.razor @@ -0,0 +1,137 @@ +@page "/terms" +@using System.Globalization +@using DataTransfer.Client.Services +@inject IOptions LegalOptions +@inject IOptions TransferOptions + +Terms of use · DataTransfer + +@* Personal data of the operator: keep the page out of search engines and their caches. *@ + + + + + + +@code { + + private LegalOptions Legal => LegalOptions.Value; + + private TransferOptions Transfer => TransferOptions.Value; + + private string LastUpdated => Legal.LastUpdated.ToString("d MMMM yyyy", CultureInfo.InvariantCulture); + +} diff --git a/DataTransfer/Components/Routes.razor b/DataTransfer/Components/Routes.razor new file mode 100644 index 0000000..093dd2d --- /dev/null +++ b/DataTransfer/Components/Routes.razor @@ -0,0 +1,8 @@ +@using DataTransfer.Components.Pages + + + + + + diff --git a/DataTransfer/Components/_Imports.razor b/DataTransfer/Components/_Imports.razor new file mode 100644 index 0000000..a87100f --- /dev/null +++ b/DataTransfer/Components/_Imports.razor @@ -0,0 +1,12 @@ +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using DataTransfer +@using DataTransfer.Client +@using DataTransfer.Client.Components +@using DataTransfer.Client.Shared +@using DataTransfer.Components +@using DataTransfer.Components.Layout +@using DataTransfer.Security +@using DataTransfer.Services +@using Microsoft.Extensions.Options diff --git a/DataTransfer/DataTransfer.csproj b/DataTransfer/DataTransfer.csproj new file mode 100644 index 0000000..16bcd8a --- /dev/null +++ b/DataTransfer/DataTransfer.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + diff --git a/DataTransfer/Hubs/InvocationRateLimitFilter.cs b/DataTransfer/Hubs/InvocationRateLimitFilter.cs new file mode 100644 index 0000000..9f870c7 --- /dev/null +++ b/DataTransfer/Hubs/InvocationRateLimitFilter.cs @@ -0,0 +1,94 @@ +using System.Collections.Concurrent; +using System.Threading.RateLimiting; +using DataTransfer.Services; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Options; + +namespace DataTransfer.Hubs; + +/// +/// Limits every browser to hub method invocations within any +/// second. Invocations beyond the limit are delayed rather than rejected, which also paces file transfers; only a +/// client that floods the queue gets an error. +/// +public sealed partial class InvocationRateLimitFilter( + IOptions options, + ILogger logger) : IHubFilter, IDisposable +{ + private readonly ConcurrentDictionary _limiters = new(StringComparer.Ordinal); + private readonly int _permitsPerSecond = options.Value.MaxInvocationsPerSecond; + + public async Task OnConnectedAsync(HubLifetimeContext context, Func next) + { + var limiter = new SlidingWindowRateLimiter( + new SlidingWindowRateLimiterOptions + { + PermitLimit = _permitsPerSecond, + Window = TimeSpan.FromSeconds(1), + SegmentsPerWindow = 10, + QueueLimit = _permitsPerSecond, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + AutoReplenishment = true + }); + + if (!_limiters.TryAdd(context.Context.ConnectionId, limiter)) + { + await limiter.DisposeAsync(); + } + + await next(context); + } + + public async ValueTask InvokeMethodAsync( + HubInvocationContext invocationContext, + Func> next) + { + var caller = invocationContext.Context; + if (!_limiters.TryGetValue(caller.ConnectionId, out var limiter)) + { + throw new HubException("The connection is closing."); + } + + using (var lease = await limiter.AcquireAsync(1, caller.ConnectionAborted)) + { + if (!lease.IsAcquired) + { + LogInvocationRejected(logger, invocationContext.HubMethodName); + throw new HubException("Too many requests."); + } + } + + return await next(invocationContext); + } + + public async Task OnDisconnectedAsync( + HubLifetimeContext context, + Exception? exception, + Func next) + { + try + { + await next(context, exception); + } + finally + { + if (_limiters.TryRemove(context.Context.ConnectionId, out var limiter)) + { + await limiter.DisposeAsync(); + } + } + } + + public void Dispose() + { + foreach (var limiter in _limiters.Values) + { + limiter.Dispose(); + } + + _limiters.Clear(); + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Rejected an invocation of {Method}: the client exceeded its rate limit")] + private static partial void LogInvocationRejected(ILogger logger, string method); +} diff --git a/DataTransfer/Hubs/TransferHub.cs b/DataTransfer/Hubs/TransferHub.cs new file mode 100644 index 0000000..6837fff --- /dev/null +++ b/DataTransfer/Hubs/TransferHub.cs @@ -0,0 +1,114 @@ +using DataTransfer.Client.Shared; +using DataTransfer.Security; +using DataTransfer.Services; +using Microsoft.AspNetCore.SignalR; + +namespace DataTransfer.Hubs; + +/// +/// Pairs browsers and relays their frames: first the key exchange, then end-to-end encrypted data. It never sees +/// private keys or plaintext. +/// +public sealed partial class TransferHub( + ITransferService transferService, + ClientAddressLimiter addressLimiter, + BotCheck botCheck, + ILogger logger) : Hub, ITransferHub +{ + private static readonly object AddressKey = new(); + + // Runs for every transport, also for clients that skip the negotiate request and its checks. + public override async Task OnConnectedAsync() + { + var httpContext = Context.GetHttpContext(); + if (httpContext is not null && !HubConnectionGuard.IsSameOrigin(httpContext.Request)) + { + LogRejected(logger, "foreign origin"); + Context.Abort(); + return; + } + + var address = ClientAddress.GetPartitionKey(httpContext?.Connection.RemoteIpAddress); + if (!addressLimiter.TryAcquire(address)) + { + // Closed instead of kept open, so idle sockets can't pile up and hold receive buffers. + LogRejected(logger, "address limit"); + Context.Abort(); + return; + } + + Context.Items[AddressKey] = address; + await base.OnConnectedAsync(); + } + + public Task GetBotCheckChallenge() => Task.FromResult(botCheck.Issue(Context)); + + public async Task CreateConnection(int botCheckSolution) + { + if (Address is not { } address) + { + return NotCreated(CreateStatus.TooManyConnections); + } + + if (!botCheck.Verify(Context, botCheckSolution)) + { + LogRejected(logger, "bot check failed"); + return NotCreated(CreateStatus.BotCheckFailed); + } + + var result = await transferService.CreateAsync(Context.ConnectionId, address); + await LeaveIfAbortedAsync(); + return result; + } + + public async Task JoinConnection(string code) + { + if (Address is null) + { + return new JoinConnectionResult(JoinStatus.TooManyConnections, TimeSpan.Zero); + } + + var result = await transferService.JoinAsync(Context.ConnectionId, code); + await LeaveIfAbortedAsync(); + return result; + } + + public Task Send(byte[] frame) => transferService.SendAsync(Context.ConnectionId, frame); + + public Task LeaveConnection() => transferService.LeaveAsync(Context.ConnectionId); + + public override async Task OnDisconnectedAsync(Exception? exception) + { + try + { + await transferService.LeaveAsync(Context.ConnectionId); + } + finally + { + if (Address is { } address) + { + addressLimiter.Release(address); + } + } + + await base.OnDisconnectedAsync(exception); + } + + // Only set for accepted connections; rejected ones are aborted and must not create or join anything. + private string? Address => Context.Items.TryGetValue(AddressKey, out var address) ? address as string : null; + + private static CreateConnectionResult NotCreated(CreateStatus status) => new(status, null, TimeSpan.Zero, TimeSpan.Zero, 0); + + // The browser may disconnect while a create/join is in flight; don't leave an orphaned registration behind. + private async Task LeaveIfAbortedAsync() + { + if (Context.ConnectionAborted.IsCancellationRequested) + { + await transferService.LeaveAsync(Context.ConnectionId); + } + } + + // Debug only: an attacker can trigger this for every connection attempt. + [LoggerMessage(Level = LogLevel.Debug, Message = "Rejected a hub connection: {Reason}")] + private static partial void LogRejected(ILogger logger, string reason); +} diff --git a/DataTransfer/Models/Connection.cs b/DataTransfer/Models/Connection.cs new file mode 100644 index 0000000..cbccb8e --- /dev/null +++ b/DataTransfer/Models/Connection.cs @@ -0,0 +1,170 @@ +using System.Diagnostics.CodeAnalysis; +using DataTransfer.Client.Shared; + +namespace DataTransfer.Models; + +/// +/// A pairing of at most two browsers, identified by their SignalR connection ids. The server only relays opaque +/// frames between them: the plaintext key exchange first, end-to-end encrypted frames afterwards. +/// +public sealed class Connection +{ + private readonly Lock _sync = new(); + private readonly DateTimeOffset _createdAt; + private string? _joinerClientId; + private DateTimeOffset _lastActivityAt; + private long _relayedBytes; + private bool _isClosed; + + public Connection(string code, string creatorClientId, string creatorAddress, DateTimeOffset createdAt) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + ArgumentException.ThrowIfNullOrEmpty(creatorClientId); + ArgumentException.ThrowIfNullOrEmpty(creatorAddress); + + Code = code; + CreatorClientId = creatorClientId; + CreatorAddress = creatorAddress; + _createdAt = createdAt; + _lastActivityAt = createdAt; + } + + public string Code { get; } + + public string CreatorClientId { get; } + + /// Network address partition of the creator (see ClientAddress), for the per-address limit. + public string CreatorAddress { get; } + + public bool IsClosed + { + get + { + lock (_sync) + { + return _isClosed; + } + } + } + + public IReadOnlyList GetClientIds() + { + lock (_sync) + { + return _joinerClientId is null ? [CreatorClientId] : [CreatorClientId, _joinerClientId]; + } + } + + /// Claims the second and last slot of the connection. + public bool TryJoin(string joinerClientId, DateTimeOffset now) + { + ArgumentException.ThrowIfNullOrEmpty(joinerClientId); + + lock (_sync) + { + if (_isClosed || _joinerClientId is not null) + { + return false; + } + + _joinerClientId = joinerClientId; + _lastActivityAt = now; + return true; + } + } + + /// + /// Resolves the recipient of a frame sent by and records the activity. Closes the + /// connection instead if the frame would exceed . + /// + public bool TryRecordTransmission( + string senderClientId, + int frameBytes, + DateTimeOffset now, + long maxRelayedBytes, + [NotNullWhen(true)] out string? recipientClientId, + out SendStatus failure) + { + lock (_sync) + { + recipientClientId = null; + var isCreator = CreatorClientId == senderClientId; + + if (_isClosed || (!isCreator && _joinerClientId != senderClientId)) + { + failure = SendStatus.NotConnected; + return false; + } + + if (_joinerClientId is null) + { + failure = SendStatus.WaitingForPeer; + return false; + } + + if (_relayedBytes + frameBytes > maxRelayedBytes) + { + _isClosed = true; + failure = SendStatus.TransferLimitReached; + return false; + } + + _relayedBytes += frameBytes; + recipientClientId = isCreator ? _joinerClientId : CreatorClientId; + _lastActivityAt = now; + failure = SendStatus.Delivered; + return true; + } + } + + public bool TryClose() + { + lock (_sync) + { + if (_isClosed) + { + return false; + } + + _isClosed = true; + return true; + } + } + + /// + /// Closes the connection atomically if nobody joined within , nothing was + /// transmitted within or it exists for . + /// + public bool TryCloseIfExpired( + DateTimeOffset now, + TimeSpan joinTimeout, + TimeSpan inactivityTimeout, + TimeSpan maxLifetime, + out ConnectionClosedReason reason) + { + lock (_sync) + { + reason = default; + if (_isClosed) + { + return false; + } + + if (now - _createdAt >= maxLifetime) + { + reason = ConnectionClosedReason.MaximumLifetime; + } + else if (now - _lastActivityAt >= (_joinerClientId is null ? joinTimeout : inactivityTimeout)) + { + reason = ConnectionClosedReason.Inactivity; + } + else + { + return false; + } + + _isClosed = true; + return true; + } + } +} diff --git a/DataTransfer/Program.cs b/DataTransfer/Program.cs new file mode 100644 index 0000000..d21bcca --- /dev/null +++ b/DataTransfer/Program.cs @@ -0,0 +1,122 @@ +using System.Threading.RateLimiting; +using DataTransfer.Client.Shared; +using DataTransfer.Components; +using DataTransfer.Hubs; +using DataTransfer.Security; +using DataTransfer.Services; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Options; +using _Imports = DataTransfer.Client._Imports; + +var builder = WebApplication.CreateBuilder(args); + +// Kestrel only reads endpoints from its configuration section; bind the connection limits explicitly. They cap the +// sockets (and receive buffers) the server keeps open, whatever the per-address limits let through. +builder.WebHost.ConfigureKestrel(options => builder.Configuration.GetSection("Kestrel:Limits").Bind(options.Limits)); + +builder.Services.AddRazorComponents() + .AddInteractiveWebAssemblyComponents(); + +builder.Services.AddSingleton(); +builder.Services.AddSignalR() + .AddHubOptions(options => + { + options.MaximumReceiveMessageSize = TransferProtocol.MaxHubMessageBytes; + options.AddFilter(); + }); + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(TransferOptions.SectionName)) + .Validate(options => options.IsValid(), "Transfer options must contain positive time spans and limits and a non-negative queue length.") + .ValidateOnStart(); + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(LegalOptions.SectionName)); + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(BotCheckOptions.SectionName)) + .Validate(options => options.IsValid(), "Bot check options must contain positive limits, a busy threshold between 0 and 1 and a positive challenge lifetime.") + .ValidateOnStart(); + +builder.AddConfiguredForwardedHeaders(); + +builder.Services.AddRateLimiter(_ => { }); +builder.Services.AddOptions() + .Configure>((limiter, transfer) => + { + var attemptsPerMinute = transfer.Value.MaxConnectAttemptsPerMinute; + limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + limiter.OnRejected = (context, cancellationToken) => + { + // A content type keeps the status code pages from replacing the response. + context.HttpContext.Response.ContentType = "text/plain"; + return new ValueTask( + context.HttpContext.Response.WriteAsync("Too many connection attempts. Please try again later.", cancellationToken)); + }; + + // Browsers start every hub connection with a negotiate request, so this gives them a clear HTTP 429. The hub + // enforces the same limit again for clients that skip the negotiate request. + limiter.GlobalLimiter = PartitionedRateLimiter.Create(context => + context.Request.Path.StartsWithSegments($"{TransferProtocol.HubPath}/negotiate") + ? RateLimitPartition.GetFixedWindowLimiter( + ClientAddress.GetPartitionKey(context.Connection.RemoteIpAddress), + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = attemptsPerMinute, + Window = TimeSpan.FromMinutes(1) + }) + : RateLimitPartition.GetNoLimiter(string.Empty)); + }); + +builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +var app = builder.Build(); + +if (!app.Environment.IsDevelopment() && app.Services.GetRequiredService>().Value.HasPlaceholders()) +{ + app.Logger.LogWarning( + "The imprint, privacy policy and terms of use still contain placeholders. Fill in the {Section} configuration section before going live.", + LegalOptions.SectionName); +} + +app.UseConfiguredForwardedHeaders(); + +if (app.Environment.IsDevelopment()) +{ + app.UseWebAssemblyDebugging(); +} +else +{ + app.UseExceptionHandler("/Error", true); + + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseSecurityHeaders(app.Environment.IsDevelopment()); +app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); +app.UseHttpsRedirection(); +app.UseHubNegotiateGuard(); +app.UseRateLimiter(); + +app.UseAntiforgery(); + +app.MapStaticAssets(); +app.MapHub(TransferProtocol.HubPath); +app.MapGet( + TransferProtocol.StatusPath, + (HttpContext context, ITransferService transferService) => + { + context.Response.Headers.CacheControl = "no-store"; + return transferService.GetStatus(); + }); +app.MapRazorComponents() + .AddInteractiveWebAssemblyRenderMode() + .AddAdditionalAssemblies(typeof(_Imports).Assembly); + +app.Run(); \ No newline at end of file diff --git a/DataTransfer/Properties/launchSettings.json b/DataTransfer/Properties/launchSettings.json new file mode 100644 index 0000000..78afe61 --- /dev/null +++ b/DataTransfer/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5133", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7260;http://localhost:5133", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/DataTransfer/Security/BotCheck.cs b/DataTransfer/Security/BotCheck.cs new file mode 100644 index 0000000..3bdbc48 --- /dev/null +++ b/DataTransfer/Security/BotCheck.cs @@ -0,0 +1,64 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using DataTransfer.Client.Shared; +using DataTransfer.Services; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Options; + +namespace DataTransfer.Security; + +/// +/// Proof of work before a connection is created: the browser has to find the number behind a SHA-256 hash. That +/// costs a person a moment once, but makes creating connections in bulk expensive, and scripts that don't run the +/// page can't create connections at all. Runs entirely on this server, without any third party or personal data. +/// Each challenge belongs to one hub connection, can be used only once and expires. +/// +public sealed class BotCheck(IOptions options, ITransferService transferService, TimeProvider timeProvider) +{ + private static readonly object ChallengeKey = new(); + private readonly BotCheckOptions _options = options.Value; + + /// The challenge to solve, or null if the check is disabled. + public BotCheckChallenge? Issue(HubCallerContext context) + { + if (!_options.Enabled) + { + return null; + } + + var maxNumber = IsBusy() ? _options.BusyMaxNumber : _options.MaxNumber; + var salt = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16)); + var number = RandomNumberGenerator.GetInt32(maxNumber + 1); + + // Hub invocations of one connection run one after another, so the item needs no further synchronization. + context.Items[ChallengeKey] = new PendingChallenge(number, timeProvider.GetUtcNow() + _options.ChallengeLifetime); + return new BotCheckChallenge(salt, ComputeHash(salt, number), maxNumber); + } + + /// Checks the solution against the connection's latest challenge, which is used up either way. + public bool Verify(HubCallerContext context, int solution) + { + if (!_options.Enabled) + { + return true; + } + + return context.Items.Remove(ChallengeKey, out var value) + && value is PendingChallenge pending + && solution == pending.Number + && timeProvider.GetUtcNow() <= pending.ExpiresAt; + } + + /// The hash the browser has to reproduce, see . + public static string ComputeHash(string salt, int number) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(salt + number.ToString(CultureInfo.InvariantCulture)))); + + private bool IsBusy() + { + var status = transferService.GetStatus(); + return status.QueuedClients > 0 || status.ActiveConnections >= status.MaxConnections * _options.BusyThreshold; + } + + private sealed record PendingChallenge(int Number, DateTimeOffset ExpiresAt); +} diff --git a/DataTransfer/Security/BotCheckOptions.cs b/DataTransfer/Security/BotCheckOptions.cs new file mode 100644 index 0000000..dbfb2b1 --- /dev/null +++ b/DataTransfer/Security/BotCheckOptions.cs @@ -0,0 +1,30 @@ +namespace DataTransfer.Security; + +public sealed class BotCheckOptions +{ + public const string SectionName = "BotCheck"; + + public bool Enabled { get; set; } = true; + + /// + /// Upper bound of the hidden number; a browser tries about half of the numbers on average. A browser computes + /// roughly 100,000 hashes per second and core, so the default takes a fraction of a second on a desktop and up to + /// about three seconds on a weak phone. + /// + public int MaxNumber { get; set; } = 200_000; + + /// Upper bound used while the server is busy (see ), to slow down bulk creation. + public int BusyMaxNumber { get; set; } = 1_000_000; + + /// Share of the connection limit in use from which applies; also while clients are queued. + public double BusyThreshold { get; set; } = 0.8; + + /// A challenge must be solved within this period. + public TimeSpan ChallengeLifetime { get; set; } = TimeSpan.FromMinutes(2); + + public bool IsValid() => + MaxNumber > 0 + && BusyMaxNumber >= MaxNumber + && BusyThreshold is > 0 and <= 1 + && ChallengeLifetime > TimeSpan.Zero; +} diff --git a/DataTransfer/Security/ClientAddress.cs b/DataTransfer/Security/ClientAddress.cs new file mode 100644 index 0000000..dcdf17b --- /dev/null +++ b/DataTransfer/Security/ClientAddress.cs @@ -0,0 +1,34 @@ +using System.Net; +using System.Net.Sockets; + +namespace DataTransfer.Security; + +public static class ClientAddress +{ + /// + /// Groups remote addresses that one client typically controls: an IPv4 address or an IPv6 /56 prefix. Subscribers + /// usually get a /56 or more, so limiting single IPv6 addresses or /64 networks would be pointless. + /// + public static string GetPartitionKey(IPAddress? address) + { + if (address is null) + { + return "unknown"; + } + + if (address.IsIPv4MappedToIPv6) + { + address = address.MapToIPv4(); + } + + if (address.AddressFamily != AddressFamily.InterNetworkV6) + { + return address.ToString(); + } + + Span bytes = stackalloc byte[16]; + address.TryWriteBytes(bytes, out _); + bytes[7..].Clear(); + return $"{new IPAddress(bytes)}/56"; + } +} diff --git a/DataTransfer/Security/ClientAddressLimiter.cs b/DataTransfer/Security/ClientAddressLimiter.cs new file mode 100644 index 0000000..9ccd50f --- /dev/null +++ b/DataTransfer/Security/ClientAddressLimiter.cs @@ -0,0 +1,89 @@ +using System.Threading.RateLimiting; +using DataTransfer.Services; +using Microsoft.Extensions.Options; + +namespace DataTransfer.Security; + +/// +/// Limits the hub connections per network address (see ): how many may exist at once and +/// how many may be opened per minute. Enforced for every hub connection, so it also covers clients that skip the +/// negotiate request and connect via WebSocket directly. +/// +public sealed class ClientAddressLimiter : IDisposable +{ + private readonly Lock _sync = new(); + private readonly Dictionary _clientsByAddress = new(StringComparer.Ordinal); + private readonly int _maxClientsPerAddress; + private readonly PartitionedRateLimiter _connectAttempts; + + public ClientAddressLimiter(IOptions options) + { + var transfer = options.Value; + _maxClientsPerAddress = transfer.MaxClientsPerAddress; + _connectAttempts = PartitionedRateLimiter.Create(address => + RateLimitPartition.GetFixedWindowLimiter( + address, + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = transfer.MaxConnectAttemptsPerMinute, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0 + })); + } + + /// Whether a new hub connection from would exceed the concurrent limit. + public bool IsAtCapacity(string address) + { + lock (_sync) + { + return _clientsByAddress.GetValueOrDefault(address) >= _maxClientsPerAddress; + } + } + + /// Counts a new hub connection; must be called when it closes. + /// false if the address exceeded its connect rate or concurrent limit. + public bool TryAcquire(string address) + { + using (var lease = _connectAttempts.AttemptAcquire(address)) + { + if (!lease.IsAcquired) + { + return false; + } + } + + lock (_sync) + { + var clients = _clientsByAddress.GetValueOrDefault(address); + if (clients >= _maxClientsPerAddress) + { + return false; + } + + _clientsByAddress[address] = clients + 1; + return true; + } + } + + public void Release(string address) + { + lock (_sync) + { + if (!_clientsByAddress.TryGetValue(address, out var clients)) + { + return; + } + + if (clients <= 1) + { + _clientsByAddress.Remove(address); + } + else + { + _clientsByAddress[address] = clients - 1; + } + } + } + + public void Dispose() => _connectAttempts.Dispose(); +} diff --git a/DataTransfer/Security/ForwardedHeadersSetup.cs b/DataTransfer/Security/ForwardedHeadersSetup.cs new file mode 100644 index 0000000..3adc996 --- /dev/null +++ b/DataTransfer/Security/ForwardedHeadersSetup.cs @@ -0,0 +1,108 @@ +using System.Net; +using Microsoft.AspNetCore.HttpOverrides; + +namespace DataTransfer.Security; + +/// +/// Takes the client address from X-Forwarded-For only when the request comes from a configured reverse +/// proxy, so clients can't spoof their address to evade the per-address limits. +/// +public static partial class ForwardedHeadersSetup +{ + private const string SectionName = "ForwardedHeaders"; + + // Set via ASPNETCORE_FORWARDEDHEADERS_ENABLED (e.g. by container images and some PaaS); the host then adds the + // middleware itself, before the app's pipeline. + private const string HostEnabledKey = "FORWARDEDHEADERS_ENABLED"; + + private static readonly TimeSpan WarningInterval = TimeSpan.FromMinutes(10); + private static long _nextWarningAt; + + public static WebApplicationBuilder AddConfiguredForwardedHeaders(this WebApplicationBuilder builder) + { + var section = builder.Configuration.GetSection(SectionName); + builder.Services.Configure(options => + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + + // Number of proxies in front of the app; each one appends an entry. + options.ForwardLimit = section.GetValue("ForwardLimit", 1); + foreach (var proxy in GetList(section, "KnownProxies")) + { + options.KnownProxies.Add(IPAddress.Parse(proxy)); + } + + foreach (var network in GetList(section, "KnownNetworks")) + { + options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse(network)); + } + }); + + return builder; + } + + public static WebApplication UseConfiguredForwardedHeaders(this WebApplication app) + { + var section = app.Configuration.GetSection(SectionName); + var hasConfiguredProxies = GetList(section, "KnownProxies").Length > 0 || GetList(section, "KnownNetworks").Length > 0; + var logger = app.Services.GetRequiredService().CreateLogger(typeof(ForwardedHeadersSetup).FullName!); + + if (app.Configuration.GetValue(HostEnabledKey)) + { + // The host already ran the middleware; a second pass would accept an entry supplied by the client. + if (!hasConfiguredProxies) + { + LogTrustingAllProxies(logger); + } + } + else + { + app.UseForwardedHeaders(); + } + + if (!hasConfiguredProxies) + { + app.Use(async (context, next) => + { + WarnIfFromUntrustedProxy(context, logger); + await next(context); + }); + } + + return app; + } + + private static string[] GetList(IConfiguration section, string key) => section.GetSection(key).Get() ?? []; + + // The middleware records X-Original-For whenever it applied X-Forwarded-For; a remaining header without it came + // from an untrusted sender: either a spoofing client or a reverse proxy that still needs to be configured. + private static void WarnIfFromUntrustedProxy(HttpContext context, ILogger logger) + { + var headers = context.Request.Headers; + if (!headers.ContainsKey("X-Forwarded-For") || headers.ContainsKey("X-Original-For")) + { + return; + } + + var now = Environment.TickCount64; + var nextWarningAt = Interlocked.Read(ref _nextWarningAt); + if (now >= nextWarningAt + && Interlocked.CompareExchange(ref _nextWarningAt, now + (long)WarningInterval.TotalMilliseconds, nextWarningAt) == nextWarningAt) + { + LogUntrustedForwardedHeader(logger, context.Connection.RemoteIpAddress); + } + } + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "ASPNETCORE_FORWARDEDHEADERS_ENABLED is set without ForwardedHeaders:KnownProxies or KnownNetworks, so " + + "X-Forwarded-For is accepted from any sender. Make sure the app is only reachable through the reverse proxy.")] + private static partial void LogTrustingAllProxies(ILogger logger); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Ignored X-Forwarded-For from {RemoteAddress}, which is not a trusted proxy. If the app runs behind a " + + "reverse proxy, add it to ForwardedHeaders:KnownProxies or KnownNetworks; otherwise all clients share the " + + "proxy's address limits.")] + private static partial void LogUntrustedForwardedHeader(ILogger logger, IPAddress? remoteAddress); +} diff --git a/DataTransfer/Security/HubConnectionGuard.cs b/DataTransfer/Security/HubConnectionGuard.cs new file mode 100644 index 0000000..25ee51b --- /dev/null +++ b/DataTransfer/Security/HubConnectionGuard.cs @@ -0,0 +1,57 @@ +using DataTransfer.Client.Shared; + +namespace DataTransfer.Security; + +/// Checks requests that open hub connections before SignalR handles them. +public static class HubConnectionGuard +{ + /// + /// Browsers always send Origin for WebSockets, so a foreign origin means another website is using its + /// visitors' browsers (and network addresses) to connect. Requests without the header aren't from a cross-site + /// browser context and are allowed. + /// + public static bool IsSameOrigin(HttpRequest request) + { + var origin = request.Headers.Origin.ToString(); + if (origin.Length == 0) + { + return true; + } + + return Uri.TryCreate(origin, UriKind.Absolute, out var originUri) + && string.Equals(originUri.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Rejects negotiate requests from foreign origins and from addresses that are at their concurrent limit, so + /// browsers get a clear HTTP error instead of a connection that is closed right away. + /// + public static IApplicationBuilder UseHubNegotiateGuard(this IApplicationBuilder app) + { + var negotiatePath = new PathString($"{TransferProtocol.HubPath}/negotiate"); + return app.Use(async (context, next) => + { + if (context.Request.Path.StartsWithSegments(negotiatePath)) + { + if (!IsSameOrigin(context.Request)) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + context.Response.ContentType = "text/plain"; + await context.Response.WriteAsync("Cross-origin connections are not allowed."); + return; + } + + var limiter = context.RequestServices.GetRequiredService(); + if (limiter.IsAtCapacity(ClientAddress.GetPartitionKey(context.Connection.RemoteIpAddress))) + { + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + context.Response.ContentType = "text/plain"; + await context.Response.WriteAsync("Too many connections from your network. Please try again later."); + return; + } + } + + await next(context); + }); + } +} diff --git a/DataTransfer/Security/SecurityHeaders.cs b/DataTransfer/Security/SecurityHeaders.cs new file mode 100644 index 0000000..ab83f94 --- /dev/null +++ b/DataTransfer/Security/SecurityHeaders.cs @@ -0,0 +1,78 @@ +using System.Security.Cryptography; + +namespace DataTransfer.Security; + +/// +/// Adds a strict Content Security Policy and related headers. The served code is the trust anchor of the +/// end-to-end encryption, so injected script must never run. +/// +public static class SecurityHeaders +{ + private static readonly object NonceKey = new(); + + public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app, bool isDevelopment) + { + return app.Use(async (context, next) => + { + // Error and status code pages re-execute the pipeline for the same request; keep its nonce. + if (GetNonce(context) is null) + { + var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); + context.Items[NonceKey] = nonce; + + // OnStarting also covers error responses, whose headers are cleared by the exception handler. + context.Response.OnStarting(() => + { + Apply(context, nonce, isDevelopment); + return Task.CompletedTask; + }); + } + + await next(context); + }); + } + + /// The nonce that allows the inline import map of the current request. + public static string? GetNonce(HttpContext? context) => + context?.Items.TryGetValue(NonceKey, out var nonce) == true ? nonce as string : null; + + private static void Apply(HttpContext context, string nonce, bool isDevelopment) + { + var headers = context.Response.Headers; + + // The WebAssembly debugger page relies on inline scripts. + if (!(isDevelopment && context.Request.Path.StartsWithSegments("/_framework/debug"))) + { + headers.ContentSecurityPolicy = BuildContentSecurityPolicy(nonce, isDevelopment); + } + + headers.XContentTypeOptions = "nosniff"; + headers.XFrameOptions = "DENY"; + headers["Referrer-Policy"] = "no-referrer"; + headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=(), usb=()"; + headers["Cross-Origin-Opener-Policy"] = "same-origin"; + headers["Cross-Origin-Resource-Policy"] = "same-origin"; + } + + private static string BuildContentSecurityPolicy(string nonce, bool isDevelopment) + { + // Development tooling (browser refresh) talks to localhost on other ports. + var connectSources = isDevelopment ? "'self' ws://localhost:* wss://localhost:*" : "'self'"; + + return string.Join( + "; ", + "default-src 'self'", + // WebAssembly compilation needs 'wasm-unsafe-eval'; the nonce allows only the generated import map. + $"script-src 'self' 'wasm-unsafe-eval' 'nonce-{nonce}'", + "style-src 'self'", + // Progress bars set their width via style attributes;