Skip to content

Encrypt the library at rest, behind a passphrase - #135

Merged
vmillet-dev merged 17 commits into
mainfrom
encrypted-at-rest
Sep 16, 2026
Merged

vmillet-dev merged 17 commits into
mainfrom
encrypted-at-rest

Conversation

@vmillet-dev

Copy link
Copy Markdown
Owner

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 on PERL5LIB and a Git Bash perl with no ExtUtils::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): no build.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, kind, language and 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. ⚠️ Tag names are the visible cost of that line, and the one thing a reader of the raw file learns.

a_note_is_not_readable_in_the_file_it_was_written_to is 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.json holds 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 cost is written into the docs: a changed passphrase answers a phrase somebody learned, never a key somebody took.

The gate

vault_state answers absent / locked / unlocked; app.config.ts awaits it before the first render and app.component.html puts the unlock screen in front of the outlet. ⚠️ It does not hide the outlet, it never creates it — which is what keeps every store free of a "locked" branch. The titlebar renders in front of the gate, so the File menu is gated too: it injects 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.

⚠️ An unprotected export is still written, and is still plaintext. Refusing one would break the portability the exchange format exists for. What the interface owes instead is to ask which of the two it is about to write, with the warning beside the button, and to say which one it wrote.

Two holes found while auditing, and closed

  • global_placeholders.value was 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.
  • The decrypted copy an attachment needs to be opened went to the OS temporary directory — readable by every account on the machine, and a directory another user created there first would have been theirs. It now lives in the profile, swept at RunEvent::Exit and 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.md rather than left to be discovered.

Numbers

Rust tests 375
Front tests 1033, coverage 95%
End-to-end 16/16, including a protected round trip and a passphrase change
Argon2id 64 MiB, 3 passes, 1 lane — ~1.2 s
query_notes on 8000 notes +28 ms

README.md gains the section a user needs before typing a passphrase they cannot recover, docs/architecture.md gains the measurements and the threat model, and CLAUDE.md gains the two constraints that will bite the next change.

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.
@vmillet-dev vmillet-dev added this to the v0.2.0 — Solid ground milestone Sep 16, 2026
@vmillet-dev
vmillet-dev merged commit 7eb1a59 into main Sep 16, 2026
11 checks passed
@vmillet-dev
vmillet-dev deleted the encrypted-at-rest branch September 16, 2026 11:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decide whether the database is encrypted at rest, and against what

1 participant