Inspiration

I already had an encrypted vault on the desktop. The problem was that my files were never on my desktop. They were on my phone. Photos, scans, receipts, notes taken in a hallway. Every one of them had to make a trip through some cloud that could read them before it could reach the vault that couldn't.

So I set out to build the companion app. I assumed it would be a UI port.

It wasn't. For a zero-knowledge vault, the mobile client is not a view onto your data. It is a second, independent implementation of the cryptography. If it disagrees with the desktop by a single byte, the user doesn't see a rendering glitch. They see a file they can never open again.

That reframing is the whole project.

What it does

Filarr is an end-to-end encrypted vault for your files and your notes, on your phone.

  • Vault: folders, sorting, multi-select batch actions, trash, local search. Import from documents or the camera roll.
  • Notes: a rich text editor, tags, backlinks, and a live knowledge graph you can actually walk around, with tunable forces.
  • Sync: an encrypted manifest, a resumable transfer queue with typed errors, streaming for large files, and a Wi-Fi / cellular policy.
  • Device pairing: a full ECDH P-256 handshake with a 6-digit code. This is the only way a key ever reaches a second device.
  • Viewer: PDF, images, text, Markdown, with screenshot blocking on sensitive surfaces and temp-file purging.
  • Filarr Send: ephemeral share links.
  • Access control: PIN, biometrics, vault password, inactivity auto-lock.

The server stores ciphertext and integers. It has never held a key and cannot derive one. There is no recovery path, by construction, and that is a feature I had to learn to defend rather than apologise for.

How I built it

Expo SDK 54, React Native 0.81, React 19, Expo Router, TypeScript in strict mode. Redux Toolkit for state. react-native-quick-crypto behind a swappable CryptoProvider interface: AES-256-GCM, PBKDF2-SHA-512 at 600,000 rounds, HKDF-SHA256, ECDH P-256. Backend on Cloudflare Workers with D1.

The part I'm proudest of is how I made the two implementations agree.

I generated golden vectors from the desktop reference code and committed them to spec/golden-vectors.json: content blobs in all four historical formats, sync manifest containers, and machine-key metadata containers. The Jest suite replays them on every commit.

But Jest runs under Node's crypto. It proves the algorithm. It does not prove that this phone, with its native provider, produces the same bytes.

So I shipped the proof to the device. Settings → Self-test replays the golden vectors through the real native crypto stack, on the user's actual hardware. It's strictly inert: it never touches vault data. It's in the production build, and anyone can run it.

Around 93,000 lines of TypeScript and 160 test files so far.

Challenges I ran into

Four blob formats. The desktop vault is real software with real history: V0, V1, V2 with zlib, and a V3 chunked container with per-chunk HKDF. The mobile client had to read all four correctly on day one. Not "mostly." A 90%-correct decryptor is a data-loss bug with a slow fuse.

Choosing the worse KDF on purpose. Everything I read said Argon2id. But the desktop wraps its key with PBKDF2-SHA-512 at 600k rounds, and the two must derive the same key from the same password or the vault simply doesn't open. Interoperability beat the benchmark. The lesson: in an existing system, the correct cryptographic choice is often the one already deployed.

The Hermes bytecode trap. My graph physics engine runs inside a WebView, so it has to be injected as text. I did the obvious thing: String(createGraphPhysics). It worked in development and died in release. Hermes ships bytecode, so toString returns function createGraphPhysics() { [bytecode] }, and the 'show source' directive that would fix it gets stripped by terser, which runs before hermesc. Three build artifacts from one source, and only the one I never tested reached users. The engine now lives as a source string, and the tests exercise the exact artifact that ships.

Two writers, one encrypted blob. Desktop and mobile can both sync a profile, and the server can't referee. It can't read anything. So the rules live in the clients: mobile never publishes a notes bundle to a profile a desktop is writing, and never rewrites desktop-owned metadata containers.

Accomplishments I'm proud of

An on-device cryptographic self-test that any user can run to verify their own phone against the format contract. I haven't seen another consumer app ship its conformance suite to production, and for a zero-knowledge product, "trust me" is the one thing you cannot say.

Also: full EN/FR parity at strict equivalence, and a design system with zero hardcoded colors, where every surface reads from tokens.

What I learned

That the hard part of end-to-end encryption isn't the encryption. It's every place two independent implementations have to agree forever: formats, key derivation, chunk boundaries, who is allowed to write what, and what happens when someone loses their only device.

And that a test which doesn't exercise the artifact you actually ship is a story you tell yourself.

What's next

iOS, sharing a vault with another person, and turning the graph into something you can query rather than only look at.

Built With

Share this project:

Updates

Submission history