DataTransfer/README.md
2026-10-15 20:09:11 +02:00

16 KiB
Raw Permalink Blame History

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). 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 for building and running locally.
  • 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

# 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

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:

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).

/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

docker build -t datatransfer .

For an arm64 server, such as a Raspberry Pi, or for both architectures at once:

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

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:

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:

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 fetches Let's Encrypt certificates automatically, forwards WebSockets and the original Host header, and sets X-Forwarded-For.

docker-compose.yml:

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:

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): 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.