Skip to main content

Architecture

Overview

sE2EEnd is a four-tier application: a React SPA served by nginx, a Spring Boot API, Keycloak for authentication, and PostgreSQL as the database. File storage is either local filesystem or S3-compatible.

Encryption model

The encryption key is a random 256-bit value generated in the browser at upload time. It is:

  • Never sent to the server — it lives only in the URL fragment (#key) which browsers do not include in HTTP requests
  • Encoded as base64url in the share link: https://your-domain.com/download/{accessId}#<base64url-key>

The server stores and serves only ciphertext. Even with full database and storage access, an attacker cannot decrypt the files without the URL fragment.

Upload flow

Download flow

Password protection

When a send is password-protected, the password is used server-side to gate the download — the server validates it before returning the ciphertext. The password is not used as or mixed with the encryption key.

Large files

Files of any size (tested at 12 GB) are transferred without ever holding the whole file in memory — neither on the client nor on the server.

Chunked upload

The browser streams the file slice by slice: each ~25 MB plaintext chunk is encrypted independently (IV(12) + ciphertext + GCM tag(16)) and PUT one at a time, with backpressure (a single chunk in flight). Multiple files are zipped on the fly (no compression) into a single stream. The backend stores each chunk, then concatenates them into one ciphertext object on complete.

  • POST /api/v1/files/chunked/init → opens a session
  • PUT /api/v1/files/chunked/{session}/chunk/{i} → stores one encrypted chunk
  • POST /api/v1/files/chunked/{session}/complete → assembles the final object

The plaintext chunk size is recorded on the file metadata so the download can re-frame the stream. The upload size limit (max_upload_size_bytes, see Instance Settings) is enforced incrementally on every chunk, before anything is written to storage.

Streaming download

The recipient never buffers the whole file either:

fetch(...).body → re-chunk → AES-256-GCM decrypt → Service Worker → native download (disk)

A same-origin service worker (/sw.js) intercepts a magic URL and answers with a streamed Response carrying Content-Disposition: attachment, so the browser's native download manager writes to disk at the stream's pace — constant memory, any file size, on Chrome / Firefox / Safari. The page decrypts one chunk at a time and pumps it to the worker over a MessageChannel with backpressure; event.waitUntil keeps the worker alive for the whole transfer.

E2EE preserved

The service worker only ever sees plaintext that was already decrypted in the same browser — nothing extra crosses the network, and the key still never leaves the URL fragment. The worker is same-origin and self-hosted (no third-party iframe).

Browsers without service-worker support fall back to an in-memory download, guarded by a size limit.

Backend

Spring Boot 4 / Java 25, Maven multi-module build.

Key packages:

PackageResponsibility
controllerREST endpoints — SendController, SendDownloadController, ChunkedUploadController, FileController, AdminController, ConfigController
serviceBusiness logic — send lifecycle, chunked upload & assembly, download counting, cleanup scheduling
storageStorage abstraction — LocalFileSystemStorage, S3FileStorage
configSpring Security + CORS (SecurityConfig), JWT conversion, OpenAPI
schedulerCleanupScheduler — cron cleanup of expired/revoked/exhausted sends + stale upload sessions
modelJPA entities — Send, FileMetadata, UploadSession, UploadChunk, DeletedSend, InstanceSetting

Authentication

The backend validates JWT Bearer tokens issued by Keycloak. The KeycloakJwtAuthenticationConverter extracts realm roles from the realm_access.roles claim. The admin role is required for admin endpoints.

Send lifecycle

A send transitions through these states:

All terminal states are eligible for cleanup. The cleanup scheduler (configurable cron, default: nightly at 2AM — see Instance Settings) deletes expired/revoked/exhausted sends and their files, prunes stale/abandoned upload sessions, and records deletions in the DeletedSend audit table.

Frontend

React 19 + Vite 8 + TypeScript 6, served as a static SPA by nginx.

Key design points:

  • Runtime configuration — Keycloak connection details are injected at container startup via config.js (no rebuild needed to change Keycloak URL/realm)
  • API proxy — in the bundled deployment, nginx forwards /api/* to the backend container, so the SPA talks only to its own origin (no CORS). In split deployments (SPA and API on different origins) the backend's CORS config applies instead
  • Streaming download — a same-origin service worker (/sw.js) streams large downloads straight to disk (see Large files)
  • i18nreact-i18next with EN and FR translation files

Runtime config injection

At container startup, docker-entrypoint.sh writes /usr/share/nginx/html/config.js:

window.__config = {
keycloakUrl: "https://auth.your-domain.com",
keycloakRealm: "se2eend",
keycloakClientId: "se2eend-frontend"
};

This file is loaded by index.html before the SPA bundle, allowing Keycloak settings to be changed without rebuilding the image.

Data model

Send
├── id (UUID)
├── accessId (public share id, distinct from id)
├── name (encrypted, nullable)
├── type (FILE | TEXT)
├── ownerId / ownerName / ownerEmail (Keycloak user)
├── passwordProtected + passwordHash (bcrypt, nullable)
├── maxDownloads / downloadCount
├── expiresAt (nullable)
├── revoked (bool) // "expired" / "exhausted" are derived at read time
└── file (one, nullable) // multiple files are zipped into a single object
├── id (UUID)
├── filename (encrypted)
├── storagePath (server-generated UUID)
├── sizeBytes
└── chunkSize (plaintext chunk size — set for chunked uploads)

UploadSession (transient, only during a chunked upload)
├── id (UUID) · send · filename (encrypted) · createdAt
└── chunks[] → UploadChunk (id, chunkIndex, storagePath, sizeBytes)

InstanceSetting
└── key / value pairs (max_upload_size_bytes, require_send_password,
require_auth_for_download, cleanup_cron)

DeletedSend (audit log)
└── id, name, size, reason (expired | revoked | exhausted | manual | user), deletedAt