Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d2c7411
Let the attachments travel with the library
vmillet-dev Sep 15, 2026
31dffd0
Rename a shadowed binding in the transfer scenario
vmillet-dev Sep 15, 2026
33acb0b
Derive a key from a passphrase, and seal a value with it
vmillet-dev Sep 15, 2026
eec1ba4
Keep the salt, the cost and a check value beside the library
vmillet-dev Sep 15, 2026
7d69460
Carry the key with the connection, and open the library behind a pass…
vmillet-dev Sep 15, 2026
88b8de6
Seal what a reader would want, and nothing SQL needs
vmillet-dev Sep 15, 2026
565c3b6
Seal the attachment files, and migrate a library that predates the pa…
vmillet-dev Sep 15, 2026
b62c98a
Put an unlock screen in front of the library
vmillet-dev Sep 15, 2026
a08165c
Let an export be sealed with a phrase of its own
vmillet-dev Sep 15, 2026
798ac05
Sweep the decrypted copies on the way out, and write the security mod…
vmillet-dev Sep 15, 2026
49c1a4e
Keep the File menu out of the titlebar until the library is open
vmillet-dev Sep 15, 2026
d77a41d
Say what the encryption protects against, and what it does not
vmillet-dev Sep 15, 2026
a9af220
Seal the values of a variable shared by the whole corpus
vmillet-dev Sep 15, 2026
f2d2f1d
Keep the decrypted copies in the profile, not in the shared temp dire…
vmillet-dev Sep 15, 2026
81ac00c
Leave the prompt on screen while the phrase is being used
vmillet-dev Sep 15, 2026
4a3eda3
Let the passphrase be changed, by wrapping the key instead of being it
vmillet-dev Sep 16, 2026
8addf9a
Answer "is this file protected?" from the recipe alone
vmillet-dev Sep 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ These are the non-obvious constraints; the rest of the architecture is in `docs/
- **A file dropped on the window is a native event, not a DOM one.** The WebView never sees the file, only Rust does, so `FileDropService` wraps `onDragDropEvent`; an HTML `drop` handler would receive nothing. A pasted image is the same idea from the other end: the editor's `paste` reads only the **type**, and `attach_clipboard_image` re-reads the system clipboard natively and encodes the PNG — the bytes never cross the bridge.
- **Deleting a note doesn't delete it.** `notes::store::trash` stamps `notes.deleted_at` — the name is deliberate, `spaces::store::delete` and `attachments::store::delete` erase; `notes::trash::RETENTION` (30 days) decides when it really goes. Every read has to filter on `deleted_at IS NULL` — `fetch`, `find`, both facet queries, the tag counts — or a trashed note comes back editable without saying it's on borrowed time. `purge` is restricted to `deleted_at IS NOT NULL` so nothing short-circuits the reprieve, and `purgeAt` is derived rather than stored (the retention can change between versions). The startup sweep in `lib.rs` and the purge inside `list_trash` are what make the retention hold even if nobody opens the panel.
- **A `{{field}}` is decided in `notes::placeholder`, and only there.** The name is restricted to `[A-Za-z0-9_-]` on purpose: without it a note holding Angular template code (`{{ user.name }}`) would demand a form on every copy. `fill` leaves an unrecognised token untouched — it's part of the text, not of the form — and an empty value falls back to the snippet's default. **The values are kept**, in `note_placeholders` (case-sensitive key, unlike `note_tags`) and written only by `set_placeholder_values`; the _text_ still decides which fields exist, so a value whose token was renamed stays stored but out of sight. The editor's fold-away panel (`placeholder-panel/`), the card's ⚡ and the palette all edit that same single set, through the same rows (`placeholder-fields/`) — an empty input is a suggestion shown as its placeholder, never a typed value, or the day the snippet's default changes the stored copy would win.
- **⚠️ The library is encrypted, and nothing answers until it is unlocked.** `Db = Mutex<Option<Library>>` — the connection _and_ the key — is empty until `unlock_vault` fills it, and a command that runs first answers `StorageError::Locked`. The front end gates on `vault_state` in `app.component.html`: the outlet is not hidden while locked, it is **never created**, 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. Sealed: titles, bodies, sources, item texts, space names, `{{field}}` values, attachment names and attachment bytes. Not sealed, deliberately: tags, instants, ids, `kind`, `language`, foreign keys — what SQL filters, sorts and joins on. `a_note_is_not_readable_in_the_file_it_was_written_to` (`tests/notes.rs`) holds that split against the raw file. Argon2id + AES-256-GCM, pure Rust crates and no `build.rs`: **do not** reach for SQLCipher, it was measured (4× slower) and refused (vendored OpenSSL in CI). Details in `docs/architecture.md` → "Encryption at rest".
- **A passphrase is never held, and never logged.** It arrives owned from the IPC payload and is `zeroize`d before the command returns; the key is a `Zeroizing<[u8; 32]>` whose `Debug` prints `Vault(…)`. ⚠️ The phrase **wraps** the library's key rather than deriving it (`vault.json` holds it sealed, and opening it is the only check there is), which is what makes `change_passphrase` a hundred bytes of rewriting instead of re-encrypting the corpus — and what means a changed phrase answers a leaked phrase, never a leaked key. An export gets a key of its own (`transfer/protect.rs`) rather than the library's, and an unprotected export is still plaintext on purpose — the interface's job is to ask which, and to say which it wrote. ⚠️ `open_attachment` writes a decrypted copy under `app_data_dir()/open/` (the profile, never the shared OS temporary directory), swept at `RunEvent::Exit` and at every launch: the program that opens a document reads it from disk, and one click is the requirement.
- **Attachment bytes are not in the database.** The `attachments` table holds a record; the file lives in `app_data_dir()/attachments/` under a name derived from the record id (`model::stored_name`) — two `capture.png` must not overwrite each other, and a name from outside has no business deciding a write path. They cross the bridge as `data:` URIs one at a time (the CSP forbids a local file, and a URI costs a third more than the file). Write order is load-bearing: copy the file, then insert; a purge collects the file names **before** the `DELETE`, since the cascade takes the records with it.
- **`updated_at` is not touched by what the user didn't aim at a note.** Deleting a space, a global retag, restoring from the trash, filling a `{{field}}`: none of the four refreshes it. The canvas sorts on that column and would float notes nobody reopened to the top — which is also why the field values have a command of their own (`set_placeholder_values`) rather than a `NotePatch` field, the patch path existing precisely to refresh that column.
- **A rename onto an existing tag is a merge**, because the primary key `(note_id, tag)` is `NOCASE`. In `notes::store::retag`, the target is swept along with the sources and rewritten — `INSERT OR IGNORE` alone would make a pure case correction (`auth` → `Auth`) a no-op.
Expand Down
59 changes: 45 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,58 @@ palette, where `Enter` copies and the window steps aside:

![A tour of DevBox: the board of notes, a search narrowing it and quoting the line that matched, a tag filter, a note open in the editor, then the quick-paste palette asking a snippet for its fields](docs/quick-paste.gif)

Everything stays on your machine, in a SQLite file you can copy. Nothing is uploaded, there
is no account, and the application works with the network off.
Everything stays on your machine, encrypted with a passphrase you choose and type once at
launch. Nothing is uploaded, there is no account, and the application works with the network
off.

## What it does

| Feature | What it gives you |
| -------------------- | ------------------------------------------------------------------------------------------------- |
| **Quick paste** | `Ctrl+Alt+P` from any application: search a snippet, `Enter` copies it and the window steps aside |
| **`{{fields}}`** | `psql -h {{host}} -p {{port=5432}}` asks for its values before landing in the clipboard |
| **Keyboard canvas** | arrows to move, `Enter` to open, `C` to copy, `P` to pin, `X` to select, `Del` to trash |
| **Todo lists** | a second kind of note: an ordered, tickable list instead of a body |
| **Trash** | deleting is undoable, and reversible for 30 days |
| **Bulk actions** | select several notes, then move, tag, export or trash them in one go |
| **Tag management** | rename, merge or drop a tag across the whole library |
| **Attachments** | drop a file on the editor or paste an image; open it, save it elsewhere, preview it inline |
| **Import / export** | a JSON bundle both ways — everything, one space, or the selection — with a report either way |
| **Copy as Markdown** | the selection rendered for a pull request, a ticket or a chat message |
| Feature | What it gives you |
| --------------------- | ------------------------------------------------------------------------------------------------- |
| **Quick paste** | `Ctrl+Alt+P` from any application: search a snippet, `Enter` copies it and the window steps aside |
| **`{{fields}}`** | `psql -h {{host}} -p {{port=5432}}` asks for its values before landing in the clipboard |
| **Keyboard canvas** | arrows to move, `Enter` to open, `C` to copy, `P` to pin, `X` to select, `Del` to trash |
| **Todo lists** | a second kind of note: an ordered, tickable list instead of a body |
| **Trash** | deleting is undoable, and reversible for 30 days |
| **Bulk actions** | select several notes, then move, tag, export or trash them in one go |
| **Tag management** | rename, merge or drop a tag across the whole library |
| **Attachments** | drop a file on the editor or paste an image; open it, save it elsewhere, preview it inline |
| **Import / export** | a `.devbox` archive both ways, attachments included — everything, one space, or the selection |
| **Copy as Markdown** | the selection rendered for a pull request, a ticket or a chat message |
| **Encrypted at rest** | one passphrase at launch; notes and attachments sealed on disk, exports optionally too |

Syntax highlighting covers eighteen languages, the interface is available in French and
English, and it ships with a light and a dark theme.

## Your library is encrypted

DevBox asks for a passphrase the first time it runs, and once at every launch after that.
It is what opens the library, and it is never stored anywhere — not in a keychain, not
behind a "remember me". While the application runs the key lives in memory and nowhere
else.

What is sealed on disk: note titles, bodies and sources, checklist items, space names,
`{{field}}` values, attachment file names, and the attachment files themselves.

| What | How |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Key derivation** | Argon2id, 64 MiB and 3 passes, over a random salt kept beside the database. The passphrase seals the library key rather than being it, so you can change it from Preferences → Security without re-encrypting anything |
| **Encryption** | AES-256-GCM, a fresh nonce per write; the authentication tag refuses a tampered value rather than decrypting it into nonsense |
| **Not sealed** | tags, dates, ids and the links between rows — what the database filters, sorts and joins on. Sealing them would mean loading the whole library to answer a query, and tag names are the visible cost of that trade |

⚠️ **There is no recovery.** No account, no escrow, no reset: a lost passphrase is a lost
library. An export written in the clear is the only copy that does not depend on it.

⚠️ **Opening an attachment** writes a decrypted copy — inside your own profile, never the
shared temporary folder — because the program that opens it reads from disk. DevBox deletes
those copies when it quits, and sweeps whatever survived — a file another application still
held, a crash — at the next launch.

An export is the one file meant to leave the machine, so it is offered a key of its own:
give it a passphrase and it travels sealed, attachments included, or write it in the clear
for a file any DevBox can read. The application asks which, every time, and says which one
it wrote.

## Install

DevBox runs on **Windows and Linux**. There is no macOS build: it cannot be tested here,
Expand Down
Loading