Encrypt the library at rest, behind a passphrase - #135
Merged
Merged
Conversation
An export carried the rows and left the files behind, so a bundle handed over notes whose thumbnails would never load — and nothing said so. It is now a zip: `bundle.json` at the root, one entry per attachment beside it. Base64 inside the JSON was the obvious alternative and was refused. It costs a third more bytes, and the import path holds the file as a String, then a Value, then a Bundle — three copies of every screenshot, which a hundred captures turn into a gigabyte. Entries are pulled one at a time instead. A new DevBox reads an old file: `read` sniffs the zip magic and falls back to JSON, and the picker keeps `json` among the extensions it offers on the way in. `FORMAT_VERSION` is untouched — the container changed, the data shape did not. A record the archive names but does not carry is counted rather than swallowed.
`view` was declared twice in the same test once the source note stopped coming from the file itself. esbuild refused the whole spec file; ESLint did not see it.
The core of encryption at rest, on its own and testable on its own: Argon2id at OWASP's floor, then AES-256-GCM per value with a fresh nonce. 224 ms to derive on this machine, paid once at unlock while the user is still typing — never per value. The parameters travel in the key file rather than living as constants, or raising them later would lock every existing library out. Nothing is wired to the database yet.
The key file sits next to the database rather than inside it: it carries what is needed to derive the key, so it has to be readable before anything opens. The check value is what earns its keep. Without it a wrong passphrase would unlock happily, the library would read as gibberish, and the first write would seal real notes under a key nobody can reproduce. With it, `WrongPassphrase` is a code of its own — which is what the unlock screen acts on, clearing the field rather than banishing the user to a banner. The cost travels in the file, so raising the default later locks nobody out.
…phrase The structural half. `Library` holds the connection and the key together, and `Db` is empty until `unlock_vault` fills it — so `setup` no longer opens anything, and the two startup sweeps move behind the unlock with it. Measured before choosing: 61 signatures take a connection and 531 test call sites pass one. Threading a key through all of them would have buried the feature under a rename. `Library` derefs to the connection instead, so the concrete-typed store functions coerce and the call sites do not move — eight raw Diesel calls in the tests needed `.db()`, because a generic parameter gets no deref coercion. `Locked` and `WrongPassphrase` are codes of their own: the unlock screen acts on the second, and the first only catches a caller that jumped the gate. Nothing is sealed yet — that is the next slice.
Titles, bodies, sources, checklist items, `{{field}}` values, space names and
attachment file names now leave the process sealed. A test greps the database
file for a note it just wrote and does not find it.
What stays in the clear is what SQL works on: ids, instants, `pinned`,
`language`, `kind`, and the tags — the facet and the filter both touch those,
and sealing them would move a corpus-wide scan into Rust for labels like `auth`.
The test asserts that decision rather than leaving it to be discovered.
Space names were the exception worth paying for: they were ordered and compared
in SQL, and both moved to Rust. A library holds a handful of spaces, so it is
cheap — the same move on the notes would not have been, which is the whole
reason the notes are filtered on columns that stay readable.
`_in` is the form a store function takes when someone else already opened the
transaction and holds the two halves apart.
…ssphrase The bytes on disk go the same way as the rows: a pasted screenshot of a credentials page has no business being the one thing left readable beside a database that is not.⚠️ `open_attachment` is the exception, and it is deliberate. Handing a file to the application the desktop chose for it means handing over a readable path, so a decrypted copy goes under a directory of ours in the OS temp folder and is swept at the next launch — it cannot be deleted on close, because the program that opened it still holds it. Replacing the one click with "save as" was the alternative and was turned down. Migration seals the rows in one transaction and the files after it commits: a file write does not roll back, and a file left readable is recoverable where a row sealed twice is not. Neither is idempotent, and neither needs to be — the key file is the guard, and `create` refuses a library that already has one.
The gate sits at the root of the shell, and the outlet is not created while the library is locked rather than merely hidden: the canvas queries notes the moment it mounts, and there would be nothing to answer with. That is what spares every store a "locked" branch. The state lives in Rust, never here — a page reload must not ask again for a library this process already has open, which is also what keeps `reopenSession` working in the end-to-end suite. A refused passphrase is said beside the field that caused it, not through the global banner: it is the ordinary answer to a typo. Too short is said before the round trip, because answering it after 224 ms of derivation reads as the application thinking about it. And the field is cleared as soon as it has been sent, success included.
The library key never leaves the machine, so a file that does would have travelled in the clear. A protected export carries its own salt and recipe beside a sealed bundle, and its attachments are opened under the library key then resealed under the export's. The prompt sits at the root next to the gate: an export starts from the titlebar and a selection export from the canvas header, and a menu that closes would tear down a prompt it owned.
…el down Opening an attachment has to hand a plaintext file to the desktop, so the copies now go at Exit as well as at launch: one another application still holds stays, which is the right outcome, and a crash is what the launch sweep was always for. README gains the section a user needs before typing a passphrase they cannot recover; docs/architecture.md gains the measurements behind the design, what is sealed and what deliberately is not, and the export's own key.
The titlebar renders in front of the gate, and the File menu injects SpacesStore — so a locked launch queried a database nobody had opened and raised a banner over the passphrase field. Every entry acts on the library, so the menu waits for it; the About menu reads nothing of it and stays. The first-launch scenario passes the gate before asking anything of the library, and reports what the banner said rather than that there was one.
The threat is a file read at rest. A sealed value is authenticated on its own and not bound to the row it sits in, and the key lives in memory for the length of the session — both are written down rather than left to be discovered, next to the two figures that had gone stale.
The migration sealed global_placeholders.value and the store did not — so a new library wrote a host name or a token in the clear, and a migrated one read base64 back into the snippet it was filling. The name stays readable, like a tag: it is the key the row is found by. The test that greps the file now asserts both halves of that.
…ctory A decrypted attachment in the OS temporary directory is readable by every account on the machine, and a directory another user created there first would be theirs. app_data_dir()/open/ is inside the profile, and the sweeps reach it exactly as before.
Deriving a key takes about a second. The dialog used to close on submit and come back carrying "wrong passphrase", which reads as a fault rather than as an answer — so it stays, disabled and saying it is working, until the operation either asks again or ends. The gate had the matching slip: a first launch said "Unlocking…" while it was sealing a library for the first time.
The key file held a key derived from the phrase, so changing the phrase meant re-encrypting every note and every attachment — an operation with no atomic form across the database and the files, and a half-readable library if it stopped halfway. It now holds a random library key sealed under the phrase. A change rewrites a hundred bytes: fresh salt, same key, new wrapping. Opening that wrapped key is also the check value, so there is one less thing to attack. The cost is worth knowing and is written down: a changed phrase answers a phrase somebody learned, never a key somebody took. Préférences → Sécurité carries it, and refuses before writing when the current phrase is wrong.
It went through `read`, which parses the whole bundle — a hundred megabytes on a large library — only to throw it away and let the import parse it again a moment later. It needs the recipe entry and nothing else. A file whose payload is unreadable still answers the question, which is what the new test holds.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #26.
The ticket asked whether the database should be encrypted at rest, and against what. The answer is yes, against a file read at rest — a stolen laptop, a copied profile directory, a backup that ended up somewhere it should not have — and this implements it.
The measurement inverted the ticket's own assumption
#26 assumed SQLCipher and worried about the cost. Both halves turned out wrong.
SQLCipher encrypts every 4 KiB page with AES-256-CBC and authenticates it with HMAC-SHA512. On the 8000-note benchmark corpus that is ~112 ms on
query_notes, 88% of it in the HMAC — and it has to be built: the crate wants a vendored OpenSSL, which is a C toolchain in CI on every platform, for every build. The attempt is what burned an afternoon onPERL5LIBand a Git Bash perl with noExtUtils::MakeMaker.Sealing values instead costs ~28 ms on the same corpus — four times less, because it seals what a reader would want rather than every byte the file system moves. The crates are pure Rust (
aes-gcm,argon2,getrandom,zeroize): nobuild.rs, no C, nothing added to a CI build. On a realistic library (~2 MB) it is well under a millisecond.What is sealed, and what is not
Sealed: note titles, bodies and sources, checklist item texts, space names,
{{field}}values and their global defaults, attachment file names — and the attachment files themselves, bytes and all.Not sealed, deliberately: tags, instants, ids,⚠️ Tag names are the visible cost of that line, and the one thing a reader of the raw file learns.
kind,languageand the foreign keys. They are what SQL filters, sorts, groups and joins on, and sealing them would move every query into Rust over the whole corpus.a_note_is_not_readable_in_the_file_it_was_written_tois the only test here that reads the file rather than the API: it writes a note through the store, greps the.sqlite3, and asserts the body, the title, the space name and a global variable's value are absent while a tag is present. Both halves, so the decision is asserted rather than the accident.The key is wrapped, not derived
vault.jsonholds a random library key sealed under the passphrase, not a key derived from it. Argon2id (64 MiB, 3 passes, ~1.2 s) protects the wrapping; AES-256-GCM with a fresh nonce per write protects each value.That is what makes changing the passphrase a hundred bytes of rewriting — fresh salt, same key, new wrapping — instead of re-encrypting the corpus, an operation with no atomic form across the database and the attachment files, which would leave a half-readable library if it stopped halfway. Opening the wrapped key is also the check value, so there is one less thing to attack.
The gate
vault_stateanswersabsent/locked/unlocked;app.config.tsawaits it before the first render andapp.component.htmlputs the unlock screen in front of the outlet.SpacesStore, and creating it queried a database nobody had opened.The export gets a key of its own
The library key never leaves the machine, so a file that does would have travelled in the clear. A protected export carries its own salt and recipe beside a sealed bundle, and its attachments are opened under the library key then resealed under the export's — openable by whoever was given the phrase and by nobody else.
Two holes found while auditing, and closed
global_placeholders.valuewas sealed by the migration and not by the store. A new library wrote a host name or a token in the clear; a migrated one read base64 back into the snippet it was filling.RunEvent::Exitand at every launch.What this does not protect against, also written down
A sealed value is authenticated on its own and not bound to the row it sits in, so someone who can write to the file could move a value between rows and the tag would still verify. And the key is in this process's memory for the length of the session — there is no idle re-lock. Both are in
docs/architecture.mdrather than left to be discovered.Numbers
query_noteson 8000 notesREADME.mdgains the section a user needs before typing a passphrase they cannot recover,docs/architecture.mdgains the measurements and the threat model, andCLAUDE.mdgains the two constraints that will bite the next change.