Initial version

This commit is contained in:
Sithis 2026-10-15 05:46:35 +02:00
commit e5b6226a85
68 changed files with 7338 additions and 0 deletions

16
.dockerignore Normal file
View file

@ -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

21
.gitignore vendored Normal file
View file

@ -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

View file

@ -0,0 +1,93 @@
@* Small stroke icon set (24×24 grid) drawn with currentColor, so icons follow the surrounding text color. *@
<svg class="icon @Class" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
@switch (Name)
{
case "transfer":
<path d="M4 9h15l-4-4"/>
<path d="M20 15H5l4 4"/>
break;
case "lock":
<rect x="5" y="11" width="14" height="10" rx="2"/>
<path d="M8 11V7a4 4 0 0 1 8 0v4"/>
break;
case "shield":
<path d="M12 3l7 3v6c0 4.5-3 7.7-7 9-4-1.3-7-4.5-7-9V6z"/>
<path d="M9 12l2 2 4-4"/>
break;
case "plus":
<path d="M12 5v14M5 12h14"/>
break;
case "join":
<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4"/>
<path d="M10 16l4-4-4-4M14 12H4"/>
break;
case "arrow-up":
<path d="M12 19V5M6 11l6-6 6 6"/>
break;
case "arrow-down":
<path d="M12 5v14M6 13l6 6 6-6"/>
break;
case "arrow-right":
<path d="M5 12h14M13 6l6 6-6 6"/>
break;
case "file":
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/>
<path d="M14 3v5h5"/>
break;
case "upload":
<path d="M12 15V4M7 9l5-5 5 5"/>
<path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/>
break;
case "download":
<path d="M12 4v11M7 10l5 5 5-5"/>
<path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/>
break;
case "copy":
<rect x="9" y="9" width="11" height="11" rx="2"/>
<path d="M15 5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2"/>
break;
case "check":
<path d="M5 12.5l4.5 4.5L19 7.5"/>
break;
case "close":
<path d="M6 6l12 12M18 6L6 18"/>
break;
case "info":
<circle cx="12" cy="12" r="9"/>
<path d="M12 11v5M12 8h.01"/>
break;
case "alert":
<path d="M12 3.5L2.5 20h19z"/>
<path d="M12 10v4M12 17h.01"/>
break;
case "power":
<path d="M12 3v9"/>
<path d="M6.3 6.3a8 8 0 1 0 11.4 0"/>
break;
case "inbox":
<path d="M3 13l3-8h12l3 8v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<path d="M3 13h5l1.5 2.5h5L16 13h5"/>
break;
case "clock":
<circle cx="12" cy="12" r="9"/>
<path d="M12 7v5l3 2"/>
break;
case "file-text":
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/>
<path d="M14 3v5h5M9 13h6M9 17h6"/>
break;
case "flag":
<path d="M5 21V4"/>
<path d="M5 4h12l-2.5 4.5L17 13H5"/>
break;
case "code":
<path d="M8 7l-5 5 5 5M16 7l5 5-5 5M14 4l-4 16"/>
break;
}
</svg>
@code {
[Parameter][EditorRequired] public string Name { get; set; } = string.Empty;
[Parameter] public string? Class { get; set; }
}

View file

@ -0,0 +1,38 @@
@implements IDisposable
@inject ServerStatusMonitor Monitor
@if (Monitor.Current is { } status)
{
<span class="server-load @LoadClass(status)" title="@Describe(status)" data-testid="server-load">
<span class="server-load-dot" aria-hidden="true"></span>
<span class="server-load-count">@status.ActiveConnections/@status.MaxConnections</span>
<span class="server-load-label">connections</span>
@if (status.QueuedClients > 0)
{
<span class="server-load-queue">· @status.QueuedClients queued</span>
}
</span>
}
@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);
}

View file

@ -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;
}
}

View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.13"/>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.13"/>
<PackageReference Include="SpawnDev.BlazorJS.Cryptography" Version="3.2.0"/>
</ItemGroup>
</Project>

View file

@ -0,0 +1,446 @@
@page "/"
@rendermode @(new InteractiveWebAssemblyRenderMode(false))
@if (!_cryptoSupported)
{
<header class="page-head">
<h1>Start a transfer</h1>
</header>
<div class="notice notice-danger" role="alert">
<Icon Name="alert"/>
<div class="notice-text">
<strong>End-to-end encryption is not available.</strong>
This browser cannot encrypt end-to-end. Please use a current browser and open this site via HTTPS.
</div>
</div>
}
else
{
@if (_error is not null)
{
<div class="notice notice-danger" role="alert" data-testid="error">
<Icon Name="alert"/>
<span class="notice-text">@_error</span>
<button type="button" class="btn btn-ghost btn-sm btn-icon" title="Dismiss" aria-label="Dismiss"
@onclick="() => _error = null">
<Icon Name="close"/>
</button>
</div>
}
@switch (Session.State)
{
case SessionState.Idle:
case SessionState.Joining:
<header class="page-head">
<h1>Start a transfer</h1>
<p>Connect two browsers with a one-time code. Messages and files are encrypted on this device before
they are sent.</p>
</header>
<div class="panel connect">
<section class="connect-option">
<h2 class="connect-title"><span class="connect-icon"><Icon Name="plus"/></span>Create a connection
</h2>
<p class="connect-text">
Generates a one-time code. Share it with the person you want to exchange messages and files
with.
</p>
@if (Load is { } load && IsServerFull)
{
<p class="capacity-note" data-testid="capacity-note">
<Icon Name="clock"/>
<span>
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.
</span>
</p>
}
<div>
<button type="button" class="btn btn-primary" disabled="@IsBusy" @onclick="CreateAsync"
data-testid="create">
@if (_pending == PendingAction.Create)
{
<span class="spinner" aria-hidden="true"></span>
<span>@(Session.IsVerifyingBrowser ? "Verifying browser…" : "Creating…")</span>
}
else
{
<span>Create connection</span>
}
</button>
</div>
</section>
<div class="connect-divider" aria-hidden="true"><span>or</span></div>
<section class="connect-option">
<h2 class="connect-title"><span class="connect-icon"><Icon Name="join"/></span>Join a connection
</h2>
<p class="connect-text">Enter the code you received.</p>
<div class="join-row">
<input class="input code-input" placeholder="ABCDE-FGHJK" maxlength="20"
autocomplete="off" spellcheck="false" aria-label="Connection code"
data-testid="join-code"
@bind="_joinCode" @bind:event="oninput" @onkeydown="OnJoinKeyDownAsync"/>
<button type="button" class="btn btn-primary" disabled="@IsBusy" @onclick="JoinAsync"
data-testid="join">
@if (_pending == PendingAction.Join)
{
<span class="spinner" aria-hidden="true"></span>
<span>Joining…</span>
}
else
{
<span>Join</span>
}
</button>
</div>
</section>
</div>
<ol class="steps">
<li>
<span class="step-no">01</span>
<div><strong>Create</strong> One side creates a connection and shares the code.</div>
</li>
<li>
<span class="step-no">02</span>
<div><strong>Join</strong> The other side enters the code to connect.</div>
</li>
<li>
<span class="step-no">03</span>
<div><strong>Verify &amp; transfer</strong> Compare the safety code, then send messages and files.
</div>
</li>
</ol>
break;
case SessionState.Queued:
<header class="page-head">
<h1>Waiting for a free slot</h1>
<p>
The server has reached its connection limit. Your connection is created automatically as soon as
another
one closes – keep this page open.
</p>
</header>
<section class="panel waiting" aria-labelledby="queue-title">
<div class="panel-header">
<h2 class="panel-title" id="queue-title">Queue</h2>
</div>
<div class="panel-body waiting-body">
<div class="queue-position" aria-live="polite">
<span class="queue-number" data-testid="queue-position">@Session.QueuePosition</span>
<div class="queue-text">
<strong>@QueueAheadText</strong>
<span class="muted">Position in the queue</span>
</div>
</div>
<div class="waiting-status">
<span class="spinner" aria-hidden="true"></span>
<span>Waiting for a connection to close…</span>
@if (Load is { } queueLoad)
{
<span class="waiting-expiry">
<span class="mono">@queueLoad.ActiveConnections/@queueLoad.MaxConnections</span> connections in use
</span>
}
</div>
</div>
<div class="panel-footer">
<button type="button" class="btn btn-danger" @onclick="ResetAsync" data-testid="leave-queue">Leave
queue
</button>
</div>
</section>
break;
case SessionState.Waiting:
<header class="page-head">
<h1>Share the code</h1>
<p>Give this code to the person you want to connect with, for example by phone or messenger. It can only
be used once.</p>
</header>
<section class="panel waiting" aria-labelledby="code-title">
<div class="panel-header">
<h2 class="panel-title" id="code-title">Connection code</h2>
</div>
<div class="panel-body waiting-body">
<div class="code-display">
<div class="code-chars" data-testid="connection-code">
@foreach (var c in DisplayCode ?? string.Empty)
{
<span class="@(c == '-' ? "code-sep" : "code-char")">@c</span>
}
</div>
<button type="button" class="btn" @onclick="CopyCodeAsync">
<Icon Name="@(_copied ? "check" : "copy")"/>
<span>@(_copied ? "Copied" : "Copy code")</span>
</button>
</div>
<div class="waiting-status">
<span class="spinner" aria-hidden="true"></span>
<span data-testid="waiting-status">@(Session.IsPeerJoining ? "The other person joined. Securing the connection…" : "Waiting for the other person to join…")</span>
<span class="waiting-expiry">Expires in <span class="mono">@RemainingTime</span></span>
</div>
<div class="progress countdown @(RemainingPercent <= 20 ? "is-low" : null)" aria-hidden="true">
<div class="progress-bar" style="width: @(RemainingPercent)%"></div>
</div>
</div>
<div class="panel-footer">
<button type="button" class="btn btn-danger" @onclick="ResetAsync">Cancel</button>
</div>
</section>
break;
default:
var connected = Session.State == SessionState.Connected;
@if (connected)
{
<section class="panel session">
<div class="session-main">
<h1 class="session-state"><span class="status status-online"
data-testid="status">Connected</span></h1>
<dl class="session-facts">
<div>
<dt>Code</dt>
<dd class="mono">@DisplayCode</dd>
</div>
<div>
<dt>Safety code</dt>
<dd class="mono safety-code" data-testid="safety-code">@Session.SafetyCode</dd>
</div>
<div
title="The connection closes after @DisplayFormat.Duration(Session.InactivityTimeout) without transfers.">
<dt>Idle timeout · @DisplayFormat.Duration(Session.InactivityTimeout)</dt>
<dd><span class="mono" data-testid="remaining">@RemainingTime</span> left</dd>
</div>
</dl>
<button type="button" class="btn btn-danger btn-sm" @onclick="DisconnectAsync"
data-testid="disconnect">
<Icon Name="power"/>
<span>Disconnect</span>
</button>
</div>
<p class="session-hint">
<Icon Name="shield"/>
<span>
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.
</span>
</p>
</section>
}
else
{
<section class="panel session" role="status">
<div class="session-main">
<h1 class="session-state"><span class="status status-offline">Disconnected</span></h1>
<div class="session-closed">
<span data-testid="closed-message">@Session.ClosedMessage</span>
@if (Session.HasPeer)
{
<span
class="muted">Received files stay available until you start a new connection.</span>
}
</div>
<button type="button" class="btn btn-primary btn-sm" @onclick="ResetAsync"
data-testid="new-connection">
New connection
</button>
</div>
</section>
}
@if (Session.HasPeer)
{
<div class="workspace @(connected ? null : "is-closed")">
@if (connected)
{
<section class="panel send" aria-labelledby="send-title">
<div class="panel-header">
<h2 class="panel-title" id="send-title">Send</h2>
</div>
<div class="panel-body send-body">
<label
class="dropzone @(_dragging ? "is-dragging" : null) @(Session.IsSendingFiles ? "is-busy" : null)"
@ondragenter="() => _dragging = true" @ondragleave="() => _dragging = false"
@ondrop="() => _dragging = false"
@ondragover:preventDefault="@Session.IsSendingFiles"
@ondrop:preventDefault="@Session.IsSendingFiles">
@if (Session.IsSendingFiles)
{
<span class="spinner dropzone-spinner" aria-hidden="true"></span>
<strong>Sending files…</strong>
<span>You can add more files when these are sent.</span>
}
else
{
<Icon Name="upload" Class="dropzone-icon"/>
<strong>Drop files here or <span class="dropzone-browse">browse</span></strong>
<span>Up to @MaxFilesPerSelection files at once, max. @(TransferProtocol.MaxFileBytes / (1024 * 1024)) MB each</span>
}
<InputFile class="dropzone-input" multiple OnChange="OnFilesSelectedAsync"
disabled="@Session.IsSendingFiles"
title="Choose files to send" data-testid="file-input"/>
</label>
<div class="send-message">
<label class="field-label" for="message">Message</label>
<textarea id="message" class="input" rows="4"
maxlength="@TransferProtocol.MaxMessageLength"
placeholder="Text, links, notes…" data-testid="message-input"
@bind="_message" @bind:event="oninput"
@onkeydown="OnMessageKeyDownAsync"></textarea>
<div class="send-actions">
<span class="hint"><kbd>Ctrl</kbd> + <kbd>Enter</kbd> to send</span>
<button type="button" class="btn btn-primary"
disabled="@(_sendingMessage || string.IsNullOrWhiteSpace(_message))"
@onclick="SendMessageAsync" data-testid="send">
<span>Send</span>
<Icon Name="arrow-right"/>
</button>
</div>
</div>
</div>
</section>
}
<section class="panel log" aria-labelledby="log-title">
<div class="panel-header">
<h2 class="panel-title" id="log-title">Transfers</h2>
<span class="log-summary">
<span><Icon Name="arrow-up"/>@SentCount sent</span>
<span><Icon Name="arrow-down"/>@ReceivedCount received</span>
</span>
</div>
@* column-reverse keeps the log scrolled to the newest entry without any JavaScript. *@
<div class="log-scroll" aria-live="polite" data-testid="chat-log">
@if (Session.Entries.Count == 0)
{
<div class="log-empty">
<Icon Name="inbox"/>
<strong>Nothing transferred yet</strong>
<span>Messages and files are encrypted in your browser before they leave it.</span>
</div>
}
else
{
<ol class="log-entries">
@foreach (var entry in Session.Entries)
{
@if (entry.Kind == ChatEntryKind.System)
{
<li class="entry entry-system @(entry.Error is null ? null : "is-error")">
<span class="entry-dir"><Icon
Name="@(entry.Error is null ? "info" : "alert")"/></span>
<div class="entry-body">@entry.Text</div>
<div class="entry-meta">
<time class="entry-time">@entry.Time.ToString("t")</time>
</div>
</li>
}
else
{
var outgoing = entry.Kind == ChatEntryKind.Outgoing;
<li class="entry @(outgoing ? "entry-out" : "entry-in")">
<span class="entry-dir" title="@(outgoing ? "Sent" : "Received")">
<Icon Name="@(outgoing ? "arrow-up" : "arrow-down")"/>
<span
class="visually-hidden">@(outgoing ? "Sent" : "Received")</span>
</span>
<div class="entry-body">
@if (entry.IsFile)
{
<div class="entry-file" title="@entry.FileName">
<Icon Name="file"/>
<span class="entry-name">@entry.FileName</span>
</div>
@if (entry.IsRiskyFile)
{
<div class="entry-warning" role="note" data-testid="risky-file-warning">
<Icon Name="alert"/>
<span>
This file type can run programs or scripts on your device. Only open it if
you know the sender and expected this file.
</span>
</div>
}
@if (entry.Error is not null)
{
<div class="entry-error">@entry.Error</div>
}
else if (entry.IsTransferring)
{
<div class="progress entry-progress" role="progressbar"
aria-label="@(outgoing ? "Sending" : "Receiving") @entry.FileName"
aria-valuenow="@entry.ProgressPercent"
aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar"
style="width: @(entry.ProgressPercent)%"></div>
</div>
}
}
else
{
<div class="entry-text">@entry.Text</div>
}
</div>
<div class="entry-meta">
<span
class="entry-size">@(entry.IsFile ? DisplayFormat.Bytes(entry.FileSize) : null)</span>
<time class="entry-time">@entry.Time.ToString("t")</time>
</div>
<div class="entry-action">
@if (!entry.IsFile)
{
<button type="button" class="btn btn-ghost btn-sm btn-icon"
title="Copy text" aria-label="Copy text"
@onclick="() => CopyEntryAsync(entry)">
<Icon
Name="@(ReferenceEquals(_copiedEntry, entry) ? "check" : "copy")"/>
</button>
}
else if (entry.Error is not null)
{
<span class="tag tag-danger">Failed</span>
}
else if (entry.IsTransferring)
{
<span
class="tag tag-progress mono">@entry.ProgressPercent%</span>
}
else if (entry.DownloadUrl is not null)
{
<a class="btn btn-sm" href="@entry.DownloadUrl"
download="@entry.FileName" title="Save @entry.FileName">
<Icon Name="download"/>
<span>Save</span>
<span class="visually-hidden">@entry.FileName</span>
</a>
}
else
{
<span class="tag tag-success"><Icon
Name="check"/>@(outgoing ? "Sent" : "Received")</span>
}
</div>
</li>
}
}
</ol>
}
</div>
</section>
</div>
}
break;
}
}

View file

@ -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;
/// <summary>A new connection would be queued; a non-empty queue also means no slot is available to newcomers.</summary>
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;
/// <summary>Reloading or leaving the page now would cancel a queued, pending or established connection.</summary>
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<Link> 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<bool>("isSecureContext") && !JS.IsUndefined("crypto.subtle");
_session = new TransferSession(Navigation, Crypto, BotCheckSolver, LoggerFactory.CreateLogger<TransferSession>());
_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 <PageTitle> 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();
}
/// <summary>
/// While enabled, the browser asks before a reload, closing the tab or clicking the title (which reloads the
/// page) cancels the connection.
/// </summary>
private void SetLeaveGuard(bool enabled)
{
if (enabled == _isLeaveGuardActive)
{
return;
}
_window ??= JS.Get<Window>("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<Task<string?>> 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<IBrowserFile> 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<bool> TryCopyAsync(string text)
{
try
{
using var navigator = JS.Get<Navigator>("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();
}
}
/// <summary>
/// Renders right away if the last render is long enough ago, otherwise once <see cref="MinRenderInterval" /> has
/// passed; changes in between are coalesced into that render.
/// </summary>
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();
});
}
}

View file

@ -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;
}
}

View file

@ -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<PortableCrypto, BrowserWASMCrypto>();
builder.Services.AddSingleton<BotCheckSolver>();
builder.Services.AddSingleton(services => new ServerStatusMonitor(
new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) },
services.GetRequiredService<ILogger<ServerStatusMonitor>>()));
await builder.Build().BlazorJSRunAsync();

View file

@ -0,0 +1,21 @@
using DataTransfer.Client.Shared;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace DataTransfer.Client.Services;
/// <summary>Solves the server's proof-of-work bot check in web workers (<c>wwwroot/js/bot-check.js</c> of the server).</summary>
public sealed class BotCheckSolver(IJSRuntime js, NavigationManager navigation)
{
/// <returns>The solution, or <c>-1</c> if none was found.</returns>
public async Task<int> 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<IJSObjectReference>(
"import",
navigation.ToAbsoluteUri("js/bot-check.js").ToString());
return await module.InvokeAsync<int>("solve", challenge.Salt, challenge.Hash, challenge.MaxNumber);
}
}

View file

@ -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; }
/// <summary>Object URL of a completely received and decrypted file.</summary>
public string? DownloadUrl { get; set; }
/// <summary>A received file whose type can run code or is often used for malware or phishing.</summary>
public bool IsRiskyFile { get; init; }
public string? Error { get; set; }
}

View file

@ -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);
}
}

View file

@ -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 = "<>:\"/\\|?*";
/// <summary>
/// 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 <c>invoice&#x202E;fdp.exe</c> as <c>invoiceexe.pdf</c>.
/// </summary>
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());
}
}
}

View file

@ -0,0 +1,101 @@
using System.Text;
namespace DataTransfer.Client.Services;
/// <summary>Application frames. They are encrypted as a whole, so the relay server cannot see their type or content.</summary>
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<byte> Data) : Frame;
public sealed record FileCancelFrame(int FileId) : Frame;
/// <summary>Plaintext layout: version (byte) | type (byte) | sequence number (int64) | type-specific body.</summary>
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();
}
/// <exception cref="InvalidDataException">The plaintext is not a valid frame.</exception>
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);
}
}

View file

@ -0,0 +1,53 @@
namespace DataTransfer.Client.Services;
/// <summary>
/// Plaintext messages of the commit-then-reveal key exchange (see <see cref="SecureChannel" />). They are relayed
/// like frames, but before the channel is encrypted. Layout: version (byte) | type (byte) | payload.
/// </summary>
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);
/// <exception cref="InvalidDataException">The message is not a commitment.</exception>
public static byte[] DecodeCommitment(byte[] message) =>
Decode(message, MessageType.Commitment, CommitmentBytes, CommitmentBytes);
/// <exception cref="InvalidDataException">The message is not a public key.</exception>
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..];
}
}

View file

@ -0,0 +1,40 @@
using System.Collections.Frozen;
namespace DataTransfer.Client.Services;
/// <summary>
/// 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.
/// </summary>
public static class RiskyFileTypes
{
private static readonly FrozenSet<string> 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);
/// <summary>Only the last extension counts, so <c>invoice.pdf.exe</c> is risky and <c>setup.exe.txt</c> is not.</summary>
public static bool IsRisky(string? fileName) =>
Path.GetExtension(fileName) is { Length: > 1 } extension && Extensions.Contains(extension[1..]);
}

View file

@ -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;
/// <summary>
/// End-to-end encryption of one connection. Runs in the browser (WebAssembly + WebCrypto); keys never leave it.
/// <list type="bullet">
/// <item>
/// Key agreement: ephemeral ECDH P-256; the private key is non-extractable and discarded after the
/// agreement.
/// </item>
/// <item>
/// 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.
/// </item>
/// <item>
/// 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.
/// </item>
/// <item>
/// Every frame carries a sequence number inside the ciphertext: replayed, reordered or dropped frames are
/// rejected.
/// </item>
/// <item>
/// The safety code is a hash of both public keys; if both users see the same code, nobody intercepted the
/// agreement.
/// </item>
/// </list>
/// </summary>
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;
}
/// <summary>Ephemeral ECDH public key (SPKI) to send to the peer.</summary>
public byte[] PublicKey { get; }
/// <summary>Hash commitment to <see cref="PublicKey" />; the joiner sends it before revealing its key.</summary>
public byte[] Commitment { get; }
public bool IsEstablished => _sendKey is not null;
public static async Task<SecureChannel> 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;
}
}
/// <summary>
/// Completes the key agreement as creator and returns the safety code both users can compare.
/// </summary>
/// <param name="joinerCommitment">The commitment the joiner sent before the creator revealed its key.</param>
/// <exception cref="CryptographicException">The joiner's key does not match its commitment.</exception>
public async Task<string> 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);
}
/// <summary>
/// Completes the key agreement as joiner and returns the safety code. Must only be called after
/// <see cref="Commitment" /> was sent, and <see cref="PublicKey" /> must only be revealed afterwards.
/// </summary>
public Task<string> EstablishAsJoinerAsync(byte[] creatorPublicKey, string code) =>
EstablishAsync(creatorPublicKey, false, code);
/// <summary>Encrypts the next outgoing frame. Callers must send sealed frames in the order they were sealed.</summary>
public Task<byte[]> 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));
}
/// <summary>Decrypts and verifies the next incoming frame.</summary>
/// <exception cref="CryptographicException">The frame was tampered with, replayed, reordered or dropped.</exception>
public async Task<Frame> 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<byte[]> 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<string> 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<PortableAESGCMKey> 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);
}
}

View file

@ -0,0 +1,63 @@
using System.Net.Http.Json;
using DataTransfer.Client.Shared;
namespace DataTransfer.Client.Services;
/// <summary>Periodically fetches the load of the relay server; shared by all components of the page.</summary>
public sealed class ServerStatusMonitor(HttpClient http, ILogger<ServerStatusMonitor> logger) : IDisposable
{
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(5);
private readonly CancellationTokenSource _stopping = new();
private Task? _polling;
public event Action? Changed;
/// <summary>The latest status, or <c>null</c> while it is unknown.</summary>
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<ServerStatus>(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) { }
}
}

View file

@ -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,
/// <summary>The server is at its connection limit; the connection is created once a slot becomes free.</summary>
Queued,
Joining,
Waiting,
Connected,
Closed
}
/// <summary>
/// One end of a transfer connection, running in the browser. Pairs via the relay hub, exchanges keys with the
/// commit-then-reveal protocol of <see cref="SecureChannel" />, encrypts everything afterwards and keeps the decrypted
/// chat history locally. Plaintext never leaves the browser.
/// </summary>
public sealed class TransferSession(
NavigationManager navigation,
PortableCrypto crypto,
BotCheckSolver botCheckSolver,
ILogger<TransferSession> 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<ChatEntry> _entries = [];
private readonly Dictionary<int, IncomingFile> _incomingFiles = [];
private readonly List<string> _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;
/// <summary>Steps of the commit-then-reveal key exchange; see <see cref="SecureChannel" />.</summary>
private enum KeyExchangeStep
{
None,
/// <summary>Creator: waits for the joiner's commitment to its public key.</summary>
AwaitingCommitment,
/// <summary>Creator: revealed its public key and waits for the joiner's.</summary>
AwaitingJoinerKey,
/// <summary>Joiner: committed to its public key and waits for the creator's.</summary>
AwaitingCreatorKey,
Completed
}
public event Action? Changed;
public IReadOnlyList<ChatEntry> Entries => _entries;
public TimeSpan InactivityTimeout { get; private set; }
/// <summary>A new code expires unless someone joins within this period.</summary>
public TimeSpan JoinTimeout { get; private set; }
/// <summary>The timeout <see cref="RemainingTime" /> counts down: joining for a fresh code, inactivity afterwards.</summary>
public TimeSpan CountdownDuration =>
State == SessionState.Waiting && !_peerJoined ? JoinTimeout : InactivityTimeout;
/// <summary>Time until the server removes the connection unless someone joins or something is transmitted.</summary>
public TimeSpan? RemainingTime => State is SessionState.Waiting or SessionState.Connected
? _lastActivity + CountdownDuration - DateTimeOffset.UtcNow
: null;
public SessionState State { get; private set; }
/// <summary>The normalized connection code.</summary>
public string? Code { get; private set; }
public string? SafetyCode { get; private set; }
public string? ClosedMessage { get; private set; }
/// <summary>1-based position in the server's queue while <see cref="SessionState.Queued" />.</summary>
public int QueuePosition { get; private set; }
public bool HasPeer { get; private set; }
/// <summary>Someone joined the creator's connection and the key exchange is in progress.</summary>
public bool IsPeerJoining => State == SessionState.Waiting && _peerJoined;
public bool IsSendingFiles { get; private set; }
/// <summary>The browser is solving the server's bot check before the connection is created.</summary>
public bool IsVerifyingBrowser { get; private set; }
/// <returns>An error message for the user, or <c>null</c>.</returns>
public async Task<string?> CreateAsync()
{
var (hub, _) = await StartAsync();
var botCheckSolution = await SolveBotCheckAsync(hub);
if (!IsCurrent(hub))
{
return null;
}
var result = await hub.InvokeAsync<CreateConnectionResult>(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.";
}
}
/// <summary>Joins a connection and completes the key exchange.</summary>
/// <returns>An error message for the user, or <c>null</c>.</returns>
public async Task<string?> 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<JoinConnectionResult>(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;
}
/// <returns>An error message for the user, or <c>null</c>.</returns>
public async Task<string?> 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<IBrowserFile> 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();
}
}
/// <summary>Leaves the connection but keeps the chat history visible.</summary>
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");
}
}
/// <summary>Leaves any connection, forgets keys and history and returns to <see cref="SessionState.Idle" />.</summary>
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);
/// <summary>Proof of work against creating connections in bulk; see <see cref="BotCheckChallenge" />.</summary>
/// <returns>The solution, or <c>-1</c> if the server doesn't require the check.</returns>
private async Task<int> SolveBotCheckAsync(HubConnection hub)
{
var challenge = await hub.InvokeAsync<BotCheckChallenge?>(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<byte[]>(nameof(ITransferHubClient.Receive), frame => OnReceiveAsync(hub, frame));
hub.On<ConnectionClosedReason>(nameof(ITransferHubClient.ConnectionClosed), reason => OnConnectionClosed(hub, reason));
hub.On<CreateConnectionResult>(nameof(ITransferHubClient.ConnectionCreated), result => OnConnectionCreated(hub, result));
hub.On<int>(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();
}
/// <summary>Handles the plaintext key-exchange messages that precede all encrypted frames.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<string?> 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.";
}
}
/// <summary>
/// Seals and sends one frame. Serialized, so frames reach the peer in sequence-number order. Never throws.
/// </summary>
/// <returns>An error message for the user, or <c>null</c>.</returns>
private async Task<string?> 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<string?> SealAndSendAsync(HubConnection hub, SecureChannel channel, Frame frame)
{
SendStatus status;
try
{
var sealedFrame = await channel.SealAsync(frame);
status = await hub.InvokeAsync<SendStatus>(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."
};
}
/// <summary>Sends a plaintext key-exchange message; only public data is sent this way.</summary>
private async Task<bool> SendKeyExchangeMessageAsync(HubConnection hub, byte[] message)
{
try
{
if (await hub.InvokeAsync<SendStatus>(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;
}
}
/// <summary>Closes locally and asks the server to close the connection for the peer, without awaiting a response.</summary>
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);
}

View file

@ -0,0 +1,54 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
namespace DataTransfer.Client.Shared;
/// <summary>Generates and parses the one-time codes used to pair two participants.</summary>
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);
/// <summary>Accepts user input case-insensitively, ignoring whitespace and dashes.</summary>
public static bool TryNormalize(string? input, [NotNullWhen(true)] out string? code)
{
code = null;
if (string.IsNullOrWhiteSpace(input))
{
return false;
}
Span<char> 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;
}

View file

@ -0,0 +1,150 @@
namespace DataTransfer.Client.Shared;
/// <summary>Constants shared by the relay server and the browser client.</summary>
public static class TransferProtocol
{
public const string HubPath = "/hubs/transfer";
/// <summary>HTTP endpoint returning the current <see cref="ServerStatus" />.</summary>
public const string StatusPath = "/api/status";
/// <summary>Plaintext size of one file chunk. Files are encrypted and relayed chunk by chunk.</summary>
public const int FileChunkBytes = 256 * 1024;
/// <summary>Upper bound of a single encrypted frame (largest chunk plus framing and AES-GCM overhead).</summary>
public const int MaxFrameBytes = FileChunkBytes + 1024;
/// <summary>
/// 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.
/// </summary>
public const long MaxHubMessageBytes = (MaxFrameBytes + 2) / 3 * 4 + 4 * 1024;
public const long MaxFileBytes = 100L * 1024 * 1024;
public const int MaxMessageLength = 10_000;
/// <summary>
/// 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.
/// </summary>
public const int MaxItemsPerConnection = 1_000;
/// <summary>Maximum total size of the files one side may send per connection; enforced like <see cref="MaxItemsPerConnection" />.</summary>
public const long MaxFileBytesPerConnection = 1024L * 1024 * 1024;
}
/// <summary>
/// Methods the browser invokes on the relay hub. Each client may invoke at most
/// <c>Transfer:MaxInvocationsPerSecond</c> methods per second; further invocations are delayed.
/// </summary>
public interface ITransferHub
{
/// <summary>
/// Returns a new proof-of-work challenge for <see cref="CreateConnection" />, or <c>null</c> if the server doesn't
/// require one. It replaces any previous challenge of the connection.
/// </summary>
Task<BotCheckChallenge?> GetBotCheckChallenge();
/// <param name="botCheckSolution">The number that solves the latest challenge; any value if none is required.</param>
Task<CreateConnectionResult> CreateConnection(int botCheckSolution);
Task<JoinConnectionResult> JoinConnection(string code);
/// <summary>
/// Relays an opaque frame to the peer: first the plaintext key-exchange messages, then end-to-end encrypted
/// frames.
/// </summary>
Task<SendStatus> Send(byte[] frame);
Task LeaveConnection();
}
/// <summary>Methods the relay hub invokes on the browser.</summary>
public interface ITransferHubClient
{
/// <summary>Someone joined the creator's connection; the key exchange follows via <see cref="Receive" />.</summary>
Task PeerJoined();
Task Receive(byte[] frame);
Task ConnectionClosed(ConnectionClosedReason reason);
/// <summary>A queued create request got a free slot; the connection now exists.</summary>
Task ConnectionCreated(CreateConnectionResult result);
/// <summary>The 1-based queue position of a waiting create request changed.</summary>
Task QueuePositionChanged(int position);
}
/// <param name="Code">The connection code, if <paramref name="Status" /> is <see cref="CreateStatus.Created" />.</param>
/// <param name="JoinTimeout">The connection is closed unless someone joins within this period.</param>
/// <param name="QueuePosition">
/// 1-based position in the queue, if <paramref name="Status" /> is
/// <see cref="CreateStatus.Queued" />.
/// </param>
public sealed record CreateConnectionResult(
CreateStatus Status,
string? Code,
TimeSpan JoinTimeout,
TimeSpan InactivityTimeout,
int QueuePosition);
public enum CreateStatus
{
Created,
/// <summary>The connection limit is reached; the connection is created as soon as a slot becomes free.</summary>
Queued,
/// <summary>The connection limit is reached and the queue is full.</summary>
ServerBusy,
/// <summary>The network address of the browser already has the maximum number of connections or queued requests.</summary>
TooManyConnections,
/// <summary>The proof-of-work solution was missing, wrong, reused or too late.</summary>
BotCheckFailed
}
/// <summary>
/// Proof of work against creating connections in bulk: find the number between 0 and <paramref name="MaxNumber" />
/// for which the lowercase hex SHA-256 of the UTF-8 string <c>Salt + number</c> equals <paramref name="Hash" />.
/// </summary>
public sealed record BotCheckChallenge(string Salt, string Hash, int MaxNumber);
/// <summary>Current load of the relay server.</summary>
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,
/// <summary>Too many browsers from the same network address are connected to the server.</summary>
TooManyConnections
}
public enum SendStatus
{
/// <summary>The frame was relayed to the peer.</summary>
Delivered,
WaitingForPeer,
NotConnected,
InvalidSize,
/// <summary>The connection relayed its maximum amount of data and was closed.</summary>
TransferLimitReached
}
public enum ConnectionClosedReason
{
PeerLeft,
Inactivity,
MaximumLifetime,
TransferLimitReached
}

View file

@ -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

4
DataTransfer.slnx Normal file
View file

@ -0,0 +1,4 @@
<Solution>
<Project Path="DataTransfer.Client/DataTransfer.Client.csproj"/>
<Project Path="DataTransfer/DataTransfer.csproj"/>
</Solution>

View file

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="description"
content="Send files and messages end-to-end encrypted between two browsers using a one-time code."/>
<meta name="color-scheme" content="light dark"/>
<meta name="theme-color" content="#1d2329"/>
<base href="/"/>
<ResourcePreloader/>
<link rel="stylesheet" href="@Assets["app.css"]"/>
<link rel="stylesheet" href="@Assets["DataTransfer.styles.css"]"/>
<ImportMap nonce="@SecurityHeaders.GetNonce(HttpContext)"/>
<link rel="icon" type="image/svg+xml" href="@Assets["favicon.svg"]"/>
<HeadOutlet/>
</head>
<body>
<Routes/>
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>
@code {
[CascadingParameter] private HttpContext? HttpContext { get; set; }
}

View file

@ -0,0 +1,67 @@
@inherits LayoutComponentBase
<div class="shell">
<header class="app-header">
<div class="app-header-inner">
@* A full reload (no enhanced navigation) disconnects from the server, which cancels any connection. *@
<a class="brand" href="" data-enhance-nav="false" title="Start over" data-testid="brand">
<span class="brand-mark"><Icon Name="transfer"/></span>
<span>DataTransfer</span>
</a>
<div class="header-meta">
<ServerLoad @rendermode="@(new InteractiveWebAssemblyRenderMode(false))"/>
<span class="header-note" title="End-to-end encrypted">
<Icon Name="lock"/>
<span class="header-note-text">End-to-end encrypted</span>
</span>
</div>
</div>
</header>
<main class="app-main">
@Body
</main>
<footer class="app-footer">
<div class="app-footer-inner">
<span>ECDH P-256 · AES-256-GCM · The server only relays encrypted data.</span>
<nav class="footer-links" aria-label="Legal and source code">
@foreach (var (href, label, icon) in LegalLinks)
{
<a href="@href" target="@(IsTransferPage ? "_blank" : null)" rel="@(IsTransferPage ? "noopener" : null)">
<Icon Name="@icon"/>
<span>@label</span>
</a>
}
<SourceCodeLink Target="@(IsTransferPage ? "_blank" : null)">
<Icon Name="code"/>
<span>Open source</span>
</SourceCodeLink>
</nav>
</div>
</footer>
</div>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss" title="Dismiss"><Icon Name="close"/></span>
</div>
@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;
}

View file

@ -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;
}

View file

@ -0,0 +1,21 @@
@inject IOptions<LegalOptions> LegalOptions
<address class="legal-address">
<strong><ProtectedText Value="@Legal.OperatorName"/></strong><br/>
@foreach (var line in Legal.Address)
{
<ProtectedText Value="@line"/><br/>
}
E-mail: <ProtectedEmail Address="@Legal.Email"/>
@if (!string.IsNullOrWhiteSpace(Legal.Phone))
{
<br/>
@("Phone: ")<ProtectedText Value="@Legal.Phone"/>
}
</address>
@code {
private LegalOptions Legal => LegalOptions.Value;
}

View file

@ -0,0 +1,18 @@
using System.Text;
namespace DataTransfer.Components.Layout;
/// <summary>
/// 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.
/// </summary>
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);
}
}

View file

@ -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. *@
<a class="protected-email" data-protected-email="@ProtectedData.Encode(Address)" data-subject="@Subject"
title="Enable JavaScript to show the e-mail address">…</a>
@code {
[Parameter] [EditorRequired] public string Address { get; set; } = string.Empty;
[Parameter] public string? Subject { get; set; }
}

View file

@ -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. *@
<span class="protected-text" data-protected-text="@ProtectedData.Encode(Value ?? string.Empty)"
title="Enable JavaScript to show this information">…</span>
@code {
[Parameter] [EditorRequired] public string? Value { get; set; }
}

View file

@ -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> LegalOptions
@if (LegalOptions.Value.SourceCodeUri is { } uri)
{
<a href="@uri.AbsoluteUri" target="@Target" rel="noopener">
@if (ChildContent is not null)
{
@ChildContent
}
else
{
@Label(uri)
}
</a>
}
@code {
/// <summary>Optional link text; defaults to the repository address.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public string? Target { get; set; }
private static string Label(Uri uri) => (uri.Host + uri.AbsolutePath).TrimEnd('/');
}

View file

@ -0,0 +1,75 @@
@page "/abuse"
@inject IOptions<LegalOptions> LegalOptions
<PageTitle>Report abuse · DataTransfer</PageTitle>
@* Personal data of the operator: keep the page out of search engines and their caches. *@
<HeadContent>
<meta name="robots" content="noindex, noarchive"/>
</HeadContent>
<article class="legal">
<h1>Report abuse</h1>
<p class="legal-updated">
If you believe this service was used for illegal content, fraud, harassment, attacks or other misuse, please let
us know.
</p>
<h2>How to report</h2>
<p>
E-mail: <ProtectedEmail Address="@Legal.AbuseEmail" Subject="Abuse report"/>
</p>
<p>Please include as much of the following as you know:</p>
<ul>
<li>date and time of the incident, with time zone;</li>
<li>the connection code, if you know it;</li>
<li>a description of what happened;</li>
<li>how we can reach you for questions (optional; you can also report anonymously).</li>
</ul>
<p class="legal-note">
<strong>Never send us illegal material</strong>, in particular depictions of sexual abuse of children. Describe it
instead, and report it to the police as described below.
</p>
<h2>What we can do</h2>
<p>
Messages and files are encrypted end to end and aren't stored, so we can't view, recover or remove content that
was transmitted. We can end active connections, block network addresses and strengthen our protective measures,
and we cooperate with law enforcement authorities within our legal obligations. We review every report as soon as
possible.
</p>
<h2>Report criminal offences to the authorities</h2>
<ul>
<li>In an emergency, call the police: <strong>117</strong>.</li>
<li>
Illegal pornography, in particular depictions of sexual abuse of children: Federal Office of Police fedpol,
<a href="https://www.fedpol.admin.ch/en/reporting-illegal-pornography" rel="noopener">reporting form</a>.
</li>
<li>
Cyber incidents such as phishing, fraud or attacks: Federal Office for Cybersecurity (BACS),
<a href="https://www.report.ncsc.admin.ch/en/" rel="noopener">reporting form</a>.
</li>
<li>
Other offences: your cantonal police; see also
<a href="https://www.cybercrimepolice.ch" rel="noopener">cybercrimepolice.ch</a>.
</li>
</ul>
<h2>Security vulnerabilities</h2>
<p>
If you found a security vulnerability in this service, please report it confidentially to
<ProtectedEmail Address="@Legal.AbuseEmail" Subject="Security vulnerability"/> and give us reasonable
time to fix it before you publish it.
</p>
@if (Legal.SourceCodeUri is not null)
{
<p>DataTransfer is open source, so you can review the code at <SourceCodeLink/>.</p>
}
</article>
@code {
private LegalOptions Legal => LegalOptions.Value;
}

View file

@ -0,0 +1,31 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error · DataTransfer</PageTitle>
<div class="status-page">
<span class="status-page-code">ERROR</span>
<h1>Something went wrong</h1>
<p>An error occurred while processing your request. Please try again.</p>
@if (ShowRequestId)
{
<p>Request ID: <code>@RequestId</code></p>
}
<a class="btn btn-primary" href="">Back to start</a>
</div>
@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;
}
}

View file

@ -0,0 +1,68 @@
@page "/imprint"
@inject IOptions<LegalOptions> LegalOptions
<PageTitle>Imprint · DataTransfer</PageTitle>
@* Personal data of the operator: keep the page out of search engines and their caches. *@
<HeadContent>
<meta name="robots" content="noindex, noarchive"/>
</HeadContent>
<article class="legal">
<h1>Imprint</h1>
<p class="legal-updated">Information about the operator of this website.</p>
<h2>Operator</h2>
<OperatorContact/>
@if (!string.IsNullOrWhiteSpace(Legal.CompanyId))
{
<h2>Enterprise identification number</h2>
<p>UID: @Legal.CompanyId</p>
}
@if (!string.IsNullOrWhiteSpace(Legal.ResponsiblePerson))
{
<h2>Responsible for the content</h2>
<p><ProtectedText Value="@Legal.ResponsiblePerson"/></p>
}
<h2>Abuse and security</h2>
<p>
To report misuse of this service or a security vulnerability, write to
<ProtectedEmail Address="@Legal.AbuseEmail"/> or see <a href="abuse">Report abuse</a>.
</p>
<h2>Disclaimer</h2>
<p>
We take great care in operating this website and the service, but we give no guarantee that the information is
accurate, complete or up to date, or that the service is available at all times. Liability is limited as set out
in the <a href="terms">terms of use</a>.
</p>
<p>
Links to third-party websites are outside our responsibility. We have no influence on their content and accept
no liability for it.
</p>
<h2>Copyright</h2>
<p>
The content and design of this website belong to the operator unless stated otherwise. Messages and files that
users transmit with the service remain theirs; we can't see them.
</p>
@if (Legal.SourceCodeUri is not null)
{
<h2>Source code</h2>
<p>
DataTransfer is free and open source under the BSD Zero Clause License (0BSD): anyone may read, review, use,
change and share the source code of this service, including the encryption, without conditions. It is
published at <SourceCodeLink/>.
</p>
}
</article>
@code {
private LegalOptions Legal => LegalOptions.Value;
}

View file

@ -0,0 +1,11 @@
@page "/not-found"
@layout MainLayout
<PageTitle>Not found · DataTransfer</PageTitle>
<div class="status-page">
<span class="status-page-code">404</span>
<h1>Page not found</h1>
<p>The page you are looking for does not exist.</p>
<a class="btn btn-primary" href="">Start a transfer</a>
</div>

View file

@ -0,0 +1,187 @@
@page "/privacy"
@using System.Globalization
@using DataTransfer.Client.Services
@inject IOptions<LegalOptions> LegalOptions
@inject IOptions<TransferOptions> TransferOptions
<PageTitle>Privacy policy · DataTransfer</PageTitle>
@* Personal data of the operator: keep the page out of search engines and their caches. *@
<HeadContent>
<meta name="robots" content="noindex, noarchive"/>
</HeadContent>
<article class="legal">
<h1>Privacy policy</h1>
<p class="legal-updated">Last updated: @LastUpdated</p>
<p>
This privacy policy explains how <ProtectedText Value="@Legal.OperatorName"/> ("we") processes personal data when
you use DataTransfer on this website. It is based on the Swiss Federal Act on Data Protection (FADP). Where the
EU General Data Protection Regulation (GDPR) applies, section 10 applies in addition.
</p>
<h2>1. Controller</h2>
<OperatorContact/>
<p>For questions about data protection, write to <ProtectedEmail Address="@Legal.Email"/>.</p>
<h2>2. In short</h2>
<ul>
<li>Messages and files are encrypted in your browser before they are sent (end to end). We can't read them.</li>
<li>We don't store messages or files. The server only relays the encrypted data while both browsers are connected.</li>
<li>There is no account, no tracking, no analytics, no advertising and no content from third parties.</li>
<li>We use your IP address only briefly, to protect the service against misuse.</li>
</ul>
<h2>3. Data we process</h2>
<h3>3.1 Visiting the website</h3>
<p>
When you open the website, your browser transmits technical data that is needed to deliver it: IP address, date
and time, the requested address and information about your browser (user agent).
@if (Legal.AccessLogRetentionDays > 0)
{
@($"Our server records this data in access logs, which are deleted after {Legal.AccessLogRetentionDays} days.")
}
else
{
@("We don't keep access logs.")
}
</p>
<p>
A technically necessary cookie (<code>.AspNetCore.Antiforgery…</code>) protects against forged requests from
other websites. It contains a random value and no information about you, and it is deleted when you close the
browser. We don't use any other cookies, local storage or similar technologies.
</p>
<p>The page regularly loads the current server load (number of connections). This contains no personal data.</p>
<h3>3.2 Creating and joining a connection</h3>
<p>
To pair two browsers and relay data between them, the server keeps in its memory: a random connection code,
random technical identifiers of the two browsers, temporary public keys for the key exchange, the time of the
last activity and the amount of data relayed.
</p>
<p>
To protect the service against misuse, for example someone occupying all connections, the server counts
connections and connection attempts per network address. For this purpose it keeps your IP address (for IPv6,
its network prefix) in memory while you are connected and for a few minutes afterwards. It isn't written to disk.
The only exception: if a request contains proxy headers from an address that isn't a configured proxy, that
address may be written to the server log (at most once every ten minutes) so that we can detect configuration
errors.
</p>
<p>
Before a connection is created, your browser solves a small computing task (proof of work) to make it harder for
automated programs to create connections in bulk. The task is generated and checked by our server alone; no
third-party service is involved and no data about you or your device is collected for it.
</p>
<h3>3.3 Messages and files</h3>
<p>
Messages and files are encrypted end to end in your browser (key agreement with ECDH P-256, encryption with
AES-256-GCM). The keys never leave the two browsers. The server relays the encrypted data piece by piece to the
other browser without storing it and can't decrypt it. It only sees technical metadata such as the time and size
of the transmitted data.
</p>
<p>
Received messages and files are kept in the memory of the recipient's browser until a new connection is started
or the page is closed. Save files you want to keep.
</p>
<p>
Whether you share personal data in messages or files, your own or other people's, is your decision and your
responsibility.
</p>
<h2>4. Purposes</h2>
<ul>
<li>to provide the service and connect two browsers;</li>
<li>to keep the service secure and stable and to prevent misuse;</li>
<li>to comply with legal obligations.</li>
</ul>
<p>
We don't use your data for marketing or profiling, and we don't make automated individual decisions within the
meaning of Art. 21 FADP.
</p>
<h2>5. Retention</h2>
<ul>
<li>
Connection data is deleted from memory when the connection ends: unused codes after
@DisplayFormat.Duration(Transfer.JoinTimeout), connections without transfers after
@DisplayFormat.Duration(Transfer.InactivityTimeout), and every connection after
@DisplayFormat.Duration(Transfer.MaxConnectionLifetime) at the latest.
</li>
<li>The counters per network address are deleted from memory a few minutes after your last connection.</li>
<li>
@(Legal.AccessLogRetentionDays > 0
? $"Access logs are deleted after {Legal.AccessLogRetentionDays} days."
: "No access logs are kept.")
</li>
<li>Restarting the server deletes everything held in memory.</li>
</ul>
<h2>6. Recipients and processors</h2>
<p>
@if (Legal.IsSelfHosted)
{
@("We run the server ourselves; no hosting provider processes data on our behalf. Internet providers only transport the encrypted connections.")
}
else
{
@($"The service is hosted by {Legal.HostingProvider}, which processes data on our behalf (Art. 9 FADP) and is bound by contract to protect it.")
}
We disclose data to authorities only where we are legally obliged to. We don't sell data.
</p>
<h2>7. Disclosure abroad</h2>
<p>
The servers are located in @(Legal.HostingLocation). If data is processed outside Switzerland, this happens only
in countries the Federal Council recognises as providing adequate data protection (such as the EU and EEA
states), or with appropriate safeguards such as standard contractual clauses (Art. 16 FADP).
</p>
<h2>8. Data security</h2>
<p>
All connections to the website are encrypted (HTTPS). In addition, messages and files are encrypted end to end,
and the server limits connections and connection attempts to prevent misuse.
@if (Legal.SourceCodeUri is not null)
{
<text>DataTransfer is open source, so anyone can check how the encryption works and that the server can't read
your data: <SourceCodeLink/>.</text>
}
No system is completely secure,
however. Compare the safety code with the other person over another channel, for example by phone: only if both
codes match can you be sure that nobody intercepted the connection.
</p>
<h2>9. Your rights</h2>
<p>
Under the FADP you can request information about your personal data (Art. 25 FADP), have incorrect data
corrected, have data deleted, object to its processing and receive certain data in a common electronic format
(Art. 28 FADP). Send your request to <ProtectedEmail Address="@Legal.Email"/>. Because we don't store data
linked to a person and delete connection data after a short time, we usually can't attribute any data to you.
</p>
<p>
You can lodge a complaint with the Federal Data Protection and Information Commissioner (FDPIC),
Feldeggweg 1, 3003 Bern, <a href="https://www.edoeb.admin.ch" rel="noopener">www.edoeb.admin.ch</a>.
</p>
<h2>10. Visitors from the EU and EEA</h2>
<p>
Where the GDPR applies, we process data to provide the service you request (Art. 6 para. 1 let. b GDPR) and
based on our legitimate interest in a secure, stable service without misuse (Art. 6 para. 1 let. f GDPR). You
have the rights under Art. 15 to 21 GDPR and can lodge a complaint with the data protection authority in your
country.
</p>
<h2>11. Changes</h2>
<p>We may update this privacy policy. The version published on this website applies.</p>
</article>
@code {
private LegalOptions Legal => LegalOptions.Value;
private TransferOptions Transfer => TransferOptions.Value;
private string LastUpdated => Legal.LastUpdated.ToString("d MMMM yyyy", CultureInfo.InvariantCulture);
}

View file

@ -0,0 +1,137 @@
@page "/terms"
@using System.Globalization
@using DataTransfer.Client.Services
@inject IOptions<LegalOptions> LegalOptions
@inject IOptions<TransferOptions> TransferOptions
<PageTitle>Terms of use · DataTransfer</PageTitle>
@* Personal data of the operator: keep the page out of search engines and their caches. *@
<HeadContent>
<meta name="robots" content="noindex, noarchive"/>
</HeadContent>
<article class="legal">
<h1>Terms of use</h1>
<p class="legal-updated">Last updated: @LastUpdated</p>
<h2>1. Scope</h2>
<p>
These terms govern the use of the DataTransfer service on this website, operated by
<ProtectedText Value="@Legal.OperatorName"/> ("we"). By using the service, you accept these terms. If you don't
agree with them, don't use the service.
</p>
<h2>2. The service</h2>
<p>
DataTransfer connects two browsers with a one-time code and transmits messages and files between them, encrypted
end to end. The service is free of charge and needs no account. We relay the encrypted data but don't store it,
and we can neither read nor recover it.
</p>
@if (Legal.SourceCodeUri is not null)
{
<p>
DataTransfer is open-source software under the BSD Zero Clause License (0BSD); its source code is published
at <SourceCodeLink/>. These terms apply to the service operated on this website, the license to the source
code.
</p>
}
<h2>3. Limits</h2>
<p>To keep the service available for everyone, the following limits currently apply. We may change them at any time.</p>
<ul>
<li>
Files of up to @DisplayFormat.Bytes(TransferProtocol.MaxFileBytes) each; per connection and direction at most
@TransferProtocol.MaxItemsPerConnection.ToString("N0", CultureInfo.InvariantCulture) messages and files and
@DisplayFormat.Bytes(TransferProtocol.MaxFileBytesPerConnection) of files in total.
</li>
<li>
A new code expires after @DisplayFormat.Duration(Transfer.JoinTimeout) unless someone joins. A connection is
closed after @DisplayFormat.Duration(Transfer.InactivityTimeout) without transfers and after
@DisplayFormat.Duration(Transfer.MaxConnectionLifetime) at the latest.
</li>
<li>
At most @Transfer.MaxConnections connections at the same time, and a limited number of connections per
network.
</li>
</ul>
<h2>4. Permitted use</h2>
<p>You may use the service only lawfully and for its intended purpose. In particular, you must not:</p>
<ul>
<li>
transmit illegal content, such as prohibited pornography and in particular depictions of sexual acts involving
minors (Art. 197 Swiss Criminal Code), depictions of violence (Art. 135 SCC), discrimination and incitement to
hatred (Art. 261<sup>bis</sup> SCC), or content that infringes copyrights or other rights of third parties;
</li>
<li>
transmit malware or phishing, or use the service for fraud, harassment, threats or other harm to third
parties;
</li>
<li>
attack, overload or disrupt the service, circumvent its limits or security measures, or use it automatically
or on a mass scale.
</li>
</ul>
<h2>5. Your responsibility</h2>
<ul>
<li>You alone are responsible for the content you transmit and for having the rights to transmit it.</li>
<li>Compare the safety code with the other person over another channel before you send confidential content.</li>
<li>
Keep your own copies. Received files exist only in your browser until you save them, and transfers can be
interrupted at any time.
</li>
<li>Keep your device and browser secure; encryption can't protect data on a compromised device.</li>
</ul>
<h2>6. Measures against misuse</h2>
<p>
We may restrict, interrupt or end connections, block network addresses, or suspend the service at any time and
without notice, in particular if we suspect misuse. As content is encrypted end to end, we can't inspect it. Where
we are legally obliged to, we cooperate with the authorities and disclose the information available to us,
usually only technical data as described in the <a href="privacy">privacy policy</a>. Please report misuse via
our <a href="abuse">abuse contact</a>.
</p>
<h2>7. Availability</h2>
<p>
We provide the service as it is and as available. We don't guarantee that it is always available, free of errors
or suitable for a particular purpose, and we may change or discontinue it at any time.
</p>
<h2>8. Liability</h2>
<p>
To the extent permitted by law, we exclude all liability for damage arising from the use of the service or from
it being unavailable, in particular for interrupted transfers, lost data and content transmitted by other users.
This doesn't apply to damage caused intentionally or through gross negligence (Art. 100 Swiss Code of
Obligations) or where liability is otherwise mandatory by law. You are liable to us for damage resulting from a
breach of these terms.
</p>
<h2>9. Privacy</h2>
<p>How we process personal data is described in the <a href="privacy">privacy policy</a>.</p>
<h2>10. Changes</h2>
<p>We may change these terms at any time. The version published on this website at the time of use applies.</p>
<h2>11. Applicable law and place of jurisdiction</h2>
<p>
These terms are governed by Swiss law, excluding its conflict-of-law rules. The place of jurisdiction is
<ProtectedText Value="@Legal.PlaceOfJurisdiction"/>, Switzerland. Mandatory places of jurisdiction, in particular
for consumers (Art. 32 Swiss Civil Procedure Code), remain reserved.
</p>
<h2>12. Severability</h2>
<p>If any provision of these terms is invalid, the remaining provisions remain in force.</p>
</article>
@code {
private LegalOptions Legal => LegalOptions.Value;
private TransferOptions Transfer => TransferOptions.Value;
private string LastUpdated => Legal.LastUpdated.ToString("d MMMM yyyy", CultureInfo.InvariantCulture);
}

View file

@ -0,0 +1,8 @@
@using DataTransfer.Components.Pages
<Router AppAssembly="typeof(Program).Assembly" AdditionalAssemblies="new[] { typeof(Client._Imports).Assembly }"
NotFoundPage="typeof(NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)"/>
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
</Found>
</Router>

View file

@ -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

View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.13"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DataTransfer.Client\DataTransfer.Client.csproj"/>
</ItemGroup>
</Project>

View file

@ -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;
/// <summary>
/// Limits every browser to <see cref="TransferOptions.MaxInvocationsPerSecond" /> 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.
/// </summary>
public sealed partial class InvocationRateLimitFilter(
IOptions<TransferOptions> options,
ILogger<InvocationRateLimitFilter> logger) : IHubFilter, IDisposable
{
private readonly ConcurrentDictionary<string, RateLimiter> _limiters = new(StringComparer.Ordinal);
private readonly int _permitsPerSecond = options.Value.MaxInvocationsPerSecond;
public async Task OnConnectedAsync(HubLifetimeContext context, Func<HubLifetimeContext, Task> 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<object?> InvokeMethodAsync(
HubInvocationContext invocationContext,
Func<HubInvocationContext, ValueTask<object?>> 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<HubLifetimeContext, Exception?, Task> 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);
}

View file

@ -0,0 +1,114 @@
using DataTransfer.Client.Shared;
using DataTransfer.Security;
using DataTransfer.Services;
using Microsoft.AspNetCore.SignalR;
namespace DataTransfer.Hubs;
/// <summary>
/// Pairs browsers and relays their frames: first the key exchange, then end-to-end encrypted data. It never sees
/// private keys or plaintext.
/// </summary>
public sealed partial class TransferHub(
ITransferService transferService,
ClientAddressLimiter addressLimiter,
BotCheck botCheck,
ILogger<TransferHub> logger) : Hub<ITransferHubClient>, 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<BotCheckChallenge?> GetBotCheckChallenge() => Task.FromResult(botCheck.Issue(Context));
public async Task<CreateConnectionResult> 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<JoinConnectionResult> 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<SendStatus> 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);
}

View file

@ -0,0 +1,170 @@
using System.Diagnostics.CodeAnalysis;
using DataTransfer.Client.Shared;
namespace DataTransfer.Models;
/// <summary>
/// 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.
/// </summary>
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; }
/// <summary>Network address partition of the creator (see <c>ClientAddress</c>), for the per-address limit.</summary>
public string CreatorAddress { get; }
public bool IsClosed
{
get
{
lock (_sync)
{
return _isClosed;
}
}
}
public IReadOnlyList<string> GetClientIds()
{
lock (_sync)
{
return _joinerClientId is null ? [CreatorClientId] : [CreatorClientId, _joinerClientId];
}
}
/// <summary>Claims the second and last slot of the connection.</summary>
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;
}
}
/// <summary>
/// Resolves the recipient of a frame sent by <paramref name="senderClientId" /> and records the activity. Closes the
/// connection instead if the frame would exceed <paramref name="maxRelayedBytes" />.
/// </summary>
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;
}
}
/// <summary>
/// Closes the connection atomically if nobody joined within <paramref name="joinTimeout" />, nothing was
/// transmitted within <paramref name="inactivityTimeout" /> or it exists for <paramref name="maxLifetime" />.
/// </summary>
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;
}
}
}

122
DataTransfer/Program.cs Normal file
View file

@ -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<InvocationRateLimitFilter>();
builder.Services.AddSignalR()
.AddHubOptions<TransferHub>(options =>
{
options.MaximumReceiveMessageSize = TransferProtocol.MaxHubMessageBytes;
options.AddFilter<InvocationRateLimitFilter>();
});
builder.Services.AddOptions<TransferOptions>()
.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<LegalOptions>()
.Bind(builder.Configuration.GetSection(LegalOptions.SectionName));
builder.Services.AddOptions<BotCheckOptions>()
.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<RateLimiterOptions>()
.Configure<IOptions<TransferOptions>>((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<HttpContext, string>(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<ClientAddressLimiter>();
builder.Services.AddSingleton<BotCheck>();
builder.Services.AddSingleton<ITransferService, TransferService>();
builder.Services.AddHostedService<ConnectionCleanupService>();
var app = builder.Build();
if (!app.Environment.IsDevelopment() && app.Services.GetRequiredService<IOptions<LegalOptions>>().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<TransferHub>(TransferProtocol.HubPath);
app.MapGet(
TransferProtocol.StatusPath,
(HttpContext context, ITransferService transferService) =>
{
context.Response.Headers.CacheControl = "no-store";
return transferService.GetStatus();
});
app.MapRazorComponents<App>()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(_Imports).Assembly);
app.Run();

View file

@ -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"
}
}
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class BotCheck(IOptions<BotCheckOptions> options, ITransferService transferService, TimeProvider timeProvider)
{
private static readonly object ChallengeKey = new();
private readonly BotCheckOptions _options = options.Value;
/// <returns>The challenge to solve, or <c>null</c> if the check is disabled.</returns>
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);
}
/// <summary>Checks the solution against the connection's latest challenge, which is used up either way.</summary>
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;
}
/// <summary>The hash the browser has to reproduce, see <see cref="BotCheckChallenge" />.</summary>
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);
}

View file

@ -0,0 +1,30 @@
namespace DataTransfer.Security;
public sealed class BotCheckOptions
{
public const string SectionName = "BotCheck";
public bool Enabled { get; set; } = true;
/// <summary>
/// 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.
/// </summary>
public int MaxNumber { get; set; } = 200_000;
/// <summary>Upper bound used while the server is busy (see <see cref="BusyThreshold" />), to slow down bulk creation.</summary>
public int BusyMaxNumber { get; set; } = 1_000_000;
/// <summary>Share of the connection limit in use from which <see cref="BusyMaxNumber" /> applies; also while clients are queued.</summary>
public double BusyThreshold { get; set; } = 0.8;
/// <summary>A challenge must be solved within this period.</summary>
public TimeSpan ChallengeLifetime { get; set; } = TimeSpan.FromMinutes(2);
public bool IsValid() =>
MaxNumber > 0
&& BusyMaxNumber >= MaxNumber
&& BusyThreshold is > 0 and <= 1
&& ChallengeLifetime > TimeSpan.Zero;
}

View file

@ -0,0 +1,34 @@
using System.Net;
using System.Net.Sockets;
namespace DataTransfer.Security;
public static class ClientAddress
{
/// <summary>
/// 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.
/// </summary>
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<byte> bytes = stackalloc byte[16];
address.TryWriteBytes(bytes, out _);
bytes[7..].Clear();
return $"{new IPAddress(bytes)}/56";
}
}

View file

@ -0,0 +1,89 @@
using System.Threading.RateLimiting;
using DataTransfer.Services;
using Microsoft.Extensions.Options;
namespace DataTransfer.Security;
/// <summary>
/// Limits the hub connections per network address (see <see cref="ClientAddress" />): 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.
/// </summary>
public sealed class ClientAddressLimiter : IDisposable
{
private readonly Lock _sync = new();
private readonly Dictionary<string, int> _clientsByAddress = new(StringComparer.Ordinal);
private readonly int _maxClientsPerAddress;
private readonly PartitionedRateLimiter<string> _connectAttempts;
public ClientAddressLimiter(IOptions<TransferOptions> options)
{
var transfer = options.Value;
_maxClientsPerAddress = transfer.MaxClientsPerAddress;
_connectAttempts = PartitionedRateLimiter.Create<string, string>(address =>
RateLimitPartition.GetFixedWindowLimiter(
address,
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = transfer.MaxConnectAttemptsPerMinute,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
}));
}
/// <summary>Whether a new hub connection from <paramref name="address" /> would exceed the concurrent limit.</summary>
public bool IsAtCapacity(string address)
{
lock (_sync)
{
return _clientsByAddress.GetValueOrDefault(address) >= _maxClientsPerAddress;
}
}
/// <summary>Counts a new hub connection; <see cref="Release" /> must be called when it closes.</summary>
/// <returns><c>false</c> if the address exceeded its connect rate or concurrent limit.</returns>
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();
}

View file

@ -0,0 +1,108 @@
using System.Net;
using Microsoft.AspNetCore.HttpOverrides;
namespace DataTransfer.Security;
/// <summary>
/// Takes the client address from <c>X-Forwarded-For</c> only when the request comes from a configured reverse
/// proxy, so clients can't spoof their address to evade the per-address limits.
/// </summary>
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<ForwardedHeadersOptions>(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<ILoggerFactory>().CreateLogger(typeof(ForwardedHeadersSetup).FullName!);
if (app.Configuration.GetValue<bool>(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<string[]>() ?? [];
// 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);
}

View file

@ -0,0 +1,57 @@
using DataTransfer.Client.Shared;
namespace DataTransfer.Security;
/// <summary>Checks requests that open hub connections before SignalR handles them.</summary>
public static class HubConnectionGuard
{
/// <summary>
/// Browsers always send <c>Origin</c> 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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<ClientAddressLimiter>();
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);
});
}
}

View file

@ -0,0 +1,78 @@
using System.Security.Cryptography;
namespace DataTransfer.Security;
/// <summary>
/// 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.
/// </summary>
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);
});
}
/// <summary>The nonce that allows the inline import map of the current request.</summary>
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; <style> elements stay forbidden.
"style-src-attr 'unsafe-inline'",
"img-src 'self'",
"font-src 'self'",
$"connect-src {connectSources}",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'");
}
}

View file

@ -0,0 +1,45 @@
using Microsoft.Extensions.Options;
namespace DataTransfer.Services;
/// <summary>
/// Periodically removes connections that have not transmitted anything within the inactivity timeout or exceeded
/// their maximum lifetime.
/// </summary>
public sealed partial class ConnectionCleanupService(
ITransferService transferService,
IOptions<TransferOptions> options,
TimeProvider timeProvider,
ILogger<ConnectionCleanupService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(options.Value.CleanupInterval, timeProvider);
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
var removed = await transferService.RemoveExpiredConnectionsAsync(stoppingToken);
if (removed > 0)
{
LogConnectionsRemoved(logger, removed);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
LogCleanupFailed(logger, ex);
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { }
}
[LoggerMessage(Level = LogLevel.Information, Message = "Removed {Count} expired connection(s)")]
private static partial void LogConnectionsRemoved(ILogger logger, int count);
[LoggerMessage(Level = LogLevel.Error, Message = "Removing inactive connections failed")]
private static partial void LogCleanupFailed(ILogger logger, Exception exception);
}

View file

@ -0,0 +1,35 @@
using DataTransfer.Client.Shared;
namespace DataTransfer.Services;
/// <summary>Registry of all connections. <c>clientId</c> is the SignalR connection id of a browser.</summary>
public interface ITransferService
{
ServerStatus GetStatus();
/// <summary>
/// Creates a new connection, or queues the request if the connection limit is reached; any previous connection or
/// queue entry of the client is removed first. Queued clients are notified once their connection is created.
/// Rejected if the creator's network address already has <see cref="TransferOptions.MaxConnectionsPerAddress" />
/// connections or queued requests.
/// </summary>
Task<CreateConnectionResult> CreateAsync(string clientId, string address);
/// <summary>Joins a connection as the second and last participant; any previous connection of the client is closed first.</summary>
Task<JoinConnectionResult> JoinAsync(string clientId, string code);
/// <summary>
/// Relays an opaque frame to the peer of the client. Closes the connection once it would exceed
/// <see cref="TransferOptions.MaxRelayedBytesPerConnection" />.
/// </summary>
Task<SendStatus> SendAsync(string clientId, byte[] frame);
/// <summary>Closes the client's connection or removes its queued create request, and notifies the peer.</summary>
Task LeaveAsync(string clientId);
/// <summary>
/// Removes all connections nobody joined within the join timeout, without a transmission within the inactivity
/// timeout or older than the maximum lifetime.
/// </summary>
Task<int> RemoveExpiredConnectionsAsync(CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,62 @@
namespace DataTransfer.Services;
/// <summary>
/// Operator details shown in the imprint, privacy policy, terms of use and abuse page. Values in square brackets
/// are placeholders that must be replaced before the service goes live.
/// </summary>
public sealed class LegalOptions
{
public const string SectionName = "Legal";
/// <summary>Full name of the person operating the service, or the company name.</summary>
public string OperatorName { get; set; } = string.Empty;
/// <summary>Postal address, one entry per line (street and number, postal code and city, country).</summary>
public string[] Address { get; set; } = [];
public string Email { get; set; } = string.Empty;
/// <summary>Address for abuse reports and security vulnerabilities.</summary>
public string AbuseEmail { get; set; } = string.Empty;
public string? Phone { get; set; }
/// <summary>Enterprise identification number (UID, e.g. CHE-123.456.789) of a company; leave empty as a private person.</summary>
public string? CompanyId { get; set; }
/// <summary>Person responsible for the content, if it isn't evident from <see cref="OperatorName" />.</summary>
public string? ResponsiblePerson { get; set; }
/// <summary>The company hosting the server; leave empty if you run the server yourself.</summary>
public string? HostingProvider { get; set; }
public bool IsSelfHosted => string.IsNullOrWhiteSpace(HostingProvider);
/// <summary>Country where the servers are located, e.g. "Switzerland".</summary>
public string HostingLocation { get; set; } = string.Empty;
/// <summary>Days the hosting infrastructure keeps access logs (IP address, time, URL); <c>0</c> if none are kept.</summary>
public int AccessLogRetentionDays { get; set; }
/// <summary>The operator's municipality of residence, or the city of the company's registered office.</summary>
public string PlaceOfJurisdiction { get; set; } = string.Empty;
/// <summary>Date the legal texts were last changed; update it whenever they change.</summary>
public DateOnly LastUpdated { get; set; }
/// <summary>Public repository with the source code of the service; leave empty to hide all source code links.</summary>
public string? SourceCodeUrl { get; set; }
/// <summary>The <see cref="SourceCodeUrl" /> if it is an absolute http(s) address, otherwise <c>null</c>.</summary>
public Uri? SourceCodeUri =>
Uri.TryCreate(SourceCodeUrl, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp)
? uri
: null;
/// <summary>Whether a required value is missing or still a placeholder.</summary>
public bool HasPlaceholders() =>
new[] { OperatorName, Email, AbuseEmail, HostingLocation, PlaceOfJurisdiction }
.Concat(Address.Length > 0 ? Address : [string.Empty])
.Any(value => string.IsNullOrWhiteSpace(value) || value.Contains('['))
|| HostingProvider?.Contains('[') == true;
}

View file

@ -0,0 +1,57 @@
namespace DataTransfer.Services;
public sealed class TransferOptions
{
public const string SectionName = "Transfer";
/// <summary>A connection is removed when no message or file was transmitted within this period.</summary>
public TimeSpan InactivityTimeout { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>A new code expires unless someone joins within this period, so unused codes don't block slots.</summary>
public TimeSpan JoinTimeout { get; set; } = TimeSpan.FromMinutes(3);
public TimeSpan CleanupInterval { get; set; } = TimeSpan.FromSeconds(15);
/// <summary>Maximum number of simultaneous connections (pairs of two browsers). Further create requests are queued.</summary>
public int MaxConnections { get; set; } = 100;
/// <summary>Maximum number of create requests waiting for a free connection slot; <c>0</c> disables the queue.</summary>
public int MaxQueueLength { get; set; } = 50;
/// <summary>A connection is closed after this period even if it is still active.</summary>
public TimeSpan MaxConnectionLifetime { get; set; } = TimeSpan.FromHours(2);
/// <summary>A connection is closed once it relayed this many bytes (both directions combined).</summary>
public long MaxRelayedBytesPerConnection { get; set; } = 2L * 1024 * 1024 * 1024;
/// <summary>
/// Maximum number of simultaneously connected browsers per network address (IPv4 address or IPv6 /56 prefix).
/// Further hub connections are closed immediately.
/// </summary>
public int MaxClientsPerAddress { get; set; } = 20;
/// <summary>
/// Maximum number of connections and queued create requests per network address, so a few addresses can't
/// occupy all slots. Joining a connection doesn't count.
/// </summary>
public int MaxConnectionsPerAddress { get; set; } = 3;
/// <summary>Maximum number of new hub connections per network address and minute.</summary>
public int MaxConnectAttemptsPerMinute { get; set; } = 60;
/// <summary>Maximum number of hub method invocations per browser and second; further invocations are delayed.</summary>
public int MaxInvocationsPerSecond { get; set; } = 10;
public bool IsValid() =>
InactivityTimeout > TimeSpan.Zero
&& JoinTimeout > TimeSpan.Zero
&& CleanupInterval > TimeSpan.Zero
&& MaxConnections > 0
&& MaxQueueLength >= 0
&& MaxConnectionLifetime > TimeSpan.Zero
&& MaxRelayedBytesPerConnection > 0
&& MaxClientsPerAddress > 0
&& MaxConnectionsPerAddress > 0
&& MaxConnectAttemptsPerMinute > 0
&& MaxInvocationsPerSecond > 0;
}

View file

@ -0,0 +1,395 @@
using System.Collections.Concurrent;
using DataTransfer.Client.Shared;
using DataTransfer.Hubs;
using DataTransfer.Models;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Options;
namespace DataTransfer.Services;
/// <summary>
/// In-memory registry of all connections. Registered as a singleton, so up to
/// <see cref="TransferOptions.MaxConnections" /> pairs can be connected in parallel; each connection is isolated by
/// its unique code and holds at most two participants. Further create requests wait in a FIFO queue and are admitted
/// as soon as a connection closes. The server never sees keys: the browsers exchange them through the relayed frames
/// and encrypt everything else end to end.
/// </summary>
public sealed partial class TransferService(
IHubContext<TransferHub, ITransferHubClient> hubContext,
IOptions<TransferOptions> options,
TimeProvider timeProvider,
ILogger<TransferService> logger) : ITransferService
{
private readonly ConcurrentDictionary<string, Connection> _connectionsByCode = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, Connection> _connectionsByClient = new(StringComparer.Ordinal);
// Guards adding connections, the queue and the per-address counts, so the limits hold and nobody can skip the queue.
private readonly Lock _admissionSync = new();
// Queued create requests, oldest first.
private readonly List<QueuedClient> _queue = [];
// Connections plus queued create requests per creator address; guarded by _admissionSync.
private readonly Dictionary<string, int> _entriesByAddress = new(StringComparer.Ordinal);
private readonly TransferOptions _options = options.Value;
public ServerStatus GetStatus()
{
lock (_admissionSync)
{
return new ServerStatus(_connectionsByCode.Count, _options.MaxConnections, _queue.Count);
}
}
public async Task<CreateConnectionResult> CreateAsync(string clientId, string address)
{
ArgumentException.ThrowIfNullOrEmpty(clientId);
ArgumentException.ThrowIfNullOrEmpty(address);
await LeaveAsync(clientId);
CreateConnectionResult result;
int activeConnections;
lock (_admissionSync)
{
if (_entriesByAddress.GetValueOrDefault(address) >= _options.MaxConnectionsPerAddress)
{
result = NotCreated(CreateStatus.TooManyConnections);
}
// A non-empty queue means a freed slot is about to be handed to the first queued client.
else if (_queue.Count == 0 && _connectionsByCode.Count < _options.MaxConnections)
{
result = Register(clientId, address);
AddAddressEntry(address);
}
else if (_queue.Count < _options.MaxQueueLength)
{
_queue.Add(new QueuedClient(clientId, address));
AddAddressEntry(address);
result = NotCreated(CreateStatus.Queued, _queue.Count);
}
else
{
result = NotCreated(CreateStatus.ServerBusy);
}
activeConnections = _connectionsByCode.Count;
}
switch (result.Status)
{
case CreateStatus.Created:
LogConnectionCreated(logger, activeConnections);
break;
case CreateStatus.Queued:
LogClientQueued(logger, result.QueuePosition);
break;
case CreateStatus.TooManyConnections:
LogAddressLimitReached(logger);
break;
default:
LogServerBusy(logger, activeConnections);
break;
}
return result;
}
public async Task<JoinConnectionResult> JoinAsync(string clientId, string code)
{
ArgumentException.ThrowIfNullOrEmpty(clientId);
await LeaveAsync(clientId);
if (!ConnectionCode.TryNormalize(code, out var normalizedCode))
{
return Failed(JoinStatus.InvalidCode);
}
if (!_connectionsByCode.TryGetValue(normalizedCode, out var connection))
{
return Failed(JoinStatus.NotFound);
}
if (!connection.TryJoin(clientId, timeProvider.GetUtcNow()))
{
return Failed(connection.IsClosed ? JoinStatus.NotFound : JoinStatus.AlreadyInUse);
}
_connectionsByClient[clientId] = connection;
if (connection.IsClosed)
{
_connectionsByClient.TryRemove(new KeyValuePair<string, Connection>(clientId, connection));
return Failed(JoinStatus.NotFound);
}
// Sent before the join result, so the creator learns about the peer before the key exchange starts.
await NotifyAsync(() => hubContext.Clients.Client(connection.CreatorClientId).PeerJoined());
LogConnectionJoined(logger);
return new JoinConnectionResult(JoinStatus.Joined, _options.InactivityTimeout);
}
public async Task<SendStatus> SendAsync(string clientId, byte[] frame)
{
ArgumentException.ThrowIfNullOrEmpty(clientId);
if (frame is not { Length: > 0 } || frame.Length > TransferProtocol.MaxFrameBytes)
{
return SendStatus.InvalidSize;
}
if (!_connectionsByClient.TryGetValue(clientId, out var connection))
{
return SendStatus.NotConnected;
}
if (!connection.TryRecordTransmission(
clientId,
frame.Length,
timeProvider.GetUtcNow(),
_options.MaxRelayedBytesPerConnection,
out var recipientClientId,
out var failure))
{
if (failure == SendStatus.TransferLimitReached)
{
await CompleteCloseAsync(connection, ConnectionClosedReason.TransferLimitReached, null);
}
return failure;
}
await hubContext.Clients.Client(recipientClientId).Receive(frame);
return SendStatus.Delivered;
}
public async Task LeaveAsync(string clientId)
{
ArgumentException.ThrowIfNullOrEmpty(clientId);
if (TryRemoveFromQueue(clientId, out List<QueuePosition> movedUp))
{
await NotifyQueuePositionsAsync(movedUp);
return;
}
if (_connectionsByClient.TryRemove(clientId, out var connection) && connection.TryClose())
{
await CompleteCloseAsync(connection, ConnectionClosedReason.PeerLeft, clientId);
}
}
public async Task<int> RemoveExpiredConnectionsAsync(CancellationToken cancellationToken = default)
{
var now = timeProvider.GetUtcNow();
var closing = new List<Task>();
foreach (var (_, connection) in _connectionsByCode)
{
cancellationToken.ThrowIfCancellationRequested();
if (connection.TryCloseIfExpired(
now,
_options.JoinTimeout,
_options.InactivityTimeout,
_options.MaxConnectionLifetime,
out var reason))
{
closing.Add(CompleteCloseAsync(connection, reason, null));
}
}
await Task.WhenAll(closing);
return closing.Count;
}
private static JoinConnectionResult Failed(JoinStatus status) => new(status, TimeSpan.Zero);
private static CreateConnectionResult NotCreated(CreateStatus status, int queuePosition = 0) =>
new(status, null, TimeSpan.Zero, TimeSpan.Zero, queuePosition);
[LoggerMessage(Level = LogLevel.Information, Message = "Connection created. Active connections: {ActiveConnections}")]
private static partial void LogConnectionCreated(ILogger logger, int activeConnections);
[LoggerMessage(Level = LogLevel.Information, Message = "Connection limit reached, create request queued at position {Position}")]
private static partial void LogClientQueued(ILogger logger, int position);
[LoggerMessage(Level = LogLevel.Warning, Message = "Connection limit and queue are full, create request rejected. Active connections: {ActiveConnections}")]
private static partial void LogServerBusy(ILogger logger, int activeConnections);
[LoggerMessage(Level = LogLevel.Debug, Message = "Create request rejected: the network address already has the maximum number of connections")]
private static partial void LogAddressLimitReached(ILogger logger);
[LoggerMessage(Level = LogLevel.Information, Message = "{Count} queued client(s) admitted. Active connections: {ActiveConnections}")]
private static partial void LogQueuedClientsAdmitted(ILogger logger, int count, int activeConnections);
[LoggerMessage(Level = LogLevel.Information, Message = "Connection joined")]
private static partial void LogConnectionJoined(ILogger logger);
[LoggerMessage(Level = LogLevel.Information, Message = "Connection closed ({Reason}). Active connections: {ActiveConnections}")]
private static partial void LogConnectionClosed(ILogger logger, ConnectionClosedReason reason, int activeConnections);
[LoggerMessage(Level = LogLevel.Debug, Message = "Notifying a client failed")]
private static partial void LogNotificationFailed(ILogger logger, Exception exception);
private async Task CompleteCloseAsync(Connection connection, ConnectionClosedReason reason, string? excludedClientId)
{
_connectionsByCode.TryRemove(new KeyValuePair<string, Connection>(connection.Code, connection));
lock (_admissionSync)
{
RemoveAddressEntry(connection.CreatorAddress);
}
var recipients = new List<string>(2);
foreach (var participantClientId in connection.GetClientIds())
{
_connectionsByClient.TryRemove(new KeyValuePair<string, Connection>(participantClientId, connection));
if (participantClientId != excludedClientId)
{
recipients.Add(participantClientId);
}
}
LogConnectionClosed(logger, reason, _connectionsByCode.Count);
try
{
if (recipients.Count > 0)
{
await hubContext.Clients.Clients(recipients).ConnectionClosed(reason);
}
}
catch (Exception ex)
{
LogNotificationFailed(logger, ex);
}
await AdmitQueuedClientsAsync();
}
/// <summary>Creates the connection of a client; the caller must hold <see cref="_admissionSync" />.</summary>
private CreateConnectionResult Register(string clientId, string address)
{
while (true)
{
var connection = new Connection(ConnectionCode.Generate(), clientId, address, timeProvider.GetUtcNow());
if (_connectionsByCode.TryAdd(connection.Code, connection))
{
_connectionsByClient[clientId] = connection;
return new CreateConnectionResult(
CreateStatus.Created,
connection.Code,
_options.JoinTimeout,
_options.InactivityTimeout,
0);
}
}
}
// A queued request keeps its entry when it is admitted; the entry is removed when the connection closes.
private void AddAddressEntry(string address) =>
_entriesByAddress[address] = _entriesByAddress.GetValueOrDefault(address) + 1;
private void RemoveAddressEntry(string address)
{
if (!_entriesByAddress.TryGetValue(address, out var entries))
{
return;
}
if (entries <= 1)
{
_entriesByAddress.Remove(address);
}
else
{
_entriesByAddress[address] = entries - 1;
}
}
private bool TryRemoveFromQueue(string clientId, out List<QueuePosition> movedUp)
{
lock (_admissionSync)
{
var index = _queue.FindIndex(queued => queued.ClientId == clientId);
if (index < 0)
{
movedUp = [];
return false;
}
RemoveAddressEntry(_queue[index].Address);
_queue.RemoveAt(index);
movedUp = GetQueuePositions(index);
return true;
}
}
/// <summary>Hands free slots to the clients at the head of the queue.</summary>
private async Task AdmitQueuedClientsAsync()
{
var admitted = new List<(string ClientId, CreateConnectionResult Result)>();
List<QueuePosition> movedUp;
int activeConnections;
lock (_admissionSync)
{
while (_queue.Count > 0 && _connectionsByCode.Count < _options.MaxConnections)
{
var next = _queue[0];
_queue.RemoveAt(0);
admitted.Add((next.ClientId, Register(next.ClientId, next.Address)));
}
movedUp = admitted.Count > 0 ? GetQueuePositions(0) : [];
activeConnections = _connectionsByCode.Count;
}
if (admitted.Count == 0)
{
return;
}
LogQueuedClientsAdmitted(logger, admitted.Count, activeConnections);
// A client that disconnected meanwhile is cleaned up by its own LeaveAsync call from the hub.
await Task.WhenAll(
admitted.Select(entry =>
NotifyAsync(() => hubContext.Clients.Client(entry.ClientId).ConnectionCreated(entry.Result))));
await NotifyQueuePositionsAsync(movedUp);
}
/// <summary>Returns the 1-based positions of all queued clients from <paramref name="startIndex" /> on.</summary>
private List<QueuePosition> GetQueuePositions(int startIndex)
{
var positions = new List<QueuePosition>(_queue.Count - startIndex);
for (var i = startIndex; i < _queue.Count; i++)
{
positions.Add(new QueuePosition(_queue[i].ClientId, i + 1));
}
return positions;
}
private Task NotifyQueuePositionsAsync(List<QueuePosition> positions) =>
positions.Count == 0
? Task.CompletedTask
: Task.WhenAll(
positions.Select(entry =>
NotifyAsync(() => hubContext.Clients.Client(entry.ClientId).QueuePositionChanged(entry.Position))));
private async Task NotifyAsync(Func<Task> notify)
{
try
{
await notify();
}
catch (Exception ex)
{
LogNotificationFailed(logger, ex);
}
}
private sealed record QueuedClient(string ClientId, string Address);
private readonly record struct QueuePosition(string ClientId, int Position);
}

View file

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View file

@ -0,0 +1,59 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "localhost;127.0.0.1;[::1]",
"Kestrel": {
"Limits": {
"MaxConcurrentConnections": 2000,
"MaxConcurrentUpgradedConnections": 1000
}
},
"Transfer": {
"InactivityTimeout": "00:10:00",
"JoinTimeout": "00:03:00",
"CleanupInterval": "00:00:15",
"MaxConnections": 100,
"MaxQueueLength": 50,
"MaxConnectionLifetime": "02:00:00",
"MaxRelayedBytesPerConnection": 2147483648,
"MaxClientsPerAddress": 20,
"MaxConnectionsPerAddress": 3,
"MaxConnectAttemptsPerMinute": 60,
"MaxInvocationsPerSecond": 10
},
"BotCheck": {
"Enabled": true,
"MaxNumber": 200000,
"BusyMaxNumber": 1000000,
"BusyThreshold": 0.8,
"ChallengeLifetime": "00:02:00"
},
"ForwardedHeaders": {
"ForwardLimit": 1,
"KnownProxies": [],
"KnownNetworks": []
},
"Legal": {
"OperatorName": "[Your full name or company name]",
"Address": [
"[Street and number]",
"[Postal code and city]",
"Switzerland"
],
"Email": "[contact e-mail address]",
"AbuseEmail": "[abuse e-mail address]",
"Phone": "",
"CompanyId": "",
"ResponsiblePerson": "",
"HostingProvider": "",
"HostingLocation": "Switzerland",
"AccessLogRetentionDays": 14,
"PlaceOfJurisdiction": "[Municipality]",
"LastUpdated": "2026-10-15",
"SourceCodeUrl": "https://git.sithis.ch/Sithis/DataTransfer"
}
}

View file

@ -0,0 +1,30 @@
// Blazor JS initializer of the server app. Reveals the personal data (e-mail addresses, name, postal address) that the
// server renders only in encoded form (Components/Layout/ProtectedData.cs), on the first load and after every enhanced
// navigation.
export function afterWebStarted(blazor) {
revealProtectedData();
blazor.addEventListener('enhancedload', revealProtectedData);
}
function decode(encoded) {
const bytes = Uint8Array.from(atob(encoded), c => c.charCodeAt(0)).reverse();
return new TextDecoder().decode(bytes);
}
function revealProtectedData() {
for (const link of document.querySelectorAll('a[data-protected-email]')) {
const address = decode(link.dataset.protectedEmail);
const subject = link.dataset.subject;
link.href = `mailto:${address}${subject ? `?subject=${encodeURIComponent(subject)}` : ''}`;
link.textContent = address;
link.removeAttribute('title');
link.removeAttribute('data-protected-email');
}
for (const element of document.querySelectorAll('[data-protected-text]')) {
element.textContent = decode(element.dataset.protectedText);
element.removeAttribute('title');
element.removeAttribute('data-protected-text');
}
}

View file

@ -0,0 +1,571 @@
/* ---------- Design tokens ---------- */
:root {
color-scheme: light;
--bg: #eceff2;
--surface: #ffffff;
--surface-2: #f5f7f9;
--border: #d9dee3;
--border-strong: #b9c1c9;
--text: #1b2127;
--text-muted: #5b6672;
--primary: #1a5fb4;
--primary-hover: #154f97;
--accent: #1a5fb4;
--accent-soft: #e5edf8;
--success: #1f7a3a;
--success-soft: #e3f2e7;
--warning: #9a5b00;
--warning-soft: #fcf1dc;
--danger: #b9302a;
--danger-soft: #fbeceb;
--danger-border: #eec3c0;
--header-bg: #1d2329;
--header-text: #eef1f4;
--header-muted: #a3adb7;
--radius: 4px;
--radius-lg: 6px;
--focus-ring: 0 0 0 3px rgb(26 95 180 / 0.25);
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--bg: #14181c;
--surface: #1c2126;
--surface-2: #22282e;
--border: #313941;
--border-strong: #46505a;
--text: #e2e7eb;
--text-muted: #96a1ac;
--primary: #2a6fc4;
--primary-hover: #3a7fd3;
--accent: #6aa5ea;
--accent-soft: #1d2e44;
--success: #5cba76;
--success-soft: #1a2e21;
--warning: #e0a63c;
--warning-soft: #352a15;
--danger: #f07a73;
--danger-soft: #3a1f1e;
--danger-border: #5c2d2a;
--header-bg: #0e1114;
--focus-ring: 0 0 0 3px rgb(106 165 234 / 0.3);
}
}
/* ---------- Base ---------- */
*, *::before, *::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: var(--font);
font-size: 0.9375rem;
line-height: 1.5;
color: var(--text);
background-color: var(--bg);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, p, ol, ul, dl, dd {
margin: 0;
}
h1:focus {
outline: none;
}
a {
color: var(--accent);
}
button, input, textarea, select {
font: inherit;
color: inherit;
}
code, kbd, .mono {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
.mono {
font-size: 0.95em;
}
kbd {
display: inline-block;
padding: 0 0.3125rem;
font-size: 0.6875rem;
line-height: 1.5;
border: 1px solid var(--border-strong);
border-bottom-width: 2px;
border-radius: 3px;
background-color: var(--surface);
}
.muted {
color: var(--text-muted);
}
.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
/* ---------- Icons ---------- */
.icon {
flex: none;
width: 1.125rem;
height: 1.125rem;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
vertical-align: middle;
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4375rem;
height: 2.25rem;
padding: 0 0.875rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background-color: var(--surface);
color: var(--text);
font-size: 0.875rem;
font-weight: 500;
line-height: 1;
white-space: nowrap;
text-decoration: none;
cursor: pointer;
transition: background-color 0.12s, border-color 0.12s, color 0.12s;
}
.btn:hover:not(:disabled) {
background-color: var(--surface-2);
border-color: var(--text-muted);
}
.btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn .icon {
width: 1rem;
height: 1rem;
}
.btn-primary {
background-color: var(--primary);
border-color: var(--primary);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--primary-hover);
border-color: var(--primary-hover);
}
.btn-danger {
color: var(--danger);
}
.btn-danger:hover:not(:disabled) {
background-color: var(--danger-soft);
border-color: var(--danger);
}
.btn-ghost {
background-color: transparent;
border-color: transparent;
color: var(--text-muted);
}
.btn-ghost:hover:not(:disabled) {
background-color: var(--surface-2);
border-color: var(--border);
color: var(--text);
}
.btn-sm {
height: 1.875rem;
padding: 0 0.625rem;
font-size: 0.8125rem;
}
.btn-icon {
width: 1.875rem;
padding: 0;
}
/* ---------- Form fields ---------- */
.input {
display: block;
width: 100%;
min-width: 0;
padding: 0.4375rem 0.75rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background-color: var(--surface);
color: var(--text);
line-height: 1.5;
transition: border-color 0.12s, box-shadow 0.12s;
}
.input::placeholder {
color: var(--text-muted);
opacity: 0.75;
}
.input:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
textarea.input {
resize: vertical;
}
.field-label {
display: block;
font-size: 0.8125rem;
font-weight: 600;
}
/* ---------- Panels ---------- */
.panel {
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 1px 2px rgb(16 24 32 / 0.04);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-height: 2.5rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
background-color: var(--surface-2);
}
.panel-title {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
}
.panel-body {
padding: 1rem;
}
.panel-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-top: 1px solid var(--border);
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
background-color: var(--surface-2);
}
/* ---------- Feedback ---------- */
.notice {
display: flex;
align-items: flex-start;
gap: 0.625rem;
margin-bottom: 1rem;
padding: 0.625rem 0.75rem 0.625rem 0.875rem;
border: 1px solid var(--border);
border-left-width: 3px;
border-radius: var(--radius);
background-color: var(--surface);
}
.notice > .icon {
margin-top: 0.1875rem;
}
.notice-text {
flex: 1;
min-width: 0;
}
.notice .btn {
margin: -0.1875rem -0.25rem -0.1875rem 0;
}
.notice-danger {
border-color: var(--danger-border);
border-left-color: var(--danger);
background-color: var(--danger-soft);
}
.notice-danger > .icon {
color: var(--danger);
}
.status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
}
.status::before {
content: "";
flex: none;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: currentColor;
}
.status-online {
color: var(--success);
}
.status-online::before {
box-shadow: 0 0 0 3px var(--success-soft);
}
.status-offline {
color: var(--text-muted);
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
height: 1.5rem;
padding: 0 0.5rem;
border-radius: 3px;
background-color: var(--surface-2);
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.tag .icon {
width: 0.875rem;
height: 0.875rem;
stroke-width: 2.5;
}
.tag-success {
background-color: var(--success-soft);
color: var(--success);
}
.tag-danger {
background-color: var(--danger-soft);
color: var(--danger);
}
.tag-progress {
background-color: var(--accent-soft);
color: var(--accent);
}
.spinner {
flex: none;
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.progress {
height: 4px;
overflow: hidden;
border-radius: 2px;
background-color: var(--border);
}
.progress-bar {
height: 100%;
background-color: var(--accent);
transition: width 0.2s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation-duration: 2.4s;
}
.progress-bar {
transition: none;
}
}
/* ---------- Error and not-found pages ---------- */
.status-page {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
max-width: 32rem;
margin: 3rem auto;
text-align: center;
}
.status-page-code {
padding: 0.125rem 0.5rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
color: var(--text-muted);
font-family: var(--mono);
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.1em;
}
.status-page h1 {
font-size: 1.5rem;
font-weight: 600;
}
.status-page p {
color: var(--text-muted);
}
.status-page .btn {
margin-top: 0.5rem;
}
/* ---------- Legal pages ---------- */
.legal {
max-width: 48rem;
margin: 0 auto;
padding: 0.5rem 0 2rem;
}
.legal h1 {
font-size: 1.5rem;
font-weight: 600;
line-height: 1.25;
}
.legal h2 {
margin-top: 1.75rem;
margin-bottom: 0.5rem;
font-size: 1.0625rem;
font-weight: 600;
}
.legal h3 {
margin-top: 1.25rem;
margin-bottom: 0.375rem;
font-size: 0.9375rem;
font-weight: 600;
}
.legal p,
.legal ul,
.legal address {
margin-bottom: 0.75rem;
}
.legal ul {
padding-left: 1.25rem;
}
.legal li + li {
margin-top: 0.25rem;
}
.legal .legal-updated {
margin-top: 0.25rem;
margin-bottom: 1.5rem;
color: var(--text-muted);
}
.legal-address {
padding: 0.75rem 1rem;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background-color: var(--surface);
font-style: normal;
}
.legal-note {
padding: 0.75rem 1rem;
border: 1px solid var(--danger-border);
border-left: 3px solid var(--danger);
border-radius: var(--radius);
background-color: var(--danger-soft);
}
/* ---------- Blazor error boundary ---------- */
.blazor-error-boundary {
padding: 0.75rem 1rem;
border: 1px solid var(--danger-border);
border-left: 3px solid var(--danger);
border-radius: var(--radius);
background-color: var(--danger-soft);
color: var(--text);
}
.blazor-error-boundary::after {
content: "An error has occurred.";
}

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#1a5fb4"/>
<path d="M7 12.5h17l-4.5-4.5M25 19.5H8l4.5 4.5" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round"
stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 286 B

View file

@ -0,0 +1,45 @@
// Searches numbers start..end for the one whose SHA-256(salt + number) equals the target hash; posts it, or -1.
const encoder = new TextEncoder();
// Digests are requested in batches, because each WebCrypto call is asynchronous.
const batchSize = 256;
self.onmessage = async ({ data: { salt, hash, start, end } }) => {
const target = hexToBytes(hash);
for (let first = start; first <= end; first += batchSize) {
const last = Math.min(first + batchSize - 1, end);
const pending = [];
for (let number = first; number <= last; number++) {
pending.push(crypto.subtle.digest('SHA-256', encoder.encode(salt + number)));
}
const digests = await Promise.all(pending);
for (let i = 0; i < digests.length; i++) {
if (matches(new Uint8Array(digests[i]), target)) {
self.postMessage(first + i);
return;
}
}
}
self.postMessage(-1);
};
function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
}
return bytes;
}
function matches(digest, target) {
for (let i = 0; i < target.length; i++) {
if (digest[i] !== target[i]) {
return false;
}
}
return true;
}

View file

@ -0,0 +1,31 @@
// Solves the server's proof-of-work bot check (see BotCheckChallenge) in parallel web workers, so the page stays
// responsive. Resolves with the number, or -1 if none was found.
export function solve(salt, hash, maxNumber) {
const workerCount = Math.max(1, Math.min(navigator.hardwareConcurrency || 2, 8));
const sliceSize = Math.ceil((maxNumber + 1) / workerCount);
const workerUrl = new URL('./bot-check-worker.js', import.meta.url);
return new Promise(resolve => {
const workers = [];
let running = workerCount;
const finish = result => {
workers.forEach(worker => worker.terminate());
resolve(result);
};
const sliceDone = () => {
if (--running === 0) {
finish(-1);
}
};
for (let i = 0; i < workerCount; i++) {
const worker = new Worker(workerUrl);
workers.push(worker);
worker.onmessage = ({ data }) => data >= 0 ? finish(data) : sliceDone();
worker.onerror = sliceDone;
const start = i * sliceSize;
worker.postMessage({ salt, hash, start, end: Math.min(start + sliceSize - 1, maxNumber) });
}
});
}

34
Dockerfile Normal file
View file

@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1
# Build: docker build -t datatransfer .
# Run: docker run -d --name datatransfer --restart unless-stopped -p 8080:8080 \
# -e AllowedHosts=transfer.example.ch \
# -e ForwardedHeaders__KnownNetworks__0=172.16.0.0/12 \
# datatransfer
#
# Put a reverse proxy with HTTPS in front of the container: browsers only allow the end-to-end encryption on HTTPS
# pages. Any setting of appsettings.json can be overridden with environment variables ("Section__Key").
# The build runs on the build machine's architecture. Its output is portable .NET code, so the same Dockerfile also
# produces arm64 images, e.g. for a Raspberry Pi: docker buildx build --platform linux/arm64 -t datatransfer .
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Restore first, so the package layer stays cached until a project file changes. The SDK only restores the package with
# blazor.web.js when it sees .razor files, which are not copied yet, so it is requested explicitly.
COPY DataTransfer/DataTransfer.csproj DataTransfer/
COPY DataTransfer.Client/DataTransfer.Client.csproj DataTransfer.Client/
RUN dotnet restore DataTransfer/DataTransfer.csproj -p:RequiresAspNetWebAssets=true
COPY . .
RUN dotnet publish DataTransfer/DataTransfer.csproj -c Release -o /app --no-restore -p:UseAppHost=false
# Minimal runtime image: no shell or package manager, runs as the non-root user "app".
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final
WORKDIR /app
COPY --from=build /app .
# The base image sets ASPNETCORE_HTTP_PORTS=8080 and ASPNETCORE_ENVIRONMENT defaults to Production.
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "DataTransfer.dll"]

14
LICENSE Normal file
View file

@ -0,0 +1,14 @@
BSD Zero Clause License
Copyright (C) 2026 Sithis
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

328
README.md Normal file
View file

@ -0,0 +1,328 @@
# DataTransfer
Send messages and files **end-to-end encrypted** between two browsers using a one-time code. No account, no
installation, and nothing is stored on the server: it only relays encrypted data while both browsers are connected.
DataTransfer is free and open source ([0BSD license](LICENSE)). Source code:
<https://git.sithis.ch/Sithis/DataTransfer>. A running instance is at <https://transfer.sithis.ch>.
```
Browser A (creator) Server (relay) Browser B (joiner)
─────────────────── ────────────── ──────────────────
Create connection ── code ──────────► pairs the two browsers ◄──────── Join with code
key exchange ◄────────────────── relays public keys ──────────► key exchange
encrypt ─ chunks ─────────────────────► relays, never stores ──────────► decrypt, save
```
## Features
- **End-to-end encryption in the browser.** The server never sees keys or plaintext.
- **One-time connection codes** (10 characters, 50 bits of randomness), shared out of band (phone, messenger, …).
- **Safety code.** Both users compare it over another channel; if both codes match, nobody intercepted the connection.
- **Messages and files**, files up to 100 MB each, transferred in 256 KB chunks with progress display.
- **Nothing stored.** The server keeps only in-memory connection state; received files stay in the recipient's
browser until they're saved.
- **Abuse protection:** per-network limits, rate limits, a self-hosted proof-of-work bot check, connection timeouts
and transfer quotas.
- **Security headers:** strict Content Security Policy, no third-party content, no tracking.
- **Recipient protection:** received file names are sanitized, and risky file types (programs, scripts, macros, …) show
a warning.
- **Legal pages** for Switzerland (imprint, privacy policy, terms of use, abuse contact), filled from configuration.
- **Open source:** the footer and the legal pages link to the source code repository (`Legal:SourceCodeUrl`).
- **Queue:** when the server is full, new connections wait in a queue and are created automatically.
## How it works
### Architecture
| Part | Technology | Responsibility |
|---|---|---|
| `DataTransfer` | ASP.NET Core 10, SignalR | Hosts the app, pairs browsers, relays frames, enforces limits, serves the legal pages |
| `DataTransfer.Client` | Blazor WebAssembly, WebCrypto | The whole user interface, key exchange, encryption, file handling |
All state on the server lives in memory (one instance, no database). A restart ends all connections.
### Encryption
1. The creator gets a code from the server and shares it. The joiner enters the code.
2. **Commit-then-reveal key exchange** with ephemeral ECDH P-256 keys:
1. the joiner sends a hash commitment to its public key;
2. the creator reveals its public key;
3. the joiner reveals its public key, and the creator checks it against the commitment.
A relay in the middle therefore has to fix its fake keys before it learns the real ones, and can't search for keys
that produce matching safety codes.
3. Both browsers derive one **AES-256-GCM key per direction** from the shared secret, salted with the code and both
public keys. Private keys are non-extractable and discarded after the agreement.
4. Every frame carries a **sequence number inside the ciphertext**, so replayed, reordered or dropped frames are rejected.
5. The **safety code** (4 × 5 digits) is a hash of both public keys.
As with every web application, the server delivers the code that runs in the browser. The end-to-end encryption
protects against a curious or compromised relay only as long as the server delivers honest code.
### Limits (defaults)
| Limit | Default |
|---|---|
| File size | 100 MB per file, up to 20 files per selection |
| Per connection and direction | 1,000 messages/files, 1 GB of files |
| Hub calls per browser | 10 per second (paces file transfers to at most about 2.5 MB/s) |
| Unjoined code expires after | 3 minutes |
| Connection closes after | 10 minutes without transfers, 2 hours at the latest |
| Data relayed per connection | 2 GiB |
| Simultaneous connections | 100, plus a queue of 50 |
| Per network address (IPv4 or IPv6 /56) | 3 connections or queued requests, 20 open browsers, 60 new connections per minute |
## Project structure
```
DataTransfer.slnx
LICENSE BSD Zero Clause License (0BSD)
Dockerfile Container image (multi-stage build)
DataTransfer/ Server
Program.cs Startup and middleware pipeline
Hubs/ SignalR hub and per-client rate limit
Services/ Connection registry, cleanup, options
Models/ Connection (pairing of two browsers)
Security/ Address limits, bot check, CSP/security headers, forwarded headers
Components/ Layout, legal pages, error pages
wwwroot/ CSS, bot-check workers, JS initializer (e-mail protection)
DataTransfer.Client/ Browser app (Blazor WebAssembly)
Pages/Link.razor The transfer page
Services/ Session state machine, encryption, frames, file handling
Shared/ Contracts shared with the server
```
## Requirements
- [.NET 10 SDK](https://dotnet.microsoft.com/download) for building and running locally.
- [Docker](https://docs.docker.com/get-docker/) for building and running the container.
- A current browser (Chrome, Edge, Firefox, Safari). The page must be opened via **HTTPS** or `localhost`; otherwise
browsers don't provide the encryption API.
## Build and run locally
```bash
# Build
dotnet build DataTransfer.slnx
# Run (Development) – http://localhost:5133
dotnet run --project DataTransfer --launch-profile http
# or with HTTPS – https://localhost:7260
dotnet run --project DataTransfer --launch-profile https
```
Open the URL in two browser windows, for example a normal and a private window: create a connection in one, join it
in the other.
### Publish without Docker
```bash
dotnet publish DataTransfer/DataTransfer.csproj -c Release -o ./publish
cd publish
ASPNETCORE_URLS=http://0.0.0.0:8080 dotnet DataTransfer.dll
```
On Windows PowerShell, set the variable with `$env:ASPNETCORE_URLS = "http://0.0.0.0:8080"` instead.
## Configuration
Settings live in `DataTransfer/appsettings.json`. Every setting can be overridden with an environment variable, using
`__` (double underscore) between section and key, and an index for list entries:
```bash
AllowedHosts=transfer.example.ch
Transfer__MaxConnections=50
ForwardedHeaders__KnownNetworks__0=172.16.0.0/12
Legal__Address__0="Example Street 1"
```
Keep personal details (the `Legal` section) out of shared code: use environment variables or an
`appsettings.Production.json` that you don't commit or publish.
| Setting | Default | Meaning |
|---|---|---|
| `AllowedHosts` | `localhost;127.0.0.1;[::1]` | Host names the server answers to. **Set it to your domain**, otherwise requests get HTTP 400. |
| `Transfer:InactivityTimeout` | `00:10:00` | A connection closes after this period without transfers. |
| `Transfer:JoinTimeout` | `00:03:00` | A new code expires unless someone joins within this period. |
| `Transfer:MaxConnectionLifetime` | `02:00:00` | Every connection closes after this period. |
| `Transfer:MaxConnections` / `MaxQueueLength` | `100` / `50` | Simultaneous connections, and requests waiting for a free slot. |
| `Transfer:MaxRelayedBytesPerConnection` | `2147483648` | Bytes a connection may relay (both directions) before it closes. |
| `Transfer:MaxConnectionsPerAddress` | `3` | Connections plus queued requests per network address. |
| `Transfer:MaxClientsPerAddress` | `20` | Open browsers (hub connections) per network address. |
| `Transfer:MaxConnectAttemptsPerMinute` | `60` | New hub connections per network address and minute. |
| `Transfer:MaxInvocationsPerSecond` | `10` | Hub calls per browser and second; further calls are delayed. |
| `BotCheck:Enabled` | `true` | Proof of work before a connection is created. |
| `BotCheck:MaxNumber` / `BusyMaxNumber` | `200000` / `1000000` | Difficulty normally, and while the server is at least `BusyThreshold` (80 %) full. |
| `ForwardedHeaders:KnownProxies` / `KnownNetworks` | empty | Reverse proxies whose `X-Forwarded-For` is trusted (IP addresses / CIDR networks). |
| `ForwardedHeaders:ForwardLimit` | `1` | Number of proxies in front of the app. |
| `Kestrel:Limits:MaxConcurrentConnections` / `MaxConcurrentUpgradedConnections` | `2000` / `1000` | Caps on open TCP connections and WebSockets. |
| `Legal:*` | placeholders | Operator details for the legal pages (see below). |
### Legal pages
`/imprint`, `/privacy`, `/terms` and `/abuse` are filled from the `Legal` section:
| Key | Content |
|---|---|
| `OperatorName` | Your full name, or the company name. |
| `Address` | Postal address, one entry per line. |
| `Email`, `AbuseEmail` | Contact and abuse addresses. |
| `Phone`, `CompanyId`, `ResponsiblePerson` | Optional; shown only if set. |
| `HostingProvider` | The hosting company, or **empty** if you run the server yourself. |
| `HostingLocation` | Country of the server. |
| `AccessLogRetentionDays` | How long your server or reverse proxy keeps access logs; `0` if none. |
| `PlaceOfJurisdiction` | Your municipality of residence, or the company's registered office. |
| `LastUpdated` | Date of the last change to the legal texts. |
| `SourceCodeUrl` | Public repository of the code you run (http or https). The footer ("Open source"), imprint, privacy policy, terms and abuse page link to it. Leave empty to hide these links. |
The `appsettings.json` in this repository contains only `[…]` placeholders. Put your real details into an
`appsettings.Production.json` next to the app (or mount it into the container) and never commit it; it is listed in
`.gitignore`. Outside Development, the server logs a warning at startup while any placeholder remains. The texts are
templates based on Swiss law (revFADP, CO, CPC) and not legal advice.
Protection against harvesting bots:
- The name, postal address, phone number, responsible person, place of jurisdiction and e-mail addresses are written
into the HTML only in encoded form (`Components/Layout/ProtectedData.cs`). The browser decodes them with JavaScript
(`wwwroot/DataTransfer.lib.module.js`), so bots that read the HTML find nothing usable.
- The legal pages are marked `noindex, noarchive`, so search engines don't list or cache them.
## Docker
The `Dockerfile` builds the app with the .NET 10 SDK and runs it in the minimal `aspnet:10.0-noble-chiseled` image
(no shell, non-root user). The container listens on **port 8080** and runs in the `Production` environment.
### Build the image
```bash
docker build -t datatransfer .
```
For an arm64 server, such as a Raspberry Pi, or for both architectures at once:
```bash
docker buildx build --platform linux/arm64 -t datatransfer .
docker buildx build --platform linux/amd64,linux/arm64 -t registry.example.ch/datatransfer:latest --push .
```
### Start the container
```bash
docker run -d --name datatransfer --restart unless-stopped \
-p 8080:8080 \
-e AllowedHosts=transfer.example.ch \
-e ForwardedHeaders__KnownNetworks__0=172.16.0.0/12 \
datatransfer
```
With your legal details in a separate file instead of the image:
```bash
docker run -d --name datatransfer --restart unless-stopped \
-p 8080:8080 \
-e AllowedHosts=transfer.example.ch \
-e ForwardedHeaders__KnownNetworks__0=172.16.0.0/12 \
-v "$(pwd)/appsettings.Production.json:/app/appsettings.Production.json:ro" \
datatransfer
```
Useful commands:
```bash
docker logs -f datatransfer # follow the log
docker stop datatransfer # stop (ends all connections)
docker start datatransfer # start again
# Update: rebuild with the latest base images and replace the container
docker build --pull -t datatransfer .
docker rm -f datatransfer
docker run -d ... # same command as above
```
### With HTTPS (Docker Compose and Caddy)
Browsers only allow the encryption on HTTPS pages, so put a reverse proxy with a certificate in front of the
container. [Caddy](https://caddyserver.com) fetches Let's Encrypt certificates automatically, forwards WebSockets and
the original `Host` header, and sets `X-Forwarded-For`.
`docker-compose.yml`:
```yaml
services:
datatransfer:
build: .
image: datatransfer
restart: unless-stopped
environment:
AllowedHosts: transfer.example.ch
# Trust Caddy, which reaches the app from Docker's internal network.
ForwardedHeaders__KnownNetworks__0: 172.16.0.0/12
# volumes:
# - ./appsettings.Production.json:/app/appsettings.Production.json:ro
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
```
`Caddyfile`:
```
transfer.example.ch {
reverse_proxy datatransfer:8080
}
```
Start, follow the log and update:
```bash
docker compose up -d --build
docker compose logs -f datatransfer
docker compose build --pull && docker compose up -d
```
For the certificate, the domain must point to your server, and ports 80 and 443 must be reachable from the internet
(port forwarding on your router if you host at home). The app container itself doesn't publish a port; only Caddy is
reachable.
## Deployment checklist
- [ ] HTTPS via a reverse proxy; only ports 80/443 reachable from the internet.
- [ ] `AllowedHosts` set to your domain.
- [ ] The reverse proxy is trusted (`ForwardedHeaders:KnownProxies` or `KnownNetworks`) and forwards the original `Host`
header and WebSockets.
- [ ] The `Legal` section is filled in, and `AccessLogRetentionDays` matches your reverse proxy's access log settings.
- [ ] Rebuild the image regularly (`--pull`) to get security updates of .NET and the base image.
## Troubleshooting
| Symptom | Cause and fix |
|---|---|
| "End-to-end encryption is not available" | The page isn't opened via HTTPS (or `localhost`). |
| Every request returns HTTP 400 | The host name isn't in `AllowedHosts`. |
| Everybody gets "Too many connections from your network" | The reverse proxy isn't trusted, so all visitors share its address. The log shows *"Ignored X-Forwarded-For from … which is not a trusted proxy"*; add the proxy to `ForwardedHeaders:KnownProxies` or `KnownNetworks`. |
| Creating or joining hangs or fails behind a proxy | The proxy doesn't forward WebSockets, or it rewrites the `Host` header (the server rejects connections whose `Origin` doesn't match the host). |
| Log warning *"Failed to determine the https port for redirect"* | Harmless when HTTPS is handled by the reverse proxy. |
| Log warning about Data Protection keys not being persisted (container) | Harmless for this app. Mount a volume at `/home/app/.aspnet` to keep the keys across container updates. |
| Log warning *"…still contain placeholders…"* | Fill in the `Legal` section. |
| Header and footer show, but the transfer area stays empty | `_framework/blazor.web.js` returns 404: the image was built without Blazor's web assets. Keep `-p:RequiresAspNetWebAssets=true` on the `dotnet restore` line in the `Dockerfile` and rebuild. |
## License
DataTransfer is free and open source under the [BSD Zero Clause License (0BSD)](LICENSE): you may use, copy, modify
and distribute it for any purpose, with or without fee, and without any conditions (not even attribution). It comes
without any warranty.