Encrypting notes
without holding the keys.
One passphrase per vault, a key that the passphrase wraps rather than replaces, a recovery kit with nothing held in escrow — and the structural decision that took encrypted notes out of every index in the app without touching a single index.
Ampersand keeps notes as plain markdown files in a folder you chose, and that folder is usually in iCloud Drive or Dropbox — so “on your own machine” is not the whole story. We wanted per-note encryption that survives sync and a stolen laptop, under one constraint that shaped everything else: it had to work with us holding nothing at all. No account, no escrow, no reset link.
That removes most of the design space. Here is the arrangement that follows, the best decision in it, what it deliberately does not do, and the mistake that cost us most.
- passphrase per vault
- 1passphrase per vaultnot one per note — it wraps a single vault key, and encrypts nothing itself
- of entropy in a recovery code
- ~160 bitsof entropy in a recovery code8 groups of 4 Crockford base32 characters, from the OS random source
- indexes changed to exclude encrypted notes
- 0indexes changed to exclude encrypted notessearch, links, tags and version history already filtered on the extension
One key per vault, and a passphrase that never touches a note
The cryptography is the least interesting part, which is how it should be. Notes are encrypted with age — X25519 key agreement with ChaCha20-Poly1305 authenticated encryption — through a standard implementation. Nothing hand-rolled, and a note mangled on somebody’s sync server fails to decrypt rather than opening as slightly different text.
Each vault has exactly one key, and there are no per-note passwords. Your passphrase does not encrypt any note; it wraps that key, using age’s scrypt passphrase recipient, into a small file beside your notes.
# the key file, beside your notes. it syncs with them.
version # schema version
scryptWorkFactor # how expensive unwrapping is
wrappedIdentity # the vault key -- as an age file whose
# only recipient is a scrypt passphrase
createdAt
recipient # the vault's PUBLIC key (age1...)
# what is not in it, in any form: the vault key itself.
# no passphrase, no key. no key, no notes. us included.
That file is safe to sync and useless on its own, which is the point: it is what lets an encrypted note open on your other Mac, with no server of ours in the middle. Unwrapping is an ordinary decryption whose authentication check fails on a wrong passphrase — so the check is the unwrap, and there is no separate stored hash to attack.
The indirection buys two things direct encryption cannot. Changing the passphrase is instant on a vault of any size — it re-wraps the same key and rewrites one small file, and nothing is re-encrypted, because nothing was encrypted to the passphrase. And a recovery kit becomes just a second wrap, which is the next section entirely.
The decision that paid for itself: exclusion by extension
An encrypted note has to disappear from things: full-text search while the vault is locked, the link graph, the tag index, version history — readable snapshots of a locked note would quietly undo the locking.
The obvious way is a flag: mark the note encrypted, then make every module that walks the vault remember to check it. That spreads a correctness obligation across search, indexing, history, export — and everything written next year by somebody who never read this paragraph.
So the fact lives in the filename instead. Encrypting Ideas.md produces Ideas.md.age — and every content indexer already filtered on the markdown extensions, because that is how each of them found notes at all.
// every content indexer in the app already had this test,
// written long before encryption existed.
ext == "md" || ext == "markdown"
// Ideas.md -> walked, read, indexed
// Ideas.md.age -> not "md". not "markdown". gone.
// search, the link graph, the tag index, version history,
// and the file walk underneath all of them: no changes.
Encrypted notes dropped out of the file walker, vault search, the link graph, the tag index and version history with zero per-module changes. None of those subsystems learned that encryption exists. Only the two places that must genuinely handle it did: the read and write seam, and the sidebar, which draws a lock.
This generalises. Encoding a security property in a name rather than a field makes exclusion the default and inclusion the deliberate act: a module that has never heard of the feature does the safe thing, which is the only kind of safety that survives a codebase growing. What it asks for instead is discipline in the other direction — the code that stages files for version history carries a comment saying not to add the encrypted extension to its allow-list, and a test fails if anybody does. We have relaxed it exactly once, on purpose: while the vault is unlocked, search reads encrypted notes by decrypting them in memory, with nothing plaintext reaching the disk.
A recovery kit with nobody holding it
“We cannot reset your passphrase” is honest, and for a real person on a real Tuesday it is a catastrophe waiting for a date. The usual answer is escrow: keep a copy of something, somewhere, so support can help. We were not willing to, so the recovery kit is the same trick run twice.
Generating a recovery key produces a high-entropy code — roughly 160 bits from the operating system’s random source, grouped for transcription — and wraps the same vault key under it, into a second file. No new cryptography, nothing leaving the machine, no escrow.
Because both files wrap one key, the behaviour falls out rather than being coded. Changing your passphrase does not invalidate a recovery key; generating a fresh code retires the old one, because it replaces the file. Redeeming a code unlocks the vault and forces a new passphrase on the spot — a code dug out of a password manager is not a login, and that reset is gated on a single-use permit only a recovery unlock issues, because setting a passphrase without knowing the old one is, from a distance, an account-takeover primitive.
And the part the app says out loud: lose the passphrase and the code and those notes are gone permanently, including for us. That is “nobody else can read them”, from the other side.
Touch ID, and an entitlement we could not claim
Typing a long passphrase every time is how encryption ends up switched off, so on macOS you can unlock with Touch ID. The passphrase lives in that Mac’s login Keychain, marked device-local and non-syncing, so it never reaches iCloud even though the wrapped key file does. The textbook way to gate such an item on biometrics is an access-control flag enforced by the Secure Enclave. We do not use it, and that is worth stating rather than leaving to look like oversight.
The tradeoff, stated as one
What it does not do
Encryption features attract a kind of vagueness we would rather not join in with. This protects the body of a note at rest. Here is the rest of it.
$ ls -l ~/Notes/Personal
-rw------- 4.1K Therapy notes.md.age
-rw------- 2.8K Salary negotiation.md.age
-rw------- 890B Groceries.md
# the bodies are ciphertext. the names, the sizes, the
# timestamps and the fact that there are exactly two of
# them are not, and will not be.
Filenames, folder names, sizes and timestamps are not encrypted, and will not be without giving up the property the whole app rests on — that your notes are ordinary files any other tool can open. A listing of an encrypted folder is a table of contents.
It is not end-to-end encrypted sharing. There is no way to hand a locked note to somebody else and have them open it with a key of their own. Sharing means decrypting first, and so does asking a cloud AI provider about it — which is why one asks every time it is pointed at a locked note.
A published page is stored readable on a server. Whatever password sits in front of a page you published from a note, it controls who we serve the page to, not whether it could be read. A published copy is a permanent, public, unencrypted copy that has left the boundary, so publishing a locked note confirms first.
The rule we learned the expensive way
The most useful thing we know about this system came from getting it wrong somewhere else. Ampersand can record a meeting and transcribe it on your Mac, and a two-hour recording cannot sit in memory — so audio is buffered to a machine-local folder outside the vault, then deleted once its content exists in a note. That buffer is private, so we encrypted it — with the vault key, because that was the key we already had.
The encryption was fine. The coupling was the bug, and it produced every bad behaviour the feature had: a locked vault could not be recorded into, an idle auto-lock ended a meeting in progress, transcription had to defer and resume on an unlock, and the record button had to be able to raise an unlock prompt — an absurd thing for a record button to do.
The fix was not to soften any of that. It was to seal the buffer with a key belonging to that Mac: one age identity per machine, in the login Keychain, non-syncing, and deliberately with no biometric gate, because it is read on the write path of a live recording and must never prompt mid-meeting. It protects the audio against a copied file, which is the threat a temporary cache has. Recording no longer consults the vault’s lock state, and a test asserts it is never even queried — a positive assertion about an absence being the only kind that survives somebody helpfully re-adding a check.
The rule we now apply everywhere: a machine-local transient cache gets machine-local protection; the vault key protects the vault.
One corollary reads backwards and is worth keeping: on a build with nowhere to store a device key, that buffer is written in the clear, and says so. Encrypting anyway would mean minting a fresh key every launch and producing audio nothing can ever read again — worse than plaintext, because it looks safe.
What we took from it
- Wrap the key; do not encrypt with the passphrase. One indirection turns changing a passphrase into a one-file write, and a recovery kit into the same operation twice.
- Encode the security property in a name, not a flag. Exclusion by extension made every existing index do the right thing without knowing why, and it will do the same for the next one.
- Do not couple a cache to a document key. The encryption is rarely the problem. What you attached it to usually is.
The security model and the technical specifications cover the rest; the time one inline style tag broke everything is a different way to ship something you were confident about.
Some notes are
nobody else’s business.
Ampersand keeps your notes as plain files on your own computer. Lock the handful that need it, with a key we could not read if we wanted to.
Free to start · Mac, Windows & Linux · No account required