Encrypt uploads in the browser and send them in chunks

A 6 GB upload kept a customer waiting long after its progress bar
reached 100%. The server wrote every upload three times: PHP's
temporary file, Livewire's copy of it ("Processing files...") and the
encrypted file ("Create Share Link"), each a full rewrite of a slow
disk. The unencrypted copy also stayed behind in livewire-tmp.

Now the uploader's browser encrypts each file in 16 MB chunks with
WebCrypto and PUTs them one at a time; the server checks each chunk in
memory and writes it once, already encrypted. Creating the share only
wraps its key and saves the options. A 200 MB upload through the
Docker image took 2.8 s, and its download matched byte for byte.

- SEALCHK2: a 19-byte header (chunk size, 7-byte nonce prefix), then
  ciphertext and tag per chunk. Each nonce holds the chunk index and a
  last-chunk flag (the STREAM construction), so cut or reordered files
  fail to decrypt. SEALCHK1 and the single-block format still read.
- Envelope encryption: one random key per share. With a password it is
  wrapped with Argon2id (sodium, libsodium's interactive limits) in
  shares.wrapped_key, which names its parameters. Password shares from
  before keep their PBKDF2-derived key.
- The upload page registers each selection with FileUploader into a
  pending share of its own, lists the files with their progress, retries
  a failed chunk after 1-16 s, then offers Retry; Remove and Cancel
  abort. UploadChunkController only accepts chunks from the session that
  started the share: a repeat is acknowledged, a skip gets 409 with the
  count stored. Chunks go out as Blobs, which Chromium sends about eight
  times faster than ArrayBuffers.
- Uploads need a secure context: over plain HTTP the page says HTTPS is
  needed and takes no files. The Docker image gains AUTO_HTTPS, which
  serves Let's Encrypt on 443 for SERVER_NAME and redirects 80; without
  it the container stays on HTTP 80 behind a proxy. docker/Caddyfile was
  never loaded and is gone; docker/healthcheck.sh covers both modes.
- "Download all" streams the ZIP with maennchen/zipstream-php (STORE,
  ZIP64) instead of decrypting whole files into memory and writing the
  archive unencrypted to /tmp.
- Pending shares count towards the quota, stay out of the admin
  dashboard and 404 everywhere else. shares:cleanup deletes uploads idle
  for 4 hours and Livewire temporary files older than that.
- PHP's upload limits no longer cap the admin's max file size and
  default to 64M; LIVEWIRE_MAX_UPLOAD_TIME is gone and
  UPLOAD_CHUNK_SIZE_MB is new.
- Tests cover the format, key wrapping, registration limits, the chunk
  endpoint's answers, completing a share, the streamed ZIP, cleanup,
  and in Chromium a real chunked upload and the HTTPS warning; the
  selected-files overflow test runs again. README, website, CHANGELOG
  and .ai/rules follow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-16 20:49:17 +02:00
co-authored by Claude Opus 5
parent 504971ad7f
commit 40e35bab0e
53 changed files with 2280 additions and 946 deletions
+5 -5
View File
@@ -273,7 +273,7 @@
<article class="feature">
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-deployed-code"/></svg></span>
<h3>One Docker image</h3>
<p>FrankenPHP with Laravel Octane, automatic TLS and SQLite &mdash; no separate database.</p>
<p>FrankenPHP with Laravel Octane, optional automatic TLS and SQLite &mdash; no separate database.</p>
</article>
</div>
</div>
@@ -543,7 +543,7 @@
<h2 class="headline" id="install-title">Running in a few minutes</h2>
<p class="lede">All you need is a server with Docker and a domain name.</p>
<ul class="install__list">
<li><svg class="icon" aria-hidden="true"><use href="#i-check"/></svg><span>The image includes the web server with automatic TLS certificates, and uses SQLite &mdash; no separate database.</span></li>
<li><svg class="icon" aria-hidden="true"><use href="#i-check"/></svg><span>The image includes the web server, with automatic TLS certificates if you want them, and uses SQLite &mdash; no separate database.</span></li>
<li><svg class="icon" aria-hidden="true"><use href="#i-check"/></svg><span>Migrations run on start. Open your domain and the setup wizard creates the first admin account.</span></li>
<li><svg class="icon" aria-hidden="true"><use href="#i-check"/></svg><span>Your files and database live in Docker volumes on your server.</span></li>
</ul>
@@ -558,7 +558,7 @@ cp docker-compose.example.yml docker-compose.yml
<span class="comment"># Generate an app key and paste it into docker-compose.yml</span>
docker run --rm gitea.nonameweb.ch/nonameweb/sealshare:latest php artisan key:generate --show
<span class="comment"># Edit docker-compose.yml — set APP_KEY, APP_URL, and SERVER_NAME</span>
<span class="comment"># Edit docker-compose.yml — set APP_KEY and APP_URL; AUTO_HTTPS and SERVER_NAME for automatic TLS</span>
<span class="comment"># Then start:</span>
docker compose up -d</code></pre>
</div>
@@ -574,8 +574,8 @@ docker compose up -d</code></pre>
<details>
<summary>Is SealShare end-to-end encrypted?<svg class="icon" aria-hidden="true"><use href="#i-expand-more"/></svg></summary>
<div class="faq__answer">
<p>No. SealShare encrypts files on your server as they arrive, with AES-256-GCM in chunks, and stores them only in encrypted form. Because the server does the encrypting, it handles the files in the clear while they are uploaded and downloaded &mdash; which is why it matters that the server is yours.</p>
<p>For a share without a password the key is kept in SealShare's database. With a share password the key is derived from that password and never stored, so the files cannot be decrypted without it.</p>
<p>No. The uploader's browser encrypts each file in chunks with AES-256-GCM before sending it, and SealShare stores the files only in encrypted form. But the key comes from your server, which checks every chunk and decrypts the files again for downloads &mdash; so the server can read them, which is why it matters that the server is yours.</p>
<p>For a share without a password the key is kept in SealShare's database. With a share password the key is locked with that password and never stored as it is, so the files cannot be decrypted without it.</p>
</div>
</details>
<details>