From fde86205f9c1051c9c494a71d95c24626db51cb2 Mon Sep 17 00:00:00 2001 From: NNIIOVO Date: Sun, 1 Nov 2026 20:58:48 -0600 Subject: [PATCH 1/3] fix: confirm Agent Companion Start in a native dialog The owner's "explicit Start" was a button and a modal in the WebView, so a script in the page could call companion_start with no owner action. Start now asks in an OS dialog drawn outside the WebView: a script can open it but can't answer it. Return and Escape both cancel, so the owner has to click Start. Stop stays unconfirmed. The Vault is checked again after the dialog closes, and after Set up's file panels, in case it locked meanwhile. The dialog is a sheet on the app's own window, and focus changes are ignored while it's open, as with file panels. Also drop what the screens never used: CompanionView's chatgpt and per_run fields, the "per start" limit (60 an hour can't reach 100 in an hour), and the optional markers on fields Rust always sends, with their fallbacks. The fixture's types now have to match. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/commands.rs | 150 ++++++++++++++++++++++++++++++++----- src-tauri/src/companion.rs | 6 -- src/Settings.tsx | 36 ++------- src/api.ts | 22 +++--- src/dev-fixture.ts | 2 - src/strings.ts | 11 +-- 6 files changed, 149 insertions(+), 78 deletions(-) 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/Settings.tsx b/src/Settings.tsx index b9cc0a5..da65b47 100644 --- a/src/Settings.tsx +++ b/src/Settings.tsx @@ -189,7 +189,7 @@ export function Settings({ status, onOperationOutcome, afterKit, scrollTo, onScr ))} - + {flow === "password" && } {flow === "open" && } @@ -201,8 +201,8 @@ export function Settings({ status, onOperationOutcome, afterKit, scrollTo, onScr const lockChoices = (settings: SettingsView) => ({ idle_minutes: settings.idle_minutes, lock_on_app_switch: settings.lock_on_app_switch, - clipboard_clear_seconds: settings.clipboard_clear_seconds ?? 30, - count_activity_anywhere: settings.count_activity_anywhere ?? false, + clipboard_clear_seconds: settings.clipboard_clear_seconds, + count_activity_anywhere: settings.count_activity_anywhere, }); /** A setting drawn as a row with a switch, and one line under it saying what it does. */ @@ -420,13 +420,8 @@ function useCompanion() { /** The optional Agent Companion (ADR-0009): readiness, the approved operation, Start/Stop. * Everything shown comes from fixed fields; Rust never sends a key, token, path or helper text. */ -function AgentCompanion({ companion, idleMinutes, countAnywhere }: { - companion: ReturnType; - idleMinutes: number; - countAnywhere: boolean; -}) { +function AgentCompanion({ companion }: { companion: ReturnType }) { const { view, setView, acting } = companion; - const [confirming, setConfirming] = useState(false); const [setupDone, setSetupDone] = useState(false); const [open, setOpen] = useState(false); const { busy, error, run } = useAction(); @@ -515,7 +510,7 @@ function AgentCompanion({ companion, idleMinutes, countAnywhere }: { {s.companionStop} ) : ( - )} @@ -544,27 +539,6 @@ function AgentCompanion({ companion, idleMinutes, countAnywhere }: { )} - {confirming && ( - setConfirming(false)}> - {s.companionConfirmText(idleMinutes, countAnywhere).map((line) => ( -

{line}

- ))} -

{s.companionCustody}

-
- - -
-
- )} ); } diff --git a/src/api.ts b/src/api.ts index d1797e4..1c3017b 100644 --- a/src/api.ts +++ b/src/api.ts @@ -70,15 +70,15 @@ export interface Settings { seed_explainer_seen: boolean; /** How long a copy stays on the clipboard: 30 (default) or 60 seconds. A copied Wallet Kind * value is cleared after 30 either way. Rust always sends it. */ - clipboard_clear_seconds?: number; + clipboard_clear_seconds: number; /** The owner's choice (off by default) to count keyboard and mouse use anywhere on this * computer as activity for Auto-lock. Even then the app locks no later than 60 minutes after * the last activity inside its own window; lock_in_ms is the real countdown. Rust always * sends it. */ - count_activity_anywhere?: boolean; + count_activity_anywhere: boolean; /** Whether this computer can do that (the Mac only, for now). Where it is false the setting * stays off whatever is sent. Rust always sends it. */ - count_activity_anywhere_supported?: boolean; + count_activity_anywhere_supported: boolean; } /** What setSettings takes. The lock rule is always sent. Each of the two newer settings may be @@ -119,9 +119,9 @@ export interface AppStatus { * the time left can't tell (a Wallet Kind copy may have less than the one before it, and a * clear that is being tried again has more). A lock doesn't reset it. Rust always sends * it. */ - copies?: number; + copies: number; /** Gone on lock, Sharing Guard or clipboard clear. */ - clipboard_source?: ClipboardSource | null; + clipboard_source: ClipboardSource | null; /** The currently shown Hidden Field, if any (screens hide anything else). */ reveal: { note_id: string; @@ -129,11 +129,11 @@ export interface AppStatus { expires_in_ms: number | null; /** Whether extendReveal would give this value more time right now. Never for a value * shown while held. Rust always sends it. */ - can_extend?: boolean; + can_extend: boolean; /** For a Wallet Kind value shown for 20 s: how many more times it can be given more time * (3 at first). null where there is no such count: an ordinary Kind (no limit) or a held * value (never). Rust always sends it. */ - extensions_left?: number | null; + extensions_left: number | null; } | null; failed_attempts: number; /** Milliseconds before another unlock attempt is allowed, null if allowed now. */ @@ -181,12 +181,12 @@ export interface VaultPreview { // Vault. Otherwise all three are null: there is nothing to compare, or the Vault here is // Locked. Rust always sends them. /** Notes in this computer's Vault that are not in Trash. */ - current_note_count?: number | null; + current_note_count: number | null; /** When this computer's Vault was last changed (Unix seconds, like changed_at). */ - current_changed_at?: number | null; + current_changed_at: number | null; /** How many Notes here (not in Trash) the copy doesn't hold at all, its Trash included. * A number only: no titles are sent. */ - only_here?: number | null; + only_here: number | null; } export interface SafetyCopy { @@ -417,7 +417,6 @@ export interface CompanionOperation { concurrent: number; per_minute: number; per_hour: number; - per_run: number; grant_minutes: number; } @@ -431,7 +430,6 @@ export interface CompanionView { /** Local readiness on this Mac. */ local: CompanionReadiness; /** The private ChatGPT connection is checked outside this app. */ - chatgpt: "not_verified"; operation: CompanionOperation; } diff --git a/src/dev-fixture.ts b/src/dev-fixture.ts index 556e998..477ef2c 100644 --- a/src/dev-fixture.ts +++ b/src/dev-fixture.ts @@ -111,7 +111,6 @@ const companion: CompanionView = { error: companionState === "error" ? "internal" : null, expires_at: now + 3600, local: { ready: true, runtime: true, plan: true, credential: true, handoff: true, agent: true, code: true }, - chatgpt: "not_verified", operation: { name: "generate_text", destination: "api.openai.com", @@ -121,7 +120,6 @@ const companion: CompanionView = { concurrent: 1, per_minute: 1, per_hour: 1, - per_run: 1, grant_minutes: 60, }, }; diff --git a/src/strings.ts b/src/strings.ts index c795f1d..60da43b 100644 --- a/src/strings.ts +++ b/src/strings.ts @@ -653,10 +653,10 @@ export const strings = { companionChatgpt: "Not checked by this app. It is set up from the agent account and counts as working only after a test call you make yourself. See docs/agent-companion.md in the project folder.", companionOperationTitle: "What an approved agent can do", - companionOperation: (o: { name: string; model: string; destination: string; max_input_bytes: number; max_output_tokens: number; per_minute: number; per_hour: number; per_run: number; grant_minutes: number }) => [ + companionOperation: (o: { name: string; model: string; destination: string; max_input_bytes: number; max_output_tokens: number; per_minute: number; per_hour: number; grant_minutes: number }) => [ `One tool: ${o.name}, with the fixed model ${o.model}. No files, browsing, other tools or other models.`, `Sends the text an agent gives it (at most ${o.max_input_bytes.toLocaleString("en-US")} bytes) to ${o.destination}, and returns at most ${o.max_output_tokens} tokens of text.`, - `One request at a time; at most ${o.per_minute} a minute, ${o.per_hour} an hour and ${o.per_run} per start. Each start lasts at most ${o.grant_minutes} minutes.`, + `One request at a time; at most ${o.per_minute} a minute and ${o.per_hour} an hour. Each start lasts at most ${o.grant_minutes} minutes.`, ], companionCustody: `The ${HELPER_KEEPS_KEY}`, // For an owner with no confirmed key. Never says that no file exists: the check can say no while @@ -676,13 +676,6 @@ export const strings = { ], companionStart: "Start for up to one hour", companionStop: "Stop", - companionConfirmTitle: "Start the Agent Companion?", - // `anywhere`: activity anywhere on this computer counts for Auto-lock, as chosen in Settings. - companionConfirmText: (minutes: number, anywhere: boolean) => [ - "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.", - `The hour ends sooner if the Vault locks: Auto-lock is set to ${plural(minutes, "minute", "minutes")} without using this ${anywhere ? "computer" : "window"}.`, - "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.", - ], companionErrors: { plan_missing: "No plan file is added. Use Set up… to choose it.", plan_invalid: "The plan file isn't a correct version-2 plan. Prepare a new one.", From 498e0ed4433b34d57e4927ad1732e85939669f46 Mon Sep 17 00:00:00 2001 From: NNIIOVO Date: Sun, 1 Nov 2026 20:58:49 -0600 Subject: [PATCH 2/3] refactor: remove checks and code nothing relies on - Two password checks compared a value with a copy of itself and could never fail. The real comparison stays in the native panel, and the strength check stays. - The old credential methods in AppCore are marked test-only. Deleting them is a later change. - A duplicate epoch and generation check, a test-only wrapper and an unused Serialize are gone. can_unlock is test-only, so available_at, which the status uses, gets its own test. - Both native panels take the 20-second reveal time from session's constant. - A shown value can't be Seed Phrase words (ADR-0004), so Value's words branch is gone, with an unused style. - Stale comments now match the code, and the Passphrase panel no longer says "Wallet passphrase". Co-Authored-By: Claude Opus 5.5 --- crates/policy/src/lib.rs | 2 +- crates/session/src/lib.rs | 2 +- src-tauri/src/core.rs | 17 +++++++-- src-tauri/src/core/native.rs | 40 ++++------------------ src-tauri/src/core/quick_unlock.rs | 6 ---- src-tauri/src/native_coordinator.rs | 10 ++---- src-tauri/src/native_credential/mac.rs | 5 +-- src-tauri/src/native_credential/windows.rs | 5 +-- src-tauri/src/platform.rs | 7 ++-- src-tauri/src/quick_unlock.rs | 21 ++++++++++++ src-tauri/tests/core.rs | 18 +--------- src-tauri/tests/quick_unlock.rs | 9 +++-- src/Note.tsx | 18 ++-------- src/styles.css | 4 --- 14 files changed, 63 insertions(+), 101 deletions(-) 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/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 && (