From d2c741128097732b9d2730d5a3585c20a9799b4b Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Tue, 15 Sep 2026 21:19:07 +0200 Subject: [PATCH 01/17] Let the attachments travel with the library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An export carried the rows and left the files behind, so a bundle handed over notes whose thumbnails would never load — and nothing said so. It is now a zip: `bundle.json` at the root, one entry per attachment beside it. Base64 inside the JSON was the obvious alternative and was refused. It costs a third more bytes, and the import path holds the file as a String, then a Value, then a Bundle — three copies of every screenshot, which a hundred captures turn into a gigabyte. Entries are pulled one at a time instead. A new DevBox reads an old file: `read` sniffs the zip magic and falls back to JSON, and the picker keeps `json` among the extensions it offers on the way in. `FORMAT_VERSION` is untouched — the container changed, the data shape did not. A record the archive names but does not carry is counted rather than swallowed. --- README.md | 2 +- docs/architecture.md | 29 +- e2e/specs/10-library-transfer.e2e.ts | 24 +- src-tauri/Cargo.lock | 22 +- src-tauri/Cargo.toml | 1 + src-tauri/benches/commands.rs | 14 +- src-tauri/src/attachments/model.rs | 4 +- src-tauri/src/attachments/store.rs | 20 ++ src-tauri/src/transfer.rs | 25 +- src-tauri/src/transfer/bundle.rs | 55 +++- src-tauri/src/transfer/file.rs | 256 ++++++++++++++++-- src-tauri/src/transfer/model.rs | 14 + src-tauri/tests/ipc_contract.rs | 3 + src-tauri/tests/transfer.rs | 173 ++++++++++-- src/app/core/ipc/bindings.ts | 11 + .../dialogs/file-dialog.service.spec.ts | 12 +- .../services/dialogs/file-dialog.service.ts | 9 +- .../core/services/i18n/translations/en.json | 3 + .../core/services/i18n/translations/fr.json | 3 + src/app/core/state/library.store.spec.ts | 72 ++++- src/app/core/state/library.store.ts | 25 +- src/testing/fake-transfer-repository.ts | 4 +- 22 files changed, 689 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index c81c070..b65760e 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ is no account, and the application works with the network off. | **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 | +| **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 | Syntax highlighting covers eighteen languages, the interface is available in French and diff --git a/docs/architecture.md b/docs/architecture.md index 096eb69..5babb03 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1139,15 +1139,34 @@ 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. +- **⚠️ 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 diff --git a/e2e/specs/10-library-transfer.e2e.ts b/e2e/specs/10-library-transfer.e2e.ts index e2eeeb2..20fbf99 100644 --- a/e2e/specs/10-library-transfer.e2e.ts +++ b/e2e/specs/10-library-transfer.e2e.ts @@ -19,7 +19,7 @@ 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 +39,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 +99,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 view = await bridge.queryNotes(query({ search: 'Worth exporting' })); + const source = view.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', diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7871ba1..aa04d93 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1050,6 +1050,7 @@ dependencies = [ "toml 1.1.3+spec-1.1.0", "unicode-normalization", "uuid", + "zip 8.6.0", ] [[package]] @@ -4852,7 +4853,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -5480,6 +5481,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" @@ -6733,6 +6740,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..7376d27 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -67,6 +67,7 @@ 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"] } [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..83fa684 100644 --- a/src-tauri/benches/commands.rs +++ b/src-tauri/benches/commands.rs @@ -191,21 +191,27 @@ 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).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).expect("a readable file"); + black_box( + bundle::merge(&mut corpus.connection, incoming, &mut payload, &attachments) + .expect("a merge"), + ); }); }); 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/store.rs b/src-tauri/src/attachments/store.rs index 99ba4fc..5f23fc8 100644 --- a/src-tauri/src/attachments/store.rs +++ b/src-tauri/src/attachments/store.rs @@ -115,6 +115,26 @@ 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 SqliteConnection, + note_ids: &[String], +) -> Result, StorageError> { + if note_ids.is_empty() { + return Ok(Vec::new()); + } + + attachments::table + .filter(attachments::note_id.eq_any(note_ids)) + .select(AttachmentRow::as_select()) + .order((attachments::created_at.asc(), attachments::id.asc())) + .load::(connection)? + .into_iter() + .map(Attachment::try_from) + .collect() +} + /// ⚠️ Collected before a purge: the cascade takes the records, never the files. pub fn stored_names_of( connection: &mut SqliteConnection, diff --git a/src-tauri/src/transfer.rs b/src-tauri/src/transfer.rs index a53d9ca..464923e 100644 --- a/src-tauri/src/transfer.rs +++ b/src-tauri/src/transfer.rs @@ -4,8 +4,9 @@ pub mod bundle; pub mod file; pub mod model; -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,6 +18,7 @@ use model::{ExportReport, ImportReport}; pub fn export_notes( path: String, space_id: Option, + app: AppHandle, db: State<'_, Db>, ) -> Result { model::validate_path(&path)?; @@ -27,7 +29,7 @@ pub fn export_notes( bundle::collect(&mut connection, notes)? }; - file::write(&path, &exported) + file::write(&path, &exported, &attachments::directory(&app)?) } #[tauri::command(async)] @@ -35,6 +37,7 @@ pub fn export_notes( pub fn export_selection( path: String, ids: Vec, + app: AppHandle, db: State<'_, Db>, ) -> Result { model::validate_path(&path)?; @@ -45,21 +48,31 @@ pub fn export_selection( bundle::collect(&mut connection, notes)? }; - file::write(&path, &exported) + file::write(&path, &exported, &attachments::directory(&app)?) } /// ⚠️ 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, + app: AppHandle, + db: State<'_, Db>, +) -> Result { model::validate_path(&path)?; - let imported = file::read(&path)?; + let (imported, mut payload) = file::read(&path)?; + 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, + )?) } /// 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..1f29e56 100644 --- a/src-tauri/src/transfer/bundle.rs +++ b/src-tauri/src/transfer/bundle.rs @@ -1,12 +1,16 @@ //! 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::error::StorageError; use crate::notes::model::Note; use crate::notes::store as notes; @@ -24,10 +28,13 @@ pub fn collect( .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, }) } @@ -40,12 +47,15 @@ pub fn collect( pub fn merge( connection: &mut SqliteConnection, incoming: IncomingBundle, + payload: &mut Payload, + directory: &Path, ) -> Result { let IncomingBundle { bundle, degraded } = incoming; connection.transaction(|connection| { let mut report = ImportReport::default(); + let mut arrived: BTreeSet = BTreeSet::new(); let mut mapping: BTreeMap = BTreeMap::new(); let existing = spaces::list(connection)?; @@ -74,6 +84,7 @@ pub fn merge( if notes::insert_imported(connection, ¬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,10 +95,52 @@ pub fn merge( } } + restore_attachments( + connection, + bundle.attachments, + &arrived, + payload, + directory, + &mut report, + )?; + Ok(report) }) } +/// ⚠️ 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, + 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, &record)?; + report.attachments_imported += 1; + } + + Ok(()) +} + /// The space names an export needs to render a note's breadcrumb. pub fn space_names( connection: &mut SqliteConnection, diff --git a/src-tauri/src/transfer/file.rs b/src-tauri/src/transfer/file.rs index d59d735..ebbb6c5 100644 --- a/src-tauri/src/transfer/file.rs +++ b/src-tauri/src/transfer/file.rs @@ -1,42 +1,172 @@ //! Nothing here knows the database. +//! +//! The export is a zip: `bundle.json` 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. 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 crate::attachments::model::Attachment; use crate::count::saturating_u32; use crate::error::{AppError, StorageError}; -/// ⚠️ 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"; + +/// 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()) +} +/// ⚠️ Written beside the target then renamed: a truncating write destroys the previous +/// export the day the disk fills. +pub fn write(path: &str, bundle: &Bundle, attachments: &Path) -> Result { let json = serde_json::to_string_pretty(bundle) .map_err(|error| StorageError::File(error.to_string()))?; 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) { + 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, + }) +} + +/// The bundle is deflated, being repetitive text. The attachments are stored as they are: +/// a PNG is already compressed, and deflating it again costs time for nothing. +fn archive( + staged: &Path, + json: &str, + records: &[Attachment], + source: &Path, +) -> Result { + let target = + File::create(staged).map_err(|error| file_error(&staged.display().to_string(), &error))?; + let mut writer = ZipWriter::new(target); + + writer + .start_file( + BUNDLE_ENTRY, + SimpleFileOptions::default().compression_method(CompressionMethod::Deflated), + ) + .map_err(|error| zip_error(&error))?; + writer + .write_all(json.as_bytes()) + .map_err(|error| file_error(BUNDLE_ENTRY, &error))?; + + 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(bytes) = std::fs::read(source.join(&name)) else { + continue; + }; + + writer + .start_file( + format!("{ATTACHMENTS_ENTRY}/{name}"), + SimpleFileOptions::default().compression_method(CompressionMethod::Stored), + ) + .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) } -pub fn read(path: &str) -> Result { - let json = std::fs::read_to_string(path) - .map_err(|error| StorageError::File(format!("{path}: {error}")))?; +/// The bundle, and whatever carries the attachment bytes that belong with it. +pub fn read(path: &str) -> Result<(IncomingBundle, Payload), 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 { + 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 mut archive = + ZipArchive::new(file).map_err(|error| StorageError::ImportFormat(error.to_string()))?; + + let json = { + let mut entry = archive + .by_name(BUNDLE_ENTRY) + .map_err(|_| StorageError::ImportFormat(format!("no {BUNDLE_ENTRY} in the archive")))?; + let mut json = String::new(); + entry + .read_to_string(&mut json) + .map_err(|error| file_error(BUNDLE_ENTRY, &error))?; + json + }; - Ok(super::model::read_bundle(&json)?) + Ok(( + super::model::read_bundle(&json)?, + Payload::Archive(Box::new(archive)), + )) +} + +/// The attachment bytes of an import, handed over one at a time. +pub enum Payload { + /// A `.json` export: it carried no attachments. + Empty, + Archive(Box>), +} + +impl Payload { + /// `None` when the archive names the record but does not carry its bytes. + pub fn take(&mut self, stored_name: &str) -> Option> { + let Self::Archive(archive) = 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()?; + + Some(bytes) + } } /// ⚠️ Same directory as the target, or the rename crosses volumes and stops being atomic. @@ -69,6 +199,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, } } @@ -82,7 +224,9 @@ mod tests { /// 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 +236,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 +247,9 @@ 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).unwrap(); let left: Vec<_> = std::fs::read_dir(&directory) .unwrap() @@ -113,41 +257,97 @@ 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(); + write(&target.to_string_lossy(), &bundle(), &directory).unwrap(); - let written = std::fs::read_to_string(&target).unwrap(); - assert!(written.starts_with('{')); - assert!(!written.contains("previous export")); + 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).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.json"); + let target = directory.join("library.devbox"); - write(&target.to_string_lossy(), &bundle()).unwrap(); - let read_back = read(&target.to_string_lossy()).unwrap(); + write(&target.to_string_lossy(), &bundle(), &directory).unwrap(); + let (read_back, _) = read(&target.to_string_lossy()).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 attachment = record(); + std::fs::write(directory.join(attachment.stored_name()), b"\x89PNG").unwrap(); + + let mut exported = bundle(); + exported.attachments = vec![attachment.clone()]; + let report = write(&target.to_string_lossy(), &exported, &directory).unwrap(); + + assert_eq!(report.attachments, 1); + + let (read_back, mut payload) = read(&target.to_string_lossy()).unwrap(); + assert_eq!(read_back.bundle.attachments.len(), 1); + assert_eq!( + payload.take(&attachment.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: the note is what + /// matters, and the import reports the gap. + #[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).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(); + + let (read_back, payload) = read(&target.to_string_lossy()).unwrap(); + + assert_eq!(read_back.bundle.notes.len(), 1); + assert!(matches!(payload, Payload::Empty)); + 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..b8a7deb 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,9 @@ 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, } /// `skipped`: notes already present or whose space is missing from the file — an import @@ -49,6 +58,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 +363,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/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/transfer.rs b/src-tauri/tests/transfer.rs index cbe8313..2b1db19 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::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,6 +33,19 @@ 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 SqliteConnection, 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 { let mut connection = open_in_memory().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,141 @@ 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"), - ) - .unwrap(); + ); assert_eq!(second.notes_imported, 0); assert_eq!(second.notes_degraded, 0); } + +/// 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); + std::fs::write(source_files.join(record.stored_name()), b"\x89PNG").unwrap(); + attachments::create(&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).unwrap(); + assert_eq!(written.attachments, 1); + + let mut target = open_in_memory().unwrap(); + let (incoming, mut payload) = file::read(&target_path).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); + std::fs::write(files.join(record.stored_name()), b"\x89PNG").unwrap(); + attachments::create(&mut source, &record).unwrap(); + + let target_path = directory + .join("library.devbox") + .to_string_lossy() + .to_string(); + file::write(&target_path, &exported(&mut source), &files).unwrap(); + + let mut target = open_in_memory().unwrap(); + let (first, mut payload) = file::read(&target_path).unwrap(); + merge_bundle(&mut target, first, &mut payload, &files).unwrap(); + + let (again, mut payload) = file::read(&target_path).unwrap(); + let second = merge_bundle(&mut target, again, &mut payload, &files).unwrap(); + + assert_eq!(second.notes_imported, 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. + attachments::create(&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).unwrap(); + assert_eq!(written.attachments, 0); + + let mut target = open_in_memory().unwrap(); + let (incoming, mut payload) = file::read(&target_path).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/core/ipc/bindings.ts b/src/app/core/ipc/bindings.ts index 0177bc6..204039b 100644 --- a/src/app/core/ipc/bindings.ts +++ b/src/app/core/ipc/bindings.ts @@ -162,6 +162,11 @@ export type ErrorCode = "noteNotFound" | "spaceNotFound" | "duplicateSpaceName" 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, }; /** @@ -184,6 +189,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, }; /** 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/i18n/translations/en.json b/src/app/core/services/i18n/translations/en.json index 9830132..0b049ce 100644 --- a/src/app/core/services/i18n/translations/en.json +++ b/src/app/core/services/i18n/translations/en.json @@ -321,7 +321,10 @@ "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}}.", "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.", diff --git a/src/app/core/services/i18n/translations/fr.json b/src/app/core/services/i18n/translations/fr.json index 416686a..5c130e2 100644 --- a/src/app/core/services/i18n/translations/fr.json +++ b/src/app/core/services/i18n/translations/fr.json @@ -321,7 +321,10 @@ "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}}.", "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.", diff --git a/src/app/core/state/library.store.spec.ts b/src/app/core/state/library.store.spec.ts index 1d461c8..be2edb9 100644 --- a/src/app/core/state/library.store.spec.ts +++ b/src/app/core/state/library.store.spec.ts @@ -55,12 +55,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 +80,8 @@ describe('LibraryStore', () => { notesImported: 5, notesSkipped: 0, notesDegraded: 1, + attachmentsImported: 0, + attachmentsMissing: 0, }; expect(await harness.store.import()).toBe(true); @@ -80,6 +89,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 +131,8 @@ describe('LibraryStore', () => { notesImported: 0, notesSkipped: 4, notesDegraded: 0, + attachmentsImported: 0, + attachmentsMissing: 0, }; expect(await harness.store.import()).toBe(false); @@ -110,7 +156,7 @@ describe('LibraryStore', () => { await harness.store.export(null, NOW); - 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 () => { @@ -133,19 +179,33 @@ describe('LibraryStore', () => { }); 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); 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 }; + + await harness.store.export(null, NOW); + + 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 }; await harness.store.export(null, NOW); diff --git a/src/app/core/state/library.store.ts b/src/app/core/state/library.store.ts index 24dbc2e..6fcda09 100644 --- a/src/app/core/state/library.store.ts +++ b/src/app/core/state/library.store.ts @@ -3,24 +3,27 @@ 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 { 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`; } /** * 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.notesDegraded > 0 ? 'file.importedFromNewerVersion' : 'file.imported'; + return report.attachmentsImported > 0 ? 'file.importedWithAttachments' : 'file.imported'; } function fileNameOf(path: string): string { @@ -55,6 +58,8 @@ export class LibraryStore { notes: String(report.notesImported), skipped: String(report.notesSkipped), degraded: String(report.notesDegraded), + attachments: String(report.attachmentsImported), + missing: String(report.attachmentsMissing), path: fileNameOf(path), }; @@ -101,7 +106,7 @@ export class LibraryStore { return false; } - private async write(action: (path: string) => Promise<{ notes: number }>, now: Date): Promise { + private async write(action: (path: string) => Promise, now: Date): Promise { const path = await this.dialog.chooseBundleDestination(defaultFileName(now)); if (path === null) return; @@ -116,8 +121,12 @@ export class LibraryStore { // 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) }, + key: report.attachments > 0 ? 'file.exportedWithAttachments' : 'file.exported', + params: { + notes: String(report.notes), + attachments: String(report.attachments), + path: fileNameOf(path), + }, }); return true; }, 'errors.exportFailed'); diff --git a/src/testing/fake-transfer-repository.ts b/src/testing/fake-transfer-repository.ts index f553098..0940912 100644 --- a/src/testing/fake-transfer-repository.ts +++ b/src/testing/fake-transfer-repository.ts @@ -12,12 +12,14 @@ export class FakeTransferRepository implements Pick Date: Tue, 15 Sep 2026 21:38:10 +0200 Subject: [PATCH 02/17] Rename a shadowed binding in the transfer scenario `view` was declared twice in the same test once the source note stopped coming from the file itself. esbuild refused the whole spec file; ESLint did not see it. --- e2e/specs/10-library-transfer.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/specs/10-library-transfer.e2e.ts b/e2e/specs/10-library-transfer.e2e.ts index 20fbf99..02ead1a 100644 --- a/e2e/specs/10-library-transfer.e2e.ts +++ b/e2e/specs/10-library-transfer.e2e.ts @@ -101,8 +101,8 @@ describe('Import, export and share', () => { it('imports a bundle from a newer version instead of refusing it whole', async () => { // ⚠️ 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 view = await bridge.queryNotes(query({ search: 'Worth exporting' })); - const source = view.sections[0]?.notes[0]; + 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('\\', '/'); From 33acb0b97e0e87d6e13ce0b2fbe3a385fd82eeb8 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Tue, 15 Sep 2026 21:51:21 +0200 Subject: [PATCH 03/17] Derive a key from a passphrase, and seal a value with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core of encryption at rest, on its own and testable on its own: Argon2id at OWASP's floor, then AES-256-GCM per value with a fresh nonce. 224 ms to derive on this machine, paid once at unlock while the user is still typing — never per value. The parameters travel in the key file rather than living as constants, or raising them later would lock every existing library out. Nothing is wired to the database yet. --- src-tauri/Cargo.lock | 231 ++++++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 4 + src-tauri/src/error.rs | 4 + src-tauri/src/lib.rs | 1 + src-tauri/src/vault.rs | 3 + src-tauri/src/vault/key.rs | 253 +++++++++++++++++++++++++++++++++++++ 6 files changed, 492 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/vault.rs create mode 100644 src-tauri/src/vault/key.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index aa04d93..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,7 @@ dependencies = [ "toml 1.1.3+spec-1.1.0", "unicode-normalization", "uuid", + "zeroize", "zip 8.6.0", ] @@ -1105,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]] @@ -1765,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]] @@ -2121,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" @@ -2371,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" @@ -3236,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" @@ -3259,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" @@ -3388,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" @@ -3513,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" @@ -4114,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]] @@ -5572,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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7376d27..0aa20b4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -68,6 +68,10 @@ 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/src/error.rs b/src-tauri/src/error.rs index b62ff7f..eae4334 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -47,6 +47,9 @@ 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), /// A command panicked while holding the connection. #[error("Storage unavailable: a previous operation failed")] Unavailable, @@ -139,6 +142,7 @@ impl From for AppError { // 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..774ed6c 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; diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs new file mode 100644 index 0000000..acd5489 --- /dev/null +++ b/src-tauri/src/vault.rs @@ -0,0 +1,3 @@ +//! Encryption at rest: the key, and what it seals. + +pub mod key; diff --git a/src-tauri/src/vault/key.rs b/src-tauri/src/vault/key.rs new file mode 100644 index 0000000..692a0ee --- /dev/null +++ b/src-tauri/src/vault/key.rs @@ -0,0 +1,253 @@ +//! 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 { + /// OWASP's Argon2id floor at the time of writing: 19 MiB, two passes, one lane. + fn default() -> Self { + Self { + memory_kib: 19 * 1024, + passes: 2, + 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 { + /// ⚠️ 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`, 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 { + 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.as_bytes()) + .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(BASE64.encode(joined)) + } + + /// ⚠️ 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()))?; + + if raw.len() <= NONCE_BYTES { + return Err(StorageError::Vault( + "a value is too short to be sealed".to_string(), + )); + } + + let (nonce, body) = raw.split_at(NONCE_BYTES); + let nonce: &[u8; NONCE_BYTES] = nonce.try_into().expect("a checked length"); + + let opened = self + .cipher() + .decrypt(nonce.into(), body) + .map_err(|_| StorageError::Vault("a value would not open".to_string()))?; + + String::from_utf8(opened).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(…)"); + } +} From eec1ba403f85522d893763d057b3ecd6b0137092 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Tue, 15 Sep 2026 22:22:04 +0200 Subject: [PATCH 04/17] Keep the salt, the cost and a check value beside the library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The key file sits next to the database rather than inside it: it carries what is needed to derive the key, so it has to be readable before anything opens. The check value is what earns its keep. Without it a wrong passphrase would unlock happily, the library would read as gibberish, and the first write would seal real notes under a key nobody can reproduce. With it, `WrongPassphrase` is a code of its own — which is what the unlock screen acts on, clearing the field rather than banishing the user to a banner. The cost travels in the file, so raising the default later locks nobody out. --- src-tauri/src/error.rs | 8 + src-tauri/src/vault.rs | 1 + src-tauri/src/vault/file.rs | 297 ++++++++++++++++++ src/app/core/ipc/bindings.ts | 7 +- src/app/core/ipc/ipc.error.ts | 1 + .../services/errors/error-notifier.service.ts | 1 + .../core/services/i18n/translations/en.json | 1 + .../core/services/i18n/translations/fr.json | 1 + 8 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/vault/file.rs diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index eae4334..5fe1bdb 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -50,6 +50,10 @@ pub enum StorageError { /// 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 command panicked while holding the connection. #[error("Storage unavailable: a previous operation failed")] Unavailable, @@ -73,6 +77,9 @@ 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, Storage, } @@ -137,6 +144,7 @@ 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::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. diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index acd5489..93c5327 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -1,3 +1,4 @@ //! Encryption at rest: the key, and what it seals. +pub mod file; pub mod key; diff --git a/src-tauri/src/vault/file.rs b/src-tauri/src/vault/file.rs new file mode 100644 index 0000000..4f12740 --- /dev/null +++ b/src-tauri/src/vault/file.rs @@ -0,0 +1,297 @@ +//! The key file, beside the database. +//! +//! ⚠️ Outside the library on purpose: it carries what is needed to derive the key, so it +//! has to be readable before anything can be opened. It holds no key — a salt, the cost +//! the key was derived at, and a known value sealed under it. +//! +//! ⚠️ 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. +const FORMAT_VERSION: u32 = 1; + +/// What the check value holds. Its content is irrelevant — that it opens is the answer. +const CHECK_PLAINTEXT: &str = "devbox-vault-v1"; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct KeyFile { + version: u32, + kdf: Kdf, + /// ⚠️ A known value sealed under the derived key. Without it a wrong passphrase would + /// unlock happily and the whole library would read as gibberish — and the first write + /// would then seal real notes under the wrong key. + check: 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 salt = fresh_salt()?; + let vault = Vault::derive(passphrase, &salt, cost)?; + + let file = 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), + }, + check: vault.seal(CHECK_PLAINTEXT)?, + }; + + write_atomically(&path, &file)?; + + Ok(vault) +} + +/// ⚠️ 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 vault = Vault::derive(passphrase, &salt, cost)?; + + match vault.open(&file.check) { + Ok(check) if check == CHECK_PLAINTEXT => Ok(vault), + _ => 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 224 ms 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(); + } + + /// ⚠️ The whole point of the check value: without it 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(); + } + + #[test] + fn the_key_file_holds_no_key_and_no_passphrase() { + let directory = scratch(); + create(&directory, "correct horse", cheap()).unwrap(); + + let written = std::fs::read_to_string(path_in(&directory)).unwrap(); + + assert!(!written.contains("correct horse")); + assert!(written.contains("argon2id")); + } + + /// 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/app/core/ipc/bindings.ts b/src/app/core/ipc/bindings.ts index 204039b..d65f242 100644 --- a/src/app/core/ipc/bindings.ts +++ b/src/app/core/ipc/bindings.ts @@ -157,7 +157,12 @@ 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" | "storage"; export type ExportReport = { notes: number, diff --git a/src/app/core/ipc/ipc.error.ts b/src/app/core/ipc/ipc.error.ts index 361b576..5e2a4c3 100644 --- a/src/app/core/ipc/ipc.error.ts +++ b/src/app/core/ipc/ipc.error.ts @@ -22,6 +22,7 @@ const IPC_ERROR_CODES: Record = { importFormat: true, invalidInput: true, storageUnavailable: true, + wrongPassphrase: true, storage: true, }; diff --git a/src/app/core/services/errors/error-notifier.service.ts b/src/app/core/services/errors/error-notifier.service.ts index 706e2ad..3f07dcc 100644 --- a/src/app/core/services/errors/error-notifier.service.ts +++ b/src/app/core/services/errors/error-notifier.service.ts @@ -25,6 +25,7 @@ const CODE_KEYS: Record = { importFormat: 'errors.importFormat', invalidInput: 'errors.invalidInput', storageUnavailable: 'errors.storageUnavailable', + wrongPassphrase: 'errors.wrongPassphrase', storage: null, }; diff --git a/src/app/core/services/i18n/translations/en.json b/src/app/core/services/i18n/translations/en.json index 0b049ce..b13ac62 100644 --- a/src/app/core/services/i18n/translations/en.json +++ b/src/app/core/services/i18n/translations/en.json @@ -150,6 +150,7 @@ "spaceGone": "This space no longer exists.", "invalidInput": "The engine rejected this value: {{field}}.", "storageUnavailable": "Storage is unavailable. Restart {{app}}.", + "wrongPassphrase": "Wrong 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.", diff --git a/src/app/core/services/i18n/translations/fr.json b/src/app/core/services/i18n/translations/fr.json index 5c130e2..bbc4c39 100644 --- a/src/app/core/services/i18n/translations/fr.json +++ b/src/app/core/services/i18n/translations/fr.json @@ -150,6 +150,7 @@ "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.", "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.", From 7d69460fb92f6d2dcc3c664676a06cffede31817 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Tue, 15 Sep 2026 22:32:10 +0200 Subject: [PATCH 05/17] Carry the key with the connection, and open the library behind a passphrase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structural half. `Library` holds the connection and the key together, and `Db` is empty until `unlock_vault` fills it — so `setup` no longer opens anything, and the two startup sweeps move behind the unlock with it. Measured before choosing: 61 signatures take a connection and 531 test call sites pass one. Threading a key through all of them would have buried the feature under a rename. `Library` derefs to the connection instead, so the concrete-typed store functions coerce and the call sites do not move — eight raw Diesel calls in the tests needed `.db()`, because a generic parameter gets no deref coercion. `Locked` and `WrongPassphrase` are codes of their own: the unlock screen acts on the second, and the first only catches a caller that jumped the gate. Nothing is sealed yet — that is the next slice. --- src-tauri/benches/corpus.rs | 12 +- src-tauri/src/db.rs | 135 +++++++++++++++-- src-tauri/src/db/migration.rs | 6 +- src-tauri/src/error.rs | 7 + src-tauri/src/lib.rs | 16 +- src-tauri/src/vault.rs | 142 +++++++++++++++++- src-tauri/tests/notes.rs | 16 +- src-tauri/tests/spaces.rs | 6 +- src-tauri/tests/transfer.rs | 8 +- src/app/core/ipc/bindings.ts | 32 +++- src/app/core/ipc/ipc.error.ts | 1 + .../services/errors/error-notifier.service.ts | 1 + .../core/services/i18n/translations/en.json | 1 + .../core/services/i18n/translations/fr.json | 1 + 14 files changed, 346 insertions(+), 38 deletions(-) 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/db.rs b/src-tauri/src/db.rs index 295c60e..a38c76c 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,134 @@ 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 + } + + /// 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 +210,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 +225,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 +239,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 5fe1bdb..b694523 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -57,6 +57,10 @@ pub enum StorageError { /// 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), @@ -80,6 +84,8 @@ pub enum ErrorCode { /// 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, Storage, } @@ -145,6 +151,7 @@ impl From for AppError { } StorageError::Unavailable => Self::new(ErrorCode::StorageUnavailable, detail), StorageError::WrongPassphrase => Self::new(ErrorCode::WrongPassphrase, 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. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 774ed6c..83df321 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -33,6 +33,7 @@ use notes::{ }; use spaces::{create_space, delete_space, list_spaces, pin_space, rename_space}; use transfer::{export_notes, export_selection, import_notes, share_notes}; +use vault::{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. @@ -88,6 +89,9 @@ fn ipc_builder() -> Builder { export_selection, import_notes, share_notes, + vault_state, + create_vault, + unlock_vault, app_changelog, sync_tray, set_global_shortcuts, @@ -186,17 +190,19 @@ 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); diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index 93c5327..f0889ba 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -1,4 +1,144 @@ -//! Encryption at rest: the key, and what it seals. +//! 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; + +use serde::Serialize; +use specta::Type; +use tauri::{AppHandle, Manager, State}; + +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(passphrase: String, 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())?; + + adopt(&app, &db, vault) +} + +/// ⚠️ Deliberately slow: deriving the key is the whole defence against someone trying +/// passphrases against a copied file. It is `(async)` for the same reason — 224 ms on the +/// main thread would freeze the window over every attempt. +#[tauri::command(async)] +#[specta::specta] +pub fn unlock_vault(passphrase: String, app: AppHandle, db: State<'_, Db>) -> Result<(), AppError> { + let directory = app.path().app_data_dir().map_err(storage)?; + let vault = file::unlock(&directory, &passphrase)?; + + adopt(&app, &db, vault) +} + +/// Opens the library under the key, hands it to the rest of the application, and only +/// then runs what a startup used to run before there was anything to unlock. +fn adopt(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); + } + } + + crate::sweep(app); + + 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/tests/notes.rs b/src-tauri/tests/notes.rs index 0395d34..79b5b8d 100644 --- a/src-tauri/tests/notes.rs +++ b/src-tauri/tests/notes.rs @@ -352,7 +352,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 +400,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 +508,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 +563,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 +727,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 +1669,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 +1683,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,7 +1708,7 @@ 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"); diff --git a/src-tauri/tests/spaces.rs b/src-tauri/tests/spaces.rs index df2662e..09f6eab 100644 --- a/src-tauri/tests/spaces.rs +++ b/src-tauri/tests/spaces.rs @@ -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 2b1db19..005e0f2 100644 --- a/src-tauri/tests/transfer.rs +++ b/src-tauri/tests/transfer.rs @@ -1,5 +1,5 @@ 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; @@ -36,7 +36,7 @@ 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 SqliteConnection, bundle: IncomingBundle) -> ImportReport { +fn merge(connection: &mut Library, bundle: IncomingBundle) -> ImportReport { merge_bundle( connection, bundle, @@ -47,7 +47,7 @@ fn merge(connection: &mut SqliteConnection, bundle: IncomingBundle) -> ImportRep } /// 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; @@ -57,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() } diff --git a/src/app/core/ipc/bindings.ts b/src/app/core/ipc/bindings.ts index d65f242..98669d2 100644 --- a/src/app/core/ipc/bindings.ts +++ b/src/app/core/ipc/bindings.ts @@ -69,6 +69,18 @@ export const commands = { importNotes: (path: string) => typedError(__TAURI_INVOKE("import_notes", { path })), /** Nothing is sent anywhere: "share" stops at the clipboard. */ shareNotes: (ids: string[]) => typedError(__TAURI_INVOKE("share_notes", { ids })), + 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 — 224 ms on the + * main thread would freeze the window over every attempt. + */ + unlockVault: (passphrase: string) => typedError(__TAURI_INVOKE("unlock_vault", { passphrase })), appChangelog: () => __TAURI_INVOKE("app_changelog"), /** * Replaces only the menu when the tray already exists, so a language change does not @@ -162,7 +174,9 @@ export type ErrorCode = "noteNotFound" | "spaceNotFound" | "duplicateSpaceName" * The one the unlock screen acts on: it clears the field rather than banishing the * user to a banner. */ -"wrongPassphrase" | "storage"; +"wrongPassphrase" | +/** A command ran before the library was unlocked. */ +"locked" | "storage"; export type ExportReport = { notes: number, @@ -398,6 +412,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 5e2a4c3..67ccfd5 100644 --- a/src/app/core/ipc/ipc.error.ts +++ b/src/app/core/ipc/ipc.error.ts @@ -23,6 +23,7 @@ const IPC_ERROR_CODES: Record = { invalidInput: true, storageUnavailable: true, wrongPassphrase: true, + locked: true, storage: true, }; diff --git a/src/app/core/services/errors/error-notifier.service.ts b/src/app/core/services/errors/error-notifier.service.ts index 3f07dcc..9418005 100644 --- a/src/app/core/services/errors/error-notifier.service.ts +++ b/src/app/core/services/errors/error-notifier.service.ts @@ -26,6 +26,7 @@ const CODE_KEYS: Record = { invalidInput: 'errors.invalidInput', storageUnavailable: 'errors.storageUnavailable', wrongPassphrase: 'errors.wrongPassphrase', + locked: 'errors.locked', storage: null, }; diff --git a/src/app/core/services/i18n/translations/en.json b/src/app/core/services/i18n/translations/en.json index b13ac62..4f4356b 100644 --- a/src/app/core/services/i18n/translations/en.json +++ b/src/app/core/services/i18n/translations/en.json @@ -151,6 +151,7 @@ "invalidInput": "The engine rejected this value: {{field}}.", "storageUnavailable": "Storage is unavailable. Restart {{app}}.", "wrongPassphrase": "Wrong passphrase.", + "locked": "The library is locked.", "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.", diff --git a/src/app/core/services/i18n/translations/fr.json b/src/app/core/services/i18n/translations/fr.json index bbc4c39..ac8497e 100644 --- a/src/app/core/services/i18n/translations/fr.json +++ b/src/app/core/services/i18n/translations/fr.json @@ -151,6 +151,7 @@ "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.", "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.", From 88b8de6a951192889c972c6a1e9b5d28747c2650 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Tue, 15 Sep 2026 23:04:43 +0200 Subject: [PATCH 06/17] Seal what a reader would want, and nothing SQL needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Titles, bodies, sources, checklist items, `{{field}}` values, space names and attachment file names now leave the process sealed. A test greps the database file for a note it just wrote and does not find it. What stays in the clear is what SQL works on: ids, instants, `pinned`, `language`, `kind`, and the tags — the facet and the filter both touch those, and sealing them would move a corpus-wide scan into Rust for labels like `auth`. The test asserts that decision rather than leaving it to be discovered. Space names were the exception worth paying for: they were ordered and compared in SQL, and both moved to Rust. A library holds a handful of spaces, so it is cheap — the same move on the notes would not have been, which is the whole reason the notes are filtered on columns that stay readable. `_in` is the form a store function takes when someone else already opened the transaction and holds the two halves apart. --- src-tauri/src/attachments.rs | 10 +- src-tauri/src/attachments/store.rs | 95 +++++++------ src-tauri/src/db.rs | 7 + src-tauri/src/notes.rs | 8 +- src-tauri/src/notes/store.rs | 200 ++++++++++++++------------- src-tauri/src/notes/store/related.rs | 57 +++++--- src-tauri/src/notes/store/trash.rs | 39 +++--- src-tauri/src/spaces/store.rs | 152 ++++++++++++-------- src-tauri/src/transfer/bundle.rs | 26 ++-- src-tauri/tests/notes.rs | 60 +++++++- src-tauri/tests/spaces.rs | 8 +- src-tauri/tests/transfer.rs | 15 +- 12 files changed, 405 insertions(+), 272 deletions(-) diff --git a/src-tauri/src/attachments.rs b/src-tauri/src/attachments.rs index 64a00c2..c41319e 100644 --- a/src-tauri/src/attachments.rs +++ b/src-tauri/src/attachments.rs @@ -95,7 +95,10 @@ pub fn attach_file( } let mut connection = lock(&db)?; - if let Err(error) = store::create(&mut connection, &attachment) { + if let Err(error) = { + let (db, vault) = connection.split(); + store::create(db, vault, &attachment) + } { remove_files(&directory, &[attachment.stored_name()]); return Err(error.into()); } @@ -140,7 +143,10 @@ fn write_attachment( .map_err(|error| file_error(&attachment.file_name, &error))?; let mut connection = lock(db)?; - if let Err(error) = store::create(&mut connection, &attachment) { + if let Err(error) = { + let (db, vault) = connection.split(); + store::create(db, vault, &attachment) + } { remove_files(&directory, &[attachment.stored_name()]); return Err(error.into()); } diff --git a/src-tauri/src/attachments/store.rs b/src-tauri/src/attachments/store.rs index 5f23fc8..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() } @@ -118,48 +117,54 @@ pub fn delete(connection: &mut SqliteConnection, id: &str) -> Result<(), Storage /// 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 SqliteConnection, + 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::(connection)? + .load::(db)? .into_iter() - .map(Attachment::try_from) + .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. @@ -194,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, @@ -216,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(), @@ -232,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); @@ -244,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(_))); } @@ -253,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 a38c76c..574b0dd 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -30,6 +30,13 @@ impl Library { &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 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..0902217 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< @@ -112,21 +124,21 @@ pub(super) fn notes_of_space( } pub fn global_placeholder_values( - connection: &mut SqliteConnection, + connection: &mut Library, ) -> Result, StorageError> { Ok(global_placeholders::table .select((global_placeholders::name, global_placeholders::value)) .order(global_placeholders::name.asc()) - .load::<(String, String)>(connection)? + .load::<(String, String)>(connection.db())? .into_iter() .collect()) } 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() { @@ -149,10 +161,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 +182,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 +194,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 +231,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 +256,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 +281,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 +312,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 +334,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 +344,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 +361,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 +370,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 +390,7 @@ pub fn move_many( } pub fn tag_many( - connection: &mut SqliteConnection, + connection: &mut Library, ids: &[String], tags: &[String], now: DateTime, @@ -384,7 +399,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 +427,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 +448,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 +484,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 +506,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 +557,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/bundle.rs b/src-tauri/src/transfer/bundle.rs index 1f29e56..d2f6a26 100644 --- a/src-tauri/src/transfer/bundle.rs +++ b/src-tauri/src/transfer/bundle.rs @@ -5,24 +5,22 @@ 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)) @@ -45,19 +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 @@ -68,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); } @@ -82,7 +80,7 @@ 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 @@ -97,6 +95,7 @@ pub fn merge( restore_attachments( connection, + vault, bundle.attachments, &arrived, payload, @@ -116,6 +115,7 @@ pub fn merge( /// already in the library, and re-importing the same file has to add nothing. fn restore_attachments( connection: &mut SqliteConnection, + vault: &Vault, records: Vec, arrived: &BTreeSet, payload: &mut Payload, @@ -134,7 +134,7 @@ fn restore_attachments( std::fs::write(directory.join(record.stored_name()), &bytes) .map_err(|error| StorageError::File(format!("{}: {error}", record.stored_name())))?; - attachments::create(connection, &record)?; + attachments::create(connection, vault, &record)?; report.attachments_imported += 1; } @@ -142,9 +142,7 @@ fn restore_attachments( } /// The space names an export needs to render a note's breadcrumb. -pub fn space_names( - connection: &mut SqliteConnection, -) -> Result, StorageError> { +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/tests/notes.rs b/src-tauri/tests/notes.rs index 79b5b8d..3417a56 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 } @@ -1713,3 +1710,52 @@ fn a_stored_date_always_carries_its_milliseconds() { 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(); + } + + 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" + ); + // ⚠️ Tags are deliberately not sealed: the facet and the filter both touch them in + // SQL. This asserts the decision rather than an accident. + assert!( + haystack.contains("prod"), + "a tag 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 09f6eab..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() diff --git a/src-tauri/tests/transfer.rs b/src-tauri/tests/transfer.rs index 005e0f2..fa6d76e 100644 --- a/src-tauri/tests/transfer.rs +++ b/src-tauri/tests/transfer.rs @@ -243,6 +243,15 @@ fn a_degraded_note_is_counted_once_and_not_again_on_a_second_import() { assert_eq!(second.notes_degraded, 0); } +/// `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() @@ -281,7 +290,7 @@ fn an_attachment_travels_with_the_library() { let note_id = notes::all(&mut source, None).unwrap()[0].id.clone(); let record = capture(¬e_id); std::fs::write(source_files.join(record.stored_name()), b"\x89PNG").unwrap(); - attachments::create(&mut source, &record).unwrap(); + attach(&mut source, &record).unwrap(); let target_path = directory .join("library.devbox") @@ -318,7 +327,7 @@ fn importing_the_same_archive_twice_restores_the_attachment_once() { let note_id = notes::all(&mut source, None).unwrap()[0].id.clone(); let record = capture(¬e_id); std::fs::write(files.join(record.stored_name()), b"\x89PNG").unwrap(); - attachments::create(&mut source, &record).unwrap(); + attach(&mut source, &record).unwrap(); let target_path = directory .join("library.devbox") @@ -351,7 +360,7 @@ fn an_attachment_the_archive_does_not_carry_is_reported() { 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. - attachments::create(&mut source, &capture(¬e_id)).unwrap(); + attach(&mut source, &capture(¬e_id)).unwrap(); let target_path = directory .join("library.devbox") From 565c3b6a6e7c04268d323a48bdcdf138e039713c Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Tue, 15 Sep 2026 23:12:30 +0200 Subject: [PATCH 07/17] Seal the attachment files, and migrate a library that predates the passphrase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bytes on disk go the same way as the rows: a pasted screenshot of a credentials page has no business being the one thing left readable beside a database that is not. ⚠️ `open_attachment` is the exception, and it is deliberate. Handing a file to the application the desktop chose for it means handing over a readable path, so a decrypted copy goes under a directory of ours in the OS temp folder and is swept at the next launch — it cannot be deleted on close, because the program that opened it still holds it. Replacing the one click with "save as" was the alternative and was turned down. Migration seals the rows in one transaction and the files after it commits: a file write does not roll back, and a file left readable is recoverable where a row sealed twice is not. Neither is idempotent, and neither needs to be — the key file is the guard, and `create` refuses a library that already has one. --- src-tauri/src/attachments.rs | 119 +++++++++++--- src-tauri/src/attachments/sealed.rs | 188 ++++++++++++++++++++++ src-tauri/src/lib.rs | 4 + src-tauri/src/vault.rs | 50 +++++- src-tauri/src/vault/key.rs | 36 +++++ src-tauri/src/vault/migrate.rs | 238 ++++++++++++++++++++++++++++ src/app/core/ipc/bindings.ts | 7 + 7 files changed, 615 insertions(+), 27 deletions(-) create mode 100644 src-tauri/src/attachments/sealed.rs create mode 100644 src-tauri/src/vault/migrate.rs diff --git a/src-tauri/src/attachments.rs b/src-tauri/src/attachments.rs index c41319e..427bdca 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,11 +106,7 @@ pub fn attach_file( } } - let mut connection = lock(&db)?; - if let Err(error) = { - let (db, vault) = connection.split(); - store::create(db, vault, &attachment) - } { + if let Err(error) = store::create(db, vault, &attachment) { remove_files(&directory, &[attachment.stored_name()]); return Err(error.into()); } @@ -139,14 +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) = { - let (db, vault) = connection.split(); - store::create(db, vault, &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()); } @@ -172,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,{}", @@ -183,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(); + 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(()) @@ -205,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(()) } @@ -273,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 { @@ -289,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(); } @@ -308,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!( @@ -322,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/sealed.rs b/src-tauri/src/attachments/sealed.rs new file mode 100644 index 0000000..d841488 --- /dev/null +++ b/src-tauri/src/attachments/sealed.rs @@ -0,0 +1,188 @@ +//! 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; + +use crate::error::StorageError; +use crate::vault::key::Vault; + +/// What `open_externally` writes into, under the OS temporary directory. +/// +/// ⚠️ 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 at every launch, so the copy outlives the session at +/// worst, and the README says so plainly. +const PLAINTEXT_DIRECTORY: &str = "devbox-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. +pub fn plaintext_directory() -> std::path::PathBuf { + std::env::temp_dir().join(PLAINTEXT_DIRECTORY) +} + +/// ⚠️ Run at every launch, beside the orphan-file sweep. A copy handed to another +/// application cannot be deleted while that application holds it, and a crash deletes +/// nothing at all — so the guarantee is "gone by the next launch", not "gone on close". +pub fn sweep_plaintext() { + let directory = plaintext_directory(); + 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/lib.rs b/src-tauri/src/lib.rs index 83df321..f4ccc40 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -209,6 +209,10 @@ pub(crate) fn sweep(handle: &tauri::AppHandle) { 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(); } /// ⚠️ Both are refused when there is no tray to find the window in (see `desktop`). diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index f0889ba..af029f0 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -8,6 +8,7 @@ pub mod file; pub mod key; +pub mod migrate; use serde::Serialize; use specta::Type; @@ -63,7 +64,54 @@ pub fn create_vault(passphrase: String, app: AppHandle, db: State<'_, Db>) -> Re let vault = file::create(&directory, &passphrase, Cost::default())?; - adopt(&app, &db, vault) + adopt(&app, &db, vault)?; + + // ⚠️ After `adopt`, which is what opened the database: a library created a moment ago + // has nothing to seal, and one that predates the passphrase has everything. + seal_what_was_there(&app, &db) +} + +/// ⚠️ 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 diff --git a/src-tauri/src/vault/key.rs b/src-tauri/src/vault/key.rs index 692a0ee..a20cee2 100644 --- a/src-tauri/src/vault/key.rs +++ b/src-tauri/src/vault/key.rs @@ -77,6 +77,42 @@ impl Vault { 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( + "a file is too short to be 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("a file would not open".to_string())) + } + /// `nonce ++ ciphertext ++ tag`, 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 { 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/app/core/ipc/bindings.ts b/src/app/core/ipc/bindings.ts index 98669d2..b7bdf08 100644 --- a/src/app/core/ipc/bindings.ts +++ b/src/app/core/ipc/bindings.ts @@ -54,6 +54,13 @@ 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. */ From b62c98ab9ff67b1a02f1be89d4908173594d063f Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Tue, 15 Sep 2026 23:20:25 +0200 Subject: [PATCH 08/17] Put an unlock screen in front of the library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate sits at the root of the shell, and the outlet is not created while the library is locked rather than merely hidden: the canvas queries notes the moment it mounts, and there would be nothing to answer with. That is what spares every store a "locked" branch. The state lives in Rust, never here — a page reload must not ask again for a library this process already has open, which is also what keeps `reopenSession` working in the end-to-end suite. A refused passphrase is said beside the field that caused it, not through the global banner: it is the ordinary answer to a typo. Too short is said before the round trip, because answering it after 224 ms of derivation reads as the application thinking about it. And the field is cleared as soon as it has been sent, success included. --- src/app/app.component.html | 11 +- src/app/app.component.spec.ts | 39 +++- src/app/app.component.ts | 9 +- src/app/app.config.ts | 6 + src/app/core/data/vault.repository.ts | 23 +++ src/app/core/model/vault.model.ts | 2 + .../core/services/i18n/translations/en.json | 18 +- .../core/services/i18n/translations/fr.json | 18 +- src/app/core/state/vault.store.ts | 80 ++++++++ src/app/vault-gate/vault-gate.component.html | 64 +++++++ src/app/vault-gate/vault-gate.component.scss | 85 +++++++++ .../vault-gate/vault-gate.component.spec.ts | 171 ++++++++++++++++++ src/app/vault-gate/vault-gate.component.ts | 96 ++++++++++ src/testing/fake-vault-repository.ts | 35 ++++ src/testing/testing.providers.ts | 7 + 15 files changed, 658 insertions(+), 6 deletions(-) create mode 100644 src/app/core/data/vault.repository.ts create mode 100644 src/app/core/model/vault.model.ts create mode 100644 src/app/core/state/vault.store.ts create mode 100644 src/app/vault-gate/vault-gate.component.html create mode 100644 src/app/vault-gate/vault-gate.component.scss create mode 100644 src/app/vault-gate/vault-gate.component.spec.ts create mode 100644 src/app/vault-gate/vault-gate.component.ts create mode 100644 src/testing/fake-vault-repository.ts diff --git a/src/app/app.component.html b/src/app/app.component.html index 4508a5b..0c6fa6e 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -2,6 +2,15 @@ - + + @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..c12ab03 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,9 +1,11 @@ -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 { VaultGateComponent } from './vault-gate/vault-gate.component'; +import { VaultStore } from '@core/state/vault.store'; @Component({ selector: 'app-root', @@ -13,9 +15,12 @@ import { UpdatePromptComponent } from '@banners/update-prompt/update-prompt.comp StatusToastComponent, RouterOutlet, UpdatePromptComponent, + VaultGateComponent, ], 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/vault.repository.ts b/src/app/core/data/vault.repository.ts new file mode 100644 index 0000000..d7884e7 --- /dev/null +++ b/src/app/core/data/vault.repository.ts @@ -0,0 +1,23 @@ +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)); + } +} diff --git a/src/app/core/model/vault.model.ts b/src/app/core/model/vault.model.ts new file mode 100644 index 0000000..5671fe6 --- /dev/null +++ b/src/app/core/model/vault.model.ts @@ -0,0 +1,2 @@ +/** Generated from the Rust enum, so a state added there breaks the shell until handled. */ +export type { VaultState } from '@core/ipc/bindings'; diff --git a/src/app/core/services/i18n/translations/en.json b/src/app/core/services/i18n/translations/en.json index 4f4356b..4448e7a 100644 --- a/src/app/core/services/i18n/translations/en.json +++ b/src/app/core/services/i18n/translations/en.json @@ -172,7 +172,9 @@ "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.", + "unlockFailed": "Unlocking failed.", + "vaultStateFailed": "Could not tell whether the library is encrypted." }, "tray": { "open": "Open {{app}}", @@ -486,5 +488,19 @@ "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…" } } diff --git a/src/app/core/services/i18n/translations/fr.json b/src/app/core/services/i18n/translations/fr.json index ac8497e..2ab5f52 100644 --- a/src/app/core/services/i18n/translations/fr.json +++ b/src/app/core/services/i18n/translations/fr.json @@ -172,7 +172,9 @@ "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.", + "unlockFailed": "Le déverrouillage a échoué.", + "vaultStateFailed": "Impossible de savoir si la bibliothèque est chiffrée." }, "tray": { "open": "Ouvrir {{app}}", @@ -486,5 +488,19 @@ "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…" } } diff --git a/src/app/core/state/vault.store.ts b/src/app/core/state/vault.store.ts new file mode 100644 index 0000000..b2e8cfc --- /dev/null +++ b/src/app/core/state/vault.store.ts @@ -0,0 +1,80 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import { ErrorNotifier } from '@core/services/errors/error-notifier.service'; +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)); + } + + /** 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): Promise { + this._isWorking.set(true); + this._refused.set(false); + try { + await action(); + this._state.set('unlocked'); + return true; + } catch (error) { + if (isWrongPassphrase(error)) { + this._refused.set(true); + return false; + } + + this.notifier.reportFailure('errors.unlockFailed', error); + return false; + } finally { + this._isWorking.set(false); + } + } +} + +function isWrongPassphrase(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'wrongPassphrase'; +} 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..0d5d325 --- /dev/null +++ b/src/app/vault-gate/vault-gate.component.html @@ -0,0 +1,64 @@ +
+
+

+ {{ (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..ce6e219 --- /dev/null +++ b/src/app/vault-gate/vault-gate.component.ts @@ -0,0 +1,96 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + afterNextRender, + computed, + inject, + signal, + viewChild, +} from '@angular/core'; +import { TranslocoPipe } from '@jsverse/transloco'; +import { VaultStore } from '@core/state/vault.store'; + +/** What `create_vault` refuses below, said before the round trip rather than after it. */ +const MINIMUM_LENGTH = 8; + +/** + * 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()); + + /** + * ⚠️ Said here rather than left to the back end: a first launch that answers "too short" + * after a 224 ms derivation reads as the application thinking about it. + */ + protected readonly tooShort = computed( + () => this.passphrase().length > 0 && this.passphrase().length < MINIMUM_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_LENGTH) return false; + + return !this.isCreating() || this.confirmation() === this.passphrase(); + }); + + protected readonly minimumLength = MINIMUM_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-vault-repository.ts b/src/testing/fake-vault-repository.ts new file mode 100644 index 0000000..09c8eae --- /dev/null +++ b/src/testing/fake-vault-repository.ts @@ -0,0 +1,35 @@ +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[] = []; + + async state(): Promise { + return this.answer; + } + + async create(passphrase: string): Promise { + return this.attempt(passphrase); + } + + async unlock(passphrase: string): Promise { + return this.attempt(passphrase); + } + + 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(), From a08165cc434fcca564a7c9d9232d2603f9f60ad1 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Tue, 15 Sep 2026 23:46:21 +0200 Subject: [PATCH 09/17] Let an export be sealed with a phrase of its own The library key never leaves the machine, so a file that does would have travelled in the clear. A protected export carries its own salt and recipe beside a sealed bundle, and its attachments are opened under the library key then resealed under the export's. The prompt sits at the root next to the gate: an export starts from the titlebar and a selection export from the canvas header, and a menu that closes would tear down a prompt it owned. --- e2e/support/app.ts | 32 ++ src-tauri/benches/commands.rs | 13 +- src-tauri/src/error.rs | 9 +- src-tauri/src/lib.rs | 3 +- src-tauri/src/transfer.rs | 47 +- src-tauri/src/transfer/file.rs | 434 ++++++++++++++++-- src-tauri/src/transfer/model.rs | 4 + src-tauri/src/transfer/protect.rs | 162 +++++++ src-tauri/src/vault.rs | 64 ++- src-tauri/src/vault/key.rs | 13 +- src-tauri/tests/transfer.rs | 39 +- src/app/app.component.html | 1 + src/app/app.component.ts | 2 + src/app/core/data/transfer.repository.ts | 27 +- src/app/core/ipc/bindings.ts | 18 +- src/app/core/ipc/ipc.error.ts | 9 + src/app/core/model/vault.model.ts | 7 + .../services/errors/error-notifier.service.ts | 1 + .../core/services/i18n/translations/en.json | 12 + .../core/services/i18n/translations/fr.json | 12 + src/app/core/state/library.store.spec.ts | 163 ++++++- src/app/core/state/library.store.ts | 97 +++- src/app/core/state/vault.store.ts | 7 +- .../passphrase-prompt.component.html | 85 ++++ .../passphrase-prompt.component.scss | 101 ++++ .../passphrase-prompt.component.spec.ts | 204 ++++++++ .../passphrase-prompt.component.ts | 101 ++++ src/app/shared/layout/dialog/dialog.model.ts | 2 +- .../file-menu/file-menu.component.spec.ts | 17 +- src/app/vault-gate/vault-gate.component.ts | 14 +- src/testing/fake-transfer-repository.ts | 43 +- 31 files changed, 1596 insertions(+), 147 deletions(-) create mode 100644 src-tauri/src/transfer/protect.rs create mode 100644 src/app/passphrase-prompt/passphrase-prompt.component.html create mode 100644 src/app/passphrase-prompt/passphrase-prompt.component.scss create mode 100644 src/app/passphrase-prompt/passphrase-prompt.component.spec.ts create mode 100644 src/app/passphrase-prompt/passphrase-prompt.component.ts 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/src-tauri/benches/commands.rs b/src-tauri/benches/commands.rs index 83fa684..ab8c3bc 100644 --- a/src-tauri/benches/commands.rs +++ b/src-tauri/benches/commands.rs @@ -201,13 +201,22 @@ fn disk(c: &mut Criterion) { 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, &attachments).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, mut payload) = file::read(&target).expect("a readable file"); + 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/src/error.rs b/src-tauri/src/error.rs index b694523..0fccec5 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -54,6 +54,10 @@ pub enum StorageError { /// 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, @@ -68,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, @@ -86,6 +90,8 @@ pub enum ErrorCode { WrongPassphrase, /// A command ran before the library was unlocked. Locked, + /// The import needs the phrase the export was protected with. + PassphraseRequired, Storage, } @@ -151,6 +157,7 @@ impl From for AppError { } 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), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f4ccc40..a9e2c33 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -32,7 +32,7 @@ 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::{create_vault, unlock_vault, vault_state}; /// ⚠️ Resolved from the manifest: a relative path writes the file next to whatever the @@ -89,6 +89,7 @@ fn ipc_builder() -> Builder { export_selection, import_notes, share_notes, + export_is_protected, vault_state, create_vault, unlock_vault, diff --git a/src-tauri/src/transfer.rs b/src-tauri/src/transfer.rs index 464923e..ed439f5 100644 --- a/src-tauri/src/transfer.rs +++ b/src-tauri/src/transfer.rs @@ -3,6 +3,7 @@ pub mod bundle; pub mod file; pub mod model; +pub mod protect; use tauri::{AppHandle, State}; @@ -18,18 +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, &attachments::directory(&app)?) + file::write( + &path, + &exported, + &attachments::directory(&app)?, + connection.vault(), + passphrase.as_deref(), + ) } #[tauri::command(async)] @@ -37,18 +43,23 @@ 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, &attachments::directory(&app)?) + 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 @@ -57,12 +68,13 @@ pub fn export_selection( #[specta::specta] pub fn import_notes( path: String, + passphrase: Option, app: AppHandle, db: State<'_, Db>, ) -> Result { model::validate_path(&path)?; - let (imported, mut payload) = file::read(&path)?; + let (imported, mut payload) = file::read(&path, passphrase.as_deref())?; let directory = attachments::directory(&app)?; let mut connection = lock(&db)?; @@ -75,6 +87,15 @@ pub fn import_notes( )?) } +/// 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. #[tauri::command(async)] #[specta::specta] diff --git a/src-tauri/src/transfer/file.rs b/src-tauri/src/transfer/file.rs index ebbb6c5..86f8695 100644 --- a/src-tauri/src/transfer/file.rs +++ b/src-tauri/src/transfer/file.rs @@ -1,10 +1,16 @@ //! Nothing here knows the database. //! -//! The export is a zip: `bundle.json` at the root, one entry per attachment under +//! 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; @@ -16,13 +22,24 @@ 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; 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]; @@ -35,14 +52,41 @@ 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. -pub fn write(path: &str, bundle: &Bundle, attachments: &Path) -> Result { +/// +/// `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); - let stored = match archive(&staged, &json, &bundle.attachments, attachments) { + let stored = match archive( + &staged, + &json, + &bundle.attachments, + attachments, + library, + protection.as_ref(), + ) { Ok(stored) => stored, Err(error) => { let _ = std::fs::remove_file(&staged); @@ -59,30 +103,50 @@ pub fn write(path: &str, bundle: &Bundle, attachments: &Path) -> Result, ) -> Result { let target = File::create(staged).map_err(|error| file_error(&staged.display().to_string(), &error))?; let mut writer = ZipWriter::new(target); - writer - .start_file( - BUNDLE_ENTRY, - SimpleFileOptions::default().compression_method(CompressionMethod::Deflated), - ) - .map_err(|error| zip_error(&error))?; - writer - .write_all(json.as_bytes()) - .map_err(|error| file_error(BUNDLE_ENTRY, &error))?; + // 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))?; + } let mut stored = 0; for record in records { @@ -90,15 +154,19 @@ fn archive( // 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(bytes) = std::fs::read(source.join(&name)) else { + 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}"), - SimpleFileOptions::default().compression_method(CompressionMethod::Stored), - ) + .start_file(format!("{ATTACHMENTS_ENTRY}/{name}"), stored_as_is()) .map_err(|error| zip_error(&error))?; writer .write_all(&bytes) @@ -112,7 +180,11 @@ fn archive( } /// The bundle, and whatever carries the attachment bytes that belong with it. -pub fn read(path: &str) -> Result<(IncomingBundle, Payload), AppError> { +/// +/// ⚠️ 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 mut file = File::open(path).map_err(|error| file_error(path, &error))?; let mut magic = [0u8; 4]; @@ -128,34 +200,110 @@ pub fn read(path: &str) -> Result<(IncomingBundle, Payload), AppError> { let mut archive = ZipArchive::new(file).map_err(|error| StorageError::ImportFormat(error.to_string()))?; - let json = { - let mut entry = archive - .by_name(BUNDLE_ENTRY) - .map_err(|_| StorageError::ImportFormat(format!("no {BUNDLE_ENTRY} in the archive")))?; - let mut json = String::new(); - entry - .read_to_string(&mut json) - .map_err(|error| file_error(BUNDLE_ENTRY, &error))?; - json + 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(Box::new(archive)), + Payload::Archive { + archive: Box::new(archive), + vault: Some(vault), + }, )) } +/// Whether a file will want a phrase, asked without one so an interface can prompt. +pub fn is_protected(path: &str) -> Result { + match read(path, None) { + Ok(_) => Ok(false), + Err(error) if error.code == crate::error::ErrorCode::PassphraseRequired => Ok(true), + Err(error) => Err(error), + } +} + +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(Box>), + 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. + /// `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) = self else { + let Self::Archive { archive, vault } = self else { return None; }; @@ -165,7 +313,10 @@ impl Payload { let mut bytes = Vec::new(); entry.read_to_end(&mut bytes).ok()?; - Some(bytes) + match vault { + Some(vault) => vault.open_bytes(&bytes).ok(), + None => Some(bytes), + } } } @@ -188,6 +339,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 { @@ -221,6 +386,12 @@ 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() { @@ -249,7 +420,14 @@ mod tests { let directory = scratch(); let target = directory.join("library.devbox"); - write(&target.to_string_lossy(), &bundle(), &directory).unwrap(); + write( + &target.to_string_lossy(), + &bundle(), + &directory, + &library(), + None, + ) + .unwrap(); let left: Vec<_> = std::fs::read_dir(&directory) .unwrap() @@ -267,7 +445,14 @@ mod tests { let target = directory.join("library.devbox"); std::fs::write(&target, "previous export, longer than what replaces it").unwrap(); - write(&target.to_string_lossy(), &bundle(), &directory).unwrap(); + write( + &target.to_string_lossy(), + &bundle(), + &directory, + &library(), + None, + ) + .unwrap(); let written = std::fs::read(&target).unwrap(); assert_eq!(written[..4], ZIP_MAGIC); @@ -278,7 +463,14 @@ mod tests { fn an_export_to_an_unreachable_directory_reports_rather_than_panicking() { let directory = scratch(); - let error = write("/no/such/directory/library.devbox", &bundle(), &directory).unwrap_err(); + 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(); @@ -289,8 +481,15 @@ mod tests { let directory = scratch(); let target = directory.join("library.devbox"); - write(&target.to_string_lossy(), &bundle(), &directory).unwrap(); - let (read_back, _) = read(&target.to_string_lossy()).unwrap(); + 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"); @@ -302,26 +501,33 @@ mod tests { fn an_attachment_travels_with_its_note() { let directory = scratch(); let target = directory.join("library.devbox"); - let attachment = record(); - std::fs::write(directory.join(attachment.stored_name()), b"\x89PNG").unwrap(); + let vault = library(); + seal_beside(&directory, &vault, b"\x89PNG"); let mut exported = bundle(); - exported.attachments = vec![attachment.clone()]; - let report = write(&target.to_string_lossy(), &exported, &directory).unwrap(); + 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()).unwrap(); + let (read_back, mut payload) = read(&target.to_string_lossy(), None).unwrap(); assert_eq!(read_back.bundle.attachments.len(), 1); assert_eq!( - payload.take(&attachment.stored_name()), + 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: the note is what - /// matters, and the import reports the gap. + /// ⚠️ 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(); @@ -329,7 +535,14 @@ mod tests { let mut exported = bundle(); exported.attachments = vec![record()]; - let report = write(&target.to_string_lossy(), &exported, &directory).unwrap(); + let report = write( + &target.to_string_lossy(), + &exported, + &directory, + &library(), + None, + ) + .unwrap(); assert_eq!(report.attachments, 0); assert_eq!(report.notes, 1); @@ -344,10 +557,133 @@ mod tests { let json = serde_json::to_string_pretty(&bundle()).unwrap(); std::fs::write(&target, json).unwrap(); - let (read_back, payload) = 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!(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(); + } + + /// 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 b8a7deb..10f609f 100644 --- a/src-tauri/src/transfer/model.rs +++ b/src-tauri/src/transfer/model.rs @@ -45,6 +45,10 @@ pub struct ExportReport { /// 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 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 index af029f0..058d461 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -14,6 +14,8 @@ 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; @@ -56,19 +58,38 @@ pub fn vault_state(app: AppHandle, db: State<'_, Db>) -> Result) -> Result<(), AppError> { - validate(&passphrase)?; +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())?; + let vault = file::create(&directory, passphrase, Cost::default())?; + + open_library(app, db, vault)?; - adopt(&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)?; - // ⚠️ After `adopt`, which is what opened the database: a library created a moment ago - // has nothing to seal, and one that predates the passphrase has everything. - 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 @@ -119,16 +140,31 @@ fn seal_what_was_there(app: &AppHandle, db: &State<'_, Db>) -> Result<(), AppErr /// main thread would freeze the window over every attempt. #[tauri::command(async)] #[specta::specta] -pub fn unlock_vault(passphrase: String, app: AppHandle, db: State<'_, Db>) -> Result<(), AppError> { +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)?; + let vault = file::unlock(&directory, passphrase)?; + + open_library(app, db, vault)?; + crate::sweep(app); - adopt(&app, &db, vault) + Ok(()) } -/// Opens the library under the key, hands it to the rest of the application, and only -/// then runs what a startup used to run before there was anything to unlock. -fn adopt(app: &AppHandle, db: &State<'_, Db>, vault: key::Vault) -> Result<(), AppError> { +/// 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)?; @@ -141,8 +177,6 @@ fn adopt(app: &AppHandle, db: &State<'_, Db>, vault: key::Vault) -> Result<(), A } } - crate::sweep(app); - Ok(()) } diff --git a/src-tauri/src/vault/key.rs b/src-tauri/src/vault/key.rs index a20cee2..1dfd744 100644 --- a/src-tauri/src/vault/key.rs +++ b/src-tauri/src/vault/key.rs @@ -35,11 +35,18 @@ pub struct Cost { } impl Default for Cost { - /// OWASP's Argon2id floor at the time of writing: 19 MiB, two passes, one lane. + /// ⚠️ 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: 19 * 1024, - passes: 2, + memory_kib: 64 * 1024, + passes: 3, lanes: 1, } } diff --git a/src-tauri/tests/transfer.rs b/src-tauri/tests/transfer.rs index fa6d76e..9b5421f 100644 --- a/src-tauri/tests/transfer.rs +++ b/src-tauri/tests/transfer.rs @@ -243,6 +243,13 @@ fn a_degraded_note_is_counted_once_and_not_again_on_a_second_import() { 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, @@ -289,7 +296,7 @@ fn an_attachment_travels_with_the_library() { let mut source = library(); let note_id = notes::all(&mut source, None).unwrap()[0].id.clone(); let record = capture(¬e_id); - std::fs::write(source_files.join(record.stored_name()), b"\x89PNG").unwrap(); + seal_beside(&source_files, &source, &record, b"\x89PNG"); attach(&mut source, &record).unwrap(); let target_path = directory @@ -297,11 +304,11 @@ fn an_attachment_travels_with_the_library() { .to_string_lossy() .to_string(); let packed = exported(&mut source); - let written = file::write(&target_path, &packed, &source_files).unwrap(); + 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).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); @@ -326,20 +333,27 @@ fn importing_the_same_archive_twice_restores_the_attachment_once() { let mut source = library(); let note_id = notes::all(&mut source, None).unwrap()[0].id.clone(); let record = capture(¬e_id); - std::fs::write(files.join(record.stored_name()), b"\x89PNG").unwrap(); + 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).unwrap(); + 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).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).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); @@ -366,11 +380,18 @@ fn an_attachment_the_archive_does_not_carry_is_reported() { .join("library.devbox") .to_string_lossy() .to_string(); - let written = file::write(&target_path, &exported(&mut source), &files).unwrap(); + 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).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); diff --git a/src/app/app.component.html b/src/app/app.component.html index 0c6fa6e..c92bd21 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -13,4 +13,5 @@ } + diff --git a/src/app/app.component.ts b/src/app/app.component.ts index c12ab03..12d39dd 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -4,6 +4,7 @@ import { ErrorBannerComponent } from '@banners/error-banner/error-banner.compone 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'; @@ -16,6 +17,7 @@ import { VaultStore } from '@core/state/vault.store'; RouterOutlet, UpdatePromptComponent, VaultGateComponent, + PassphrasePromptComponent, ], templateUrl: './app.component.html', styleUrl: './app.component.scss', 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/ipc/bindings.ts b/src/app/core/ipc/bindings.ts index b7bdf08..155d74b 100644 --- a/src/app/core/ipc/bindings.ts +++ b/src/app/core/ipc/bindings.ts @@ -67,15 +67,17 @@ export const commands = { 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 @@ -183,7 +185,9 @@ export type ErrorCode = "noteNotFound" | "spaceNotFound" | "duplicateSpaceName" */ "wrongPassphrase" | /** A command ran before the library was unlocked. */ -"locked" | "storage"; +"locked" | +/** The import needs the phrase the export was protected with. */ +"passphraseRequired" | "storage"; export type ExportReport = { notes: number, @@ -193,6 +197,12 @@ export type ExportReport = { * 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, }; /** diff --git a/src/app/core/ipc/ipc.error.ts b/src/app/core/ipc/ipc.error.ts index 67ccfd5..a6d0480 100644 --- a/src/app/core/ipc/ipc.error.ts +++ b/src/app/core/ipc/ipc.error.ts @@ -24,6 +24,7 @@ const IPC_ERROR_CODES: Record = { storageUnavailable: true, wrongPassphrase: true, locked: true, + passphraseRequired: true, storage: true, }; @@ -64,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 index 5671fe6..da1aa51 100644 --- a/src/app/core/model/vault.model.ts +++ b/src/app/core/model/vault.model.ts @@ -1,2 +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/errors/error-notifier.service.ts b/src/app/core/services/errors/error-notifier.service.ts index 9418005..bc56502 100644 --- a/src/app/core/services/errors/error-notifier.service.ts +++ b/src/app/core/services/errors/error-notifier.service.ts @@ -27,6 +27,7 @@ const CODE_KEYS: Record = { 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 4448e7a..2d2e59f 100644 --- a/src/app/core/services/i18n/translations/en.json +++ b/src/app/core/services/i18n/translations/en.json @@ -152,6 +152,7 @@ "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.", @@ -329,6 +330,17 @@ "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", "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.", diff --git a/src/app/core/services/i18n/translations/fr.json b/src/app/core/services/i18n/translations/fr.json index 2ab5f52..a11cd7f 100644 --- a/src/app/core/services/i18n/translations/fr.json +++ b/src/app/core/services/i18n/translations/fr.json @@ -152,6 +152,7 @@ "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.", @@ -329,6 +330,17 @@ "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", "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.", diff --git a/src/app/core/state/library.store.spec.ts b/src/app/core/state/library.store.spec.ts index be2edb9..9139d24 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,28 @@ function createStore(): Harness { }; } +/** The prompt is a promise the store is waiting on; nothing advances until it is given one. */ +async function answer(harness: Harness, ...answers: PassphraseAnswer[]): Promise { + for (const given of answers) { + for (let turn = 0; turn < 50 && harness.store.passphraseRequest() === null; turn++) { + await Promise.resolve(); + } + + 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; @@ -148,13 +173,79 @@ 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' }); + for (let turn = 0; turn < 50 && harness.store.passphraseRequest() === null; turn++) { + await Promise.resolve(); + } + + 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.devbox'); }); @@ -162,10 +253,14 @@ describe('LibraryStore', () => { 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(); }); @@ -174,6 +269,7 @@ describe('LibraryStore', () => { await harness.store.export(null, NOW); + expect(harness.store.passphraseRequest()).toBeNull(); expect(harness.repository.exportedTo).toBeNull(); expect(harness.status.status()).toBeNull(); }); @@ -181,7 +277,7 @@ describe('LibraryStore', () => { it('says how many notes went out, and where', async () => { harness.dialog.savePath = 'C:/backups/devbox.devbox'; - await harness.store.export(null, NOW); + await exportEverything(harness); expect(harness.status.status()).toEqual({ key: 'file.exported', @@ -193,9 +289,9 @@ describe('LibraryStore', () => { * 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 }; + harness.repository.exportReport = { notes: 3, spaces: 1, attachments: 2, protected: false }; - await harness.store.export(null, NOW); + await exportEverything(harness); expect(harness.status.status()).toEqual({ key: 'file.exportedWithAttachments', @@ -205,9 +301,9 @@ describe('LibraryStore', () => { it('does not pretend to have exported an empty library', async () => { harness.dialog.savePath = 'C:/out.json'; - harness.repository.exportReport = { notes: 0, spaces: 0, attachments: 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'); }); @@ -215,7 +311,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' }); @@ -227,6 +325,49 @@ 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); + for (let turn = 0; turn < 50 && harness.store.passphraseRequest() === null; turn++) { + await Promise.resolve(); + } + + 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(); + }); }); describe('copy as Markdown', () => { diff --git a/src/app/core/state/library.store.ts b/src/app/core/state/library.store.ts index 6fcda09..4b1f42c 100644 --- a/src/app/core/state/library.store.ts +++ b/src/app/core/state/library.store.ts @@ -4,6 +4,7 @@ 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 { 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'; @@ -12,6 +13,23 @@ function defaultFileName(now: Date): string { 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. The rest is a ladder of what most deserves saying: an @@ -26,6 +44,15 @@ function importedKey(report: ImportReport): string { 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.attachments > 0 ? 'file.exportedWithAttachments' : 'file.exported'; +} + function fileNameOf(path: string): string { return path.split(/[/\\]/).pop() ?? path; } @@ -44,16 +71,24 @@ export class LibraryStore { private readonly revision = inject(NotesRevision); private readonly _isBusy = signal(false); + private readonly _passphraseRequest = signal(null); 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(); + + 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.read(path, await this.repository.isProtected(path)); + if (report === null) return false; + const params = { notes: String(report.notesImported), skipped: String(report.notesSkipped), @@ -74,13 +109,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. */ @@ -99,6 +134,46 @@ export class LibraryStore { }, 'errors.shareFailed'); } + /** The prompt's only way back in. Answering is what dismisses it. */ + answerPassphrase(answer: PassphraseAnswer): void { + const resolve = this.pending; + this.pending = null; + this._passphraseRequest.set(null); + resolve?.(answer); + } + + private ask(request: PassphraseRequest): Promise { + return new Promise((resolve) => { + this.pending = resolve; + this._passphraseRequest.set(request); + }); + } + + /** + * ⚠️ 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; @@ -106,12 +181,22 @@ export class LibraryStore { return false; } - private async write(action: (path: string) => Promise, 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; + const answer = await this.ask({ purpose: 'protect', fileName: fileNameOf(path), refused: false }); + if (answer.kind === 'cancelled') return; + await this.run(async () => { - const report = await action(path); + const report = await action(path, answer.kind === 'phrase' ? answer.value : null); if (report.notes === 0) { this.status.notify({ key: 'file.emptyLibrary' }); @@ -121,7 +206,7 @@ export class LibraryStore { // The file name is part of the report: an export whose landing place is unknown // is no use. this.status.notify({ - key: report.attachments > 0 ? 'file.exportedWithAttachments' : 'file.exported', + key: exportedKey(report), params: { notes: String(report.notes), attachments: String(report.attachments), diff --git a/src/app/core/state/vault.store.ts b/src/app/core/state/vault.store.ts index b2e8cfc..763a3cf 100644 --- a/src/app/core/state/vault.store.ts +++ b/src/app/core/state/vault.store.ts @@ -1,5 +1,6 @@ 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'; @@ -62,7 +63,7 @@ export class VaultStore { this._state.set('unlocked'); return true; } catch (error) { - if (isWrongPassphrase(error)) { + if (hasErrorCode(error, 'wrongPassphrase')) { this._refused.set(true); return false; } @@ -74,7 +75,3 @@ export class VaultStore { } } } - -function isWrongPassphrase(error: unknown): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === 'wrongPassphrase'; -} 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..6989d83 --- /dev/null +++ b/src/app/passphrase-prompt/passphrase-prompt.component.html @@ -0,0 +1,85 @@ +@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..4ee82ba --- /dev/null +++ b/src/app/passphrase-prompt/passphrase-prompt.component.ts @@ -0,0 +1,101 @@ +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 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.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 { + this.clear(); + this.library.answerPassphrase({ kind: 'none' }); + } + + protected cancel(): void { + 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/vault-gate/vault-gate.component.ts b/src/app/vault-gate/vault-gate.component.ts index ce6e219..b3efba6 100644 --- a/src/app/vault-gate/vault-gate.component.ts +++ b/src/app/vault-gate/vault-gate.component.ts @@ -9,11 +9,9 @@ import { 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'; -/** What `create_vault` refuses below, said before the round trip rather than after it. */ -const MINIMUM_LENGTH = 8; - /** * The screen that stands in front of everything until the library is open. * @@ -36,12 +34,8 @@ export class VaultGateComponent { protected readonly isCreating = computed(() => this.vault.needsCreating()); - /** - * ⚠️ Said here rather than left to the back end: a first launch that answers "too short" - * after a 224 ms derivation reads as the application thinking about it. - */ protected readonly tooShort = computed( - () => this.passphrase().length > 0 && this.passphrase().length < MINIMUM_LENGTH, + () => this.passphrase().length > 0 && this.passphrase().length < MINIMUM_PASSPHRASE_LENGTH, ); protected readonly mismatched = computed( @@ -49,12 +43,12 @@ export class VaultGateComponent { ); protected readonly canSubmit = computed(() => { - if (this.vault.isWorking() || this.passphrase().length < MINIMUM_LENGTH) return false; + if (this.vault.isWorking() || this.passphrase().length < MINIMUM_PASSPHRASE_LENGTH) return false; return !this.isCreating() || this.confirmation() === this.passphrase(); }); - protected readonly minimumLength = MINIMUM_LENGTH; + protected readonly minimumLength = MINIMUM_PASSPHRASE_LENGTH; private readonly passphraseField = viewChild>('passphraseField'); diff --git a/src/testing/fake-transfer-repository.ts b/src/testing/fake-transfer-repository.ts index 0940912..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,12 +8,20 @@ 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; From 798ac0534f23d9d6ed4fa023d9a87d86c8a74f30 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Tue, 15 Sep 2026 23:49:38 +0200 Subject: [PATCH 10/17] Sweep the decrypted copies on the way out, and write the security model down Opening an attachment has to hand a plaintext file to the desktop, so the copies now go at Exit as well as at launch: one another application still holds stays, which is the right outcome, and a crash is what the launch sweep was always for. README gains the section a user needs before typing a passphrase they cannot recover; docs/architecture.md gains the measurements behind the design, what is sealed and what deliberately is not, and the export's own key. --- README.md | 59 +++++++++++++---- docs/architecture.md | 97 +++++++++++++++++++++++++++- e2e/specs/10-library-transfer.e2e.ts | 30 +++++++++ e2e/support/bridge.ts | 10 +-- src-tauri/src/attachments/sealed.rs | 7 +- src-tauri/src/lib.rs | 14 +++- 6 files changed, 193 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index b65760e..2f46834 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 `.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 | +| 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 | +| **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 to a 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 5babb03..c86741a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1147,6 +1147,23 @@ about samples nobody asked for would only add noise. 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 @@ -1593,6 +1610,83 @@ 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. + +### 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 a check value. Opening that check value is what tells a wrong passphrase from a + corrupt file. Written staged-then-renamed, and `create` refuses to overwrite one. +- **`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. + +### 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 `temp/devbox-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. + ## Persistence (Rust) Storage is **SQLite**, queried through **Diesel** and embedded via `libsqlite3-sys` with the @@ -1612,7 +1706,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/10-library-transfer.e2e.ts b/e2e/specs/10-library-transfer.e2e.ts index 02ead1a..5113a08 100644 --- a/e2e/specs/10-library-transfer.e2e.ts +++ b/e2e/specs/10-library-transfer.e2e.ts @@ -15,6 +15,13 @@ 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-')); @@ -132,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/support/bridge.ts b/e2e/support/bridge.ts index 9bb2d92..cd2a031 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,11 @@ 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 }), } as const; /** A `NoteDraft` is exhaustive on the wire; a scenario cares about two or three fields. */ diff --git a/src-tauri/src/attachments/sealed.rs b/src-tauri/src/attachments/sealed.rs index d841488..deb83f9 100644 --- a/src-tauri/src/attachments/sealed.rs +++ b/src-tauri/src/attachments/sealed.rs @@ -65,9 +65,10 @@ pub fn plaintext_directory() -> std::path::PathBuf { std::env::temp_dir().join(PLAINTEXT_DIRECTORY) } -/// ⚠️ Run at every launch, beside the orphan-file sweep. A copy handed to another -/// application cannot be deleted while that application holds it, and a crash deletes -/// nothing at all — so the guarantee is "gone by the next launch", not "gone on close". +/// ⚠️ 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() { let directory = plaintext_directory(); if let Err(error) = std::fs::remove_dir_all(&directory) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a9e2c33..a83624a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -251,8 +251,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(); + } + }); } #[cfg(test)] From 49c1a4e37ddefb5abea9d4903691c71b7f1c19e3 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Wed, 16 Sep 2026 00:08:06 +0200 Subject: [PATCH 11/17] Keep the File menu out of the titlebar until the library is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The titlebar renders in front of the gate, and the File menu injects SpacesStore — so a locked launch queried a database nobody had opened and raised a banner over the passphrase field. Every entry acts on the library, so the menu waits for it; the About menu reads nothing of it and stays. The first-launch scenario passes the gate before asking anything of the library, and reports what the banner said rather than that there was one. --- e2e/specs/01-first-launch.e2e.ts | 10 +++++- src/app/titlebar/titlebar.component.html | 9 +++++- src/app/titlebar/titlebar.component.spec.ts | 36 +++++++++++++++++---- src/app/titlebar/titlebar.component.ts | 2 ++ 4 files changed, 48 insertions(+), 9 deletions(-) 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/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); } From d77a41d28368100eca6d40f7c8fc2a4d81e77d73 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Wed, 16 Sep 2026 00:09:33 +0200 Subject: [PATCH 12/17] Say what the encryption protects against, and what it does not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The threat is a file read at rest. A sealed value is authenticated on its own and not bound to the row it sits in, and the key lives in memory for the length of the session — both are written down rather than left to be discovered, next to the two figures that had gone stale. --- CLAUDE.md | 2 ++ docs/architecture.md | 16 ++++++++++++++++ src-tauri/src/vault.rs | 8 ++++---- src-tauri/src/vault/file.rs | 2 +- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e63e7a7..3de8945 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(…)`. 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 `temp/devbox-open/`, 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/docs/architecture.md b/docs/architecture.md index c86741a..378e2dc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1642,6 +1642,22 @@ the one thing a reader of the raw file learns. 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, diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index 058d461..fb017a1 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -1,8 +1,8 @@ //! 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. +//! ⚠️ 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)] @@ -136,7 +136,7 @@ fn seal_what_was_there(app: &AppHandle, db: &State<'_, Db>) -> Result<(), AppErr } /// ⚠️ Deliberately slow: deriving the key is the whole defence against someone trying -/// passphrases against a copied file. It is `(async)` for the same reason — 224 ms on the +/// 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] diff --git a/src-tauri/src/vault/file.rs b/src-tauri/src/vault/file.rs index 4f12740..278c9f1 100644 --- a/src-tauri/src/vault/file.rs +++ b/src-tauri/src/vault/file.rs @@ -151,7 +151,7 @@ fn write_atomically(path: &Path, file: &KeyFile) -> Result<(), StorageError> { mod tests { use super::*; - /// ⚠️ Cheap parameters: the real ones cost 224 ms a derivation, and these tests derive + /// ⚠️ 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 { From a9af220b8cdc00986af9838d0b325ba2d86f8ba6 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Wed, 16 Sep 2026 00:11:26 +0200 Subject: [PATCH 13/17] Seal the values of a variable shared by the whole corpus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration sealed global_placeholders.value and the store did not — so a new library wrote a host name or a token in the clear, and a migrated one read base64 back into the snippet it was filling. The name stays readable, like a tag: it is the key the row is found by. The test that greps the file now asserts both halves of that. --- src-tauri/src/notes/store.rs | 21 +++++++++++-------- src-tauri/src/vault/key.rs | 39 ++++++------------------------------ src-tauri/tests/notes.rs | 20 ++++++++++++++++-- 3 files changed, 37 insertions(+), 43 deletions(-) diff --git a/src-tauri/src/notes/store.rs b/src-tauri/src/notes/store.rs index 0902217..2c305c2 100644 --- a/src-tauri/src/notes/store.rs +++ b/src-tauri/src/notes/store.rs @@ -123,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 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.db())? + .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 Library, values: &BTreeMap, ) -> Result<(), StorageError> { - connection.transaction(|connection, _vault| { + 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)?; diff --git a/src-tauri/src/vault/key.rs b/src-tauri/src/vault/key.rs index 1dfd744..5c596c4 100644 --- a/src-tauri/src/vault/key.rs +++ b/src-tauri/src/vault/key.rs @@ -108,7 +108,7 @@ impl Vault { pub fn open_bytes(&self, sealed: &[u8]) -> Result, StorageError> { if sealed.len() <= NONCE_BYTES { return Err(StorageError::Vault( - "a file is too short to be sealed".to_string(), + "too short to have been sealed".to_string(), )); } @@ -117,26 +117,13 @@ impl Vault { self.cipher() .decrypt(nonce.into(), body) - .map_err(|_| StorageError::Vault("a file would not open".to_string())) + .map_err(|_| StorageError::Vault("it would not open".to_string())) } - /// `nonce ++ ciphertext ++ tag`, base64. The columns are TEXT, so what goes in one has - /// to survive being read back as a 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 { - 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.as_bytes()) - .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(BASE64.encode(joined)) + 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 @@ -147,21 +134,7 @@ impl Vault { .decode(sealed) .map_err(|_| StorageError::Vault("a value is not base64".to_string()))?; - if raw.len() <= NONCE_BYTES { - return Err(StorageError::Vault( - "a value is too short to be sealed".to_string(), - )); - } - - let (nonce, body) = raw.split_at(NONCE_BYTES); - let nonce: &[u8; NONCE_BYTES] = nonce.try_into().expect("a checked length"); - - let opened = self - .cipher() - .decrypt(nonce.into(), body) - .map_err(|_| StorageError::Vault("a value would not open".to_string()))?; - - String::from_utf8(opened).map_err(|_| { + String::from_utf8(self.open_bytes(&raw)?).map_err(|_| { StorageError::Vault("a value opened to something that is not text".to_string()) }) } diff --git a/src-tauri/tests/notes.rs b/src-tauri/tests/notes.rs index 3417a56..243db6a 100644 --- a/src-tauri/tests/notes.rs +++ b/src-tauri/tests/notes.rs @@ -1736,6 +1736,13 @@ fn a_note_is_not_readable_in_the_file_it_was_written_to() { 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(); @@ -1750,12 +1757,21 @@ fn a_note_is_not_readable_in_the_file_it_was_written_to() { !haystack.contains("Secrets"), "the space name is in the clear" ); - // ⚠️ Tags are deliberately not sealed: the facet and the filter both touch them in - // SQL. This asserts the decision rather than an accident. + 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(); } From f2d2f1d1a3a38b7d59253ca266856bb4f04a5190 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Wed, 16 Sep 2026 00:12:44 +0200 Subject: [PATCH 14/17] Keep the decrypted copies in the profile, not in the shared temp directory A decrypted attachment in the OS temporary directory is readable by every account on the machine, and a directory another user created there first would be theirs. app_data_dir()/open/ is inside the profile, and the sweeps reach it exactly as before. --- CLAUDE.md | 2 +- README.md | 8 ++++---- docs/architecture.md | 6 ++++-- src-tauri/src/attachments.rs | 2 +- src-tauri/src/attachments/sealed.rs | 32 +++++++++++++++++++++-------- src-tauri/src/lib.rs | 6 +++--- 6 files changed, 36 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3de8945..8ae599d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,7 +129,7 @@ These are the non-obvious constraints; the rest of the architecture is in `docs/ - **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(…)`. 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 `temp/devbox-open/`, swept at `RunEvent::Exit` and at every launch: the program that opens a document reads it from disk, and one click is the requirement. +- **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(…)`. 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 2f46834..c523931 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,10 @@ What is sealed on disk: note titles, bodies and sources, checklist items, space ⚠️ **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 to a 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. +⚠️ **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 diff --git a/docs/architecture.md b/docs/architecture.md index 378e2dc..1761b04 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1698,10 +1698,12 @@ on the way out: `read_attachment` decrypts into the `data:` URI the preview alre 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 `temp/devbox-open/` +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. +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) diff --git a/src-tauri/src/attachments.rs b/src-tauri/src/attachments.rs index 427bdca..ba0b705 100644 --- a/src-tauri/src/attachments.rs +++ b/src-tauri/src/attachments.rs @@ -217,7 +217,7 @@ pub fn open_attachment(id: String, app: AppHandle, db: State<'_, Db>) -> Result< sealed::read_sealed(connection.vault(), &stored)? }; - let directory = sealed::plaintext_directory(); + let directory = sealed::plaintext_directory(&app)?; std::fs::create_dir_all(&directory) .map_err(|error| file_error("a directory for decrypted copies", &error))?; diff --git a/src-tauri/src/attachments/sealed.rs b/src-tauri/src/attachments/sealed.rs index deb83f9..543ff8c 100644 --- a/src-tauri/src/attachments/sealed.rs +++ b/src-tauri/src/attachments/sealed.rs @@ -5,18 +5,20 @@ //! file was not tampered with, and the 10 MiB cap on an attachment is what keeps it //! bounded. -use std::path::Path; +use std::path::{Path, PathBuf}; + +use tauri::{AppHandle, Manager}; use crate::error::StorageError; use crate::vault::key::Vault; -/// What `open_externally` writes into, under the OS temporary directory. +/// 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 at every launch, so the copy outlives the session at -/// worst, and the README says so plainly. -const PLAINTEXT_DIRECTORY: &str = "devbox-open"; +/// [`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) @@ -61,16 +63,28 @@ pub fn seal_in_place(vault: &Vault, path: &Path) -> Result<(), StorageError> { } /// Where a decrypted copy goes so the desktop can open it. -pub fn plaintext_directory() -> std::path::PathBuf { - std::env::temp_dir().join(PLAINTEXT_DIRECTORY) +/// +/// ⚠️ 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() { - let directory = plaintext_directory(); +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 { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a83624a..ecc0b17 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -213,7 +213,7 @@ pub(crate) fn sweep(handle: &tauri::AppHandle) { // ⚠️ 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(); + attachments::sealed::sweep_plaintext(handle); } /// ⚠️ Both are refused when there is no tray to find the window in (see `desktop`). @@ -253,14 +253,14 @@ pub fn run() { .invoke_handler(builder.invoke_handler()) .build(tauri::generate_context!()) .expect("error while launching the Tauri application") - .run(|_handle, event| { + .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(); + attachments::sealed::sweep_plaintext(handle); } }); } From 81ac00c11eb840721ff141ce04b9a425ad3df776 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Wed, 16 Sep 2026 00:14:44 +0200 Subject: [PATCH 15/17] Leave the prompt on screen while the phrase is being used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deriving a key takes about a second. The dialog used to close on submit and come back carrying "wrong passphrase", which reads as a fault rather than as an answer — so it stays, disabled and saying it is working, until the operation either asks again or ends. The gate had the matching slip: a first launch said "Unlocking…" while it was sealing a library for the first time. --- src/app/core/ipc/bindings.ts | 2 +- .../core/services/i18n/translations/en.json | 4 +- .../core/services/i18n/translations/fr.json | 4 +- src/app/core/state/library.store.spec.ts | 40 +++++++-- src/app/core/state/library.store.ts | 90 ++++++++++++++----- .../passphrase-prompt.component.html | 23 ++++- .../passphrase-prompt.component.ts | 8 ++ src/app/vault-gate/vault-gate.component.html | 9 +- 8 files changed, 140 insertions(+), 40 deletions(-) diff --git a/src/app/core/ipc/bindings.ts b/src/app/core/ipc/bindings.ts index 155d74b..62cc13c 100644 --- a/src/app/core/ipc/bindings.ts +++ b/src/app/core/ipc/bindings.ts @@ -86,7 +86,7 @@ export const commands = { 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 — 224 ms on the + * 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 })), diff --git a/src/app/core/services/i18n/translations/en.json b/src/app/core/services/i18n/translations/en.json index 2d2e59f..2b767bc 100644 --- a/src/app/core/services/i18n/translations/en.json +++ b/src/app/core/services/i18n/translations/en.json @@ -341,6 +341,7 @@ "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.", @@ -513,6 +514,7 @@ "mismatched": "The two entries differ.", "createAction": "Protect and continue", "unlockAction": "Unlock", - "working": "Unlocking…" + "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 a11cd7f..60b8ea5 100644 --- a/src/app/core/services/i18n/translations/fr.json +++ b/src/app/core/services/i18n/translations/fr.json @@ -341,6 +341,7 @@ "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.", @@ -513,6 +514,7 @@ "mismatched": "Les deux saisies diffèrent.", "createAction": "Protéger et continuer", "unlockAction": "Déverrouiller", - "working": "Déverrouillage…" + "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 9139d24..55d1cec 100644 --- a/src/app/core/state/library.store.spec.ts +++ b/src/app/core/state/library.store.spec.ts @@ -42,11 +42,16 @@ 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) { - for (let turn = 0; turn < 50 && harness.store.passphraseRequest() === null; turn++) { - await Promise.resolve(); - } + await asking(harness); expect(harness.store.passphraseRequest()).not.toBeNull(); harness.store.answerPassphrase(given); @@ -217,9 +222,7 @@ describe('LibraryStore', () => { const done = harness.store.import(); await answer(harness, { kind: 'phrase', value: 'a typo' }); - for (let turn = 0; turn < 50 && harness.store.passphraseRequest() === null; turn++) { - await Promise.resolve(); - } + await asking(harness); expect(harness.store.passphraseRequest()).toMatchObject({ purpose: 'unlock', refused: true }); @@ -344,9 +347,7 @@ describe('LibraryStore', () => { harness.dialog.savePath = 'C:/backups/devbox.devbox'; const done = harness.store.export(null, NOW); - for (let turn = 0; turn < 50 && harness.store.passphraseRequest() === null; turn++) { - await Promise.resolve(); - } + await asking(harness); expect(harness.store.passphraseRequest()).toEqual({ purpose: 'protect', @@ -370,6 +371,27 @@ describe('LibraryStore', () => { }); }); + /** ⚠️ 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', () => { it('asks for a selection rather than copying everything', async () => { await harness.store.copyAsMarkdown([]); diff --git a/src/app/core/state/library.store.ts b/src/app/core/state/library.store.ts index 4b1f42c..60ea735 100644 --- a/src/app/core/state/library.store.ts +++ b/src/app/core/state/library.store.ts @@ -72,12 +72,17 @@ export class LibraryStore { 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. */ @@ -86,7 +91,7 @@ export class LibraryStore { if (path === null) return false; return this.run(async () => { - const report = await this.read(path, await this.repository.isProtected(path)); + const report = await this.readWithPrompt(path); if (report === null) return false; const params = { @@ -134,21 +139,51 @@ export class LibraryStore { }, 'errors.shareFailed'); } - /** The prompt's only way back in. Answering is what dismisses it. */ + /** + * 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; - this._passphraseRequest.set(null); - resolve?.(answer); + 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 @@ -196,27 +231,40 @@ export class LibraryStore { if (answer.kind === 'cancelled') return; await this.run(async () => { - const report = await action(path, answer.kind === 'phrase' ? answer.value : null); - - if (report.notes === 0) { - this.status.notify({ key: 'file.emptyLibrary' }); - return false; + 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: exportedKey(report), - params: { - notes: String(report.notes), - attachments: String(report.attachments), - 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/passphrase-prompt/passphrase-prompt.component.html b/src/app/passphrase-prompt/passphrase-prompt.component.html index 6989d83..ab4f6a7 100644 --- a/src/app/passphrase-prompt/passphrase-prompt.component.html +++ b/src/app/passphrase-prompt/passphrase-prompt.component.html @@ -1,7 +1,12 @@ @let current = request(); @if (current) { - +

{{ (isProtecting() ? 'file.protectTitle' : 'file.unlockTitle') | transloco }} @@ -27,6 +32,7 @@

[value]="passphrase()" [attr.aria-invalid]="refused() || tooShort() ? 'true' : null" [attr.aria-describedby]="refused() || tooShort() ? 'passphrase-prompt-problem' : null" + [disabled]="working()" (input)="onPassphrase($any($event.target).value)" data-testid="passphrase-prompt-field" /> @@ -41,6 +47,7 @@

autocomplete="off" spellcheck="false" [value]="confirmation()" + [disabled]="working()" (input)="onConfirmation($any($event.target).value)" data-testid="passphrase-prompt-confirmation" /> @@ -58,13 +65,20 @@

- @if (isProtecting()) {
diff --git a/src/app/passphrase-prompt/passphrase-prompt.component.ts b/src/app/passphrase-prompt/passphrase-prompt.component.ts index 4ee82ba..5a24d02 100644 --- a/src/app/passphrase-prompt/passphrase-prompt.component.ts +++ b/src/app/passphrase-prompt/passphrase-prompt.component.ts @@ -22,6 +22,7 @@ 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'); @@ -54,6 +55,7 @@ export class PassphrasePromptComponent { ); 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(); @@ -85,11 +87,17 @@ export class PassphrasePromptComponent { /** ⚠️ 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' }); } diff --git a/src/app/vault-gate/vault-gate.component.html b/src/app/vault-gate/vault-gate.component.html index 0d5d325..5b54834 100644 --- a/src/app/vault-gate/vault-gate.component.html +++ b/src/app/vault-gate/vault-gate.component.html @@ -55,10 +55,11 @@

From 4a3eda3c1e92067d57590fad11e66757fcf8ffe6 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Wed, 16 Sep 2026 08:01:59 +0200 Subject: [PATCH 16/17] Let the passphrase be changed, by wrapping the key instead of being it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The key file held a key derived from the phrase, so changing the phrase meant re-encrypting every note and every attachment — an operation with no atomic form across the database and the files, and a half-readable library if it stopped halfway. It now holds a random library key sealed under the phrase. A change rewrites a hundred bytes: fresh salt, same key, new wrapping. Opening that wrapped key is also the check value, so there is one less thing to attack. The cost is worth knowing and is written down: a changed phrase answers a phrase somebody learned, never a key somebody took. Préférences → Sécurité carries it, and refuses before writing when the current phrase is wrong. --- CLAUDE.md | 2 +- README.md | 10 +- docs/architecture.md | 27 ++- e2e/specs/16-passphrase-change.e2e.ts | 55 ++++++ e2e/support/bridge.ts | 2 + e2e/support/profile.ts | 5 + src-tauri/src/lib.rs | 3 +- src-tauri/src/vault.rs | 42 ++++ src-tauri/src/vault/file.rs | 186 ++++++++++++++---- src-tauri/src/vault/key.rs | 35 ++++ src/app/core/data/vault.repository.ts | 4 + src/app/core/ipc/bindings.ts | 9 + .../core/services/i18n/translations/en.json | 14 ++ .../core/services/i18n/translations/fr.json | 14 ++ src/app/core/state/vault.store.ts | 16 +- .../change-passphrase-dialog.component.html | 87 ++++++++ .../change-passphrase-dialog.component.scss | 95 +++++++++ ...change-passphrase-dialog.component.spec.ts | 131 ++++++++++++ .../change-passphrase-dialog.component.ts | 83 ++++++++ .../settings-page.component.html | 22 +++ .../settings-page.component.spec.ts | 19 +- .../settings-page/settings-page.component.ts | 15 +- src/testing/fake-vault-repository.ts | 9 + 23 files changed, 833 insertions(+), 52 deletions(-) create mode 100644 e2e/specs/16-passphrase-change.e2e.ts create mode 100644 src/app/titlebar/file-menu/settings-dialog/change-passphrase-dialog/change-passphrase-dialog.component.html create mode 100644 src/app/titlebar/file-menu/settings-dialog/change-passphrase-dialog/change-passphrase-dialog.component.scss create mode 100644 src/app/titlebar/file-menu/settings-dialog/change-passphrase-dialog/change-passphrase-dialog.component.spec.ts create mode 100644 src/app/titlebar/file-menu/settings-dialog/change-passphrase-dialog/change-passphrase-dialog.component.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8ae599d..28282dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,7 +129,7 @@ These are the non-obvious constraints; the rest of the architecture is in `docs/ - **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(…)`. 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. +- **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 c523931..d322f3a 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,11 @@ 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 | -| **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 | +| 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. diff --git a/docs/architecture.md b/docs/architecture.md index 1761b04..8c8cc71 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1666,8 +1666,19 @@ in this process’s memory for the length of the session, and there is no idle r 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 a check value. Opening that check value is what tells a wrong passphrase from a - corrupt file. Written staged-then-renamed, and `create` refuses to overwrite one. + 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 @@ -1691,6 +1702,18 @@ to answer. 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 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/bridge.ts b/e2e/support/bridge.ts index cd2a031..0133ba5 100644 --- a/e2e/support/bridge.ts +++ b/e2e/support/bridge.ts @@ -76,6 +76,8 @@ export const bridge = { 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/src/lib.rs b/src-tauri/src/lib.rs index ecc0b17..5f6dd2b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -33,7 +33,7 @@ use notes::{ }; use spaces::{create_space, delete_space, list_spaces, pin_space, rename_space}; use transfer::{export_is_protected, export_notes, export_selection, import_notes, share_notes}; -use vault::{create_vault, unlock_vault, vault_state}; +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. @@ -93,6 +93,7 @@ fn ipc_builder() -> Builder { vault_state, create_vault, unlock_vault, + change_passphrase, app_changelog, sync_tray, set_global_shortcuts, diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index fb017a1..03e474c 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -162,6 +162,48 @@ fn unlock_with(passphrase: &str, app: &AppHandle, db: &State<'_, Db>) -> Result< 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> { diff --git a/src-tauri/src/vault/file.rs b/src-tauri/src/vault/file.rs index 278c9f1..022727a 100644 --- a/src-tauri/src/vault/file.rs +++ b/src-tauri/src/vault/file.rs @@ -1,8 +1,9 @@ //! The key file, beside the database. //! -//! ⚠️ Outside the library on purpose: it carries what is needed to derive the key, so it -//! has to be readable before anything can be opened. It holds no key — a salt, the cost -//! the key was derived at, and a known value sealed under it. +//! ⚠️ 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. @@ -19,21 +20,20 @@ use crate::error::StorageError; pub const FILE_NAME: &str = "vault.json"; -/// Bumped when a file written today would stop being readable. -const FORMAT_VERSION: u32 = 1; - -/// What the check value holds. Its content is irrelevant — that it opens is the answer. -const CHECK_PLAINTEXT: &str = "devbox-vault-v1"; +/// 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, - /// ⚠️ A known value sealed under the derived key. Without it a wrong passphrase would - /// unlock happily and the whole library would read as gibberish — and the first write - /// would then seal real notes under the wrong key. - check: String, + /// ⚠️ 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)] @@ -64,26 +64,53 @@ pub fn create(directory: &Path, passphrase: &str, cost: Cost) -> Result 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 { @@ -121,12 +148,12 @@ pub fn unlock(directory: &Path, passphrase: &str) -> Result passes: file.kdf.passes, lanes: file.kdf.lanes, }; - let vault = Vault::derive(passphrase, &salt, cost)?; + 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()))?; - match vault.open(&file.check) { - Ok(check) if check == CHECK_PLAINTEXT => Ok(vault), - _ => Err(StorageError::WrongPassphrase), - } + Vault::unwrapped_with(&wrapping, &wrapped).map_err(|_| StorageError::WrongPassphrase) } /// ⚠️ Staged then renamed. A key file half-written is a library nobody opens again, and @@ -181,8 +208,8 @@ mod tests { std::fs::remove_dir_all(&directory).ok(); } - /// ⚠️ The whole point of the check value: without it this would succeed and every - /// later write would seal real notes under a key nobody can reproduce. + /// ⚠️ 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(); @@ -194,15 +221,100 @@ mod tests { 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_key_and_no_passphrase() { + fn the_key_file_holds_no_passphrase_and_no_key_in_the_clear() { let directory = scratch(); - create(&directory, "correct horse", cheap()).unwrap(); + 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 diff --git a/src-tauri/src/vault/key.rs b/src-tauri/src/vault/key.rs index 5c596c4..d8503e3 100644 --- a/src-tauri/src/vault/key.rs +++ b/src-tauri/src/vault/key.rs @@ -66,6 +66,41 @@ impl std::fmt::Debug for 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 { diff --git a/src/app/core/data/vault.repository.ts b/src/app/core/data/vault.repository.ts index d7884e7..427127b 100644 --- a/src/app/core/data/vault.repository.ts +++ b/src/app/core/data/vault.repository.ts @@ -20,4 +20,8 @@ export class VaultRepository { 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 62cc13c..73ff860 100644 --- a/src/app/core/ipc/bindings.ts +++ b/src/app/core/ipc/bindings.ts @@ -90,6 +90,15 @@ export const commands = { * 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 diff --git a/src/app/core/services/i18n/translations/en.json b/src/app/core/services/i18n/translations/en.json index 2b767bc..6a61267 100644 --- a/src/app/core/services/i18n/translations/en.json +++ b/src/app/core/services/i18n/translations/en.json @@ -174,6 +174,7 @@ "attachmentSaveFailed": "Could not save the attachment to that location.", "variablesLoadFailed": "Could not load 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." }, @@ -429,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", diff --git a/src/app/core/services/i18n/translations/fr.json b/src/app/core/services/i18n/translations/fr.json index 60b8ea5..1597b51 100644 --- a/src/app/core/services/i18n/translations/fr.json +++ b/src/app/core/services/i18n/translations/fr.json @@ -174,6 +174,7 @@ "attachmentSaveFailed": "Impossible d'enregistrer la pièce jointe à cet emplacement.", "variablesLoadFailed": "Impossible de charger 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." }, @@ -429,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", diff --git a/src/app/core/state/vault.store.ts b/src/app/core/state/vault.store.ts index 763a3cf..34265a3 100644 --- a/src/app/core/state/vault.store.ts +++ b/src/app/core/state/vault.store.ts @@ -45,6 +45,18 @@ export class VaultStore { 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); @@ -55,7 +67,7 @@ export class VaultStore { * 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): Promise { + private async attempt(action: () => Promise, failureKey = 'errors.unlockFailed'): Promise { this._isWorking.set(true); this._refused.set(false); try { @@ -68,7 +80,7 @@ export class VaultStore { return false; } - this.notifier.reportFailure('errors.unlockFailed', error); + this.notifier.reportFailure(failureKey, error); return false; } finally { this._isWorking.set(false); 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/testing/fake-vault-repository.ts b/src/testing/fake-vault-repository.ts index 09c8eae..deaa537 100644 --- a/src/testing/fake-vault-repository.ts +++ b/src/testing/fake-vault-repository.ts @@ -11,6 +11,7 @@ export class FakeVaultRepository implements Pick { return this.answer; @@ -24,6 +25,14 @@ export class FakeVaultRepository implements Pick { + 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; From 8addf9a91d1f3adf8e6124dbd11d80e2bee54913 Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Wed, 16 Sep 2026 08:23:31 +0200 Subject: [PATCH 17/17] Answer "is this file protected?" from the recipe alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It went through `read`, which parses the whole bundle — a hundred megabytes on a large library — only to throw it away and let the import parse it again a moment later. It needs the recipe entry and nothing else. A file whose payload is unreadable still answers the question, which is what the new test holds. --- src-tauri/src/transfer/file.rs | 73 ++++++++++++++++++++++++++-------- 1 file changed, 57 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/transfer/file.rs b/src-tauri/src/transfer/file.rs index 86f8695..d96f8f4 100644 --- a/src-tauri/src/transfer/file.rs +++ b/src-tauri/src/transfer/file.rs @@ -185,20 +185,10 @@ fn archive( /// 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 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 { + 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 mut archive = - ZipArchive::new(file).map_err(|error| StorageError::ImportFormat(error.to_string()))?; + }; let Some(recipe) = read_recipe(&mut archive)? else { let raw = entry(&mut archive, BUNDLE_ENTRY)?; @@ -239,12 +229,35 @@ pub fn read(path: &str, passphrase: Option<&str>) -> Result<(IncomingBundle, Pay } /// 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 { - match read(path, None) { - Ok(_) => Ok(false), - Err(error) if error.code == crate::error::ErrorCode::PassphraseRequired => Ok(true), - Err(error) => Err(error), + 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> { @@ -668,6 +681,34 @@ mod tests { 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() {