DEV Community

Cover image for Your file-sharing server shouldn't be able to read your files. Here's how I made sure mine can't.
onokashino
onokashino

Posted on

Your file-sharing server shouldn't be able to read your files. Here's how I made sure mine can't.

Most "encrypted" file sharing means encrypted in transit and at rest, which is a polite way of saying the server holds the keys and can read everything whenever it likes. I wanted the other kind: the server stores my files and genuinely cannot open them. Here's how that works in share·me, including the parts the tutorials quietly skip.

The whole trick is the URL fragment

The browser generates a random key, encrypts the file client-side with AES-256-GCM, and the key goes here:

https://share.example/d/AbC123#k=<base64-key>
Enter fullscreen mode Exit fullscreen mode

Everything after the # never leaves the browser. It's not in the request line, not in the server logs, not in the access logs of whatever proxy you stuck in front. The link carries the key; the server only ever sees ciphertext, filenames included. That's the entire game, and it's almost embarrassingly simple.

Don't want the key in the link at all? Password mode derives it with Argon2id (PBKDF2 fallback). A wrong password just fails server-side authorization, which is rate-limited, so there's no offline brute-force surface to attack.

The part tutorials skip: you can't buffer a 5 GB file in RAM

Every "encrypt a file with WebCrypto" tutorial calls encrypt(wholeFile). Try that with a 5 GB upload and you OOM the tab. Real files have to stream.

So the crypto package uses a segmented AES-256-GCM STREAM construction: the file is split into chunks, each chunk encrypted under an HKDF-derived sub-key with a sequence nonce. Two things that bit me and are worth calling out:

  • Per-chunk nonces have to be deterministic and ordered, or you can't decrypt a stream you didn't buffer. Sequence numbers, not random nonces.
  • AES-GCM is not key-committing. A ciphertext can be crafted to decrypt cleanly under two different keys. For a sharing service that's a real footgun, so there's a key-committing header that binds the ciphertext to exactly one key.

Browser ↔ API streams directly; the Rust service never holds a whole file in memory.

Architecture

Browser (client-side AES-256-GCM)
   │
   ▼
Traefik ──/api/*──► Rust / axum API ──► blobs (disk or S3) + metadata (SQLite/Postgres)
   └─────/*──────► Next.js BFF
Enter fullscreen mode Exit fullscreen mode

One origin via Traefik, so no CORS. A thin Next.js BFF (Server Actions) keeps each drop's owner token in an httpOnly cookie; big blobs bypass it and stream straight to the API. Expiry, download limits, burn-after-reading and time-lock are all enforced server-side, so the client can't lie its way past them.

Running it

git clone https://github.com/onokashino/share-me.git && cd share-me
docker compose up --build   # http://localhost
Enter fullscreen mode Exit fullscreen mode

Set DOMAIN + ACME_EMAIL and Traefik fetches a Let's Encrypt cert itself. Prebuilt images are on ghcr.io. ~1 vCPU / 1 GB RAM runs it.

One honest caveat: there's no third-party audit yet. The crypto is deliberately small and dependency-light so it's actually readable in one sitting, and I'd love eyes on it. It's AGPL-3.0.

If you spot something wrong in the threat model, I want to hear it.

Top comments (0)