← All posts

The two-week encryption rework.

Two weeks ago I ruled out a design that would have let our own server hold your key, even briefly, and started rebuilding around one that never lets it. Here's what that actually took, told with the bugs left in.

The goal was simple to say and hard to earn: build Neeos so that we genuinely don't hold a key that can unlock your stored data — not "we promise not to look," but the app physically not having the means. Two weeks later, that's real for two accounts, in progress for the rest, and I'd rather tell the whole thing honestly than round it up to "done."

The decision that started it

There were two ways to build this. One lets the server hold your decryption key for the length of a single request — long enough to answer a question, then gone. The other never lets the server hold it at all; your device does every bit of decrypting, and the server works entirely with ciphertext it cannot open. The first is easier to build and was explicitly ruled out, in writing, before a line of it shipped: a server that holds a key for even one request is a server that can read your entire account for as long as that key sits in memory, and calling that "end-to-end encrypted" would have been a lie the moment anyone checked. I approved the harder one — no key on the server, ever — on August 10th. Everything below is what that decision actually cost.

Four phases, and what each one really was

Phase 1 was just the keys: generating them on-device, wrapping one copy in the phone's own Secure Enclave, syncing an encrypted copy through iCloud Keychain, cutting a recovery key — and proving the server genuinely had no path to any of it before anything else was allowed to depend on that being true.

Phase 2 was the migration itself: turning the columns that hold your actual content into ones that hold sealed envelopes instead, moving real accounts across without losing anything mid-flight, and sealing files in S3 before they ever reach us — not after. The migration had to survive being interrupted, so it batches, checkpoints, and resumes rather than trusting one long run to finish clean. It didn't, the first several times I tried it.

Phase 3 was the part I expected to be a compromise and wasn't: moving the semantic index itself — the thing that makes Neeos able to answer "how is my dad doing" from a note that never used the word "dad" — off the server and onto your device entirely. I assumed on-device retrieval would be the private option and the server's version would stay the accurate one. When I actually benchmarked them against each other, the on-device version won on accuracy too. That wasn't the result I was bracing for.

The fourth phase is ongoing, and it's not a phase so much as a discipline: auditing every single place in the app that writes data, one at a time, and asking whether it could still put something readable where a sealed envelope belongs. That part doesn't end on a schedule. It ends when the count hits zero and stays there.

What actually went wrong

A schema meant to reject malformed input started rejecting correctly sealed values instead, and emptied the note being saved when it did. A brand-new signup — someone who had never touched Neeos before — could get through account creation and have their very first session written to the database unencrypted, because a failed encryption check was silently disabling the exact gate meant to catch that. A local-file indexer stopped working the moment an account sealed, because it needed to send a value it had quietly stopped sending. One audit pass, checked properly instead of from memory, found eighty- eight write paths that hadn't been covered by the pass before it.

And the one that mattered most, because it was sitting in plain sight the whole time: your question gets sealed on your device before it's ever sent — that part was solid early. The reply can't work the same way, because at the moment your question leaves your device, the answer doesn't exist yet. Nobody has both the words and a key at the same instant. So the server writes the reply in readable, and a process on your device sweeps in afterward and replaces it with a sealed one — on your next launch, when the app comes forward, or right as that turn finishes. That shortens the exposure. It doesn't remove it. And on a Mac used by itself, with no phone or iPad ever opening the app, that sweep never runs at all — so a Mac-only account's replies stay unsealed indefinitely, not briefly. That one isn't fixed yet, and I'd rather say so here than let it go unmentioned.

Update, August 15: this one's closed. The Mac now seals its own replies instead of waiting on a phone that might not exist for that account — verified running, not just shipped: the sweep fires at the right moments and the server's own count of what's left unsealed reads zero. One thing this does not cover, so I don't repeat the mistake of saying more than what's true: the sealed inbox — the path wearable data like WHOOP and Oura come in through — is a different mechanism, and it's still phone-only. A Mac-only account's replies are sealed; its wearable data still waits for a phone. Two different gaps, and only one of them just closed.

Where it honestly stands

Two real accounts have actually migrated — mine, and a second real person's, the same week this was written. Everyone else's content still sits in the new, sealed-shaped columns without actually being sealed yet, which is a real and meaningful difference, not a technicality. Cross-account isolation got checked against this new schema for the first time rather than assumed to still hold from before. The vault's file storage finished the prerequisite work it needed before sealing was even possible, and turned up code comments along the way that had been quietly wrong about the state of things. The reply window above is closed now; the sealed inbox's Mac gap, noted above, is not.

None of that is "encryption is done," and I'm not going to write the post that says it is. What two weeks bought is a real architecture, running on real accounts, with a precise, current account of what's still open — and that last part is exactly what the architecture page is for. This post is the two weeks; that page is today, and I'd rather it stay more current than any blog post can.

Small note, since a few people have asked: this blog has an RSS feed — /blog/feed.xml — if you'd rather follow it that way than check back by hand.

Older: A page about me, and a roadmap that stopped hedging →