Blog · 25 August 2026 · 8 min read

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.

What the key file holds
# 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.
Everything in this file is either metadata or ciphertext. The vault key is present only as something a correct passphrase can produce — which is exactly what makes the file safe to leave in iCloud, and useless to whoever ends up with a copy of it.

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.

What wraps what · schematic
The key hierarchy of an encrypted vaultYour passphrase and a generated recovery code are two independent scrypt wraps of one vault key. That single key, an X25519 identity that exists in the clear only in memory, is what encrypts every encrypted note in the vault. Neither the passphrase nor the recovery code ever touches a note.Your passphrasechosen · memorized · never storedRecovery codegenerated once · ~160 bits · written downscrypt wrapscrypt wrapOne vault keyX25519 identity · on disk only as ciphertext, in both files aboveencrypts · ChaCha20-Poly1305Every encrypted note in the vaultIdeas.md.age · Therapy notes.md.age · …
Three consequences fall out of the shape rather than being implemented. Changing the passphrase rewrites only the left-hand wrap, so a vault of any size re-keys in one small file write and nothing is re-encrypted. The recovery wrap is independent, so a code written down months ago still opens the vault afterwards. And there is no third wrap: lose both and the notes are unreadable permanently, including by us.

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.

The filter that was already there
// 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.
The whole of the exclusion, and not one character of it was written for encryption. It was there to find notes — and it kept being right when the meaning of “not a note” changed underneath it.
One gate, and everything was already behind it · schematic
On disk
Ideas.mdIdeas.md.age
The test every indexer already had
ext == "md" || ext == "markdown"
Ideas.md — passes, exactly as it always did.Ideas.md.age — not “md”, not “markdown”. Stops here.
Everything downstream · none of it changed
Vault searchThe link graphThe tag indexVersion historyThe file listing under all of them
A flag would have put the same decision in five places and in whatever is written next year by somebody who never read this page. Putting it in the name meant the decision stayed in the one place it was already being made — so the safe behaviour is what a module gets for knowing nothing about the feature at all.

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

A biometry-gated Keychain item needs a keychain access group entitlement, and a standard Apple Development signing identity cannot claim one without a provisioning profile granting Keychain Sharing. Without the entitlement the write is refused; signing with the entitlement and no matching profile makes macOS terminate the app at launch. So we keep a plain device-local item and gate it with LocalAuthentication instead. The cost is real: the check is the app’s rather than the Secure Enclave’s, and the item is not invalidated when the enrolled fingerprints change. The boundary that protects the notes — the scrypt-wrapped key — is unchanged either way, and on iPhone, where the entitlement is available, we use the stricter flag.

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.

Still readable, forever
$ 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.
Both bodies are unreadable. Neither filename is — and the sizes say roughly how much there is to read, the timestamps say when you last thought about it, and the count says how many of these you keep.

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.

Get started

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