diff --git a/crates/policy/src/lib.rs b/crates/policy/src/lib.rs index 738c435..8196ea5 100644 --- a/crates/policy/src/lib.rs +++ b/crates/policy/src/lib.rs @@ -151,7 +151,7 @@ fn guesses_log10(entropy: &zxcvbn::Entropy) -> f64 { (orderings + each).min(entropy.guesses_log10()) } -/// 0-4 for the strength meter, in even steps up to the bar: 4 means the estimate reaches +/// 0-4, in even steps up to the bar: 4 means the estimate reaches /// 10^16. (zxcvbn's own scale gives 4 from 10^10, which would show full marks for passwords /// the gate refuses.) fn score_for(guesses_log10: f64) -> u8 { diff --git a/crates/session/src/lib.rs b/crates/session/src/lib.rs index 3740e81..6e4290d 100644 --- a/crates/session/src/lib.rs +++ b/crates/session/src/lib.rs @@ -383,7 +383,7 @@ impl Session { pub fn failed_attempts(&self) -> u32 { self.failures } - /// True after 3+ consecutive failures ("Forgot it? Use your Recovery Kit"). + /// True after 3+ consecutive failures ("Forgot it? Use your Recovery Key"). pub fn show_recovery_hint(&self) -> bool { self.failures >= FREE_ATTEMPTS } diff --git a/docs/capture-test.md b/docs/capture-test.md index 495a797..bfe2fe4 100644 --- a/docs/capture-test.md +++ b/docs/capture-test.md @@ -1,6 +1,6 @@ # Screen-capture test checklist (ADR-0002) -Re-run after every macOS or meeting-app update. Use a **release build** (`npx tauri build`; release builds have no screenshot override), open it on a **normal desktop, not a full-screen Space**, and keep it on the welcome or lock screen. A test **passes** when the capture shows whatever is *behind* the encrypted-note window, or a blank or black box, and never the app's contents. +Re-run after every macOS or meeting-app update. Use a **release build** (`npm run tauri -- build -- --locked`; release builds have no screenshot override), open it on a **normal desktop, not a full-screen Space**, and keep it on the welcome or lock screen. A test **passes** when the capture shows whatever is *behind* the encrypted-note window, or a blank or black box, and never the app's contents. Quick check, run from a terminal that has Screen Recording permission. It passes when macOS refuses the window capture or the captured pixels are uniform. A nonuniform capture requires manual inspection and exits with status 2; file size alone can't tell whether pixels are the app or the desktop behind it. In that case the script keeps the captured image and prints its path, so you can look at it; delete it afterwards (the command is printed too). @@ -36,6 +36,7 @@ macOS 26.4.1 · Zoom 7.0.6 · Discord 0.0.413 · OBS 32.1.2 · Chrome 153 · Qui | Capture path | How to test | Result | |---|---|---| +| Spotlight clipboard history | turn it on, copy a fake Login password from a fake-data Vault, check the list | ⬜ | | Window server flag | automatic (window server reports `kCGWindowSharingState`) | ✅ 0 = excluded (2026-09-24) | | ScreenCaptureKit window capture | an automation tool captured the release window | ✅ blank (2026-09-24) | | Sharing Guard | open Zoom / Discord / QuickTime while unlocked | ✅ shield named Discord + QuickTime Player (2026-09-23) | @@ -115,7 +116,7 @@ Use a fake-data Vault and fake values only. The steps come from the Windows chec | Recovery Kit retype box during a Retry long enough to say Please wait… | The typed text masks, then unmasks | pending | | Show in File Explorer | The Vault folder opens | pending | | A fake value containing `&` | It shows as typed in the panel | pending | -| Panels at 100%, 125%, 150% and 200% scaling | No cut-off text. Record the System font's height at each; a height above 20 px means the panels need scaling work | pending | +| Panels at 100%, 125%, 150% and 200% scaling | No cut-off text. Record the System font's height at each; a height above 20 px means the panels need scaling work. A long fake readout scrolls to its last character | pending | ### Mac ↔ Windows Backup round trip diff --git a/docs/guide.md b/docs/guide.md index 84620f7..2cb26c2 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -95,7 +95,7 @@ Your Notes are still in the Vault file, and the Emergency Reader reads it withou If the Locked screen keeps saying encrypted-note couldn't read or write a file, or that the file is far larger than any Vault: quit the app and move `vault.enote` out of that folder. Keep it; don't delete it. Reopen the app: it offers your Safety Copies and "I already have a Vault → Open a Backup". -**Read them with the Emergency Reader.** It works on a Vault, a Backup or a Safety Copy. It asks for the Master Password without showing what you type (add `--recovery-key` to use the Recovery Key instead), prints to the terminal and writes nothing to disk. +**Read them with the Emergency Reader.** It works on a Vault, a Backup or a Safety Copy. It asks for the Master Password without showing what you type (add `--recovery-key` to use the Recovery Key instead), prints to the terminal and writes nothing to disk. Your terminal keeps what it prints and may save it. ```bash cargo run --locked -p enote-reader -- # list every Note with a number; Hidden Fields stay as dots @@ -105,6 +105,8 @@ cargo run --locked -p enote-reader -- --reveal # every Note, wi Give the reader a file, such as `vault.enote`, or a Safety Copy inside the `safety` folder. Don't give it the folder. Run these from a current copy of this project's source, with Rust installed. The first build may need internet, to download the exact versions listed in `Cargo.lock`. Keep the quotes, because the Mac path has a space in it. Don't write `~` inside quotes: the shell won't expand it. +**Do this once, now, while everything works:** run `cargo build --locked --release -p enote-reader`, then copy `target/release/enote-reader` and this guide next to your Backup. Replace it after updating the app. No Rust? [FORMAT.md section 11](FORMAT.md#11-reference-decryptor-python) has a Python decryptor. + Mac, the built app: ```bash diff --git a/docs/native-credential-design.md b/docs/native-credential-design.md index b8caabe..b9f8607 100644 --- a/docs/native-credential-design.md +++ b/docs/native-credential-design.md @@ -43,7 +43,7 @@ Ordinary Note entry/checks, including newly entered Wallet Hidden Fields, remain ## Validation still required -Fake-data core/coordinator tests cover creation and Recovery Kit confirmation, both unlock kinds, recovery reset, Wallet mutations/readouts, both password-change modes, copy inspection, Auto-lock weakening/tightening and stale/expired/cancelled operations. Browser fixture checks cover non-credential IPC contracts and the migrated callers. The presence of these tests is not evidence of physical native behavior. +Fake-data core/coordinator tests cover creation and Recovery Kit confirmation, both unlock kinds, recovery reset, Wallet mutations/readouts, both password-change modes, copy inspection, Auto-lock weakening/tightening and stale/expired/cancelled operations. `npm run typecheck` checks WebView calls against `src/api.ts`; `/fixture.html` only draws screens. The presence of these tests is not evidence of physical native behavior. Sharing Guard regression: with a detected sharing app running, create a fictional Vault, acknowledge the guard, and lock. The shield must appear before either native unlock route; after an explicit acknowledgement, Master Password unlock must open native controls. Cancelling leaves the Vault locked; a new or restarted sharing app must invalidate the pending native action. Repeat after restarting the app with the fictional Vault already present. A sharing warning arriving while already locked must not move focus to its acknowledgement button. @@ -65,4 +65,4 @@ Windows checks on a real PC, with fake values: Create and Unlock panels stay ope Touch ID, in addition to its own checklist: a closed lid and a disconnected Touch ID keyboard should each keep Touch ID on. That rests on Apple's documented error codes and hasn't been seen on hardware. -Run the current local app on real macOS and Windows for complete create/recovery/Wallet/copy/rotation/settings flows, native keyboard behavior, VoiceOver/Narrator, held Space/button release, long/control-character readouts, lock/switch/sleep races and first-frame capture behavior. Inspect only sanitized outcomes and fake fixture markers when checking IPC/events/logs; real credential or Wallet contents must not enter reports. Existing capture results from an older UI do not validate the new native controls. Touch ID has its own [physical acceptance checklist](quick-unlock-design.md#boundaries-and-acceptance-still-pending). +Run the current local app on real macOS and Windows for complete create/recovery/Wallet/copy/rotation/settings flows, the Agent Companion's Start dialog on Mac (Return and Escape cancel, and the Vault stays Unlocked while it's open), native keyboard behavior, VoiceOver/Narrator, held Space/button release, long/control-character readouts, lock/switch/sleep races and first-frame capture behavior. Inspect only sanitized outcomes and fake fixture markers when checking IPC/events/logs; real credential or Wallet contents must not enter reports. Existing capture results from an older UI do not validate the new native controls. Touch ID has its own [physical acceptance checklist](quick-unlock-design.md#boundaries-and-acceptance-still-pending). diff --git a/docs/security-model.md b/docs/security-model.md index f96a577..72d2952 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -9,7 +9,7 @@ | A stolen laptop or USB stick (Argon2id + XChaCha20-Poly1305, strong password required) | Malware already running on your computer (keyloggers, memory readers) | | Someone at your unlocked computer (Auto-lock, re-entering the password for wallets, hold-to-show for Wallet values) | A phone camera pointed at your screen, or someone watching it while you choose to show what you typed | | Accidentally showing Hidden Fields while screen sharing (layered, see below) | A hacked operating system, or being forced to unlock | -| Clipboard history and Universal/cloud clipboard (copies are marked private, which third-party clipboard managers honor by convention, and cleared after 30 s, or 60 s if you choose; Wallet Kinds always 30 s). A password you paste from another app gets none of this protection: this app can't mark it or clear it | Old copies of your Vault: they still open with the old password | +| Clipboard history and Universal/cloud clipboard (copies are marked private, which clipboard tools, including OS history, aren't obliged to honor, and cleared after 30 s, or 60 s if you choose; Wallet Kinds always 30 s). A password you paste from another app gets none of this protection: this app can't mark it or clear it | Old copies of your Vault: they still open with the old password | **Screen-capture hiding is best-effort on Mac.** Apple offers no API that guarantees it. Windows' exclusion is much stronger but still not a security boundary. That's why the app layers it with hidden-by-default fields and the Sharing Guard, which covers the window when known screen-sharing or recording apps are detected. See [ADR-0002](adr/0002-screen-capture-hiding-is-layered-and-best-effort-on-mac.md). @@ -24,7 +24,7 @@ - **Quick Unlock on Mac.** The source implementation uses a biometric-gated Secure Enclave key to wrap only the Vault Key in an encrypted private local file. No account, server, Apple Developer profile or persistent Keychain item is part of this route. Native Master Password verification is required after app restart and every seven days; Touch ID does not extend that deadline, release a Wallet Key or erase password backoff. This file uses filesystem access controls rather than Keychain app access-group isolation: another process with the same user's file access may copy it and request its own protected operation outside the app's process policy. Deleting the current file does not erase backup copies. [Custody details and physical acceptance](quick-unlock-design.md). - **Where things are stored.** See [Where your keys are stored](guide.md#where-your-keys-are-stored). The Finder or File Explorer window opened from Settings is outside Capture Hiding and shows file names, dates and a path containing the user name, never Note content. - **Backups.** A Backup is just a copy of the Vault file you save wherever you like (e.g. an exFAT USB stick) and open on your other computer. There's no sync by design ([ADR-0001](adr/0001-local-only-vault-moved-by-hand.md)). -- **Emergency Reader.** `crates/reader` is a small command-line tool that reads a Vault or Backup even if the app stops working ([how to use it](guide.md#if-the-app-wont-open)). +- **Emergency Reader.** `crates/reader` is a small command-line tool that reads a Vault or Backup even if the app stops working ([how to use it](guide.md#if-the-app-wont-open)). It doesn't have the app's wiping allocator. - **Optional Agent Companion.** A separate helper (an XPC service with its own App Sandbox container) can let one approved agent use one fixed OpenAI text tool with a separately created Agent Key. It can't open the Vault's container, receives nothing from the Vault, and stops on Stop, lock, sleep, grant expiry and quit. Its Agent Key is kept in an owner-only plain-text file and Node.js memory, not encrypted Vault storage ([ADR-0009](adr/0009-optional-agent-companion-helper.md), [guide](agent-companion.md)). - **Vocabulary.** [CONTEXT.md](../CONTEXT.md) defines Vault, Note, Kind, Hidden Field, Recovery Kit and the rest. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 781651e..3471d7f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -166,9 +166,6 @@ pub async fn quick_unlock(app: AppHandle) -> Result<(), ApiError> { // ---------- setup, unlock, lock ---------- -// These two need no Vault and no state, so they don't wait for the core: scoring a long -// password can take a while, and nothing that locks may queue behind it. - #[tauri::command] pub async fn create_vault(app: AppHandle) -> Result<(), ApiError> { match crate::native_coordinator::create(app).await? { @@ -517,23 +514,34 @@ pub fn dismiss_sharing_guard(app: AppHandle) { // ---------- dialogs ---------- -/// Show a native file dialog off the main thread (rfd hands it to the main thread itself) and -/// wait for the owner. Cancelling is the `cancelled` error the screens ignore. +/// Show a native dialog off the main thread (rfd hands it to the main thread itself) and wait for +/// the owner. /// /// Focus changes are ignored while it is open, so a shown value is hidden first; switching to /// another app meanwhile still arrives as an app-switch notification (`platform`). -async fn pick_file( +async fn with_native_dialog( app: &AppHandle, - dialog: impl FnOnce() -> Option + Send + 'static, -) -> Result { + dialog: impl FnOnce() -> T + Send + 'static, +) -> Option { with_core(app, AppCore::hide_field); DIALOG_OPEN.store(true, Ordering::SeqCst); - let picked = tauri::async_runtime::spawn_blocking(dialog).await; + let answer = tauri::async_runtime::spawn_blocking(dialog).await; DIALOG_OPEN.store(false, Ordering::SeqCst); - picked.ok().flatten().ok_or_else(|| ApiError { - code: "cancelled", - message: "Nothing was chosen.".to_owned(), - }) + answer.ok() +} + +/// A native file dialog. Cancelling is the `cancelled` error the screens ignore. +async fn pick_file( + app: &AppHandle, + dialog: impl FnOnce() -> Option + Send + 'static, +) -> Result { + with_native_dialog(app, dialog) + .await + .flatten() + .ok_or_else(|| ApiError { + code: "cancelled", + message: "Nothing was chosen.".to_owned(), + }) } /// The owner's real home folder (not the App Sandbox container), as the core was given it. @@ -587,11 +595,82 @@ pub fn companion_status(app: AppHandle) -> CompanionView { app.state::().status() } -/// The owner's explicit Start: one new grant of at most an hour, only while Unlocked. -#[tauri::command(async)] -pub fn companion_start(app: AppHandle) -> Result { +const COMPANION_START: &str = "Start"; + +/// What the owner agrees to in the Start dialog. +fn companion_start_text(idle_minutes: u32, count_activity_anywhere: bool) -> String { + let minutes = if idle_minutes == 1 { + "1 minute".to_owned() + } else { + format!("{idle_minutes} minutes") + }; + let place = if count_activity_anywhere { + "computer" + } else { + "window" + }; + format!( + "For up to one hour, the approved agent can send text to OpenAI through the helper. That \ + can cost money, and what it sends leaves this computer.\n\n\ + The hour ends sooner if the Vault locks: Auto-lock is set to {minutes} without using this \ + {place}.\n\n\ + Stop ends new requests at once. It can't undo a request already sent or billed, and it \ + doesn't revoke the Agent Key at OpenAI." + ) +} + +/// Only the Start button counts: closing the dialog, or anything unexpected, doesn't start it. +fn companion_start_confirmed(answer: Option) -> bool { + matches!(answer, Some(rfd::MessageDialogResult::Custom(button)) if button == COMPANION_START) +} + +/// The owner's explicit Start: one new grant of at most an hour, only while Unlocked. The owner +/// confirms in an OS dialog outside the WebView, so a script in the page can ask but can't answer. +#[tauri::command] +pub async fn companion_start(app: AppHandle) -> Result { + require_unlocked(&app)?; + if !cfg!(target_os = "macos") { + return Ok(app.state::().status()); + } + if DIALOG_OPEN.load(Ordering::SeqCst) || crate::native_coordinator::busy(&app) { + return Err(crate::core::not_now()); + } + let settings = core(&app).app_status().settings; + let description = companion_start_text(settings.idle_minutes, settings.count_activity_anywhere); + let window = app.get_webview_window("main"); + let answer = with_native_dialog(&app, move || { + // A sheet on the app's own window, so the alert taking focus doesn't count as leaving the + // app. Cancel comes first because Return presses the first button. + let window = window?; + Some( + rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Warning) + .set_title("Start the Agent Companion?") + .set_description(description) + .set_buttons(rfd::MessageButtons::OkCancelCustom( + "Cancel".to_owned(), + COMPANION_START.to_owned(), + )) + .set_parent(&window) + .show(), + ) + }) + .await + .flatten(); + if !companion_start_confirmed(answer) { + return Err(ApiError { + code: "cancelled", + message: "The Agent Companion wasn't started.".to_owned(), + }); + } + // The Vault may have locked while the dialog was open. require_unlocked(&app)?; - Ok(app.state::().start()) + tauri::async_runtime::spawn_blocking(move || app.state::().start()) + .await + .map_err(|_| ApiError { + code: "internal", + message: "The Agent Companion didn't start. Press Start again.".to_owned(), + }) } #[tauri::command(async)] @@ -627,6 +706,8 @@ pub async fn companion_setup(app: AppHandle) -> Result .pick_file() }) .await?; + // The Vault may have locked while the panels were open. + require_unlocked(&app)?; let plan = open_chosen(&plan)?; let credential = open_chosen(&credential)?; tauri::async_runtime::spawn_blocking(move || { @@ -656,7 +737,40 @@ fn open_chosen(path: &std::path::Path) -> Result { #[cfg(test)] mod tests { - use super::{SettingsRequest, backup_file_name}; + use super::{ + COMPANION_START, SettingsRequest, backup_file_name, companion_start_confirmed, + companion_start_text, + }; + + #[test] + fn only_the_start_button_starts_the_companion() { + use rfd::MessageDialogResult::{Cancel, Custom, No, Ok, Yes}; + assert!(companion_start_confirmed(Some(Custom( + COMPANION_START.to_owned() + )))); + for answer in [ + None, + Some(Custom("Cancel".to_owned())), + Some(Custom(String::new())), + Some(Cancel), + Some(Ok), + Some(Yes), + Some(No), + ] { + assert!(!companion_start_confirmed(answer)); + } + } + + #[test] + fn the_start_dialog_says_what_the_owner_is_agreeing_to() { + let window = companion_start_text(5, false); + assert!(window.contains("For up to one hour")); + assert!(window.contains("can cost money")); + assert!(window.contains("Auto-lock is set to 5 minutes without using this window.")); + assert!(window.contains("doesn't revoke the Agent Key")); + let computer = companion_start_text(1, true); + assert!(computer.contains("Auto-lock is set to 1 minute without using this computer.")); + } #[test] fn webview_settings_cannot_supply_a_master_password() { diff --git a/src-tauri/src/companion.rs b/src-tauri/src/companion.rs index 50496f2..742cd02 100644 --- a/src-tauri/src/companion.rs +++ b/src-tauri/src/companion.rs @@ -30,7 +30,6 @@ pub const OPERATION: OperationView = OperationView { concurrent: 1, per_minute: 6, per_hour: 60, - per_run: 100, grant_minutes: 60, }; @@ -98,7 +97,6 @@ pub struct OperationView { pub concurrent: u32, pub per_minute: u32, pub per_hour: u32, - pub per_run: u32, pub grant_minutes: u32, } @@ -126,7 +124,6 @@ pub struct CompanionView { pub expires_at: Option, pub local: Readiness, /// The private ChatGPT connection is set up and checked outside this app. - pub chatgpt: &'static str, pub operation: OperationView, } @@ -291,7 +288,6 @@ impl Companion { error: None, expires_at: None, local: Readiness::default(), - chatgpt: "not_verified", operation: OPERATION, }; let raw = match reply { @@ -613,7 +609,6 @@ mod tests { assert!(!partial.local.ready); assert!(partial.local.runtime && partial.local.plan && partial.local.credential); assert!(!partial.local.handoff && partial.local.code && !partial.local.agent); - assert_eq!(partial.chatgpt, "not_verified"); } #[test] @@ -737,7 +732,6 @@ mod tests { assert_eq!( keys, [ - "chatgpt", "error", "expires_at", "helper_bundled", diff --git a/src-tauri/src/core.rs b/src-tauri/src/core.rs index 91fa439..b6b87b9 100644 --- a/src-tauri/src/core.rs +++ b/src-tauri/src/core.rs @@ -1,8 +1,8 @@ //! `AppCore`: everything the app bridge does, as plain Rust with no Tauri types, so it can be //! tested with a temp folder, a fake clock and a fake clipboard. //! -//! One method per command in `src/api.ts`, with the same name and meaning. Every method checks -//! the phase first. Errors are [`ApiError`]s whose `message` is calm, owner-facing English. +//! Most commands in `src/api.ts` have a method here; Agent Companion commands stay outside +//! (ADR-0009). Every method checks the phase first. Errors are [`ApiError`]s whose `message` is calm, owner-facing English. //! //! What lives here (and nowhere else): the open Vault (Vault Key + decrypted document), the //! session rules, the Sharing Guard state and the non-secret settings. The Wallet Key is never @@ -220,7 +220,6 @@ pub struct AssessmentView { } /// `create_vault` and `change_password` results. No `Debug`: it holds the Recovery Key. -#[derive(Serialize)] pub struct RecoveryKeyShown { /// Shown to the owner once; `None` after a password change without Key Rotation. pub recovery_key: Option>, @@ -701,10 +700,12 @@ impl AppCore { // ---------- setup, unlock, lock ---------- + /// Test-only: the app goes through `begin_native`. pub fn suggest_passphrase(&self) -> Zeroizing { policy::suggest_passphrase() } + /// Test-only: the app goes through `begin_native`. pub fn assess_password(&self, password: &str) -> AssessmentView { assess_password(password) } @@ -720,6 +721,8 @@ impl AppCore { /// Calibrates the KDF and creates the Vault in memory; returns the Recovery Key to show once. /// `params`: Argon2id settings for the new Vault (the app calibrates them on this computer /// before calling, without holding the core). + /// + /// Test-only: the app goes through `begin_native`. pub fn create_vault( &mut self, master_password: Zeroizing, @@ -783,6 +786,7 @@ impl AppCore { Ok(()) } + /// Test-only: the app goes through `begin_native`. pub fn unlock(&mut self, credential: CredentialInput) -> Result<(), ApiError> { self.tick(); if !matches!(self.phase, Phase::Locked) { @@ -812,6 +816,8 @@ impl AppCore { } /// After a Recovery Key unlock: re-wrap the password slot, save, unlock. + /// + /// Test-only: the app goes through `begin_native`. pub fn set_new_password(&mut self, master_password: Zeroizing) -> Result<(), ApiError> { self.tick(); self.invalidate_quick_unlock_registration()?; @@ -1205,6 +1211,7 @@ impl AppCore { // ---------- checks ---------- + /// Test-only: the app goes through `begin_native`. pub fn seed_wordlist(&self) -> &'static [&'static str] { notes::seed::wordlist() } @@ -1298,6 +1305,7 @@ impl AppCore { Ok(picked) } + /// Test-only: the app goes through `begin_native`. pub fn inspect_backup( &mut self, credential: CredentialInput, @@ -1341,6 +1349,7 @@ impl AppCore { .collect()) } + /// Test-only: the app goes through `begin_native`. pub fn inspect_safety_copy( &mut self, id: &str, @@ -1359,6 +1368,8 @@ impl AppCore { // ---------- account care & settings ---------- /// `rotate`: Key Rotation, and the new Recovery Key must be confirmed before it is saved. + /// + /// Test-only: the app goes through `begin_native`. pub fn change_password( &mut self, current_password: Zeroizing, diff --git a/src-tauri/src/core/native.rs b/src-tauri/src/core/native.rs index e2f5d14..41a6844 100644 --- a/src-tauri/src/core/native.rs +++ b/src-tauri/src/core/native.rs @@ -69,12 +69,10 @@ pub enum NativeCredentials { RecoveryKey(Zeroizing), NewPassword { password: Zeroizing, - confirmation: Zeroizing, }, PasswordChange { current: Zeroizing, password: Zeroizing, - confirmation: Zeroizing, }, KitConfirmation(Zeroizing), } @@ -858,14 +856,8 @@ impl NativeJob { let limits = self.limits; let bytes = self.bytes.take(); match (&self.action, credentials) { - ( - NativeAction::CreateVault { params }, - NativeCredentials::NewPassword { - password, - confirmation, - }, - ) => { - checked_new_password(&password, &confirmation).map_err(job_input)?; + (NativeAction::CreateVault { params }, NativeCredentials::NewPassword { password }) => { + check_strength(&password).map_err(job_input)?; let doc = Document::new(self.now_unix); let created = vault_format::create(&secret(&password), *params, &limits, &doc.to_json()) @@ -881,14 +873,8 @@ impl NativeJob { (NativeAction::ConfirmRecoveryKit, NativeCredentials::KitConfirmation(groups)) => { Ok(Prepared::Kit(groups)) } - ( - NativeAction::SetNewPassword, - NativeCredentials::NewPassword { - password, - confirmation, - }, - ) => { - checked_new_password(&password, &confirmation).map_err(job_input)?; + (NativeAction::SetNewPassword, NativeCredentials::NewPassword { password }) => { + check_strength(&password).map_err(job_input)?; let key = self .recovery_key .take() @@ -912,13 +898,9 @@ impl NativeJob { } ( NativeAction::ChangePassword { rotate }, - NativeCredentials::PasswordChange { - current, - password, - confirmation, - }, + NativeCredentials::PasswordChange { current, password }, ) => { - checked_new_password(&password, &confirmation).map_err(job_input)?; + check_strength(&password).map_err(job_input)?; let current = secret(¤t); let (mut vault, body) = vault_format::open( bytes.as_deref().ok_or_else(|| job_input(not_now()))?, @@ -1114,16 +1096,6 @@ fn validate_credentials( if valid { Ok(()) } else { Err(not_now()) } } -fn checked_new_password(password: &str, confirmation: &str) -> Result<(), ApiError> { - if password != confirmation { - return Err(err( - "invalid_input", - "The passwords don't match. Please try again.", - )); - } - check_strength(password) -} - fn attempt_allowed(session: &Session, now: u64) -> Result<(), ApiError> { session.attempt_allowed(now).map_err(|wait_ms| { let seconds = wait_ms.div_ceil(1000); diff --git a/src-tauri/src/core/quick_unlock.rs b/src-tauri/src/core/quick_unlock.rs index 54b2b20..1ca1ed0 100644 --- a/src-tauri/src/core/quick_unlock.rs +++ b/src-tauri/src/core/quick_unlock.rs @@ -168,10 +168,6 @@ impl AppCore { std::mem::take(&mut self.quick_cleanup_needed) } - pub fn quick_unlock_cleanup_failed(&mut self) { - self.quick_unlock_cleanup_finished(false); - } - /// Only a completed exact-file removal changes this warning; queuing a retry does not. pub fn quick_unlock_cleanup_finished(&mut self, succeeded: bool) { self.quick_cleanup_failed = !succeeded; @@ -365,8 +361,6 @@ impl AppCore { .is_ok_and(|open| Some(open.doc.change_counter()) == snapshot.counter) }; if self.quick_pending.is_none() - || snapshot.epoch != self.native_epoch - || snapshot.generation != self.session_generation || !phase_matches || self.clock.now_ms() >= snapshot.deadline || self.session.sharing_guard_up() diff --git a/src-tauri/src/native_coordinator.rs b/src-tauri/src/native_coordinator.rs index 13d61c3..8876abd 100644 --- a/src-tauri/src/native_coordinator.rs +++ b/src-tauri/src/native_coordinator.rs @@ -1036,7 +1036,7 @@ fn kind_name(kind: Option) -> &'static str { fn field_name(field: Option<&str>) -> &'static str { match field { Some("words") => "Seed Phrase", - Some("passphrase") => "Wallet passphrase", + Some("passphrase") => "Passphrase", Some("key") => "Private Key", _ => "Hidden Field", } @@ -1200,10 +1200,7 @@ fn credentials(kind: EntryKind, reply: Reply) -> Result NativeCredentials::Password(value), (EntryKind::Recovery, Reply::Credential(value)) => NativeCredentials::RecoveryKey(value), (EntryKind::NewPassword, Reply::NewPassword { current: None, new }) => { - NativeCredentials::NewPassword { - confirmation: new.clone(), - password: new, - } + NativeCredentials::NewPassword { password: new } } ( EntryKind::PasswordChange, @@ -1213,7 +1210,6 @@ fn credentials(kind: EntryKind, reply: Reply) -> Result NativeCredentials::PasswordChange { current, - confirmation: new.clone(), password: new, }, _ => return Err(cancelled()), @@ -1911,7 +1907,7 @@ mod tests { ); assert_eq!( title("show_and_copy_wallet_field", seed, Some("passphrase")), - "Show and copy Wallet passphrase" + "Show and copy Passphrase" ); assert_eq!( title("copy_field", Some(Kind::PrivateKey), Some("key")), diff --git a/src-tauri/src/native_credential/mac.rs b/src-tauri/src/native_credential/mac.rs index 9bc353d..7264b66 100644 --- a/src-tauri/src/native_credential/mac.rs +++ b/src-tauri/src/native_credential/mac.rs @@ -758,7 +758,7 @@ pub(super) fn present( root.addSubview(&view); let expires = matches!(&prompt, Request::Reveal { reveal, .. } if reveal.mode == RevealMode::Timed) - .then(|| Instant::now() + Duration::from_secs(20)); + .then(|| Instant::now() + Duration::from_millis(session::ACCESSIBLE_REVEAL_MS)); // SAFETY: Notification selectors have the documented one-object signature. The observer // is removed before target release, and timer callbacks remain on this AppKit run loop. let timer = unsafe { @@ -1919,7 +1919,8 @@ fn extend() { } if authorization == Authorization::Allowed { panel.extensions += 1; - panel.expires = Some(Instant::now() + Duration::from_secs(20)); + panel.expires = + Some(Instant::now() + Duration::from_millis(session::ACCESSIBLE_REVEAL_MS)); } true }); diff --git a/src-tauri/src/native_credential/windows.rs b/src-tauri/src/native_credential/windows.rs index c5f497c..1c1d0c6 100644 --- a/src-tauri/src/native_credential/windows.rs +++ b/src-tauri/src/native_credential/windows.rs @@ -314,7 +314,7 @@ fn build( )?); } else { key_loop.push(button(overlay, RESET_BUTTON, x, y, 200, EXTEND)?); - expires = Some(Instant::now() + Duration::from_secs(20)); + expires = Some(Instant::now() + Duration::from_millis(session::ACCESSIBLE_REVEAL_MS)); } y += 44; } @@ -1166,7 +1166,8 @@ fn extend() { } if authorization == Authorization::Allowed { panel.extensions += 1; - panel.expires = Some(Instant::now() + Duration::from_secs(20)); + panel.expires = + Some(Instant::now() + Duration::from_millis(session::ACCESSIBLE_REVEAL_MS)); } true }); diff --git a/src-tauri/src/platform.rs b/src-tauri/src/platform.rs index 82cd757..49e0d39 100644 --- a/src-tauri/src/platform.rs +++ b/src-tauri/src/platform.rs @@ -2,8 +2,8 @@ //! //! * [`capture_hiding_active`]: the Capture Hiding self-check (main thread only). //! * [`running_apps`]: what the Sharing Guard classifies. -//! * [`SystemClipboard`]: the clipboard, with our copies marked private (kept out of OS history -//! and cloud sync; clipboard managers skip them only by convention). It never reads the +//! * [`SystemClipboard`]: the clipboard, with our copies marked private (asked to stay out of OS +//! history and cloud sync; clipboard tools aren't obliged to honor that). It never reads the //! clipboard's contents. //! * [`observe_lock_events`]: OS notifications that must lock the app at once, and app switches. //! * [`lock_events_unavailable`]: Windows lock notifications whose registration failed. @@ -19,8 +19,7 @@ //! macOS and Windows are implemented here; other platforms get harmless stand-ins (no self-check, //! no running apps, no copy). //! -//! This module, `hardening.rs` and `wipe_on_free.rs` are the only places allowed to use -//! `unsafe`; every block says why it is sound. +//! Only the modules marked in `lib.rs` may use `unsafe`; every block says why it is sound. #[cfg(target_os = "macos")] pub use mac::*; diff --git a/src-tauri/src/quick_unlock.rs b/src-tauri/src/quick_unlock.rs index d7feb5d..2c27b66 100644 --- a/src-tauri/src/quick_unlock.rs +++ b/src-tauri/src/quick_unlock.rs @@ -129,6 +129,7 @@ impl QuickUnlockGate { .is_some_and(|verified| verified.binding == binding && now < verified.deadline) } + #[cfg(test)] pub fn can_unlock( &mut self, current_binding: QuickUnlockBinding, @@ -296,6 +297,26 @@ mod tests { assert!(gate.can_unlock(binding, Some(3))); } + #[test] + fn the_status_hint_follows_the_master_password_and_the_clock() { + let binding = binding(); + let mut gate = QuickUnlockGate::default(); + assert!(!gate.available_at(binding, Some(0)), "nothing enrolled"); + gate.load_registration(binding); + assert!( + !gate.available_at(binding, Some(1)), + "no Master Password yet" + ); + gate.master_verified(binding, Some(2)).unwrap(); + assert!(gate.available_at(binding, Some(3))); + assert!(!gate.available_at(binding, None), "no clock"); + assert!(!gate.available_at(binding, Some(1)), "clock went back"); + assert!( + !gate.available_at(binding, Some(MASTER_REQUIRED_AFTER_MS + 2)), + "the 7 days are up" + ); + } + #[test] fn biometric_reads_never_extend_the_seven_day_deadline() { let binding = binding(); diff --git a/src-tauri/tests/core.rs b/src-tauri/tests/core.rs index 75ec050..3400bfd 100644 --- a/src-tauri/tests/core.rs +++ b/src-tauri/tests/core.rs @@ -4698,7 +4698,6 @@ fn native_run( fn native_new_password(password: &str) -> NativeCredentials { NativeCredentials::NewPassword { password: pw(password), - confirmation: pw(password), } } @@ -4953,7 +4952,6 @@ fn native_setup_recovery_reset_and_password_change_keep_credentials_in_rust() { NativeCredentials::PasswordChange { current: pw(NEW_PASSWORD), password: pw(PASSWORD), - confirmation: pw(PASSWORD), }, ) .unwrap(); @@ -5613,21 +5611,8 @@ fn restoring_a_pre_rotation_safety_copy_brings_back_its_credentials() { } #[test] -fn native_setup_rejects_mismatch_and_weak_password_without_a_vault() { +fn native_setup_rejects_a_weak_password_without_a_vault() { let mut app = App::new(); - assert_eq!( - code(native_run( - &mut app.core, - NativeAction::CreateVault { - params: KdfParams::TESTING - }, - NativeCredentials::NewPassword { - password: pw(PASSWORD), - confirmation: pw(NEW_PASSWORD) - } - )), - "invalid_input" - ); assert_eq!( code(native_run( &mut app.core, @@ -5795,7 +5780,6 @@ fn native_password_change_failure_preserves_the_saved_and_live_password() { NativeCredentials::PasswordChange { current: pw(PASSWORD), password: pw(NEW_PASSWORD), - confirmation: pw(NEW_PASSWORD), }, ) .unwrap(); diff --git a/src-tauri/tests/quick_unlock.rs b/src-tauri/tests/quick_unlock.rs index 3a84618..89473fa 100644 --- a/src-tauri/tests/quick_unlock.rs +++ b/src-tauri/tests/quick_unlock.rs @@ -448,7 +448,7 @@ fn disable_persists_before_cleanup_and_surviving_items_are_unusable() { app.core.disable_quick_unlock().unwrap(); assert!(app.settings()["quick_unlock_binding"].is_null()); assert!(app.core.take_quick_unlock_cleanup()); - app.core.quick_unlock_cleanup_failed(); + app.core.quick_unlock_cleanup_finished(false); assert!(app.core.app_status().quick_unlock.cleanup_failed); assert!(app.core.app_status().quick_unlock.reason.is_none()); let mut app = app.restart(); @@ -464,7 +464,7 @@ fn cleanup_retry_stays_disabled_until_removal_success_clears_the_warning() { let payload = app.enroll(); app.core.disable_quick_unlock().unwrap(); assert!(app.core.take_quick_unlock_cleanup()); - app.core.quick_unlock_cleanup_failed(); + app.core.quick_unlock_cleanup_finished(false); assert!(app.core.app_status().quick_unlock.cleanup_failed); // Retrying uses the same disable path and remains a cleanup request, never enrollment. @@ -495,7 +495,7 @@ fn cleanup_retry_preserves_independent_unavailable_hardware_reason() { app.core.set_quick_unlock_availability(false, Some(REASON)); app.core.disable_quick_unlock().unwrap(); assert!(app.core.take_quick_unlock_cleanup()); - app.core.quick_unlock_cleanup_failed(); + app.core.quick_unlock_cleanup_finished(false); let failed = app.core.app_status().quick_unlock; assert!(failed.cleanup_failed); assert_eq!(failed.reason, Some(REASON)); @@ -550,8 +550,7 @@ fn a_failed_password_change_leaves_quick_unlock_off_and_the_vault_unchanged() { NativeAction::ChangePassword { rotate: false }, NativeCredentials::PasswordChange { current: text(WRONG), - password: text(NEW_PASSWORD), - confirmation: text(NEW_PASSWORD) + password: text(NEW_PASSWORD) } ) .is_err() diff --git a/src/Note.tsx b/src/Note.tsx index b7aef94..4121935 100644 --- a/src/Note.tsx +++ b/src/Note.tsx @@ -213,22 +213,10 @@ function useShown(noteId: string, status: AppStatus) { * line, and each line that holds something has its own Copy button. The lines are numbered as * Rust numbers them: from 0, split at every line feed, empty lines counted too. `lines.name`: * the field's label, for what those buttons are called. */ -function Value({ field, value, lines }: { - field: string; +function Value({ value, lines }: { value: string; lines?: { name: string; copy: (line: number) => void } | undefined; }) { - if (field === "words") - return ( -
    - {value.split(" ").map((word, i) => ( -
  1. - {i + 1} - {word} -
  2. - ))} -
- ); const split = lines ? value.split("\n") : []; if (!lines || split.length < 2) return
{value}
; return ( @@ -489,7 +477,7 @@ export function Detail( {/* A line is copied in Rust, by its number: the shown text is only what the owner picks from. */} - run(() => copyFieldLine(id, f, line)) }} /> + run(() => copyFieldLine(id, f, line)) }} /> )} @@ -1132,7 +1120,7 @@ export function Editor(props: { {peeking ? s.hide : s.show} )} - {peeking && } + {peeking && } {had && !adds && (