Encrypting everything in a notes app, and keeping sharing: the Nuo 2.0 key design
How a Next.js + Postgres notes app went from "one encrypted folder" to end-to-end encryption
for every row, migrated existing users without downtime, and kept shared folders working. The
product-side story is on Medium.
Nuo is a notes and tasks app: block editor, My Day, folders, sharing, Android and web, one
person building it. Until this release it had one end-to-end encrypted folder per user, opt
in, and everything else was plaintext in Postgres. Version 2.0 encrypts every note, task and
folder name for every account, with sharing intact. This is the design, the migration, and
the parts that bit.
The constraints
- Mandatory. No plaintext tier, no opt-in. The copy "we can't read your notes" has to be true for every account on day one.
- The server still has work to do. Home, My Day, High priority and reminders are server queries. They need dates, priorities and completion. Only the words have to be hidden.
- Sharing survives. A folder shared with someone must stay readable by them and unreadable by the server, and un-sharing has to mean something.
- Existing users migrate themselves, because only their device can read their key, and the app has to stay usable while it happens.
- One person, no budget. WebCrypto only, nothing paid.
Key hierarchy
Three levels, all AES-256-GCM, all WebCrypto.
Master key. 32 random bytes, generated at set-up, held in the browser as a
non-extractable CryptoKey. Three sealed copies live on the server, each useless without
something the server doesn't have:
- under a wrap key derived from the Nuo password: PBKDF2-SHA256, 600,000 iterations, salted
with
SHA-256("nuo-login-v1:" + email), 64 bytes out. Bytes 0 to 31 are the wrap key and never leave the browser. Bytes 32 to 63, hex, are the login secret, which is what the auth provider receives as the password. One password, two independent uses, and the auth server never sees the real one. - under a random 32-byte recovery code that goes out in an email as a URL fragment. The server stores the sealed copy and forgets the code.
- optionally, under a key derived (HKDF) from a passkey's PRF output, for fingerprint unlock.
The 24 recovery words are BIP-39 over the master key bytes themselves. No fourth copy.
Folder keys. One random AES key per top-level folder, sealed under the owner's master key
in a folder_keys table. The master key is non-extractable with encrypt/decrypt usages, so a
folder key is bytes the master key encrypts, not a WebCrypto wrapKey; unwrapping is decrypt
then importKey as non-extractable. They're cached in memory for the session and dropped on
lock.
Rows. Every task, note, list and folder has a ciphertext + iv pair sealed under its
folder's key, with a payload_version column saying what shape is inside. The blob holds the
words: title, notes, subtasks, page content, folder name. Plain columns hold everything the
server queries: due date, priority, completion, My Day, pinned, sort order, parents.
Measured on a 2026 laptop: PBKDF2 about 108 ms; unwrapping a key 0.2 ms; decrypting a task
0.1 ms, five hundred of them 7 ms; a 640 KB note 1.5 ms. Phones are three to five times
slower. The network dominates everything except the password stretch.
Why metadata lives in columns
The old secure folder put everything in the blob, dates included. It was pure, and it
meant the server couldn't put a secure task on Home or send a reminder. With encryption
mandatory that would have broken the app's main screens for everyone.
So the first migration step moved metadata out of the blob into plain columns. That is a
deliberate, bounded leak: the server knows when things are due and whether they're done,
not what they are. The privacy policy lists it in those words. If you need a notes app where
the server doesn't know you have anything due on Friday, Nuo isn't it.
The migration, run by the users
The server can't convert a plaintext row into an encrypted one, because it would need the
key. So the conversion is a client-side background job:
- On the next sign-in after the release, the user goes through set-up: password, recovery email, 24 words. (Everyone is signed out once by a script that revokes all sessions.)
- The app opens, and a job runs while it's unlocked. For each plain folder it creates a folder key, seals the folder's name, and flips the folder to encrypted. Then it fetches the folder's plain rows in batches of 100 (notes in batches of 20, since bodies run to a megabyte), seals each one, and writes them back with the words scrubbed from the plain columns.
- Every write is conditional on the row still being plain (
WHERE is_secure = false), so two devices running the job at once share the work instead of clobbering it, and a crash mid-way just resumes next time. The unmigrated rows are the queue; there's no job table. - The folder flips before its rows, so from that moment every new write into it is encrypted. The window in which a plaintext write can still happen is one folder at a time, not the whole account.
Readers handle both shapes until the last plain row is gone, which means the server-side list
queries say is_secure = false OR payload_version = 2 instead of filtering plaintext.
On a test account with 19 folders, 99 tasks, 43 notes and 49 inner folders the job took four
seconds against a database in another city. The one real bug: I first wrapped each batch in a
single interactive Prisma transaction, and 100 round trips blew its 5-second limit. Since each
write guards its own row, the transaction bought nothing; the writes now run through a small
concurrent pool.
Moving things between folders
A row sealed under folder A's key that gets moved into folder B is now in the wrong folder
for its key. The textbook answer, "re-encrypt in the move request", fails on size: moving a
list of three hundred notes would mean re-uploading a few hundred megabytes in one request.
Instead, rows remember which key seals them. A key_group_id column, kept by a Postgres
trigger, is stamped with the source folder whenever group_id changes without a new blob,
and cleared whenever a new blob lands. The key resolver reads key_group_id ?? group_id. A
background re-seal pass then rewrites moved rows under their new folder's key, so the state
is short-lived; meanwhile they still open, because folder keys are never deleted while a
folder exists. The trigger covers every path that moves a row, including the ones that
cascade through other triggers, which is why it isn't done in application code.
Sharing without a readable server
Sharing is key sharing. Three mechanisms, all client-side:
Invite links. The owner's device generates a random 32-byte secret, seals the folder key
under it, and stores that sealed copy beside the share token. The link is
/join/<token>#<secret>. The fragment never reaches the server. The joiner's device fetches
the sealed copy by token, opens it with the fragment, re-seals the key under their own master
key, and stores that copy together with their membership in one transaction. From then on
they read and write the folder like the owner. The join card can even show the folder's
decrypted name before you join, because the key is already in hand.
Grants. Someone who was already in a folder when it became encrypted has no link to open.
For that, every account has an ECDH P-256 keypair: public key in plain, private key sealed
under the master key. The owner's device lists members who lack the folder's current key,
and for each one does ECIES: an ephemeral keypair, a shared secret with the member's public
key, HKDF to an AES key, the folder key sealed under it, stored with the ephemeral public key
beside it. The member's device opens it with their private key and immediately replaces it
with a copy under their own master key, so the private key is used once per grant.
Rotation. Folder keys are versioned. Removing a member or leaving a folder deletes that
person's copies, clears the invite link (they might hold it), and flags the folder. The
owner's device then makes version N+1, re-seals every row and the folder's name under it,
grants N+1 to the people still in, and deletes the old versions. Every row carries
key_version, every sealed write reports which version it used, and readers hold several
versions of a folder's key during the rotation. What the removed person already fetched, they
keep; the Remove button says so. What's written afterwards, they can't open.
The thing that bit here: three database selects on the folder screen returned every column
except key_version. The client read the missing value as 1, and after a rotation version 1
no longer existed, so the owner's own folder said "waiting for a key". Every select that
returns an encrypted row now carries all four fields the resolver needs, and a test pins it.
What had to go
Server-side search, because there's nothing to search. Google Calendar sync, because it
handed titles to a third party. Image uploads, rather than building encrypted file storage
on a free plan. Push notifications carry "a task is due" and nothing else. The server-rendered
Trash became client-rendered, decrypting names like any other list, and finally got a 30-day
purge. Even the example folder a new account starts with is now written by the client,
encrypted, because the server can't write a readable note any more.
What's next
Phase 6 is cleanup: once a query shows every active account migrated, the plaintext columns
and the dual-read paths go, and is_secure with them. After that: approving a new device
from an old one instead of typing the password, and a client-side search over decrypted
titles.
Nuo is free, on Android and at nuo-app.vercel.app. The design doc and the per-phase plans are
in the repo under docs/, if you want the version with the decisions numbered.
Top comments (0)