← HighlightsProjects · 2026-09-30

Case study: cairn

A persistent, end-to-end encrypted personal file store — the third shape next to flit and wisp, built around chunked streaming crypto and a server that can't read its own filenames.

View the projectcairn →

Context

There were already two answers to moving a file between my own devices. flit is realtime: both devices online, direct WebRTC, nothing ever touches a server disk. wisp is asynchronous and single-shot: upload once, retrieve once, then it’s gone. Both are good at what they do, and both are structurally the wrong tool for the most common version of the need: save this somewhere, get at it from another device next month, keep it.

In practice that gap was being filled by emailing files to myself, wisp links I had to remember not to let expire, and USB sticks. A full Nextcloud instance would fix it, but that’s a lot of machinery for what is fundamentally a personal filing cabinet. So the brief was the part of Dropbox that’s actually load-bearing for one person — persistent encrypted storage with folders — and nothing else.

I wrote the design up as a proposal first, then built v1 against it.

Problem

Build a store that:

  • Holds many files across many devices with no expiry
  • Can’t read what it’s holding — not as policy, as capability. That includes filenames and folder structure, not just file contents
  • Handles files much larger than wisp is designed for, without choking a browser tab
  • Doesn’t make me retype a passphrase every session, without that convenience quietly undoing the encryption
  • Deploys as one container and one small database, like everything else in this family

The interesting constraint was the third one, and it shaped the most important decision in the project.

Approach

1) Chunked from the start, not whole-blob

wisp and flit both encrypt whole files in memory. That’s reasonable for a one-off share, and wrong for a store that might hold a video. The tempting path is to reuse the whole-blob format, ship it, and “add chunking later.” The problem is that later means a data migration: every file already in the vault is in the old format, and the store is designed to be kept forever.

So cairn uses libsodium’s crypto_secretstream (XChaCha20-Poly1305) from day one, in roughly 4MB frames. That gives real streaming AEAD framing with per-chunk tamper detection rather than a hand-rolled chunk-and-concatenate scheme, which is the kind of thing that looks fine until someone reorders or drops a chunk.

The part I like is the final tag. The last frame is sealed with TAG_FINAL, so a download that was cut short — dropped connection, a proxy giving up — ends on an ordinary message frame and the decrypt side refuses it (stream ended before final tag). Truncation is a detected error rather than a file that opens fine and is silently missing its last third. The last frame can also be empty, which is what a zero-byte file or an exact multiple of the chunk size produces, so that case has to be handled deliberately instead of falling out of a loop by accident.

Encryption is an async generator: it yields the stream header, then one ciphertext frame per chunk, pulling from file.stream() as it goes. Nothing in the crypto step ever holds the whole plaintext or the whole ciphertext.

2) Two secrets, for two different jobs

Unlocking takes an access password and a vault passphrase, and they do different things.

The access password is the front door. The server checks it (timing-safe comparison, X-Vault-Password header) on every route, including the salt endpoint — the salt isn’t secret, but there’s no reason to let an unauthenticated caller confirm a vault exists at this URL. The server refuses to boot without one set, rather than starting open.

The vault passphrase never leaves the browser. It’s run through Argon2id with a server-persisted salt to produce a 256-bit vault key. The consequence is worth stating plainly: knowing the access password gets you past the door but not into the files, and knowing the passphrase without the access password doesn’t even let you fetch the ciphertext to try. Two independent failures are required, which is the point of keeping them separate rather than reusing one password for both.

The salt is generated once on first boot and persisted next to the database, because a salt that changes derives a different key from the same passphrase and locks you out of everything already uploaded. Better that the app owns that than that I’m trusted to invent one and never lose it. Argon2id is tuned to “moderate” rather than “sensitive”: it runs on every unlock including on phones, and the key is only as strong as the passphrase anyway.

One dependency wrinkle: the standard libsodium-wrappers build leaves out crypto_pwhash to save bundle size, so cairn uses the “sumo” build. flit and waste-go don’t need Argon2id, so that’s a deliberate divergence, not drift.

3) The server doesn’t know filenames

Per-file metadata — the full path and mimetype — is sealed in an encrypted envelope (crypto_secretbox, random nonce, vault key). The files table has an id, that opaque envelope, a byte count and two timestamps. There is no filename column, no path column, no mimetype column. The same trick wisp uses, extended from “the filename” to “your entire folder structure.”

This makes folders an interesting problem, because the server can’t know they exist. They’re virtual: a file’s decrypted path is /receipts/2026/q3.pdf, and the browser groups paths by common prefix to draw the tree. “Create folder” is free until a file lands in it. The cost is that the client decrypts every envelope on each listing, which is nothing at personal scale (hundreds to low thousands of files) and would need revisiting well beyond it.

Reusing the vault key for the envelope is fine here, unlike wisp’s per-drop random key: there’s nobody to hand this key to.

4) Remembering the key without undoing the point

Retyping an Argon2id passphrase on every tab open gets old fast, so after the first unlock the vault key is cached per device. A fresh AES-GCM wrapping key is generated with extractable: false, used to wrap the vault key, and both the wrapping key handle and the wrapped bytes go into IndexedDB. CryptoKey objects are structured-cloneable, so the non-extractable handle can be stored directly.

What that buys is specific and worth being honest about: page script can ask the browser to decrypt with that key but can never read its raw bytes out, so an XSS bug can use the key while the tab is open but can’t exfiltrate a reusable copy. It is not protection against someone with full access to an unlocked browser profile — they can do what the page does. “Lock” deletes the record and the access password with it, and a cache that can no longer be decrypted (cleared key storage, say) is treated as simply not being there and sends you back to the unlock screen.

The access password lives in sessionStorage instead, so it dies with the tab. Adding a new device is just typing both secrets again — the deliberate v1 answer, since it works whether or not the new device is anywhere near an existing one. QR-style pairing from flit is left as a possible convenience layer rather than a dependency.

5) Storage, split by how it’s backed up

Same shape as wisp, minus the expiry logic: SQLite (WAL mode) for metadata, one blob file per upload on local disk named by a random id. Uploads are size-capped (5GB default) twice — a Content-Length check up front, and a running byte count while streaming, since a client can lie about the former. An over-limit or failed write removes its partial file and never inserts a row, so there’s no half-uploaded entry for the listing to trip over.

The late change I’m happiest with is splitting blobs into their own mount (BLOB_DIR). The SQLite database and the salt are tiny and the two things you absolutely cannot lose — losing the salt means losing access to everything — so they want frequent backup. The ciphertext is large and cheap to lose-and-reupload in comparison, so it wants bulk storage. One volume would force one policy onto both. With two, either can be swapped for a bind mount independently, and BLOB_DIR falls back to a subdirectory of the data dir if unset so the single-mount setup still works. It’s also as much of an S3-shaped escape hatch as I wanted to build before needing one.

6) Shipping it

Multi-stage Docker build: Vite client, TypeScript server, then a lean runtime. Same better-sqlite3 story as wisp — no prebuilt binary for this platform and Node combination, so the toolchain is installed, the addon is rebuilt, and the compiler is purged in the same layer. Gitea Actions builds and pushes on every push to main.

It isn’t deployed anywhere yet. The image, compose file and CI pipeline exist and the app runs locally, but it hasn’t been exercised on a real host, and I want a proper one (with real storage behind BLOB_DIR) before I trust it with anything I care about. Everything here describes a built v1, not something that’s been lived with — the proposal’s own test, that the passphrase and key-caching flow holds up across two real devices, is still ahead of it.

What I’d do differently

The v1 code falls short of its own design in one place worth naming. The proposal’s principle is that nothing buffers a whole file, and that’s only true of the crypto step. Uploads collect the encrypted chunks into a Blob before sending, and downloads decrypt into a Blob before saving. I tried a genuinely streaming request body first, but that needs HTTP/2, which fails over the plain HTTP/1.1 dev proxy and isn’t guaranteed in every self-hosted setup — and worse, an async generator can’t be replayed, so a try-then-fall-back approach silently uploaded truncated ciphertext. I chose “correct but memory-hungry” over “streaming but sometimes wrong.” It’s fine for documents and photos and will hurt on multi-gigabyte files. The real fix is a chunked upload protocol that doesn’t depend on streaming request bodies, and on the download side streaming straight into a File System Access writable instead of assembling a Blob first.

Other gaps are deliberate but real. Rename and move aren’t built yet (a metadata rewrite, blob untouched — the data model already allows it, and a folder move will mean re-sealing every envelope under it). There’s no OPFS cache, no thumbnails, and no conflict handling: last write wins, and “sync” is a refresh. Uploads aren’t resumable. And the access password is a single static credential, the same limitation wisp has before its scoped invites.

Stack

Client: TypeScript, Vite, libsodium-wrappers-sumo (Argon2id and secretstream), WebCrypto and IndexedDB for key caching — no framework, a two-screen app.

Server: Node.js, Express, better-sqlite3 (WAL mode), streamed blob writes to local disk — no external services.

Infrastructure: Docker (multi-stage build), separate named volumes for metadata and ciphertext, Gitea Actions → private registry → docker-compose.

View the projectcairn →
Contents