diff --git a/CLAUDE.md b/CLAUDE.md index e63e7a7..28282dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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>` — 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. diff --git a/README.md b/README.md index c81c070..d322f3a 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/docs/architecture.md b/docs/architecture.md index 096eb69..8c8cc71 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1139,15 +1139,51 @@ about samples nobody asked for would only add noise. ### Import, export and copying out -- **Export** writes a JSON bundle (`transfer::model::Bundle`: a version, an instant, the - spaces cited and the notes), for everything, one space, or the current selection. The format - reuses the domain types rather than duplicating them, so a field added to `Note` is exported - without anyone thinking about it. Only the spaces actually cited travel: exporting one space - should not recreate a whole tree on the other side. +- **Export writes an archive**, `.devbox`, which is a zip: `bundle.json` at the root + (`transfer::model::Bundle` — a version, an instant, the spaces cited, the notes and the + attachment records) and one entry per attachment under `attachments/`, named by its + `stored_name`. The bundle is deflated, being repetitive text; the attachments are stored as + they are, a PNG being compressed already. The format reuses the domain types rather than + duplicating them, so a field added to `Note` is exported without anyone thinking about it. + Only the spaces actually cited travel: exporting one space should not recreate a whole tree + on the other side. +- **⚠️ An export can be sealed, and with a key of its own.** The library key is derived + from the passphrase typed at launch and never leaves the machine; an export is the one + file meant to reach another one, so `transfer/protect.rs` derives a second key from a + phrase the user gives that file, and writes the recipe (version, algorithm, cost, salt) + in the clear beside the payload — a salt is not a secret, and a reader has to know how + to derive before it can ask anything else. The sealed `bundle.sealed` **replaces** + `bundle.json` rather than sitting beside it, or a reader that could not open it would + quietly fall back on a bundle in the clear. Attachments are opened under the library key + and resealed under the export’s, so the file is 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 the user + instead is to ask which of the two it is about to write (`PassphrasePromptComponent`, + with the warning beside the button) and to say which one it wrote — `ExportReport.protected` + picks the message. On the way in, `export_is_protected` is what lets the prompt appear + before the import starts rather than as a failure after it; a refused phrase asks again + rather than failing, since it is the ordinary answer to a typo. +- **⚠️ 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 `serde_json::Value`, + then a `Bundle` — three copies of every screenshot in memory, which a library of a hundred + captures turns into a gigabyte. `file::Payload` hands entries over one at a time instead. +- **⚠️ A new DevBox reads an old file; an old DevBox does not read a new one.** `file::read` + sniffs the zip magic and falls back to parsing the whole file as JSON, so every `.json` + export written before the archive still imports. The picker keeps `json` among its + extensions on the way in for exactly that reason, and offers only `devbox` on the way out. + `FORMAT_VERSION` is untouched: the container changed, the data shape did not. - **Import merges, it never replaces.** Spaces are matched by name, case-insensitively, and a note whose id is already taken is counted as skipped rather than overwritten — so the same file can be imported twice without duplicating anything. A bundle from a newer format version is refused outright rather than half-read. +- **An attachment comes back only with a note that actually arrived.** One belonging to a + skipped note is already in the library. ⚠️ The file is written **before** the record, the + rule `attachments.rs` already holds — a record without a file is a broken thumbnail, where + a file without a record is swept at the next startup, which is also what collects these if + the transaction rolls back. A record the archive names but does not carry is counted in + `attachments_missing` rather than swallowed: the note arrives with a preview that will stay + empty, and the report is the only thing that explains it. - **Copying out stops at the clipboard.** `share_notes` renders the selection as Markdown (heading, space, context, tags, then a fenced block). The fence is longer than the longest run of backticks in the content, otherwise a note that already contains a Markdown block @@ -1574,6 +1610,124 @@ whitespace, so `"Personal "` would otherwise sit beside `"Personal"`, identical `NotesQuery.now` as a parseable instant — falling back to the server clock would silently re-cut every section on a different day. +## Encryption at rest + +The library is sealed with a key derived from a passphrase typed once per launch. Nothing +is kept of it: no keychain, no "remember me", no recovery — the semantics of a KeePass file, +and the same consequence. + +### Why not SQLCipher + +Measured rather than assumed. SQLCipher encrypts every 4 KiB page with AES-256-CBC and +authenticates it with HMAC-SHA512; on the 8000-note benchmark corpus that costs ~112 ms on +`query_notes`, 88% of it in the HMAC. It also has to be built — the crate wants a vendored +OpenSSL, which means a C toolchain in CI on every platform, for every build. + +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) the sealing costs 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` (`tests/notes.rs`) greps a freshly +written database and asserts exactly that split — the only test here that reads the file +rather than the API. + +### What this protects against, and what it does not + +The threat is a file read at rest: a stolen laptop, a copied profile directory, a backup +that ended up somewhere it should not have. Against that, a value is unreadable and a +tampered one is refused rather than decrypted into nonsense. + +⚠️ It is **not** a defence against someone who can write to the file while you are away. +A sealed value is authenticated on its own and not bound to the row it sits in — no +associated data — so a value could be moved from one row to another and its tag would +still verify. Binding it would mean threading the row identity through every seal and open +call in the stores; it buys nothing against the threat above, where the attacker reads the +file rather than edits it and hands it back. + +⚠️ Nor is it a defence against a machine already compromised while DevBox runs: the key is +in this process’s memory for the length of the session, and there is no idle re-lock. + +### The pieces + +- **`vault/key.rs`** — `Vault`: the key, and the two operations. Argon2id (`Cost`: 64 MiB, + 3 passes, 1 lane — 1.16 s on the development machine, which is the point of it) and + AES-256-GCM with a fresh 96-bit nonce per write, laid out `nonce || ciphertext || tag`, + base64 for a TEXT column and raw bytes for a file. The key is a `Zeroizing<[u8; 32]>` and + the hand-written `Debug` prints `Vault(…)`, so it cannot reach a log line. +- **`vault/file.rs`** — `vault.json` beside the database: format version, KDF parameters, + salt, and **the library's key sealed under the phrase**. ⚠️ The phrase does not derive the + key the notes are sealed with, it wraps it: the key is random, and a new phrase re-wraps + the same one. That is what makes a passphrase change a hundred bytes of rewriting rather + than re-encrypting every note and every attachment — an operation that could not be + atomic across the database and the files, and would leave a half-readable library if it + stopped halfway. Opening the wrapped key is also the check: a phrase that fails to open + it is the wrong phrase, said by the authentication tag, so there is no separate check + value to attack. Written staged-then-renamed, and `create` refuses to overwrite one. + + ⚠️ The other side of that coin, and the reason it is worth writing down: changing the + passphrase answers a phrase somebody **learned**, never a key somebody **took**. Whoever + got hold of the unwrapped key keeps it, exactly as with LUKS or KeePass. + +- **`vault/migrate.rs`** — `seal_existing`, one transaction that seals a library written + before any of this. ⚠️ It runs from `create_vault` **after** the library is open and + **before** the startup sweeps: the orphan-attachment sweep reads stored file names and + would meet them in the clear if it ran first. +- **`db.rs`** — `Library` carries the `SqliteConnection` **and** the `Vault`, and derefs to + the connection so the store functions did not have to grow an argument. `split()` hands + the two fields over separately where the borrow checker needs both at once, and + `Db = Mutex>` is empty until `unlock_vault` fills it — a command that + runs before the unlock answers `StorageError::Locked` rather than reading a database + nobody opened. + +### The gate + +`vault_state` answers `absent` / `locked` / `unlocked`; `app.config.ts` awaits it before the +first render and `app.component.html` puts `VaultGateComponent` 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 canvas queries notes the moment it mounts, and nothing would be there +to answer. + +⚠️ The unlocked state lives in Rust and not in the front end: a page reload must not ask +again for a library this process already has open. That is also what keeps `reopenSession()` +working in the end-to-end suite, where the front end reboots and the process does not. + +### Changing the passphrase + +`change_passphrase` (from Préférences → Sécurité) unlocks with the current phrase, then +rewrites the key file with a fresh salt and the same library key wrapped under the new +one. ⚠️ It refuses before writing anything when the current phrase is wrong — a change +that took it on trust would lock the library behind a phrase nobody chose. The open +session is untouched: the key in memory is the one that was already there. + +⚠️ The command asks for the connection only to check that the library is open, then +releases it: the two derivations cost about a second each, and holding the mutex across +them would freeze every other command. + +### Attachments, and the one plaintext copy + +The bytes are sealed on the way in (`attachments::copy_within_limit`) and opened in memory +on the way out: `read_attachment` decrypts into the `data:` URI the preview already used, +and `save_attachment` writes plaintext where the user chose to put it. + +⚠️ `open_attachment` is the exception, and a deliberate one: the program that opens a +document reads it from disk, so DevBox writes a decrypted copy under `app_data_dir()/open/` +and opens that — one click, as before. Those copies are swept on the way out +(`RunEvent::Exit`) and again at every launch, which is what covers one another application +still held, and a crash. ⚠️ The profile and **not** the OS temporary directory: that one is +shared with every account on the machine, where the copy would be readable by all of them +and a directory somebody else created first would be theirs rather than ours. + ## Persistence (Rust) Storage is **SQLite**, queried through **Diesel** and embedded via `libsqlite3-sys` with the @@ -1593,7 +1747,8 @@ installed or shipped alongside the executable. The database file lives in Tauri' the migration SQL. Diesel obeys those; it does not own them. - **Concurrency.** A `SqliteConnection` is not `Sync`, and Diesel takes it exclusively for every query, reads included. A single connection is shared as `tauri::State` - (`Db = Mutex`), registered with `.manage()` in `lib.rs` — never a global. + (`Db = Mutex>`, the connection and the key together), registered with + `.manage()` in `lib.rs` — never a global, and empty until the library is unlocked. Overlapping commands serialize on that mutex, and each command holds `db::lock` for its whole body, so a check and the write that depends on it cannot be interleaved. - **⚠️ Commands that touch the database or the disk are `#[tauri::command(async)]`.** A plain diff --git a/e2e/specs/01-first-launch.e2e.ts b/e2e/specs/01-first-launch.e2e.ts index fe1ef26..82262db 100644 --- a/e2e/specs/01-first-launch.e2e.ts +++ b/e2e/specs/01-first-launch.e2e.ts @@ -2,6 +2,7 @@ import { browser, expect } from '@wdio/globals'; import { canvas } from '../pageobjects/canvas.page.js'; import { banners, fileMenu, titlebar } from '../pageobjects/titlebar.page.js'; +import { passTheGate } from '../support/app.js'; import { bridge, homeSpaceId, query } from '../support/bridge.js'; /** @@ -19,6 +20,10 @@ describe('First launch', () => { * would be its own witness. */ before(async () => { + // ⚠️ Before anything is asked of the library: this is the only file that meets the + // gate, and no command is answered — not even a read — until it has been passed. + await passTheGate(); + await browser.waitUntil(async () => (await bridge.queryNotes(query())).matched > 0, { timeout: 30_000, timeoutMsg: 'the first launch seeded no note', @@ -76,6 +81,9 @@ describe('First launch', () => { it('boots without an error banner', async () => { // NG0203, a missing capability and a failed migration all land here. - expect(await banners.error().isExisting()).toBe(false); + const banner = banners.error(); + const shown = (await banner.isExisting()) ? await banner.getText() : ''; + + expect(shown).toBe(''); }); }); diff --git a/e2e/specs/10-library-transfer.e2e.ts b/e2e/specs/10-library-transfer.e2e.ts index e2eeeb2..5113a08 100644 --- a/e2e/specs/10-library-transfer.e2e.ts +++ b/e2e/specs/10-library-transfer.e2e.ts @@ -15,11 +15,18 @@ import { bridge, draft, homeSpaceId, query } from '../support/bridge.js'; * ⚠️ The OS file picker is not driven here (see `support/app.ts`): the commands take a * path, and the path is where the real work happens. */ +/** The message rather than the throw: a refusal is what these two assertions are about. */ +async function failureOf(running: Promise): Promise { + return running.then( + () => 'it was not refused', + (error: Error) => error.message, + ); +} describe('Import, export and share', () => { const directory = mkdtempSync(join(tmpdir(), 'devbox-e2e-')); /** ⚠️ Forward slashes: `\` is an escape on the wire and a separator on Windows. */ - const bundlePath = join(directory, 'library.json').replaceAll('\\', '/'); + const bundlePath = join(directory, 'library.devbox').replaceAll('\\', '/'); /** Kept from `before`: the seeded space is named from a translation (see below). */ let homeId = ''; @@ -39,8 +46,11 @@ describe('Import, export and share', () => { expect(written.spaces).toBe((await bridge.listSpaces()).length); expect(existsSync(bundlePath)).toBe(true); - const bundle = JSON.parse(readFileSync(bundlePath, 'utf8')) as { notes: { title: string }[] }; - expect(bundle.notes.map((note) => note.title)).toContain('Worth exporting'); + + // ⚠️ An archive, not JSON: the attachments travel as entries beside the bundle. What + // the archive holds is asserted in `tests/transfer.rs`, which can open one — reading + // a deflated entry from here would mean a zip reader in the harness for one check. + expect(readFileSync(bundlePath).subarray(0, 4)).toEqual(Buffer.from('PK\x03\x04', 'binary')); }); it('imports nothing when every note is already there', async () => { @@ -96,17 +106,22 @@ describe('Import, export and share', () => { * arrives with that field brought down to the default rather than failing the file. */ it('imports a bundle from a newer version instead of refusing it whole', async () => { - const source = JSON.parse(readFileSync(bundlePath, 'utf8')) as { - notes: Record[]; - }; + // ⚠️ Written as a bare `.json`, which is also the shape DevBox exported before the + // archive: this doubles as the proof that an old export still imports. + const exported = await bridge.queryNotes(query({ search: 'Worth exporting' })); + const source = exported.sections[0]?.notes[0]; + expect(source).toBeDefined(); + const newerPath = join(directory, 'newer.json').replaceAll('\\', '/'); writeFileSync( newerPath, JSON.stringify({ - ...source, + version: 1, + exportedAt: new Date().toISOString(), + spaces: await bridge.listSpaces(), notes: [ { - ...source.notes[0], + ...source, id: 'written-by-a-newer-devbox', title: 'Ahead of this build', language: 'from-the-future', @@ -124,6 +139,29 @@ describe('Import, export and share', () => { expect(view.sections[0]?.notes[0]?.language).toBe('txt'); }); + /** + * ⚠️ The export is the one file the library key does not protect: it is meant to reach + * another machine, so it carries a key of its own. Read from Node, against the bytes on + * disk rather than against what the application says about them. + */ + it('seals an export with a phrase, and will not open it without that phrase', async () => { + const sealedPath = join(directory, 'sealed.devbox').replaceAll('\\', '/'); + + const written = await bridge.exportNotes(sealedPath, null, 'an export passphrase'); + expect(written.protected).toBe(true); + expect(await bridge.exportIsProtected(sealedPath)).toBe(true); + expect(await bridge.exportIsProtected(bundlePath)).toBe(false); + expect(readFileSync(sealedPath).includes(Buffer.from('Worth exporting'))).toBe(false); + + const refused = await failureOf(bridge.importNotes(sealedPath)); + expect(refused).toContain('passphraseRequired'); + + const wrong = await failureOf(bridge.importNotes(sealedPath, 'not the phrase')); + expect(wrong).toContain('wrongPassphrase'); + + const report = await bridge.importNotes(sealedPath, 'an export passphrase'); + expect(report.notesSkipped).toBeGreaterThan(0); + }); it('greys out the menu entries that have nothing to act on', async () => { await fileMenu.open(); // The entry stays in the DOM and clickable — it carries `aria-disabled`, not `disabled`. diff --git a/e2e/specs/16-passphrase-change.e2e.ts b/e2e/specs/16-passphrase-change.e2e.ts new file mode 100644 index 0000000..c902ff6 --- /dev/null +++ b/e2e/specs/16-passphrase-change.e2e.ts @@ -0,0 +1,55 @@ +import { expect } from '@wdio/globals'; +import { readFileSync } from 'node:fs'; + +import { canvas } from '../pageobjects/canvas.page.js'; +import { PASSPHRASE } from '../support/app.js'; +import { bridge, query } from '../support/bridge.js'; +import { vaultPath } from '../support/profile.js'; + +/** + * ⚠️ Last on purpose: it leaves the profile behind a phrase nothing else in the run + * knows. Nothing after it unlocks — the process stays open for the whole suite — but a + * scenario inserted after this one would be the first to find out the hard way. + * + * What it proves is the shape of the change: the key file is rewritten, the notes are + * not, and the session carries on. Read from Node, against the file rather than against + * what the application says about it. + */ +describe('Changing the passphrase', () => { + const NEXT = 'a second end-to-end passphrase'; + + function keyFile(): { kdf: { salt: string }; key: string } { + return JSON.parse(readFileSync(vaultPath(), 'utf8')) as { kdf: { salt: string }; key: string }; + } + + before(canvas.open); + + it('refuses a change that cannot name the current phrase, and rewrites nothing', async () => { + const before = keyFile(); + + const refused = await bridge.changePassphrase('not the current one', NEXT).then( + () => 'it was not refused', + (error: Error) => error.message, + ); + + expect(refused).toContain('wrongPassphrase'); + expect(keyFile()).toEqual(before); + }); + + it('rewraps the key without touching a note', async () => { + const before = keyFile(); + const corpus = (await bridge.queryNotes(query())).matched; + expect(corpus).toBeGreaterThan(0); + + await bridge.changePassphrase(PASSPHRASE, NEXT); + + const after = keyFile(); + // A fresh salt and a fresh wrapping, so neither phrase says anything about the other. + expect(after.kdf.salt).not.toBe(before.kdf.salt); + expect(after.key).not.toBe(before.key); + + // ⚠️ The library is still open on the same key: a change that re-encrypted would have + // had to stop and restart everything to prove as much. + expect((await bridge.queryNotes(query())).matched).toBe(corpus); + }); +}); diff --git a/e2e/support/app.ts b/e2e/support/app.ts index 3a00ee8..86d1677 100644 --- a/e2e/support/app.ts +++ b/e2e/support/app.ts @@ -15,7 +15,39 @@ const SETTLE_INTERVAL = 200; * The canvas is behind a lazy route, a `resource` and a debounce; every scenario waits * for it rather than for the window, which exists long before anything is queryable. */ +/** + * ⚠️ The library is encrypted, so a run creates this on its first launch and carries it + * for the rest. There is nothing to remember between runs: `resetProfile` deletes the key + * file with everything else, so every run starts from a library that has none. + */ +export const PASSPHRASE = 'an end-to-end passphrase'; + +/** + * Passes the unlock screen when it is there, and says nothing when it is not. + * + * ⚠️ Only `01-first-launch` ever meets it. The unlocked state lives in Rust and the + * process outlives every page reload — `before()` and `reopenSession` both rebuild the + * front end over a library that is already open — so a later file finds no gate at all. + */ +export async function passTheGate(): Promise { + const field = $(testid('vault-passphrase')); + if (!(await field.isExisting())) return; + + await setField(testid('vault-passphrase'), PASSPHRASE); + + // Two entries on a library that has never been encrypted, one on a locked one. + if (await $(testid('vault-confirmation')).isExisting()) { + await setField(testid('vault-confirmation'), PASSPHRASE); + } + + await $(testid('vault-submit')).click(); + // ⚠️ Generous: deriving the key is deliberately slow, and a first launch seals whatever + // was already there on top of it. + await field.waitForExist({ reverse: true, timeout: 60_000 }); +} + export async function waitForCanvas(): Promise { + await passTheGate(); await $(testid('canvas')).waitForExist({ timeout: 30_000 }); // ⚠️ `aria-busy` answers "has anything at all arrived yet", once, and then says nothing diff --git a/e2e/support/bridge.ts b/e2e/support/bridge.ts index 9bb2d92..0133ba5 100644 --- a/e2e/support/bridge.ts +++ b/e2e/support/bridge.ts @@ -33,7 +33,7 @@ async function invoke(command: string, args: Record = {}): P tauri.core .invoke(name, payload) .then((value: unknown) => done({ ok: value })) - .catch((error: unknown) => done({ err: String(error) })); + .catch((error: unknown) => done({ err: typeof error === 'string' ? error : JSON.stringify(error) })); }, command, args, @@ -71,9 +71,13 @@ export const bridge = { setGlobalPlaceholders: (values: Record) => invoke>('set_global_placeholders', { values }), - exportNotes: (path: string, spaceId: string | null = null) => - invoke('export_notes', { path, spaceId }), - importNotes: (path: string) => invoke('import_notes', { path }), + exportNotes: (path: string, spaceId: string | null = null, passphrase: string | null = null) => + invoke('export_notes', { path, spaceId, passphrase }), + importNotes: (path: string, passphrase: string | null = null) => + invoke('import_notes', { path, passphrase }), + exportIsProtected: (path: string) => invoke('export_is_protected', { path }), + + changePassphrase: (current: string, next: string) => invoke('change_passphrase', { current, next }), } as const; /** A `NoteDraft` is exhaustive on the wire; a scenario cares about two or three fields. */ diff --git a/e2e/support/profile.ts b/e2e/support/profile.ts index 38880a0..01f67e4 100644 --- a/e2e/support/profile.ts +++ b/e2e/support/profile.ts @@ -39,6 +39,11 @@ export function preferencesPath(): string { return join(e2eDataDir(), 'preferences.json'); } +/** The key file, beside the database — what a passphrase change rewrites. */ +export function vaultPath(): string { + return join(e2eDataDir(), 'vault.json'); +} + /** * Where `homeSpaceId()` records the seeded space. Outside the profile on purpose: it is * the harness's own note, not the application's state. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7871ba1..56f8d64 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -14,6 +14,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "aes" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", +] + +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ctutils", + "ghash", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -121,6 +156,18 @@ dependencies = [ "x11rb", ] +[[package]] +name = "argon2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.3.1", + "password-hash", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -368,6 +415,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -398,6 +451,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -407,6 +469,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -659,6 +730,17 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "clap" version = "4.6.7" @@ -693,6 +775,12 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "combine" version = "4.6.7" @@ -772,6 +860,12 @@ dependencies = [ "libc", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -781,6 +875,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -854,6 +957,17 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -893,6 +1007,24 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.21.3" @@ -1018,11 +1150,14 @@ dependencies = [ name = "devbox" version = "0.1.4" dependencies = [ + "aes-gcm", + "argon2", "base64 0.23.1", "chrono", "criterion", "diesel", "diesel_migrations", + "getrandom 0.3.4", "libsqlite3-sys", "log", "png 0.18.1", @@ -1050,6 +1185,8 @@ dependencies = [ "toml 1.1.3+spec-1.1.0", "unicode-normalization", "uuid", + "zeroize", + "zip 8.6.0", ] [[package]] @@ -1104,8 +1241,19 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -1764,6 +1912,16 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", ] [[package]] @@ -2120,6 +2278,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -2370,6 +2537,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3235,6 +3411,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "getrandom 0.4.3", + "phc", +] + [[package]] name = "paste" version = "1.0.15" @@ -3258,6 +3444,17 @@ dependencies = [ "indexmap 2.14.0", ] +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", +] + [[package]] name = "phf" version = "0.13.1" @@ -3387,6 +3584,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -3512,6 +3720,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -4113,8 +4327,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -4852,7 +5066,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -5480,6 +5694,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -5565,6 +5785,16 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -6733,6 +6963,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.14.0", + "memchr", + "typed-path", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index baf07f0..0aa20b4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -67,6 +67,11 @@ tauri-plugin-wdio-webdriver = { version = "1", optional = true } log = "0.4" thiserror = "2.0.20" unicode-normalization = "0.1.25" +zip = { version = "8.6.0", default-features = false, features = ["deflate-flate2"] } +aes-gcm = "0.11.1" +argon2 = "0.6.0" +zeroize = "1.9.0" +getrandom = "0.3" [dev-dependencies] criterion = { version = "0.8", default-features = false, features = ["cargo_bench_support"] } diff --git a/src-tauri/benches/commands.rs b/src-tauri/benches/commands.rs index d683d09..ab8c3bc 100644 --- a/src-tauri/benches/commands.rs +++ b/src-tauri/benches/commands.rs @@ -191,21 +191,36 @@ fn disk(c: &mut Criterion) { // The bundle is ~100 MB, so these two want their own sample size. group.sample_size(10); - let path = std::env::temp_dir().join("devbox-bench-export.json"); + let path = std::env::temp_dir().join("devbox-bench-export.devbox"); let target = path.to_string_lossy().to_string(); + // The corpus seeds no attachment, so the archive carries the bundle alone. What the + // directory is does not matter; that it exists does. + let attachments = std::env::temp_dir(); group.bench_function("export_notes", |b| { b.iter(|| { let notes = store::all(&mut corpus.connection, None).expect("the corpus"); let packed = bundle::collect(&mut corpus.connection, notes).expect("a bundle"); - black_box(file::write(&target, &packed).expect("a written file")); + black_box( + file::write( + &target, + &packed, + &attachments, + corpus.connection.vault(), + None, + ) + .expect("a written file"), + ); }); }); group.bench_function("import_notes, every id already there", |b| { b.iter(|| { - let incoming = file::read(&target).expect("a readable file"); - black_box(bundle::merge(&mut corpus.connection, incoming).expect("a merge")); + let (incoming, mut payload) = file::read(&target, None).expect("a readable file"); + black_box( + bundle::merge(&mut corpus.connection, incoming, &mut payload, &attachments) + .expect("a merge"), + ); }); }); diff --git a/src-tauri/benches/corpus.rs b/src-tauri/benches/corpus.rs index 89da3f1..e3b5d06 100644 --- a/src-tauri/benches/corpus.rs +++ b/src-tauri/benches/corpus.rs @@ -9,7 +9,7 @@ use std::path::PathBuf; use chrono::{DateTime, TimeDelta, Utc}; -use diesel::SqliteConnection; +use devbox_lib::db::Library; use devbox_lib::db; use devbox_lib::notes::checklist::{ChecklistItem, NoteKind}; @@ -28,7 +28,7 @@ const TAGS: &[&str] = &[ /// A database file of its own per group, erased with the guard. pub(crate) struct Corpus { - pub(crate) connection: SqliteConnection, + pub(crate) connection: Library, pub(crate) space_ids: Vec, pub(crate) note_ids: Vec, /// ⚠️ Last, and the erasure lives on this field rather than on `Corpus`: fields drop @@ -109,7 +109,13 @@ pub(crate) fn build() -> Corpus { let directory = std::env::temp_dir().join(format!("devbox-bench-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&directory).expect("a writable temporary directory"); - let mut connection = db::open(&directory.join("bench.sqlite3")).expect("a fresh database"); + // The corpus is sealed like a real library, so the benchmarks measure what the + // commands really pay, encryption included. + let mut connection = db::open( + &directory.join("bench.sqlite3"), + db::bench_vault().expect("a key"), + ) + .expect("a fresh database"); let space_ids: Vec = (0..SPACES) .map(|at| { diff --git a/src-tauri/src/attachments.rs b/src-tauri/src/attachments.rs index 64a00c2..ba0b705 100644 --- a/src-tauri/src/attachments.rs +++ b/src-tauri/src/attachments.rs @@ -5,6 +5,7 @@ #![allow(clippy::needless_pass_by_value)] pub mod model; +pub mod sealed; pub mod store; use std::io::Read; @@ -18,6 +19,7 @@ use uuid::Uuid; use crate::db::{Db, lock}; use crate::error::{AppError, StorageError}; +use crate::vault::key::Vault; use model::Attachment; const DIRECTORY: &str = "attachments"; @@ -52,15 +54,21 @@ pub(crate) fn remove_files(directory: &Path, stored_names: &[String]) { /// ⚠️ The limit is enforced by the copy itself: reading `metadata().len()` first leaves /// the two free to disagree, and a file growing between them lands whole. -fn copy_within_limit(source: &str, destination: &Path) -> Result { +/// ⚠️ The limit is still enforced by the read rather than by `metadata`: a file growing +/// between the two would land whole, whatever the limit said. What changed is that the +/// bytes are sealed before they touch the destination, so nothing readable is ever +/// written — not even briefly. +fn copy_within_limit(vault: &Vault, source: &str, destination: &Path) -> Result { let mut reader = std::fs::File::open(source).map_err(|error| file_error(source, &error))?; - let mut writer = - std::fs::File::create(destination).map_err(|error| file_error(source, &error))?; - let copied = std::io::copy(&mut reader.by_ref().take(model::MAX_BYTES + 1), &mut writer) + let mut plain = Vec::new(); + std::io::copy(&mut reader.by_ref().take(model::MAX_BYTES + 1), &mut plain) .map_err(|error| file_error(source, &error))?; - Ok(model::validate_size(copied)?) + let size = model::validate_size(plain.len() as u64)?; + sealed::write_sealed(vault, destination, &plain)?; + + Ok(size) } #[tauri::command(async)] @@ -84,9 +92,13 @@ pub fn attach_file( let directory = directory(&app)?; let destination = directory.join(attachment.stored_name()); - // ⚠️ Copy before the database write: a record without a file shows a broken + + let mut connection = lock(&db)?; + let (db, vault) = connection.split(); + + // ⚠️ Seal and copy before the database write: a record without a file shows a broken // thumbnail, where a file without a record is swept at startup. - match copy_within_limit(&path, &destination) { + match copy_within_limit(vault, &path, &destination) { Ok(byte_size) => attachment.byte_size = byte_size, Err(error) => { remove_files(&directory, &[attachment.stored_name()]); @@ -94,8 +106,7 @@ pub fn attach_file( } } - let mut connection = lock(&db)?; - if let Err(error) = store::create(&mut connection, &attachment) { + if let Err(error) = store::create(db, vault, &attachment) { remove_files(&directory, &[attachment.stored_name()]); return Err(error.into()); } @@ -136,11 +147,15 @@ fn write_attachment( let directory = directory(app)?; let destination = directory.join(attachment.stored_name()); - std::fs::write(&destination, bytes) - .map_err(|error| file_error(&attachment.file_name, &error))?; let mut connection = lock(db)?; - if let Err(error) = store::create(&mut connection, &attachment) { + let (db, vault) = connection.split(); + + // Sealed on the way in, like a copied file: a pasted screenshot of a credentials page + // has no business being the one attachment left readable. + sealed::write_sealed(vault, &destination, &bytes)?; + + if let Err(error) = store::create(db, vault, &attachment) { remove_files(&directory, &[attachment.stored_name()]); return Err(error.into()); } @@ -166,7 +181,10 @@ pub fn read_attachment(id: String, app: AppHandle, db: State<'_, Db>) -> Result< }; let path = directory(&app)?.join(attachment.stored_name()); - let bytes = std::fs::read(&path).map_err(|error| file_error(&attachment.file_name, &error))?; + let bytes = { + let connection = lock(&db)?; + sealed::read_sealed(connection.vault(), &path)? + }; Ok(format!( "data:{};base64,{}", @@ -177,13 +195,39 @@ pub fn read_attachment(id: String, app: AppHandle, db: State<'_, Db>) -> Result< /// ⚠️ The call starts from Rust: opening a path from the front end would mean allowing /// `opener:allow-open-path` over a whole directory. +/// +/// ⚠️ **This is the one place a decrypted copy reaches the disk.** Handing a file to the +/// application the desktop chose for it means handing over a path, and that file has to +/// be readable. The copy goes under a directory of ours in the OS temporary folder and is +/// swept at the next launch — it cannot be deleted on close, because the application that +/// opened it still holds it. The README says so; replacing this with "save as" was the +/// alternative and was turned down, one click being the point. #[tauri::command(async)] #[specta::specta] pub fn open_attachment(id: String, app: AppHandle, db: State<'_, Db>) -> Result<(), AppError> { - let path = locate(&id, &app, &db)?; + let attachment = { + let mut connection = lock(&db)?; + store::find(&mut connection, &id)? + .ok_or_else(|| StorageError::AttachmentNotFound(id.clone()))? + }; + + let stored = directory(&app)?.join(attachment.stored_name()); + let bytes = { + let connection = lock(&db)?; + sealed::read_sealed(connection.vault(), &stored)? + }; + + let directory = sealed::plaintext_directory(&app)?; + std::fs::create_dir_all(&directory) + .map_err(|error| file_error("a directory for decrypted copies", &error))?; + + // Named after the record, not after what the user called it: two `capture.png` must + // not overwrite each other here either. + let copy = directory.join(attachment.stored_name()); + std::fs::write(©, &bytes).map_err(|error| file_error(&attachment.file_name, &error))?; tauri_plugin_opener::OpenerExt::opener(&app) - .open_path(path.to_string_lossy(), None::<&str>) + .open_path(copy.to_string_lossy(), None::<&str>) .map_err(|error| StorageError::File(format!("open: {error}")))?; Ok(()) @@ -199,8 +243,14 @@ pub fn save_attachment( db: State<'_, Db>, ) -> Result<(), AppError> { let source = locate(&id, &app, &db)?; + let bytes = { + let connection = lock(&db)?; + sealed::read_sealed(connection.vault(), &source)? + }; - std::fs::copy(&source, &path).map_err(|error| file_error(&path, &error))?; + // In the clear, where the user chose: that is what "save as" means, and it is an + // explicit gesture rather than something the application does behind them. + std::fs::write(&path, &bytes).map_err(|error| file_error(&path, &error))?; Ok(()) } @@ -267,6 +317,10 @@ pub fn sweep_orphan_files(app: &AppHandle, db: &Db) -> Result Vault { + crate::db::test_vault().expect("a key") + } use crate::error::ErrorCode; fn scratch() -> PathBuf { @@ -283,10 +337,23 @@ mod tests { std::fs::write(&source, vec![7u8; 2048]).unwrap(); let destination = directory.join("a-1.png"); - let copied = copy_within_limit(&source.to_string_lossy(), &destination).unwrap(); + let vault = test_vault(); + let copied = copy_within_limit(&vault, &source.to_string_lossy(), &destination).unwrap(); + // The size reported is the plaintext's: it is what the interface shows. assert_eq!(copied, 2048); - assert_eq!(std::fs::read(&destination).unwrap().len(), 2048); + + let written = std::fs::read(&destination).unwrap(); + assert!(written.len() > 2048, "the file carries a nonce and a tag"); + assert_ne!( + written[..2048], + [7u8; 2048], + "and none of the bytes as given" + ); + assert_eq!( + sealed::read_sealed(&vault, &destination).unwrap(), + vec![7u8; 2048] + ); std::fs::remove_dir_all(&directory).ok(); } @@ -302,7 +369,8 @@ mod tests { .unwrap(); let destination = directory.join("a-1.bin"); - let error = copy_within_limit(&source.to_string_lossy(), &destination).unwrap_err(); + let error = + copy_within_limit(&test_vault(), &source.to_string_lossy(), &destination).unwrap_err(); assert!(matches!(error.code, ErrorCode::InvalidInput)); assert_eq!( @@ -316,7 +384,12 @@ mod tests { fn a_missing_source_is_reported_rather_than_panicking() { let directory = scratch(); - let error = copy_within_limit("no-such-file.png", &directory.join("a-1.png")).unwrap_err(); + let error = copy_within_limit( + &test_vault(), + "no-such-file.png", + &directory.join("a-1.png"), + ) + .unwrap_err(); assert!(matches!(error.code, ErrorCode::FileAccess)); std::fs::remove_dir_all(&directory).ok(); diff --git a/src-tauri/src/attachments/model.rs b/src-tauri/src/attachments/model.rs index e87adc6..a7295fc 100644 --- a/src-tauri/src/attachments/model.rs +++ b/src-tauri/src/attachments/model.rs @@ -4,7 +4,7 @@ //! deciding a write path. use chrono::{DateTime, Utc}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use specta::Type; use crate::count::saturating_u32; @@ -13,7 +13,7 @@ use crate::error::{StorageError, ValidationError}; /// 10 MB: past that it is no longer a screenshot pasted next to a note. pub const MAX_BYTES: u64 = 10 * 1024 * 1024; -#[derive(Debug, Clone, Serialize, Type)] +#[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct Attachment { pub id: String, diff --git a/src-tauri/src/attachments/sealed.rs b/src-tauri/src/attachments/sealed.rs new file mode 100644 index 0000000..543ff8c --- /dev/null +++ b/src-tauri/src/attachments/sealed.rs @@ -0,0 +1,203 @@ +//! The bytes on disk, sealed like the rows beside them. +//! +//! ⚠️ A sealed file is read whole to be opened: AES-GCM authenticates the message, and a +//! message is only authentic once all of it has been seen. That is the price of knowing a +//! file was not tampered with, and the 10 MiB cap on an attachment is what keeps it +//! bounded. + +use std::path::{Path, PathBuf}; + +use tauri::{AppHandle, Manager}; + +use crate::error::StorageError; +use crate::vault::key::Vault; + +/// What [`plaintext_directory`] is called, beside the attachments themselves. +/// +/// ⚠️ A decrypted copy lands here whenever an attachment is opened with the application +/// the desktop chose for it — there is no other way to hand a file to another program. +/// [`sweep_plaintext`] empties it on the way out and at every launch, so the copy outlives +/// the session at worst, and the README says so plainly. +const PLAINTEXT_DIRECTORY: &str = "open"; + +pub fn seal_into(vault: &Vault, source: &Path, destination: &Path) -> Result { + let plain = std::fs::read(source) + .map_err(|error| StorageError::File(format!("{}: {error}", source.display())))?; + + write_sealed(vault, destination, &plain) +} + +pub fn write_sealed(vault: &Vault, destination: &Path, bytes: &[u8]) -> Result { + let sealed = vault.seal_bytes(bytes)?; + std::fs::write(destination, &sealed) + .map_err(|error| StorageError::File(format!("{}: {error}", destination.display())))?; + + // ⚠️ The plaintext length, not the file's: the record is what the interface shows, and + // a size inflated by the nonce and the tag would be a lie the user could measure. + Ok(bytes.len() as u64) +} + +pub fn read_sealed(vault: &Vault, path: &Path) -> Result, StorageError> { + let sealed = std::fs::read(path) + .map_err(|error| StorageError::File(format!("{}: {error}", path.display())))?; + + vault.open_bytes(&sealed) +} + +/// Seals a file that is still in the clear, for a library that predates the passphrase. +/// +/// ⚠️ Staged then renamed: a file half-rewritten is an attachment lost, where a rename is +/// atomic. And ⚠️ it is **not** idempotent — sealing twice gives a file that opens into +/// ciphertext. It runs once, on the launch that creates the key file. +pub fn seal_in_place(vault: &Vault, path: &Path) -> Result<(), StorageError> { + let plain = std::fs::read(path) + .map_err(|error| StorageError::File(format!("{}: {error}", path.display())))?; + + let staged = path.with_extension("sealing"); + write_sealed(vault, &staged, &plain)?; + + std::fs::rename(&staged, path).map_err(|error| { + let _ = std::fs::remove_file(&staged); + StorageError::File(format!("{}: {error}", path.display())) + }) +} + +/// Where a decrypted copy goes so the desktop can open it. +/// +/// ⚠️ The application's own data directory, deliberately, and **not** the OS temporary +/// one: that is a namespace shared with every account on the machine, where the copy would +/// be readable by all of them and where a directory somebody else created first would be +/// theirs rather than ours. This one sits inside the user's profile. +pub fn plaintext_directory(app: &AppHandle) -> Result { + Ok(app + .path() + .app_data_dir() + .map_err(|error| StorageError::File(format!("app_data_dir: {error}")))? + .join(PLAINTEXT_DIRECTORY)) +} + +/// ⚠️ Run on the way out *and* at every launch. A copy handed to another application +/// cannot be deleted while that application holds it, and a crash reaches neither path — +/// so the guarantee is "gone by the next launch", with the exit sweep narrowing the +/// window to the session for everything not still open. +pub fn sweep_plaintext(app: &AppHandle) { + let Ok(directory) = plaintext_directory(app) else { + return; + }; + + if let Err(error) = std::fs::remove_dir_all(&directory) + && error.kind() != std::io::ErrorKind::NotFound + { + log::warn!( + "Decrypted copies not swept from {}: {error}", + directory.display() + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vault::key::Cost; + + fn vault() -> Vault { + Vault::derive( + "a passphrase", + b"0123456789abcdef", + Cost { + memory_kib: 64, + passes: 1, + lanes: 1, + }, + ) + .unwrap() + } + + fn scratch() -> std::path::PathBuf { + let directory = std::env::temp_dir().join(format!( + "devbox-sealed-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&directory).unwrap(); + + directory + } + + #[test] + fn a_sealed_file_reads_back_byte_for_byte() { + let directory = scratch(); + let vault = vault(); + let target = directory.join("capture.png"); + + let bytes = b"\x89PNG\r\n\x1a\n and whatever follows"; + write_sealed(&vault, &target, bytes).unwrap(); + + assert_eq!(read_sealed(&vault, &target).unwrap(), bytes); + std::fs::remove_dir_all(&directory).ok(); + } + + /// ⚠️ The point: a screenshot of a credentials page must not be readable beside a + /// database that is. + #[test] + fn the_file_on_disk_carries_none_of_the_bytes_it_was_given() { + let directory = scratch(); + let vault = vault(); + let target = directory.join("capture.png"); + + write_sealed(&vault, &target, b"SECRET-TOKEN-abc123").unwrap(); + + let raw = std::fs::read(&target).unwrap(); + assert!(!String::from_utf8_lossy(&raw).contains("SECRET-TOKEN")); + std::fs::remove_dir_all(&directory).ok(); + } + + /// The record shows what the user attached, not what the cipher added to it. + #[test] + fn the_size_reported_is_the_plaintext_size() { + let directory = scratch(); + let vault = vault(); + + let written = write_sealed(&vault, &directory.join("f"), &[0u8; 1000]).unwrap(); + + assert_eq!(written, 1000); + std::fs::remove_dir_all(&directory).ok(); + } + + #[test] + fn a_file_sealed_under_another_key_will_not_open() { + let directory = scratch(); + let target = directory.join("capture.png"); + write_sealed(&vault(), &target, b"bytes").unwrap(); + + let other = Vault::derive( + "another passphrase", + b"0123456789abcdef", + Cost { + memory_kib: 64, + passes: 1, + lanes: 1, + }, + ) + .unwrap(); + + assert!(read_sealed(&other, &target).is_err()); + std::fs::remove_dir_all(&directory).ok(); + } + + #[test] + fn a_truncated_file_is_refused_rather_than_half_read() { + let directory = scratch(); + let vault = vault(); + let target = directory.join("capture.png"); + write_sealed(&vault, &target, b"a reasonably long run of bytes").unwrap(); + + let sealed = std::fs::read(&target).unwrap(); + std::fs::write(&target, &sealed[..sealed.len() - 4]).unwrap(); + + assert!(read_sealed(&vault, &target).is_err()); + std::fs::remove_dir_all(&directory).ok(); + } +} diff --git a/src-tauri/src/attachments/store.rs b/src-tauri/src/attachments/store.rs index 99ba4fc..39b9a6f 100644 --- a/src-tauri/src/attachments/store.rs +++ b/src-tauri/src/attachments/store.rs @@ -4,9 +4,11 @@ use diesel::prelude::*; use super::model::{self, Attachment}; use crate::count::saturating_u32; +use crate::db::Library; use crate::db::iso8601; use crate::db::schema::{attachments, notes}; use crate::error::StorageError; +use crate::vault::key::Vault; #[derive(Queryable, Selectable, Insertable)] #[diesel(table_name = attachments)] @@ -20,42 +22,41 @@ struct AttachmentRow { created_at: String, } -impl TryFrom for Attachment { - type Error = StorageError; - - fn try_from(row: AttachmentRow) -> Result { +/// ⚠️ `mime_type` stays in the clear, deliberately: the file on disk is named +/// `{id}.{extension}`, so the type is already public. Sealing it would be theatre. +impl AttachmentRow { + fn open(row: Self, vault: &Vault) -> Result { let created_at = iso8601::parse(&row.created_at).map_err(|_| StorageError::CorruptRow { id: row.id.clone(), field: "createdAt", })?; - Ok(Self { + Ok(Attachment { byte_size: saturating_u32(row.byte_size), + file_name: vault.open(&row.file_name)?, id: row.id, note_id: row.note_id, - file_name: row.file_name, mime_type: row.mime_type, created_at, }) } -} -impl From<&Attachment> for AttachmentRow { - fn from(attachment: &Attachment) -> Self { - Self { + fn seal(attachment: &Attachment, vault: &Vault) -> Result { + Ok(Self { id: attachment.id.clone(), note_id: attachment.note_id.clone(), - file_name: attachment.file_name.clone(), + file_name: vault.seal(&attachment.file_name)?, mime_type: attachment.mime_type.clone(), byte_size: i64::from(attachment.byte_size), created_at: iso8601::format(attachment.created_at), - } + }) } } /// The foreign key would refuse it too, but with a message the front cannot translate. pub fn create( connection: &mut SqliteConnection, + vault: &Vault, attachment: &Attachment, ) -> Result<(), StorageError> { connection.transaction(|connection| { @@ -71,37 +72,35 @@ pub fn create( } diesel::insert_into(attachments::table) - .values(AttachmentRow::from(attachment)) + .values(AttachmentRow::seal(attachment, vault)?) .execute(connection)?; Ok(()) }) } -pub fn list( - connection: &mut SqliteConnection, - note_id: &str, -) -> Result, StorageError> { +pub fn list(connection: &mut Library, note_id: &str) -> Result, StorageError> { + let (db, vault) = connection.split(); + attachments::table .filter(attachments::note_id.eq(note_id)) .select(AttachmentRow::as_select()) .order((attachments::created_at.asc(), attachments::id.asc())) - .load::(connection)? + .load::(db)? .into_iter() - .map(Attachment::try_from) + .map(|row| AttachmentRow::open(row, vault)) .collect() } -pub fn find( - connection: &mut SqliteConnection, - id: &str, -) -> Result, StorageError> { +pub fn find(connection: &mut Library, id: &str) -> Result, StorageError> { + let (db, vault) = connection.split(); + attachments::table .find(id) .select(AttachmentRow::as_select()) - .first::(connection) + .first::(db) .optional()? - .map(Attachment::try_from) + .map(|row| AttachmentRow::open(row, vault)) .transpose() } @@ -115,31 +114,57 @@ pub fn delete(connection: &mut SqliteConnection, id: &str) -> Result<(), Storage Ok(()) } +/// The records an export carries. The bytes are not here: the caller reads them from the +/// attachments directory by [`model::stored_name`]. +pub fn for_notes( + connection: &mut Library, + note_ids: &[String], +) -> Result, StorageError> { + if note_ids.is_empty() { + return Ok(Vec::new()); + } + + let (db, vault) = connection.split(); + + attachments::table + .filter(attachments::note_id.eq_any(note_ids)) + .select(AttachmentRow::as_select()) + .order((attachments::created_at.asc(), attachments::id.asc())) + .load::(db)? + .into_iter() + .map(|row| AttachmentRow::open(row, vault)) + .collect() +} + /// ⚠️ Collected before a purge: the cascade takes the records, never the files. pub fn stored_names_of( - connection: &mut SqliteConnection, + connection: &mut Library, note_ids: &[String], ) -> Result, StorageError> { if note_ids.is_empty() { return Ok(Vec::new()); } - Ok(attachments::table + let (db, vault) = connection.split(); + + attachments::table .filter(attachments::note_id.eq_any(note_ids)) .select((attachments::id, attachments::file_name)) - .load::<(String, String)>(connection)? + .load::<(String, String)>(db)? .iter() - .map(|(id, file_name)| model::stored_name(id, file_name)) - .collect()) + .map(|(id, file_name)| Ok(model::stored_name(id, &vault.open(file_name)?))) + .collect::, StorageError>>() } -pub fn all_stored_names(connection: &mut SqliteConnection) -> Result, StorageError> { - Ok(attachments::table +pub fn all_stored_names(connection: &mut Library) -> Result, StorageError> { + let (db, vault) = connection.split(); + + attachments::table .select((attachments::id, attachments::file_name)) - .load::<(String, String)>(connection)? + .load::<(String, String)>(db)? .iter() - .map(|(id, file_name)| model::stored_name(id, file_name)) - .collect()) + .map(|(id, file_name)| Ok(model::stored_name(id, &vault.open(file_name)?))) + .collect::, StorageError>>() } /// One note; [`counts`] answers for the whole corpus at once. @@ -174,7 +199,7 @@ mod tests { use crate::notes::language::Language; use crate::notes::model::{NoteDraft, NoteLifecycle}; - fn note(connection: &mut SqliteConnection) -> String { + fn note(connection: &mut Library) -> String { let space = crate::spaces::store::create(connection, "Personal").unwrap(); crate::notes::store::create( connection, @@ -196,6 +221,12 @@ mod tests { .id } + /// The tests hold a library; `create` takes the pair, as an import does. + fn create_here(connection: &mut Library, attachment: &Attachment) -> Result<(), StorageError> { + let (db, vault) = connection.split(); + create(db, vault, attachment) + } + fn sample(id: &str, note_id: &str) -> Attachment { Attachment { id: id.to_string(), @@ -212,7 +243,7 @@ mod tests { let mut connection = open_in_memory().unwrap(); let note_id = note(&mut connection); - create(&mut connection, &sample("a-1", ¬e_id)).unwrap(); + create_here(&mut connection, &sample("a-1", ¬e_id)).unwrap(); let listed = list(&mut connection, ¬e_id).unwrap(); assert_eq!(listed.len(), 1); @@ -224,7 +255,7 @@ mod tests { fn attaching_to_an_unknown_note_is_refused() { let mut connection = open_in_memory().unwrap(); - let error = create(&mut connection, &sample("a-1", "ghost")).unwrap_err(); + let error = create_here(&mut connection, &sample("a-1", "ghost")).unwrap_err(); assert!(matches!(error, StorageError::NoteNotFound(_))); } @@ -233,7 +264,7 @@ mod tests { fn purging_a_note_takes_its_attachment_rows_with_it() { let mut connection = open_in_memory().unwrap(); let note_id = note(&mut connection); - create(&mut connection, &sample("a-1", ¬e_id)).unwrap(); + create_here(&mut connection, &sample("a-1", ¬e_id)).unwrap(); let files = stored_names_of(&mut connection, std::slice::from_ref(¬e_id)).unwrap(); crate::notes::store::trash::trash(&mut connection, ¬e_id, Utc::now()).unwrap(); diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 295c60e..574b0dd 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -1,6 +1,7 @@ pub mod migration; pub mod schema; +use std::ops::{Deref, DerefMut}; use std::path::Path; use std::sync::{Mutex, MutexGuard}; @@ -8,34 +9,141 @@ use diesel::connection::SimpleConnection; use diesel::prelude::*; use crate::error::StorageError; +use crate::vault::key::Vault; pub const DB_FILE_NAME: &str = "devbox.sqlite3"; -/// `SqliteConnection` is not `Sync`: overlapping commands serialize on this mutex. -pub type Db = Mutex; +/// The connection, and the key everything it holds is sealed with. +/// +/// ⚠️ It derefs to the connection, so a caller keeps writing `&mut connection`. What it +/// does **not** do is stand in for one where Diesel expects it: `load` and its siblings +/// take their connection as a generic parameter, and a generic gets no deref coercion — +/// hence [`Library::db`] at every query. +pub struct Library { + connection: SqliteConnection, + vault: Vault, +} + +impl Library { + /// The connection, for the Diesel call that needs it by that name. + pub fn db(&mut self) -> &mut SqliteConnection { + &mut self.connection + } + + /// ⚠️ Both halves at once. Two calls would not do: one borrows mutably and the other + /// shared, and the compiler cannot see they touch different fields until they are + /// destructured together. + pub fn split(&mut self) -> (&mut SqliteConnection, &Vault) { + (&mut self.connection, &self.vault) + } + + /// The key. Sealing is the caller's to do — this only hands it over. + pub fn vault(&self) -> &Vault { + &self.vault + } + + /// ⚠️ Hands the closure the connection **and** the key. `SqliteConnection::transaction` + /// alone gives back a bare connection, which would leave a caller unable to seal + /// anything inside the transaction it just opened. + pub fn transaction(&mut self, f: F) -> Result + where + F: FnOnce(&mut SqliteConnection, &Vault) -> Result, + { + // Split borrows: the connection mutably, the key shared, and they are disjoint + // fields — which is the whole reason this is destructured rather than chained. + let Self { connection, vault } = self; + connection.transaction(|connection| f(connection, vault)) + } +} + +impl Deref for Library { + type Target = SqliteConnection; + + fn deref(&self) -> &Self::Target { + &self.connection + } +} + +impl DerefMut for Library { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.connection + } +} + +/// ⚠️ Empty until the passphrase has been given. Every command runs behind the front +/// end's unlock gate, so `None` here is a caller that jumped the queue, not a state to +/// render. +pub type Db = Mutex>; /// A poisoned mutex means a command panicked while holding it. -pub(crate) fn lock(db: &Db) -> Result, StorageError> { - db.lock().map_err(|_| StorageError::Unavailable) +pub(crate) fn lock(db: &Db) -> Result, StorageError> { + let guard = db.lock().map_err(|_| StorageError::Unavailable)?; + if guard.is_none() { + return Err(StorageError::Locked); + } + + Ok(LibraryGuard(guard)) } -pub fn open(path: &Path) -> Result { +/// The guard, narrowed to a library that is definitely there — [`lock`] refused otherwise. +pub(crate) struct LibraryGuard<'a>(MutexGuard<'a, Option>); + +impl Deref for LibraryGuard<'_> { + type Target = Library; + + fn deref(&self) -> &Self::Target { + self.0.as_ref().expect("a library checked by lock") + } +} + +impl DerefMut for LibraryGuard<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.0.as_mut().expect("a library checked by lock") + } +} + +pub fn open(path: &Path, vault: Vault) -> Result { let mut connection = SqliteConnection::establish(&path.to_string_lossy()) .map_err(|error| StorageError::Migration(error.to_string()))?; configure(&mut connection)?; migration::run(&mut connection)?; - Ok(connection) + Ok(Library { connection, vault }) } /// Public for the integration tests, which see nothing of the crate but its API. -pub fn open_in_memory() -> Result { +pub fn open_in_memory() -> Result { let mut connection = SqliteConnection::establish(":memory:") .map_err(|error| StorageError::Migration(error.to_string()))?; configure(&mut connection)?; migration::run(&mut connection)?; - Ok(connection) + Ok(Library { + connection, + vault: test_vault()?, + }) +} + +/// The same key the in-memory libraries use, for the benchmarks, which open a file. +pub fn bench_vault() -> Result { + test_vault() +} + +/// ⚠️ A key of its own per in-memory library, derived at a cost nobody would ship. These +/// libraries exist for the length of a test and never reach a file, so what matters is +/// that the sealing path is the real one — not that the key is expensive to guess. +pub fn test_vault() -> Result { + use crate::vault::key::Cost; + + Vault::derive( + "in-memory", + b"0123456789abcdef", + Cost { + memory_kib: 64, + passes: 1, + lanes: 1, + }, + ) } fn configure(connection: &mut SqliteConnection) -> Result<(), StorageError> { @@ -109,7 +217,7 @@ mod tests { use diesel::sql_types::Integer; fn in_memory() -> Db { - Mutex::new(SqliteConnection::establish(":memory:").unwrap()) + Mutex::new(Some(open_in_memory().unwrap())) } /// Stays here rather than in `tests/`: `configure` is private. @@ -124,7 +232,7 @@ mod tests { let mut connection = open_in_memory().unwrap(); let enabled = diesel::sql_query("PRAGMA foreign_keys") - .get_result::(&mut connection) + .get_result::(connection.db()) .unwrap() .foreign_keys; @@ -138,6 +246,20 @@ mod tests { assert!(lock(&db).is_ok()); } + /// ⚠️ A command that reached the library before the passphrase did. The front gates + /// on the unlock screen, so this only catches a caller that jumped the queue — but it + /// answers rather than unwrapping a `None`. + #[test] + fn a_library_still_locked_is_reported_rather_than_unwrapped() { + let db: Db = Mutex::new(None); + + let Err(error) = lock(&db) else { + panic!("a locked library must be reported, not handed over"); + }; + + assert!(matches!(error, StorageError::Locked)); + } + #[test] fn a_poisoned_connection_is_reported_instead_of_panicking_again() { let db = in_memory(); diff --git a/src-tauri/src/db/migration.rs b/src-tauri/src/db/migration.rs index af347a8..753d73c 100644 --- a/src-tauri/src/db/migration.rs +++ b/src-tauri/src/db/migration.rs @@ -148,8 +148,8 @@ mod tests { std::fs::create_dir_all(&directory).unwrap(); let path = directory.join(DB_FILE_NAME); - open(&path).unwrap(); - let mut connection = open(&path).unwrap(); + open(&path, crate::db::test_vault().unwrap()).unwrap(); + let mut connection = open(&path, crate::db::test_vault().unwrap()).unwrap(); assert!(!connection.has_pending_migration(MIGRATIONS).unwrap()); @@ -301,7 +301,7 @@ mod tests { diesel::sql_query( "INSERT INTO __diesel_schema_migrations (version) VALUES ('2099-01-01-000000')", ) - .execute(&mut connection) + .execute(connection.db()) .unwrap(); let error = run(&mut connection).unwrap_err(); diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index b62ff7f..0fccec5 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -47,9 +47,24 @@ pub enum StorageError { SchemaTooRecent(String), #[error("Migration failed: {0}")] Migration(String), + /// Deriving a key, sealing a value, or opening one that will not open. + #[error("Vault error: {0}")] + Vault(String), + /// ⚠️ Says only that: which of the passphrase and the file is wrong is not something + /// to help a caller narrow down. + #[error("Wrong passphrase")] + WrongPassphrase, + /// A protected export was offered without the phrase that opens it. Not a failure — + /// the interface has to ask, and nothing could know before looking inside. + #[error("This file is protected by a passphrase")] + PassphraseRequired, /// A command panicked while holding the connection. #[error("Storage unavailable: a previous operation failed")] Unavailable, + /// ⚠️ A command reached the library before the passphrase did. The front gates on the + /// unlock screen, so this is a caller that jumped the queue rather than a state. + #[error("The library is locked")] + Locked, /// `#[from]`: required by `Connection::transaction`. #[error("Storage error: {0}")] Sqlite(#[from] diesel::result::Error), @@ -57,7 +72,7 @@ pub enum StorageError { /// ⚠️ Adding a variant breaks the front-end build until `CODE_KEYS` /// (`core/services/errors/error-notifier.service.ts`) and both locales have their key. -#[derive(Debug, Clone, Copy, Serialize, Type)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Type)] #[serde(rename_all = "camelCase")] pub enum ErrorCode { NoteNotFound, @@ -70,6 +85,13 @@ pub enum ErrorCode { InvalidInput, /// Poisoned mutex: a command panicked while holding the connection. StorageUnavailable, + /// The one the unlock screen acts on: it clears the field rather than banishing the + /// user to a banner. + WrongPassphrase, + /// A command ran before the library was unlocked. + Locked, + /// The import needs the phrase the export was protected with. + PassphraseRequired, Storage, } @@ -134,11 +156,15 @@ impl From for AppError { Self::with(ErrorCode::AttachmentNotFound, detail, "id", &id) } StorageError::Unavailable => Self::new(ErrorCode::StorageUnavailable, detail), + StorageError::WrongPassphrase => Self::new(ErrorCode::WrongPassphrase, detail), + StorageError::PassphraseRequired => Self::new(ErrorCode::PassphraseRequired, detail), + StorageError::Locked => Self::new(ErrorCode::Locked, detail), StorageError::File(_) => Self::new(ErrorCode::FileAccess, detail), StorageError::ImportFormat(_) => Self::new(ErrorCode::ImportFormat, detail), // Nothing here gives the front anything to do beyond reporting the failure. StorageError::SchemaTooRecent(_) | StorageError::Migration(_) + | StorageError::Vault(_) | StorageError::CorruptRow { .. } | StorageError::Sqlite(_) => Self::new(ErrorCode::Storage, detail), } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 27cbf06..5f6dd2b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ pub mod error; pub mod notes; pub mod spaces; pub mod transfer; +pub mod vault; pub(crate) mod app_info; pub(crate) mod closed_enum; @@ -31,7 +32,8 @@ use notes::{ tag_notes, update_note, }; use spaces::{create_space, delete_space, list_spaces, pin_space, rename_space}; -use transfer::{export_notes, export_selection, import_notes, share_notes}; +use transfer::{export_is_protected, export_notes, export_selection, import_notes, share_notes}; +use vault::{change_passphrase, create_vault, unlock_vault, vault_state}; /// ⚠️ Resolved from the manifest: a relative path writes the file next to whatever the /// current directory happens to be, without saying a word. @@ -87,6 +89,11 @@ fn ipc_builder() -> Builder { export_selection, import_notes, share_notes, + export_is_protected, + vault_state, + create_vault, + unlock_vault, + change_passphrase, app_changelog, sync_tray, set_global_shortcuts, @@ -185,23 +192,29 @@ fn setup(app: &tauri::App) -> Result<(), Box> { let directory = app.path().app_data_dir()?; std::fs::create_dir_all(&directory)?; - let connection = db::open(&directory.join(db::DB_FILE_NAME))?; - app.manage(db::Db::new(connection)); - - sweep(app.handle()); + // ⚠️ Nothing is opened here any more: the key comes from a passphrase the front end + // has not asked for yet. `vault::unlock` is what fills this and runs the sweeps. + app.manage(db::Db::new(None)); Ok(()) } /// What makes retention hold even if nobody opens the trash. Neither sweep is fatal: /// the application has to start. -fn sweep(handle: &tauri::AppHandle) { +/// +/// ⚠️ Moved behind the unlock with the database itself. A sweep needs to read the notes, +/// and before the passphrase there is nothing to read. +pub(crate) fn sweep(handle: &tauri::AppHandle) { let db = handle.state::(); notes::trash::sweep_at_startup(handle, &db); if let Err(error) = attachments::sweep_orphan_files(handle, &db) { log::warn!("Orphan attachment files not swept: {error}"); } + // ⚠️ The decrypted copies `open_attachment` had to write. They cannot be deleted on + // close — the application that opened one still holds it — so this is the guarantee: + // gone by the next launch. + attachments::sealed::sweep_plaintext(handle); } /// ⚠️ Both are refused when there is no tray to find the window in (see `desktop`). @@ -239,8 +252,18 @@ pub fn run() { .setup(|app| setup(app)) .on_window_event(on_window_event) .invoke_handler(builder.invoke_handler()) - .run(tauri::generate_context!()) - .expect("error while launching the Tauri application"); + .build(tauri::generate_context!()) + .expect("error while launching the Tauri application") + .run(|handle, event| { + // ⚠️ Built and run rather than `run` alone, for this one event: a decrypted + // copy handed to another application should not outlive the session that + // asked for it. Best effort by design — one the desktop still holds is + // locked and stays, and a crash reaches none of this, which is what the + // sweep at launch is for. + if matches!(event, tauri::RunEvent::Exit) { + attachments::sealed::sweep_plaintext(handle); + } + }); } #[cfg(test)] diff --git a/src-tauri/src/notes.rs b/src-tauri/src/notes.rs index 03c9841..c1582f2 100644 --- a/src-tauri/src/notes.rs +++ b/src-tauri/src/notes.rs @@ -50,12 +50,11 @@ pub(crate) mod fixtures { use std::collections::BTreeMap; use chrono::Utc; -use diesel::SqliteConnection; use tauri::{AppHandle, State}; use crate::attachments; use crate::count::saturating_u32 as count; -use crate::db::{Db, lock}; +use crate::db::{Db, Library, lock}; use crate::error::{AppError, StorageError}; use model::{DisplayNote, NoteDraft, NotePatch, TagUsage}; use trash::TrashedNote; @@ -294,10 +293,7 @@ pub fn set_global_placeholders( /// What only the database knows. Both are queries of their own, which is why neither /// lives in [`model::decorate`]. -fn display( - connection: &mut SqliteConnection, - note: model::Note, -) -> Result { +fn display(connection: &mut Library, note: model::Note) -> Result { let mut decorated = model::decorate_now(note); decorated.attachment_count = attachments::store::count_for(connection, &decorated.id)?; model::apply_global_defaults( diff --git a/src-tauri/src/notes/store.rs b/src-tauri/src/notes/store.rs index 838db4b..2c305c2 100644 --- a/src-tauri/src/notes/store.rs +++ b/src-tauri/src/notes/store.rs @@ -12,10 +12,11 @@ use super::checklist; use super::model::{self, Note, NoteDraft, NoteLifecycle, NotePatch}; use super::placeholder; use super::view::{Facets, NoteFilter, NotesQuery}; -use crate::db::iso8601; use crate::db::schema::{global_placeholders, note_tags, notes}; +use crate::db::{Library, iso8601}; use crate::error::StorageError; use crate::spaces::store as spaces; +use crate::vault::key::Vault; #[derive(Queryable, Selectable, Insertable)] #[diesel(table_name = notes)] @@ -35,13 +36,15 @@ pub(super) struct NoteRow { kind: String, } +/// ⚠️ Not a `TryFrom`: opening a row needs the key, and a trait cannot take one. The +/// same goes for [`NoteRow::seal`] in the other direction. +/// /// An unreadable date fails the read: these columns are only ever written by /// [`iso8601::format`]. Language and `kind` degrade instead — a newer version may have -/// written a value this build does not know. -impl TryFrom for Note { - type Error = StorageError; - - fn try_from(row: NoteRow) -> Result { +/// written a value this build does not know. ⚠️ A value that will not open does **not** +/// degrade: a wrong key must stop the read rather than hand back plausible emptiness. +impl NoteRow { + fn open(row: Self, vault: &Vault) -> Result { let instant = |field: &'static str, value: &str| { iso8601::parse(value).map_err(|_| StorageError::CorruptRow { id: row.id.clone(), @@ -57,16 +60,16 @@ impl TryFrom for Note { _ => NoteLifecycle::Permanent, }; - Ok(Self { + Ok(Note { created_at: instant("createdAt", &row.created_at)?, updated_at: instant("updatedAt", &row.updated_at)?, language: row.language.parse().unwrap_or_default(), kind: row.kind.parse().unwrap_or_default(), + title: vault.open(&row.title)?, + content: vault.open(&row.content)?, + source: vault.open(&row.source)?, id: row.id, space_id: row.space_id, - title: row.title, - content: row.content, - source: row.source, tags: Vec::new(), items: Vec::new(), placeholder_values: BTreeMap::new(), @@ -74,32 +77,41 @@ impl TryFrom for Note { lifecycle, }) } -} -impl From<&Note> for NoteRow { - fn from(note: &Note) -> Self { + /// ⚠️ `space_id`, the instants, `pinned`, `language` and `kind` stay in the clear: + /// every one of them is filtered, ordered or grouped on in SQL, and sealing one would + /// move that work into Rust for no secret. What is sealed is what a reader would want. + fn seal(note: &Note, vault: &Vault) -> Result { let (lifecycle_kind, lifecycle_expires_at) = match note.lifecycle { NoteLifecycle::Permanent => ("permanent", None), NoteLifecycle::Expires { at } => ("expires", Some(iso8601::format(at))), }; - Self { + Ok(Self { id: note.id.clone(), space_id: note.space_id.clone(), - title: note.title.clone(), + title: vault.seal(¬e.title)?, language: note.language.to_string(), - content: note.content.clone(), - source: note.source.clone(), + content: vault.seal(¬e.content)?, + source: vault.seal(¬e.source)?, pinned: note.pinned, created_at: iso8601::format(note.created_at), updated_at: iso8601::format(note.updated_at), lifecycle_kind: lifecycle_kind.to_string(), lifecycle_expires_at, kind: note.kind.to_string(), - } + }) } } +/// ⚠️ Every row or none: a value that will not open stops the read rather than handing +/// back a note with an empty body. A wrong key is not a degraded note. +fn open_all(rows: Vec, vault: &Vault) -> Result, StorageError> { + rows.into_iter() + .map(|row| NoteRow::open(row, vault)) + .collect() +} + pub(super) fn notes_of_space( space_id: &str, ) -> diesel::helper_types::Filter< @@ -111,34 +123,39 @@ pub(super) fn notes_of_space( .filter(notes::space_id.eq(space_id.to_string())) } +/// ⚠️ The value is sealed like a note's own, the name is not: the name is the key rows +/// are found by, and a variable called `host` is worth a good deal less than what it holds. pub fn global_placeholder_values( - connection: &mut SqliteConnection, + connection: &mut Library, ) -> Result, StorageError> { - Ok(global_placeholders::table + let (db, vault) = connection.split(); + + global_placeholders::table .select((global_placeholders::name, global_placeholders::value)) .order(global_placeholders::name.asc()) - .load::<(String, String)>(connection)? + .load::<(String, String)>(db)? .into_iter() - .collect()) + .map(|(name, value)| Ok((name, vault.open(&value)?))) + .collect::, StorageError>>() } pub fn replace_global_placeholder_values( - connection: &mut SqliteConnection, + connection: &mut Library, values: &BTreeMap, ) -> Result<(), StorageError> { - connection.transaction(|connection| { + connection.transaction(|connection, vault| { diesel::delete(global_placeholders::table).execute(connection)?; if !values.is_empty() { let rows: Vec<_> = values .iter() .map(|(name, value)| { - ( + Ok(( global_placeholders::name.eq(name), - global_placeholders::value.eq(value), - ) + global_placeholders::value.eq(vault.seal(value)?), + )) }) - .collect(); + .collect::, StorageError>>()?; diesel::insert_into(global_placeholders::table) .values(rows) .execute(connection)?; @@ -149,10 +166,7 @@ pub fn replace_global_placeholder_values( } /// Scoped to the space and not to the current filter — see [`NotesView`]. -fn facets( - connection: &mut SqliteConnection, - space_id: Option<&str>, -) -> Result { +fn facets(connection: &mut Library, space_id: Option<&str>) -> Result { let mut tags = note_tags::table .inner_join(notes::table) .filter(notes::deleted_at.is_null()) @@ -173,10 +187,10 @@ fn facets( } Ok(Facets { - tags: tags.load::(connection)?, + tags: tags.load::(connection.db())?, // A stored language this build does not know has no facet to offer. languages: languages - .load::(connection)? + .load::(connection.db())? .iter() .filter_map(|language| language.parse().ok()) .collect(), @@ -185,7 +199,7 @@ fn facets( /// Coarse criteria only; `view::build` takes over for search and sections. pub fn fetch( - connection: &mut SqliteConnection, + connection: &mut Library, request: &NotesQuery, ) -> Result<(Vec, Facets), StorageError> { let mut query = notes::table @@ -222,19 +236,21 @@ pub fn fetch( // On `updated_at` although the sections group on `created_at`: the section says when // a note was born, the order within it which one moved last. - let mut notes = query + let rows = query .order((notes::updated_at.desc(), notes::id.asc())) - .load::(connection)? - .into_iter() - .map(Note::try_from) - .collect::, _>>()?; - - related::attach_related(connection, &mut notes, request.space_id.as_deref())?; + .load::(connection.db())?; + let (db, vault) = connection.split(); + let mut notes = open_all(rows, vault)?; + related::attach_related(db, vault, &mut notes, request.space_id.as_deref())?; Ok((notes, facets(connection, request.space_id.as_deref())?)) } -fn find(connection: &mut SqliteConnection, id: &str) -> Result, StorageError> { +fn find( + connection: &mut SqliteConnection, + vault: &Vault, + id: &str, +) -> Result, StorageError> { let Some(row) = notes::table .find(id) .filter(notes::deleted_at.is_null()) @@ -245,20 +261,24 @@ fn find(connection: &mut SqliteConnection, id: &str) -> Result, Sto return Ok(None); }; + let tags = related::tags_of(connection, id)?; + let items = related::items_of(connection, vault, id)?; + let placeholder_values = related::placeholder_values_of(connection, vault, id)?; + Ok(Some(Note { - tags: related::tags_of(connection, id)?, - items: related::items_of(connection, id)?, - placeholder_values: related::placeholder_values_of(connection, id)?, - ..Note::try_from(row)? + tags, + items, + placeholder_values, + ..NoteRow::open(row, vault)? })) } pub fn create( - connection: &mut SqliteConnection, + connection: &mut Library, draft: NoteDraft, now: DateTime, ) -> Result { - connection.transaction(|connection| { + connection.transaction(|connection, vault| { if !spaces::exists(connection, &draft.space_id)? { return Err(StorageError::SpaceNotFound(draft.space_id)); } @@ -266,24 +286,24 @@ pub fn create( let mut note = draft.into_note(Uuid::new_v4().to_string(), now); diesel::insert_into(notes::table) - .values(NoteRow::from(¬e)) + .values(NoteRow::seal(¬e, vault)?) .execute(connection)?; let written = std::mem::take(&mut note.tags); note.tags = related::replace_tags(connection, ¬e.id, &written)?; - related::replace_items(connection, ¬e.id, ¬e.items)?; + related::replace_items(connection, vault, ¬e.id, ¬e.items)?; Ok(note) }) } pub fn update( - connection: &mut SqliteConnection, + connection: &mut Library, id: &str, patch: &NotePatch, now: DateTime, ) -> Result { - connection.transaction(|connection| { - let Some(mut note) = find(connection, id)? else { + connection.transaction(|connection, vault| { + let Some(mut note) = find(connection, vault, id)? else { return Err(StorageError::NoteNotFound(id.to_string())); }; @@ -297,7 +317,7 @@ pub fn update( // Columns listed rather than an `AsChangeset`, which would also rewrite // `created_at`. - let row = NoteRow::from(¬e); + let row = NoteRow::seal(¬e, vault)?; diesel::update(notes::table.find(¬e.id)) .set(( notes::space_id.eq(&row.space_id), @@ -319,7 +339,7 @@ pub fn update( } if patch.items.is_some() { - related::replace_items(connection, ¬e.id, ¬e.items)?; + related::replace_items(connection, vault, ¬e.id, ¬e.items)?; } Ok(note) @@ -329,16 +349,16 @@ pub fn update( /// ⚠️ `updated_at` is not touched: filling a field is not editing the note, and the /// canvas sorts on that column. pub fn set_placeholder_values( - connection: &mut SqliteConnection, + connection: &mut Library, id: &str, values: &BTreeMap, ) -> Result { - connection.transaction(|connection| { - let Some(mut note) = find(connection, id)? else { + connection.transaction(|connection, vault| { + let Some(mut note) = find(connection, vault, id)? else { return Err(StorageError::NoteNotFound(id.to_string())); }; - related::replace_placeholder_values(connection, id, values)?; + related::replace_placeholder_values(connection, vault, id, values)?; note.placeholder_values = values.clone(); Ok(note) @@ -346,7 +366,7 @@ pub fn set_placeholder_values( } pub fn move_many( - connection: &mut SqliteConnection, + connection: &mut Library, ids: &[String], space_id: &str, now: DateTime, @@ -355,7 +375,7 @@ pub fn move_many( return Ok(0); } - connection.transaction(|connection| { + connection.transaction(|connection, _vault| { if !spaces::exists(connection, space_id)? { return Err(StorageError::SpaceNotFound(space_id.to_string())); } @@ -375,7 +395,7 @@ pub fn move_many( } pub fn tag_many( - connection: &mut SqliteConnection, + connection: &mut Library, ids: &[String], tags: &[String], now: DateTime, @@ -384,7 +404,7 @@ pub fn tag_many( return Ok(0); } - connection.transaction(|connection| { + connection.transaction(|connection, _vault| { let targets = notes::table .filter(notes::id.eq_any(ids)) .filter(notes::deleted_at.is_null()) @@ -412,20 +432,20 @@ pub fn tag_many( }) } -pub fn tag_usage(connection: &mut SqliteConnection) -> Result, StorageError> { +pub fn tag_usage(connection: &mut Library) -> Result, StorageError> { Ok(note_tags::table .inner_join(notes::table) .filter(notes::deleted_at.is_null()) .group_by(note_tags::tag) .select((note_tags::tag, diesel::dsl::count_star())) .order(note_tags::tag.asc()) - .load::<(String, i64)>(connection)?) + .load::<(String, i64)>(connection.db())?) } /// ⚠️ `updated_at` stays intact — the canvas sorts on it, and a corpus-wide rename would /// float up notes nobody reopened. pub fn retag( - connection: &mut SqliteConnection, + connection: &mut Library, sources: &[String], target: &str, ) -> Result { @@ -433,7 +453,7 @@ pub fn retag( return Ok(0); } - connection.transaction(|connection| { + connection.transaction(|connection, _vault| { let renamed = note_tags::table .filter(note_tags::tag.eq_any(sources)) .select(note_tags::note_id) @@ -469,22 +489,19 @@ pub fn retag( }) } -pub fn drop_tags( - connection: &mut SqliteConnection, - tags: &[String], -) -> Result { +pub fn drop_tags(connection: &mut Library, tags: &[String]) -> Result { if tags.is_empty() { return Ok(0); } - Ok(diesel::delete(note_tags::table.filter(note_tags::tag.eq_any(tags))).execute(connection)?) + Ok( + diesel::delete(note_tags::table.filter(note_tags::tag.eq_any(tags))) + .execute(connection.db())?, + ) } /// Export only: no command hands this list to the front, which would re-filter it. -pub fn all( - connection: &mut SqliteConnection, - space_id: Option<&str>, -) -> Result, StorageError> { +pub fn all(connection: &mut Library, space_id: Option<&str>) -> Result, StorageError> { let mut query = notes::table .filter(notes::deleted_at.is_null()) .select(NoteRow::as_select()) @@ -494,46 +511,46 @@ pub fn all( query = query.filter(notes::space_id.eq(space_id.to_string())); } - let mut notes = query + let rows = query .order((notes::created_at.asc(), notes::id.asc())) - .load::(connection)? - .into_iter() - .map(Note::try_from) - .collect::, _>>()?; - - related::attach_related(connection, &mut notes, space_id)?; + .load::(connection.db())?; + let (db, vault) = connection.split(); + let mut notes = open_all(rows, vault)?; + related::attach_related(db, vault, &mut notes, space_id)?; Ok(notes) } -pub fn by_ids( - connection: &mut SqliteConnection, - ids: &[String], -) -> Result, StorageError> { +pub fn by_ids(connection: &mut Library, ids: &[String]) -> Result, StorageError> { if ids.is_empty() { return Ok(Vec::new()); } - let mut notes = notes::table + let rows = notes::table .filter(notes::id.eq_any(ids)) .filter(notes::deleted_at.is_null()) .select(NoteRow::as_select()) .order((notes::created_at.asc(), notes::id.asc())) - .load::(connection)? - .into_iter() - .map(Note::try_from) - .collect::, _>>()?; - - related::attach_related(connection, &mut notes, None)?; + .load::(connection.db())?; + let (db, vault) = connection.split(); + let mut notes = open_all(rows, vault)?; + related::attach_related(db, vault, &mut notes, None)?; Ok(notes) } -pub fn insert_imported( +pub fn insert_imported(connection: &mut Library, note: &Note) -> Result { + connection.transaction(|connection, vault| insert_imported_in(connection, vault, note)) +} + +/// For a caller already inside a transaction: an import is one transaction for the whole +/// file, and every note it brings in runs inside it. +pub(crate) fn insert_imported_in( connection: &mut SqliteConnection, + vault: &Vault, note: &Note, ) -> Result { - connection.transaction(|connection| { + { let taken = notes::table .find(¬e.id) .select(notes::id) @@ -545,20 +562,22 @@ pub fn insert_imported( } diesel::insert_into(notes::table) - .values(NoteRow::from(note)) + .values(NoteRow::seal(note, vault)?) .execute(connection)?; related::replace_tags(connection, ¬e.id, &model::normalize_tags(¬e.tags))?; related::replace_items( connection, + vault, ¬e.id, &checklist::normalize_items(¬e.items), )?; related::replace_placeholder_values( connection, + vault, ¬e.id, &placeholder::normalize_values(note.placeholder_values.clone()), )?; Ok(true) - }) + } } diff --git a/src-tauri/src/notes/store/related.rs b/src-tauri/src/notes/store/related.rs index 3ebeec7..b2d340b 100644 --- a/src-tauri/src/notes/store/related.rs +++ b/src-tauri/src/notes/store/related.rs @@ -10,6 +10,7 @@ use crate::db::schema::{note_items, note_placeholders, note_tags}; use crate::error::StorageError; use crate::notes::checklist::ChecklistItem; use crate::notes::model::Note; +use crate::vault::key::Vault; /// ⚠️ Narrowed by subquery, not by a list of bound ids: binding one parameter per note /// measured slower than reading the table whole past a few thousand notes. Reading a @@ -70,6 +71,7 @@ pub fn replace_tags( pub fn all_items( connection: &mut SqliteConnection, + vault: &Vault, space_id: Option<&str>, ) -> Result>, StorageError> { let mut query = note_items::table @@ -83,10 +85,10 @@ pub fn all_items( let mut grouped: HashMap> = HashMap::new(); for (note_id, text, done) in query.load::<(String, String, bool)>(connection)? { - grouped - .entry(note_id) - .or_default() - .push(ChecklistItem { text, done }); + grouped.entry(note_id).or_default().push(ChecklistItem { + text: vault.open(&text)?, + done, + }); } Ok(grouped) @@ -94,22 +96,29 @@ pub fn all_items( pub fn items_of( connection: &mut SqliteConnection, + vault: &Vault, note_id: &str, ) -> Result, StorageError> { - Ok(note_items::table + note_items::table .filter(note_items::note_id.eq(note_id)) .select((note_items::text, note_items::done)) .order(note_items::position.asc()) .load::<(String, bool)>(connection)? .into_iter() - .map(|(text, done)| ChecklistItem { text, done }) - .collect()) + .map(|(text, done)| { + Ok(ChecklistItem { + text: vault.open(&text)?, + done, + }) + }) + .collect() } /// Wiped then reinserted: the position is part of the key, so reordering would otherwise /// move rows one at a time under a key that refuses duplicates. pub fn replace_items( connection: &mut SqliteConnection, + vault: &Vault, note_id: &str, items: &[ChecklistItem], ) -> Result<(), StorageError> { @@ -121,14 +130,14 @@ pub fn replace_items( .iter() .enumerate() .map(|(position, item)| { - ( + Ok(( note_items::note_id.eq(note_id), note_items::position.eq(i32::try_from(position).unwrap_or(i32::MAX)), - note_items::text.eq(&item.text), + note_items::text.eq(vault.seal(&item.text)?), note_items::done.eq(item.done), - ) + )) }) - .collect(); + .collect::, StorageError>>()?; diesel::insert_into(note_items::table) .values(rows) .execute(connection)?; @@ -138,6 +147,7 @@ pub fn replace_items( } pub fn attach_related( connection: &mut SqliteConnection, + vault: &Vault, notes: &mut [Note], space_id: Option<&str>, ) -> Result<(), StorageError> { @@ -146,8 +156,8 @@ pub fn attach_related( } let mut tags = all_tags(connection, space_id)?; - let mut items = all_items(connection, space_id)?; - let mut values = all_placeholder_values(connection, space_id)?; + let mut items = all_items(connection, vault, space_id)?; + let mut values = all_placeholder_values(connection, vault, space_id)?; for note in notes { note.tags = tags.remove(¬e.id).unwrap_or_default(); @@ -160,6 +170,7 @@ pub fn attach_related( pub fn all_placeholder_values( connection: &mut SqliteConnection, + vault: &Vault, space_id: Option<&str>, ) -> Result>, StorageError> { let mut query = note_placeholders::table @@ -176,7 +187,10 @@ pub fn all_placeholder_values( let mut grouped: HashMap> = HashMap::new(); for (note_id, name, value) in query.load::<(String, String, String)>(connection)? { - grouped.entry(note_id).or_default().insert(name, value); + grouped + .entry(note_id) + .or_default() + .insert(name, vault.open(&value)?); } Ok(grouped) @@ -184,18 +198,21 @@ pub fn all_placeholder_values( pub fn placeholder_values_of( connection: &mut SqliteConnection, + vault: &Vault, note_id: &str, ) -> Result, StorageError> { - Ok(note_placeholders::table + note_placeholders::table .filter(note_placeholders::note_id.eq(note_id)) .select((note_placeholders::name, note_placeholders::value)) .load::<(String, String)>(connection)? .into_iter() - .collect()) + .map(|(name, value)| Ok((name, vault.open(&value)?))) + .collect::, StorageError>>() } pub fn replace_placeholder_values( connection: &mut SqliteConnection, + vault: &Vault, note_id: &str, values: &BTreeMap, ) -> Result<(), StorageError> { @@ -206,13 +223,13 @@ pub fn replace_placeholder_values( let rows: Vec<_> = values .iter() .map(|(name, value)| { - ( + Ok(( note_placeholders::note_id.eq(note_id), note_placeholders::name.eq(name), - note_placeholders::value.eq(value), - ) + note_placeholders::value.eq(vault.seal(value)?), + )) }) - .collect(); + .collect::, StorageError>>()?; diesel::insert_into(note_placeholders::table) .values(rows) .execute(connection)?; diff --git a/src-tauri/src/notes/store/trash.rs b/src-tauri/src/notes/store/trash.rs index de46754..42f8c79 100644 --- a/src-tauri/src/notes/store/trash.rs +++ b/src-tauri/src/notes/store/trash.rs @@ -8,6 +8,7 @@ use chrono::{DateTime, Utc}; use diesel::prelude::*; use super::{NoteRow, related}; +use crate::db::Library; use crate::db::iso8601; use crate::db::schema::notes; use crate::error::StorageError; @@ -17,11 +18,7 @@ use crate::notes::trash; /// ⚠️ Stamps `deleted_at`; the row survives for [`trash::RETENTION`]. [`purge`] is what /// erases — `delete` is the word `spaces::store` and `attachments::store` use for an /// irreversible one. -pub fn trash( - connection: &mut SqliteConnection, - id: &str, - now: DateTime, -) -> Result<(), StorageError> { +pub fn trash(connection: &mut Library, id: &str, now: DateTime) -> Result<(), StorageError> { if trash_many(connection, std::slice::from_ref(&id.to_string()), now)? == 0 { return Err(StorageError::NoteNotFound(id.to_string())); } @@ -32,7 +29,7 @@ pub fn trash( /// Returns what was actually moved: a selection can hold an id gone stale, and failing /// the whole batch for one of them would be worse than a partial result. pub fn trash_many( - connection: &mut SqliteConnection, + connection: &mut Library, ids: &[String], now: DateTime, ) -> Result { @@ -46,14 +43,11 @@ pub fn trash_many( .filter(notes::deleted_at.is_null()), ) .set(notes::deleted_at.eq(iso8601::format(now))) - .execute(connection)?) + .execute(connection.db())?) } /// `updated_at` is not touched: the note comes back where it was. -pub fn restore_many( - connection: &mut SqliteConnection, - ids: &[String], -) -> Result { +pub fn restore_many(connection: &mut Library, ids: &[String]) -> Result { if ids.is_empty() { return Ok(0); } @@ -64,19 +58,18 @@ pub fn restore_many( .filter(notes::deleted_at.is_not_null()), ) .set(notes::deleted_at.eq(None::)) - .execute(connection)?) + .execute(connection.db())?) } -pub fn list_trashed( - connection: &mut SqliteConnection, -) -> Result)>, StorageError> { +pub fn list_trashed(connection: &mut Library) -> Result)>, StorageError> { let rows = notes::table .filter(notes::deleted_at.is_not_null()) .select((NoteRow::as_select(), notes::deleted_at)) .order((notes::deleted_at.desc(), notes::id.asc())) - .load::<(NoteRow, Option)>(connection)?; + .load::<(NoteRow, Option)>(connection.db())?; let mut grouped = related::all_tags(connection, None)?; + let vault = connection.vault(); rows.into_iter() .map(|(row, deleted_at)| { let id = row.id.clone(); @@ -89,7 +82,7 @@ pub fn list_trashed( Ok(( Note { tags: grouped.remove(&id).unwrap_or_default(), - ..Note::try_from(row)? + ..NoteRow::open(row, vault)? }, deleted_at, )) @@ -99,13 +92,13 @@ pub fn list_trashed( /// Separate from [`purge`] so the caller can erase the attached files first. pub fn expired_ids( - connection: &mut SqliteConnection, + connection: &mut Library, now: DateTime, ) -> Result, StorageError> { let rows = notes::table .filter(notes::deleted_at.is_not_null()) .select((notes::id, notes::deleted_at)) - .load::<(String, Option)>(connection)?; + .load::<(String, Option)>(connection.db())?; Ok(rows .into_iter() @@ -116,16 +109,16 @@ pub fn expired_ids( .collect()) } -pub fn trashed_ids(connection: &mut SqliteConnection) -> Result, StorageError> { +pub fn trashed_ids(connection: &mut Library) -> Result, StorageError> { Ok(notes::table .filter(notes::deleted_at.is_not_null()) .select(notes::id) - .load::(connection)?) + .load::(connection.db())?) } /// Permanent. Tags and attachments leave by cascade — hence the `PRAGMA foreign_keys` in /// `db::configure`. ⚠️ Restricted to trashed notes: nothing may short-circuit the reprieve. -pub fn purge(connection: &mut SqliteConnection, ids: &[String]) -> Result { +pub fn purge(connection: &mut Library, ids: &[String]) -> Result { if ids.is_empty() { return Ok(0); } @@ -135,5 +128,5 @@ pub fn purge(connection: &mut SqliteConnection, ids: &[String]) -> Result Result, StorageError> { - let rows = spaces::table +/// Pinned first, then by name — the same shape the canvas gives notes. Folded for the +/// comparison, so "personal" does not land after "Zebra". +fn in_display_order(spaces: &mut [Space]) { + spaces.sort_by(|left, right| { + right + .pinned + .cmp(&left.pinned) + .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) + }); +} + +fn all(connection: &mut SqliteConnection, vault: &Vault) -> Result, StorageError> { + spaces::table .select((spaces::id, spaces::name, spaces::pinned)) - // Pinned first, then by name — the same shape the canvas gives notes. - .order(spaces::pinned.desc()) - // ⚠️ Raw fragment: Diesel does not model collations, and sorting as BINARY would - // place "personal" after "Zebra". - .then_order_by(sql::("name COLLATE NOCASE")) - .load::<(String, String, bool)>(connection)?; - - Ok(rows + .load::<(String, String, bool)>(connection)? .into_iter() - .map(|(id, name, pinned)| Space { id, name, pinned }) - .collect()) + .map(|(id, name, pinned)| { + Ok(Space { + id, + name: vault.open(&name)?, + pinned, + }) + }) + .collect() +} + +/// An empty list is valid: it is the state of the first launch. +pub fn list(connection: &mut Library) -> Result, StorageError> { + let (db, vault) = connection.split(); + + list_in(db, vault) +} + +/// For a caller already inside a transaction, which holds the two halves apart. +pub(crate) fn list_in( + connection: &mut SqliteConnection, + vault: &Vault, +) -> Result, StorageError> { + let mut spaces = all(connection, vault)?; + in_display_order(&mut spaces); + + Ok(spaces) } /// Reads back, so a rename does not quietly drop whether the space was pinned. -fn find(connection: &mut SqliteConnection, id: &str) -> Result { +fn find(connection: &mut SqliteConnection, vault: &Vault, id: &str) -> Result { spaces::table .find(id) .select((spaces::id, spaces::name, spaces::pinned)) .first::<(String, String, bool)>(connection) .optional()? - .map(|(id, name, pinned)| Space { id, name, pinned }) + .map(|(id, name, pinned)| -> Result { + Ok(Space { + id, + name: vault.open(&name)?, + pinned, + }) + }) + .transpose()? .ok_or_else(|| StorageError::SpaceNotFound(id.to_string())) } -pub fn set_pinned( - connection: &mut SqliteConnection, - id: &str, - pinned: bool, -) -> Result { - connection.transaction(|connection| { +pub fn set_pinned(connection: &mut Library, id: &str, pinned: bool) -> Result { + connection.transaction(|connection, vault| { if !exists(connection, id)? { return Err(StorageError::SpaceNotFound(id.to_string())); } @@ -49,7 +86,7 @@ pub fn set_pinned( .set(spaces::pinned.eq(pinned)) .execute(connection)?; - find(connection, id) + find(connection, vault, id) }) } @@ -67,26 +104,21 @@ pub fn exists(connection: &mut SqliteConnection, id: &str) -> Result, ) -> Result<(), StorageError> { - // ⚠️ `spaces.name` is not declared `NOCASE` — only the unique index is — so the - // collation must be set on the comparison, or "PERSONAL" would miss "Personal". - let mut query = spaces::table - .filter( - sql::("name = ") - .bind::(name.to_string()) - .sql(" COLLATE NOCASE"), - ) - .into_boxed(); - - if let Some(id) = except_id { - query = query.filter(spaces::id.ne(id.to_string())); - } + let taken = all(connection, vault)?.into_iter().any(|space| { + Some(space.id.as_str()) != except_id && space.name.to_lowercase() == name.to_lowercase() + }); - if query.count().get_result::(connection)? > 0 { + if taken { return Err(StorageError::DuplicateSpaceName(name.to_string())); } @@ -95,9 +127,18 @@ fn ensure_unique_name( /// `name` is expected already validated: this layer only decides uniqueness, and the /// transaction is what pairs the check with the write. -pub fn create(connection: &mut SqliteConnection, name: &str) -> Result { - connection.transaction(|connection| { - ensure_unique_name(connection, name, None)?; +pub fn create(connection: &mut Library, name: &str) -> Result { + connection.transaction(|connection, vault| create_in(connection, vault, name)) +} + +/// For a caller already inside a transaction — an import creates the spaces it needs. +pub(crate) fn create_in( + connection: &mut SqliteConnection, + vault: &Vault, + name: &str, +) -> Result { + { + ensure_unique_name(connection, vault, name, None)?; let space = Space { id: Uuid::new_v4().to_string(), @@ -106,42 +147,37 @@ pub fn create(connection: &mut SqliteConnection, name: &str) -> Result Result { - connection.transaction(|connection| { +pub fn rename(connection: &mut Library, id: &str, name: &str) -> Result { + connection.transaction(|connection, vault| { if !exists(connection, id)? { return Err(StorageError::SpaceNotFound(id.to_string())); } - ensure_unique_name(connection, name, Some(id))?; + ensure_unique_name(connection, vault, name, Some(id))?; diesel::update(spaces::table.find(id)) - .set(spaces::name.eq(name)) + .set(spaces::name.eq(vault.seal(name)?)) .execute(connection)?; - find(connection, id) + find(connection, vault, id) }) } /// ⚠️ Same transaction and this order: `notes.space_id` has an `ON DELETE CASCADE`, so /// deleting first — or failing between the two — sweeps away the notes instead of moving /// them. `updated_at` is not refreshed, or the absorbed space floats to the top. -pub fn delete( - connection: &mut SqliteConnection, - id: &str, - target_id: &str, -) -> Result<(), StorageError> { - connection.transaction(|connection| { +pub fn delete(connection: &mut Library, id: &str, target_id: &str) -> Result<(), StorageError> { + connection.transaction(|connection, _vault| { if !exists(connection, id)? { return Err(StorageError::SpaceNotFound(id.to_string())); } diff --git a/src-tauri/src/transfer.rs b/src-tauri/src/transfer.rs index a53d9ca..ed439f5 100644 --- a/src-tauri/src/transfer.rs +++ b/src-tauri/src/transfer.rs @@ -3,9 +3,11 @@ pub mod bundle; pub mod file; pub mod model; +pub mod protect; -use tauri::State; +use tauri::{AppHandle, State}; +use crate::attachments; use crate::db::{Db, lock}; use crate::error::AppError; use crate::notes::store as notes; @@ -17,17 +19,23 @@ use model::{ExportReport, ImportReport}; pub fn export_notes( path: String, space_id: Option, + passphrase: Option, + app: AppHandle, db: State<'_, Db>, ) -> Result { model::validate_path(&path)?; - let exported = { - let mut connection = lock(&db)?; - let notes = notes::all(&mut connection, space_id.as_deref())?; - bundle::collect(&mut connection, notes)? - }; + let mut connection = lock(&db)?; + let notes = notes::all(&mut connection, space_id.as_deref())?; + let exported = bundle::collect(&mut connection, notes)?; - file::write(&path, &exported) + file::write( + &path, + &exported, + &attachments::directory(&app)?, + connection.vault(), + passphrase.as_deref(), + ) } #[tauri::command(async)] @@ -35,31 +43,57 @@ pub fn export_notes( pub fn export_selection( path: String, ids: Vec, + passphrase: Option, + app: AppHandle, db: State<'_, Db>, ) -> Result { model::validate_path(&path)?; - let exported = { - let mut connection = lock(&db)?; - let notes = notes::by_ids(&mut connection, &ids)?; - bundle::collect(&mut connection, notes)? - }; + let mut connection = lock(&db)?; + let notes = notes::by_ids(&mut connection, &ids)?; + let exported = bundle::collect(&mut connection, notes)?; - file::write(&path, &exported) + file::write( + &path, + &exported, + &attachments::directory(&app)?, + connection.vault(), + passphrase.as_deref(), + ) } /// ⚠️ The file is read before the lock is taken: parsing a large export while holding the /// connection would block every other command for the length of it. #[tauri::command(async)] #[specta::specta] -pub fn import_notes(path: String, db: State<'_, Db>) -> Result { +pub fn import_notes( + path: String, + passphrase: Option, + app: AppHandle, + db: State<'_, Db>, +) -> Result { model::validate_path(&path)?; - let imported = file::read(&path)?; + let (imported, mut payload) = file::read(&path, passphrase.as_deref())?; + let directory = attachments::directory(&app)?; let mut connection = lock(&db)?; - Ok(bundle::merge(&mut connection, imported)?) + Ok(bundle::merge( + &mut connection, + imported, + &mut payload, + &directory, + )?) +} + +/// Whether an import will want a phrase, so the interface can ask before it starts. +#[tauri::command(async)] +#[specta::specta] +pub fn export_is_protected(path: String) -> Result { + model::validate_path(&path)?; + + file::is_protected(&path) } /// Nothing is sent anywhere: "share" stops at the clipboard. diff --git a/src-tauri/src/transfer/bundle.rs b/src-tauri/src/transfer/bundle.rs index 14b6e5f..d2f6a26 100644 --- a/src-tauri/src/transfer/bundle.rs +++ b/src-tauri/src/transfer/bundle.rs @@ -1,33 +1,38 @@ //! The commands in `transfer.rs` open the file and hold the lock; the rules live here. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; use chrono::Utc; use diesel::SqliteConnection; -use diesel::prelude::*; +use super::file::Payload; use super::model::{self, Bundle, ImportReport, IncomingBundle}; +use crate::attachments::model::Attachment; +use crate::attachments::store as attachments; +use crate::db::Library; use crate::error::StorageError; use crate::notes::model::Note; use crate::notes::store as notes; use crate::spaces::model::Space; use crate::spaces::store as spaces; +use crate::vault::key::Vault; /// Only the spaces actually cited travel with the notes: exporting one space must not /// recreate the whole tree for whoever imports it. -pub fn collect( - connection: &mut SqliteConnection, - exported: Vec, -) -> Result { +pub fn collect(connection: &mut Library, exported: Vec) -> Result { let spaces: Vec = spaces::list(connection)? .into_iter() .filter(|space| exported.iter().any(|note| note.space_id == space.id)) .collect(); + let note_ids: Vec = exported.iter().map(|note| note.id.clone()).collect(); + Ok(Bundle { version: model::FORMAT_VERSION, exported_at: Utc::now(), spaces, + attachments: attachments::for_notes(connection, ¬e_ids)?, notes: exported, }) } @@ -38,16 +43,19 @@ pub fn collect( /// ⚠️ One transaction for the whole file, or a failure halfway leaves spaces created and /// part of the notes in, with the report lost along with the error. pub fn merge( - connection: &mut SqliteConnection, + connection: &mut Library, incoming: IncomingBundle, + payload: &mut Payload, + directory: &Path, ) -> Result { let IncomingBundle { bundle, degraded } = incoming; - connection.transaction(|connection| { + connection.transaction(|connection, vault| { let mut report = ImportReport::default(); + let mut arrived: BTreeSet = BTreeSet::new(); let mut mapping: BTreeMap = BTreeMap::new(); - let existing = spaces::list(connection)?; + let existing = spaces::list_in(connection, vault)?; for space in &bundle.spaces { let matched = existing @@ -58,7 +66,7 @@ pub fn merge( candidate.id.clone() } else { report.spaces_created += 1; - spaces::create(connection, &space.name)?.id + spaces::create_in(connection, vault, &space.name)?.id }; mapping.insert(space.id.clone(), local_id); } @@ -72,8 +80,9 @@ pub fn merge( }; note.space_id.clone_from(space_id); - if notes::insert_imported(connection, ¬e)? { + if notes::insert_imported_in(connection, vault, ¬e)? { report.notes_imported += 1; + arrived.insert(note.id.clone()); // Only what actually came in, or re-importing the same file would keep // reporting the same degradation. if degraded.contains(¬e.id) { @@ -84,14 +93,56 @@ pub fn merge( } } + restore_attachments( + connection, + vault, + bundle.attachments, + &arrived, + payload, + directory, + &mut report, + )?; + Ok(report) }) } -/// The space names an export needs to render a note's breadcrumb. -pub fn space_names( +/// ⚠️ The file is written **before** the record, the rule `attachments.rs` already holds: +/// a record without a file is a broken thumbnail, where a file without a record is swept +/// at the next startup — which is also what collects these when the transaction rolls back. +/// +/// Only attachments whose note actually arrived: one belonging to a skipped note is +/// already in the library, and re-importing the same file has to add nothing. +fn restore_attachments( connection: &mut SqliteConnection, -) -> Result, StorageError> { + vault: &Vault, + records: Vec, + arrived: &BTreeSet, + payload: &mut Payload, + directory: &Path, + report: &mut ImportReport, +) -> Result<(), StorageError> { + for record in records { + if !arrived.contains(&record.note_id) { + continue; + } + + let Some(bytes) = payload.take(&record.stored_name()) else { + report.attachments_missing += 1; + continue; + }; + + std::fs::write(directory.join(record.stored_name()), &bytes) + .map_err(|error| StorageError::File(format!("{}: {error}", record.stored_name())))?; + attachments::create(connection, vault, &record)?; + report.attachments_imported += 1; + } + + Ok(()) +} + +/// The space names an export needs to render a note's breadcrumb. +pub fn space_names(connection: &mut Library) -> Result, StorageError> { Ok(spaces::list(connection)? .into_iter() .map(|space| (space.id, space.name)) diff --git a/src-tauri/src/transfer/file.rs b/src-tauri/src/transfer/file.rs index d59d735..d96f8f4 100644 --- a/src-tauri/src/transfer/file.rs +++ b/src-tauri/src/transfer/file.rs @@ -1,42 +1,336 @@ //! Nothing here knows the database. +//! +//! The export is a zip: the bundle at the root, one entry per attachment under +//! `attachments/`. ⚠️ 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 +//! `serde_json::Value`, then a `Bundle` — three copies of every screenshot in memory. +//! Archive entries are pulled one at a time instead. +//! +//! ⚠️ An export leaves the library's key behind. The attachment files on disk are sealed +//! with a key that never leaves this machine, so they are opened on the way out and then +//! either written in the clear — an unprotected export is portable and readable, which is +//! what the exchange format exists for — or resealed under a key derived from the phrase +//! the user gave this one file. use std::ffi::{OsStr, OsString}; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use uuid::Uuid; +use zip::write::SimpleFileOptions; +use zip::{CompressionMethod, ZipArchive, ZipWriter}; use super::model::{Bundle, ExportReport, IncomingBundle}; +use super::protect::{self, Recipe}; +use crate::attachments::model::Attachment; use crate::count::saturating_u32; use crate::error::{AppError, StorageError}; +use crate::vault::key::Vault; -/// ⚠️ Written beside the target then renamed: `fs::write` truncates first, so an export -/// that ran out of disk destroyed the file it was overwriting. -pub fn write(path: &str, bundle: &Bundle) -> Result { - let report = ExportReport { - notes: saturating_u32(bundle.notes.len()), - spaces: saturating_u32(bundle.spaces.len()), - }; +const BUNDLE_ENTRY: &str = "bundle.json"; +const ATTACHMENTS_ENTRY: &str = "attachments"; + +/// ⚠️ Present only in a protected export, and it **replaces** `bundle.json` rather than +/// sitting beside it: a reader that finds this and cannot open it must not fall back on a +/// plaintext bundle that should not exist. +const SEALED_ENTRY: &str = "bundle.sealed"; + +/// Beside the sealed payload, in the clear: the salt and the cost are what a reader needs +/// to derive the same key, and neither is a secret. +const RECIPE_ENTRY: &str = "recipe.json"; +/// What a zip opens with. An export written before the archive existed is plain JSON and +/// is still read: a new DevBox reads an old file, an old DevBox does not read a new one. +const ZIP_MAGIC: [u8; 4] = [b'P', b'K', 0x03, 0x04]; + +fn file_error(what: &str, error: &std::io::Error) -> StorageError { + StorageError::File(format!("{what}: {error}")) +} + +fn zip_error(error: &zip::result::ZipError) -> StorageError { + StorageError::File(error.to_string()) +} + +fn deflated() -> SimpleFileOptions { + SimpleFileOptions::default().compression_method(CompressionMethod::Deflated) +} + +/// A PNG is compressed already, and ciphertext does not compress at all. +fn stored_as_is() -> SimpleFileOptions { + SimpleFileOptions::default().compression_method(CompressionMethod::Stored) +} + +/// ⚠️ Written beside the target then renamed: a truncating write destroys the previous +/// export the day the disk fills. +/// +/// `passphrase` protects the archive. Without one the file is plaintext — every note, +/// every screenshot — which is what the interface has to say before it writes one. +pub fn write( + path: &str, + bundle: &Bundle, + attachments: &Path, + library: &Vault, + passphrase: Option<&str>, +) -> Result { let json = serde_json::to_string_pretty(bundle) .map_err(|error| StorageError::File(error.to_string()))?; + let protection = passphrase.map(protect::seal_with).transpose()?; + let staged = staging_path(path); - std::fs::write(&staged, json) - .map_err(|error| StorageError::File(format!("{}: {error}", staged.display())))?; + let stored = match archive( + &staged, + &json, + &bundle.attachments, + attachments, + library, + protection.as_ref(), + ) { + Ok(stored) => stored, + Err(error) => { + let _ = std::fs::remove_file(&staged); + return Err(error.into()); + } + }; if let Err(error) = std::fs::rename(&staged, path) { let _ = std::fs::remove_file(&staged); return Err(StorageError::File(format!("{path}: {error}")).into()); } - Ok(report) + Ok(ExportReport { + notes: saturating_u32(bundle.notes.len()), + spaces: saturating_u32(bundle.spaces.len()), + attachments: stored, + protected: passphrase.is_some(), + }) } -pub fn read(path: &str) -> Result { - let json = std::fs::read_to_string(path) - .map_err(|error| StorageError::File(format!("{path}: {error}")))?; +fn archive( + staged: &Path, + json: &str, + records: &[Attachment], + source: &Path, + library: &Vault, + protection: Option<&(Vault, Recipe)>, +) -> Result { + let target = + File::create(staged).map_err(|error| file_error(&staged.display().to_string(), &error))?; + let mut writer = ZipWriter::new(target); + + // The recipe goes in first and in the clear: a reader has to know how to derive the + // key before it can be asked for a phrase. + if let Some((_, recipe)) = protection { + let written = serde_json::to_string_pretty(recipe) + .map_err(|error| StorageError::File(error.to_string()))?; + writer + .start_file(RECIPE_ENTRY, deflated()) + .map_err(|error| zip_error(&error))?; + writer + .write_all(written.as_bytes()) + .map_err(|error| file_error(RECIPE_ENTRY, &error))?; + } + + if let Some((vault, _)) = protection { + writer + .start_file(SEALED_ENTRY, stored_as_is()) + .map_err(|error| zip_error(&error))?; + writer + .write_all(&vault.seal_bytes(json.as_bytes())?) + .map_err(|error| file_error(SEALED_ENTRY, &error))?; + } else { + writer + .start_file(BUNDLE_ENTRY, deflated()) + .map_err(|error| zip_error(&error))?; + writer + .write_all(json.as_bytes()) + .map_err(|error| file_error(BUNDLE_ENTRY, &error))?; + } - Ok(super::model::read_bundle(&json)?) + let mut stored = 0; + for record in records { + let name = record.stored_name(); + + // A record whose file has gone missing leaves the export rather than failing it: + // the note still travels, and `attachments_missing` says so on the way back in. + let Ok(sealed) = std::fs::read(source.join(&name)) else { + continue; + }; + + // Opened under the library's key, then written the way this export travels. + let plain = library.open_bytes(&sealed)?; + let bytes = match protection { + Some((vault, _)) => vault.seal_bytes(&plain)?, + None => plain, + }; + + writer + .start_file(format!("{ATTACHMENTS_ENTRY}/{name}"), stored_as_is()) + .map_err(|error| zip_error(&error))?; + writer + .write_all(&bytes) + .map_err(|error| file_error(&name, &error))?; + stored += 1; + } + + writer.finish().map_err(|error| zip_error(&error))?; + + Ok(stored) +} + +/// The bundle, and whatever carries the attachment bytes that belong with it. +/// +/// ⚠️ Answers [`StorageError::PassphraseRequired`] on a protected file offered without +/// one: nothing can tell a protected archive from an ordinary one until it has looked +/// inside, so looking is this function's job rather than the interface's. +pub fn read(path: &str, passphrase: Option<&str>) -> Result<(IncomingBundle, Payload), AppError> { + let Some(mut archive) = open_archive(path)? else { + let json = std::fs::read_to_string(path).map_err(|error| file_error(path, &error))?; + return Ok((super::model::read_bundle(&json)?, Payload::Empty)); + }; + + let Some(recipe) = read_recipe(&mut archive)? else { + let raw = entry(&mut archive, BUNDLE_ENTRY)?; + let json = String::from_utf8(raw) + .map_err(|_| StorageError::ImportFormat("the bundle is not text".to_string()))?; + + return Ok(( + super::model::read_bundle(&json)?, + Payload::Archive { + archive: Box::new(archive), + vault: None, + }, + )); + }; + + let Some(passphrase) = passphrase else { + return Err(StorageError::PassphraseRequired.into()); + }; + + let vault = protect::open_with(passphrase, &recipe)?; + let sealed = entry(&mut archive, SEALED_ENTRY)?; + + // ⚠️ The payload's own tag is the check: a phrase that does not open it is refused + // here, so the file carries no separate verifier to work against. + let raw = vault + .open_bytes(&sealed) + .map_err(|_| StorageError::WrongPassphrase)?; + let json = String::from_utf8(raw) + .map_err(|_| StorageError::ImportFormat("the bundle is not text".to_string()))?; + + Ok(( + super::model::read_bundle(&json)?, + Payload::Archive { + archive: Box::new(archive), + vault: Some(vault), + }, + )) +} + +/// Whether a file will want a phrase, asked without one so an interface can prompt. +/// +/// ⚠️ The recipe and nothing else. Answering this through [`read`] would parse the whole +/// bundle — a hundred megabytes on a large library — and then throw it away, for the import +/// to parse it again a moment later. +pub fn is_protected(path: &str) -> Result { + let Some(mut archive) = open_archive(path)? else { + return Ok(false); + }; + + Ok(read_recipe(&mut archive)?.is_some()) +} + +/// `None` for a file that is not a zip: a `.json` export written before the archive, which +/// carries no attachments and cannot be protected. +fn open_archive(path: &str) -> Result>, AppError> { + let mut file = File::open(path).map_err(|error| file_error(path, &error))?; + + let mut magic = [0u8; 4]; + let zipped = file.read_exact(&mut magic).is_ok() && magic == ZIP_MAGIC; + file.seek(SeekFrom::Start(0)) + .map_err(|error| file_error(path, &error))?; + + if !zipped { + return Ok(None); + } + + Ok(Some(ZipArchive::new(file).map_err(|error| { + StorageError::ImportFormat(error.to_string()) + })?)) +} + +fn read_recipe(archive: &mut ZipArchive) -> Result, StorageError> { + if archive.by_name(RECIPE_ENTRY).is_err() { + return Ok(None); + } + + let raw = entry(archive, RECIPE_ENTRY)?; + let recipe: Recipe = serde_json::from_slice(&raw) + .map_err(|error| StorageError::ImportFormat(format!("unreadable recipe: {error}")))?; + + Ok(Some(recipe)) +} + +fn entry(archive: &mut ZipArchive, name: &str) -> Result, StorageError> { + let mut entry = archive + .by_name(name) + .map_err(|_| StorageError::ImportFormat(format!("no {name} in the archive")))?; + + let mut bytes = Vec::new(); + entry + .read_to_end(&mut bytes) + .map_err(|error| file_error(name, &error))?; + + Ok(bytes) +} + +/// The attachment bytes of an import, handed over one at a time. +/// +/// ⚠️ `Debug` says nothing about the key it may hold, for the same reason `Vault`'s does. +pub enum Payload { + /// A `.json` export: it carried no attachments. + Empty, + Archive { + archive: Box>, + /// `None` for a plaintext archive; the export's own key for a protected one. + vault: Option, + }, +} + +impl std::fmt::Debug for Payload { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Empty => formatter.write_str("Payload::Empty"), + Self::Archive { vault, .. } => formatter.write_str(if vault.is_some() { + "Payload::Archive(protected)" + } else { + "Payload::Archive" + }), + } + } +} + +impl Payload { + /// `None` when the archive names the record but does not carry its bytes, and `None` + /// too when it carries them under a key that will not open them — a caller cannot act + /// on the difference, and the import reports both as missing. + pub fn take(&mut self, stored_name: &str) -> Option> { + let Self::Archive { archive, vault } = self else { + return None; + }; + + let mut entry = archive + .by_name(&format!("{ATTACHMENTS_ENTRY}/{stored_name}")) + .ok()?; + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes).ok()?; + + match vault { + Some(vault) => vault.open_bytes(&bytes).ok(), + None => Some(bytes), + } + } } /// ⚠️ Same directory as the target, or the rename crosses volumes and stops being atomic. @@ -58,6 +352,20 @@ mod tests { use super::*; use crate::notes::fixtures::note as sample; use crate::spaces::model::Space; + use crate::vault::key::Cost; + + fn library() -> Vault { + Vault::derive( + "the library", + b"0123456789abcdef", + Cost { + memory_kib: 64, + passes: 1, + lanes: 1, + }, + ) + .unwrap() + } fn bundle() -> Bundle { Bundle { @@ -69,6 +377,18 @@ mod tests { pinned: false, }], notes: vec![sample()], + attachments: Vec::new(), + } + } + + fn record() -> Attachment { + Attachment { + id: "a-1".to_string(), + note_id: sample().id, + file_name: "capture.png".to_string(), + mime_type: "image/png".to_string(), + byte_size: 4, + created_at: sample().created_at, } } @@ -79,10 +399,18 @@ mod tests { directory } + /// The attachment as it really sits beside the database: sealed under the library. + fn seal_beside(directory: &Path, vault: &Vault, bytes: &[u8]) { + let sealed = vault.seal_bytes(bytes).unwrap(); + std::fs::write(directory.join(record().stored_name()), sealed).unwrap(); + } + /// A staging file one directory away would make the rename cross volumes. #[test] fn the_staging_file_sits_next_to_its_target() { - let target = std::env::temp_dir().join("documents").join("library.json"); + let target = std::env::temp_dir() + .join("documents") + .join("library.devbox"); let staged = staging_path(&target.to_string_lossy()); @@ -92,7 +420,7 @@ mod tests { #[test] fn two_exports_of_the_same_target_never_stage_the_same_file() { - let target = std::env::temp_dir().join("library.json"); + let target = std::env::temp_dir().join("library.devbox"); let first = staging_path(&target.to_string_lossy()); let second = staging_path(&target.to_string_lossy()); @@ -103,9 +431,16 @@ mod tests { #[test] fn an_export_leaves_no_staging_file_behind() { let directory = scratch(); - let target = directory.join("library.json"); + let target = directory.join("library.devbox"); - write(&target.to_string_lossy(), &bundle()).unwrap(); + write( + &target.to_string_lossy(), + &bundle(), + &directory, + &library(), + None, + ) + .unwrap(); let left: Vec<_> = std::fs::read_dir(&directory) .unwrap() @@ -113,41 +448,283 @@ mod tests { .map(|entry| entry.file_name()) .collect(); - assert_eq!(left, ["library.json"]); + assert_eq!(left, ["library.devbox"]); std::fs::remove_dir_all(&directory).ok(); } #[test] fn exporting_over_an_existing_file_replaces_it_whole() { let directory = scratch(); - let target = directory.join("library.json"); + let target = directory.join("library.devbox"); std::fs::write(&target, "previous export, longer than what replaces it").unwrap(); - write(&target.to_string_lossy(), &bundle()).unwrap(); - - let written = std::fs::read_to_string(&target).unwrap(); - assert!(written.starts_with('{')); - assert!(!written.contains("previous export")); + write( + &target.to_string_lossy(), + &bundle(), + &directory, + &library(), + None, + ) + .unwrap(); + + let written = std::fs::read(&target).unwrap(); + assert_eq!(written[..4], ZIP_MAGIC); std::fs::remove_dir_all(&directory).ok(); } #[test] fn an_export_to_an_unreachable_directory_reports_rather_than_panicking() { - let error = write("/no/such/directory/library.json", &bundle()).unwrap_err(); + let directory = scratch(); + + let error = write( + "/no/such/directory/library.devbox", + &bundle(), + &directory, + &library(), + None, + ) + .unwrap_err(); assert!(matches!(error.code, crate::error::ErrorCode::FileAccess)); + std::fs::remove_dir_all(&directory).ok(); } #[test] fn a_written_bundle_reads_back_as_itself() { + let directory = scratch(); + let target = directory.join("library.devbox"); + + write( + &target.to_string_lossy(), + &bundle(), + &directory, + &library(), + None, + ) + .unwrap(); + let (read_back, _) = read(&target.to_string_lossy(), None).unwrap(); + + assert_eq!(read_back.bundle.notes.len(), 1); + assert_eq!(read_back.bundle.spaces[0].name, "Personal"); + std::fs::remove_dir_all(&directory).ok(); + } + + /// The point of the archive: the bytes travel with the record. + #[test] + fn an_attachment_travels_with_its_note() { + let directory = scratch(); + let target = directory.join("library.devbox"); + let vault = library(); + seal_beside(&directory, &vault, b"\x89PNG"); + + let mut exported = bundle(); + exported.attachments = vec![record()]; + let report = write( + &target.to_string_lossy(), + &exported, + &directory, + &vault, + None, + ) + .unwrap(); + + assert_eq!(report.attachments, 1); + assert!(!report.protected); + + let (read_back, mut payload) = read(&target.to_string_lossy(), None).unwrap(); + assert_eq!(read_back.bundle.attachments.len(), 1); + assert_eq!( + payload.take(&record().stored_name()), + Some(b"\x89PNG".to_vec()) + ); + std::fs::remove_dir_all(&directory).ok(); + } + + /// ⚠️ A record whose file has gone missing must not fail the export. + #[test] + fn a_record_whose_file_is_gone_leaves_the_export_rather_than_failing_it() { + let directory = scratch(); + let target = directory.join("library.devbox"); + + let mut exported = bundle(); + exported.attachments = vec![record()]; + let report = write( + &target.to_string_lossy(), + &exported, + &directory, + &library(), + None, + ) + .unwrap(); + + assert_eq!(report.attachments, 0); + assert_eq!(report.notes, 1); + std::fs::remove_dir_all(&directory).ok(); + } + + /// ⚠️ New DevBox reads what old DevBox wrote: a `.json` export predates the archive. + #[test] + fn a_json_export_from_before_the_archive_still_imports() { let directory = scratch(); let target = directory.join("library.json"); + let json = serde_json::to_string_pretty(&bundle()).unwrap(); + std::fs::write(&target, json).unwrap(); - write(&target.to_string_lossy(), &bundle()).unwrap(); - let read_back = read(&target.to_string_lossy()).unwrap(); + let (read_back, payload) = read(&target.to_string_lossy(), None).unwrap(); assert_eq!(read_back.bundle.notes.len(), 1); - assert_eq!(read_back.bundle.spaces[0].name, "Personal"); + assert!(matches!(payload, Payload::Empty)); + std::fs::remove_dir_all(&directory).ok(); + } + + /// ⚠️ The whole point of protecting an export: the file most likely to leave the + /// machine was the one carrying everything in the clear. + #[test] + fn a_protected_export_carries_none_of_the_notes_in_the_clear() { + let directory = scratch(); + let target = directory.join("library.devbox"); + let vault = library(); + seal_beside(&directory, &vault, b"a screenshot of something"); + + let mut exported = bundle(); + exported.notes[0].content = "psql -h prod -W hunter2".to_string(); + exported.attachments = vec![record()]; + + let report = write( + &target.to_string_lossy(), + &exported, + &directory, + &vault, + Some("a shared phrase"), + ) + .unwrap(); + assert!(report.protected); + + let raw = std::fs::read(&target).unwrap(); + let haystack = String::from_utf8_lossy(&raw); + assert!(!haystack.contains("hunter2")); + assert!(!haystack.contains("Personal")); + assert!(!haystack.contains("a screenshot of")); + + std::fs::remove_dir_all(&directory).ok(); + } + + #[test] + fn a_protected_export_reads_back_whole_with_its_phrase() { + let directory = scratch(); + let target = directory.join("library.devbox"); + let vault = library(); + seal_beside(&directory, &vault, b"\x89PNG"); + + let mut exported = bundle(); + exported.attachments = vec![record()]; + write( + &target.to_string_lossy(), + &exported, + &directory, + &vault, + Some("a shared phrase"), + ) + .unwrap(); + + let (read_back, mut payload) = + read(&target.to_string_lossy(), Some("a shared phrase")).unwrap(); + + assert_eq!(read_back.bundle.notes.len(), 1); + assert_eq!( + payload.take(&record().stored_name()), + Some(b"\x89PNG".to_vec()) + ); + std::fs::remove_dir_all(&directory).ok(); + } + + /// ⚠️ Offered without one, it asks rather than failing: nothing can know a file is + /// protected until something has looked inside it. + #[test] + fn a_protected_export_asks_for_a_phrase_rather_than_failing() { + let directory = scratch(); + let target = directory.join("library.devbox"); + + write( + &target.to_string_lossy(), + &bundle(), + &directory, + &library(), + Some("a shared phrase"), + ) + .unwrap(); + + let error = read(&target.to_string_lossy(), None).unwrap_err(); + assert_eq!(error.code, crate::error::ErrorCode::PassphraseRequired); + assert!(is_protected(&target.to_string_lossy()).unwrap()); + + std::fs::remove_dir_all(&directory).ok(); + } + + #[test] + fn the_wrong_phrase_is_refused_rather_than_read_as_nonsense() { + let directory = scratch(); + let target = directory.join("library.devbox"); + + write( + &target.to_string_lossy(), + &bundle(), + &directory, + &library(), + Some("a shared phrase"), + ) + .unwrap(); + + let error = read(&target.to_string_lossy(), Some("the wrong one")).unwrap_err(); + + assert_eq!(error.code, crate::error::ErrorCode::WrongPassphrase); + std::fs::remove_dir_all(&directory).ok(); + } + + /// ⚠️ Asked before an import starts, so it must not pay for the bundle: a file whose + /// payload could not be parsed at all still answers the question. + #[test] + fn whether_a_file_is_protected_is_answered_without_reading_the_bundle() { + let directory = scratch(); + let target = directory.join("library.devbox"); + write( + &target.to_string_lossy(), + &bundle(), + &directory, + &library(), + None, + ) + .unwrap(); + + // The entry is there and is nonsense; only the recipe decides the answer. + let mut rewritten = ZipWriter::new(std::fs::File::create(&target).unwrap()); + rewritten.start_file(BUNDLE_ENTRY, deflated()).unwrap(); + rewritten.write_all(b"not json at all").unwrap(); + rewritten.finish().unwrap(); + + assert!(!is_protected(&target.to_string_lossy()).unwrap()); + // And the import that follows is what says the file is unreadable. + assert!(read(&target.to_string_lossy(), None).is_err()); + + std::fs::remove_dir_all(&directory).ok(); + } + + /// An ordinary export needs no phrase, and must not be made to ask for one. + #[test] + fn an_unprotected_export_is_not_reported_as_protected() { + let directory = scratch(); + let target = directory.join("library.devbox"); + + write( + &target.to_string_lossy(), + &bundle(), + &directory, + &library(), + None, + ) + .unwrap(); + + assert!(!is_protected(&target.to_string_lossy()).unwrap()); std::fs::remove_dir_all(&directory).ok(); } } diff --git a/src-tauri/src/transfer/model.rs b/src-tauri/src/transfer/model.rs index b73a60e..10f609f 100644 --- a/src-tauri/src/transfer/model.rs +++ b/src-tauri/src/transfer/model.rs @@ -9,6 +9,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use specta::Type; +use crate::attachments::model::Attachment; use crate::error::{StorageError, ValidationError}; use crate::notes::checklist::{self, NoteKind}; use crate::notes::language::Language; @@ -29,6 +30,11 @@ pub struct Bundle { pub exported_at: DateTime, pub spaces: Vec, pub notes: Vec, + /// The records only — the bytes are entries of the archive, keyed by + /// [`crate::attachments::model::stored_name`]. ⚠️ `default` so a `.json` export written + /// before the archive existed still parses. + #[serde(default)] + pub attachments: Vec, } #[derive(Debug, Clone, Copy, Serialize, Type)] @@ -36,6 +42,13 @@ pub struct Bundle { pub struct ExportReport { pub notes: u32, pub spaces: u32, + /// What actually went into the archive. A record whose file has gone missing is left + /// out rather than failing the export. + pub attachments: u32, + /// ⚠️ `false` means the file is readable by anyone who has it — every note, every + /// screenshot. The interface says which of the two it wrote, because the file is the + /// one thing here most likely to leave the machine. + pub protected: bool, } /// `skipped`: notes already present or whose space is missing from the file — an import @@ -49,6 +62,10 @@ pub struct ImportReport { /// Imported with a `language` or `kind` this build does not know brought down to the /// default. Counted so the loss is said rather than discovered. pub notes_degraded: u32, + pub attachments_imported: u32, + /// Records the archive named but did not carry. Counted rather than swallowed: the + /// note arrives with a thumbnail that will never load, and only this says why. + pub attachments_missing: u32, } /// A bundle read from a file, and the ids [`read_bundle`] had to degrade. @@ -350,6 +367,7 @@ mod tests { pinned: false, }], notes: vec![sample()], + attachments: Vec::new(), }; let read = read_bundle(&serde_json::to_string(&bundle).unwrap()).unwrap(); diff --git a/src-tauri/src/transfer/protect.rs b/src-tauri/src/transfer/protect.rs new file mode 100644 index 0000000..8ae64f9 --- /dev/null +++ b/src-tauri/src/transfer/protect.rs @@ -0,0 +1,162 @@ +//! The passphrase an export can be protected with. +//! +//! ⚠️ A different key from the library's, always. The library key is derived from the +//! passphrase typed at launch and never leaves this machine; an export is meant to reach +//! another one, so it carries its own salt and is opened by whatever phrase the user gave +//! it. That is also what lets a file be sent to someone without handing them the keys to +//! the library it came from. +//! +//! ⚠️ An unprotected export is still written, and is still plaintext. Refusing to write +//! one would break the portability the exchange format exists for — what the interface +//! owes the user is to say which of the two they are about to produce. + +use serde::{Deserialize, Serialize}; + +use crate::error::StorageError; +use crate::vault::key::{Cost, SALT_BYTES, Vault, fresh_salt}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; + +/// Bumped when a protected file written today would stop being readable. +const FORMAT_VERSION: u32 = 1; + +/// What a reader needs to derive the same key, and nothing more. Written in the clear +/// beside the sealed payload: a salt is not a secret, and a cost has to be read before +/// anything can be derived. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Recipe { + pub version: u32, + pub algorithm: String, + pub memory_kib: u32, + pub passes: u32, + pub lanes: u32, + pub salt: String, +} + +/// A fresh key for one export, and the recipe that reproduces it. +pub fn seal_with(passphrase: &str) -> Result<(Vault, Recipe), StorageError> { + let cost = Cost::default(); + let salt = fresh_salt()?; + let vault = Vault::derive(passphrase, &salt, cost)?; + + Ok(( + vault, + Recipe { + version: FORMAT_VERSION, + algorithm: "argon2id".to_string(), + memory_kib: cost.memory_kib, + passes: cost.passes, + lanes: cost.lanes, + salt: BASE64.encode(salt), + }, + )) +} + +/// ⚠️ Answers [`StorageError::WrongPassphrase`] on a phrase that does not open the +/// payload, and the caller has no way to tell that from a corrupt file — which is +/// deliberate. The recipe carries no check value of its own: the payload's own +/// authentication tag is the check, so there is nothing extra to brute-force against. +pub fn open_with(passphrase: &str, recipe: &Recipe) -> Result { + if recipe.version > FORMAT_VERSION { + return Err(StorageError::ImportFormat(format!( + "protected with format version {}, this version of DevBox reads up to {FORMAT_VERSION}", + recipe.version + ))); + } + if recipe.algorithm != "argon2id" { + return Err(StorageError::ImportFormat(format!( + "protected with \"{}\", which this version of DevBox cannot reproduce", + recipe.algorithm + ))); + } + + let salt = BASE64 + .decode(&recipe.salt) + .map_err(|_| StorageError::ImportFormat("the salt is not base64".to_string()))?; + if salt.len() != SALT_BYTES { + return Err(StorageError::ImportFormat( + "the salt is the wrong size".to_string(), + )); + } + + Vault::derive( + passphrase, + &salt, + Cost { + memory_kib: recipe.memory_kib, + passes: recipe.passes, + lanes: recipe.lanes, + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_payload_sealed_for_an_export_opens_with_the_same_phrase() { + let (vault, recipe) = seal_with("a shared phrase").unwrap(); + let sealed = vault.seal_bytes(b"the bundle").unwrap(); + + let reader = open_with("a shared phrase", &recipe).unwrap(); + + assert_eq!(reader.open_bytes(&sealed).unwrap(), b"the bundle"); + } + + #[test] + fn another_phrase_will_not_open_it() { + let (vault, recipe) = seal_with("a shared phrase").unwrap(); + let sealed = vault.seal_bytes(b"the bundle").unwrap(); + + let reader = open_with("the wrong phrase", &recipe).unwrap(); + + assert!(reader.open_bytes(&sealed).is_err()); + } + + /// ⚠️ Two exports of the same library under the same phrase must not share a key: a + /// fresh salt is what stops one opened file from opening every other. + #[test] + fn two_exports_under_one_phrase_do_not_share_a_key() { + let (first, first_recipe) = seal_with("a shared phrase").unwrap(); + let (_, second_recipe) = seal_with("a shared phrase").unwrap(); + + assert_ne!(first_recipe.salt, second_recipe.salt); + + let sealed = first.seal_bytes(b"the bundle").unwrap(); + let other = open_with("a shared phrase", &second_recipe).unwrap(); + assert!(other.open_bytes(&sealed).is_err()); + } + + #[test] + fn a_recipe_from_a_newer_version_says_so() { + let (_, mut recipe) = seal_with("a shared phrase").unwrap(); + recipe.version = FORMAT_VERSION + 1; + + let error = open_with("a shared phrase", &recipe).unwrap_err(); + + assert!(format!("{error}").contains("reads up to")); + } + + #[test] + fn an_unknown_derivation_is_named_rather_than_guessed_at() { + let (_, mut recipe) = seal_with("a shared phrase").unwrap(); + recipe.algorithm = "scrypt".to_string(); + + let error = open_with("a shared phrase", &recipe).unwrap_err(); + + assert!(format!("{error}").contains("scrypt")); + } + + /// The recipe is what travels in the clear, so it must carry nothing that matters. + #[test] + fn the_recipe_holds_no_phrase_and_no_key() { + let (_, recipe) = seal_with("correct horse battery staple").unwrap(); + + let written = serde_json::to_string(&recipe).unwrap(); + + assert!(!written.contains("correct horse")); + assert!(written.contains("argon2id")); + } +} diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs new file mode 100644 index 0000000..03e474c --- /dev/null +++ b/src-tauri/src/vault.rs @@ -0,0 +1,268 @@ +//! Encryption at rest: the key, what it seals, and the one gate that opens the library. +//! +//! ⚠️ The passphrase is never stored, anywhere, deliberately: this is the bargain a +//! password manager makes, not a keychain's. Losing it loses the library, and an export +//! is the only copy that does not depend on it. + +#![allow(clippy::needless_pass_by_value)] + +pub mod file; +pub mod key; +pub mod migrate; + +use serde::Serialize; +use specta::Type; +use tauri::{AppHandle, Manager, State}; + +use zeroize::Zeroize; + +use crate::db::{self, Db}; +use crate::error::{AppError, StorageError, ValidationError}; +use key::Cost; + +/// Short enough to be typed at every launch, long enough to be worth deriving from. +const MINIMUM_LENGTH: usize = 8; + +/// What the front end renders before it renders anything else. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum VaultState { + /// A library that has never been encrypted: the first launch asks for a passphrase + /// twice and creates one. + Absent, + /// A key file is there and the passphrase has not been given yet. + Locked, + /// ⚠️ Held in Rust, never in the front end: 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. + Unlocked, +} + +#[tauri::command] +#[specta::specta] +pub fn vault_state(app: AppHandle, db: State<'_, Db>) -> Result { + if db.lock().map_err(|_| StorageError::Unavailable)?.is_some() { + return Ok(VaultState::Unlocked); + } + + let directory = app.path().app_data_dir().map_err(storage)?; + + Ok(if file::exists(&directory) { + VaultState::Locked + } else { + VaultState::Absent + }) +} + +/// The first launch. ⚠️ Refuses a library that already has a key file rather than +/// replacing it: that file is the only way into the notes beside it. +#[tauri::command(async)] +#[specta::specta] +pub fn create_vault( + mut passphrase: String, + app: AppHandle, + db: State<'_, Db>, +) -> Result<(), AppError> { + // ⚠️ Wiped before this returns, whatever it returns. The string arrives owned from the + // IPC payload, so this is the last reference to it — and a passphrase left in freed + // memory is a passphrase in a crash dump. + let result = create_with(&passphrase, &app, &db); + passphrase.zeroize(); + + result +} + +fn create_with(passphrase: &str, app: &AppHandle, db: &State<'_, Db>) -> Result<(), AppError> { + validate(passphrase)?; + + let directory = app.path().app_data_dir().map_err(storage)?; + std::fs::create_dir_all(&directory).map_err(|error| storage_msg(&error.to_string()))?; + + let vault = file::create(&directory, passphrase, Cost::default())?; + + open_library(app, db, vault)?; + + // ⚠️ Between opening and sweeping, never after. The orphan-file sweep reads attachment + // records, and on a library that predates the passphrase those are still in the clear — + // it would fail to open every one of them and log a warning for nothing. + seal_what_was_there(app, db)?; + + crate::sweep(app); + + Ok(()) +} + +/// ⚠️ The rows first, in one transaction, and the files after it commits. A file write +/// does not roll back — a file left readable is recoverable, a row sealed twice is not. +fn seal_what_was_there(app: &AppHandle, db: &State<'_, Db>) -> Result<(), AppError> { + let stored_names = { + let mut connection = crate::db::lock(db)?; + let done = { + let (connection, vault) = connection.split(); + migrate::seal_existing(connection, vault)? + }; + + if done.is_empty() { + return Ok(()); + } + + log::info!( + "Sealed an existing library: {} note(s), {} space(s), {} item(s), {} value(s), {} attachment record(s)", + done.notes, + done.spaces, + done.items, + done.values, + done.attachments + ); + + crate::attachments::store::all_stored_names(&mut connection)? + }; + + let directory = crate::attachments::directory(app)?; + let connection = crate::db::lock(db)?; + let vault = connection.vault(); + + for name in stored_names { + let path = directory.join(&name); + // ⚠️ Best effort, one file at a time, and never fatal: a library whose notes are + // sealed is worth keeping even if one screenshot resisted. The alternative is + // refusing to start over a file nobody may ever open. + if let Err(error) = crate::attachments::sealed::seal_in_place(vault, &path) { + log::warn!("Attachment {name} left as it was: {error}"); + } + } + + Ok(()) +} + +/// ⚠️ Deliberately slow: deriving the key is the whole defence against someone trying +/// passphrases against a copied file. It is `(async)` for the same reason — a second on the +/// main thread would freeze the window over every attempt. +#[tauri::command(async)] +#[specta::specta] +pub fn unlock_vault( + mut passphrase: String, + app: AppHandle, + db: State<'_, Db>, +) -> Result<(), AppError> { + // ⚠️ Wiped before this returns, whatever it returns — see `create_vault`. + let result = unlock_with(&passphrase, &app, &db); + passphrase.zeroize(); + + result +} + +fn unlock_with(passphrase: &str, app: &AppHandle, db: &State<'_, Db>) -> Result<(), AppError> { + let directory = app.path().app_data_dir().map_err(storage)?; + let vault = file::unlock(&directory, passphrase)?; + + open_library(app, db, vault)?; + crate::sweep(app); + + Ok(()) +} + +/// A new phrase over the same library, from the preferences panel. +/// +/// ⚠️ Not a re-encryption: the key the notes are sealed with is the one being rewrapped, +/// so nothing in the database moves and the library stays open on the key it already had. +/// The consequence is worth knowing — this answers a phrase somebody else learned, never +/// a key somebody else got hold of. +#[tauri::command(async)] +#[specta::specta] +pub fn change_passphrase( + mut current: String, + mut next: String, + app: AppHandle, + db: State<'_, Db>, +) -> Result<(), AppError> { + // ⚠️ Wiped before this returns, whatever it returns — see `create_vault`. + let result = change_with(¤t, &next, &app, &db); + current.zeroize(); + next.zeroize(); + + result +} + +fn change_with( + current: &str, + next: &str, + app: &AppHandle, + db: &State<'_, Db>, +) -> Result<(), AppError> { + validate(next)?; + + // ⚠️ Asked and released rather than held: the two derivations below cost a second + // each, and keeping the connection for them would freeze every other command. + if db.lock().map_err(|_| StorageError::Unavailable)?.is_none() { + return Err(StorageError::Locked.into()); + } + + let directory = app.path().app_data_dir().map_err(storage)?; + file::change_passphrase(&directory, current, next, Cost::default())?; + + Ok(()) +} + +/// Opens the library under the key and hands it to the rest of the application. The +/// sweeps are the caller's to run, because a first launch has to seal what is there first. +fn open_library(app: &AppHandle, db: &State<'_, Db>, vault: key::Vault) -> Result<(), AppError> { + let directory = app.path().app_data_dir().map_err(storage)?; + let library = db::open(&directory.join(db::DB_FILE_NAME), vault)?; + + { + let mut held = db.lock().map_err(|_| StorageError::Unavailable)?; + // ⚠️ A second unlock would drop the library the first one opened, and with it any + // connection state. The front gates on `vault_state`, so this only catches a race. + if held.is_none() { + *held = Some(library); + } + } + + Ok(()) +} + +/// ⚠️ A length, and nothing else. A rule about digits and symbols pushes people towards +/// one memorable pattern, and the cost of guessing is Argon2id's to carry. +fn validate(passphrase: &str) -> Result<(), ValidationError> { + if passphrase.chars().count() < MINIMUM_LENGTH { + return Err(ValidationError::new( + "passphrase", + "a passphrase of at least 8 characters", + )); + } + + Ok(()) +} + +fn storage(error: tauri::Error) -> StorageError { + StorageError::Vault(error.to_string()) +} + +fn storage_msg(detail: &str) -> StorageError { + StorageError::Vault(detail.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_passphrase_too_short_to_be_worth_deriving_is_refused() { + assert!(validate("short").is_err()); + assert!(validate("1234567").is_err()); + } + + #[test] + fn a_passphrase_of_the_minimum_length_is_accepted() { + assert!(validate("12345678").is_ok()); + } + + /// ⚠️ Counted in characters, not bytes: "clé-privée" is ten characters and twelve + /// bytes, and a byte count would accept a shorter one through an accent. + #[test] + fn the_length_is_counted_in_characters() { + assert!(validate("éàèùçâêîô").is_ok()); + assert!(validate("éàèùç").is_err()); + } +} diff --git a/src-tauri/src/vault/file.rs b/src-tauri/src/vault/file.rs new file mode 100644 index 0000000..022727a --- /dev/null +++ b/src-tauri/src/vault/file.rs @@ -0,0 +1,409 @@ +//! The key file, beside the database. +//! +//! ⚠️ Outside the library on purpose: it carries what is needed to derive the key that +//! opens it, so it has to be readable before anything else can be. What it holds is a +//! salt, the cost, and the library's own key **sealed under the phrase** — never a key in +//! the clear, and no separate check value: opening the wrapped key is the check. +//! +//! ⚠️ Losing this file loses the library, exactly as losing the passphrase does. An export +//! is the only copy that does not depend on it. + +use std::path::{Path, PathBuf}; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::key::{Cost, SALT_BYTES, Vault, fresh_salt}; +use crate::error::StorageError; + +pub const FILE_NAME: &str = "vault.json"; + +/// Bumped when a file written today would stop being readable. 2 wraps the library key +/// under the phrase where 1 derived the library key from it — which is what lets the +/// phrase change without touching a single note. +const FORMAT_VERSION: u32 = 2; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct KeyFile { + version: u32, + kdf: Kdf, + /// ⚠️ The library's key, sealed under the one derived from the passphrase. A wrong + /// phrase fails to open it, which is what stops an unlock from succeeding on a key + /// nobody can reproduce and sealing real notes under it. + key: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Kdf { + algorithm: String, + memory_kib: u32, + passes: u32, + lanes: u32, + salt: String, +} + +pub fn path_in(directory: &Path) -> PathBuf { + directory.join(FILE_NAME) +} + +pub fn exists(directory: &Path) -> bool { + path_in(directory).is_file() +} + +/// A library that has never been encrypted. ⚠️ Refuses to overwrite: the file that is +/// there is the only way into the notes beside it. +pub fn create(directory: &Path, passphrase: &str, cost: Cost) -> Result { + let path = path_in(directory); + if path.exists() { + return Err(StorageError::Vault( + "this library already has a key file".to_string(), + )); + } + + let vault = Vault::random()?; + write_wrapped(&path, &vault, passphrase, cost)?; + + Ok(vault) +} + +/// A new phrase over the same library. ⚠️ Nothing is re-encrypted: the key the notes are +/// sealed with does not change, only what wraps it — so this cannot half-succeed and +/// leave some notes unreadable, and it costs one derivation rather than a full rewrite. +pub fn change_passphrase( + directory: &Path, + current: &str, + next: &str, + cost: Cost, +) -> Result<(), StorageError> { + let vault = unlock(directory, current)?; + + write_wrapped(&path_in(directory), &vault, next, cost) +} + +/// ⚠️ A fresh salt every time, change included: two phrases must not share a derivation, +/// or knowing one would say something about the other. +fn write_wrapped( + path: &Path, + vault: &Vault, + passphrase: &str, + cost: Cost, +) -> Result<(), StorageError> { + let salt = fresh_salt()?; + let wrapping = Vault::derive(passphrase, &salt, cost)?; + + write_atomically( + path, + &KeyFile { + version: FORMAT_VERSION, + kdf: Kdf { + algorithm: "argon2id".to_string(), + memory_kib: cost.memory_kib, + passes: cost.passes, + lanes: cost.lanes, + salt: BASE64.encode(salt), + }, + key: BASE64.encode(vault.wrapped_with(&wrapping)?), + }, + ) +} + +/// ⚠️ Answers [`StorageError::WrongPassphrase`] and nothing more detailed: which of the +/// two the caller got wrong is not something to help with. +pub fn unlock(directory: &Path, passphrase: &str) -> Result { + let path = path_in(directory); + let json = std::fs::read_to_string(&path) + .map_err(|error| StorageError::Vault(format!("{}: {error}", path.display())))?; + + let file: KeyFile = serde_json::from_str(&json) + .map_err(|error| StorageError::Vault(format!("unreadable key file: {error}")))?; + + if file.version > FORMAT_VERSION { + return Err(StorageError::Vault(format!( + "key file version {}, this version of DevBox reads up to {FORMAT_VERSION}", + file.version + ))); + } + if file.kdf.algorithm != "argon2id" { + return Err(StorageError::Vault(format!( + "key derived with \"{}\", which this version of DevBox cannot reproduce", + file.kdf.algorithm + ))); + } + + let salt = BASE64 + .decode(&file.kdf.salt) + .map_err(|_| StorageError::Vault("the salt is not base64".to_string()))?; + if salt.len() != SALT_BYTES { + return Err(StorageError::Vault( + "the salt is the wrong size".to_string(), + )); + } + + let cost = Cost { + memory_kib: file.kdf.memory_kib, + passes: file.kdf.passes, + lanes: file.kdf.lanes, + }; + let wrapping = Vault::derive(passphrase, &salt, cost)?; + let wrapped = BASE64 + .decode(&file.key) + .map_err(|_| StorageError::Vault("the wrapped key is not base64".to_string()))?; + + Vault::unwrapped_with(&wrapping, &wrapped).map_err(|_| StorageError::WrongPassphrase) +} + +/// ⚠️ Staged then renamed. A key file half-written is a library nobody opens again, and +/// a plain write truncates before it fills. +fn write_atomically(path: &Path, file: &KeyFile) -> Result<(), StorageError> { + let json = serde_json::to_string_pretty(file) + .map_err(|error| StorageError::Vault(error.to_string()))?; + + let staged = path.with_file_name(format!(".{FILE_NAME}.{}.tmp", Uuid::new_v4())); + std::fs::write(&staged, json) + .map_err(|error| StorageError::Vault(format!("{}: {error}", staged.display())))?; + + if let Err(error) = std::fs::rename(&staged, path) { + let _ = std::fs::remove_file(&staged); + return Err(StorageError::Vault(format!("{}: {error}", path.display()))); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// ⚠️ Cheap parameters: the real ones cost about a second a derivation, and these tests derive + /// a dozen times. What they assert on is the file, not Argon2id's strength. + fn cheap() -> Cost { + Cost { + memory_kib: 64, + passes: 1, + lanes: 1, + } + } + + fn scratch() -> PathBuf { + let directory = std::env::temp_dir().join(format!("devbox-vault-{}", Uuid::new_v4())); + std::fs::create_dir_all(&directory).unwrap(); + + directory + } + + #[test] + fn a_created_vault_opens_again_with_the_same_passphrase() { + let directory = scratch(); + + let created = create(&directory, "correct horse", cheap()).unwrap(); + let sealed = created.seal("a note").unwrap(); + + let reopened = unlock(&directory, "correct horse").unwrap(); + assert_eq!(reopened.open(&sealed).unwrap(), "a note"); + + std::fs::remove_dir_all(&directory).ok(); + } + + /// ⚠️ Without the wrapped key to open, this would succeed and every later write + /// would seal real notes under a key nobody can reproduce. + #[test] + fn a_wrong_passphrase_is_refused_rather_than_accepted_quietly() { + let directory = scratch(); + create(&directory, "correct horse", cheap()).unwrap(); + + let error = unlock(&directory, "battery staple").unwrap_err(); + + assert!(matches!(error, StorageError::WrongPassphrase)); + std::fs::remove_dir_all(&directory).ok(); + } + + /// The key it carries is sealed; the phrase that opens it is nowhere. + #[test] + fn the_key_file_holds_no_passphrase_and_no_key_in_the_clear() { + let directory = scratch(); + let vault = create(&directory, "correct horse", cheap()).unwrap(); + + let written = std::fs::read_to_string(path_in(&directory)).unwrap(); + let sealed = vault.seal("a note").unwrap(); + + assert!(!written.contains("correct horse")); + assert!(written.contains("argon2id")); + // Nothing in the file opens what the library sealed — only the phrase does. + let file: serde_json::Value = serde_json::from_str(&written).unwrap(); + let wrapped = file["key"].as_str().unwrap().to_string(); + assert!(unlock(&directory, &wrapped).is_err()); + assert_eq!( + unlock(&directory, "correct horse") + .unwrap() + .open(&sealed) + .unwrap(), + "a note" + ); + + std::fs::remove_dir_all(&directory).ok(); + } + + /// ⚠️ The point of wrapping a random key rather than deriving one: the notes stay + /// sealed exactly as they were, and a change cannot half-rewrite a library. + #[test] + fn a_changed_passphrase_opens_the_notes_the_old_one_sealed() { + let directory = scratch(); + let sealed = create(&directory, "correct horse", cheap()) + .unwrap() + .seal("a note") + .unwrap(); + + change_passphrase(&directory, "correct horse", "battery staple", cheap()).unwrap(); + + let reopened = unlock(&directory, "battery staple").unwrap(); + assert_eq!(reopened.open(&sealed).unwrap(), "a note"); + + std::fs::remove_dir_all(&directory).ok(); + } + + #[test] + fn the_old_passphrase_stops_opening_the_library() { + let directory = scratch(); + create(&directory, "correct horse", cheap()).unwrap(); + + change_passphrase(&directory, "correct horse", "battery staple", cheap()).unwrap(); + + assert!(matches!( + unlock(&directory, "correct horse").unwrap_err(), + StorageError::WrongPassphrase + )); + std::fs::remove_dir_all(&directory).ok(); + } + + /// ⚠️ Refused *before* anything is written: a change that took a wrong current phrase + /// on trust would lock the library behind a phrase nobody chose. + #[test] + fn a_change_that_cannot_name_the_current_passphrase_writes_nothing() { + let directory = scratch(); + create(&directory, "correct horse", cheap()).unwrap(); + let before = std::fs::read_to_string(path_in(&directory)).unwrap(); + + let error = change_passphrase(&directory, "not it", "battery staple", cheap()).unwrap_err(); + + assert!(matches!(error, StorageError::WrongPassphrase)); + assert_eq!( + std::fs::read_to_string(path_in(&directory)).unwrap(), + before + ); + assert!(unlock(&directory, "correct horse").is_ok()); + + std::fs::remove_dir_all(&directory).ok(); + } + + /// Two phrases over one library must not share a derivation. + #[test] + fn a_change_draws_a_fresh_salt() { + let directory = scratch(); + create(&directory, "correct horse", cheap()).unwrap(); + let before: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path_in(&directory)).unwrap()).unwrap(); + + change_passphrase(&directory, "correct horse", "battery staple", cheap()).unwrap(); + + let after: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path_in(&directory)).unwrap()).unwrap(); + assert_ne!(before["kdf"]["salt"], after["kdf"]["salt"]); + assert_ne!(before["key"], after["key"]); + + std::fs::remove_dir_all(&directory).ok(); + } + + /// The cost travels with the file, so raising the default later does not lock an + /// existing library out. + #[test] + fn a_library_reopens_at_the_cost_it_was_written_with() { + let directory = scratch(); + let odd = Cost { + memory_kib: 96, + passes: 3, + lanes: 1, + }; + create(&directory, "correct horse", odd).unwrap(); + + // `unlock` reads the parameters rather than assuming today's defaults. + assert!(unlock(&directory, "correct horse").is_ok()); + std::fs::remove_dir_all(&directory).ok(); + } + + /// ⚠️ Overwriting would throw away the only way into the notes sitting beside it. + #[test] + fn creating_over_an_existing_key_file_is_refused() { + let directory = scratch(); + create(&directory, "first", cheap()).unwrap(); + + assert!(create(&directory, "second", cheap()).is_err()); + // And the first passphrase still works. + assert!(unlock(&directory, "first").is_ok()); + + std::fs::remove_dir_all(&directory).ok(); + } + + #[test] + fn a_key_file_from_a_newer_version_says_so_rather_than_failing_on_serde() { + let directory = scratch(); + create(&directory, "correct horse", cheap()).unwrap(); + + let path = path_in(&directory); + let mut json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + json["version"] = serde_json::json!(FORMAT_VERSION + 1); + std::fs::write(&path, json.to_string()).unwrap(); + + let error = unlock(&directory, "correct horse").unwrap_err(); + assert!(format!("{error}").contains("reads up to")); + + std::fs::remove_dir_all(&directory).ok(); + } + + #[test] + fn an_unknown_derivation_is_named_rather_than_guessed_at() { + let directory = scratch(); + create(&directory, "correct horse", cheap()).unwrap(); + + let path = path_in(&directory); + let mut json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + json["kdf"]["algorithm"] = serde_json::json!("scrypt"); + std::fs::write(&path, json.to_string()).unwrap(); + + let error = unlock(&directory, "correct horse").unwrap_err(); + assert!(format!("{error}").contains("scrypt")); + + std::fs::remove_dir_all(&directory).ok(); + } + + #[test] + fn a_library_with_no_key_file_says_it_has_none() { + let directory = scratch(); + + assert!(!exists(&directory)); + create(&directory, "correct horse", cheap()).unwrap(); + assert!(exists(&directory)); + + std::fs::remove_dir_all(&directory).ok(); + } + + #[test] + fn creating_leaves_no_staging_file_behind() { + let directory = scratch(); + create(&directory, "correct horse", cheap()).unwrap(); + + let left: Vec<_> = std::fs::read_dir(&directory) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.file_name()) + .collect(); + + assert_eq!(left, [FILE_NAME]); + std::fs::remove_dir_all(&directory).ok(); + } +} diff --git a/src-tauri/src/vault/key.rs b/src-tauri/src/vault/key.rs new file mode 100644 index 0000000..d8503e3 --- /dev/null +++ b/src-tauri/src/vault/key.rs @@ -0,0 +1,304 @@ +//! Deriving the key from a passphrase, and sealing a value with it. +//! +//! ⚠️ AES-256-GCM hides the content of a value, never its **length**: the ciphertext is as +//! long as the plaintext. Someone holding the file learns how big each note is, and that a +//! title is empty. That is inherent to the construction and is not worth padding around. +//! +//! ⚠️ The nonce is 96 bits of randomness, fresh per seal. Reusing one under the same key +//! breaks GCM outright, so nothing here ever takes a nonce from the caller. + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Key}; +use argon2::{Algorithm, Argon2, Params, Version}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use zeroize::Zeroizing; + +use crate::error::StorageError; + +/// 96 bits, what GCM is specified around. +const NONCE_BYTES: usize = 12; +/// 128 bits: the salt only has to be unique per library, never secret. +pub(crate) const SALT_BYTES: usize = 16; + +/// What `derive` costs, and what an attacker guessing passphrases pays per guess. +/// +/// ⚠️ These travel **in the key file**, not as constants read at derivation: raising them +/// in a later version must not lock every existing library out. A file carries the +/// parameters it was written with, and only a deliberate re-key changes them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Cost { + /// KiB of memory. The parameter that actually hurts a GPU. + pub memory_kib: u32, + pub passes: u32, + pub lanes: u32, +} + +impl Default for Cost { + /// ⚠️ Well above OWASP's floor of 19 MiB and two passes, deliberately. This is paid + /// **once per launch**, while the user is still lifting their hands off the keyboard, + /// and it is the only thing standing between a copied library and someone working + /// through a wordlist. Memory is the parameter that hurts a GPU, so it carries most + /// of the weight. + /// + /// Raising it again later locks nobody out: the cost travels in the key file, and a + /// library reopens at whatever it was written with. Measured at ~1.2 s here. + fn default() -> Self { + Self { + memory_kib: 64 * 1024, + passes: 3, + lanes: 1, + } + } +} + +/// The key, and nothing else. ⚠️ Held in a `Zeroizing` so it is wiped when the vault is +/// dropped rather than left in freed memory for whatever reads it next. +pub struct Vault { + key: Zeroizing<[u8; 32]>, +} + +impl std::fmt::Debug for Vault { + /// ⚠️ Deliberately says nothing: a key that reaches a log is a key that is gone. + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("Vault(…)") + } +} + +impl Vault { + /// The key the library's values are actually sealed with — random, never derived. + /// + /// ⚠️ This is what makes changing the passphrase a hundred bytes of rewriting instead + /// of re-encrypting every note and every attachment: the phrase only ever protects + /// this key, and a new phrase wraps the same one again. The cost is the other side of + /// that coin — whoever gets hold of this key keeps access across a change, so a + /// changed passphrase answers a leaked *phrase*, never a leaked key. + pub fn random() -> Result { + let mut key = Zeroizing::new([0u8; 32]); + getrandom::fill(key.as_mut()) + .map_err(|error| StorageError::Vault(format!("no randomness: {error}")))?; + + Ok(Self { key }) + } + + /// Seals this key under another one, for the key file to hold. + pub fn wrapped_with(&self, wrapping: &Self) -> Result, StorageError> { + wrapping.seal_bytes(self.key.as_ref()) + } + + /// ⚠️ The other direction, and the only check there is: a phrase that does not open + /// the wrapped key is the wrong phrase, said by the authentication tag rather than by + /// a known value sealed beside it. + pub fn unwrapped_with(wrapping: &Self, wrapped: &[u8]) -> Result { + let opened = Zeroizing::new(wrapping.open_bytes(wrapped)?); + let key: [u8; 32] = opened + .as_slice() + .try_into() + .map_err(|_| StorageError::Vault("the wrapped key is the wrong size".to_string()))?; + + Ok(Self { + key: Zeroizing::new(key), + }) + } + + /// ⚠️ Slow on purpose — this is the whole defence against someone trying passphrases + /// against a copied file. It is paid once, at unlock, never per value. + pub fn derive(passphrase: &str, salt: &[u8], cost: Cost) -> Result { + let params = Params::new(cost.memory_kib, cost.passes, cost.lanes, Some(32)) + .map_err(|error| StorageError::Vault(format!("key parameters: {error}")))?; + + let mut key = Zeroizing::new([0u8; 32]); + Argon2::new(Algorithm::Argon2id, Version::V0x13, params) + .hash_password_into(passphrase.as_bytes(), salt, key.as_mut()) + .map_err(|error| StorageError::Vault(format!("key derivation: {error}")))?; + + Ok(Self { key }) + } + + fn cipher(&self) -> Aes256Gcm { + Aes256Gcm::new(&Key::::from(*self.key)) + } + + /// `nonce ++ ciphertext ++ tag`, raw. What a file holds — a column holds the base64 of + /// this, because the columns are TEXT. + pub fn seal_bytes(&self, plaintext: &[u8]) -> Result, StorageError> { + let mut nonce = [0u8; NONCE_BYTES]; + getrandom::fill(&mut nonce) + .map_err(|error| StorageError::Vault(format!("no randomness: {error}")))?; + + let sealed = self + .cipher() + .encrypt((&nonce).into(), plaintext) + .map_err(|_| StorageError::Vault("could not seal a value".to_string()))?; + + let mut joined = Vec::with_capacity(NONCE_BYTES + sealed.len()); + joined.extend_from_slice(&nonce); + joined.extend_from_slice(&sealed); + + Ok(joined) + } + + /// ⚠️ Whole, never streamed: GCM only authenticates a message once all of it has been + /// seen, and handing back bytes before the tag is checked would defeat the point. + pub fn open_bytes(&self, sealed: &[u8]) -> Result, StorageError> { + if sealed.len() <= NONCE_BYTES { + return Err(StorageError::Vault( + "too short to have been sealed".to_string(), + )); + } + + let (nonce, body) = sealed.split_at(NONCE_BYTES); + let nonce: &[u8; NONCE_BYTES] = nonce.try_into().expect("a checked length"); + + self.cipher() + .decrypt(nonce.into(), body) + .map_err(|_| StorageError::Vault("it would not open".to_string())) + } + + /// The same bytes, base64. The columns are TEXT, so what goes in one has to survive + /// being read back as a string. + pub fn seal(&self, plaintext: &str) -> Result { + Ok(BASE64.encode(self.seal_bytes(plaintext.as_bytes())?)) + } + + /// ⚠️ Fails on a value that was not sealed with this key, and that is the point: the + /// tag is what tells a wrong passphrase from a tampered file. Neither is recoverable, + /// so neither is guessed at. + pub fn open(&self, sealed: &str) -> Result { + let raw = BASE64 + .decode(sealed) + .map_err(|_| StorageError::Vault("a value is not base64".to_string()))?; + + String::from_utf8(self.open_bytes(&raw)?).map_err(|_| { + StorageError::Vault("a value opened to something that is not text".to_string()) + }) + } +} + +/// A salt for a library that has none yet. +pub fn fresh_salt() -> Result<[u8; SALT_BYTES], StorageError> { + let mut salt = [0u8; SALT_BYTES]; + getrandom::fill(&mut salt) + .map_err(|error| StorageError::Vault(format!("no randomness: {error}")))?; + + Ok(salt) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// ⚠️ Cheap parameters, and only here: deriving at the real cost in every test would + /// add minutes to `cargo test` to prove nothing the real parameters prove better. + fn cheap() -> Cost { + Cost { + memory_kib: 64, + passes: 1, + lanes: 1, + } + } + + fn vault() -> Vault { + Vault::derive("a passphrase", b"0123456789abcdef", cheap()).unwrap() + } + + #[test] + fn a_sealed_value_opens_back_to_itself() { + let vault = vault(); + + let sealed = vault.seal("psql -h prod.internal -U admin").unwrap(); + + assert_eq!( + vault.open(&sealed).unwrap(), + "psql -h prod.internal -U admin" + ); + } + + #[test] + fn an_empty_value_survives_the_round_trip() { + let vault = vault(); + + let sealed = vault.seal("").unwrap(); + + assert_eq!(vault.open(&sealed).unwrap(), ""); + } + + #[test] + fn accents_and_emoji_come_back_whole() { + let vault = vault(); + let text = "étape de déploiement ⚡ 日本語"; + + assert_eq!(vault.open(&vault.seal(text).unwrap()).unwrap(), text); + } + + /// The same text twice must not produce the same ciphertext, or the file would say + /// which notes are identical. + #[test] + fn sealing_the_same_text_twice_gives_two_different_values() { + let vault = vault(); + + assert_ne!(vault.seal("same").unwrap(), vault.seal("same").unwrap()); + } + + #[test] + fn the_sealed_form_carries_none_of_the_plaintext() { + let vault = vault(); + + let sealed = vault.seal("hunter2").unwrap(); + + assert!(!sealed.contains("hunter2")); + } + + /// ⚠️ What tells a wrong passphrase from a right one. Without it an unlock would + /// succeed and the library would read as gibberish. + #[test] + fn a_value_will_not_open_under_another_passphrase() { + let sealed = vault().seal("secret").unwrap(); + let other = Vault::derive("another passphrase", b"0123456789abcdef", cheap()).unwrap(); + + assert!(other.open(&sealed).is_err()); + } + + #[test] + fn the_same_passphrase_under_another_salt_is_another_key() { + let sealed = vault().seal("secret").unwrap(); + let elsewhere = Vault::derive("a passphrase", b"fedcba9876543210", cheap()).unwrap(); + + assert!(elsewhere.open(&sealed).is_err()); + } + + /// ⚠️ GCM authenticates: a flipped byte is refused rather than decrypted into + /// plausible-looking nonsense. + #[test] + fn a_tampered_value_is_refused_rather_than_opened() { + let vault = vault(); + let sealed = vault.seal("the original").unwrap(); + + let mut raw = BASE64.decode(&sealed).unwrap(); + let last = raw.len() - 1; + raw[last] ^= 0x01; + + assert!(vault.open(&BASE64.encode(raw)).is_err()); + } + + #[test] + fn anything_that_is_not_a_sealed_value_is_refused() { + let vault = vault(); + + assert!(vault.open("not base64 at all !!").is_err()); + assert!(vault.open("").is_err()); + assert!(vault.open(&BASE64.encode([0u8; 4])).is_err()); + } + + /// Two salts drawn in a row must differ, or every library would share a key. + #[test] + fn a_fresh_salt_is_not_the_previous_one() { + assert_ne!(fresh_salt().unwrap(), fresh_salt().unwrap()); + } + + /// ⚠️ A key that reaches a log is a key that is gone. + #[test] + fn a_vault_never_prints_its_key() { + assert_eq!(format!("{:?}", vault()), "Vault(…)"); + } +} diff --git a/src-tauri/src/vault/migrate.rs b/src-tauri/src/vault/migrate.rs new file mode 100644 index 0000000..b7babdd --- /dev/null +++ b/src-tauri/src/vault/migrate.rs @@ -0,0 +1,238 @@ +//! Turning a library that was written in the clear into a sealed one. +//! +//! ⚠️ Run once, from [`super::create_vault`], on a database that already exists. Every +//! sealed column is read as it stands and written back sealed, in **one transaction** — +//! a half-sealed library opens into a mixture nothing can tell apart, since a value that +//! will not open is refused rather than degraded. +//! +//! ⚠️ The attachment files are sealed after the transaction commits, and not inside it: +//! a file write does not roll back. A file left in the clear is readable and recoverable; +//! a row sealed twice is neither. + +use diesel::prelude::*; + +use crate::db::schema::{ + attachments, global_placeholders, note_items, note_placeholders, notes, spaces, +}; +use crate::error::StorageError; +use crate::vault::key::Vault; + +/// How many rows carried plaintext, so a caller can say what happened. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Sealed { + pub notes: usize, + pub spaces: usize, + pub items: usize, + pub values: usize, + pub attachments: usize, +} + +impl Sealed { + pub fn is_empty(&self) -> bool { + *self == Self::default() + } +} + +/// ⚠️ Not idempotent, and it cannot be: a sealed value is bytes like any other, so there +/// is no telling one apart from a plaintext body that happens to be base64. The guard is +/// the key file — [`super::file::create`] refuses a library that already has one, and +/// this only ever runs on the launch that creates it. +pub fn seal_existing( + connection: &mut SqliteConnection, + vault: &Vault, +) -> Result { + connection.transaction(|connection| { + let mut done = Sealed::default(); + + for (id, title, content, source) in notes::table + .select((notes::id, notes::title, notes::content, notes::source)) + .load::<(String, String, String, String)>(connection)? + { + diesel::update(notes::table.find(&id)) + .set(( + notes::title.eq(vault.seal(&title)?), + notes::content.eq(vault.seal(&content)?), + notes::source.eq(vault.seal(&source)?), + )) + .execute(connection)?; + done.notes += 1; + } + + for (id, name) in spaces::table + .select((spaces::id, spaces::name)) + .load::<(String, String)>(connection)? + { + diesel::update(spaces::table.find(&id)) + .set(spaces::name.eq(vault.seal(&name)?)) + .execute(connection)?; + done.spaces += 1; + } + + // ⚠️ Keyed on `(note_id, position)` and `(note_id, name)`, so the update has to + // name both halves — `find` takes a single-column key and these have none. + for (note_id, position, text) in note_items::table + .select((note_items::note_id, note_items::position, note_items::text)) + .load::<(String, i32, String)>(connection)? + { + diesel::update( + note_items::table + .filter(note_items::note_id.eq(¬e_id)) + .filter(note_items::position.eq(position)), + ) + .set(note_items::text.eq(vault.seal(&text)?)) + .execute(connection)?; + done.items += 1; + } + + for (note_id, name, value) in note_placeholders::table + .select(( + note_placeholders::note_id, + note_placeholders::name, + note_placeholders::value, + )) + .load::<(String, String, String)>(connection)? + { + diesel::update( + note_placeholders::table + .filter(note_placeholders::note_id.eq(¬e_id)) + .filter(note_placeholders::name.eq(&name)), + ) + .set(note_placeholders::value.eq(vault.seal(&value)?)) + .execute(connection)?; + done.values += 1; + } + + for (name, value) in global_placeholders::table + .select((global_placeholders::name, global_placeholders::value)) + .load::<(String, String)>(connection)? + { + diesel::update(global_placeholders::table.find(&name)) + .set(global_placeholders::value.eq(vault.seal(&value)?)) + .execute(connection)?; + done.values += 1; + } + + for (id, file_name) in attachments::table + .select((attachments::id, attachments::file_name)) + .load::<(String, String)>(connection)? + { + diesel::update(attachments::table.find(&id)) + .set(attachments::file_name.eq(vault.seal(&file_name)?)) + .execute(connection)?; + done.attachments += 1; + } + + Ok(done) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + use crate::notes::store as note_store; + use crate::spaces::store as space_store; + + fn plaintext_library() -> db::Library { + // ⚠️ Built through the sealing stores, then un-sealed by hand below: there is no + // way left in the crate to write a plaintext row, which is the point. + db::open_in_memory().expect("a library") + } + + /// Writes the columns back as they read, which is what a library from before + /// encryption looks like. + fn unseal_in_place(library: &mut db::Library) { + let (connection, vault) = library.split(); + let rows = notes::table + .select((notes::id, notes::title, notes::content, notes::source)) + .load::<(String, String, String, String)>(connection) + .unwrap(); + let opened: Vec<_> = rows + .into_iter() + .map(|(id, title, content, source)| { + ( + id, + vault.open(&title).unwrap(), + vault.open(&content).unwrap(), + vault.open(&source).unwrap(), + ) + }) + .collect(); + for (id, title, content, source) in opened { + diesel::update(notes::table.find(&id)) + .set(( + notes::title.eq(title), + notes::content.eq(content), + notes::source.eq(source), + )) + .execute(connection) + .unwrap(); + } + + let spaces_rows = spaces::table + .select((spaces::id, spaces::name)) + .load::<(String, String)>(connection) + .unwrap(); + let opened: Vec<_> = spaces_rows + .into_iter() + .map(|(id, name)| (id, vault.open(&name).unwrap())) + .collect(); + for (id, name) in opened { + diesel::update(spaces::table.find(&id)) + .set(spaces::name.eq(name)) + .execute(connection) + .unwrap(); + } + } + + #[test] + fn a_plaintext_library_reads_back_whole_once_sealed() { + let mut library = plaintext_library(); + let space = space_store::create(&mut library, "Secrets").unwrap().id; + let draft = crate::notes::model::NoteDraft { + space_id: space.clone(), + title: "AWS prod".to_string(), + language: crate::notes::language::Language::Txt, + content: "hunter2".to_string(), + source: String::new(), + tags: Vec::new(), + pinned: false, + lifecycle: crate::notes::model::NoteLifecycle::Permanent, + kind: crate::notes::checklist::NoteKind::Snippet, + items: Vec::new(), + }; + let created = note_store::create( + &mut library, + draft, + crate::notes::fixtures::at(crate::notes::fixtures::NOW), + ) + .unwrap() + .id; + unseal_in_place(&mut library); + + let done = { + let (connection, vault) = library.split(); + seal_existing(connection, vault).unwrap() + }; + + assert_eq!(done.notes, 1); + assert_eq!(done.spaces, 1); + + let read_back = note_store::by_ids(&mut library, &[created]).unwrap(); + assert_eq!(read_back[0].title, "AWS prod"); + assert_eq!(read_back[0].content, "hunter2"); + assert_eq!(space_store::list(&mut library).unwrap()[0].name, "Secrets"); + } + + #[test] + fn an_empty_library_seals_nothing_and_says_so() { + let mut library = plaintext_library(); + + let done = { + let (connection, vault) = library.split(); + seal_existing(connection, vault).unwrap() + }; + + assert!(done.is_empty()); + } +} diff --git a/src-tauri/tests/ipc_contract.rs b/src-tauri/tests/ipc_contract.rs index d74cfc0..8589573 100644 --- a/src-tauri/tests/ipc_contract.rs +++ b/src-tauri/tests/ipc_contract.rs @@ -426,6 +426,8 @@ fn an_import_report_names_what_it_skipped() { notes_imported: 2, notes_skipped: 3, notes_degraded: 4, + attachments_imported: 1, + attachments_missing: 0, }) .unwrap(); @@ -448,6 +450,7 @@ fn an_export_bundle_reads_back_the_notes_it_wrote() { pinned: false, }], notes: vec![sample()], + attachments: Vec::new(), }; let json = serde_json::to_string(&bundle).unwrap(); diff --git a/src-tauri/tests/notes.rs b/src-tauri/tests/notes.rs index 0395d34..243db6a 100644 --- a/src-tauri/tests/notes.rs +++ b/src-tauri/tests/notes.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; -use diesel::SqliteConnection; +use devbox_lib::db::Library; use diesel::prelude::*; use chrono::{DateTime, Utc}; @@ -22,7 +22,7 @@ use devbox_lib::spaces::store as spaces; /// No such shortcut exists in production code: it would invite re-filtering on the /// front end. -fn list(connection: &mut SqliteConnection) -> Result, StorageError> { +fn list(connection: &mut Library) -> Result, StorageError> { fetch( connection, &NotesQuery { @@ -51,15 +51,12 @@ fn t1() -> DateTime { at("2026-07-25T10:00:00.000Z") } -fn query( - connection: &mut SqliteConnection, - request: &NotesQuery, -) -> Result { +fn query(connection: &mut Library, request: &NotesQuery) -> Result { let (notes, facets) = fetch(connection, request)?; Ok(view::build(notes, facets, request)) } -fn space(connection: &mut SqliteConnection, name: &str) -> String { +fn space(connection: &mut Library, name: &str) -> String { spaces::create(connection, name).unwrap().id } @@ -352,7 +349,7 @@ fn emptying_the_list_leaves_no_row_behind() { assert_eq!( note_items::table .count() - .get_result::(&mut connection) + .get_result::(connection.db()) .unwrap(), 0 ); @@ -400,7 +397,7 @@ fn purging_removes_the_note_and_its_items_for_good() { assert_eq!( note_items::table .count() - .get_result::(&mut connection) + .get_result::(connection.db()) .unwrap(), 0 ); @@ -508,7 +505,7 @@ fn deleting_takes_the_note_off_the_canvas_without_destroying_it() { assert!(list(&mut connection).unwrap().is_empty()); let kept_tags = note_tags::table .count() - .get_result::(&mut connection) + .get_result::(connection.db()) .unwrap(); assert_eq!(kept_tags, 2); @@ -563,7 +560,7 @@ fn purging_removes_the_note_and_its_tags_for_good() { assert!(list_trashed(&mut connection).unwrap().is_empty()); let orphan_tags = note_tags::table .count() - .get_result::(&mut connection) + .get_result::(connection.db()) .unwrap(); assert_eq!(orphan_tags, 0); } @@ -727,7 +724,7 @@ fn purging_removes_the_note_and_its_values_for_good() { assert_eq!( note_placeholders::table .count() - .get_result::(&mut connection) + .get_result::(connection.db()) .unwrap(), 0 ); @@ -1669,7 +1666,7 @@ fn deleting_a_space_takes_its_notes_with_it() { create(&mut connection, draft(&space_id), t0()).unwrap(); diesel::delete(spaces_table::table.find(&space_id)) - .execute(&mut connection) + .execute(connection.db()) .unwrap(); assert!(list(&mut connection).unwrap().is_empty()); @@ -1683,7 +1680,7 @@ fn a_stored_date_that_is_out_of_format_is_reported_rather_than_guessed() { diesel::update(devbox_lib::db::schema::notes::table.find(&created.id)) .set(devbox_lib::db::schema::notes::created_at.eq("pas une date")) - .execute(&mut connection) + .execute(connection.db()) .unwrap(); let error = list(&mut connection).unwrap_err(); @@ -1708,8 +1705,73 @@ fn a_stored_date_always_carries_its_milliseconds() { let stored: String = devbox_lib::db::schema::notes::table .find(&created.id) .select(devbox_lib::db::schema::notes::updated_at) - .first(&mut connection) + .first(connection.db()) .unwrap(); assert_eq!(stored, "2026-07-25T09:00:00.000Z"); } + +/// ⚠️ The point of the whole thing, and the only test that reads the file rather than the +/// API: a note written through the store must not be findable by grepping the database. +#[test] +fn a_note_is_not_readable_in_the_file_it_was_written_to() { + use devbox_lib::db; + + let directory = std::env::temp_dir().join(format!( + "devbox-sealed-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join("sealed.sqlite3"); + + let secret = "psql -h prod.internal -U admin -W hunter2"; + { + let mut library = db::open(&path, db::test_vault().unwrap()).unwrap(); + let space = spaces::create(&mut library, "Secrets").unwrap().id; + let mut seeded = draft(&space); + seeded.title = "AWS prod credentials".to_string(); + seeded.content = secret.to_string(); + seeded.tags = vec!["prod".to_string()]; + create(&mut library, seeded, t0()).unwrap(); + + // A variable for the whole corpus is where a host name or a token ends up, so it + // is sealed like anything else a reader would want. + let mut globals = std::collections::BTreeMap::new(); + globals.insert("host".to_string(), "prod.internal".to_string()); + devbox_lib::notes::store::replace_global_placeholder_values(&mut library, &globals) + .unwrap(); + } + + let raw = std::fs::read(&path).unwrap(); + let haystack = String::from_utf8_lossy(&raw); + + assert!(!haystack.contains(secret), "the body is in the clear"); + assert!( + !haystack.contains("AWS prod credentials"), + "the title is in the clear" + ); + assert!( + !haystack.contains("Secrets"), + "the space name is in the clear" + ); + assert!( + !haystack.contains("prod.internal"), + "a global variable's value is in the clear" + ); + // ⚠️ Tags and the names of variables are deliberately not sealed: the facet, the + // filter and the lookup all touch them in SQL. This asserts the decision rather than + // an accident. + assert!( + haystack.contains("prod"), + "a tag is expected to stay readable" + ); + assert!( + haystack.contains("host"), + "a variable's name is expected to stay readable" + ); + + std::fs::remove_dir_all(&directory).ok(); +} diff --git a/src-tauri/tests/spaces.rs b/src-tauri/tests/spaces.rs index df2662e..9f09cce 100644 --- a/src-tauri/tests/spaces.rs +++ b/src-tauri/tests/spaces.rs @@ -1,4 +1,4 @@ -use diesel::SqliteConnection; +use devbox_lib::db::Library; use diesel::prelude::*; use devbox_lib::db::open_in_memory; @@ -10,7 +10,7 @@ const T0: &str = "2026-07-25T09:00:00.000Z"; /// A note written straight into the database: going through `notes::create` would /// drag its own rules in. -fn note_in(connection: &mut SqliteConnection, space_id: &str) { +fn note_in(connection: &mut Library, space_id: &str) { diesel::insert_into(notes::table) .values(( notes::id.eq("n-1"), @@ -24,11 +24,11 @@ fn note_in(connection: &mut SqliteConnection, space_id: &str) { notes::updated_at.eq(T0), notes::lifecycle_kind.eq("permanent"), )) - .execute(connection) + .execute(connection.db()) .unwrap(); } -fn names(connection: &mut SqliteConnection) -> Vec { +fn names(connection: &mut Library) -> Vec { list(connection) .unwrap() .into_iter() @@ -207,7 +207,7 @@ fn deleting_a_space_moves_its_notes_to_the_target() { let space_id = notes::table .find("n-1") .select(notes::space_id) - .first::(&mut connection) + .first::(connection.db()) .unwrap(); assert_eq!(space_id, refuge.id); assert_eq!(list(&mut connection).unwrap().len(), 1); @@ -225,7 +225,7 @@ fn moving_notes_out_of_a_deleted_space_does_not_touch_their_timestamps() { let updated_at = notes::table .find("n-1") .select(notes::updated_at) - .first::(&mut connection) + .first::(connection.db()) .unwrap(); assert_eq!(updated_at, T0); } @@ -264,7 +264,7 @@ fn deleting_into_an_unknown_space_changes_nothing() { assert_eq!( notes::table .count() - .get_result::(&mut connection) + .get_result::(connection.db()) .unwrap(), 1 ); diff --git a/src-tauri/tests/transfer.rs b/src-tauri/tests/transfer.rs index cbe8313..9b5421f 100644 --- a/src-tauri/tests/transfer.rs +++ b/src-tauri/tests/transfer.rs @@ -1,14 +1,18 @@ use chrono::{DateTime, Utc}; -use diesel::SqliteConnection; +use devbox_lib::db::Library; +use devbox_lib::attachments::model::Attachment; +use devbox_lib::attachments::store as attachments; use devbox_lib::db::{iso8601, open_in_memory}; use devbox_lib::notes::checklist::NoteKind; use devbox_lib::notes::language::Language; use devbox_lib::notes::model::{NoteDraft, NoteLifecycle}; use devbox_lib::notes::store as notes; use devbox_lib::spaces::store as spaces; -use devbox_lib::transfer::bundle::{collect, merge}; -use devbox_lib::transfer::model::{self, Bundle, IncomingBundle}; +use devbox_lib::transfer::bundle::{collect, merge as merge_bundle}; +use devbox_lib::transfer::file; +use devbox_lib::transfer::file::Payload; +use devbox_lib::transfer::model::{self, Bundle, ImportReport, IncomingBundle}; fn t0() -> DateTime { iso8601::parse("2026-07-25T09:00:00.000Z").unwrap() @@ -29,8 +33,21 @@ fn draft(space_id: &str, title: &str) -> NoteDraft { } } +/// No attachment travels in these scenarios — the archive itself is covered in +/// `transfer::file`. `Payload::Empty` hands over no bytes, so the directory is never +/// written to. +fn merge(connection: &mut Library, bundle: IncomingBundle) -> ImportReport { + merge_bundle( + connection, + bundle, + &mut Payload::Empty, + &std::env::temp_dir(), + ) + .unwrap() +} + /// A populated database, the way a user would have one. -fn library() -> SqliteConnection { +fn library() -> Library { let mut connection = open_in_memory().unwrap(); let personal = spaces::create(&mut connection, "Personal").unwrap().id; let boulot = spaces::create(&mut connection, "Boulot").unwrap().id; @@ -40,7 +57,7 @@ fn library() -> SqliteConnection { connection } -fn exported(connection: &mut SqliteConnection) -> Bundle { +fn exported(connection: &mut Library) -> Bundle { let all = notes::all(connection, None).unwrap(); collect(connection, all).unwrap() } @@ -65,7 +82,7 @@ fn a_library_moves_whole_to_another_machine() { let bundle = round_tripped(&exported(&mut source)); let mut target = open_in_memory().unwrap(); - let report = merge(&mut target, bundle).unwrap(); + let report = merge(&mut target, bundle); assert_eq!(report.notes_imported, 2); assert_eq!(report.spaces_created, 2); @@ -99,8 +116,8 @@ fn importing_the_same_file_twice_adds_nothing_the_second_time() { let file = exported(&mut source); let mut target = open_in_memory().unwrap(); - merge(&mut target, round_tripped(&file)).unwrap(); - let second = merge(&mut target, round_tripped(&file)).unwrap(); + merge(&mut target, round_tripped(&file)); + let second = merge(&mut target, round_tripped(&file)); assert_eq!(second.notes_imported, 0); assert_eq!(second.notes_skipped, 2); @@ -113,7 +130,7 @@ fn reimporting_into_the_base_it_came_from_changes_nothing() { let mut library = library(); let bundle = round_tripped(&exported(&mut library)); - let report = merge(&mut library, bundle).unwrap(); + let report = merge(&mut library, bundle); assert_eq!(report.notes_imported, 0); assert_eq!(report.notes_skipped, 2); @@ -128,7 +145,7 @@ fn a_space_of_the_same_name_is_reused_rather_than_duplicated() { let mut target = open_in_memory().unwrap(); spaces::create(&mut target, "PERSONAL").unwrap(); - let report = merge(&mut target, bundle).unwrap(); + let report = merge(&mut target, bundle); assert_eq!(report.spaces_created, 1); assert_eq!(spaces::list(&mut target).unwrap().len(), 2); @@ -141,7 +158,7 @@ fn a_note_whose_space_is_missing_from_the_file_is_skipped_not_misfiled() { incoming.bundle.spaces.clear(); let mut target = open_in_memory().unwrap(); - let report = merge(&mut target, incoming).unwrap(); + let report = merge(&mut target, incoming); assert_eq!(report.notes_imported, 0); assert_eq!(report.notes_skipped, 2); @@ -169,8 +186,7 @@ fn a_note_in_an_unknown_language_arrives_without_taking_the_file_down() { let report = merge( &mut target, written_by_a_newer_version(&file, "language", "from-the-future"), - ) - .unwrap(); + ); assert_eq!(report.notes_imported, 2); assert_eq!(report.notes_degraded, 1); @@ -193,8 +209,7 @@ fn a_note_of_an_unknown_kind_is_degraded_the_same_way() { let report = merge( &mut target, written_by_a_newer_version(&file, "kind", "from-the-future"), - ) - .unwrap(); + ); assert_eq!(report.notes_imported, 2); assert_eq!(report.notes_degraded, 1); @@ -217,15 +232,171 @@ fn a_degraded_note_is_counted_once_and_not_again_on_a_second_import() { merge( &mut target, written_by_a_newer_version(&file, "language", "from-the-future"), - ) - .unwrap(); + ); let second = merge( &mut target, written_by_a_newer_version(&file, "language", "from-the-future"), + ); + + assert_eq!(second.notes_imported, 0); + assert_eq!(second.notes_degraded, 0); +} + +/// The attachment as it really sits beside the database: sealed under the library key, +/// which is what the export has to open before it can write the file out. +fn seal_beside(directory: &std::path::Path, library: &Library, record: &Attachment, bytes: &[u8]) { + let sealed = library.vault().seal_bytes(bytes).unwrap(); + std::fs::write(directory.join(record.stored_name()), sealed).unwrap(); +} + +/// `create` takes the pair, the way an import inside a transaction does. +fn attach( + library: &mut Library, + record: &Attachment, +) -> Result<(), devbox_lib::error::StorageError> { + let (db, vault) = library.split(); + attachments::create(db, vault, record) +} + +/// A directory of its own per scenario: these write real files beside a real archive. +fn scratch() -> std::path::PathBuf { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!("devbox-transfer-{stamp}")); + std::fs::create_dir_all(&directory).unwrap(); + + directory +} + +fn capture(note_id: &str) -> Attachment { + Attachment { + id: format!("a-{note_id}"), + note_id: note_id.to_string(), + file_name: "capture.png".to_string(), + mime_type: "image/png".to_string(), + byte_size: 4, + created_at: t0(), + } +} + +/// ⚠️ The point of the archive. A screenshot is a file beside the database, so an export +/// that carried only the rows handed over notes whose thumbnails would never load — and +/// nothing said so. +#[test] +fn an_attachment_travels_with_the_library() { + let directory = scratch(); + let source_files = directory.join("source"); + let target_files = directory.join("target"); + std::fs::create_dir_all(&source_files).unwrap(); + std::fs::create_dir_all(&target_files).unwrap(); + + let mut source = library(); + let note_id = notes::all(&mut source, None).unwrap()[0].id.clone(); + let record = capture(¬e_id); + seal_beside(&source_files, &source, &record, b"\x89PNG"); + attach(&mut source, &record).unwrap(); + + let target_path = directory + .join("library.devbox") + .to_string_lossy() + .to_string(); + let packed = exported(&mut source); + let written = file::write(&target_path, &packed, &source_files, source.vault(), None).unwrap(); + assert_eq!(written.attachments, 1); + + let mut target = open_in_memory().unwrap(); + let (incoming, mut payload) = file::read(&target_path, None).unwrap(); + let report = merge_bundle(&mut target, incoming, &mut payload, &target_files).unwrap(); + + assert_eq!(report.attachments_imported, 1); + assert_eq!(report.attachments_missing, 0); + assert_eq!(attachments::list(&mut target, ¬e_id).unwrap().len(), 1); + assert_eq!( + std::fs::read(target_files.join(record.stored_name())).unwrap(), + b"\x89PNG" + ); + + std::fs::remove_dir_all(&directory).ok(); +} + +/// Re-importing the same archive adds nothing, attachments included: the notes are +/// skipped, so their files have nowhere to land twice. +#[test] +fn importing_the_same_archive_twice_restores_the_attachment_once() { + let directory = scratch(); + let files = directory.join("files"); + std::fs::create_dir_all(&files).unwrap(); + + let mut source = library(); + let note_id = notes::all(&mut source, None).unwrap()[0].id.clone(); + let record = capture(¬e_id); + seal_beside(&files, &source, &record, b"\x89PNG"); + attach(&mut source, &record).unwrap(); + + let target_path = directory + .join("library.devbox") + .to_string_lossy() + .to_string(); + file::write( + &target_path, + &exported(&mut source), + &files, + source.vault(), + None, ) .unwrap(); + let mut target = open_in_memory().unwrap(); + let (first, mut payload) = file::read(&target_path, None).unwrap(); + merge_bundle(&mut target, first, &mut payload, &files).unwrap(); + + let (again, mut payload) = file::read(&target_path, None).unwrap(); + let second = merge_bundle(&mut target, again, &mut payload, &files).unwrap(); + assert_eq!(second.notes_imported, 0); - assert_eq!(second.notes_degraded, 0); + assert_eq!(second.attachments_imported, 0); + assert_eq!(attachments::list(&mut target, ¬e_id).unwrap().len(), 1); + + std::fs::remove_dir_all(&directory).ok(); +} + +/// ⚠️ A record whose bytes the archive does not carry is counted, never swallowed: the +/// note arrives with a preview that will stay empty, and the report is what explains it. +#[test] +fn an_attachment_the_archive_does_not_carry_is_reported() { + let directory = scratch(); + let files = directory.join("files"); + std::fs::create_dir_all(&files).unwrap(); + + let mut source = library(); + let note_id = notes::all(&mut source, None).unwrap()[0].id.clone(); + // The record exists, its file never did: the export writes the row and no entry. + attach(&mut source, &capture(¬e_id)).unwrap(); + + let target_path = directory + .join("library.devbox") + .to_string_lossy() + .to_string(); + let written = file::write( + &target_path, + &exported(&mut source), + &files, + source.vault(), + None, + ) + .unwrap(); + assert_eq!(written.attachments, 0); + + let mut target = open_in_memory().unwrap(); + let (incoming, mut payload) = file::read(&target_path, None).unwrap(); + let report = merge_bundle(&mut target, incoming, &mut payload, &files).unwrap(); + + assert_eq!(report.notes_imported, 2); + assert_eq!(report.attachments_missing, 1); + assert_eq!(report.attachments_imported, 0); + + std::fs::remove_dir_all(&directory).ok(); } diff --git a/src/app/app.component.html b/src/app/app.component.html index 4508a5b..c92bd21 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -2,6 +2,16 @@ - + + @if (vault.isUnlocked()) { + + } @else if (vault.state() !== null) { + + } + diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 9adf3f2..c4086c1 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -6,6 +6,10 @@ import { ErrorBannerComponent } from '@banners/error-banner/error-banner.compone import { TitlebarComponent } from '@titlebar/titlebar.component'; import { UpdatePromptComponent } from '@banners/update-prompt/update-prompt.component'; import { provideAppTesting } from '@testing/testing.providers'; +import { VaultGateComponent } from './vault-gate/vault-gate.component'; +import { VaultStore } from '@core/state/vault.store'; +import { VaultRepository } from '@core/data/vault.repository'; +import { FakeVaultRepository } from '@testing/fake-vault-repository'; import { AppComponent } from './app.component'; describe('AppComponent', () => { @@ -22,13 +26,46 @@ describe('AppComponent', () => { fixture.autoDetectChanges(); }); + /** The shell asks Rust before the first render; a spec says what the answer was. */ + async function withVault(state: 'absent' | 'locked' | 'unlocked'): Promise { + (TestBed.inject(VaultRepository) as unknown as FakeVaultRepository).answer = state; + await TestBed.inject(VaultStore).load(); + await fixture.whenStable(); + } + it('renders the persistent chrome: titlebar, global error banner and update prompt', () => { expect(fixture.debugElement.query(By.directive(TitlebarComponent))).not.toBeNull(); expect(fixture.debugElement.query(By.directive(ErrorBannerComponent))).not.toBeNull(); expect(fixture.debugElement.query(By.directive(UpdatePromptComponent))).not.toBeNull(); }); - it('hosts features through the router outlet rather than importing them directly', () => { + it('hosts features through the router outlet rather than importing them directly', async () => { + await withVault('unlocked'); + expect(fixture.debugElement.query(By.directive(RouterOutlet))).not.toBeNull(); }); + + /** + * ⚠️ The outlet is not merely hidden while the library is locked: it is not created. + * The canvas queries notes the moment it mounts, and there would be nothing to answer. + */ + it('puts the gate in front of the outlet while the library is locked', async () => { + await withVault('locked'); + + expect(fixture.debugElement.query(By.directive(VaultGateComponent))).not.toBeNull(); + expect(fixture.debugElement.query(By.directive(RouterOutlet))).toBeNull(); + }); + + /** A library that has never been encrypted asks for a passphrase to be chosen. */ + it('asks for a passphrase to be created on a library that has none', async () => { + await withVault('absent'); + + expect(fixture.debugElement.query(By.directive(VaultGateComponent))).not.toBeNull(); + }); + + /** Before the answer lands, neither: the shell would flash a screen it is replacing. */ + it('renders neither until Rust has answered', () => { + expect(fixture.debugElement.query(By.directive(VaultGateComponent))).toBeNull(); + expect(fixture.debugElement.query(By.directive(RouterOutlet))).toBeNull(); + }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 2c339c7..12d39dd 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,9 +1,12 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { ErrorBannerComponent } from '@banners/error-banner/error-banner.component'; import { StatusToastComponent } from '@banners/status-toast/status-toast.component'; import { TitlebarComponent } from '@titlebar/titlebar.component'; import { UpdatePromptComponent } from '@banners/update-prompt/update-prompt.component'; +import { PassphrasePromptComponent } from './passphrase-prompt/passphrase-prompt.component'; +import { VaultGateComponent } from './vault-gate/vault-gate.component'; +import { VaultStore } from '@core/state/vault.store'; @Component({ selector: 'app-root', @@ -13,9 +16,13 @@ import { UpdatePromptComponent } from '@banners/update-prompt/update-prompt.comp StatusToastComponent, RouterOutlet, UpdatePromptComponent, + VaultGateComponent, + PassphrasePromptComponent, ], templateUrl: './app.component.html', styleUrl: './app.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AppComponent {} +export class AppComponent { + protected readonly vault = inject(VaultStore); +} diff --git a/src/app/app.config.ts b/src/app/app.config.ts index e1366f0..43a2c15 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -20,6 +20,7 @@ import { SettingsStore } from '@core/services/settings/settings.store'; import { GlobalShortcutsService } from '@core/services/shortcuts/global-shortcuts.service'; import { TrayService } from '@core/services/tray/tray.service'; import { UpdateStore } from '@core/services/updates/update.store'; +import { VaultStore } from '@core/state/vault.store'; import { WindowBehaviorService } from '@core/services/window/window-behavior.service'; export const appConfig: ApplicationConfig = { @@ -54,6 +55,7 @@ export const appConfig: ApplicationConfig = { const shortcuts = inject(GlobalShortcutsService); const windowBehavior = inject(WindowBehaviorService); const autostart = inject(AutostartService); + const vault = inject(VaultStore); await preferences.hydrate(); // ⚠️ Before the first render, and before `locale.restore()`, which reads the @@ -64,6 +66,10 @@ export const appConfig: ApplicationConfig = { // earlier would push the default language and a setting the user had changed. tray.start(); + // ⚠️ Before the first render: the shell renders nothing at all until this answers, + // rather than flashing a canvas it is about to replace with an unlock screen. + await vault.load(); + shortcuts.start(); windowBehavior.start(); // Not awaited: asking the system must not delay the first render. diff --git a/src/app/core/data/transfer.repository.ts b/src/app/core/data/transfer.repository.ts index 1d92123..1214148 100644 --- a/src/app/core/data/transfer.repository.ts +++ b/src/app/core/data/transfer.repository.ts @@ -6,17 +6,30 @@ import { ExportReport, ImportReport } from '../model/note.model'; /** The file is written and read back on the Rust side; the front only picks a path. */ @Injectable({ providedIn: 'root' }) export class TransferRepository { - /** A `null` `spaceId` exports the whole corpus. */ - async export(path: string, spaceId: string | null): Promise { - return unwrap('export_notes', await commands.exportNotes(path, spaceId)); + /** + * A `null` `spaceId` exports the whole corpus. ⚠️ A `null` `passphrase` writes the file + * in the clear — the library's own key protects what is on this machine, never what + * leaves it. + */ + async export(path: string, spaceId: string | null, passphrase: string | null): Promise { + return unwrap('export_notes', await commands.exportNotes(path, spaceId, passphrase)); } - async exportSelection(path: string, ids: readonly string[]): Promise { - return unwrap('export_selection', await commands.exportSelection(path, [...ids])); + async exportSelection( + path: string, + ids: readonly string[], + passphrase: string | null, + ): Promise { + return unwrap('export_selection', await commands.exportSelection(path, [...ids], passphrase)); } - async import(path: string): Promise { - return unwrap('import_notes', await commands.importNotes(path)); + async import(path: string, passphrase: string | null): Promise { + return unwrap('import_notes', await commands.importNotes(path, passphrase)); + } + + /** Asked before the import, so the phrase can be requested rather than demanded twice. */ + async isProtected(path: string): Promise { + return unwrap('export_is_protected', await commands.exportIsProtected(path)); } async share(ids: readonly string[]): Promise { diff --git a/src/app/core/data/vault.repository.ts b/src/app/core/data/vault.repository.ts new file mode 100644 index 0000000..427127b --- /dev/null +++ b/src/app/core/data/vault.repository.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@angular/core'; +import { commands } from '@core/ipc/bindings'; +import { unwrap } from '@core/ipc/ipc.error'; +import { VaultState } from '../model/vault.model'; + +/** + * ⚠️ The passphrase crosses the bridge and is never held on this side: nothing here keeps + * it, and the field that carried it is cleared as soon as it has been sent. + */ +@Injectable({ providedIn: 'root' }) +export class VaultRepository { + async state(): Promise { + return unwrap('vault_state', await commands.vaultState()); + } + + async create(passphrase: string): Promise { + unwrap('create_vault', await commands.createVault(passphrase)); + } + + async unlock(passphrase: string): Promise { + unwrap('unlock_vault', await commands.unlockVault(passphrase)); + } + + async changePassphrase(current: string, next: string): Promise { + unwrap('change_passphrase', await commands.changePassphrase(current, next)); + } +} diff --git a/src/app/core/ipc/bindings.ts b/src/app/core/ipc/bindings.ts index 0177bc6..73ff860 100644 --- a/src/app/core/ipc/bindings.ts +++ b/src/app/core/ipc/bindings.ts @@ -54,21 +54,51 @@ export const commands = { /** * ⚠️ The call starts from Rust: opening a path from the front end would mean allowing * `opener:allow-open-path` over a whole directory. + * + * ⚠️ **This is the one place a decrypted copy reaches the disk.** Handing a file to the + * application the desktop chose for it means handing over a path, and that file has to + * be readable. The copy goes under a directory of ours in the OS temporary folder and is + * swept at the next launch — it cannot be deleted on close, because the application that + * opened it still holds it. The README says so; replacing this with "save as" was the + * alternative and was turned down, one click being the point. */ openAttachment: (id: string) => typedError(__TAURI_INVOKE("open_attachment", { id })), /** The path comes from a native picker; the write stays here. */ saveAttachment: (id: string, path: string) => typedError(__TAURI_INVOKE("save_attachment", { id, path })), deleteAttachment: (id: string) => typedError(__TAURI_INVOKE("delete_attachment", { id })), /** The spaces travel with the notes, or an import holds an id with nowhere to file it. */ - exportNotes: (path: string, spaceId: string | null) => typedError(__TAURI_INVOKE("export_notes", { path, spaceId })), - exportSelection: (path: string, ids: string[]) => typedError(__TAURI_INVOKE("export_selection", { path, ids })), + exportNotes: (path: string, spaceId: string | null, passphrase: string | null) => typedError(__TAURI_INVOKE("export_notes", { path, spaceId, passphrase })), + exportSelection: (path: string, ids: string[], passphrase: string | null) => typedError(__TAURI_INVOKE("export_selection", { path, ids, passphrase })), /** * ⚠️ The file is read before the lock is taken: parsing a large export while holding the * connection would block every other command for the length of it. */ - importNotes: (path: string) => typedError(__TAURI_INVOKE("import_notes", { path })), + importNotes: (path: string, passphrase: string | null) => typedError(__TAURI_INVOKE("import_notes", { path, passphrase })), /** Nothing is sent anywhere: "share" stops at the clipboard. */ shareNotes: (ids: string[]) => typedError(__TAURI_INVOKE("share_notes", { ids })), + /** Whether an import will want a phrase, so the interface can ask before it starts. */ + exportIsProtected: (path: string) => typedError(__TAURI_INVOKE("export_is_protected", { path })), + vaultState: () => typedError(__TAURI_INVOKE("vault_state")), + /** + * The first launch. ⚠️ Refuses a library that already has a key file rather than + * replacing it: that file is the only way into the notes beside it. + */ + createVault: (passphrase: string) => typedError(__TAURI_INVOKE("create_vault", { passphrase })), + /** + * ⚠️ Deliberately slow: deriving the key is the whole defence against someone trying + * passphrases against a copied file. It is `(async)` for the same reason — a second on the + * main thread would freeze the window over every attempt. + */ + unlockVault: (passphrase: string) => typedError(__TAURI_INVOKE("unlock_vault", { passphrase })), + /** + * A new phrase over the same library, from the preferences panel. + * + * ⚠️ Not a re-encryption: the key the notes are sealed with is the one being rewrapped, + * so nothing in the database moves and the library stays open on the key it already had. + * The consequence is worth knowing — this answers a phrase somebody else learned, never + * a key somebody else got hold of. + */ + changePassphrase: (current: string, next: string) => typedError(__TAURI_INVOKE("change_passphrase", { current, next })), appChangelog: () => __TAURI_INVOKE("app_changelog"), /** * Replaces only the menu when the tray already exists, so a language change does not @@ -157,11 +187,31 @@ export type ErrorCode = "noteNotFound" | "spaceNotFound" | "duplicateSpaceName" /** The `field` parameter names the offending field. */ "invalidInput" | /** Poisoned mutex: a command panicked while holding the connection. */ -"storageUnavailable" | "storage"; +"storageUnavailable" | +/** + * The one the unlock screen acts on: it clears the field rather than banishing the + * user to a banner. + */ +"wrongPassphrase" | +/** A command ran before the library was unlocked. */ +"locked" | +/** The import needs the phrase the export was protected with. */ +"passphraseRequired" | "storage"; export type ExportReport = { notes: number, spaces: number, + /** + * What actually went into the archive. A record whose file has gone missing is left + * out rather than failing the export. + */ + attachments: number, + /** + * ⚠️ `false` means the file is readable by anyone who has it — every note, every + * screenshot. The interface says which of the two it wrote, because the file is the + * one thing here most likely to leave the machine. + */ + protected: boolean, }; /** @@ -184,6 +234,12 @@ export type ImportReport = { * default. Counted so the loss is said rather than discovered. */ notesDegraded: number, + attachmentsImported: number, + /** + * Records the archive named but did not carry. Counted rather than swallowed: the + * note arrives with a thumbnail that will never load, and only this says why. + */ + attachmentsMissing: number, }; /** @@ -382,6 +438,22 @@ export type TrayLabels = { quit: string, }; +/** What the front end renders before it renders anything else. */ +export type VaultState = +/** + * A library that has never been encrypted: the first launch asks for a passphrase + * twice and creates one. + */ +"absent" | +/** A key file is there and the passphrase has not been given yet. */ +"locked" | +/** + * ⚠️ Held in Rust, never in the front end: 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. + */ +"unlocked"; + /** * Pushed from the preferences panel like the tray labels: reading `preferences.json` * back from Rust would be a second source to keep in step. diff --git a/src/app/core/ipc/ipc.error.ts b/src/app/core/ipc/ipc.error.ts index 361b576..a6d0480 100644 --- a/src/app/core/ipc/ipc.error.ts +++ b/src/app/core/ipc/ipc.error.ts @@ -22,6 +22,9 @@ const IPC_ERROR_CODES: Record = { importFormat: true, invalidInput: true, storageUnavailable: true, + wrongPassphrase: true, + locked: true, + passphraseRequired: true, storage: true, }; @@ -62,6 +65,14 @@ export class IpcError extends Error { } } +/** + * The one code a caller acts on rather than reports: a refused passphrase is the ordinary + * answer to a typo and belongs beside the field, not in the error banner. + */ +export function hasErrorCode(error: unknown, code: IpcErrorCode): boolean { + return error instanceof IpcError && error.code === code; +} + /** Throws rather than propagating the `status`, which every caller would have to branch on. */ export function unwrap(command: string, result: IpcResult): T { if (result.status === 'error') { diff --git a/src/app/core/model/vault.model.ts b/src/app/core/model/vault.model.ts new file mode 100644 index 0000000..da1aa51 --- /dev/null +++ b/src/app/core/model/vault.model.ts @@ -0,0 +1,9 @@ +/** Generated from the Rust enum, so a state added there breaks the shell until handled. */ +export type { VaultState } from '@core/ipc/bindings'; + +/** + * What `vault::validate` refuses below. Said on the front too, so a first launch does not + * answer "too short" after a second of derivation, and an export prompt can refuse a + * phrase it would be sold as protection. + */ +export const MINIMUM_PASSPHRASE_LENGTH = 8; diff --git a/src/app/core/services/dialogs/file-dialog.service.spec.ts b/src/app/core/services/dialogs/file-dialog.service.spec.ts index 60c045a..9d9d13a 100644 --- a/src/app/core/services/dialogs/file-dialog.service.spec.ts +++ b/src/app/core/services/dialogs/file-dialog.service.spec.ts @@ -40,10 +40,18 @@ describe('FileDialogService', () => { expect(await service.pickBundle()).toBeNull(); }); - it('filters the bundle picker on the exchange format', async () => { + /** ⚠️ `json` stays on the way in: an export written before the archive existed is + * still importable, and the picker has to let the user reach it. */ + it('filters the bundle picker on the exchange format, old one included', async () => { await service.pickBundle(); - expect(adapter.openCalls[0].filters?.[0].extensions).toEqual(['json']); + expect(adapter.openCalls[0].filters?.[0].extensions).toEqual(['devbox', 'json']); + }); + + it('offers only the archive when saving', async () => { + await service.chooseBundleDestination('library.devbox'); + + expect(adapter.saveCalls[0].filters?.[0].extensions).toEqual(['devbox']); }); it('leaves the attachment picker unfiltered', async () => { diff --git a/src/app/core/services/dialogs/file-dialog.service.ts b/src/app/core/services/dialogs/file-dialog.service.ts index 7f687d2..3d6f972 100644 --- a/src/app/core/services/dialogs/file-dialog.service.ts +++ b/src/app/core/services/dialogs/file-dialog.service.ts @@ -14,7 +14,10 @@ export const FILE_DIALOG_ADAPTER = new InjectionToken('FILE_D factory: () => ({ open, save }), }); -const BUNDLE_FILTER = { name: APP_INFO.name, extensions: ['json'] }; +/** ⚠️ `json` stays on the way in: an export written before the archive existed is still + * importable, and the picker has to let the user reach it. */ +const OPEN_FILTER = { name: APP_INFO.name, extensions: ['devbox', 'json'] }; +const SAVE_FILTER = { name: APP_INFO.name, extensions: ['devbox'] }; /** `null` covers both a cancellation and the plugin being unavailable. */ @Injectable({ providedIn: 'root' }) @@ -22,7 +25,7 @@ export class FileDialogService { private readonly adapter = inject(FILE_DIALOG_ADAPTER); async pickBundle(): Promise { - return this.pick({ multiple: false, filters: [BUNDLE_FILTER] }); + return this.pick({ multiple: false, filters: [OPEN_FILTER] }); } async pickAttachment(): Promise { @@ -30,7 +33,7 @@ export class FileDialogService { } async chooseBundleDestination(defaultPath: string): Promise { - return this.destination({ defaultPath, filters: [BUNDLE_FILTER] }); + return this.destination({ defaultPath, filters: [SAVE_FILTER] }); } /** No filter: an attachment can be of any type. */ diff --git a/src/app/core/services/errors/error-notifier.service.ts b/src/app/core/services/errors/error-notifier.service.ts index 706e2ad..bc56502 100644 --- a/src/app/core/services/errors/error-notifier.service.ts +++ b/src/app/core/services/errors/error-notifier.service.ts @@ -25,6 +25,9 @@ const CODE_KEYS: Record = { importFormat: 'errors.importFormat', invalidInput: 'errors.invalidInput', storageUnavailable: 'errors.storageUnavailable', + wrongPassphrase: 'errors.wrongPassphrase', + locked: 'errors.locked', + passphraseRequired: 'errors.passphraseRequired', storage: null, }; diff --git a/src/app/core/services/i18n/translations/en.json b/src/app/core/services/i18n/translations/en.json index 9830132..6a61267 100644 --- a/src/app/core/services/i18n/translations/en.json +++ b/src/app/core/services/i18n/translations/en.json @@ -150,6 +150,9 @@ "spaceGone": "This space no longer exists.", "invalidInput": "The engine rejected this value: {{field}}.", "storageUnavailable": "Storage is unavailable. Restart {{app}}.", + "wrongPassphrase": "Wrong passphrase.", + "locked": "The library is locked.", + "passphraseRequired": "This file is protected by a passphrase.", "updateFailed": "The update failed. {{app}} remains usable on its current version.", "updateCheckFailed": "The update check failed. Try again later.", "attachmentGone": "This attachment no longer exists.", @@ -170,7 +173,10 @@ "attachmentOpenFailed": "Could not open the attachment with an application.", "attachmentSaveFailed": "Could not save the attachment to that location.", "variablesLoadFailed": "Could not load the variables.", - "variablesSaveFailed": "Could not save the variables." + "variablesSaveFailed": "Could not save the variables.", + "passphraseChangeFailed": "Could not change the passphrase. The old one still opens the library.", + "unlockFailed": "Unlocking failed.", + "vaultStateFailed": "Could not tell whether the library is encrypted." }, "tray": { "open": "Open {{app}}", @@ -321,7 +327,22 @@ "imported": "{{notes}} note(s) imported from {{path}}, {{skipped}} skipped.", "importedNothing": "Nothing to import: those {{skipped}} note(s) are already in your library.", "importedFromNewerVersion": "{{notes}} note(s) imported from {{path}}, {{skipped}} skipped. {{degraded}} came from a newer version: their language or kind was brought down to what this version can read.", + "importedWithAttachments": "{{notes}} note(s) imported from {{path}}, {{skipped}} skipped, {{attachments}} attachment(s) restored.", + "importedWithoutSomeAttachments": "{{notes}} note(s) imported from {{path}}. ⚠️ {{missing}} attachment(s) the file named but did not carry: their preview will stay empty.", "exported": "{{notes}} note(s) exported to {{path}}.", + "exportedWithAttachments": "{{notes}} note(s) and {{attachments}} attachment(s) exported to {{path}}.", + "exportedProtected": "{{notes}} note(s) exported to {{path}}, sealed with the passphrase you gave it.", + "exportedProtectedWithAttachments": "{{notes}} note(s) and {{attachments}} attachment(s) exported to {{path}}, sealed with the passphrase you gave it.", + "protectTitle": "Protect this export?", + "protectLead": "{{path}} is about to leave your library. A passphrase seals it with a key of its own, which travels with the file rather than with this machine.", + "protectWarning": "⚠️ Without one, the file carries every note and every attachment in the clear: anyone holding it can read them.", + "protectAction": "Export protected", + "protectPlain": "Export in the clear", + "unlockTitle": "This file is protected", + "unlockLead": "Enter the passphrase {{path}} was exported with.", + "unlockAction": "Import", + "cancel": "Cancel", + "working": "Working…", "copied": "{{notes}} note(s) copied as Markdown to the clipboard.", "needsSelection": "Select at least one note.", "emptyLibrary": "Nothing to export: this library has no notes.", @@ -409,6 +430,19 @@ "shortcutReset": "Reset", "showPinnedFirst": "Show pinned first" }, + "security": { + "title": "Security", + "passphrase": "Passphrase", + "change": "Change…", + "changing": "Changing…", + "note": "It opens the library at every launch. Changing it re-encrypts nothing: your notes stay sealed with the same key, which is why it takes a second rather than a rewrite.", + "dialogTitle": "Change the passphrase", + "dialogLead": "The new one applies at the next launch. This session carries on as it is.", + "current": "Current passphrase", + "next": "New passphrase", + "cancel": "Cancel", + "changed": "Passphrase changed." + }, "notifications": { "title": "Notifications", "copyConfirmation": "Confirm every copy", @@ -481,5 +515,20 @@ "empty": "No release described yet.", "installed": "installed", "close": "Close" + }, + "vault": { + "createTitle": "Protect this library", + "createLead": "Choose a passphrase. {{app}} will ask for it once at every launch.", + "noRecovery": "⚠️ This passphrase cannot be recovered. Lose it and the library goes with it — a plaintext export is the only copy that does not depend on it.", + "unlockTitle": "Unlock the library", + "unlockLead": "Enter your passphrase to open your notes.", + "passphrase": "Passphrase", + "confirmation": "Confirmation", + "tooShort": "At least {{length}} characters.", + "mismatched": "The two entries differ.", + "createAction": "Protect and continue", + "unlockAction": "Unlock", + "working": "Unlocking…", + "creating": "Protecting the library…" } } diff --git a/src/app/core/services/i18n/translations/fr.json b/src/app/core/services/i18n/translations/fr.json index 416686a..1597b51 100644 --- a/src/app/core/services/i18n/translations/fr.json +++ b/src/app/core/services/i18n/translations/fr.json @@ -150,6 +150,9 @@ "spaceGone": "Cet espace n'existe plus.", "invalidInput": "Valeur refusée par le moteur : {{field}}.", "storageUnavailable": "Le stockage est indisponible. Redémarrez {{app}}.", + "wrongPassphrase": "Phrase de passe incorrecte.", + "locked": "La bibliothèque est verrouillée.", + "passphraseRequired": "Ce fichier est protégé par une phrase de passe.", "updateFailed": "La mise à jour a échoué. {{app}} reste utilisable dans sa version actuelle.", "updateCheckFailed": "La recherche de mise à jour a échoué. Réessayez plus tard.", "attachmentGone": "Cette pièce jointe n'existe plus.", @@ -170,7 +173,10 @@ "attachmentOpenFailed": "Impossible d'ouvrir la pièce jointe avec une application.", "attachmentSaveFailed": "Impossible d'enregistrer la pièce jointe à cet emplacement.", "variablesLoadFailed": "Impossible de charger les variables.", - "variablesSaveFailed": "Impossible d'enregistrer les variables." + "variablesSaveFailed": "Impossible d'enregistrer les variables.", + "passphraseChangeFailed": "Impossible de changer la phrase de passe. L'ancienne ouvre toujours la bibliothèque.", + "unlockFailed": "Le déverrouillage a échoué.", + "vaultStateFailed": "Impossible de savoir si la bibliothèque est chiffrée." }, "tray": { "open": "Ouvrir {{app}}", @@ -321,7 +327,22 @@ "imported": "{{notes}} note(s) importée(s) depuis {{path}}, {{skipped}} ignorée(s).", "importedNothing": "Rien à importer : ces {{skipped}} note(s) sont déjà dans votre bibliothèque.", "importedFromNewerVersion": "{{notes}} note(s) importée(s) depuis {{path}}, {{skipped}} ignorée(s). {{degraded}} vien(nen)t d’une version plus récente : leur langage ou leur type a été ramené à ce que cette version sait lire.", + "importedWithAttachments": "{{notes}} note(s) importée(s) depuis {{path}}, {{skipped}} ignorée(s), {{attachments}} pièce(s) jointe(s) restaurée(s).", + "importedWithoutSomeAttachments": "{{notes}} note(s) importée(s) depuis {{path}}. ⚠️ {{missing}} pièce(s) jointe(s) annoncée(s) par le fichier mais absente(s) : leur aperçu restera vide.", "exported": "{{notes}} note(s) exportée(s) vers {{path}}.", + "exportedWithAttachments": "{{notes}} note(s) et {{attachments}} pièce(s) jointe(s) exportée(s) vers {{path}}.", + "exportedProtected": "{{notes}} note(s) exportée(s) vers {{path}}, scellées avec la phrase de passe que vous lui avez donnée.", + "exportedProtectedWithAttachments": "{{notes}} note(s) et {{attachments}} pièce(s) jointe(s) exportée(s) vers {{path}}, scellées avec la phrase de passe que vous lui avez donnée.", + "protectTitle": "Protéger cet export ?", + "protectLead": "{{path}} va quitter votre bibliothèque. Une phrase de passe le scelle avec une clé qui lui est propre, et qui voyage avec le fichier plutôt qu’avec cette machine.", + "protectWarning": "⚠️ Sans elle, le fichier emporte toutes les notes et toutes les pièces jointes en clair : quiconque le détient peut les lire.", + "protectAction": "Exporter protégé", + "protectPlain": "Exporter en clair", + "unlockTitle": "Ce fichier est protégé", + "unlockLead": "Saisissez la phrase de passe avec laquelle {{path}} a été exporté.", + "unlockAction": "Importer", + "cancel": "Annuler", + "working": "En cours…", "copied": "{{notes}} note(s) copiée(s) en Markdown dans le presse-papier.", "needsSelection": "Sélectionnez au moins une note.", "emptyLibrary": "Rien à exporter : cette bibliothèque n'a aucune note.", @@ -409,6 +430,19 @@ "shortcutReset": "Rétablir", "showPinnedFirst": "Épinglées en premier" }, + "security": { + "title": "Sécurité", + "passphrase": "Phrase de passe", + "change": "Changer…", + "changing": "Changement…", + "note": "Elle ouvre la bibliothèque à chaque lancement. La changer ne rechiffre rien : les notes restent scellées avec la même clé, d'où une seconde plutôt qu'une réécriture.", + "dialogTitle": "Changer la phrase de passe", + "dialogLead": "La nouvelle s'applique au prochain lancement. Cette session continue telle quelle.", + "current": "Phrase actuelle", + "next": "Nouvelle phrase", + "cancel": "Annuler", + "changed": "Phrase de passe changée." + }, "notifications": { "title": "Notifications", "copyConfirmation": "Accuser chaque copie", @@ -481,5 +515,20 @@ "empty": "Aucune version décrite pour l'instant.", "installed": "installée", "close": "Fermer" + }, + "vault": { + "createTitle": "Protéger cette bibliothèque", + "createLead": "Choisissez une phrase de passe. Elle sera demandée à chaque lancement de {{app}}, une seule fois.", + "noRecovery": "⚠️ Cette phrase ne peut pas être récupérée. Perdue, la bibliothèque l’est aussi — un export en clair est la seule copie qui n’en dépend pas.", + "unlockTitle": "Déverrouiller la bibliothèque", + "unlockLead": "Entrez votre phrase de passe pour ouvrir vos notes.", + "passphrase": "Phrase de passe", + "confirmation": "Confirmation", + "tooShort": "Au moins {{length}} caractères.", + "mismatched": "Les deux saisies diffèrent.", + "createAction": "Protéger et continuer", + "unlockAction": "Déverrouiller", + "working": "Déverrouillage…", + "creating": "Protection de la bibliothèque…" } } diff --git a/src/app/core/state/library.store.spec.ts b/src/app/core/state/library.store.spec.ts index 1d461c8..55d1cec 100644 --- a/src/app/core/state/library.store.spec.ts +++ b/src/app/core/state/library.store.spec.ts @@ -6,10 +6,13 @@ import { FakeClipboard } from '@testing/fake-clipboard'; import { FakeFileDialog } from '@testing/fake-file-dialog'; import { FakeTransferRepository } from '@testing/fake-transfer-repository'; import { provideAppTesting } from '@testing/testing.providers'; -import { LibraryStore } from './library.store'; +import { LibraryStore, PassphraseAnswer } from './library.store'; const NOW = new Date('2026-08-27T09:00:00Z'); +/** Declining the protection: what most of these scenarios are not about. */ +const IN_THE_CLEAR: PassphraseAnswer = { kind: 'none' }; + interface Harness { readonly store: LibraryStore; readonly repository: FakeTransferRepository; @@ -38,6 +41,33 @@ function createStore(): Harness { }; } +/** The prompt is a promise the store is waiting on; nothing advances until it is given one. */ +async function asking(harness: Harness): Promise { + for (let turn = 0; turn < 50; turn++) { + if (harness.store.passphraseRequest() !== null && !harness.store.passphraseWorking()) return; + await Promise.resolve(); + } +} + +async function answer(harness: Harness, ...answers: PassphraseAnswer[]): Promise { + for (const given of answers) { + await asking(harness); + + expect(harness.store.passphraseRequest()).not.toBeNull(); + harness.store.answerPassphrase(given); + } +} + +async function exportEverything( + harness: Harness, + spaceId: string | null = null, + given: PassphraseAnswer = IN_THE_CLEAR, +): Promise { + const done = harness.store.export(spaceId, NOW); + await answer(harness, given); + await done; +} + describe('LibraryStore', () => { let harness: Harness; @@ -55,12 +85,19 @@ describe('LibraryStore', () => { }); it('reports what came in, naming the file it read', async () => { - harness.dialog.openPath = 'C:/notes/devbox-2026-08-27.json'; + harness.dialog.openPath = 'C:/notes/devbox-2026-08-27.devbox'; expect(await harness.store.import()).toBe(true); expect(harness.status.status()).toEqual({ key: 'file.imported', - params: { notes: '2', skipped: '0', degraded: '0', path: 'devbox-2026-08-27.json' }, + params: { + notes: '2', + skipped: '0', + degraded: '0', + attachments: '0', + missing: '0', + path: 'devbox-2026-08-27.devbox', + }, }); }); @@ -73,6 +110,8 @@ describe('LibraryStore', () => { notesImported: 5, notesSkipped: 0, notesDegraded: 1, + attachmentsImported: 0, + attachmentsMissing: 0, }; expect(await harness.store.import()).toBe(true); @@ -80,6 +119,41 @@ describe('LibraryStore', () => { expect(harness.status.status()?.params).toMatchObject({ notes: '5', degraded: '1' }); }); + it('counts the attachments that came back with the notes', async () => { + harness.dialog.openPath = 'C:/in.devbox'; + harness.repository.importReport = { + spacesCreated: 0, + notesImported: 2, + notesSkipped: 0, + notesDegraded: 0, + attachmentsImported: 3, + attachmentsMissing: 0, + }; + + expect(await harness.store.import()).toBe(true); + expect(harness.status.status()?.key).toBe('file.importedWithAttachments'); + expect(harness.status.status()?.params).toMatchObject({ attachments: '3' }); + }); + + /** ⚠️ The note arrives with a thumbnail that will never load, and this is the only + * thing that says why. It outranks the degraded notice on purpose: a missing file is + * a defect, a degraded field is a shrug. */ + it('says when the archive named an attachment it did not carry', async () => { + harness.dialog.openPath = 'C:/in.devbox'; + harness.repository.importReport = { + spacesCreated: 0, + notesImported: 2, + notesSkipped: 0, + notesDegraded: 1, + attachmentsImported: 0, + attachmentsMissing: 2, + }; + + expect(await harness.store.import()).toBe(true); + expect(harness.status.status()?.key).toBe('file.importedWithoutSomeAttachments'); + expect(harness.status.status()?.params).toMatchObject({ missing: '2' }); + }); + it('says so plainly when everything was already there', async () => { harness.dialog.openPath = 'C:/in.json'; harness.repository.importReport = { @@ -87,6 +161,8 @@ describe('LibraryStore', () => { notesImported: 0, notesSkipped: 4, notesDegraded: 0, + attachmentsImported: 0, + attachmentsMissing: 0, }; expect(await harness.store.import()).toBe(false); @@ -102,24 +178,92 @@ describe('LibraryStore', () => { expect(harness.notifier.notice()?.ref.key).toBe('errors.importFailed'); expect(harness.status.status()).toBeNull(); }); + + it('asks nothing of a file that is not protected', async () => { + harness.dialog.openPath = 'C:/in.devbox'; + + expect(await harness.store.import()).toBe(true); + expect(harness.store.passphraseRequest()).toBeNull(); + expect(harness.repository.importedWith).toBeNull(); + }); + + it('asks for the phrase a protected file was sealed with, and hands it over', async () => { + harness.dialog.openPath = 'C:/in.devbox'; + harness.repository.fileIsProtected = true; + harness.repository.expectedPassphrase = 'the shared phrase'; + + const done = harness.store.import(); + await answer(harness, { kind: 'phrase', value: 'the shared phrase' }); + + expect(await done).toBe(true); + expect(harness.repository.importedWith).toBe('the shared phrase'); + }); + + /** ⚠️ A typo must not cost the import: the phrase is asked for again, and the refusal + * is said beside the field rather than in the error banner. */ + it('asks again when the phrase is refused', async () => { + harness.dialog.openPath = 'C:/in.devbox'; + harness.repository.fileIsProtected = true; + harness.repository.expectedPassphrase = 'the shared phrase'; + + const done = harness.store.import(); + await answer(harness, { kind: 'phrase', value: 'a typo' }); + await answer(harness, { kind: 'phrase', value: 'the shared phrase' }); + + expect(await done).toBe(true); + expect(harness.notifier.notice()).toBeNull(); + expect(harness.status.status()?.key).toBe('file.imported'); + }); + + it('says the phrase was refused the second time it asks', async () => { + harness.dialog.openPath = 'C:/in.devbox'; + harness.repository.fileIsProtected = true; + harness.repository.expectedPassphrase = 'the shared phrase'; + + const done = harness.store.import(); + await answer(harness, { kind: 'phrase', value: 'a typo' }); + await asking(harness); + + expect(harness.store.passphraseRequest()).toMatchObject({ purpose: 'unlock', refused: true }); + + harness.store.answerPassphrase({ kind: 'cancelled' }); + await done; + }); + + it('imports nothing and reports no failure when the prompt is given up on', async () => { + harness.dialog.openPath = 'C:/in.devbox'; + harness.repository.fileIsProtected = true; + + const done = harness.store.import(); + await answer(harness, { kind: 'cancelled' }); + + expect(await done).toBe(false); + expect(harness.notifier.notice()).toBeNull(); + expect(harness.status.status()).toBeNull(); + expect(harness.repository.importedWith).toBeNull(); + }); }); describe('export', () => { it('proposes a dated file name', async () => { harness.dialog.savePath = 'C:/out.json'; - await harness.store.export(null, NOW); + await exportEverything(harness); - expect(harness.dialog.saveCalls[0].defaultPath).toBe('devbox-2026-08-27.json'); + expect(harness.dialog.saveCalls[0].defaultPath).toBe('devbox-2026-08-27.devbox'); }); it('passes the active space through, or null for everything', async () => { harness.dialog.savePath = 'C:/out.json'; - await harness.store.export('space-1', NOW); - expect(harness.repository.exportedTo).toEqual({ path: 'C:/out.json', spaceId: 'space-1' }); + await exportEverything(harness, 'space-1'); + expect(harness.repository.exportedTo).toEqual({ + path: 'C:/out.json', + spaceId: 'space-1', + passphrase: null, + }); - await harness.store.export(null, NOW); + await exportEverything(harness); expect(harness.repository.exportedTo?.spaceId).toBeNull(); }); @@ -128,26 +272,41 @@ describe('LibraryStore', () => { await harness.store.export(null, NOW); + expect(harness.store.passphraseRequest()).toBeNull(); expect(harness.repository.exportedTo).toBeNull(); expect(harness.status.status()).toBeNull(); }); it('says how many notes went out, and where', async () => { - harness.dialog.savePath = 'C:/backups/devbox.json'; + harness.dialog.savePath = 'C:/backups/devbox.devbox'; - await harness.store.export(null, NOW); + await exportEverything(harness); expect(harness.status.status()).toEqual({ key: 'file.exported', - params: { notes: '3', path: 'devbox.json' }, + params: { notes: '3', attachments: '0', path: 'devbox.devbox' }, + }); + }); + + /** The attachments now travel, and a report that stayed silent about them would let a + * user believe an export of screenshots carried none. */ + it('counts the attachments that travelled with the notes', async () => { + harness.dialog.savePath = 'C:/backups/devbox.devbox'; + harness.repository.exportReport = { notes: 3, spaces: 1, attachments: 2, protected: false }; + + await exportEverything(harness); + + expect(harness.status.status()).toEqual({ + key: 'file.exportedWithAttachments', + params: { notes: '3', attachments: '2', path: 'devbox.devbox' }, }); }); it('does not pretend to have exported an empty library', async () => { harness.dialog.savePath = 'C:/out.json'; - harness.repository.exportReport = { notes: 0, spaces: 0 }; + harness.repository.exportReport = { notes: 0, spaces: 0, attachments: 0, protected: false }; - await harness.store.export(null, NOW); + await exportEverything(harness); expect(harness.status.status()?.key).toBe('file.emptyLibrary'); }); @@ -155,7 +314,9 @@ describe('LibraryStore', () => { it('restricts the file to the selection when asked', async () => { harness.dialog.savePath = 'C:/out.json'; - await harness.store.exportSelection(['note-1', 'note-2'], NOW); + const done = harness.store.exportSelection(['note-1', 'note-2'], NOW); + await answer(harness, IN_THE_CLEAR); + await done; expect(harness.repository.exportedIds).toEqual(['note-1', 'note-2']); expect(harness.status.status()?.params).toMatchObject({ notes: '2' }); @@ -167,6 +328,68 @@ describe('LibraryStore', () => { expect(harness.notifier.notice()?.ref.key).toBe('file.needsSelection'); expect(harness.dialog.saveCalls).toHaveLength(0); }); + + /** ⚠️ The file is the one thing here most likely to leave the machine: the phrase goes + * through to the command, and the report says the file was sealed with it. */ + it('seals the file with the phrase that was given', async () => { + harness.dialog.savePath = 'C:/backups/devbox.devbox'; + + await exportEverything(harness, null, { kind: 'phrase', value: 'a shared phrase' }); + + expect(harness.repository.exportedTo?.passphrase).toBe('a shared phrase'); + expect(harness.status.status()).toEqual({ + key: 'file.exportedProtected', + params: { notes: '3', attachments: '0', path: 'devbox.devbox' }, + }); + }); + + it('asks after the destination is known, naming the file it is about to write', async () => { + harness.dialog.savePath = 'C:/backups/devbox.devbox'; + + const done = harness.store.export(null, NOW); + await asking(harness); + + expect(harness.store.passphraseRequest()).toEqual({ + purpose: 'protect', + fileName: 'devbox.devbox', + refused: false, + }); + + harness.store.answerPassphrase(IN_THE_CLEAR); + await done; + }); + + it('writes nothing when the prompt is cancelled', async () => { + harness.dialog.savePath = 'C:/out.devbox'; + + const done = harness.store.export(null, NOW); + await answer(harness, { kind: 'cancelled' }); + await done; + + expect(harness.repository.exportedTo).toBeNull(); + expect(harness.status.status()).toBeNull(); + }); + }); + + /** ⚠️ A dialog that vanished and came back on a typo would read as a fault, so the + * prompt stays up while the phrase is being derived from — and refuses a second answer. */ + it('keeps the prompt on screen while the phrase is being used', async () => { + harness.dialog.openPath = 'C:/in.devbox'; + harness.repository.fileIsProtected = true; + harness.repository.expectedPassphrase = 'the shared phrase'; + + const done = harness.store.import(); + await answer(harness, { kind: 'phrase', value: 'a typo' }); + + expect(harness.store.passphraseRequest()).not.toBeNull(); + expect(harness.store.passphraseWorking()).toBe(true); + + harness.store.answerPassphrase({ kind: 'cancelled' }); + expect(harness.store.passphraseWorking()).toBe(true); + + await answer(harness, { kind: 'cancelled' }); + expect(await done).toBe(false); + expect(harness.store.passphraseRequest()).toBeNull(); }); describe('copy as Markdown', () => { diff --git a/src/app/core/state/library.store.ts b/src/app/core/state/library.store.ts index 24dbc2e..60ea735 100644 --- a/src/app/core/state/library.store.ts +++ b/src/app/core/state/library.store.ts @@ -3,24 +3,54 @@ import { ClipboardService } from '@core/services/clipboard/clipboard.service'; import { ErrorNotifier } from '@core/services/errors/error-notifier.service'; import { FileDialogService } from '@core/services/dialogs/file-dialog.service'; import { StatusNotifier } from '@core/services/notifications/status.service'; -import { ImportReport } from '@core/model/note.model'; +import { ExportReport, ImportReport } from '@core/model/note.model'; +import { hasErrorCode } from '@core/ipc/ipc.error'; import { TransferRepository } from '../data/transfer.repository'; import { NotesRevision } from './notes-revision'; /** Dated, so two exports do not overlap. */ function defaultFileName(now: Date): string { - return `devbox-${now.toISOString().slice(0, 10)}.json`; + return `devbox-${now.toISOString().slice(0, 10)}.devbox`; } +/** + * What the prompt is for: sealing a file about to be written, or opening one about to be + * read. The two ask for different things — the first confirms the phrase and may be + * declined, the second cannot be. + */ +export interface PassphraseRequest { + readonly purpose: 'protect' | 'unlock'; + readonly fileName: string; + /** The previous attempt was refused, which belongs beside the field and nowhere else. */ + readonly refused: boolean; +} + +export type PassphraseAnswer = + | { readonly kind: 'phrase'; readonly value: string } + | { readonly kind: 'none' } + | { readonly kind: 'cancelled' }; + /** * Export then re-import at once adds nothing at all, and saying so explicitly stops it - * looking like a breakdown. A note degraded from a newer version arrived all the same, - * and the report is the only place that says so. + * looking like a breakdown. The rest is a ladder of what most deserves saying: an + * attachment the archive named and did not carry leaves a thumbnail that will never + * load, and only this says why. */ function importedKey(report: ImportReport): string { if (report.notesImported === 0) return 'file.importedNothing'; + if (report.attachmentsMissing > 0) return 'file.importedWithoutSomeAttachments'; + if (report.notesDegraded > 0) return 'file.importedFromNewerVersion'; + + return report.attachmentsImported > 0 ? 'file.importedWithAttachments' : 'file.imported'; +} + +/** Which of the two files was written is part of the report, not a detail. */ +function exportedKey(report: ExportReport): string { + if (report.protected) { + return report.attachments > 0 ? 'file.exportedProtectedWithAttachments' : 'file.exportedProtected'; + } - return report.notesDegraded > 0 ? 'file.importedFromNewerVersion' : 'file.imported'; + return report.attachments > 0 ? 'file.exportedWithAttachments' : 'file.exported'; } function fileNameOf(path: string): string { @@ -41,20 +71,35 @@ export class LibraryStore { private readonly revision = inject(NotesRevision); private readonly _isBusy = signal(false); + private readonly _passphraseRequest = signal(null); + private readonly _passphraseWorking = signal(false); readonly isBusy = this._isBusy.asReadonly(); + /** What the prompt drawn over the page is asking for; `null` when it is not asking. */ + readonly passphraseRequest = this._passphraseRequest.asReadonly(); + + /** A phrase has been given and is being derived from. The prompt waits rather than + * leaving the screen, and cannot be answered twice. */ + readonly passphraseWorking = this._passphraseWorking.asReadonly(); + + private pending: ((answer: PassphraseAnswer) => void) | null = null; + /** `true` when notes came in, which is what bumps the canvas revision. */ async import(): Promise { const path = await this.dialog.pickBundle(); if (path === null) return false; return this.run(async () => { - const report = await this.repository.import(path); + const report = await this.readWithPrompt(path); + if (report === null) return false; + const params = { notes: String(report.notesImported), skipped: String(report.notesSkipped), degraded: String(report.notesDegraded), + attachments: String(report.attachmentsImported), + missing: String(report.attachmentsMissing), path: fileNameOf(path), }; @@ -69,13 +114,13 @@ export class LibraryStore { /** A `null` `spaceId` exports the whole corpus. */ async export(spaceId: string | null, now: Date): Promise { - await this.write((path) => this.repository.export(path, spaceId), now); + await this.write((path, passphrase) => this.repository.export(path, spaceId, passphrase), now); } async exportSelection(ids: readonly string[], now: Date): Promise { if (!this.requireSelection(ids)) return; - await this.write((path) => this.repository.exportSelection(path, ids), now); + await this.write((path, passphrase) => this.repository.exportSelection(path, ids, passphrase), now); } /** Sharing stops at the clipboard: nothing is sent anywhere. */ @@ -94,6 +139,76 @@ export class LibraryStore { }, 'errors.shareFailed'); } + /** + * The prompt's only way back in. + * + * ⚠️ A phrase leaves the prompt on screen, working: deriving the key takes about a + * second, and a dialog that vanished and came back on a typo would read as a fault. + * Anything else ends the asking there and then. + */ + answerPassphrase(answer: PassphraseAnswer): void { + const resolve = this.pending; + if (resolve === null) return; + + this.pending = null; + if (answer.kind === 'phrase') { + this._passphraseWorking.set(true); + } else { + this.closePrompt(); + } + + resolve(answer); + } + + private ask(request: PassphraseRequest): Promise { + return new Promise((resolve) => { + this.pending = resolve; + this._passphraseWorking.set(false); + this._passphraseRequest.set(request); + }); + } + + private closePrompt(): void { + this.pending = null; + this._passphraseWorking.set(false); + this._passphraseRequest.set(null); + } + + /** The prompt never outlives the operation it was opened for, failure included. */ + private async readWithPrompt(path: string): Promise { + const isProtected = await this.repository.isProtected(path); + try { + return await this.read(path, isProtected); + } finally { + this.closePrompt(); + } + } + + /** + * ⚠️ A refused phrase asks again rather than failing the import: it is the ordinary + * answer to a typo, and a file nobody can reopen for one is a file lost. `null` when + * the user gave up at the prompt, which is not a failure either. + */ + private async read(path: string, isProtected: boolean): Promise { + let refused = false; + + for (;;) { + let passphrase: string | null = null; + if (isProtected) { + const answer = await this.ask({ purpose: 'unlock', fileName: fileNameOf(path), refused }); + if (answer.kind !== 'phrase') return null; + passphrase = answer.value; + } + + try { + return await this.repository.import(path, passphrase); + } catch (error) { + if (!isProtected || !hasErrorCode(error, 'wrongPassphrase')) throw error; + refused = true; + } + } + } + private requireSelection(ids: readonly string[]): boolean { if (ids.length > 0) return true; @@ -101,28 +216,55 @@ export class LibraryStore { return false; } - private async write(action: (path: string) => Promise<{ notes: number }>, now: Date): Promise { + /** + * ⚠️ The phrase is asked for once the destination is known, and never kept: it goes + * straight to the command, which derives a key of its own for that one file. + */ + private async write( + action: (path: string, passphrase: string | null) => Promise, + now: Date, + ): Promise { const path = await this.dialog.chooseBundleDestination(defaultFileName(now)); if (path === null) return; - await this.run(async () => { - const report = await action(path); + const answer = await this.ask({ purpose: 'protect', fileName: fileNameOf(path), refused: false }); + if (answer.kind === 'cancelled') return; - if (report.notes === 0) { - this.status.notify({ key: 'file.emptyLibrary' }); - return false; + await this.run(async () => { + try { + return await this.writeWith(action, path, answer); + } finally { + this.closePrompt(); } - - // The file name is part of the report: an export whose landing place is unknown - // is no use. - this.status.notify({ - key: 'file.exported', - params: { notes: String(report.notes), path: fileNameOf(path) }, - }); - return true; }, 'errors.exportFailed'); } + private async writeWith( + action: (path: string, passphrase: string | null) => Promise, + path: string, + answer: PassphraseAnswer, + ): Promise { + const report = await action(path, answer.kind === 'phrase' ? answer.value : null); + + if (report.notes === 0) { + this.status.notify({ key: 'file.emptyLibrary' }); + return false; + } + + // The file name is part of the report: an export whose landing place is unknown + // is no use. + this.status.notify({ + key: exportedKey(report), + params: { + notes: String(report.notes), + attachments: String(report.attachments), + path: fileNameOf(path), + }, + }); + + return true; + } + private async run(action: () => Promise, failureKey: string): Promise { this._isBusy.set(true); try { diff --git a/src/app/core/state/vault.store.ts b/src/app/core/state/vault.store.ts new file mode 100644 index 0000000..34265a3 --- /dev/null +++ b/src/app/core/state/vault.store.ts @@ -0,0 +1,89 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import { ErrorNotifier } from '@core/services/errors/error-notifier.service'; +import { hasErrorCode } from '@core/ipc/ipc.error'; +import { VaultRepository } from '../data/vault.repository'; +import { VaultState } from '@core/model/vault.model'; + +/** + * ⚠️ The state lives in Rust, not here: a page reload must not ask again for a library + * this process already has open. That is also what keeps `reopenSession` working in the + * end-to-end suite, where the front end reboots and the process does not. + */ +@Injectable({ providedIn: 'root' }) +export class VaultStore { + private readonly repository = inject(VaultRepository); + private readonly notifier = inject(ErrorNotifier); + + private readonly _state = signal(null); + private readonly _isWorking = signal(false); + private readonly _refused = signal(false); + + /** `null` until the first answer: the shell renders nothing rather than guessing. */ + readonly state = this._state.asReadonly(); + readonly isWorking = this._isWorking.asReadonly(); + + /** The last attempt was refused. Cleared as soon as the field is touched again. */ + readonly refused = this._refused.asReadonly(); + + readonly isUnlocked = computed(() => this._state() === 'unlocked'); + readonly needsCreating = computed(() => this._state() === 'absent'); + + async load(): Promise { + try { + this._state.set(await this.repository.state()); + } catch (error) { + this.notifier.reportFailure('errors.vaultStateFailed', error); + } + } + + /** The first launch of a library that has never been encrypted. */ + async create(passphrase: string): Promise { + return this.attempt(() => this.repository.create(passphrase)); + } + + async unlock(passphrase: string): Promise { + return this.attempt(() => this.repository.unlock(passphrase)); + } + + /** + * A new phrase over the same library. ⚠️ Nothing is re-encrypted — the phrase only ever + * wrapped the key the notes are sealed with — so this cannot leave a library half + * readable, and the session carries on as it was. + */ + async changePassphrase(current: string, next: string): Promise { + return this.attempt( + () => this.repository.changePassphrase(current, next), + 'errors.passphraseChangeFailed', + ); + } + + /** Typing again is what withdraws the refusal — it should not outlive the correction. */ + clearRefusal(): void { + this._refused.set(false); + } + + /** + * ⚠️ A refused passphrase is not reported through the banner: it is the ordinary answer + * to a typo, and it belongs beside the field that caused it. Anything else is a failure + * and goes where failures go. + */ + private async attempt(action: () => Promise, failureKey = 'errors.unlockFailed'): Promise { + this._isWorking.set(true); + this._refused.set(false); + try { + await action(); + this._state.set('unlocked'); + return true; + } catch (error) { + if (hasErrorCode(error, 'wrongPassphrase')) { + this._refused.set(true); + return false; + } + + this.notifier.reportFailure(failureKey, error); + return false; + } finally { + this._isWorking.set(false); + } + } +} diff --git a/src/app/passphrase-prompt/passphrase-prompt.component.html b/src/app/passphrase-prompt/passphrase-prompt.component.html new file mode 100644 index 0000000..ab4f6a7 --- /dev/null +++ b/src/app/passphrase-prompt/passphrase-prompt.component.html @@ -0,0 +1,102 @@ +@let current = request(); + +@if (current) { + +
+

+ {{ (isProtecting() ? 'file.protectTitle' : 'file.unlockTitle') | transloco }} +

+

+ {{ + (isProtecting() ? 'file.protectLead' : 'file.unlockLead') | transloco: { path: current.fileName } + }} +

+ + @if (isProtecting()) { +

{{ 'file.protectWarning' | transloco }}

+ } + + + + @if (isProtecting()) { + + } + +

+ @if (refused()) { + {{ 'errors.wrongPassphrase' | transloco }} + } @else if (tooShort()) { + {{ 'vault.tooShort' | transloco: { length: minimumLength } }} + } @else if (mismatched()) { + {{ 'vault.mismatched' | transloco }} + } +

+ +
+ + @if (isProtecting()) { + + } + +
+
+
+} diff --git a/src/app/passphrase-prompt/passphrase-prompt.component.scss b/src/app/passphrase-prompt/passphrase-prompt.component.scss new file mode 100644 index 0000000..1601338 --- /dev/null +++ b/src/app/passphrase-prompt/passphrase-prompt.component.scss @@ -0,0 +1,101 @@ +@use 'mixins' as *; + +app-dialog { + --dialog-width: 440px; +} + +.prompt { + display: flex; + flex-direction: column; + gap: 12px; +} + +.title { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--text-0); +} + +.lead, +.warning, +.problem { + margin: 0; + font-size: 11.5px; + color: var(--text-2); +} + +// ⚠️ Amber rather than red: an export in the clear is a choice, not a failure. +.warning { + color: var(--amber); +} + +// Keeps its line whether or not it says anything, so the buttons do not move under the +// pointer as the user types. +.problem { + min-height: 1.2em; + color: var(--red); +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.label { + font-size: 11.5px; + color: var(--text-2); +} + +.input { + @include text-field; + + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--bg-1); + color: var(--text-0); + + &[aria-invalid='true'] { + border-color: var(--red); + } +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 8px; + border-top: 1px solid var(--line-soft); +} + +.action { + @include unstyled-control; + @include surface($radius: 7px); + + padding: 7px 12px; + font-size: 12px; + color: var(--text-1); + cursor: pointer; + + &:hover:not(:disabled) { + @include accent-state(var(--text-0), var(--amber-dim)); + } + + &:disabled { + opacity: 0.5; + cursor: default; + } +} + +.action.primary { + background: var(--amber); + border-color: var(--amber); + color: var(--amber-ink); +} + +.prompt :focus-visible { + outline: 2px solid var(--amber); + outline-offset: 2px; +} diff --git a/src/app/passphrase-prompt/passphrase-prompt.component.spec.ts b/src/app/passphrase-prompt/passphrase-prompt.component.spec.ts new file mode 100644 index 0000000..ec0a9a6 --- /dev/null +++ b/src/app/passphrase-prompt/passphrase-prompt.component.spec.ts @@ -0,0 +1,204 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { LibraryStore, PassphraseAnswer } from '@core/state/library.store'; +import { FakeFileDialog } from '@testing/fake-file-dialog'; +import { FakeTransferRepository } from '@testing/fake-transfer-repository'; +import { provideAppTesting } from '@testing/testing.providers'; +import { PassphrasePromptComponent } from './passphrase-prompt.component'; + +describe('PassphrasePromptComponent', () => { + let fixture: ComponentFixture; + let library: LibraryStore; + let repository: FakeTransferRepository; + let dialog: FakeFileDialog; + + beforeEach(() => { + TestBed.resetTestingModule(); + repository = new FakeTransferRepository(); + dialog = new FakeFileDialog(); + TestBed.configureTestingModule({ + imports: [PassphrasePromptComponent], + providers: [provideAppTesting({ transferRepository: repository, fileDialog: dialog })], + }); + library = TestBed.inject(LibraryStore); + fixture = TestBed.createComponent(PassphrasePromptComponent); + fixture.autoDetectChanges(); + }); + + function element(hook: string): HTMLElement | null { + return fixture.debugElement.query(By.css(`[data-testid="${hook}"]`))?.nativeElement ?? null; + } + + function submitButton(): HTMLButtonElement { + return element('passphrase-prompt-submit') as HTMLButtonElement; + } + + async function type(hook: string, value: string): Promise { + const input = element(hook) as HTMLInputElement | null; + if (input === null) throw new Error(`no field "${hook}"`); + input.value = value; + input.dispatchEvent(new Event('input')); + await fixture.whenStable(); + } + + /** + * The prompt is what an export or an import waits on, so a scenario starts one. + * ⚠️ Wrapped rather than returned: an `async` helper handing back a promise would + * adopt it, and wait for the very operation the prompt is blocking. + */ + async function whileExporting(): Promise<{ done: Promise }> { + dialog.savePath = 'C:/out/library.devbox'; + const done = library.export(null, new Date('2026-08-27T09:00:00Z')); + await vi.waitFor(() => expect(element('passphrase-prompt')).not.toBeNull()); + return { done }; + } + + async function whileImporting(): Promise<{ done: Promise }> { + dialog.openPath = 'C:/in/library.devbox'; + repository.fileIsProtected = true; + repository.expectedPassphrase = 'a shared phrase'; + const done = library.import(); + await vi.waitFor(() => expect(element('passphrase-prompt')).not.toBeNull()); + return { done }; + } + + it('is not on screen while nothing is being asked for', () => { + expect(element('passphrase-prompt')).toBeNull(); + }); + + describe('protecting an export', () => { + it('names the file it is about to write', async () => { + const { done } = await whileExporting(); + + expect(element('passphrase-prompt')?.textContent).toContain('library.devbox'); + + library.answerPassphrase({ kind: 'cancelled' }); + await done; + }); + + /** Sold as protection, a four-character phrase is not protection. */ + it('refuses a phrase too short to be one', async () => { + const { done } = await whileExporting(); + + await type('passphrase-prompt-field', 'short'); + await type('passphrase-prompt-confirmation', 'short'); + + expect(submitButton().disabled).toBe(true); + expect(element('passphrase-prompt-problem')?.textContent?.trim()).not.toBe(''); + + library.answerPassphrase({ kind: 'cancelled' }); + await done; + }); + + it('refuses two entries that differ', async () => { + const { done } = await whileExporting(); + + await type('passphrase-prompt-field', 'a shared phrase'); + await type('passphrase-prompt-confirmation', 'a shared phrasr'); + + expect(submitButton().disabled).toBe(true); + + library.answerPassphrase({ kind: 'cancelled' }); + await done; + }); + + it('hands the phrase over once and clears the fields behind it', async () => { + const { done } = await whileExporting(); + + await type('passphrase-prompt-field', 'a shared phrase'); + await type('passphrase-prompt-confirmation', 'a shared phrase'); + submitButton().click(); + await done; + await fixture.whenStable(); + + expect(repository.exportedTo?.passphrase).toBe('a shared phrase'); + expect(element('passphrase-prompt')).toBeNull(); + }); + + /** ⚠️ The escape has to stay, and has to be a deliberate second button: an export in + * the clear is the portable format, and the warning beside it is the point. */ + it('lets the file be written in the clear', async () => { + const { done } = await whileExporting(); + + (element('passphrase-prompt-plain') as HTMLButtonElement).click(); + await done; + + expect(repository.exportedTo?.passphrase).toBeNull(); + }); + }); + + describe('opening a protected import', () => { + it('asks for one field and no confirmation', async () => { + const { done } = await whileImporting(); + + expect(element('passphrase-prompt-confirmation')).toBeNull(); + expect(element('passphrase-prompt-plain')).toBeNull(); + + library.answerPassphrase({ kind: 'cancelled' }); + await done; + }); + + it('takes any phrase the file might have been sealed with', async () => { + const { done } = await whileImporting(); + + await type('passphrase-prompt-field', 'a shared phrase'); + submitButton().click(); + + expect(await done).toBe(true); + expect(repository.importedWith).toBe('a shared phrase'); + }); + + it('says the phrase was refused, and empties the field for the next one', async () => { + const { done } = await whileImporting(); + + await type('passphrase-prompt-field', 'a typo'); + submitButton().click(); + await vi.waitFor(() => expect(element('passphrase-prompt-problem')?.textContent?.trim()).not.toBe('')); + + expect((element('passphrase-prompt-field') as HTMLInputElement).value).toBe(''); + + library.answerPassphrase({ kind: 'cancelled' }); + await done; + }); + + it('withdraws the refusal as soon as the field is touched again', async () => { + const { done } = await whileImporting(); + + await type('passphrase-prompt-field', 'a typo'); + submitButton().click(); + await vi.waitFor(() => expect(element('passphrase-prompt-problem')?.textContent?.trim()).not.toBe('')); + + await type('passphrase-prompt-field', 'another try'); + + expect(element('passphrase-prompt-problem')?.textContent?.trim()).toBe(''); + + library.answerPassphrase({ kind: 'cancelled' }); + await done; + }); + + it('gives up rather than importing nothing silently', async () => { + const { done } = await whileImporting(); + + (element('passphrase-prompt-cancel') as HTMLButtonElement).click(); + + expect(await done).toBe(false); + expect(repository.importedWith).toBeNull(); + }); + }); + + it('answers the store exactly once per request', async () => { + const { done } = await whileExporting(); + const answers: PassphraseAnswer[] = []; + const original = library.answerPassphrase.bind(library); + vi.spyOn(library, 'answerPassphrase').mockImplementation((answer) => { + answers.push(answer); + original(answer); + }); + + (element('passphrase-prompt-cancel') as HTMLButtonElement).click(); + await done; + + expect(answers).toEqual([{ kind: 'cancelled' }]); + }); +}); diff --git a/src/app/passphrase-prompt/passphrase-prompt.component.ts b/src/app/passphrase-prompt/passphrase-prompt.component.ts new file mode 100644 index 0000000..5a24d02 --- /dev/null +++ b/src/app/passphrase-prompt/passphrase-prompt.component.ts @@ -0,0 +1,109 @@ +import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal } from '@angular/core'; +import { TranslocoPipe } from '@jsverse/transloco'; +import { DialogComponent } from '@shared/layout/dialog/dialog.component'; +import { LibraryStore, PassphraseRequest } from '@core/state/library.store'; +import { MINIMUM_PASSPHRASE_LENGTH } from '@core/model/vault.model'; + +/** + * The phrase a transfer needs, asked for at the moment it is needed. + * + * ⚠️ It sits at the root, like the gate: an export starts from the titlebar and a + * selection export from the canvas header, and a prompt owned by either would be torn + * down by the menu that closes under it. + */ +@Component({ + selector: 'app-passphrase-prompt', + imports: [DialogComponent, TranslocoPipe], + templateUrl: './passphrase-prompt.component.html', + styleUrl: './passphrase-prompt.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PassphrasePromptComponent { + private readonly library = inject(LibraryStore); + + protected readonly request = this.library.passphraseRequest; + protected readonly working = this.library.passphraseWorking; + + protected readonly isProtecting = computed(() => this.request()?.purpose === 'protect'); + + /** A new request — a retry included — starts from an empty field. */ + protected readonly passphrase = linkedSignal({ + source: this.request, + computation: () => '', + }); + + protected readonly confirmation = linkedSignal({ + source: this.request, + computation: () => '', + }); + + /** Typing again is what withdraws the refusal; it must not outlive the correction. */ + protected readonly refused = linkedSignal({ + source: this.request, + computation: (request) => request?.refused ?? false, + }); + + protected readonly tooShort = computed( + () => + this.isProtecting() && + this.passphrase().length > 0 && + this.passphrase().length < MINIMUM_PASSPHRASE_LENGTH, + ); + + protected readonly mismatched = computed( + () => this.isProtecting() && this.confirmation().length > 0 && this.confirmation() !== this.passphrase(), + ); + + protected readonly canSubmit = computed(() => { + if (this.working()) return false; + if (!this.isProtecting()) return this.passphrase().length > 0; + + return this.passphrase().length >= MINIMUM_PASSPHRASE_LENGTH && this.confirmation() === this.passphrase(); + }); + + protected readonly minimumLength = MINIMUM_PASSPHRASE_LENGTH; + + protected onPassphrase(value: string): void { + this.passphrase.set(value); + this.refused.set(false); + } + + protected onConfirmation(value: string): void { + this.confirmation.set(value); + } + + /** + * ⚠️ The fields are cleared before the answer leaves: a phrase left in a DOM node + * outlives the dialog, and the store never holds one either. + */ + protected submit(event: Event): void { + event.preventDefault(); + if (!this.canSubmit()) return; + + const typed = this.passphrase(); + this.clear(); + this.library.answerPassphrase({ kind: 'phrase', value: typed }); + } + + /** ⚠️ The plain export, taken deliberately: the warning above the button is the point. */ + protected exportInTheClear(): void { + if (this.working()) return; + + this.clear(); + this.library.answerPassphrase({ kind: 'none' }); + } + + /** ⚠️ Refused while a phrase is being derived from: the operation is under way, and + * the dialog is showing that rather than waiting for an answer. */ + protected cancel(): void { + if (this.working()) return; + + this.clear(); + this.library.answerPassphrase({ kind: 'cancelled' }); + } + + private clear(): void { + this.passphrase.set(''); + this.confirmation.set(''); + } +} diff --git a/src/app/shared/layout/dialog/dialog.model.ts b/src/app/shared/layout/dialog/dialog.model.ts index 34b84e8..025d4ca 100644 --- a/src/app/shared/layout/dialog/dialog.model.ts +++ b/src/app/shared/layout/dialog/dialog.model.ts @@ -4,7 +4,7 @@ * `layout/` sit at 80 and must stay above every modal — they are triggered from inside * one — hence the base well below it. */ -const LAYERS = ['editor', 'app', 'settings', 'update', 'palette', 'fields', 'zoom'] as const; +const LAYERS = ['editor', 'app', 'settings', 'update', 'palette', 'fields', 'zoom', 'passphrase'] as const; export type DialogLayer = (typeof LAYERS)[number]; diff --git a/src/app/titlebar/file-menu/file-menu.component.spec.ts b/src/app/titlebar/file-menu/file-menu.component.spec.ts index f26c144..43d1294 100644 --- a/src/app/titlebar/file-menu/file-menu.component.spec.ts +++ b/src/app/titlebar/file-menu/file-menu.component.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { StatusNotifier } from '@core/services/notifications/status.service'; +import { LibraryStore } from '@core/state/library.store'; import { NoteSelectionStore } from '@core/state/note-selection.store'; import { NotesQueryStore } from '@core/state/notes-query.store'; import { SpacesStore } from '@core/state/spaces.store'; @@ -41,6 +42,13 @@ describe('FileMenuComponent', () => { return found; } + /** ⚠️ The prompt stands between the click and the file; these scenarios decline it. */ + async function declineProtection(): Promise { + const library = TestBed.inject(LibraryStore); + await vi.waitFor(() => expect(library.passphraseRequest()).not.toBeNull()); + library.answerPassphrase({ kind: 'none' }); + } + async function openMenu(): Promise { trigger().click(); await fixture.whenStable(); @@ -125,9 +133,14 @@ describe('FileMenuComponent', () => { await openMenu(); optionLabelled('Exporter tout').click(); + await declineProtection(); await vi.waitFor(() => - expect(transferRepository.exportedTo).toEqual({ path: 'C:\\out\\all.json', spaceId: null }), + expect(transferRepository.exportedTo).toEqual({ + path: 'C:\\out\\all.json', + spaceId: null, + passphrase: null, + }), ); }); @@ -143,6 +156,7 @@ describe('FileMenuComponent', () => { spaces.selectSpace('work'); await fixture.whenStable(); optionLabelled("Exporter l'espace").click(); + await declineProtection(); await vi.waitFor(() => expect(transferRepository.exportedTo?.spaceId).toBe('work')); }); @@ -165,6 +179,7 @@ describe('FileMenuComponent', () => { await openMenu(); optionLabelled('Exporter la sélection').click(); + await declineProtection(); await vi.waitFor(() => expect(transferRepository.exportedIds).toEqual(['note-42'])); }); diff --git a/src/app/titlebar/file-menu/settings-dialog/change-passphrase-dialog/change-passphrase-dialog.component.html b/src/app/titlebar/file-menu/settings-dialog/change-passphrase-dialog/change-passphrase-dialog.component.html new file mode 100644 index 0000000..e8b0ae0 --- /dev/null +++ b/src/app/titlebar/file-menu/settings-dialog/change-passphrase-dialog/change-passphrase-dialog.component.html @@ -0,0 +1,87 @@ + +
+

+ {{ 'settings.security.dialogTitle' | transloco }} +

+

{{ 'settings.security.dialogLead' | transloco }}

+ + + + + + + +

+ @if (vault.refused()) { + {{ 'errors.wrongPassphrase' | transloco }} + } @else if (tooShort()) { + {{ 'vault.tooShort' | transloco: { length: minimumLength } }} + } @else if (mismatched()) { + {{ 'vault.mismatched' | transloco }} + } +

+ +
+ + +
+
+
diff --git a/src/app/titlebar/file-menu/settings-dialog/change-passphrase-dialog/change-passphrase-dialog.component.scss b/src/app/titlebar/file-menu/settings-dialog/change-passphrase-dialog/change-passphrase-dialog.component.scss new file mode 100644 index 0000000..54fff08 --- /dev/null +++ b/src/app/titlebar/file-menu/settings-dialog/change-passphrase-dialog/change-passphrase-dialog.component.scss @@ -0,0 +1,95 @@ +@use 'mixins' as *; + +app-dialog { + --dialog-width: 420px; +} + +.change { + display: flex; + flex-direction: column; + gap: 12px; +} + +.title { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--text-0); +} + +.lead, +.problem { + margin: 0; + font-size: 11.5px; + color: var(--text-2); +} + +// Keeps its line whether or not it says anything, so the buttons do not move under the +// pointer as the user types. +.problem { + min-height: 1.2em; + color: var(--red); +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.label { + font-size: 11.5px; + color: var(--text-2); +} + +.input { + @include text-field; + + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--bg-1); + color: var(--text-0); + + &[aria-invalid='true'] { + border-color: var(--red); + } +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 8px; + border-top: 1px solid var(--line-soft); +} + +.action { + @include unstyled-control; + @include surface($radius: 7px); + + padding: 7px 12px; + font-size: 12px; + color: var(--text-1); + cursor: pointer; + + &:hover:not(:disabled) { + @include accent-state(var(--text-0), var(--amber-dim)); + } + + &:disabled { + opacity: 0.5; + cursor: default; + } +} + +.action.primary { + background: var(--amber); + border-color: var(--amber); + color: var(--amber-ink); +} + +.change :focus-visible { + outline: 2px solid var(--amber); + outline-offset: 2px; +} diff --git a/src/app/titlebar/file-menu/settings-dialog/change-passphrase-dialog/change-passphrase-dialog.component.spec.ts b/src/app/titlebar/file-menu/settings-dialog/change-passphrase-dialog/change-passphrase-dialog.component.spec.ts new file mode 100644 index 0000000..13a402e --- /dev/null +++ b/src/app/titlebar/file-menu/settings-dialog/change-passphrase-dialog/change-passphrase-dialog.component.spec.ts @@ -0,0 +1,131 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { IpcError } from '@core/ipc/ipc.error'; +import { VaultRepository } from '@core/data/vault.repository'; +import { StatusNotifier } from '@core/services/notifications/status.service'; +import { FakeVaultRepository } from '@testing/fake-vault-repository'; +import { provideAppTesting } from '@testing/testing.providers'; +import { ChangePassphraseDialogComponent } from './change-passphrase-dialog.component'; + +const REFUSED = new IpcError('change_passphrase', { + code: 'wrongPassphrase', + params: {}, + detail: 'Wrong passphrase', +}); + +describe('ChangePassphraseDialogComponent', () => { + let fixture: ComponentFixture; + let repository: FakeVaultRepository; + let status: StatusNotifier; + let closed: number; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ChangePassphraseDialogComponent], + providers: [provideAppTesting()], + }); + repository = TestBed.inject(VaultRepository) as unknown as FakeVaultRepository; + status = TestBed.inject(StatusNotifier); + fixture = TestBed.createComponent(ChangePassphraseDialogComponent); + closed = 0; + fixture.componentInstance.closed.subscribe(() => (closed += 1)); + fixture.autoDetectChanges(); + }); + + function element(hook: string): HTMLElement { + return fixture.debugElement.query(By.css(`[data-testid="${hook}"]`)).nativeElement; + } + + function submitButton(): HTMLButtonElement { + return element('change-passphrase-submit') as HTMLButtonElement; + } + + async function type(hook: string, value: string): Promise { + const input = element(hook) as HTMLInputElement; + input.value = value; + input.dispatchEvent(new Event('input')); + await fixture.whenStable(); + } + + async function fill(current: string, next: string, confirmation = next): Promise { + await type('change-passphrase-current', current); + await type('change-passphrase-next', next); + await type('change-passphrase-confirmation', confirmation); + } + + it('will not submit without the current phrase', async () => { + await fill('', 'a longer phrase'); + + expect(submitButton().disabled).toBe(true); + }); + + /** The same floor the gate holds, said before the round trip rather than after it. */ + it('refuses a new phrase too short to be worth deriving', async () => { + await fill('the old one', 'short'); + + expect(submitButton().disabled).toBe(true); + expect(element('change-passphrase-problem').textContent?.trim()).not.toBe(''); + }); + + it('refuses two entries that differ', async () => { + await fill('the old one', 'a longer phrase', 'a longer phrasr'); + + expect(submitButton().disabled).toBe(true); + }); + + it('hands both phrases over, says so, and closes', async () => { + await fill('the old one', 'a longer phrase'); + submitButton().click(); + await fixture.whenStable(); + + expect(repository.changes).toEqual([{ current: 'the old one', next: 'a longer phrase' }]); + expect(status.status()?.key).toBe('settings.security.changed'); + expect(closed).toBe(1); + }); + + /** ⚠️ A phrase left in a DOM node outlives the dialog that carried it. */ + it('clears the three fields whatever the answer', async () => { + repository.failNext = REFUSED; + await fill('not the old one', 'a longer phrase'); + submitButton().click(); + await fixture.whenStable(); + + expect((element('change-passphrase-current') as HTMLInputElement).value).toBe(''); + expect((element('change-passphrase-next') as HTMLInputElement).value).toBe(''); + expect((element('change-passphrase-confirmation') as HTMLInputElement).value).toBe(''); + }); + + /** A refused current phrase belongs beside the field, and the dialog stays. */ + it('says the current phrase was refused rather than closing on it', async () => { + repository.failNext = REFUSED; + await fill('not the old one', 'a longer phrase'); + submitButton().click(); + await fixture.whenStable(); + + expect(element('change-passphrase-problem').textContent?.trim()).not.toBe(''); + expect(status.status()).toBeNull(); + expect(closed).toBe(0); + }); + + it('withdraws the refusal as soon as the field is touched again', async () => { + repository.failNext = REFUSED; + await fill('not the old one', 'a longer phrase'); + submitButton().click(); + await fixture.whenStable(); + + await type('change-passphrase-current', 'another try'); + + expect(element('change-passphrase-problem').textContent?.trim()).toBe(''); + }); + + it('closes without changing anything when cancelled', async () => { + await fill('the old one', 'a longer phrase'); + (element('change-passphrase-cancel') as HTMLButtonElement).click(); + await fixture.whenStable(); + + expect(repository.changes).toEqual([]); + expect(closed).toBe(1); + }); +}); diff --git a/src/app/titlebar/file-menu/settings-dialog/change-passphrase-dialog/change-passphrase-dialog.component.ts b/src/app/titlebar/file-menu/settings-dialog/change-passphrase-dialog/change-passphrase-dialog.component.ts new file mode 100644 index 0000000..b140701 --- /dev/null +++ b/src/app/titlebar/file-menu/settings-dialog/change-passphrase-dialog/change-passphrase-dialog.component.ts @@ -0,0 +1,83 @@ +import { ChangeDetectionStrategy, Component, computed, inject, output, signal } from '@angular/core'; +import { TranslocoPipe } from '@jsverse/transloco'; +import { DialogComponent } from '@shared/layout/dialog/dialog.component'; +import { MINIMUM_PASSPHRASE_LENGTH } from '@core/model/vault.model'; +import { StatusNotifier } from '@core/services/notifications/status.service'; +import { VaultStore } from '@core/state/vault.store'; + +/** + * ⚠️ Changing the phrase re-wraps the library's key; it re-encrypts nothing. So there is + * no progress to report beyond the two derivations, and nothing to roll back. + */ +@Component({ + selector: 'app-change-passphrase-dialog', + imports: [DialogComponent, TranslocoPipe], + templateUrl: './change-passphrase-dialog.component.html', + styleUrl: './change-passphrase-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChangePassphraseDialogComponent { + protected readonly vault = inject(VaultStore); + private readonly status = inject(StatusNotifier); + + readonly closed = output(); + + protected readonly current = signal(''); + protected readonly next = signal(''); + protected readonly confirmation = signal(''); + + protected readonly tooShort = computed( + () => this.next().length > 0 && this.next().length < MINIMUM_PASSPHRASE_LENGTH, + ); + + protected readonly mismatched = computed( + () => this.confirmation().length > 0 && this.confirmation() !== this.next(), + ); + + protected readonly canSubmit = computed(() => { + if (this.vault.isWorking() || this.current().length === 0) return false; + + return this.next().length >= MINIMUM_PASSPHRASE_LENGTH && this.confirmation() === this.next(); + }); + + protected readonly minimumLength = MINIMUM_PASSPHRASE_LENGTH; + + protected onCurrent(value: string): void { + this.current.set(value); + this.vault.clearRefusal(); + } + + protected onNext(value: string): void { + this.next.set(value); + } + + protected onConfirmation(value: string): void { + this.confirmation.set(value); + } + + protected close(): void { + if (this.vault.isWorking()) return; + + this.closed.emit(); + } + + /** + * ⚠️ The three fields are cleared before the round trip, success or not: a phrase left + * in a DOM node outlives the dialog. + */ + protected async submit(event: Event): Promise { + event.preventDefault(); + if (!this.canSubmit()) return; + + const from = this.current(); + const to = this.next(); + this.current.set(''); + this.next.set(''); + this.confirmation.set(''); + + if (await this.vault.changePassphrase(from, to)) { + this.status.notify({ key: 'settings.security.changed' }); + this.closed.emit(); + } + } +} diff --git a/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.html b/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.html index 8d5d4de..96c0c4c 100644 --- a/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.html +++ b/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.html @@ -135,6 +135,24 @@

{{ 'settings.quickPaste.title' | transloco }} +
+

{{ 'settings.security.title' | transloco }}

+ +
+ {{ 'settings.security.passphrase' | transloco }} + +
+ +

{{ 'settings.security.note' | transloco }}

+
+

{{ 'settings.notifications.title' | transloco }}

@@ -177,3 +195,7 @@

{{ 'settings.notifications.title' | transloco }} }

+ +@if (isChangingPassphrase()) { + +} diff --git a/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.spec.ts b/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.spec.ts index 91330ad..63731ff 100644 --- a/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.spec.ts +++ b/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.spec.ts @@ -38,12 +38,27 @@ describe('SettingsPageComponent', () => { await fixture.whenStable(); }); - it('shows the four groups the panel is made of', () => { + it('shows the five groups the panel is made of', () => { const titles = [...fixture.nativeElement.querySelectorAll('.setting-group-title')].map( (title: HTMLElement) => title.textContent?.trim(), ); - expect(titles).toEqual(['Apparence', 'Comportement', 'Collage rapide', 'Notifications']); + expect(titles).toEqual(['Apparence', 'Comportement', 'Collage rapide', 'Sécurité', 'Notifications']); + }); + + /** The dialog is opened from here and nowhere else; what it does is its own spec. */ + it('opens the passphrase dialog from the security group, and only on demand', async () => { + const opener = (): HTMLButtonElement => + fixture.nativeElement.querySelector('[data-testid="setting-change-passphrase"]'); + const dialog = (): HTMLElement | null => + fixture.nativeElement.querySelector('[data-testid="change-passphrase"]'); + + expect(dialog()).toBeNull(); + + opener().click(); + await fixture.whenStable(); + + expect(dialog()).not.toBeNull(); }); it('offers the language alongside the titlebar buttons, system included', () => { diff --git a/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.ts b/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.ts index a68aef0..8d1263f 100644 --- a/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.ts +++ b/src/app/titlebar/file-menu/settings-dialog/settings-page/settings-page.component.ts @@ -1,5 +1,6 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { TranslocoPipe } from '@jsverse/transloco'; +import { ChangePassphraseDialogComponent } from '../change-passphrase-dialog/change-passphrase-dialog.component'; import { DENSITIES, Density, @@ -22,7 +23,7 @@ function checkedValue(event: Event): boolean { @Component({ selector: 'app-settings-page', - imports: [TranslocoPipe], + imports: [TranslocoPipe, ChangePassphraseDialogComponent], templateUrl: './settings-page.component.html', styleUrl: './settings-page.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -35,6 +36,16 @@ export class SettingsPageComponent { protected readonly densities = DENSITIES; protected readonly defaultShortcut = DEFAULT_SHORTCUTS.palette; + protected readonly isChangingPassphrase = signal(false); + + protected openPassphraseChange(): void { + this.isChangingPassphrase.set(true); + } + + protected closePassphraseChange(): void { + this.isChangingPassphrase.set(false); + } + protected onUpdateNotifications(event: Event): void { this.settings.setUpdateNotifications(checkedValue(event)); } diff --git a/src/app/titlebar/titlebar.component.html b/src/app/titlebar/titlebar.component.html index eb1d33d..4cf21fc 100644 --- a/src/app/titlebar/titlebar.component.html +++ b/src/app/titlebar/titlebar.component.html @@ -3,7 +3,14 @@ - + + @if (vault.isUnlocked()) { + + } diff --git a/src/app/titlebar/titlebar.component.spec.ts b/src/app/titlebar/titlebar.component.spec.ts index 973015f..df99d0d 100644 --- a/src/app/titlebar/titlebar.component.spec.ts +++ b/src/app/titlebar/titlebar.component.spec.ts @@ -2,7 +2,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { LocaleService } from '@core/services/i18n/locale.service'; -import { provideTranslocoTesting } from '@testing/provide-transloco-testing'; +import { VaultStore } from '@core/state/vault.store'; +import { provideAppTesting } from '@testing/testing.providers'; import { TitlebarComponent } from './titlebar.component'; describe('TitlebarComponent', () => { @@ -18,11 +19,21 @@ describe('TitlebarComponent', () => { Object.defineProperty(navigator, 'language', { value: tag, configurable: true }); } + /** The titlebar is on screen before the library is, so a spec has to say which. */ + async function unlock(): Promise { + await TestBed.inject(VaultStore).load(); + await fixture.whenStable(); + } + + function menus(): HTMLElement { + return fixture.nativeElement.querySelector('.titlebar-menus'); + } + beforeEach(() => { TestBed.resetTestingModule(); localStorage.clear(); stubSystemLanguage('fr-FR'); - TestBed.configureTestingModule({ imports: [TitlebarComponent], providers: [provideTranslocoTesting()] }); + TestBed.configureTestingModule({ imports: [TitlebarComponent], providers: [provideAppTesting()] }); fixture = TestBed.createComponent(TitlebarComponent); fixture.autoDetectChanges(); }); @@ -41,13 +52,24 @@ describe('TitlebarComponent', () => { expect(fixture.nativeElement.querySelector('.dots').getAttribute('aria-hidden')).toBe('true'); }); - it('groups the menus on the left, ahead of the title', () => { - const menus = fixture.nativeElement.querySelector('.titlebar-menus'); + it('groups the menus on the left, ahead of the title', async () => { + await unlock(); const title = fixture.nativeElement.querySelector('.titlebar-title'); - expect(menus.querySelector('app-file-menu')).not.toBeNull(); - expect(menus.querySelector('app-about-menu')).not.toBeNull(); - expect(menus.compareDocumentPosition(title)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + expect(menus().querySelector('app-file-menu')).not.toBeNull(); + expect(menus().querySelector('app-about-menu')).not.toBeNull(); + expect(menus().compareDocumentPosition(title)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + }); + + /** ⚠️ Every File entry acts on the library, and the component itself injects + * `SpacesStore`, which would query a database nobody has opened yet. */ + it('keeps the File menu out of the titlebar until the library is open', async () => { + expect(menus().querySelector('app-file-menu')).toBeNull(); + expect(menus().querySelector('app-about-menu')).not.toBeNull(); + + await unlock(); + + expect(menus().querySelector('app-file-menu')).not.toBeNull(); }); it('renders a locale option per available locale, marking French active by default', () => { diff --git a/src/app/titlebar/titlebar.component.ts b/src/app/titlebar/titlebar.component.ts index ea504a4..678efe5 100644 --- a/src/app/titlebar/titlebar.component.ts +++ b/src/app/titlebar/titlebar.component.ts @@ -3,6 +3,7 @@ import { TranslocoPipe } from '@jsverse/transloco'; import { APP_INFO } from '@core/services/app-info/app-info.service'; import { APP_LOCALES } from '@core/services/i18n/locale.model'; import { LocaleService } from '@core/services/i18n/locale.service'; +import { VaultStore } from '@core/state/vault.store'; import { AboutMenuComponent } from './about-menu/about-menu.component'; import { FileMenuComponent } from './file-menu/file-menu.component'; @@ -18,4 +19,5 @@ export class TitlebarComponent { protected readonly locales = APP_LOCALES; protected readonly localeService = inject(LocaleService); + protected readonly vault = inject(VaultStore); } diff --git a/src/app/vault-gate/vault-gate.component.html b/src/app/vault-gate/vault-gate.component.html new file mode 100644 index 0000000..5b54834 --- /dev/null +++ b/src/app/vault-gate/vault-gate.component.html @@ -0,0 +1,65 @@ +
+
+

+ {{ (isCreating() ? 'vault.createTitle' : 'vault.unlockTitle') | transloco }} +

+

+ {{ (isCreating() ? 'vault.createLead' : 'vault.unlockLead') | transloco }} +

+ + + @if (isCreating()) { +

{{ 'vault.noRecovery' | transloco }}

+ } + + + + @if (isCreating()) { + + } + + +

+ @if (vault.refused()) { + {{ 'errors.wrongPassphrase' | transloco }} + } @else if (tooShort()) { + {{ 'vault.tooShort' | transloco: { length: minimumLength } }} + } @else if (mismatched()) { + {{ 'vault.mismatched' | transloco }} + } +

+ + +
+
diff --git a/src/app/vault-gate/vault-gate.component.scss b/src/app/vault-gate/vault-gate.component.scss new file mode 100644 index 0000000..68fb739 --- /dev/null +++ b/src/app/vault-gate/vault-gate.component.scss @@ -0,0 +1,85 @@ +@use 'mixins' as *; + +.gate { + display: grid; + place-items: center; + min-height: 100%; + padding: var(--space-canvas); +} + +.panel { + @include surface; + + display: flex; + flex-direction: column; + gap: 14px; + width: min(420px, 100%); + padding: 28px; +} + +.title { + margin: 0; + font-size: 1.15rem; +} + +.lead, +.problem, +.warning { + margin: 0; + color: var(--text-2); + font-size: 0.85rem; +} + +// ⚠️ Amber rather than red: it is a condition of the choice, not a failure of it. +.warning { + color: var(--amber); +} + +// Reserves its line whether or not it says anything, so the button never moves under +// the pointer as the user types. +.problem { + min-height: 1.2em; + color: var(--red); +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.label { + color: var(--text-2); + font-size: 0.8rem; +} + +.input { + @include text-field; + + padding: 9px 11px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--bg-1); + color: var(--text-0); + + &[aria-invalid='true'] { + border-color: var(--red); + } +} + +.submit { + @include unstyled-control; + + padding: 10px; + border-radius: 8px; + background: var(--amber); + color: var(--amber-ink); + font-weight: 600; + text-align: center; + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: default; + } +} diff --git a/src/app/vault-gate/vault-gate.component.spec.ts b/src/app/vault-gate/vault-gate.component.spec.ts new file mode 100644 index 0000000..d6fc615 --- /dev/null +++ b/src/app/vault-gate/vault-gate.component.spec.ts @@ -0,0 +1,171 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { IpcError } from '@core/ipc/ipc.error'; +import { VaultRepository } from '@core/data/vault.repository'; +import { VaultStore } from '@core/state/vault.store'; +import { FakeVaultRepository } from '@testing/fake-vault-repository'; +import { provideAppTesting } from '@testing/testing.providers'; +import { VaultGateComponent } from './vault-gate.component'; + +describe('VaultGateComponent', () => { + let fixture: ComponentFixture; + let repository: FakeVaultRepository; + let store: VaultStore; + + beforeEach(async () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [VaultGateComponent], + providers: [provideAppTesting()], + }); + repository = TestBed.inject(VaultRepository) as unknown as FakeVaultRepository; + store = TestBed.inject(VaultStore); + fixture = TestBed.createComponent(VaultGateComponent); + fixture.autoDetectChanges(); + }); + + function field(hook: string): HTMLInputElement | null { + return fixture.debugElement.query(By.css(`[data-testid="${hook}"]`))?.nativeElement ?? null; + } + + function submitButton(): HTMLButtonElement { + return fixture.debugElement.query(By.css('[data-testid="vault-submit"]')).nativeElement; + } + + function problem(): string { + return ( + fixture.debugElement.query(By.css('[data-testid="vault-problem"]')).nativeElement.textContent?.trim() ?? + '' + ); + } + + async function type(hook: string, value: string): Promise { + const input = field(hook); + if (input === null) throw new Error(`no field "${hook}"`); + input.value = value; + input.dispatchEvent(new Event('input')); + await fixture.whenStable(); + } + + async function open(state: 'absent' | 'locked'): Promise { + repository.answer = state; + await store.load(); + await fixture.whenStable(); + } + + describe('unlocking an existing library', () => { + beforeEach(async () => { + await open('locked'); + }); + + it('asks for one passphrase and no confirmation', () => { + expect(field('vault-passphrase')).not.toBeNull(); + expect(field('vault-confirmation')).toBeNull(); + }); + + it('will not submit an empty field', () => { + expect(submitButton().disabled).toBe(true); + }); + + /** + * ⚠️ Said before the round trip: deriving takes 224 ms, and answering "too short" + * after it reads as the application thinking about it. + */ + it('says a passphrase is too short without asking the back end', async () => { + await type('vault-passphrase', 'short'); + + expect(problem()).toContain('8'); + expect(submitButton().disabled).toBe(true); + expect(repository.passphrases).toEqual([]); + }); + + it('hands the passphrase over once it is long enough', async () => { + await type('vault-passphrase', 'correct horse'); + submitButton().click(); + await fixture.whenStable(); + + expect(repository.passphrases).toEqual(['correct horse']); + }); + + /** + * ⚠️ Whatever happens, success included: a passphrase left in a DOM node is a + * passphrase in a memory dump. + */ + it('clears the field as soon as it has been sent', async () => { + await type('vault-passphrase', 'correct horse'); + submitButton().click(); + await fixture.whenStable(); + + expect(field('vault-passphrase')?.value).toBe(''); + }); + + /** A typo belongs beside the field that caused it, not in the global error banner. */ + it('says a refused passphrase in place rather than through the banner', async () => { + repository.failNext = new IpcError('unlock_vault', { + code: 'wrongPassphrase', + params: {}, + detail: 'Wrong passphrase', + }); + + await type('vault-passphrase', 'battery staple'); + submitButton().click(); + await fixture.whenStable(); + + expect(store.refused()).toBe(true); + expect(problem()).not.toBe(''); + }); + + it('withdraws the refusal as soon as the field is touched again', async () => { + repository.failNext = new IpcError('unlock_vault', { + code: 'wrongPassphrase', + params: {}, + detail: 'Wrong passphrase', + }); + await type('vault-passphrase', 'battery staple'); + submitButton().click(); + await fixture.whenStable(); + + await type('vault-passphrase', 'b'); + + expect(store.refused()).toBe(false); + }); + }); + + describe('protecting a library that has never been encrypted', () => { + beforeEach(async () => { + await open('absent'); + }); + + it('asks for the passphrase twice', () => { + expect(field('vault-passphrase')).not.toBeNull(); + expect(field('vault-confirmation')).not.toBeNull(); + }); + + it('refuses to submit while the two entries differ', async () => { + await type('vault-passphrase', 'correct horse'); + await type('vault-confirmation', 'correct hors'); + + expect(submitButton().disabled).toBe(true); + expect(problem()).not.toBe(''); + }); + + it('creates once both entries agree', async () => { + await type('vault-passphrase', 'correct horse'); + await type('vault-confirmation', 'correct horse'); + submitButton().click(); + await fixture.whenStable(); + + expect(repository.passphrases).toEqual(['correct horse']); + expect(store.isUnlocked()).toBe(true); + }); + + /** It cannot be recovered, and this is the only moment that can still be acted on. */ + it('says plainly that a lost passphrase is a lost library', () => { + const warning = fixture.debugElement.query(By.css('.warning')); + + expect(warning).not.toBeNull(); + expect(warning.nativeElement.textContent.trim()).not.toBe(''); + }); + }); +}); diff --git a/src/app/vault-gate/vault-gate.component.ts b/src/app/vault-gate/vault-gate.component.ts new file mode 100644 index 0000000..b3efba6 --- /dev/null +++ b/src/app/vault-gate/vault-gate.component.ts @@ -0,0 +1,90 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + afterNextRender, + computed, + inject, + signal, + viewChild, +} from '@angular/core'; +import { TranslocoPipe } from '@jsverse/transloco'; +import { MINIMUM_PASSPHRASE_LENGTH } from '@core/model/vault.model'; +import { VaultStore } from '@core/state/vault.store'; + +/** + * The screen that stands in front of everything until the library is open. + * + * ⚠️ The gate is here, at the root, and not in each store: the canvas is never mounted + * while the library is locked, so no store has to hold a "locked" branch and no command + * is called before it can be answered. + */ +@Component({ + selector: 'app-vault-gate', + imports: [TranslocoPipe], + templateUrl: './vault-gate.component.html', + styleUrl: './vault-gate.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VaultGateComponent { + protected readonly vault = inject(VaultStore); + + protected readonly passphrase = signal(''); + protected readonly confirmation = signal(''); + + protected readonly isCreating = computed(() => this.vault.needsCreating()); + + protected readonly tooShort = computed( + () => this.passphrase().length > 0 && this.passphrase().length < MINIMUM_PASSPHRASE_LENGTH, + ); + + protected readonly mismatched = computed( + () => this.isCreating() && this.confirmation().length > 0 && this.confirmation() !== this.passphrase(), + ); + + protected readonly canSubmit = computed(() => { + if (this.vault.isWorking() || this.passphrase().length < MINIMUM_PASSPHRASE_LENGTH) return false; + + return !this.isCreating() || this.confirmation() === this.passphrase(); + }); + + protected readonly minimumLength = MINIMUM_PASSPHRASE_LENGTH; + + private readonly passphraseField = viewChild>('passphraseField'); + + constructor() { + // ⚠️ Not the `autofocus` attribute, which the linter refuses: this screen is the only + // thing there is, and the user opened the application to type into this field. + afterNextRender(() => this.passphraseField()?.nativeElement.focus()); + } + + protected onPassphrase(value: string): void { + this.passphrase.set(value); + this.vault.clearRefusal(); + } + + protected onConfirmation(value: string): void { + this.confirmation.set(value); + } + + /** + * ⚠️ The field is cleared whatever happens, success included: a passphrase left in a + * DOM node is a passphrase in a memory dump, and the store never held it either. + */ + protected async submit(event: Event): Promise { + event.preventDefault(); + if (!this.canSubmit()) return; + + const typed = this.passphrase(); + const created = this.isCreating(); + + this.passphrase.set(''); + this.confirmation.set(''); + + if (created) { + await this.vault.create(typed); + } else { + await this.vault.unlock(typed); + } + } +} diff --git a/src/testing/fake-transfer-repository.ts b/src/testing/fake-transfer-repository.ts index f553098..83b9f9e 100644 --- a/src/testing/fake-transfer-repository.ts +++ b/src/testing/fake-transfer-repository.ts @@ -1,5 +1,6 @@ import { guard } from './fail-next'; import { TransferRepository } from '@core/data/transfer.repository'; +import { IpcError } from '@core/ipc/ipc.error'; import { ExportReport, ImportReport } from '@core/model/note.model'; /** Records the arguments and hands back the report the spec asked for. */ @@ -7,42 +8,68 @@ export class FakeTransferRepository implements Pick { + export(path: string, spaceId: string | null, passphrase: string | null): Promise { return guard(this, () => { - this.exportedTo = { path, spaceId }; - return this.exportReport; + this.exportedTo = { path, spaceId, passphrase }; + return { ...this.exportReport, protected: passphrase !== null }; }); } - exportSelection(path: string, ids: readonly string[]): Promise { + exportSelection(path: string, ids: readonly string[], passphrase: string | null): Promise { return guard(this, () => { - this.exportedTo = { path, spaceId: null }; + this.exportedTo = { path, spaceId: null, passphrase }; this.exportedIds = ids; - return { ...this.exportReport, notes: ids.length }; + return { ...this.exportReport, notes: ids.length, protected: passphrase !== null }; }); } - import(path: string): Promise { + import(path: string, passphrase: string | null): Promise { return guard(this, () => { this.importedFrom = path; + this.importedWith = passphrase; + if (this.expectedPassphrase !== null && passphrase !== this.expectedPassphrase) { + throw new IpcError('import_notes', { + code: 'wrongPassphrase', + params: {}, + detail: 'Wrong passphrase', + }); + } + return this.importReport; }); } + isProtected(path: string): Promise { + return guard(this, () => { + this.inspectedPath = path; + return this.fileIsProtected; + }); + } + share(ids: readonly string[]): Promise { return guard(this, () => { this.sharedIds = ids; diff --git a/src/testing/fake-vault-repository.ts b/src/testing/fake-vault-repository.ts new file mode 100644 index 0000000..deaa537 --- /dev/null +++ b/src/testing/fake-vault-repository.ts @@ -0,0 +1,44 @@ +import { VaultRepository } from '@core/data/vault.repository'; +import { IpcError } from '@core/ipc/ipc.error'; +import { VaultState } from '@core/model/vault.model'; + +/** The real one reaches for the Tauri bridge, absent under jsdom. */ +export class FakeVaultRepository implements Pick { + /** What `state()` answers. Specs set it before asking the store to load. */ + answer: VaultState = 'unlocked'; + + /** When set, the next call rejects with it — a wrong passphrase, or worse. */ + failNext: IpcError | null = null; + + passphrases: string[] = []; + changes: { current: string; next: string }[] = []; + + async state(): Promise { + return this.answer; + } + + async create(passphrase: string): Promise { + return this.attempt(passphrase); + } + + async unlock(passphrase: string): Promise { + return this.attempt(passphrase); + } + + /** Records the pair; `failNext` is how a spec makes the current phrase wrong. */ + async changePassphrase(current: string, next: string): Promise { + this.changes.push({ current, next }); + const failure = this.failNext; + this.failNext = null; + if (failure !== null) throw failure; + } + + private async attempt(passphrase: string): Promise { + this.passphrases.push(passphrase); + const failure = this.failNext; + this.failNext = null; + if (failure !== null) throw failure; + + this.answer = 'unlocked'; + } +} diff --git a/src/testing/testing.providers.ts b/src/testing/testing.providers.ts index d10456c..2b902a0 100644 --- a/src/testing/testing.providers.ts +++ b/src/testing/testing.providers.ts @@ -6,6 +6,7 @@ import { AttachmentsRepository } from '@core/data/attachments.repository'; import { NotesRepository } from '@core/data/notes.repository'; import { SpacesRepository } from '@core/data/spaces.repository'; import { TransferRepository } from '@core/data/transfer.repository'; +import { VaultRepository } from '@core/data/vault.repository'; import { AppInfoService } from '@core/services/app-info/app-info.service'; import { Note } from '@core/model/note.model'; import { Space } from '@core/model/space.model'; @@ -18,6 +19,7 @@ import { FakeFileDialog } from './fake-file-dialog'; import { FakeNotesRepository } from './fake-notes-repository'; import { FakeSpacesRepository } from './fake-spaces-repository'; import { FakeTransferRepository } from './fake-transfer-repository'; +import { FakeVaultRepository } from './fake-vault-repository'; import { FakeUpdater } from './fake-updater'; import { provideTranslocoTesting } from './provide-transloco-testing'; @@ -29,6 +31,7 @@ interface DataDoubles { readonly spacesRepository?: FakeSpacesRepository; readonly attachmentsRepository?: FakeAttachmentsRepository; readonly transferRepository?: FakeTransferRepository; + readonly vaultRepository?: FakeVaultRepository; readonly updater?: FakeUpdater; readonly appInfo?: FakeAppInfo; readonly clipboard?: FakeClipboard; @@ -51,6 +54,10 @@ export function provideAppTesting(doubles: DataDoubles = {}): Provider[] { provide: AttachmentsRepository, useValue: doubles.attachmentsRepository ?? new FakeAttachmentsRepository(), }, + { + provide: VaultRepository, + useValue: doubles.vaultRepository ?? new FakeVaultRepository(), + }, { provide: TransferRepository, useValue: doubles.transferRepository ?? new FakeTransferRepository(),