From f5681c34180509954f1bc15e7b67cdda389bc8c0 Mon Sep 17 00:00:00 2001 From: NNIIOVO Date: Sun, 1 Nov 2026 17:57:50 -0600 Subject: [PATCH] fix: easier typing, clearer screens, and where keys are stored The owner found the Recovery Key step and typing hard, and couldn't tell where keys are kept. A verified audit also found screens that ended silently, hidden rules and wording that implied a key file exists. - Recovery Key step: up to 10 minutes instead of sharing a 2-minute limit with all of setup. A timeout saved nothing and left the written key dead. The retype box shows what is typed, the first Cancel only asks, and the app says when the step ended. - Password boxes: time left, Caps Lock warning, clearer errors, live advice and Return moving to the next empty box. Optional, off-by-default ways to check typing: Show what I type and Hold to show (Mac), and Show in the Note editor for Login passwords and API Keys, never Wallet Kinds. - Settings: Where your keys are stored comes first and says what each file is, where it is and whether it is encrypted. The folder can be copied or opened. The Agent Key is named as plain text. - Lock screen, first run, Note editor and Touch ID: keyboard use, plainer words and messages when something ends or fails. Note blur no longer cancels Wallet panels. Nothing has been confirmed on screen yet, and Windows has only been type-checked. Hand checks are in docs/native-credential-design.md and docs/capture-test.md. No new crates. objc2-app-kit gains NSActionCell, NSCell and NSTextFieldCell for the retype box's own field editor. windows gains Win32_UI_Input_Ime to turn the input method off on that box. Co-Authored-By: Claude Opus 5.5 --- CONTEXT.md | 11 +- README.md | 15 +- crates/policy/src/lib.rs | 14 +- crates/store/src/lib.rs | 3 +- ...change-rewraps-key-rotation-is-separate.md | 3 +- ...ay-key-use-limits-the-owner-can-stretch.md | 8 + docs/capture-test.md | 22 + docs/guide.md | 115 +- docs/native-credential-design.md | 28 +- docs/quick-unlock-design.md | 7 +- docs/security-model.md | 11 +- src-tauri/Cargo.toml | 10 + src-tauri/build.rs | 1 + src-tauri/capabilities/main.json | 1 + src-tauri/native/biometry.swift | 15 +- src-tauri/src/biometry.rs | 12 + src-tauri/src/commands.rs | 64 +- src-tauri/src/core.rs | 46 +- src-tauri/src/core/native.rs | 95 +- src-tauri/src/core/quick_unlock.rs | 15 +- src-tauri/src/lib.rs | 8 +- src-tauri/src/native_coordinator.rs | 1276 +++++++++++-- src-tauri/src/native_credential.rs | 1359 ++++++++++++- src-tauri/src/native_credential/mac.rs | 1679 +++++++++++++++-- src-tauri/src/native_credential/windows.rs | 901 +++++++-- src-tauri/src/platform.rs | 52 +- src-tauri/src/quick_runtime.rs | 118 +- src-tauri/tests/core.rs | 524 ++++- src-tauri/tests/mac_typed_line.rs | 58 + src-tauri/tests/quick_unlock.rs | 37 + src-tauri/tests/vault_reveal.rs | 187 ++ src/App.tsx | 72 +- src/Home.tsx | 142 +- src/Note.tsx | 679 +++++-- src/Settings.tsx | 354 +++- src/Start.tsx | 209 +- src/api.ts | 3 + src/components.tsx | 117 +- src/strings.ts | 526 ++++-- src/styles.css | 119 +- 40 files changed, 7726 insertions(+), 1190 deletions(-) create mode 100644 src-tauri/tests/mac_typed_line.rs create mode 100644 src-tauri/tests/vault_reveal.rs diff --git a/CONTEXT.md b/CONTEXT.md index 3fa2cdd..01b0ce3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -10,12 +10,16 @@ A personal desktop app (Mac and Windows) for keeping secrets encrypted and store The single encrypted file that holds all of the owner's **Notes**; the same file opens on Mac and Windows. _Avoid_: database, store, safe, wallet +**Vault folder**: +The folder in the owner's user account on this computer that holds the **Vault** file, **Safety Copies**, settings and the **Quick Unlock** file. +_Avoid_: data folder, container + **Backup**: A complete copy of the **Vault** file the owner saved outside the app (e.g. on a USB stick) with "Back up now"; it is itself a **Vault** and opens with the same **Master Password** or **Recovery Key**. _Avoid_: export, snapshot, sync **Safety Copy**: -An earlier version of this computer's **Vault** that the app keeps automatically (the 10 most recent saves, the first save of each day for 30 days, and whenever opening a **Backup** replaced it), so a damaged or mistakenly replaced **Vault** can be restored. +An earlier version of this computer's **Vault** that the app keeps automatically (the 10 most recent saves, the first save of each day for 30 days, and for at least 30 days whenever opening a **Backup** replaced it), so a damaged or mistakenly replaced **Vault** can be restored. _Avoid_: old vault, previous version, auto-backup, snapshot **Emergency Reader**: @@ -33,7 +37,7 @@ A long random code generated once at setup that can also open the **Vault** and _Avoid_: backup code, secret key, reset code **Key Rotation**: -Replacing the **Vault**'s internal keys and its **Recovery Key** (so the owner writes a new **Recovery Kit**), used when the old **Master Password** or **Recovery Kit** may be known to someone else. +Replacing the **Vault**'s internal keys and its **Recovery Key** (so the owner writes a new **Recovery Kit**), used when the old **Master Password** or **Recovery Kit** may be known to someone else, or the owner lost the paper with the **Recovery Key**. _Avoid_: reset, re-key, re-encrypt **Locked** / **Unlocked**: @@ -49,7 +53,7 @@ Opening a **Locked** app with Touch ID on a supported Mac after explicit enrollm _Avoid_: biometric login, fingerprint login **Recovery Kit**: -The sheet (printed or handwritten) the owner keeps somewhere physical, carrying the **Recovery Key**. +The sheet (printed or handwritten) the owner keeps somewhere physical, carrying the **Recovery Key**. On screen: your paper with your Recovery Key. _Avoid_: emergency kit, backup sheet ### Working with AI agents @@ -147,6 +151,7 @@ _Avoid_: pinned, bookmarked, starred item - A **Note** in **Trash** is still inside the **Vault** (still encrypted); "Delete forever" removes it from this copy only; older copies still contain it. - Removing a **Wallet Kind** needs the **Master Password**, like every other change to one: moving it to **Trash**, "Delete forever", and emptying a **Trash** that holds one. - Changing the **Master Password** changes it only in the copy of the **Vault** where it was changed; older copies still open with the old **Master Password** and old **Recovery Key**. +- Restoring an older copy makes its old **Master Password** and **Recovery Key** current again. - Changing the **Master Password** keeps the same **Recovery Key**; only **Key Rotation** replaces it. ## Example dialogue diff --git a/README.md b/README.md index e4923a6..08be651 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,15 @@ An offline, encrypted Vault for Seed Phrases, Private Keys, Logins and API Keys, ## Features -- **Offline Vault.** The Vault has no network code, accounts, sync, updater or telemetry; it's one encrypted file on your computer. An optional [Agent Companion](docs/agent-companion.md), off unless you start it, runs in its own sandbox, can't open the Vault, and sends text an approved agent supplies to OpenAI. -- **Hidden by default.** Secrets stay as dots until you choose Show. Seed Phrases and Private Keys also need your Master Password. -- **Careful on screen shares.** Asks the OS to keep the window out of captures (best effort) and covers it when a screen-sharing app is running. +- **Offline Vault.** The Vault has no network code, accounts, sync, updater or telemetry. Your Notes are saved encrypted in one private folder on your computer, next to their encrypted Safety Copies and a small file of non-secret settings ([where your keys are stored](docs/guide.md#where-your-keys-are-stored)). An optional [Agent Companion](docs/agent-companion.md), off unless you start it, runs in its own sandbox, can't open the Vault, and sends text an approved agent supplies to OpenAI. It keeps its Agent Key in its own plain-text file, not in the Vault. +- **Hidden by default.** Hidden Fields stay as dots until you choose Show. Seed Phrases and Private Keys also need your Master Password. +- **Careful on screen shares.** Asks the OS to keep the window out of captures (best effort) and covers it when a known screen-sharing or recording app is running; meetings inside a web browser aren't detected. - **Recoverable.** A Recovery Key you write down at setup, and an [Emergency Reader](docs/guide.md#if-the-app-wont-open) that opens your Vault without the app. -- **Portable.** Copy the Vault file to a USB stick to open it on another computer. +- **Portable.** Use Back up now to save an encrypted copy, for example on a USB stick, then [open it on another computer](docs/guide.md#moving-to-another-computer). > [!WARNING] -> v1 personal project, not independently audited. Keep a paper or metal backup of any seed phrase that holds real funds. +> v1 personal project, not independently audited. The Windows build has not been run on a real Windows PC yet ([#11](https://github.com/nniiovoo/encrypted-note/issues/11)), so it may not work. Don't keep real secrets in it until that's done. Keep a paper or metal backup of any seed phrase that holds real funds. + ## Build @@ -62,10 +63,10 @@ Agents should test with fictional data in a separate development Vault using `EN ## Docs - [Security model](docs/security-model.md): what it protects against, how it works, what has been tested -- [User guide](docs/guide.md): keyboard shortcuts, and reading your Vault if the app won't open +- [User guide](docs/guide.md): where your keys are stored, keyboard shortcuts, and reading your Vault if the app won't open - [Agent Companion](docs/agent-companion.md): the optional helper for one approved agent, setup and limits - [File format](docs/FORMAT.md) · [Design decisions](docs/adr/) · [Glossary](CONTEXT.md) ## License -[MIT](LICENSE). The passphrase generator uses the [EFF Long Wordlist](https://www.eff.org/dice) ([CC BY 3.0 US](https://creativecommons.org/licenses/by/3.0/us/)). Seed Phrase checks use the BIP39 English word list via the [`bip39`](https://crates.io/crates/bip39) crate. +[MIT](LICENSE). The suggested Master Password uses the [EFF Long Wordlist](https://www.eff.org/dice) ([CC BY 3.0 US](https://creativecommons.org/licenses/by/3.0/us/)). Seed Phrase checks use the BIP39 English word list via the [`bip39`](https://crates.io/crates/bip39) crate. diff --git a/crates/policy/src/lib.rs b/crates/policy/src/lib.rs index 0fc8755..738c435 100644 --- a/crates/policy/src/lib.rs +++ b/crates/policy/src/lib.rs @@ -43,11 +43,12 @@ pub const MIN_GUESSES_LOG10: f64 = 16.0; const EFF_LIST: &str = include_str!("../data/eff_large_wordlist.txt"); const FAMOUS_PHRASES: &str = include_str!("../data/famous_phrases.txt"); -const HINT_LENGTH: &str = "Use at least 15 characters. A few random words work well."; +const HINT_LENGTH: &str = + "Use at least 15 characters. 5 or 6 random words work well, or press Suggest a password."; const HINT_WELL_KNOWN: &str = "This is a well-known phrase that attackers try early. \ - Choose something of your own, or use a suggested passphrase."; + Choose your own words, or press Suggest a password."; const HINT_GUESSABLE: &str = - "This could still be guessed. Add another uncommon word or two, or use a suggested passphrase."; + "This could still be guessed. Add another unusual word or two, or press Suggest a password."; #[derive(Clone, Debug, PartialEq, Eq)] pub struct Assessment { @@ -59,6 +60,11 @@ pub struct Assessment { pub feedback: Vec, } +/// Characters on the NFC form the Vault uses: what the 15-character rule in [`assess`] counts. +pub fn char_count(password: &str) -> usize { + password.nfc().count() +} + pub fn assess(password: &str) -> Assessment { let nfc: Zeroizing = Zeroizing::new(password.nfc().collect()); let long_enough = nfc.chars().count() >= MIN_CHARS; @@ -107,7 +113,7 @@ fn push_unique(feedback: &mut Vec, hint: &str) { /// The weaker of two readings of the password, as typed and without its separators: zxcvbn's /// analysis of that reading, and log10 of the guesses it comes to (see [`guesses_log10`]). /// -/// zxcvbn can take seconds on a long password full of symbols and this runs as the owner types, +/// zxcvbn can take seconds on a long password full of symbols and this runs at typing pauses, /// so the second reading is made only when it can change the answer: the password passes as /// typed, and taking the separators out leaves something different to judge. Neither reading is /// cut shorter than zxcvbn's own 100 characters: a cut can land inside a repeat and raise the diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 4017f86..d265871 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -1,7 +1,7 @@ //! Vault store: everything about the files on disk. Knows nothing about crypto; it moves //! opaque bytes and calls a `verify` callback the app supplies (which decrypts in memory). //! -//! Layout inside the data folder (created 0700 on Unix; files 0600): +//! Layout inside the Vault folder (created 0700 on Unix; files 0600): //! ```text //! / e.g. ~/Library/Application Support//Vault.noindex //! vault.enote the Vault @@ -9,6 +9,7 @@ //! settings.json non-secret preferences (opaque bytes to this crate) //! safety/auto--.enote automatic Safety Copies (previous version on each save) //! safety/replaced--.enote Safety Copies made when a Backup replaced the Vault +//! quick-unlock.sealed Touch ID registration, written by the app shell (not this crate) //! .tmp- in-flight writes (removed at open if left over) //! ``` //! diff --git a/docs/adr/0005-password-change-rewraps-key-rotation-is-separate.md b/docs/adr/0005-password-change-rewraps-key-rotation-is-separate.md index 49aff34..2c13a68 100644 --- a/docs/adr/0005-password-change-rewraps-key-rotation-is-separate.md +++ b/docs/adr/0005-password-change-rewraps-key-rotation-is-separate.md @@ -1,11 +1,12 @@ # Changing the Master Password re-wraps keys; Key Rotation is a separate step -Each way into the **Vault** (the **Master Password**, the **Recovery Key**) has its own slot in the header. The slot holds the Vault Key and the Wallet Key (ADR-0004), encrypted under a key derived from that credential with Argon2id. Changing the **Master Password** re-encrypts only the password slot, so it's instant and the **Recovery Kit** stays valid. Replacing the Vault Key would also mean re-encrypting the recovery slot, and that requires the **Recovery Key**, which the app deliberately never stores. So we don't rotate on every password change. We offer **Key Rotation** instead: new Vault Key, new Wallet Key and new **Recovery Key**, re-encrypting every **Note** and showing a new **Recovery Kit**. The change-password screen asks why the owner is changing it. "Someone may know my old password" also runs **Key Rotation**. +Each way into the **Vault** (the **Master Password**, the **Recovery Key**) has its own slot in the header. The slot holds the Vault Key and the Wallet Key (ADR-0004), encrypted under a key derived from that credential with Argon2id. Changing the **Master Password** re-encrypts only the password slot, so it's instant and the **Recovery Kit** stays valid. Replacing the Vault Key would also mean re-encrypting the recovery slot, and that requires the **Recovery Key**, which the app deliberately never stores. So we don't rotate on every password change. We offer **Key Rotation** instead: new Vault Key, new Wallet Key and new **Recovery Key**, re-encrypting every **Note** and showing a new **Recovery Kit**. The change-password screen asks why the owner is changing it. "Someone may know it, or I lost my paper" also runs **Key Rotation**. It needs only the current **Master Password**, so an owner who lost the paper can still get a new **Recovery Key**. ## Consequences - Without **Key Rotation**, someone holding an old copy plus the old **Master Password** could decrypt a newer copy of the same **Vault** if they obtained it. The UI says so plainly. - After **Key Rotation** the old **Recovery Kit** no longer opens this copy (or later copies), but it still opens older copies. +- Restoring an older copy makes its old credentials current again. ## Considered Options diff --git a/docs/adr/0006-everyday-key-use-limits-the-owner-can-stretch.md b/docs/adr/0006-everyday-key-use-limits-the-owner-can-stretch.md index 3c911c6..7d01312 100644 --- a/docs/adr/0006-everyday-key-use-limits-the-owner-can-stretch.md +++ b/docs/adr/0006-everyday-key-use-limits-the-owner-can-stretch.md @@ -43,6 +43,14 @@ The clipboard choice remains 30 or 60 seconds under its existing policy, and a * Loosening Auto-lock now collects the Master Password in Rust-owned native controls. Rust freezes the complete effective candidate and checks the current native operation/session before applying it; no password or reusable approval is returned to the WebView. This source implementation of [issue #13](https://github.com/nniiovoo/encrypted-note/issues/13) still needs physical native accessibility/capture acceptance. New Hidden Field input and ordinary Show remain in the renderer under ADR-0003's stated limits. +## Show in the Editor: owner decision, 30 October 2026 + +The owner approved a Show button in the Editor, so a typed **Login** password, or an **API Key**'s key or secret, can be checked before saving. A mistake there otherwise stays hidden until after the save. + +It is off by default, for those fields only: never a **Wallet Kind**, a **Passphrase**, an empty box or a box marked for removal. The box stays a password input. The value is drawn on a separate read-only line under it that can't be selected and isn't announced. It hides on window blur and 30 seconds after the press, and typing doesn't extend that. It reads only the typed text already in the Editor, never asks the core to show a stored value, and isn't kept in the unsaved draft. + +This adds time on screen, not a new path for the value: typed values are already in WebView memory under ADR-0003. It is best-effort like any text on screen, and this describes the source until it is tested by hand. + ## Considered Options - **Keep one Note's decrypted Wallet Kind fields for a short "visit" so several actions need one Master Password**: rejected. It is the state ADR-0004 exists to prevent; show-and-copy and the bounded extension cover most of the need. diff --git a/docs/capture-test.md b/docs/capture-test.md index 255842a..495a797 100644 --- a/docs/capture-test.md +++ b/docs/capture-test.md @@ -95,6 +95,28 @@ Use a fake-data Vault for these checks. Copy a fake Hidden Field through the app Capture Hiding and Sharing Guard are separate observations. A captured shield is not proof of Capture Hiding. Browser meetings are not detected by the Sharing Guard (ADR-0002). File each failure as a separate issue and link it in its row. +### Native panels and focus + +Use a fake-data Vault and fake values only. The steps come from the Windows checks in [native-credential-design.md](native-credential-design.md). + +| Check | Expected observation | Result / sanitized evidence / issue | +|---|---|---| +| Create and Unlock panels | Each panel opens and stays open | pending | +| Alt-Tab with a panel open | The panel closes, with no value left on screen | pending | +| Alt-Tab with a Hidden Field shown | The value hides and the reveal ends | pending | +| Alt-Tab with lock on app switch turned on | The Vault locks | pending | +| Enter on a focused Cancel | The panel cancels, except on the Recovery Kit step, where the first press only asks | pending | +| Click Cancel once on the Recovery Kit step, then type | The keys go into the box | pending | +| Click Suggest a password, then press Enter | It asks if you wrote it down, and the words don't change | pending | +| A refusal after clicking Continue | The next keys go into a box | pending | +| Type right after a panel opens, and after dragging the window by its title bar | One mask dot per key; otherwise file a separate security issue (ADR-0003) | pending | +| Each key with the core lock held on purpose (needs a temporary debug build) | One more mask dot in a Master Password, Recovery Key, New or Confirm box | pending | +| Recovery Kit retype box with Pinyin or another input method | The input method stays off | pending | +| 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 | + ### Mac ↔ Windows Backup round trip Use current builds on both computers, one exFAT USB stick prepared by the owner, and only fake Vaults. No formatting, deletion, or replacement of real data is part of this test. Include at least one fake Note with an optional schema-2 field (for example the purpose of a Login), so portability covers the current file format. Opening a Backup replaces this computer's Vault; confirm the destination is the disposable fake Vault before accepting. diff --git a/docs/guide.md b/docs/guide.md index 2c141e5..84620f7 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -1,5 +1,39 @@ # User guide +## Where your keys are stored + +Your Vault is saved in one folder in your user account on this computer (table below). In the app: Settings → Where your keys are stored. + +| Computer | Vault folder | +|---|---| +| Mac, the built app | `~/Library/Containers/com.encryptednote.app/Data/Library/Application Support/com.encryptednote.app/Vault.noindex` | +| Mac, a release build that isn't sandboxed | `~/Library/Application Support/com.encryptednote.app/Vault.noindex` | +| Mac, a test run (`npm run tauri dev`) | `~/Library/Application Support/com.encryptednote.app/Vault.dev.noindex`. A Vault from an older test run stays in `Vault.noindex` beside it. A Windows test run uses `Vault.dev.noindex` in the Windows folder. | +| Windows | `%LOCALAPPDATA%\com.encryptednote.app\Vault.noindex` | + +| What | Where | Encrypted? | +|---|---|---| +| Your Notes, including API Keys, Private Keys and Seed Phrases | `vault.enote` in the Vault folder | Yes. Seed Phrases and Private Keys are encrypted twice. | +| The keys that lock your Vault | Inside `vault.enote`, locked by your Master Password and by your Recovery Key | Yes. | +| Safety Copies | `safety/` in the Vault folder | Yes. Each opens with the Master Password and Recovery Key it had when saved. A Note you deleted or changed stays inside older copies until the app removes them. It keeps your 10 most recent saves, the first save of each day for 30 days, and a Vault replaced by a Backup or restored copy for at least 30 days. | +| Master Password | Not in any file | Only you know it. | +| Recovery Key | Not in any file | Only on the paper where you wrote it. | +| Touch ID (Mac, if you turn it on) | `quick-unlock.sealed` in the Vault folder | Yes. It holds a protected copy of the key that opens your Logins, API Keys and Text. It works only on this Mac, with a fingerprint saved on this Mac. It can't open the secret parts of Seed Phrases or Private Keys. | +| Settings | `settings.json` in the Vault folder | No. Choices, dates, the count of wrong password tries and, on a Mac, a check value for Touch ID; no Notes, titles or keys. | +| Backups | Wherever you saved them. The app suggests the name `encrypted-note-backup-YYYY-MM-DD-HHMM.enote`, with the date and time in UTC | Yes. Each opens with the Master Password and Recovery Key it had when saved. The app doesn't remember where. A Note you deleted or changed stays inside an old Backup for as long as you keep it. | +| Copied values | Clipboard for 30 or 60 seconds (Seed Phrases and Private Keys always 30), then cleared | No. Other apps could read it during that time. If the app crashes, it can stay until you copy something else. | +| Open Notes | App memory while Unlocked | Cleared on lock and quit, as far as the computer allows. The key that opens your Notes, unsaved typing and shown values are in memory too, and the computer may write memory to disk. | +| Agent Key (only if you set up the Agent Companion) | `credential.env` in the helper's folder: `~/Library/Containers/com.encryptednote.app.companion/Data/Library/Application Support/encrypted-note-companion/` | **No. Plain text.** Other macOS accounts can't read it, but other software running under your account may be able to. While the Agent Companion runs, the key is also in the helper's memory. The file you made for setup also holds the key until you delete it. Deleting a file doesn't cancel the key: cancel it at OpenAI. | +| Agent token (only while the Agent Companion runs) | `/Users/Shared/encrypted-note-companion/agent-token` | No. Made only for the agent account. Removed when the Agent Companion stops normally; each Start makes a new one. Not your Agent Key. | + +Nothing is kept in Apple Keychain or on any server. + +Time Machine or other full-computer backups may also copy these folders. Your Vault stays encrypted there, but old copies open with the old Master Password and Recovery Key, and the Agent Key file stays plain text. + +**What to keep safe:** the paper with your Recovery Key, somewhere physical and apart from your Backups (never photograph it or save it on a computer), and a Backup made with Back up now. Nothing else in the folder needs copying. + +**If you lose the paper:** if you still know your Master Password, you can make a new Recovery Key. In Settings, go to Change Master Password and choose "Someone may know it, or I lost my paper". You also choose a new Master Password there, and then write down the new Recovery Key. Touch ID turns off, and you can turn it on again. Then choose Back up now. Your older Backups and Safety Copies still open only with the old password and the old key, and anyone who finds the old paper could open those old copies. + ## Keyboard shortcuts Cmd on Mac, Ctrl on Windows. @@ -11,41 +45,88 @@ Cmd on Mac, Ctrl on Windows. | Cmd/Ctrl+N | New Note (then **1** to **5** pick its Kind) | | Cmd/Ctrl+S or Cmd/Ctrl+Enter | Save | | Cmd/Ctrl+Shift+C | Copy the open Note's first Hidden Field | +| Esc | Leave the Editor (asks first if anything changed) or the Kind picker | Copy feedback names the Note and field that reached the clipboard. The shortcut still targets the open Note while search highlights another Note. Lock and Sharing Guard remove the source title from feedback. ## Opening the Vault and Wallet values -The app collects your Master Password and Recovery Key in native controls. New-password suggestion, strength feedback, confirmation and your Recovery Kit stay there too. Wallet values already stored in the Vault use native readouts after a fresh Master Password check; copying happens inside the Rust core. New or edited Hidden Field values still use the Note editor in the WebView. +You type your Master Password and Recovery Key on a separate screen inside the app window, made with your computer's own controls. The normal app page can't read what you type there, but this doesn't stop malware already on your computer. Your Recovery Key is not in any file: the app shows it once, and the only copy is the one you write on paper. To show a stored Seed Phrase or Private Key you type your Master Password again, and it appears on that same screen. New or edited values, including new Seed Phrases and Private Keys, are typed in the normal Note editor in the app's window, so they don't get this extra protection. + +**Checking what you typed.** Password boxes show dots. Nothing is shown unless you ask, and each option starts off every time: + +- Recovery Key box, Mac only: tick **Show what I type**. The box keeps its dots, and a separate line shows the characters. It turns off after 1 minute with no typing. Tick it again to see the key. On Mac and Windows the box also counts the characters and checks for typing mistakes, which shows nothing secret. +- New and Confirm Master Password boxes, Mac only: click the box you want to check, then press and hold **Hold to show what I typed**. A line above the text names the box. If that box is empty, or you use the keyboard to hold, it shows the box you typed in last. It lasts about 10 seconds at most, and it can't be used until one of the two boxes has text. +- Note editor: press **Show** for a Login password, or for an API Key's key or secret. The value appears on a line under the box. It hides after 30 seconds, or when you leave the window. Seed Phrases and Private Keys never have this button. + +The first two can't be used while a known screen-sharing or recording app is running, even if you dismissed the Sharing Guard. A line on the screen says so. Quit that app, then open the screen again. All of them put the text on your screen, and keeping the window out of captures is best-effort, so stop any screen share first. These options have not been tested by hand yet. + +**Suggest a password** shows the new password in plain text on the same screen, on Mac and Windows, so you can write it down. It does this even while a known screen-sharing or recording app is running, if you dismissed the Sharing Guard, so stop any screen share first. + +One box is different: when the app gives you a new Recovery Key, the box where you type its last two groups shows what you type, because the key is on screen right above it. You can't copy or cut from that box, and right-clicking it does nothing. You can still paste into it with Cmd+V on Mac or Ctrl+V on Windows. + +**Using a password manager.** Switching to another app closes the password box, so copy your Master Password before you start, then paste it in the box that opens (Cmd+V on Mac, Ctrl+V on Windows). A password you paste from another app gets none of this app's clipboard protection: other apps and clipboard history can read it, and this app can't clear it. When you finish, copy something else to replace it on the clipboard. This doesn't remove it from a clipboard history app. Pasting into these boxes has not been tested by hand yet. + +On a supported Mac, open Settings → Touch ID → Turn on Touch ID. Type your Master Password, then touch the sensor. This uses your Mac's security chip and a small encrypted file. It needs no account, server or Apple Developer membership. If Touch ID isn't available on this Mac, use your Master Password. + +After every app restart, use your Master Password before Touch ID. Enter it again at least every seven days; Touch ID never moves that deadline. Changing your Master Password, using your Recovery Key, Key Rotation and installing a Backup or Safety Copy turn Touch ID off, and you have to turn Touch ID on again. Wallet actions continue to require their own fresh Master Password. Choosing Forgot it? Use your Recovery Key turns Touch ID off as soon as the box opens, even if you cancel. Starting to change your Master Password does the same. A missed finger, a lockout, a closed lid or a disconnected Touch ID keyboard leaves Touch ID on. Changing the fingers saved on this Mac turns it off. + +To turn Touch ID off, use the same Settings section. This also deletes its encrypted file. If deleting the file fails, Touch ID stays off and the app asks you to try again. Old system backups or copied files may still exist and can still work on this Mac. Other apps running under your macOS account can copy this file and ask for Touch ID to open it, so only touch the sensor when you asked for it yourself. + +These protected screens and Touch ID are not yet fully tested on real hardware, including keyboard, screen reader and capture use. See the [security model](security-model.md) and the [Quick Unlock acceptance record](quick-unlock-design.md#boundaries-and-acceptance-still-pending). Windows Hello comes later. -On a supported Mac, open Settings → Touch ID → Enable Touch ID. Enter the current Vault's Master Password in the native panel, then complete the protected Touch ID read-back. This local route uses the Secure Enclave and an encrypted private file; it needs no account, server or Apple Developer membership/profile. If the hardware or its enrolled biometric protection is unavailable, use your Master Password. +## Moving to another computer -After every app restart, use your Master Password before Touch ID. Enter it again at least every seven days; Touch ID never moves that deadline. Password/recovery changes, Key Rotation and installing a Backup or Safety Copy turn the registration off and require explicit enrollment again. Wallet actions continue to require their own fresh Master Password. +Build a current encrypted-note there first. Choose Settings → Backups → Back up now. On the other computer choose "I already have a Vault → Open a Backup". To go back, on the Locked screen choose "Restore a saved copy (Backup or Safety Copy)". This computer's current Vault is kept as a Safety Copy. -Disable Touch ID in the same Settings section to turn app policy off and remove its current encrypted registration. A cleanup error leaves it disabled and asks you to retry. Old system backups or copied registration files are not assured erased. Apps with the same user's file access are outside a Keychain application-access-group boundary; approve only biometric prompts you intentionally requested. +Restoring an older copy also brings back the Master Password and Recovery Key it had. If those differ from the ones you use now, the ones you use now stop working, so keep the paper with the Recovery Key that matches that copy. -The native/Touch ID implementation is present in source; its physical hardware, accessibility and capture acceptance are still pending. See the [security model](security-model.md) and [Quick Unlock acceptance record](quick-unlock-design.md#boundaries-and-acceptance-still-pending). Windows Hello remains later. +## Deleting the app + +Deleting the Mac app leaves the Vault folder in place, so your Vault stays on this computer. If you set up the Agent Companion, its Agent Key file stays too: see [Stop, revoke, clean up](agent-companion.md#stop-revoke-clean-up). + +On Windows, what the uninstaller does to the Vault folder has not been checked. Make a Backup first and keep it outside that folder. If the uninstaller offers to delete the app's data, that could remove your Vault and Safety Copies. ## If the app won't open Your Notes are still in the Vault file, and the Emergency Reader reads it without the app. -**Where the files are.** One folder holds `vault.enote` (the Vault) and a `safety` folder (the Safety Copies, each a complete Vault). While the app still runs, Settings → Backups shows that folder. +**Where the files are.** One folder holds `vault.enote` (the Vault), a `safety` folder (the Safety Copies, each a complete Vault), `settings.json`, and `quick-unlock.sealed` if you use Touch ID. While the app still runs, Settings → Where your keys are stored shows that folder and lets you copy its path. The folder for each computer is in the table under [Where your keys are stored](#where-your-keys-are-stored). -| Computer | Vault folder | -|---|---| -| Mac, the built app | `~/Library/Containers/com.encryptednote.app/Data/Library/Application Support/com.encryptednote.app/Vault.noindex` | -| Mac, a build that isn't sandboxed (such as `npm run tauri dev`) | `~/Library/Application Support/com.encryptednote.app/Vault.noindex` | -| Windows | `%LOCALAPPDATA%\com.encryptednote.app\Vault.noindex` | - -The built Mac app is sandboxed, so its folder is inside the app's container, and macOS may ask before Terminal is allowed to read it. +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. ```bash -cargo run -p enote-reader -- # list every Note with a number; Hidden Fields stay as dots -cargo run -p enote-reader -- --note 3 --reveal # only Note 3, with its Hidden Fields -cargo run -p enote-reader -- --reveal # every Note, with its Hidden Fields +cargo run --locked -p enote-reader -- # list every Note with a number; Hidden Fields stay as dots +cargo run --locked -p enote-reader -- --note 3 --reveal # only Note 3, with its Hidden Fields +cargo run --locked -p enote-reader -- --reveal # every Note, with its Hidden Fields +``` + +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. + +Mac, the built app: + +```bash +cargo run --locked -p enote-reader -- "$HOME/Library/Containers/com.encryptednote.app/Data/Library/Application Support/com.encryptednote.app/Vault.noindex/vault.enote" +``` + +Mac, a release build that isn't sandboxed (for a test run, use `Vault.dev.noindex` in this path): + +```bash +cargo run --locked -p enote-reader -- "$HOME/Library/Application Support/com.encryptednote.app/Vault.noindex/vault.enote" ``` -Put the path in quotes: the Mac one has a space in it. `--reveal` prints only to a terminal, so nothing lands in a file by accident. +Windows, PowerShell (this line has not been run on a real Windows PC yet; `%LOCALAPPDATA%` from the table works only in Command Prompt and File Explorer): + +```powershell +cargo run --locked -p enote-reader -- "$env:LOCALAPPDATA\com.encryptednote.app\Vault.noindex\vault.enote" +``` + +Keep `--note` and `--reveal` after the file, as shown above. `--reveal` prints only to a terminal, so nothing lands in a file by accident. + +The terminal has no Capture Hiding or Sharing Guard. Before `--reveal`, stop any screen share, use `--note --reveal` to show only the Note you need, and close the terminal window when you finish. + +`No such file or directory` means the path is wrong, not that your Vault is gone. + +The built Mac app's folder is protected from other apps, so macOS may ask before Terminal can read it. `Operation not permitted` means macOS said no. Run the command again and allow it when macOS asks. If you refused before and it doesn't ask again, change that choice for Terminal in System Settings → Privacy & Security. Or give the reader a Backup instead, because a Backup isn't in that protected folder. These macOS steps have not been checked by hand. diff --git a/docs/native-credential-design.md b/docs/native-credential-design.md index e6e17e2..b8caabe 100644 --- a/docs/native-credential-design.md +++ b/docs/native-credential-design.md @@ -29,7 +29,13 @@ Raw password-generation/scoring IPC, password arguments, React password modals a ## Cancellation, retention and limits -Native operations expire after two minutes. Escape/close, explicit cancellation/navigation/reload, owner Lock, timeout, sleep, screen lock, user switch, focus loss, replacement/password/rotation transitions, target mutation/removal or Sharing Guard arrival invalidates pending work. Rust rejects stale callbacks before failed-password accounting or any plaintext, clipboard or disk effect. A cancelled derivation cannot revive a held reveal or approve another action. +Native operations expire after two minutes, with these exceptions: choosing a new Master Password has up to ten minutes and ends after five minutes with no typing or clicks; typing a Recovery Key has five minutes; the Recovery Kit step has its own ten minutes; a Wallet readout has its own two minutes from when the Master Password is accepted, and its reveal limits can end it sooner. Auto-lock and every other invalidation listed here can end any panel sooner. After a valid submit on a long entry, the core and coordinator allow two more minutes for key derivation and the commit; every commit recheck stays. Escape/close, explicit cancellation/navigation/reload, owner Lock, timeout, sleep, screen lock, user switch, focus loss, replacement/password/rotation transitions, target mutation/removal or Sharing Guard arrival invalidates pending work. Rust rejects stale callbacks before failed-password accounting or any plaintext, clipboard or disk effect. A cancelled derivation cannot revive a held reveal or approve another action. + +On the Recovery Kit step the first Escape or Cancel only asks, and a second press within ten seconds cancels. Until then the key stays on screen; switching apps, Lock, the Sharing Guard and the time limit still close it at once. While the step shows Please wait…, Escape and Cancel are rejected like every other action; switching apps and the time limit still close it. On that step, and on Recovery Key entry and the new-password boxes, one brief Retry within 350 ms of a successful check paints nothing and still rejects actions, so a press in that moment does nothing and says nothing. A suggested password, and on Mac an opt-in view, can stay on screen that much longer, because the Sharing Guard can't update while the core is locked either. Typing in a box still lands while the core is busy, on every panel with a box: a missing key in a box of dots can't be seen, and it would change the password. On Mac the field editor takes the key before anything checks it. On Windows the box's subclass lets plain typing through, except in a blanked Recovery Kit retype box, and rejects arrows, Delete, Tab and Ctrl+V until the core is free. + +Master Password and Recovery Key entry boxes stay secure, masked fields. The Recovery Kit step's retype box shows what is typed, since the key is on screen right above it, so nothing may carry its text away: on Mac its own field editor refuses copy, cut, dragging out, Services, the right-click menu, Writing Tools and Look Up, which would open a system window that Capture Hiding doesn't cover, and on Windows the box refuses copy, cut and the context menu. Typing, Select All and paste by keyboard or the Edit menu stay; right-click Paste goes with the menu. Two opt-in views exist on Mac only, each off by default. **Show what I type**, on Recovery Key entry, is off each time and is never remembered; it shows a separate read-only line that holds only symbols a Recovery Key can contain, grouped and capped at 28 characters, with anything else as `?`. Letters and digits of a password typed there by mistake would still show. It turns off after about a minute with no real key press. The typed key stays in the box until the panel closes, so someone at the keyboard can tick it again or press Continue. **Hold to show what I typed**, on the New and Confirm Master Password boxes, shows a snapshot while held, for about ten seconds at most, under a caption with that box's label: the one of those two boxes that has the focus and holds text, otherwise the last-edited one, and never the Current box. A keyboard hold has no box in focus, so it shows the last-edited one. The button stays disabled until one of the two boxes has text. Both views are hidden from the accessibility tree, blank when the panel suspends or closes and don't come back on their own, and refuse while a known sharing app is detected, even after its Shield was dismissed; a plain line then says why and names no app. A brief busy core is not a detection: the timer leaves them as they are and the Retry grace above applies, but a press during it shows nothing. Capture Hiding is best effort, so text on screen can still be captured. A suggested password is a third case of plain text on screen, on both platforms: Suggest a password draws it on a separate line so it can be written down, assistive tools can read that line, and it is not refused while a known sharing app is detected after a dismissal. Assistive tools can also read the Recovery Kit step's key line and its retype box. Recovery Key entry also has a character count and a local typo check on both platforms; the check runs no key derivation and counts no attempt, and its messages hold only counts and group numbers. Physical runs with fake values are required on Mac before this is claimed to work. + +New-password boxes are read at typing pauses for length, match and strength. Those checks leave extra unwiped copies inside zxcvbn and, on Mac, in NSString, within the best-effort limits below. Continue always re-runs the real checks. Keys pressed before a native box appears go to the WebView. Native activity keeps the existing idle timer current. Wallet readouts are bound to the native operation epoch and current reveal identity, so an obsolete panel cannot hide or extend a newer field. Recovery Kit cancellation rolls back the uncommitted creation/rotation state. Plaintext values and transient Wallet Keys are dropped on every completion/error path; the app does not cache a previous Master Password approval for future Wallet actions. @@ -37,8 +43,26 @@ 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 latest build/test report records execution; 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. Browser fixture checks cover non-credential IPC contracts and the migrated callers. 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. +Open Note regression, with a fictional Seed Phrase: from an open Note, Continue: hold to show, Copy and Move to Trash must keep their panel open, and switching apps must still close it. With a fictional Login: Show, then switch apps; the value must disappear and the reveal must end. + +Recovery Kit retype box on Mac, with a fake key: type a fake value, then try Cmd+A then Cmd+C, Cmd+X, Edit > Copy, right-click, the app menu's Services and dragging the selection out. The clipboard must not change and nothing must leave the box, while typing, Cmd+V and Edit > Paste still work. Then try a force click, a three-finger tap and Ctrl-Cmd-D on the typed text, both with nothing selected and after Cmd+A: no Look Up window may open. The force-click route relies on private AppKit names, and the route Ctrl-Cmd-D takes hasn't been traced, so if it opens Look Up, that needs its own fix. Also with Pinyin selected: the box takes letters as typed, and normal input comes back after the panel closes. During a forced core-lock blink long enough to say Please wait…, the retype text and caret must be hidden, and must come back. Confirm the panel still opens on macOS 13 or 14 if a machine is available, and repeat the Look Up gestures there. + +Other Mac checks, with fake values: with Keyboard navigation off, Tab reaches Show what I type, Suggest a password, Hold to show and Reset to 20 seconds, and Space works on each. Hold to show on New after typing both boxes, by mouse with Keyboard navigation off and on. With a known sharing app running after a dismissal, the two views go grey and the line explains why. A forced core-lock blink leaves Show what I type ticked and a hold showing, and typing survives it, also with Full Keyboard Access on. A three-finger tap on a Wallet readout opens no Look Up window. + +Accessibility, Mac, with fake values: tick Show what I type, then press and hold the Hold to show what I typed button. While each line is shown, VoiceOver and Accessibility Inspector must not find the line or its text, either by walking the tree or by pointing at the line. A test (`src-tauri/tests/mac_typed_line.rs`) checks the line under its parent view, not a real panel on screen. + +Keyboard focus, Mac, with fake values: Tab to Suggest a password and press Space, then press Space again: the words must not silently change, and the focus has left the button. Hold Space on that button too. After a suggestion is forgotten by typing in New or Confirm, the focus stays in that box. Tab to Hold to show what I typed and press Return with a weak password, then type: the letters are added to the end of New and don't replace it. Tab to Show what I type and press Return with a short Recovery Key, then type: the letters are added to the end of the box. + +Settings, Mac: click the Vault folder path and press Cmd+C: only the path is copied. Run `tmutil isexcluded` on the helper folder and record the answer, because the guide says full-computer backups may copy the Agent Key file. Show in Finder, on the signed sandboxed build with a fictional Vault: if macOS asks whether Finder may access data from other apps, stop and review; do not tell the owner to allow it, because ADR-0001 relies on that container protection. The guide's advice to allow Terminal for the Emergency Reader is a separate case. + +Both platforms: after a Recovery Key unlock, Choose a Master Password has the keyboard. A Return held from the panel must not press it, a later Return opens the box, and Escape in the box puts the keyboard back on the button. + +Windows checks on a real PC, with fake values: Create and Unlock panels stay open. Alt-Tab with a panel open closes it, with no value left on screen. Alt-Tab with a Hidden Field shown hides it and ends the reveal. Alt-Tab with lock on app switch turned on locks. Enter on a focused Cancel cancels, except on the Recovery Kit step, where the first press only asks. Clicking Cancel once on that step, then typing, puts the keys in the box. Click Suggest a password, then press Enter: it asks if you wrote it down and the words don't change. A refusal after clicking Continue leaves the keys going to a box. Typing right after a panel opens and after dragging the window by its title bar must add one mask dot per key; if not, file a separate security issue (ADR-0003). With the core lock held on purpose (this needs a temporary debug build, because nothing in the app can force it), each key in a Master Password, Recovery Key, New or Confirm box must still add one mask dot. On the Recovery Kit retype box, the input method stays off, and a Retry long enough to say Please wait… masks the typed text and then unmasks it. Show in File Explorer opens the Vault folder. Fonts and scaling at 100%, 125%, 150% and 200%: record the System font's height, because the panel's rows use fixed pixels. Those checks belong in the Windows table in [capture-test.md](capture-test.md). + +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). diff --git a/docs/quick-unlock-design.md b/docs/quick-unlock-design.md index 8559bb2..57511e7 100644 --- a/docs/quick-unlock-design.md +++ b/docs/quick-unlock-design.md @@ -24,8 +24,9 @@ Source boundaries: | Seven-day deadline | Rust records a process-local deadline from the last successful native Master Password verification of the current Vault. The continuous clock counts sleep. Expiry, a missing clock, backward time or overflow revokes authorization. Core ticks observe expiry while Locked too. Touch ID never extends the deadline. | | Ordinary lock, sleep, screen lock or user switch | Keep the encrypted registration, drop the open Vault/decrypted Notes, and invalidate pending native authentication. A later unlock needs another protected private-key operation within the process deadline. | | Biometric request | Use a fresh LAContext with reuse disabled, paired with Apple's embedded `LAAuthenticationView` in a native panel inside the protected main window ([#95](https://github.com/nniiovoo/encrypted-note/issues/95)). The system alert would take focus and be cancelled by the app-switch rule, which stays as it is: no focus exemption or process allowlist. Request biometrics only in that view, then perform the real Secure Enclave operation with the same context and further UI refused; the view's success never authorizes anything by itself. A read with no presented view does not start. Closing the panel (Cancel, Escape, focus loss, Lock, the Sharing Guard, expiry) cancels the request and invalidates its context. Run protected work outside the core mutex, then recheck generation, phase, operation deadline and the exact current file before committing the ordinary session. Late callbacks are discarded. | -| Cancel, failed match, lockout, unavailable hardware or malformed file | Stay Locked, drop returned wiping buffers, give a sanitized fallback message and offer native Master Password entry. Never fall back to the Mac login password or clear password-attempt backoff. Invalidated or mismatching registrations require explicit re-enrollment. | -| Changed enrolled fingers | The current-set constraint makes the protected key unavailable. Require native Master Password verification and explicit enrollment; do not recreate a weaker key automatically. | +| Cancel, failed match, lockout, closed lid or disconnected Touch ID keyboard | Keep the registration and stay Locked. Drop returned wiping buffers, give a sanitized fallback message and offer native Master Password entry. Never fall back to the Mac login password or clear password-attempt backoff. Only the sensor check can report these; the error codes have not been observed on hardware. | +| Malformed or missing file, or any error from the key itself | Turn Touch ID off and say so, unless the settings write fails. Stay Locked, drop returned wiping buffers and offer native Master Password entry. Invalidated or mismatching registrations require explicit re-enrollment. | +| Changed enrolled fingers | The current-set constraint makes the protected key unavailable, which turns Touch ID off. Require native Master Password verification and explicit enrollment; do not recreate a weaker key automatically. | | Master Password change, recovery reset, Backup/Safety Copy installation | Disable registration and process authorization before the transition, including an installation with the same Vault ID or identical header. Inspecting a copy does not authorize Quick Unlock. | | Key Rotation | Disable registration as soon as rotation starts. Cancellation/failure does not enroll pending keys. After the new Recovery Kit is confirmed and the Vault saved, explicit enrollment is required again. | | Disable or stale registration | Mark the process unavailable, persist disabled policy, then remove only the exact private registration file. Failed persistence prevents scheduled old-registration cleanup and the credential transition; failed cleanup keeps app policy off and shows a warning. A newly written, uncommitted enrollment is cleaned up on failure. | @@ -44,4 +45,4 @@ Rust wipes its payload/key buffers. Swift resets the plaintext Data it owns wher Fake-data tests cover custody, ordinary-save versus slot binding, startup, seven-day boundaries, missing/backward clocks, stale callbacks, cancellation, wrong items, credential/rotation/copy transitions, persistence failures and cleanup ordering. Private-file tests cover publication, permissions, bounds and malformed input without creating an enclave key. These tests and compilation do not establish physical biometric behavior. -Physical acceptance remains open on an actual supported Mac running the local build: enable/read-back, cancel/retry, failed match and lockout, no login-password fallback, finger enrollment changes, app/OS restart, sleep/screen-lock/user-switch/app-switch races, deadline expiry, password/recovery/Backup/rotation transitions, disable/cleanup failure, file-copy restoration and app update. Record sanitized app/OS/hardware versions and outcomes. Do not record real secrets, recovery text or private file contents. Windows native credential behavior and Capture Hiding have their separate physical test matrix; Windows Hello is not implemented. +Physical acceptance remains open on an actual supported Mac running the local build: enable/read-back, cancel/retry, failed match, lockout, closed lid and a disconnected Touch ID keyboard (each should keep Touch ID on), no login-password fallback, finger enrollment changes (should turn Touch ID off), app/OS restart, sleep/screen-lock/user-switch/app-switch races, deadline expiry, password/recovery/Backup/rotation transitions, disable/cleanup failure, file-copy restoration and app update. Record sanitized app/OS/hardware versions and outcomes. Do not record real secrets, recovery text or private file contents. Windows native credential behavior and Capture Hiding have their separate physical test matrix; Windows Hello is not implemented. diff --git a/docs/security-model.md b/docs/security-model.md index 72da5df..f96a577 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -7,9 +7,9 @@ | Protects | Doesn't protect | |---|---| | 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) | A phone camera pointed at your screen | -| Accidentally showing secrets while screen sharing (layered, see below) | A hacked operating system, or being forced to unlock | -| Clipboard history and Universal/cloud clipboard (private clipboard, cleared after 30 s) | Old copies of your Vault: they still open with the old password | +| 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 | **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). @@ -17,9 +17,12 @@ - **Tauri v2.** React/TypeScript screens run in an untrusted WebView. Rust/native code owns keys and decrypted Notes. Ordinary Show returns one authorized Hidden Field; stored Wallet readouts remain native ([ADR-0003](adr/0003-tauri-with-secrets-kept-in-rust.md)). New/edited Hidden Field inputs still pass through JavaScript, so a compromised renderer can observe them. - **Native credential entry.** Actual Master Password and Recovery Key entry, password suggestion/strength/confirmation and Recovery Kit display stay in native controls. Rust binds the exact action and checks cancellation, source state and expiry before committing. No credential or reusable approval is returned through IPC. Native/OS copies and the stack remain best effort; a fake renderer prompt or compromised native process remains a risk. See [native custody](native-credential-design.md). +- **Showing typed credentials (owner decision, 30 October 2026).** Master Password and Recovery Key entry boxes stay secure, masked fields; only the Recovery Kit step's retype box shows what is typed, because the key is on screen right above it, and it refuses copy and cut. Two opt-in views exist on Mac only, each off by default: a **Show what I type** tickbox on Recovery Key entry, off each time and never remembered, which shows a separate read-only line; and a **Hold to show what I typed** button on the New and Confirm Master Password boxes, which shows a snapshot while held, for about 10 seconds at most: the one of those two boxes that has the focus and holds text, otherwise the last-edited one. Show what I type turns itself off after about a minute with no typing. Neither applies to unlocking with a Master Password, Wallet actions, the current password, or Touch ID, and both refuse while a known screen-sharing or recording app is detected, even after the Sharing Guard is dismissed. The tickbox would also show a password typed into the Recovery Key box by mistake. Both are best-effort: the text is on screen while shown, and Capture Hiding is no guarantee. A suggested password is a third case of plain text on screen, on Mac and Windows: Suggest a password draws it on a separate line so it can be written down, assistive tools can read that line, and it is not refused while a known sharing app is detected after a dismissal. Windows has neither of the two opt-in views, because it has never been run on a real PC. Recovery Key entry on both platforms also has a character count and a typo check, which show nothing secret. All of this is described from the code until tested by hand. - **Encryption.** Argon2id is calibrated on your machine (≥ 64 MiB). A random Vault Key and a separate Wallet Key are wrapped by your Master Password and by your Recovery Key ([ADR-0004](adr/0004-wallet-kinds-under-a-separate-key.md), [ADR-0005](adr/0005-password-change-rewraps-key-rotation-is-separate.md)). The file format is documented byte by byte in [FORMAT.md](FORMAT.md). - **Reveal limits.** Logins, API Keys and Text use a 30-second reveal. Seed Phrases and Private Keys need the Master Password to view or edit, with hold-to-show or a 20-second timed reveal. The limits the owner can stretch, and the ones that stay fixed, are in [ADR-0006](adr/0006-everyday-key-use-limits-the-owner-can-stretch.md). +- **Show in the Editor (owner decision, 30 October 2026).** While typing a Login password, or an API Key's key or secret, the owner can press Show to read it before saving. It is off by default and never offered for Wallet Kinds or a Passphrase. The box stays a password input; the value is drawn on a separate line that can't be selected. It hides on window blur and 30 seconds after the press, with no extension. It is best-effort, like any text on screen, and is described from the code until tested by hand. See [ADR-0006](adr/0006-everyday-key-use-limits-the-owner-can-stretch.md). - **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)). - **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)). @@ -29,6 +32,6 @@ - **Automated:** `./scripts/check.sh` runs the test suite, including tamper tests over every byte of a Vault file and the RFC 9106 and XChaCha20-Poly1305 reference vectors. - **Earlier UI, by hand on macOS 26:** the main flows, clipboard privacy markers, the Sharing Guard with real apps, and a Backup read back by the Emergency Reader. Those records do not validate the new native credential/readout controls or local Touch ID custody. Screen-capture evidence and remaining matrix: [capture-test.md](capture-test.md). -- **New native flows:** fake-data core/coordinator, custody/lifecycle and private-file checks exist; build/test reports record their execution separately. Actual Touch ID enrollment/unlock, biometric ACL behavior, restart/sleep/switch races, native accessibility and first-frame capture acceptance remain pending. +- **New native flows:** fake-data core/coordinator, custody/lifecycle and private-file checks exist. Actual Touch ID enrollment/unlock, biometric ACL behavior, restart/sleep/switch races, native accessibility and first-frame capture acceptance remain pending. So do Show what I type, Hold to show what I typed, Show in the Editor and pasting into password boxes: none has been tested by hand. - **Windows:** native runtime behavior has **not yet been tested on a real Windows PC**. Compilation checks cannot establish it; Windows Hello remains later. - **Agent Companion:** checked with fictional data only, including that the packaged helper can't read a fake Vault canary in the app's container ([results](agent-key-validation.md#integrated-helper-2026-10-26)). No real key, live request or ChatGPT connection yet; it's unavailable on Windows. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a9261f7..e354f81 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,6 +15,11 @@ crate-type = ["rlib"] name = "encrypted-note" path = "src/main.rs" +# AppKit views need the main thread, and libtest never runs a test on it. +[[test]] +name = "mac_typed_line" +harness = false + [build-dependencies] tauri-build = { version = "=2.6.3", features = ["isolation"] } @@ -76,8 +81,10 @@ objc2-core-graphics = { version = "=0.3.2", default-features = false, features = ] } objc2-app-kit = { version = "=0.3.2", default-features = false, features = [ "std", + "NSActionCell", "NSApplication", "NSButtonCell", + "NSCell", "NSFontDescriptor", "objc2-core-foundation", "NSFont", @@ -87,6 +94,7 @@ objc2-app-kit = { version = "=0.3.2", default-features = false, features = [ "NSButton", "NSSecureTextField", "NSTextField", + "NSTextFieldCell", "NSView", "NSScrollView", "NSClipView", @@ -101,9 +109,11 @@ objc2-app-kit = { version = "=0.3.2", default-features = false, features = [ # Windows adapters (platform.rs): Capture Hiding self-check and re-apply, running processes for # the Sharing Guard, private clipboard, and session-lock/sleep notifications. tao already brings # the `windows` crate (an older minor); this is the current one, with only these API families. +# Input_Ime turns the input method off on the Recovery Key retype box (native_credential/windows.rs). [target.'cfg(windows)'.dependencies] windows = { version = "=0.62.2", default-features = false, features = [ "Win32_Foundation", + "Win32_UI_Input_Ime", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Controls", "Win32_System_Threading", diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 5927088..ae44ecc 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -48,6 +48,7 @@ const COMMANDS: &[&str] = &[ "set_settings", "mark_seed_explainer_seen", "vault_location", + "reveal_vault_folder", "dismiss_sharing_guard", // The optional Agent Companion (ADR-0009): status, start and stop, plus native owner setup. "companion_status", diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index 67eeb61..5f64ecc 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -53,6 +53,7 @@ "allow-set-settings", "allow-mark-seed-explainer-seen", "allow-vault-location", + "allow-reveal-vault-folder", "allow-dismiss-sharing-guard", "allow-cancel-native-operation", "allow-enable-quick-unlock", diff --git a/src-tauri/native/biometry.swift b/src-tauri/native/biometry.swift index 946f654..e63226b 100644 --- a/src-tauri/native/biometry.swift +++ b/src-tauri/native/biometry.swift @@ -15,7 +15,7 @@ private let domain = Data("com.encryptednote.app.quick-unlock.p256.v1".utf8) private let reason = "unlock your encrypted-note Vault" private let authenticationTimeout: DispatchTimeInterval = .seconds(120) -private enum BiometryFailure: Error { case cancelled, unavailable, malformed, failed } +private enum BiometryFailure: Error { case cancelled, unavailable, malformed, failed, tryAgain } private func freshContext() -> LAContext { let context = LAContext() @@ -142,6 +142,7 @@ private func status(_ error: Error) -> Int32 { case .unavailable: return 2 case .malformed: return 3 case .failed: return 4 + case .tryAgain: return 5 } } if let error = error as? LAError, @@ -164,7 +165,17 @@ private func authenticate(_ context: LAContext, _ access: SecAccessControl) thro context.invalidate() throw BiometryFailure.cancelled } - if let error = outcome.error { throw error } + if let error = outcome.error { + // A missed finger, a lockout, a closed lid or a disconnected Touch ID keyboard leaves + // Touch ID on. Only this check says so: an error from the key itself (such as changed + // fingers) still turns it off. + if let error = error as? LAError, + [.authenticationFailed, .biometryLockout, .biometryNotAvailable, + .biometryDisconnected, .biometryNotPaired].contains(error.code) { + throw BiometryFailure.tryAgain + } + throw error + } } private func randomBytes(_ count: Int) throws -> Data { diff --git a/src-tauri/src/biometry.rs b/src-tauri/src/biometry.rs index 524ae6e..9a8f4c5 100644 --- a/src-tauri/src/biometry.rs +++ b/src-tauri/src/biometry.rs @@ -25,6 +25,8 @@ pub enum Availability { pub enum Error { Unavailable(Availability), Cancelled, + /// A missed finger, a lockout, a closed lid or a disconnected keyboard: Touch ID stays on. + TryAgain, Missing, AlreadyExists, InvalidPayload, @@ -194,6 +196,7 @@ mod mac { 1 => Err(Error::Cancelled), 2 => Err(Error::Unavailable(Availability::BiometryUnavailable)), 3 => Err(Error::InvalidPayload), + 5 => Err(Error::TryAgain), _ => Err(Error::Failed), } } @@ -478,6 +481,15 @@ mod mac { (directory, path) } + #[test] + fn native_status_tells_a_missed_touch_from_a_key_failure() { + assert_eq!(native_status(0), Ok(())); + assert_eq!(native_status(1), Err(Error::Cancelled)); + assert_eq!(native_status(4), Err(Error::Failed)); + assert_eq!(native_status(5), Err(Error::TryAgain)); + assert_eq!(native_status(6), Err(Error::Failed)); + } + #[test] fn private_location_works_when_ancestor_listing_is_denied() { const CHILD_PATH: &str = "ENOTE_TEST_SANDBOX_LOCATION"; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index d05e553..781651e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -11,7 +11,8 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Mutex, MutexGuard, PoisonError}; +use std::sync::{Mutex, MutexGuard, PoisonError, mpsc}; +use std::time::Duration; use crate::core::native::{NativeAction, NativeResult}; use notes::seed::SeedCheck; @@ -73,16 +74,17 @@ async fn blocking( .map_err(|_| internal())? } +/// Its own text, not the core's: a panic can land here after a save has already happened. fn internal() -> ApiError { ApiError { code: "internal", - message: "Something went wrong inside encrypted-note. Nothing was changed.".to_owned(), + message: "Something went wrong inside encrypted-note. Your last action may not have finished. Check it before you try again. If it keeps happening, restart the app.".to_owned(), } } pub fn os_event(app: &AppHandle, event: OsEvent) { if !matches!(event, OsEvent::FocusGained) { - crate::native_coordinator::cancel(app); + crate::native_coordinator::interrupt(app); crate::quick_runtime::cancel(app); } with_core(app, |core| core.os_event(event)); @@ -481,6 +483,33 @@ pub fn vault_location(app: AppHandle) -> String { core(&app).vault_location() } +/// Show this computer's Vault in Finder or File Explorer. No argument of any kind: the screens +/// can't choose what opens. `DIALOG_OPEN` stays off because this is a real switch to another app, +/// so losing focus still cancels native work and locks if the owner chose that. +#[tauri::command(async)] +pub fn reveal_vault_folder(app: AppHandle) -> Result<(), ApiError> { + if crate::native_coordinator::busy(&app) { + return Err(crate::core::not_now()); + } + let target = with_core(&app, AppCore::vault_reveal_target)?; + let (tx, rx) = mpsc::channel(); + let opened = app + .run_on_main_thread(move || { + let _ = tx.send(crate::platform::reveal_in_file_manager(&target)); + }) + .is_ok() + && rx.recv_timeout(Duration::from_secs(5)).unwrap_or(false); + if opened { + Ok(()) + } else { + Err(ApiError { + code: "io", + message: "The folder couldn't be opened. You can copy the path and open it yourself." + .to_owned(), + }) + } +} + #[tauri::command(async)] pub fn dismiss_sharing_guard(app: AppHandle) { with_core(&app, AppCore::dismiss_sharing_guard); @@ -512,10 +541,16 @@ fn home_dir(app: &AppHandle) -> PathBuf { core(app).home_dir().to_path_buf() } -/// `encrypted-note-backup-YYYY-MM-DD.enote` (UTC date). +/// `encrypted-note-backup-YYYY-MM-DD-HHMM.enote` (UTC date and time), so a second Backup on one +/// day doesn't land on the first one's name, which the app can't safely replace. fn backup_file_name(unix_secs: i64) -> String { let (y, m, d) = civil_date(unix_secs); - format!("encrypted-note-backup-{y:04}-{m:02}-{d:02}.enote") + let mins = unix_secs.rem_euclid(86_400) / 60; + format!( + "encrypted-note-backup-{y:04}-{m:02}-{d:02}-{:02}{:02}.enote", + mins / 60, + mins % 60 + ) } /// Unix seconds to (year, month, day) in UTC (Howard Hinnant's `civil_from_days`). @@ -578,7 +613,7 @@ pub async fn companion_setup(app: AppHandle) -> Result let home = home.clone(); move || { rfd::FileDialog::new() - .set_title("Choose the reviewed Agent Companion plan") + .set_title("Choose the Agent Companion plan file.") .set_directory(&home) .add_filter("Companion plan", &["json"]) .pick_file() @@ -600,7 +635,7 @@ pub async fn companion_setup(app: AppHandle) -> Result .await .map_err(|_| ApiError { code: "internal", - message: "The Agent Companion setup did not finish.".to_owned(), + message: "The Agent Companion setup didn't finish. Try Set up… again.".to_owned(), }) } @@ -615,7 +650,7 @@ fn open_chosen(path: &std::path::Path) -> Result { } options.open(path).map_err(|_| ApiError { code: "io", - message: "That file could not be opened.".to_owned(), + message: "That file couldn't be opened. Check that it still exists and isn't a link to another file, then choose it again.".to_owned(), }) } @@ -635,20 +670,25 @@ mod tests { } #[test] - fn backup_file_names_carry_the_utc_date() { + fn backup_file_names_carry_the_utc_date_and_time() { assert_eq!( backup_file_name(0), - "encrypted-note-backup-1970-01-01.enote" + "encrypted-note-backup-1970-01-01-0000.enote" ); // 2024-02-29 23:59:59 UTC (leap day). assert_eq!( backup_file_name(1_709_251_199), - "encrypted-note-backup-2024-02-29.enote" + "encrypted-note-backup-2024-02-29-2359.enote" ); // 2026-09-23 12:00:00 UTC. assert_eq!( backup_file_name(1_790_164_800), - "encrypted-note-backup-2026-09-23.enote" + "encrypted-note-backup-2026-09-23-1200.enote" + ); + // Before 1970, the time of day still counts forward from midnight. + assert_eq!( + backup_file_name(-60), + "encrypted-note-backup-1969-12-31-2359.enote" ); } } diff --git a/src-tauri/src/core.rs b/src-tauri/src/core.rs index b64a5b9..91fa439 100644 --- a/src-tauri/src/core.rs +++ b/src-tauri/src/core.rs @@ -32,6 +32,7 @@ use zeroize::Zeroizing; pub mod native; mod quick_unlock; +pub(crate) use quick_unlock::MSG_TOUCH_ID_TURNED_OFF; pub use quick_unlock::{ QuickUnlockCompletion, QuickUnlockEnrollment, QuickUnlockEnrollmentCompletion, QuickUnlockJob, QuickUnlockStatus, @@ -1484,6 +1485,27 @@ impl AppCore { self.dir.root().display().to_string() } + /// What Show in Finder or File Explorer points at. The screens can't choose it, and it is + /// refused while anything else is under way: the file manager takes the owner to another app. + pub fn vault_reveal_target(&mut self) -> Result { + self.tick(); + self.open()?; + self.guard_check()?; + if self.native_pending.is_some() || self.session.current_reveal().is_some() { + return Err(not_now()); + } + let root = self.dir.root(); + // Finder selects the file inside its folder. A link is not followed anywhere else. + #[cfg(target_os = "macos")] + { + let vault = root.join(store::VAULT_FILE); + if std::fs::symlink_metadata(&vault).is_ok_and(|found| found.is_file()) { + return Ok(vault); + } + } + Ok(root.to_path_buf()) + } + /// Rust-only custody location; canonicalization errors must never select a fallback path. pub(crate) fn quick_unlock_path(&self) -> Result { self.dir @@ -1492,7 +1514,7 @@ impl AppCore { .map(|root| root.join("quick-unlock.sealed")) .map_err(|_| ApiError { code: "internal", - message: "The local Touch ID storage folder could not be opened.".to_owned(), + message: "encrypted-note can't open its data folder on this computer, so it has to close. Try opening the app again.".to_owned(), }) } @@ -2099,18 +2121,17 @@ enum CredKind { const MSG_WRONG_PASSWORD: &str = "That password didn't unlock this Vault. Check Caps Lock and try again."; const MSG_WRONG_KEY: &str = - "That Recovery Key didn't unlock this Vault. Check each group against your Recovery Kit."; + "That Recovery Key didn't unlock this Vault. Check each group of 4 against your paper."; const MSG_KEY_LENGTH: &str = - "A Recovery Key has 28 characters in 7 groups of 4. Check it against your Recovery Kit."; -const MSG_KEY_TYPO: &str = - "One of the groups doesn't look right. Check it against your Recovery Kit."; + "A Recovery Key has 28 characters in 7 groups of 4. Check it against your paper."; +const MSG_KEY_TYPO: &str = "One of the groups doesn't look right. Check it against your paper."; const MSG_DAMAGED: &str = "This Vault file is damaged. Restore a Safety Copy or Backup."; -const MSG_REFUSED_SETTINGS: &str = "This Vault file has security settings this app won't accept, so it may have been tampered with. Restore a Safety Copy or Backup."; +const MSG_REFUSED_SETTINGS: &str = "This Vault file has security settings this app won't accept, so it may be damaged, or someone may have changed it. Restore a Safety Copy or Backup."; const MSG_UNSUPPORTED: &str = "This Vault was made by a newer version of encrypted-note. Update the app, then try again."; const MSG_WEAK: &str = "This password is too easy to guess."; const MSG_LOCKED: &str = "encrypted-note is locked. Unlock it, then try again."; -const MSG_NOT_NOW: &str = "That isn't available right now."; +const MSG_NOT_NOW: &str = "That can't be done right now. If a window from this app is still open, finish it first. Then try again."; const MSG_GUARD_UP: &str = "A screen-sharing or recording app is running, so nothing can be shown or copied right now."; const MSG_NOTE_NOT_FOUND: &str = "That Note couldn't be found. It may have been deleted."; @@ -2156,10 +2177,9 @@ const MSG_ALREADY_OPEN: &str = "encrypted-note is already open. Use the window that's already open."; const MSG_NO_VAULT: &str = "There's no Vault on this computer yet."; const MSG_SAFETY_COPY_GONE: &str = "That Safety Copy isn't there any more."; -const MSG_VERIFY_FAILED: &str = - "The file was written but didn't read back correctly, so it wasn't used. Nothing was changed."; -const MSG_BACKUP_OVERWRITE_NOT_ATOMIC: &str = "This Backup already exists, and encrypted-note can't safely replace it with the access the file picker granted. Choose a new file name. Nothing was changed."; -const MSG_INTERNAL: &str = "Something went wrong inside encrypted-note. Nothing was changed."; +const MSG_VERIFY_FAILED: &str = "The file failed a check after it was saved, so it wasn't used. Nothing was changed. Please try again. If this is a Backup, choose another place, such as another USB stick."; +const MSG_BACKUP_OVERWRITE_NOT_ATOMIC: &str = "A file with this name is already there, and the app can't safely replace it. Choose a new file name. Nothing was changed."; +const MSG_INTERNAL: &str = "Something went wrong inside encrypted-note. Nothing was changed. Please try again. If it keeps happening, restart the app."; const MSG_NO_MEMORY: &str = "There isn't enough free memory to check that right now. Close other apps and try again. Nothing was changed."; const MSG_TOO_LARGE: &str = "That file is far larger than any Vault, so encrypted-note left it alone. Nothing was changed."; @@ -2175,7 +2195,7 @@ fn locked() -> ApiError { err("locked", MSG_LOCKED) } -fn not_now() -> ApiError { +pub(crate) fn not_now() -> ApiError { err("invalid_input", MSG_NOT_NOW) } @@ -2272,7 +2292,7 @@ fn store_error(e: StoreError) -> ApiError { StoreError::Io(e) => ApiError { code: "io", message: format!( - "encrypted-note couldn't read or write a file ({e}). Nothing was changed." + "encrypted-note couldn't read or write a file. Check that the disk isn't full and any USB stick is still plugged in, then try again. Nothing was changed. ({e})" ), }, } diff --git a/src-tauri/src/core/native.rs b/src-tauri/src/core/native.rs index 585beed..e2f5d14 100644 --- a/src-tauri/src/core/native.rs +++ b/src-tauri/src/core/native.rs @@ -4,6 +4,14 @@ use super::*; const OPERATION_MS: u64 = 2 * 60_000; +/// The owner copies 28 characters onto paper in this step, and running out of time wastes the +/// key they just wrote. Auto-lock, app switches and every other invalidation still end it. +const RECOVERY_KIT_MS: u64 = 10 * 60_000; +/// Choosing a password takes thought, and writing a suggested one down takes time. +const NEW_PASSWORD_MS: u64 = 10 * 60_000; +/// 28 characters copied from paper. +const RECOVERY_ENTRY_MS: u64 = 5 * 60_000; +const MSG_COPY_UNREADABLE: &str = "That file isn't a complete, readable Vault, so it can't be opened. This computer's Vault wasn't touched. Try another Backup or Safety Copy."; type ActionDescription = (&'static str, Option, Option, bool); pub enum NativeAction { @@ -78,6 +86,8 @@ pub struct NativeOperation { pub kind: Option, pub field: Option, pub requires_credentials: bool, + /// How long this step may stay open; the coordinator's clock reads the same number. + pub limit_ms: u64, } /// The coordinator must handle RecoveryKit natively and return only the action's public result. @@ -183,9 +193,15 @@ impl AppCore { return Err(not_now()); } self.guard_check()?; - let effects = self.session.end_reveal(); - self.apply(effects); - let (label, target, kind, requires_credentials) = self.native_description(&action)?; + let described = self.native_description(&action); + // Copying an ordinary Hidden Field opens no panel, so the value being read stays shown. + let keeps_reveal = matches!(action, NativeAction::CopyField { .. }) + && matches!(described, Ok((_, _, _, false))); + if !keeps_reveal { + let effects = self.session.end_reveal(); + self.apply(effects); + } + let (label, target, kind, requires_credentials) = described?; if matches!( action, NativeAction::ChangePassword { .. } @@ -210,6 +226,10 @@ impl AppCore { ) { self.inspected = None; } + // After the two steps above, so a wait still turns Touch ID off and revokes the preview. + if native_authenticates(&action, requires_credentials) { + attempt_allowed(&self.session, self.clock.now_ms())?; + } let inspection = match &action { NativeAction::InspectBackup { .. } => { let path = self @@ -225,6 +245,11 @@ impl AppCore { )), _ => None, }; + // A file that can't be a Vault is refused here, before anyone types a credential for it. + if let Some((_, bytes)) = &inspection { + vault_format::inspect(bytes, &self.limits) + .map_err(|e| copy_error(e, CredKind::Password))?; + } let disk = self.native_disk()?; self.native_sequence = self.native_sequence.checked_add(1).ok_or_else(not_now)?; let id = self.native_sequence; @@ -234,6 +259,7 @@ impl AppCore { | NativeAction::CopyField { field, .. } => Some(field.clone()), _ => None, }; + let limit_ms = native_limit_ms(&action); self.native_pending = Some(Pending { id, snapshot: Snapshot { @@ -243,7 +269,7 @@ impl AppCore { counter: self.native_counter(), disk, inspection, - deadline: self.clock.now_ms().saturating_add(OPERATION_MS), + deadline: self.clock.now_ms().saturating_add(limit_ms), }, action: Some(action), requires_credentials, @@ -255,6 +281,7 @@ impl AppCore { kind, field, requires_credentials, + limit_ms, }) } @@ -302,11 +329,18 @@ impl AppCore { None }; let now_unix = self.clock.unix_secs(); - let action = self - .native_pending - .as_mut() - .and_then(|p| p.action.take()) - .ok_or_else(not_now)?; + let now = self.clock.now_ms(); + let pending = self.native_pending.as_mut().ok_or_else(not_now)?; + let action = pending.action.take().ok_or_else(not_now)?; + // A long entry can be sent at its last moment: derivation and the commit get their own time. + if native_limit_ms(&action) > OPERATION_MS + && !matches!(action, NativeAction::ConfirmRecoveryKit) + { + pending.snapshot.deadline = pending + .snapshot + .deadline + .max(now.saturating_add(OPERATION_MS)); + } Ok(NativeJob { id, action, @@ -340,6 +374,11 @@ impl AppCore { self.native_pending.as_ref().map(|p| p.id) } + /// True while a known sharing app runs, even after its Shield was dismissed. + pub fn sharing_app_detected(&self) -> bool { + !self.detected.is_empty() + } + pub(super) fn invalidate_native(&mut self) { self.quick_unlock.cancel_pending(); self.quick_pending = None; @@ -957,7 +996,19 @@ impl NativeJob { cred.credential(), &self.limits, ) - .map_err(|e| job_format(e, kind))?; + .map_err(|e| { + if matches!( + action, + NativeAction::InspectBackup { .. } | NativeAction::InspectSafetyCopy { .. } + ) { + JobError { + wrong_credential: e == vault_format::Error::WrongCredential, + error: copy_error(e, kind), + } + } else { + job_format(e, kind) + } + })?; if matches!(action, NativeAction::EnrollQuickUnlock) { let key = vault_format::enroll_quick_unlock( &vault, @@ -1009,6 +1060,20 @@ impl NativeJob { } } +/// The one limit table. The coordinator reads it through `NativeOperation::limit_ms`. +fn native_limit_ms(action: &NativeAction) -> u64 { + match action { + NativeAction::ConfirmRecoveryKit => RECOVERY_KIT_MS, + NativeAction::CreateVault { .. } + | NativeAction::SetNewPassword + | NativeAction::ChangePassword { .. } => NEW_PASSWORD_MS, + NativeAction::Unlock { recovery: true } + | NativeAction::InspectBackup { recovery: true } + | NativeAction::InspectSafetyCopy { recovery: true, .. } => RECOVERY_ENTRY_MS, + _ => OPERATION_MS, + } +} + fn native_authenticates(action: &NativeAction, required: bool) -> bool { required && !matches!( @@ -1070,6 +1135,16 @@ fn attempt_allowed(session: &Session, now: u64) -> Result<(), ApiError> { }) } +/// A chosen copy being unreadable says nothing about this computer's Vault. +fn copy_error(error: vault_format::Error, kind: CredKind) -> ApiError { + match error { + vault_format::Error::Malformed(_) | vault_format::Error::Damaged => { + err("damaged", MSG_COPY_UNREADABLE) + } + other => format_error(other, kind), + } +} + fn job_input(error: ApiError) -> JobError { JobError { error, diff --git a/src-tauri/src/core/quick_unlock.rs b/src-tauri/src/core/quick_unlock.rs index 2312646..54b2b20 100644 --- a/src-tauri/src/core/quick_unlock.rs +++ b/src-tauri/src/core/quick_unlock.rs @@ -6,6 +6,9 @@ use vault_format::{QuickUnlockBinding, QuickUnlockKey}; const QUICK_OPERATION_MS: u64 = 2 * 60_000; +/// Only for a failure that really turned Touch ID off. +pub(crate) const MSG_TOUCH_ID_TURNED_OFF: &str = "Touch ID didn't work, so it was turned off. Unlock with your Master Password. Before you turn it on again in Settings, check that only your fingers are listed in your Mac's System Settings, under Touch ID & Password."; + #[derive(Clone, Debug, PartialEq, Serialize)] pub struct QuickUnlockStatus { pub available: bool, @@ -137,9 +140,11 @@ impl AppCore { }); let reason = self.quick_unlock_reason.or_else(|| { if self.clock.continuous_ms().is_none() { - Some("Touch ID needs a clock that keeps counting through sleep.") + Some("Touch ID can't be used on this computer right now. Use your Master Password.") } else if enabled && !can_unlock && matches!(self.phase, Phase::Locked) { - Some("Use your Master Password before Touch ID.") + Some( + "Each time you start this app, and at least every 7 days, you must type your Master Password once before Touch ID works.", + ) } else { None } @@ -306,7 +311,11 @@ impl AppCore { Ok(open) => open, Err(error) => { self.invalidate_quick_unlock_registration()?; - return Err(error); + return Err(if error == quick_required() { + err("invalid_input", MSG_TOUCH_ID_TURNED_OFF) + } else { + error + }); } }; self.quick_unlock diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5d048f5..92b1f40 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -103,6 +103,7 @@ pub fn run() { commands::set_settings, commands::mark_seed_explainer_seen, commands::vault_location, + commands::reveal_vault_folder, commands::dismiss_sharing_guard, commands::companion_status, commands::companion_start, @@ -193,7 +194,9 @@ fn setup(app: &AppHandle) { Some("Touch ID is available only on supported Macs.") } biometry::Availability::BiometryUnavailable => { - Some("Touch ID is not available on this Mac.") + Some( + "Touch ID isn't ready. If this Mac has it, check that a fingerprint is set up and the lid is open, then restart the app.", + ) } }, ); @@ -276,6 +279,9 @@ fn on_window_event(window: &Window, event: &WindowEvent) { let event = match event { // Our own Backup file dialog taking focus isn't switching apps. WindowEvent::Focused(false) if DIALOG_OPEN.load(Ordering::SeqCst) => return, + // On Windows this reports WebView2 focus, which also moves when our own panel takes it. + #[cfg(windows)] + WindowEvent::Focused(false) if platform::app_in_front() => return, WindowEvent::Focused(false) => OsEvent::FocusLost, WindowEvent::Focused(true) => OsEvent::FocusGained, _ => return, diff --git a/src-tauri/src/native_coordinator.rs b/src-tauri/src/native_coordinator.rs index fc860da..13d61c3 100644 --- a/src-tauri/src/native_coordinator.rs +++ b/src-tauri/src/native_coordinator.rs @@ -1,7 +1,8 @@ //! One native operation at a time. Credentials, Wallet readouts and Recovery Kits stay in Rust. +use std::fmt::Write as _; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Mutex, PoisonError, TryLockError, mpsc}; +use std::sync::{Mutex, OnceLock, PoisonError, TryLockError, mpsc}; use std::time::{Duration, Instant}; use notes::Kind; @@ -12,16 +13,30 @@ use crate::commands::{self, CoreState, STATUS_EVENT}; use crate::core::native::{NativeAction, NativeCredentials, NativeOperation, NativeResult}; use crate::core::{ApiError, AppCore, AppStatus, ShowMode}; use crate::native_credential::{ - self, Authorization, Prompt, PromptError, Reply, Reveal, RevealMode, + self, Authorization, Prompt, PromptError, Reply, Reveal, RevealMode, ShowCheck, TimeLeft, + TimeLimit, }; const OPERATION_TIMEOUT: Duration = Duration::from_secs(120); +/// A new-password panel also ends after this long with no real key or click. +const NO_INPUT_MS: u64 = session::DEFAULT_IDLE_MINUTES as u64 * 60_000; +const WARN_MS: u64 = 60_000; +/// At a one-minute Auto-lock a 60-second warning would never go away. +const WARN_SHORT_IDLE_MS: u64 = 30_000; #[derive(Default)] pub struct CoordinatorState { sequence: AtomicU64, current: AtomicU64, active: Mutex>, + /// The step whose end the owner hears about: zero for plain Notes, the Kit and readouts. + reporting: AtomicU64, + /// The step that an OS event, the Sharing Guard or a limit stopped, not the owner. + interrupted: AtomicU64, + /// The step under the no-input rule and when it runs out. Atomics, so the UI thread never waits. + input_token: AtomicU64, + input_by: AtomicU64, + input_origin: OnceLock, } #[derive(Clone)] @@ -79,7 +94,7 @@ impl CoordinatorState { + 1; self.current .compare_exchange(0, token, Ordering::SeqCst, Ordering::SeqCst) - .map_err(|_| busy())?; + .map_err(|_| busy_error())?; if !self.publish(token, 0, deadline, Stage::Entry) { return Err(cancelled()); } @@ -141,6 +156,48 @@ impl CoordinatorState { fn idle(&self) -> bool { self.current.load(Ordering::SeqCst) == 0 } + + fn stop_reporting(&self, token: u64) { + let _ = self + .reporting + .compare_exchange(token, 0, Ordering::SeqCst, Ordering::SeqCst); + } + + fn input_clock_ms(&self, at: Instant) -> u64 { + let origin = *self.input_origin.get_or_init(|| at); + u64::try_from(at.saturating_duration_since(origin).as_millis()).unwrap_or(u64::MAX) + } + + fn expect_input(&self, token: u64, now: Instant) { + let by = self.input_clock_ms(now).saturating_add(NO_INPUT_MS); + self.input_by.store(by, Ordering::SeqCst); + self.input_token.store(token, Ordering::SeqCst); + } + + fn owner_input(&self, token: u64, now: Instant) { + if self.input_token.load(Ordering::SeqCst) == token { + let by = self.input_clock_ms(now).saturating_add(NO_INPUT_MS); + self.input_by.store(by, Ordering::SeqCst); + } + } + + fn end_input_rule(&self, token: u64) { + let _ = self + .input_token + .compare_exchange(token, 0, Ordering::SeqCst, Ordering::SeqCst); + } + + fn input_left(&self, token: u64, now: Instant) -> Option { + (self.input_token.load(Ordering::SeqCst) == token).then(|| { + let by = self.input_by.load(Ordering::SeqCst); + Duration::from_millis(by.saturating_sub(self.input_clock_ms(now))) + }) + } + + fn input_expired(&self, token: u64, now: Instant) -> bool { + self.input_left(token, now) + .is_some_and(|left| left.is_zero()) + } } enum Snapshot { @@ -184,12 +241,16 @@ pub async fn create(app: AppHandle) -> Result { let lease = reserve(&app)?; let token = lease.token; let deadline = lease.deadline; + app.state::() + .reporting + .store(token, Ordering::SeqCst); let prepare_app = app.clone(); core_work(&app, move |core| { check_live(&prepare_app, token)?; core.create_vault_allowed()?; let status = core.app_status(); - if !prepare_app.state::().publish( + let state = prepare_app.state::(); + if !state.publish( token, 0, deadline, @@ -199,7 +260,7 @@ pub async fn create(app: AppHandle) -> Result { phase: status.phase, }, ) { - return Err(cancelled()); + return Err(ended(&state, token)); } Ok(()) }) @@ -228,7 +289,6 @@ async fn run_leased( lease: Lease, ) -> Result { let token = lease.token; - let deadline = lease.deadline; let entry_kind = match &action { NativeAction::CreateVault { .. } | NativeAction::SetNewPassword => EntryKind::NewPassword, NativeAction::ChangePassword { .. } => EntryKind::PasswordChange, @@ -245,32 +305,38 @@ async fn run_leased( _ => RevealMode::Timed, }; let begin_app = app.clone(); - let operation = core_work(&app, move |core| { + let (operation, entry_deadline) = core_work(&app, move |core| { check_live(&begin_app, token)?; - let active = begin_app - .state::() - .snapshot(token) - .ok_or_else(cancelled)?; + let state = begin_app.state::(); + let active = state.snapshot(token).ok_or_else(|| ended(&state, token))?; if !active_matches(&active, core) { - return Err(cancelled()); + return Err(ended(&state, token)); } let operation = core.begin_native(action)?; - if !begin_app.state::().publish( - token, - operation.id, - deadline, - Stage::Entry, - ) { + let now = Instant::now(); + let entry_deadline = now + Duration::from_millis(operation.limit_ms); + if !state.publish(token, operation.id, entry_deadline, Stage::Entry) { core.cancel_native(operation.id); - return Err(cancelled()); + return Err(ended(&state, token)); } - Ok(operation) + if matches!( + entry_kind, + EntryKind::NewPassword | EntryKind::PasswordChange + ) { + state.expect_input(token, now); + } + Ok((operation, entry_deadline)) }) .await?; let credentials = if operation.requires_credentials { + app.state::() + .reporting + .store(token, Ordering::SeqCst); let prompt = entry_prompt(&operation, entry_kind); - let reply = ask(&lease, operation.id, prompt).await?; - credentials(entry_kind, reply)? + let reply = ask_until(&lease, operation.id, prompt, entry_deadline).await?; + let entered = credentials(entry_kind, reply)?; + allow_commit_time(&app.state::(), token, &operation)?; + entered } else { NativeCredentials::None }; @@ -302,11 +368,16 @@ async fn run_leased( note_id: shown.note_id, field: shown.field, }; - if !state.publish(token, id, deadline, Stage::Reveal(identity)) { + // Its own time, so typing the password doesn't shorten what it unlocked. + let readout_deadline = Instant::now() + OPERATION_TIMEOUT; + if !state.publish(token, id, readout_deadline, Stage::Reveal(identity)) { core.hide_field(); - return Err(cancelled()); + return Err(ended(&state, token)); } - Ok(Outcome::Reveal(value)) + Ok(Outcome::Reveal { + value, + deadline: readout_deadline, + }) } NativeResult::RecoveryKit(mut kit) => { if let Some(recovery) = kit.recovery_key.take() { @@ -317,20 +388,22 @@ async fn run_leased( return Err(error); } }; + let kit_deadline = Instant::now() + Duration::from_millis(next.limit_ms); if !state.publish( token, next.id, - deadline, + kit_deadline, Stage::RecoveryKit { epoch: core.native_operation_epoch(), }, ) { core.lock(); - return Err(cancelled()); + return Err(ended(&state, token)); } Ok(Outcome::RecoveryKit { recovery, operation: next, + deadline: kit_deadline, }) } else { state.stop(token); @@ -346,20 +419,26 @@ async fn run_leased( .await?; match outcome { Outcome::Return(result) => return Ok(result), - Outcome::Reveal(value) => { - reveal(&lease, &operation, value, reveal_mode).await?; + Outcome::Reveal { value, deadline } => { + app.state::().stop_reporting(token); + reveal(&lease, &operation, value, reveal_mode, deadline).await?; return Ok(NativeResult::Unit); } Outcome::RecoveryKit { recovery, operation: next, + deadline: kit_deadline, } => { + app.state::().stop_reporting(token); let groups = last_groups(&recovery)?; - let reply = ask(&lease, next.id, Prompt::RecoveryKit { - title: "Save your Recovery Kit".to_owned(), - detail: "Write down the Recovery Key and keep it somewhere safe. Retype the final two groups to finish. It stays in this native window.".to_owned(), + let rotation = operation.action == "rotate_keys"; + let prompt = Prompt::RecoveryKit { + title: "Write down your Recovery Key".to_owned(), + detail: kit_detail(rotation).to_owned(), recovery, - }).await?; + rotation, + }; + let reply = ask_until(&lease, next.id, prompt, kit_deadline).await?; if !matches!(reply, Reply::RecoveryKitConfirmed) { return Err(cancelled()); } @@ -372,10 +451,14 @@ async fn run_leased( enum Outcome { Return(NativeResult), - Reveal(Zeroizing), + Reveal { + value: Zeroizing, + deadline: Instant, + }, RecoveryKit { recovery: Zeroizing, operation: NativeOperation, + deadline: Instant, }, } @@ -390,27 +473,75 @@ async fn core_work( } fn check_live(app: &AppHandle, token: u64) -> Result<(), ApiError> { - let state = app.state::(); + check_state(&app.state::(), token) +} + +fn check_state(state: &CoordinatorState, token: u64) -> Result<(), ApiError> { if state .snapshot(token) .is_some_and(|active| Instant::now() < active.deadline) { Ok(()) } else { - Err(cancelled()) + Err(ended(state, token)) + } +} + +/// How a stopped step is reported. A stop the owner asked for, or a step that isn't reporting, +/// stays silent. +fn ended(state: &CoordinatorState, token: u64) -> ApiError { + let reporting = state.reporting.load(Ordering::SeqCst) == token; + if reporting && (state.interrupted.load(Ordering::SeqCst) == token || state.live(token)) { + interrupted() + } else { + cancelled() } } -async fn ask(lease: &Lease, id: u64, prompt: Prompt) -> Result { +fn prompt_failure(state: &CoordinatorState, token: u64, error: PromptError) -> ApiError { + match error { + // The Cancel button or Escape: nothing else has stopped this step. + PromptError::Cancelled if state.live(token) => cancelled(), + PromptError::Cancelled | PromptError::Interrupted => ended(state, token), + PromptError::Busy => busy_error(), + PromptError::Unavailable => unavailable(), + } +} + +/// After a valid submit on a long entry, key derivation and the commit get their own time, and +/// the owner has nothing left to type. Once, here: publishing later could overwrite the Kit stage. +fn allow_commit_time( + state: &CoordinatorState, + token: u64, + operation: &NativeOperation, +) -> Result<(), ApiError> { + if u128::from(operation.limit_ms) <= OPERATION_TIMEOUT.as_millis() { + return Ok(()); + } + state.end_input_rule(token); + let active = state.snapshot(token).ok_or_else(|| ended(state, token))?; + let deadline = active.deadline.max(Instant::now() + OPERATION_TIMEOUT); + if state.publish(token, operation.id, deadline, Stage::Entry) { + Ok(()) + } else { + Err(ended(state, token)) + } +} + +async fn ask_until( + lease: &Lease, + id: u64, + prompt: Prompt, + deadline: Instant, +) -> Result { let (tx, rx) = mpsc::channel(); let app = lease.app.clone(); let token = lease.token; - let deadline = lease.deadline; lease .app .run_on_main_thread(move || { if !can_present(&app, token, id, deadline) { - let _ = tx.send(Err(PromptError::Cancelled)); + let _ = tx.send(Err(PromptError::Interrupted)); return; } let Some(window) = app.get_webview_window("main") else { @@ -423,6 +554,8 @@ async fn ask(lease: &Lease, id: u64, prompt: Prompt) -> Result id, prompt, activity_hook(&app, token, deadline), + time_probe(&app, token), + may_show_hook(&app), Box::new(move |reply| { let _ = tx.send(reply); }), @@ -431,7 +564,13 @@ async fn ask(lease: &Lease, id: u64, prompt: Prompt) -> Result } }) .map_err(|_| unavailable())?; - let reply = receive(rx, lease.deadline).await?.map_err(prompt_error)?; + let reply = { + let answer = receive(rx, deadline).await?; + let state = lease.app.state::(); + answer + .ok_or_else(|| ended(&state, token))? + .map_err(|error| prompt_failure(&state, token, error))? + }; check_live(&lease.app, lease.token)?; Ok(reply) } @@ -441,18 +580,26 @@ async fn reveal( operation: &NativeOperation, value: Zeroizing, mode: RevealMode, + deadline: Instant, ) -> Result<(), ApiError> { let (tx, rx) = mpsc::channel(); let app = lease.app.clone(); let token = lease.token; let id = operation.id; - let deadline = lease.deadline; - let detail = action_detail(operation); + let title = format!("Your {}", field_name(operation.field.as_deref())); + let detail = reveal_detail(operation); + let value = if operation.kind == Some(Kind::SeedPhrase) + && operation.field.as_deref() == Some("words") + { + seed_grid(&value) + } else { + value + }; lease .app .run_on_main_thread(move || { if !can_present(&app, token, id, deadline) { - let _ = tx.send(Err(PromptError::Cancelled)); + let _ = tx.send(Err(PromptError::Interrupted)); return; } let Some(window) = app.get_webview_window("main") else { @@ -465,13 +612,19 @@ async fn reveal( &window, id, Reveal { - title: "Wallet Hidden Field".to_owned(), + title, detail, value, mode, + closes_at: deadline, }, Box::new(move || extend(&extend_app, token, deadline)), activity_hook(&app, token, deadline), + // A timed readout counts down in its own line. + match mode { + RevealMode::Held => time_probe(&app, token), + RevealMode::Timed => Box::new(|| TimeLeft::Quiet), + }, Box::new(move || { let _ = tx.send(Ok(())); }), @@ -480,7 +633,14 @@ async fn reveal( } }) .map_err(|_| unavailable())?; - receive(rx, lease.deadline).await?.map_err(prompt_error)?; + match receive(rx, deadline).await? { + Some(Ok(())) => {} + Some(Err(PromptError::Busy)) => return Err(busy_error()), + Some(Err(PromptError::Unavailable)) => return Err(unavailable()), + Some(Err(PromptError::Cancelled | PromptError::Interrupted)) | None => { + return Err(cancelled()); + } + } let app = lease.app.clone(); core_work(&lease.app, move |core| { check_live(&app, token)?; @@ -491,15 +651,15 @@ async fn reveal( .await } +/// `None` when the deadline came first. async fn receive( rx: mpsc::Receiver, deadline: Instant, -) -> Result { +) -> Result, ApiError> { let remaining = deadline.saturating_duration_since(Instant::now()); - tauri::async_runtime::spawn_blocking(move || rx.recv_timeout(remaining)) + tauri::async_runtime::spawn_blocking(move || rx.recv_timeout(remaining).ok()) .await - .map_err(|_| unavailable())? - .map_err(|_| cancelled()) + .map_err(|_| unavailable()) } /// The UI thread must never wait for the core or a KDF worker. @@ -566,6 +726,7 @@ fn authorize_core_callback( } if genuine_owner_activity { core.activity(); + state.owner_input(active.token, Instant::now()); } if active_authorized(&active, core, state, deadline, id) { Authorization::Allowed @@ -581,10 +742,12 @@ fn active_authorized( deadline: Instant, id: Option, ) -> bool { + let now = Instant::now(); state.live(active.token) && id.is_none_or(|id| id == active.id) - && Instant::now() < deadline - && Instant::now() < active.deadline + && now < deadline + && now < active.deadline + && !state.input_expired(active.token, now) && active_matches(active, core) } @@ -624,6 +787,78 @@ fn activity_hook(app: &AppHandle, token: u64, deadline: Instant) -> native_crede }) } +fn time_probe(app: &AppHandle, token: u64) -> native_credential::TimeProbe { + let app = app.clone(); + Box::new(move || { + time_left( + &app.state::().0, + &app.state::(), + token, + Instant::now(), + ) + }) +} + +/// For the time row. Read only: it never ticks, so asking can't lock the app or end a readout, +/// and it never counts as activity. +fn time_left( + core: &Mutex, + state: &CoordinatorState, + token: u64, + now: Instant, +) -> TimeLeft { + let Snapshot::Active(active) = state.try_snapshot(token) else { + return TimeLeft::Busy; + }; + let Ok(core) = core.try_lock() else { + return TimeLeft::Busy; + }; + let status = core.app_status(); + let idle_minutes = status.settings.idle_minutes; + let mut panel = active.deadline.saturating_duration_since(now); + if let Some(input) = state.input_left(token, now) { + panel = panel.min(input); + } + let panel_ms = u64::try_from(panel.as_millis()).unwrap_or(u64::MAX); + let (left_ms, limit, warn_ms) = match status.lock_in_ms { + Some(lock_ms) if lock_ms < panel_ms => ( + lock_ms, + TimeLimit::AutoLock, + if idle_minutes == 1 { + WARN_SHORT_IDLE_MS + } else { + WARN_MS + }, + ), + _ => (panel_ms, TimeLimit::Panel, WARN_MS), + }; + if left_ms <= warn_ms { + TimeLeft::Left { + seconds: left_ms.div_ceil(1_000), + limit, + } + } else if matches!(active.stage, Stage::RecoveryKit { .. }) { + TimeLeft::KitCalm { idle_minutes } + } else { + TimeLeft::Quiet + } +} + +fn may_show_hook(app: &AppHandle) -> native_credential::MayShow { + let app = app.clone(); + Box::new(move || may_show(&app.state::().0)) +} + +/// Never waits. Only a clear answer is Allowed; a poisoned core is a no, and closes the panel too. +fn may_show(core: &Mutex) -> ShowCheck { + match core.try_lock() { + Ok(core) if core.sharing_app_detected() => ShowCheck::Refused, + Ok(_) => ShowCheck::Allowed, + Err(TryLockError::WouldBlock) => ShowCheck::Busy, + Err(TryLockError::Poisoned(_)) => ShowCheck::Refused, + } +} + fn extend(app: &AppHandle, token: u64, deadline: Instant) -> Authorization { extend_callback( &app.state::().0, @@ -679,9 +914,11 @@ pub fn sync(app: &AppHandle, core: &mut AppCore) { let Some(active) = state.snapshot(token) else { return; }; - if Instant::now() < active.deadline && active_matches(&active, core) { + let now = Instant::now(); + if now < active.deadline && !state.input_expired(token, now) && active_matches(&active, core) { return; } + state.interrupted.store(token, Ordering::SeqCst); if let Some(active) = state.stop(token) { cleanup_core(core, &active, state.idle()); close_panel(app, active.id); @@ -699,6 +936,21 @@ pub fn cancel(app: &AppHandle) { } } +/// Like `cancel`, for what the owner didn't ask for: sleep, screen lock, another app taking focus. +/// The waiting step then says it was interrupted. +pub fn interrupt(app: &AppHandle) { + let state = app.state::(); + let token = state.current.load(Ordering::SeqCst); + if token != 0 { + state.interrupted.store(token, Ordering::SeqCst); + cancel_token(app, token); + } +} + +pub fn busy(app: &AppHandle) -> bool { + !app.state::().idle() +} + fn cancel_token(app: &AppHandle, token: u64) { let Some(active) = app.state::().stop(token) else { return; @@ -745,88 +997,204 @@ fn close_panel(app: &AppHandle, id: u64) { } fn entry_prompt(operation: &NativeOperation, kind: EntryKind) -> Prompt { - let title = match operation.action { - "create_vault" => "Create your Vault", - "unlock" => "Unlock your Vault", - "set_new_password" => "Set a new Master Password", - "create_note" => "Create Wallet Note", - "update_note" => "Save Wallet Note", - "trash_note" => "Move Wallet Note to Trash", - "delete_forever" => "Delete Wallet Note forever", - "empty_trash" => "Empty Trash containing Wallet Notes", - "show_wallet_field" => "Show Wallet Hidden Field", - "show_and_copy_wallet_field" => "Show and copy Wallet Hidden Field", - "copy_field" => "Copy Wallet Hidden Field", - "inspect_backup" => "Open a Backup", - "inspect_safety_copy" => "Open a Safety Copy", - "change_password" => "Change Master Password", - "rotate_keys" => "Rotate Vault keys", - "set_settings" => "Confirm less strict Auto-lock", - "enroll_quick_unlock" => "Enable Touch ID for this Vault", - _ => "Confirm encrypted-note action", - } - .to_owned(); - let detail = action_detail(operation); + let title = entry_title(operation, kind); + let detail = entry_detail(operation, kind); match kind { EntryKind::NewPassword | EntryKind::PasswordChange => Prompt::NewPassword { title, detail, current_password: matches!(kind, EntryKind::PasswordChange), }, - EntryKind::Password | EntryKind::Recovery => Prompt::Credential { - title, - detail, - label: if matches!(kind, EntryKind::Recovery) { - "Recovery Key" - } else { - "Master Password" + EntryKind::Password | EntryKind::Recovery => { + let recovery = matches!(kind, EntryKind::Recovery); + Prompt::Credential { + title, + detail, + label: if recovery { + "Recovery Key" + } else { + "Master Password" + } + .to_owned(), + recovery, } - .to_owned(), - }, + } } } -fn action_detail(operation: &NativeOperation) -> String { - let mut detail = - "This approval applies only to this action. Credentials stay in native controls." - .to_owned(); - if let Some(target) = &operation.target { - let title: String = target - .chars() - .filter(|c| !c.is_control()) - .take(120) - .collect(); - detail.push_str(&format!("\nNote: {title}")); - } - if let Some(kind) = operation.kind { - detail.push_str(match kind { - Kind::SeedPhrase => "\nKind: Seed Phrase", - Kind::PrivateKey => "\nKind: Private Key", - Kind::Login => "\nKind: Login", - Kind::ApiKey => "\nKind: API Key", - Kind::Text => "\nKind: Text", - }); +fn kind_name(kind: Option) -> &'static str { + match kind { + Some(Kind::SeedPhrase) => "Seed Phrase", + Some(Kind::PrivateKey) => "Private Key", + Some(Kind::Login) => "Login", + Some(Kind::ApiKey) => "API Key", + Some(Kind::Text) => "Text", + None => "Note", } - if let Some(field) = &operation.field { - let field = match field.as_str() { - "words" => "Seed Phrase", - "passphrase" => "Wallet passphrase", - "key" => "Private Key", - _ => "Hidden Field", - }; - detail.push_str(&format!("\nField: {field}")); +} + +fn field_name(field: Option<&str>) -> &'static str { + match field { + Some("words") => "Seed Phrase", + Some("passphrase") => "Wallet passphrase", + Some("key") => "Private Key", + _ => "Hidden Field", } - if operation.kind == Some(Kind::SeedPhrase) - && matches!( - operation.action, - "copy_field" | "show_and_copy_wallet_field" - ) +} + +fn entry_title(operation: &NativeOperation, entry: EntryKind) -> String { + let kind = kind_name(operation.kind); + let field = field_name(operation.field.as_deref()); + let recovery = matches!(entry, EntryKind::Recovery); + match operation.action { + "create_vault" => "Create your Master Password".to_owned(), + "unlock" if recovery => "Unlock with your Recovery Key".to_owned(), + "unlock" => "Unlock your Vault".to_owned(), + "set_new_password" => "Set a new Master Password".to_owned(), + "create_note" => format!("Save new {kind}"), + "update_note" => format!("Save changes to {kind}"), + "trash_note" => format!("Move {kind} to Trash"), + "delete_forever" => format!("Delete {kind} forever"), + "empty_trash" => "Empty Trash".to_owned(), + "show_wallet_field" => format!("Show {field}"), + "show_and_copy_wallet_field" => format!("Show and copy {field}"), + "copy_field" => format!("Copy {field}"), + "inspect_backup" if recovery => "Open a Backup with your Recovery Key".to_owned(), + "inspect_backup" => "Open a Backup".to_owned(), + "inspect_safety_copy" if recovery => "Open a Safety Copy with your Recovery Key".to_owned(), + "inspect_safety_copy" => "Open a Safety Copy".to_owned(), + "change_password" => "Change Master Password".to_owned(), + "rotate_keys" => "Change Master Password and Recovery Key".to_owned(), + "set_settings" => "Let the app lock less often?".to_owned(), + "enroll_quick_unlock" => "Turn on Touch ID for this Vault".to_owned(), + _ => "Confirm encrypted-note action".to_owned(), + } +} + +fn entry_detail(operation: &NativeOperation, entry: EntryKind) -> String { + let recovery = matches!(entry, EntryKind::Recovery); + let mut detail = match operation.action { + "create_vault" => { + "You'll type this to open your Vault. This app doesn't save it. If you forget it, only your Recovery Key can replace it. Use at least 15 characters; 5 or 6 random words work well. Stay in this window: switching apps clears these boxes." + } + "unlock" if recovery => { + "Type the Recovery Key from your paper. It has 28 characters in 7 groups of 4. Capitals, spaces and dashes don't matter." + } + "unlock" => "Type your Master Password. This app doesn't save it.", + "set_new_password" => { + "You opened your Vault with your Recovery Key. Choose a new Master Password with at least 15 characters; 5 or 6 random words work well. Your Recovery Key keeps working. Stay in this window: switching apps clears these boxes." + } + "change_password" => { + "Type your current Master Password, then choose a new one with at least 15 characters; 5 or 6 random words work well. If you forget it, only your Recovery Key can replace it. Stay in this window: switching apps clears these boxes." + } + "rotate_keys" => { + "Type your current Master Password, then choose a new one with at least 15 characters. Next you will write down a new Recovery Key. Stay in this window: switching apps clears these boxes." + } + "inspect_backup" | "inspect_safety_copy" if recovery => { + "Type the Recovery Key this copy opens with. Capitals, spaces and dashes don't matter." + } + "inspect_backup" | "inspect_safety_copy" => { + "Type the Master Password this copy opens with. It may be an older one." + } + "create_note" + | "update_note" + | "trash_note" + | "delete_forever" + | "show_wallet_field" + | "show_and_copy_wallet_field" + | "copy_field" => "Seed Phrases and Private Keys need your Master Password every time.", + "empty_trash" => { + "Your Trash holds at least one Seed Phrase or Private Key. This deletes everything in Trash forever from this copy of your Vault. Type your Master Password to confirm." + } + "set_settings" => { + "This makes the app lock less often. Type your Master Password only if you asked for this." + } + "enroll_quick_unlock" => "Type your Master Password. Then you will use Touch ID.", + _ => "Type your Master Password to continue.", + } + .to_owned(); + for line in [target_line(operation), copy_warning(operation)] + .into_iter() + .flatten() { - detail.push_str("\nCopying a Seed Phrase can expose your wallet through clipboard history or another app. Continue only if you need to paste it. encrypted-note clears its own copy after 30 seconds if unchanged; clipboard managers and receiving apps may retain it."); + detail.push('\n'); + detail.push_str(&line); } detail } +/// The value screen only names its target, and keeps the warning when the value is being copied. +fn reveal_detail(operation: &NativeOperation) -> String { + let warning = (operation.action == "show_and_copy_wallet_field") + .then(|| copy_warning(operation)) + .flatten(); + [target_line(operation), warning] + .into_iter() + .flatten() + .collect::>() + .join("\n") +} + +fn target_line(operation: &NativeOperation) -> Option { + let title: String = operation + .target + .as_ref()? + .chars() + .filter(|c| !c.is_control()) + .take(120) + .collect(); + Some(format!("For: {title}")) +} + +fn copy_warning(operation: &NativeOperation) -> Option { + let copying = matches!( + operation.action, + "copy_field" | "show_and_copy_wallet_field" + ); + let wallet = matches!(operation.kind, Some(Kind::SeedPhrase | Kind::PrivateKey)); + (copying && wallet).then(|| { + format!( + "Copying a {} can expose your wallet. Other apps can read what you copy, and clipboard-history apps and the app you paste into may keep it. Continue only if you must paste it. This app clears its copy after 30 seconds, unless the clipboard has changed by then.", + kind_name(operation.kind) + ) + }) +} + +fn kit_detail(rotation: bool) -> &'static str { + if rotation { + "No file is created: this new key exists only here and on your paper. Stay in this window until you finish. If you switch apps, this step ends, nothing changes, and your old password and old Recovery Key still work. Before you finish, compare all 7 groups with your paper. Capitals, spaces and dashes don't matter when you type it back." + } else { + "No file is created: this key exists only here and on your paper. Stay in this window until you finish, because switching apps cancels this step and the key then stops working. Before you finish, compare all 7 groups with your paper. Capitals, spaces and dashes don't matter when you type it back." + } +} + +/// Numbered rows of three, so a word is never split across lines. Display only: the clipboard +/// gets the core's own value. +fn seed_grid(words: &str) -> Zeroizing { + const COLUMNS: usize = 3; + const WORD_WIDTH: usize = 8; + // Reserved up front: growing would leave an unwiped copy behind. + let capacity = words + .split_whitespace() + .map(|word| word.len() + 32) + .sum::(); + let mut grid = Zeroizing::new(String::with_capacity(capacity)); + let mut last_width = 0; + for (index, word) in words.split_whitespace().enumerate() { + if index % COLUMNS != 0 { + for _ in last_width..WORD_WIDTH { + grid.push(' '); + } + grid.push_str(" "); + } else if index > 0 { + grid.push('\n'); + } + let _ = write!(grid, "{:>2} {word}", index + 1); + last_width = word.chars().count(); + } + grid +} + fn credentials(kind: EntryKind, reply: Reply) -> Result { Ok(match (kind, reply) { (EntryKind::Password, Reply::Credential(value)) => NativeCredentials::Password(value), @@ -865,29 +1233,29 @@ fn last_groups(recovery: &str) -> Result, ApiError> { Ok(confirmation) } -fn prompt_error(error: PromptError) -> ApiError { - match error { - PromptError::Cancelled => cancelled(), - PromptError::Busy => busy(), - PromptError::Unavailable => unavailable(), - } -} fn cancelled() -> ApiError { ApiError { code: "cancelled", - message: "The native action was cancelled. Please try again.".to_owned(), + message: "That was cancelled. Please try again.".to_owned(), + } +} +fn interrupted() -> ApiError { + ApiError { + code: "interrupted", + message: "This step closed before it finished, so please do it again. This happens if you switch to another app or window, a screen sharing or recording app starts, the app or computer locks, or the time runs out. Your Notes and password were not changed.".to_owned(), } } -fn busy() -> ApiError { +fn busy_error() -> ApiError { ApiError { code: "invalid_input", - message: "Finish or cancel the current native action first.".to_owned(), + message: "Another step is still running. Finish it, or wait a moment, then try again." + .to_owned(), } } fn unavailable() -> ApiError { ApiError { code: "internal", - message: "The native action could not finish. Please try again.".to_owned(), + message: "That step couldn't start or finish. Click this window, then try again. If it keeps happening, quit the app and open it again.".to_owned(), } } @@ -1451,33 +1819,655 @@ mod tests { kind: None, field: None, requires_credentials: true, + limit_ms: 120_000, }; let Prompt::Credential { title, label, .. } = entry_prompt(&operation, EntryKind::Password) else { panic!("Touch ID enrollment requires native credential entry"); }; - assert_eq!(title, "Enable Touch ID for this Vault"); + assert_eq!(title, "Turn on Touch ID for this Vault"); assert_eq!(label, "Master Password"); } - #[test] - fn seed_copy_warning_is_owned_by_the_native_prompt() { - let mut operation = NativeOperation { + fn fake_operation( + action: &'static str, + kind: Option, + field: Option<&str>, + ) -> NativeOperation { + NativeOperation { id: 1, - action: "copy_field", - target: Some("Fake Wallet".to_owned()), - kind: Some(Kind::SeedPhrase), - field: Some("words".to_owned()), + action, + target: kind.map(|_| "Fake Wallet".to_owned()), + kind, + field: field.map(str::to_owned), requires_credentials: true, + limit_ms: 120_000, + } + } + + #[test] + fn wallet_copy_warning_is_owned_by_the_native_prompt() { + const WARNING: &str = "can expose your wallet. Other apps can read what you copy"; + for (kind, name) in [ + (Kind::SeedPhrase, "Copying a Seed Phrase"), + (Kind::PrivateKey, "Copying a Private Key"), + ] { + for action in ["copy_field", "show_and_copy_wallet_field"] { + let operation = fake_operation(action, Some(kind), Some("words")); + let detail = entry_detail(&operation, EntryKind::Password); + assert!( + detail.contains(WARNING) && detail.contains(name), + "{detail}" + ); + assert!(detail.contains("\nFor: Fake Wallet\n"), "{detail}"); + assert!(detail.contains("clears its copy after 30 seconds")); + } + let readout = fake_operation("show_and_copy_wallet_field", Some(kind), Some("words")); + assert!(reveal_detail(&readout).contains(WARNING)); + let show = fake_operation("show_wallet_field", Some(kind), Some("words")); + assert!(!entry_detail(&show, EntryKind::Password).contains(WARNING)); + assert_eq!(reveal_detail(&show), "For: Fake Wallet"); + } + let login = fake_operation("copy_field", Some(Kind::Login), Some("password")); + assert!(!entry_detail(&login, EntryKind::Password).contains(WARNING)); + } + + #[test] + fn every_action_has_a_plain_title() { + let title = |action, kind, field| { + entry_title(&fake_operation(action, kind, field), EntryKind::Password) }; - assert!(action_detail(&operation).contains("clipboard history or another app")); - operation.action = "show_and_copy_wallet_field"; - assert!( - action_detail(&operation) - .contains("clipboard managers and receiving apps may retain it") + let seed = Some(Kind::SeedPhrase); + assert_eq!( + title("create_vault", None, None), + "Create your Master Password" + ); + assert_eq!(title("unlock", None, None), "Unlock your Vault"); + assert_eq!( + title("set_new_password", None, None), + "Set a new Master Password" + ); + assert_eq!(title("create_note", seed, None), "Save new Seed Phrase"); + assert_eq!( + title("update_note", Some(Kind::PrivateKey), None), + "Save changes to Private Key" + ); + assert_eq!( + title("trash_note", Some(Kind::Login), None), + "Move Login to Trash" + ); + assert_eq!( + title("delete_forever", Some(Kind::ApiKey), None), + "Delete API Key forever" + ); + assert_eq!( + title("create_note", Some(Kind::Text), None), + "Save new Text" + ); + assert_eq!(title("empty_trash", None, None), "Empty Trash"); + assert_eq!( + title("show_wallet_field", seed, Some("words")), + "Show Seed Phrase" + ); + assert_eq!( + title("show_and_copy_wallet_field", seed, Some("passphrase")), + "Show and copy Wallet passphrase" + ); + assert_eq!( + title("copy_field", Some(Kind::PrivateKey), Some("key")), + "Copy Private Key" + ); + assert_eq!( + title("copy_field", seed, Some("other")), + "Copy Hidden Field" + ); + assert_eq!(title("inspect_backup", None, None), "Open a Backup"); + assert_eq!( + title("inspect_safety_copy", None, None), + "Open a Safety Copy" + ); + assert_eq!( + title("change_password", None, None), + "Change Master Password" + ); + assert_eq!( + title("rotate_keys", None, None), + "Change Master Password and Recovery Key" + ); + assert_eq!( + title("set_settings", None, None), + "Let the app lock less often?" + ); + assert_eq!( + title("enroll_quick_unlock", None, None), + "Turn on Touch ID for this Vault" + ); + assert_eq!( + title("fake_action", None, None), + "Confirm encrypted-note action" + ); + for (action, expected) in [ + ("unlock", "Unlock with your Recovery Key"), + ("inspect_backup", "Open a Backup with your Recovery Key"), + ( + "inspect_safety_copy", + "Open a Safety Copy with your Recovery Key", + ), + ] { + let operation = fake_operation(action, None, None); + assert_eq!(entry_title(&operation, EntryKind::Recovery), expected); + } + } + + #[test] + fn panel_text_tells_the_owner_what_to_do_without_promising_too_much() { + let trash = entry_detail( + &fake_operation("empty_trash", None, None), + EntryKind::Password, + ); + assert!(trash.contains("Seed Phrase or Private Key") && trash.contains("forever")); + let unlock = fake_operation("unlock", None, None); + assert!(entry_detail(&unlock, EntryKind::Recovery).contains("28 characters in 7 groups")); + assert_eq!( + entry_detail(&unlock, EntryKind::Password), + "Type your Master Password. This app doesn't save it." + ); + for action in [ + "create_vault", + "unlock", + "set_new_password", + "create_note", + "update_note", + "trash_note", + "delete_forever", + "empty_trash", + "show_wallet_field", + "show_and_copy_wallet_field", + "copy_field", + "inspect_backup", + "inspect_safety_copy", + "change_password", + "rotate_keys", + "set_settings", + "enroll_quick_unlock", + "fake_action", + ] { + let operation = fake_operation(action, Some(Kind::SeedPhrase), Some("words")); + for entry in [EntryKind::Password, EntryKind::Recovery] { + let text = entry_detail(&operation, entry).to_lowercase(); + assert!( + !text.contains("only you ") && !text.contains("only you."), + "{text}" + ); + assert!(!text.contains("nobody can reset"), "{text}"); + assert!(!text.contains("native"), "{text}"); + } + } + for rotation in [false, true] { + assert!( + kit_detail(rotation) + .contains("Before you finish, compare all 7 groups with your paper.") + ); + } + assert!(!kit_detail(true).contains("stops working")); + assert!(kit_detail(true).contains("old Recovery Key still work")); + } + + #[test] + fn target_line_stays_one_short_line() { + let mut operation = fake_operation("trash_note", Some(Kind::Login), None); + operation.target = Some(format!("Fake\nname\u{7}{}", "x".repeat(200))); + let line = target_line(&operation).unwrap(); + assert!(line.starts_with("For: Fakename")); + assert_eq!(line.chars().count(), "For: ".len() + 120); + assert!(!line.contains('\n')); + operation.target = None; + assert_eq!(target_line(&operation), None); + assert_eq!(reveal_detail(&operation), ""); + } + + fn fake_words(count: usize) -> String { + (1..=count) + .map(|n| format!("fake{n:02}")) + .collect::>() + .join(" ") + } + + #[test] + fn seed_phrase_readout_is_numbered_rows_with_no_split_words() { + for count in [12, 24] { + let words = fake_words(count); + let grid = seed_grid(&words); + let rows: Vec<&str> = grid.lines().collect(); + assert_eq!(rows.len(), count / 3); + assert!(rows.iter().all(|row| row.chars().count() <= 48)); + let cells: Vec<&str> = grid.split_whitespace().collect(); + let numbers: Vec = cells.iter().step_by(2).map(|n| (*n).to_owned()).collect(); + let expected: Vec = (1..=count).map(|n| n.to_string()).collect(); + assert_eq!(numbers, expected); + let rejoined: Vec<&str> = cells.iter().skip(1).step_by(2).copied().collect(); + assert_eq!(rejoined.join(" "), words); + assert!(grid.capacity() >= grid.len()); + } + let grid = seed_grid("zoo fake\nabandon wrong"); + assert_eq!( + grid.as_str(), + " 1 zoo 2 fake 3 abandon\n 4 wrong" + ); + assert_eq!(seed_grid("").as_str(), ""); + } + + fn reporting_state(deadline: Instant) -> (CoordinatorState, u64) { + let state = CoordinatorState::default(); + let token = state.reserve(deadline).unwrap(); + state.reporting.store(token, Ordering::SeqCst); + (state, token) + } + + #[test] + fn only_a_stop_the_owner_did_not_ask_for_says_so() { + let deadline = Instant::now() + OPERATION_TIMEOUT; + + // An OS event or the Sharing Guard marks it before stopping. + let (state, token) = reporting_state(deadline); + state.interrupted.store(token, Ordering::SeqCst); + assert!(state.stop(token).is_some()); + assert_eq!(check_state(&state, token).unwrap_err().code, "interrupted"); + assert_eq!( + prompt_failure(&state, token, PromptError::Cancelled).code, + "interrupted" + ); + assert_eq!( + prompt_failure(&state, token, PromptError::Interrupted).code, + "interrupted" + ); + + // An owner route doesn't mark it. + let (state, token) = reporting_state(deadline); + assert!(state.stop(token).is_some()); + assert_eq!(check_state(&state, token).unwrap_err().code, "cancelled"); + for error in [PromptError::Cancelled, PromptError::Interrupted] { + assert_eq!(prompt_failure(&state, token, error).code, "cancelled"); + } + + // The Cancel button or Escape, with nothing else going on. + let (state, token) = reporting_state(deadline); + assert_eq!( + prompt_failure(&state, token, PromptError::Cancelled).code, + "cancelled" + ); + // The panel closing by itself. + assert_eq!( + prompt_failure(&state, token, PromptError::Interrupted).code, + "interrupted" + ); + assert_eq!( + prompt_failure(&state, token, PromptError::Busy).code, + "invalid_input" + ); + assert_eq!( + prompt_failure(&state, token, PromptError::Unavailable).code, + "internal" + ); + } + + #[test] + fn a_step_that_is_not_reporting_stays_cancelled() { + let deadline = Instant::now() + OPERATION_TIMEOUT; + let (state, token) = reporting_state(deadline); + state.stop_reporting(token); + assert_eq!(ended(&state, token).code, "cancelled"); + state.interrupted.store(token, Ordering::SeqCst); + assert!(state.stop(token).is_some()); + assert_eq!(ended(&state, token).code, "cancelled"); + assert_eq!( + prompt_failure(&state, token, PromptError::Interrupted).code, + "cancelled" + ); + // A late step can't silence a newer one. + let other = state.reserve(deadline).unwrap(); + state.reporting.store(other, Ordering::SeqCst); + state.stop_reporting(token); + assert_eq!(state.reporting.load(Ordering::SeqCst), other); + } + + #[test] + fn running_out_of_time_while_reporting_is_an_interruption() { + let now = Instant::now(); + let (state, token) = reporting_state(now); + assert_eq!(check_state(&state, token).unwrap_err().code, "interrupted"); + // A reply after the limit isn't revived, and a live step is left alone. + assert!(state.publish(token, 1, now + OPERATION_TIMEOUT, Stage::Entry)); + assert!(check_state(&state, token).is_ok()); + let message = interrupted().message; + assert!(message.contains("Your Notes and password were not changed.")); + } + + #[test] + fn a_new_password_panel_needs_real_input_to_stay_open() { + let (mut core, _temp) = fake_core(); + let state = CoordinatorState::default(); + let now = Instant::now(); + let cap = now + Duration::from_secs(10 * 60); + let token = state.reserve(cap).unwrap(); + state.expect_input(token, now); + assert_eq!( + state.input_left(token, now), + Some(Duration::from_secs(5 * 60)) + ); + assert!(!state.input_expired(token, now + Duration::from_millis(NO_INPUT_MS - 1))); + assert!(state.input_expired(token, now + Duration::from_millis(NO_INPUT_MS))); + + // Key repeat and programmatic edits reach the hook as false: they change nothing. + let by = state.input_by.load(Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(5)); + assert!(authorized_for_test(&mut core, &state, token, None, false)); + assert_eq!(state.input_by.load(Ordering::SeqCst), by); + + // A real key or click restarts it. + assert!(authorized_for_test(&mut core, &state, token, None, true)); + assert!(state.input_by.load(Ordering::SeqCst) > by); + + // Run out: the panel ends, and input after that doesn't bring it back. + let at_limit = state.input_clock_ms(Instant::now()); + state.input_by.store(at_limit, Ordering::SeqCst); + assert!(!authorized_for_test(&mut core, &state, token, None, false)); + assert!(!authorized_for_test(&mut core, &state, token, None, true)); + assert_eq!(state.input_by.load(Ordering::SeqCst), at_limit); + } + + #[test] + fn typing_never_outlasts_the_cap_of_a_new_password_panel() { + let (mut core, _temp) = fake_core(); + let state = CoordinatorState::default(); + let now = Instant::now(); + let token = state.reserve(now + OPERATION_TIMEOUT).unwrap(); + state.expect_input(token, now); + assert!(authorized_for_test(&mut core, &state, token, None, true)); + assert!(state.publish(token, 0, Instant::now(), Stage::Entry)); + assert!(!authorized_for_test(&mut core, &state, token, None, true)); + } + + #[test] + fn other_steps_have_no_input_rule() { + let (mut core, _temp) = fake_core(); + let state = CoordinatorState::default(); + let now = Instant::now(); + let token = state.reserve(now + OPERATION_TIMEOUT).unwrap(); + assert_eq!(state.input_left(token, now), None); + assert!(!state.input_expired(token, now + Duration::from_secs(3_600))); + assert!(authorized_for_test(&mut core, &state, token, None, true)); + // Another step's rule doesn't reach this one. + state.expect_input(token + 1, now); + assert_eq!(state.input_left(token, now), None); + } + + #[test] + fn commit_time_is_given_once_and_only_to_long_entries() { + let now = Instant::now(); + let soon = now + Duration::from_secs(1); + let later = now + Duration::from_secs(9 * 60); + + let (state, token) = reporting_state(soon); + let mut operation = fake_operation("unlock", None, None); + // A password prompt gets no extended window. + assert!(allow_commit_time(&state, token, &operation).is_ok()); + assert_eq!(state.snapshot(token).unwrap().deadline, soon); + + // A long entry sent at its last moment does, and nothing more is expected from the owner. + operation.limit_ms = 300_000; + state.expect_input(token, now); + assert!(allow_commit_time(&state, token, &operation).is_ok()); + assert!(state.snapshot(token).unwrap().deadline >= now + OPERATION_TIMEOUT); + assert_eq!(state.input_left(token, now), None); + + // The later limit is kept. + assert!(state.publish(token, 1, later, Stage::Entry)); + assert!(allow_commit_time(&state, token, &operation).is_ok()); + assert_eq!(state.snapshot(token).unwrap().deadline, later); + + // A stopped step isn't brought back. + state.interrupted.store(token, Ordering::SeqCst); + assert!(state.stop(token).is_some()); + assert_eq!( + allow_commit_time(&state, token, &operation) + .unwrap_err() + .code, + "interrupted" + ); + assert!(state.idle()); + } + + #[test] + fn time_row_reads_the_sooner_limit_and_changes_nothing() { + let (mut core, _temp, now_ms) = fake_core_with_clock(); + let state = CoordinatorState::default(); + let token = fake_wallet_readout(&mut core, &state); + let core = Mutex::new(core); + let now = Instant::now(); + let at = |seconds| now + Duration::from_secs(seconds); + let left = |seconds, limit| TimeLeft::Left { seconds, limit }; + + // Auto-lock at one minute warns only in its last 30 seconds. + assert!(state.publish(token, 71, at(600), Stage::Entry)); + assert_eq!(time_left(&core, &state, token, now), TimeLeft::Quiet); + now_ms.fetch_add(31_000, Ordering::SeqCst); + assert_eq!( + time_left(&core, &state, token, now), + left(29, TimeLimit::AutoLock) + ); + // Asking neither locks the app nor counts as activity. + now_ms.fetch_add(29_000, Ordering::SeqCst); + assert_eq!( + time_left(&core, &state, token, now), + left(0, TimeLimit::AutoLock) + ); + assert_eq!(core.lock().unwrap().app_status().phase, "unlocked"); + assert_eq!(core.lock().unwrap().app_status().lock_in_ms, Some(0)); + } + + #[test] + fn time_row_warns_about_the_panel_and_stays_calm_on_the_kit() { + let (core, _temp) = fake_core(); + let core = Mutex::new(core); + let state = CoordinatorState::default(); + let now = Instant::now(); + let at = |seconds| now + Duration::from_secs(seconds); + let token = state.reserve(at(61)).unwrap(); + assert_eq!(time_left(&core, &state, token, now), TimeLeft::Quiet); + assert!(state.publish(token, 0, at(60), Stage::Entry)); + assert_eq!( + time_left(&core, &state, token, now), + TimeLeft::Left { + seconds: 60, + limit: TimeLimit::Panel + } + ); + // A partial second still shows, rounded up. + assert!(state.publish(token, 0, now + Duration::from_millis(500), Stage::Entry)); + assert_eq!( + time_left(&core, &state, token, now), + TimeLeft::Left { + seconds: 1, + limit: TimeLimit::Panel + } + ); + + // The no-input limit counts as the panel's own. + assert!(state.publish(token, 0, at(600), Stage::Entry)); + state.expect_input(token, now); + assert_eq!( + time_left( + &core, + &state, + token, + now + Duration::from_millis(NO_INPUT_MS - 45_000) + ), + TimeLeft::Left { + seconds: 45, + limit: TimeLimit::Panel + } + ); + state.end_input_rule(token); + + assert!(state.publish(token, 0, at(600), Stage::RecoveryKit { epoch: 0 })); + assert_eq!( + time_left(&core, &state, token, now), + TimeLeft::KitCalm { idle_minutes: 5 } + ); + assert!(state.publish(token, 0, at(5), Stage::RecoveryKit { epoch: 0 })); + assert_eq!( + time_left(&core, &state, token, now), + TimeLeft::Left { + seconds: 5, + limit: TimeLimit::Panel + } + ); + } + + #[test] + fn time_row_is_left_alone_while_anything_is_busy() { + let (core, _temp) = fake_core(); + let core = Mutex::new(core); + let state = CoordinatorState::default(); + let now = Instant::now(); + let token = state.reserve(now + OPERATION_TIMEOUT).unwrap(); + { + let _held = core.lock().unwrap(); + assert_eq!(time_left(&core, &state, token, now), TimeLeft::Busy); + } + { + let _held = state.active.lock().unwrap(); + assert_eq!(time_left(&core, &state, token, now), TimeLeft::Busy); + } + assert_eq!(time_left(&core, &state, token, now), TimeLeft::Quiet); + assert!(state.stop(token).is_some()); + assert_eq!(time_left(&core, &state, token, now), TimeLeft::Busy); + } + + #[test] + fn showing_typed_text_is_refused_while_a_sharing_app_runs_even_after_a_dismissal() { + let (mut core, _temp) = fake_core(); + let zoom = [guard::RunningApp { + bundle_id: Some("us.zoom.xos".into()), + exe_name: Some("Zoom.exe".into()), + }]; + core.guard_update(&zoom); + core.dismiss_sharing_guard(); + assert!(!core.app_status().sharing_guard.up); + let core = Mutex::new(core); + assert_eq!(may_show(&core), ShowCheck::Refused); + core.lock().unwrap().guard_update(&[]); + assert_eq!(may_show(&core), ShowCheck::Allowed); + // A locked core says nothing about sharing apps, so it isn't a refusal. + let held = core.lock().unwrap(); + assert_eq!(may_show(&core), ShowCheck::Busy); + drop(held); + assert_eq!(may_show(&core), ShowCheck::Allowed); + // A poisoned core stays a no. + std::thread::scope(|scope| { + let poison = scope.spawn(|| { + let _held = core.lock().unwrap(); + std::panic::resume_unwind(Box::new("fake panic")); + }); + assert!(poison.join().is_err()); + }); + assert_eq!(may_show(&core), ShowCheck::Refused); + } + + #[test] + fn a_wallet_readout_keeps_its_ceilings_and_an_ended_one_is_not_revived() { + // Timed: 20 seconds and three resets are 80 seconds at the very most. + let (mut core, _temp, now) = fake_core_with_clock(); + let state = CoordinatorState::default(); + let token = fake_wallet_readout_mode(&mut core, &state, ShowMode::Accessible); + let deadline = state.snapshot(token).unwrap().deadline; + let core = Mutex::new(core); + for _ in 0..3 { + now.fetch_add(19_999, Ordering::SeqCst); + // Owner input keeps the one-minute Auto-lock of this fixture away. + assert_eq!( + authorize_callback(&core, &state, token, deadline, Some(71), true), + Authorization::Allowed + ); + assert_eq!( + extend_callback(&core, &state, token, deadline, |_| ()), + Authorization::Allowed + ); + } + now.fetch_add(19_999, Ordering::SeqCst); + assert_eq!( + authorize_callback(&core, &state, token, deadline, Some(71), true), + Authorization::Allowed + ); + now.fetch_add(1, Ordering::SeqCst); + assert_eq!( + authorize_callback(&core, &state, token, deadline, Some(71), true), + Authorization::Denied + ); + assert!(core.lock().unwrap().app_status().reveal.is_none()); + assert_eq!( + authorize_callback(&core, &state, token, deadline, Some(71), true), + Authorization::Denied ); - operation.action = "show_wallet_field"; - assert!(!action_detail(&operation).contains("clipboard history")); + assert_eq!( + extend_callback(&core, &state, token, deadline, |_| panic!("not extended")), + Authorization::Denied + ); + + // Held: its own two minutes end it, whatever the panel's limit says. + let (mut core, _temp, now) = fake_core_with_clock(); + let state = CoordinatorState::default(); + let token = fake_wallet_readout(&mut core, &state); + let deadline = Instant::now() + OPERATION_TIMEOUT; + assert!(state.publish(token, 71, deadline, state.snapshot(token).unwrap().stage)); + for _ in 0..2 { + now.fetch_add(59_999, Ordering::SeqCst); + assert!(authorized_for_test( + &mut core, + &state, + token, + Some(71), + true + )); + } + now.fetch_add(2, Ordering::SeqCst); + assert!(!authorized_for_test( + &mut core, + &state, + token, + Some(71), + true + )); + assert!(core.app_status().reveal.is_none()); + assert!(!authorized_for_test( + &mut core, + &state, + token, + Some(71), + true + )); + } + + #[test] + fn cancelling_the_kit_still_rolls_back_and_stays_cancelled() { + let (mut core, _temp) = fake_core(); + let _fake_key = setup_fake_vault(&mut core); + let operation = core.begin_native(NativeAction::ConfirmRecoveryKit).unwrap(); + let deadline = Instant::now() + OPERATION_TIMEOUT; + let (state, token) = reporting_state(deadline); + state.stop_reporting(token); + let epoch = core.native_operation_epoch(); + assert!(state.publish(token, operation.id, deadline, Stage::RecoveryKit { epoch })); + assert_eq!( + prompt_failure(&state, token, PromptError::Cancelled).code, + "cancelled" + ); + let active = state.stop(token).unwrap(); + assert_eq!(check_state(&state, token).unwrap_err().code, "cancelled"); + cleanup_core(&mut core, &active, state.idle()); + assert_eq!(core.app_status().phase, "no_vault"); + assert!(core.native_pending_id().is_none()); } #[test] diff --git a/src-tauri/src/native_credential.rs b/src-tauri/src/native_credential.rs index 424fdc8..a3213a5 100644 --- a/src-tauri/src/native_credential.rs +++ b/src-tauri/src/native_credential.rs @@ -4,6 +4,10 @@ //! inside the existing protected main window. A reply is never an authentication token. //! Entry points run on the UI thread. OS string copies are cleared where APIs permit; //! their physical erasure cannot be promised. +use std::borrow::Cow; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + use zeroize::Zeroizing; #[cfg(target_os = "macos")] @@ -11,14 +15,72 @@ mod mac; #[cfg(windows)] mod windows; +#[cfg(target_os = "macos")] +#[doc(hidden)] +pub use mac::typed_line; + pub type Completion = Box) + Send + 'static>; /// Validate the pending operation; true marks genuine input, false only revalidates. pub type Activity = Box Authorization + Send>; +/// Read-only, for the time row. Asking never counts as activity. +pub type TimeProbe = Box TimeLeft + Send>; +/// May typed text be drawn? Asking never counts as activity. +pub type MayShow = Box ShowCheck + Send>; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShowCheck { + Allowed, + /// A known sharing app runs, even after its Shield was dismissed, or the core is unusable. + Refused, + /// The core is locked for now, so nothing is known. Never a reason to start showing. + Busy, +} + +impl ShowCheck { + /// (the controls stay usable, blank what they show) for a timer's answer, given whether they + /// are usable now. A brief busy core changes nothing: a long one ends in `Clear`. + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + fn controls(self, usable: bool) -> (bool, bool) { + match self { + Self::Allowed => (true, false), + Self::Refused => (false, true), + Self::Busy => (usable, false), + } + } +} + +const TOO_LONG: &str = "That is too long. Use fewer characters."; +const KIT_PAPER_NOTE: &str = "Write this key on paper. It can open your Vault, so keep the paper somewhere private. Don't save it as a photo, screenshot, file or note, or in a cloud folder like iCloud Drive, OneDrive or Dropbox."; +const SUGGEST_BUTTON: &str = "Suggest a password"; +const RESET_BUTTON: &str = "Reset to 20 seconds"; +const SUGGESTED_TEXT: &str = "Write this on paper now and keep the paper away from this computer. Switching apps closes this screen and this password will not be used."; +const SUGGESTION_REMOVED: &str = "You changed the suggested password, so it was removed. Start typing again, or press Suggest a password."; +const SUGGESTION_ASK: &str = "Did you write it down? Press Continue again to use it."; +const KIT_ARMED: &str = "If you cancel, the key shown here will never work. Press Cancel or Escape again to cancel, or keep typing."; +const CAPS_LOCK_TEXT: &str = "Caps Lock is on."; +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +const SHOW_TYPING_LABEL: &str = "Show what I type"; +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +const HOLD_TO_SHOW_LABEL: &str = "Hold to show what I typed"; +/// Only on a real refusal, never for a busy core. Names no app and nothing typed. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +const SHOW_REFUSED_TEXT: &str = "You can't see what you type while a screen sharing or recording app is running. Quit that app, then open this screen again."; + +const MAX_FIELD_BYTES: usize = 4096; +const RECOVERY_SYMBOLS: usize = 28; +/// A core lock this brief blanks nothing where the owner is typing (see `rides_out_a_blip`). +const RETRY_GRACE: Duration = Duration::from_millis(350); +/// A second press this soon is the same press: a bounce, or a double-click on Cancel. +const ARM_KEY_GAP: Duration = Duration::from_millis(300); +const ARM_CLICK_GAP: Duration = Duration::from_millis(500); +const ARM_WINDOW: Duration = Duration::from_secs(10); +const LIVE_PAUSE: Duration = Duration::from_millis(300); #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Authorization { Allowed, - /// Authorization is temporarily busy. Hide plaintext and reject actions until revalidated. + /// Authorization is temporarily busy. Reject actions until revalidated, and hide plaintext + /// unless this is a blip the panel rides out (see `PresentationState::authorize`). Retry, Denied, } @@ -27,27 +89,48 @@ pub enum Authorization { struct PresentationState { suspended: bool, closed: bool, + last_allowed: Option, } #[derive(Debug, PartialEq, Eq)] enum PresentationUpdate { Keep, + /// Paint nothing and reject actions. + Hold, Clear, Restore, Close, } impl PresentationState { - fn authorize(&mut self, authorization: Authorization) -> PresentationUpdate { + /// Only `grace` gets Hold, and only after a real Allowed: a first frame still blanks. + fn authorize( + &mut self, + authorization: Authorization, + now: Instant, + grace: bool, + ) -> PresentationUpdate { if self.closed { return PresentationUpdate::Close; } match authorization { - Authorization::Allowed if std::mem::take(&mut self.suspended) => { - PresentationUpdate::Restore + Authorization::Allowed => { + self.last_allowed = Some(now); + if std::mem::take(&mut self.suspended) { + PresentationUpdate::Restore + } else { + PresentationUpdate::Keep + } } - Authorization::Allowed => PresentationUpdate::Keep, Authorization::Retry => { + let blip = grace + && !self.suspended + && self.last_allowed.is_some_and(|allowed| { + now.saturating_duration_since(allowed) < RETRY_GRACE + }); + if blip { + return PresentationUpdate::Hold; + } self.suspended = true; PresentationUpdate::Clear } @@ -60,6 +143,161 @@ impl PresentationState { } } +/// What the first press of Cancel or Escape on the Recovery Kit does: it only asks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ArmDecision { + Arm, + Ignore, + Cancel, +} + +/// The adapter drops auto-repeat before calling `press`. +#[derive(Default)] +struct CancelArm { + armed_at: Option, +} + +impl CancelArm { + fn press(&mut self, now: Instant, from_key: bool) -> ArmDecision { + let Some(armed_at) = self.armed_at else { + self.armed_at = Some(now); + return ArmDecision::Arm; + }; + let since = now.saturating_duration_since(armed_at); + if since < if from_key { ARM_KEY_GAP } else { ARM_CLICK_GAP } { + ArmDecision::Ignore + } else if since <= ARM_WINDOW { + self.armed_at = None; + ArmDecision::Cancel + } else { + self.armed_at = Some(now); + ArmDecision::Arm + } + } + + fn disarm(&mut self) { + self.armed_at = None; + } + + fn expired(&self, now: Instant) -> bool { + self.armed_at + .is_some_and(|armed_at| now.saturating_duration_since(armed_at) > ARM_WINDOW) + } +} + +/// A generated password on screen. Kept together, so a new one or a forget resets the flag too. +struct Suggestion { + text: Zeroizing, + acknowledged: bool, +} + +/// The advice line under new-password boxes, shown at typing pauses. It never reads or scores +/// the Current box, and Continue always re-runs the real gate: nothing here approves anything. +#[derive(Default)] +struct LiveCheck { + /// The last real edit that `line` hasn't seen yet. + edited_at: Option, + generation: u64, + /// The answer for the text of `generation`. + strength: Option, + running: bool, + /// `line` wanted an answer while the one worker was busy. + waiting: bool, + slot: Arc>>, +} + +impl LiveCheck { + /// Reads no text. Call for every edit except this code's own writes to the boxes. `index` is + /// the edited box of `boxes`, when known. The strength answer is about New, so Confirm keeps + /// it. Only a positive match counts: a box that can't be told apart forgets it. + fn box_edited(&mut self, now: Instant, boxes: usize, index: Option) { + if index.is_some() && index == boxes.checked_sub(1) { + self.edited_at = Some(now); + } else { + self.edited(now); + } + } + + /// New's text changed, or may have. + fn edited(&mut self, now: Instant) { + self.edited_at = Some(now); + self.generation = self.generation.wrapping_add(1); + self.strength = None; + } + + /// True once the typing has paused and `line` hasn't seen the latest edit. + fn due(&self, now: Instant) -> bool { + self.edited_at + .is_some_and(|edited_at| now.saturating_duration_since(edited_at) >= LIVE_PAUSE) + } + + fn line(&mut self, new: &str, confirm: &str) -> String { + self.edited_at = None; + if new.len() > MAX_FIELD_BYTES || confirm.len() > MAX_FIELD_BYTES { + return TOO_LONG.to_owned(); + } + let count = policy::char_count(new); + let short = count < policy::MIN_CHARS; + let mut line = if short { + format!("{count} of {} characters", policy::MIN_CHARS) + } else { + match self.strength { + Some(true) => "Strong enough.".to_owned(), + Some(false) => "Not strong enough yet. Add another unusual word.".to_owned(), + None => { + self.score(new); + "Checking…".to_owned() + } + } + }; + if !confirm.is_empty() { + line.push_str(if short { ". " } else { " " }); + line.push_str(if new == confirm { + "The two passwords match." + } else { + "The two passwords don't match yet." + }); + } + line + } + + /// True when `line` should run again: a fresh answer came, or the worker is free for text + /// that had to wait. A stale answer is dropped here. Never waits and never counts as activity. + fn poll(&mut self) -> bool { + let Some((generation, acceptable)) = + self.slot.try_lock().ok().and_then(|mut slot| slot.take()) + else { + return false; + }; + self.running = false; + if generation == self.generation { + self.strength = Some(acceptable); + self.waiting = false; + return true; + } + std::mem::take(&mut self.waiting) && self.edited_at.is_none() + } + + /// At most one worker. It owns the only copy made here, and only a bool comes back. + fn score(&mut self, new: &str) { + if self.running { + self.waiting = true; + return; + } + let text = Zeroizing::new(new.to_owned()); + let (generation, slot) = (self.generation, Arc::clone(&self.slot)); + self.running = std::thread::Builder::new() + .spawn(move || { + let acceptable = policy::assess(&text).acceptable; + drop(text); + if let Ok(mut slot) = slot.lock() { + *slot = Some((generation, acceptable)); + } + }) + .is_ok(); + } +} + #[derive(Clone, Copy, PartialEq, Eq)] pub enum RevealMode { Held, @@ -72,6 +310,8 @@ pub struct Reveal { pub detail: String, pub value: Zeroizing, pub mode: RevealMode, + /// The readout's own limit, set when the Master Password was accepted. + pub closes_at: Instant, } /// Touch ID inside the protected window: an explanation, the system's embedded biometric view @@ -109,6 +349,37 @@ impl Callback { } impl Request { + /// The Recovery Key retype box shows what is typed: the key it repeats is on screen right + /// above it, and typing blind from paper is needlessly hard. Every other input stays a + /// secure field; `offers_show_typing` and `offers_hold_to_show` only add a separate mirror. + fn shows_typing(&self) -> bool { + matches!(self, Self::Entry(Prompt::RecoveryKit { .. })) + } + fn is_kit(&self) -> bool { + matches!(self, Self::Entry(Prompt::RecoveryKit { .. })) + } + #[cfg_attr(not(any(target_os = "macos", windows)), allow(dead_code))] + fn offers_show_typing(&self) -> bool { + matches!(self, Self::Entry(Prompt::Credential { recovery: true, .. })) + } + #[cfg_attr(not(any(target_os = "macos", windows)), allow(dead_code))] + fn offers_hold_to_show(&self) -> bool { + matches!(self, Self::Entry(Prompt::NewPassword { .. })) + } + /// A brief core lock must not disturb these: the owner is typing, and on Mac an opt-in view + /// would switch itself off. + #[cfg_attr(not(any(target_os = "macos", windows)), allow(dead_code))] + fn rides_out_a_blip(&self) -> bool { + self.is_kit() || self.offers_show_typing() || self.offers_hold_to_show() + } + /// Windows, where a rejected key never reaches the box. While the core is busy, actions wait + /// but typing still lands: a missing key in a box of dots can't be seen, and it changes the + /// password. Not in a blanked Kit box, because that step's key is hidden for now. The Mac's + /// field editor takes the key before anything checks it. + #[cfg_attr(not(windows), allow(dead_code))] + fn box_takes_typing_while_busy(&self, suspended: bool) -> bool { + matches!(self, Self::Entry(_)) && !(self.is_kit() && suspended) + } fn text(&self) -> (&str, &str) { match self { Self::Entry(prompt) => prompt.text(), @@ -122,13 +393,199 @@ impl Request { Self::Reveal { .. } | Self::Biometric(_) => Vec::new(), } } - fn reply(&self, fields: Vec>) -> Result { + fn reply(&self, fields: Vec>) -> Result { match self { Self::Entry(prompt) => prompt.reply(fields), - Self::Reveal { .. } => Err("Close this native readout to finish."), - Self::Biometric(_) => Err("Use Touch ID, or choose Cancel."), + Self::Reveal { .. } => Err(Refusal::keep("Press Close to finish.")), + Self::Biometric(_) => Err(Refusal::keep("Use Touch ID, or choose Cancel.")), } } + fn feedback_text(&self) -> String { + let key = if cfg!(windows) { "Enter" } else { "Return" }; + match self { + Self::Biometric(_) => { + "Touch the sensor now. Switching to another app or pressing Escape cancels." + .to_owned() + } + Self::Entry(Prompt::RecoveryKit { .. }) => { + format!( + "You can see what you type here. Press {key} to finish. Press Esc twice to cancel." + ) + } + Self::Entry(Prompt::NewPassword { .. }) => { + format!("{key} moves to the next box, then finishes. Esc cancels.") + } + Self::Entry(Prompt::Credential { .. }) => { + format!( + "Press {key} to continue, or Esc to cancel. Switching to another app clears this box." + ) + } + Self::Reveal { .. } => "Press Esc or Close when you finish.".to_owned(), + } + } + /// None leaves the row as it is. An empty text clears it. + fn time_row_text(&self, left: &TimeLeft) -> Option { + let (seconds, limit) = match *left { + TimeLeft::Busy => return None, + TimeLeft::Quiet => return Some(String::new()), + TimeLeft::KitCalm { idle_minutes } => { + return Some(if self.is_kit() { + kit_calm_text(idle_minutes) + } else { + String::new() + }); + } + TimeLeft::Left { seconds, limit } => (seconds, limit), + }; + let time = seconds_text(seconds); + Some(match self { + Self::Entry(Prompt::Credential { .. } | Prompt::NewPassword { .. }) => match limit { + TimeLimit::Panel => { + format!("This box closes in {time}. You would need to type again.") + } + TimeLimit::AutoLock => format!("The app locks in {time} if you stop typing."), + }, + Self::Entry(Prompt::RecoveryKit { rotation, .. }) => { + let outcome = if *rotation { + "nothing changes: your old password and old Recovery Key still work, and this new key is never used." + } else { + "no Vault is made and this key is never used." + }; + match limit { + TimeLimit::Panel => format!("This step ends in {time}. Then {outcome}"), + // "If it locks": a "Then" here would read as the result of typing. + TimeLimit::AutoLock => format!( + "The app locks in {time}. Typing in the box above gives you more time. If it locks, {outcome}" + ), + } + } + Self::Reveal { reveal, .. } if reveal.mode == RevealMode::Held => format!( + "This screen closes in {time}. You will need your Master Password to show it again." + ), + Self::Reveal { .. } | Self::Biometric(_) => String::new(), + }) + } +} + +fn seconds_text(seconds: u64) -> String { + if seconds == 1 { + "1 second".to_owned() + } else { + format!("{seconds} seconds") + } +} + +/// States the rules, not the time left: this line stays up until the last minute. +fn kit_calm_text(idle_minutes: u32) -> String { + match idle_minutes { + 1 => { + "If you don't type for 1 minute, the app locks. This step ends after 10 minutes in all." + .to_owned() + } + 2..=9 => format!( + "If you don't type for {idle_minutes} minutes, the app locks. This step ends after 10 minutes in all." + ), + _ => "This step ends after 10 minutes in all.".to_owned(), + } +} + +/// `left` is how many resets remain. +fn timed_readout_text(seconds: u64, left: u8) -> String { + let time = seconds_text(seconds); + match left { + 0 => format!( + "Hides in {time}. You can't reset the timer again. Enter your Master Password to show it again." + ), + 1 => format!("Hides in {time}. You can reset the timer 1 more time."), + left => format!("Hides in {time}. You can reset the timer {left} more times."), + } +} + +/// Every refusal goes through this, so color is never the only sign of a problem. +fn problem_text(message: &str) -> String { + format!("Problem: {message}") +} + +/// The same filter as `vault_format::RecoveryKey::parse`. +fn recovery_symbols(value: &str) -> impl Iterator + '_ { + value.chars().filter(|c| *c != '-' && !c.is_whitespace()) +} + +/// Holds a count only. +pub fn recovery_progress(value: &str) -> String { + format!( + "{} of {RECOVERY_SYMBOLS} characters", + recovery_symbols(value).count() + ) +} + +/// For the opt-in mirror: what the app reads as a Recovery Key, grouped like the paper. Anything +/// that can't be in one shows as `?`, so a password typed here by mistake doesn't show whole. +pub fn recovery_mirror(value: &str) -> Zeroizing { + // Reserved up front: growing would leave an unwiped copy behind. + let mut mirror = Zeroizing::new(String::with_capacity( + RECOVERY_SYMBOLS + RECOVERY_SYMBOLS / 4, + )); + for (index, symbol) in recovery_symbols(value).take(RECOVERY_SYMBOLS).enumerate() { + if index > 0 && index % 4 == 0 { + mirror.push('-'); + } + mirror.push(match symbol.to_ascii_uppercase() { + 'O' => '0', + 'I' | 'L' => '1', + 'U' => '?', + upper @ ('0'..='9' | 'A'..='Z') => upper, + _ => '?', + }); + } + mirror +} + +/// Which box takes the focus after a refusal that keeps the boxes, when the focus isn't in one: +/// `empty` says which boxes are empty, in order. The first empty box is what the message asks +/// for. Otherwise New, where a weak password gets its next word, or the only box. +#[cfg_attr(not(any(target_os = "macos", windows)), allow(dead_code))] +fn kept_refusal_box(empty: &[bool]) -> Option { + empty + .iter() + .position(|empty| *empty) + .or_else(|| (!empty.is_empty()).then(|| empty.len().saturating_sub(2))) +} + +/// Local checks only: no key derivation runs and no attempt is counted. +fn check_recovery_key(value: &str) -> Result<(), Refusal> { + let wrong_length = || { + Refusal::keep(format!( + "A Recovery Key has {RECOVERY_SYMBOLS} characters. You typed {}.", + recovery_symbols(value).count() + )) + }; + vault_format::RecoveryKey::parse(value) + .map(drop) + .map_err(|error| match error { + vault_format::RecoveryKeyError::Length => wrong_length(), + vault_format::RecoveryKeyError::InvalidSymbol(at) if at >= RECOVERY_SYMBOLS => { + wrong_length() + } + vault_format::RecoveryKeyError::InvalidSymbol(at) => Refusal::keep(format!( + "Group {} has a character a Recovery Key can't contain. Check it on your paper.", + at / 4 + 1 + )), + vault_format::RecoveryKeyError::Checksum => Refusal::keep( + "Something in this key is wrong. Check each group against your paper.", + ), + }) +} + +/// The first hint is all the row holds. A hint with no next step gets one. +fn weak_password_text(assessment: &policy::Assessment) -> String { + match assessment.feedback.first() { + Some(hint) if hint.contains(SUGGEST_BUTTON) => hint.clone(), + Some(hint) => format!("{hint} Add another unusual word, or press {SUGGEST_BUTTON}."), + None => format!( + "Choose a longer password. 5 or 6 random words work well, or press {SUGGEST_BUTTON}." + ), + } } /// Display-only wrapping. Core clipboard writes keep the original value. Explicit line breaks @@ -171,6 +628,7 @@ pub enum Prompt { title: String, detail: String, label: String, + recovery: bool, }, NewPassword { title: String, @@ -181,6 +639,7 @@ pub enum Prompt { title: String, detail: String, recovery: Zeroizing, + rotation: bool, }, } @@ -194,11 +653,63 @@ pub enum Reply { RecoveryKitConfirmed, } +/// Why a panel stays open. Holds counts and group numbers only, never anything typed. +struct Refusal { + message: Cow<'static, str>, + focus: RefusalFocus, +} + +impl Refusal { + fn new(message: impl Into>, focus: RefusalFocus) -> Self { + Self { + message: message.into(), + focus, + } + } + fn keep(message: impl Into>) -> Self { + Self::new(message, RefusalFocus::Keep) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RefusalFocus { + Keep, + /// Clears New and Confirm, never Current. + ClearAndFocusNew, + FocusConfirm, + /// Never select the text instead: a selection can reach the ordinary clipboard. + ClearAndFocusRetype, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PromptError { Unavailable, Busy, + /// The owner's Cancel button or Escape, or the coordinator closing this exact request. Cancelled, + /// The panel closed by itself: another app took focus, or authorization was denied. + Interrupted, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TimeLimit { + Panel, + AutoLock, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TimeLeft { + /// Can't tell right now: leave the row as it is. + Busy, + Quiet, + /// Recovery Kit with time to spare. + KitCalm { + idle_minutes: u32, + }, + Left { + seconds: u64, + limit: TimeLimit, + }, } /// Present controls inside this exact main window. Never opens a separate window. @@ -207,6 +718,8 @@ pub fn present( id: u64, prompt: Prompt, activity: Activity, + time_left: TimeProbe, + may_show: MayShow, completion: Completion, ) -> Result<(), PromptError> { #[cfg(target_os = "macos")] @@ -215,6 +728,8 @@ pub fn present( id, Request::Entry(prompt), activity, + time_left, + may_show, Callback::Entry(completion), ); #[cfg(windows)] @@ -223,11 +738,15 @@ pub fn present( id, Request::Entry(prompt), activity, + time_left, + may_show, Callback::Entry(completion), ); #[cfg(not(any(target_os = "macos", windows)))] { - let _ = (window, id, prompt, activity, completion); + let _ = ( + window, id, prompt, activity, time_left, may_show, completion, + ); Err(PromptError::Unavailable) } } @@ -240,6 +759,7 @@ pub fn present_reveal( reveal: Reveal, on_extend: Box Authorization + Send>, activity: Activity, + time_left: TimeProbe, completion: Box, ) -> Result<(), PromptError> { #[cfg(target_os = "macos")] @@ -251,6 +771,8 @@ pub fn present_reveal( extend: Some(on_extend), }, activity, + time_left, + Box::new(|| ShowCheck::Refused), Callback::Reveal(completion), ); #[cfg(windows)] @@ -262,11 +784,15 @@ pub fn present_reveal( extend: Some(on_extend), }, activity, + time_left, + Box::new(|| ShowCheck::Refused), Callback::Reveal(completion), ); #[cfg(not(any(target_os = "macos", windows)))] { - let _ = (window, id, reveal, on_extend, activity, completion); + let _ = ( + window, id, reveal, on_extend, activity, time_left, completion, + ); Err(PromptError::Unavailable) } } @@ -284,7 +810,15 @@ pub fn present_biometric( let request = Request::Biometric(biometric); let callback = Callback::Biometric(completion); #[cfg(target_os = "macos")] - return mac::present(window, id, request, activity, callback); + return mac::present( + window, + id, + request, + activity, + Box::new(|| TimeLeft::Quiet), + Box::new(|| ShowCheck::Refused), + callback, + ); #[cfg(not(target_os = "macos"))] { let _ = (window, id, request, activity, callback); @@ -324,32 +858,59 @@ impl Prompt { if *current_password { labels.push("Current Master Password"); } - labels.extend(["New Master Password", "Confirm new Master Password"]); + labels.extend(["New Master Password", "Type the new Master Password again"]); labels } - Self::RecoveryKit { .. } => vec!["Retype the last two groups of your Recovery Key"], + Self::RecoveryKit { .. } => vec!["Type the last two groups of 4 from your paper"], } } - fn reply(&self, mut fields: Vec>) -> Result { - if fields.len() != self.labels().len() || fields.iter().any(|value| value.len() > 4096) { - return Err("Use at most 4,096 bytes in each field."); + fn reply(&self, mut fields: Vec>) -> Result { + if fields.len() != self.labels().len() + || fields.iter().any(|value| value.len() > MAX_FIELD_BYTES) + { + return Err(Refusal::keep(TOO_LONG)); } match self { - Self::Credential { .. } => Ok(Reply::Credential(fields.remove(0))), + Self::Credential { recovery, .. } => { + let value = fields.remove(0); + // Empty only, never trimmed: a space is a real attempt. + if value.is_empty() { + return Err(Refusal::keep(if *recovery { + "Type your Recovery Key first." + } else { + "Type your Master Password first." + })); + } + if *recovery { + check_recovery_key(&value)?; + } + Ok(Reply::Credential(value)) + } Self::NewPassword { current_password, .. } => { let current = current_password.then(|| fields.remove(0)); + if current.as_ref().is_some_and(|current| current.is_empty()) { + return Err(Refusal::keep("Type your current Master Password first.")); + } let new = fields.remove(0); let confirmation = fields.remove(0); + if confirmation.is_empty() && !new.is_empty() { + return Err(Refusal::new( + "Type the same password in the second box.", + RefusalFocus::FocusConfirm, + )); + } if new.as_str() != confirmation.as_str() { - return Err("The new Master Passwords do not match."); + return Err(Refusal::new( + "The two passwords are different. Type both again.", + RefusalFocus::ClearAndFocusNew, + )); } - if !policy::assess(&new).acceptable { - return Err( - "Choose a stronger Master Password, or use a suggested passphrase.", - ); + let assessment = policy::assess(&new); + if !assessment.acceptable { + return Err(Refusal::keep(weak_password_text(&assessment))); } Ok(Reply::NewPassword { current, new }) } @@ -369,9 +930,10 @@ impl Prompt { let expected = symbols(recovery); let typed = symbols(&fields.remove(0)); if typed.len() != 8 || !expected.ends_with(typed.as_str()) { - return Err( - "Check the last two groups against the Recovery Kit you wrote down.", - ); + return Err(Refusal::new( + "That doesn't match. Look at the last two groups on your paper and type them again.", + RefusalFocus::ClearAndFocusRetype, + )); } Ok(Reply::RecoveryKitConfirmed) } @@ -382,8 +944,12 @@ impl Prompt { #[cfg(test)] mod tests { use super::{ - Authorization, PresentationState, PresentationUpdate, Prompt, Reply, readout_text, + ArmDecision, Authorization, Biometric, CancelArm, LiveCheck, PresentationState, + PresentationUpdate, Prompt, Refusal, RefusalFocus, Reply, Request, Reveal, RevealMode, + ShowCheck, TimeLeft, TimeLimit, kept_refusal_box, problem_text, readout_text, + recovery_mirror, recovery_progress, timed_readout_text, weak_password_text, }; + use std::time::{Duration, Instant}; use zeroize::Zeroizing; fn fields(values: &[&str]) -> Vec> { values @@ -391,28 +957,80 @@ mod tests { .map(|value| Zeroizing::new((*value).to_owned())) .collect() } + fn refused(result: Result) -> Refusal { + match result { + Ok(_) => panic!("expected the panel to stay open"), + Err(refusal) => refusal, + } + } + fn credential(recovery: bool) -> Prompt { + Prompt::Credential { + title: "Fake action".into(), + detail: String::new(), + label: "Fake label".into(), + recovery, + } + } + fn new_password(current_password: bool) -> Prompt { + Prompt::NewPassword { + title: "Fake setup".into(), + detail: String::new(), + current_password, + } + } + fn kit(rotation: bool) -> Prompt { + Prompt::RecoveryKit { + title: "Fake kit".into(), + detail: String::new(), + recovery: Zeroizing::new("FAKE-FAKE-FAKE-FAKE-FAKE-ABCD-EF01".into()), + rotation, + } + } + fn readout(mode: RevealMode) -> Request { + Request::Reveal { + reveal: Reveal { + title: "Fake readout".into(), + detail: String::new(), + value: Zeroizing::new("fake-value".into()), + mode, + closes_at: Instant::now(), + }, + extend: None, + } + } + fn biometric() -> Request { + Request::Biometric(Biometric { + title: "Fake".into(), + detail: String::new(), + request: 1, + }) + } + fn left(seconds: u64, limit: TimeLimit) -> TimeLeft { + TimeLeft::Left { seconds, limit } + } #[test] fn retry_clears_plaintext_until_one_successful_revalidation_restores_it() { let mut state = PresentationState::default(); + let now = Instant::now(); assert_eq!( - state.authorize(Authorization::Allowed), + state.authorize(Authorization::Allowed, now, false), PresentationUpdate::Keep ); assert_eq!( - state.authorize(Authorization::Retry), + state.authorize(Authorization::Retry, now, false), PresentationUpdate::Clear ); assert_eq!( - state.authorize(Authorization::Retry), + state.authorize(Authorization::Retry, now, false), PresentationUpdate::Clear ); assert_eq!( - state.authorize(Authorization::Allowed), + state.authorize(Authorization::Allowed, now, false), PresentationUpdate::Restore ); assert_eq!( - state.authorize(Authorization::Allowed), + state.authorize(Authorization::Allowed, now, false), PresentationUpdate::Keep ); } @@ -420,46 +1038,217 @@ mod tests { #[test] fn denied_presentation_never_restores_plaintext() { let mut state = PresentationState::default(); + let now = Instant::now(); assert_eq!( - state.authorize(Authorization::Retry), + state.authorize(Authorization::Retry, now, false), PresentationUpdate::Clear ); assert_eq!( - state.authorize(Authorization::Denied), + state.authorize(Authorization::Denied, now, false), PresentationUpdate::Close ); assert_eq!( - state.authorize(Authorization::Allowed), + state.authorize(Authorization::Allowed, now, false), PresentationUpdate::Close ); assert_eq!( - state.authorize(Authorization::Retry), + state.authorize(Authorization::Retry, now, false), PresentationUpdate::Close ); } + #[test] + fn a_brief_retry_on_the_kit_paints_nothing_but_only_after_a_real_allowed() { + let start = Instant::now(); + let at = |ms| start + Duration::from_millis(ms); + + // No grace on a first frame. + let mut state = PresentationState::default(); + assert_eq!( + state.authorize(Authorization::Retry, at(0), true), + PresentationUpdate::Clear + ); + assert_eq!( + state.authorize(Authorization::Allowed, at(10), true), + PresentationUpdate::Restore + ); + + // Under the limit it holds, and holding doesn't move the limit. + assert_eq!( + state.authorize(Authorization::Retry, at(100), true), + PresentationUpdate::Hold + ); + assert_eq!( + state.authorize(Authorization::Retry, at(359), true), + PresentationUpdate::Hold + ); + assert_eq!( + state.authorize(Authorization::Retry, at(360), true), + PresentationUpdate::Clear + ); + // Blanked stays blanked until a real Allowed, however near the last one was. + assert_eq!( + state.authorize(Authorization::Retry, at(361), true), + PresentationUpdate::Clear + ); + assert_eq!( + state.authorize(Authorization::Allowed, at(400), true), + PresentationUpdate::Restore + ); + + // A hold had nothing to restore. + assert_eq!( + state.authorize(Authorization::Retry, at(500), true), + PresentationUpdate::Hold + ); + assert_eq!( + state.authorize(Authorization::Allowed, at(520), true), + PresentationUpdate::Keep + ); + + // Denied wins. + assert_eq!( + state.authorize(Authorization::Retry, at(530), true), + PresentationUpdate::Hold + ); + assert_eq!( + state.authorize(Authorization::Denied, at(531), true), + PresentationUpdate::Close + ); + assert_eq!( + state.authorize(Authorization::Retry, at(532), true), + PresentationUpdate::Close + ); + + // Other prompts behave as before. + let mut state = PresentationState::default(); + assert_eq!( + state.authorize(Authorization::Allowed, at(0), false), + PresentationUpdate::Keep + ); + assert_eq!( + state.authorize(Authorization::Retry, at(1), false), + PresentationUpdate::Clear + ); + } + + #[test] + fn only_panels_where_the_owner_types_ride_out_a_blip() { + for request in [ + Request::Entry(kit(false)), + Request::Entry(credential(true)), + Request::Entry(new_password(false)), + Request::Entry(new_password(true)), + ] { + assert!(request.rides_out_a_blip()); + } + for request in [ + Request::Entry(credential(false)), + readout(RevealMode::Held), + readout(RevealMode::Timed), + biometric(), + ] { + assert!(!request.rides_out_a_blip()); + } + } + + #[test] + fn typing_lands_in_a_box_while_the_core_is_busy_except_a_blanked_kit() { + for request in [ + Request::Entry(credential(false)), + Request::Entry(credential(true)), + Request::Entry(new_password(false)), + Request::Entry(new_password(true)), + ] { + assert!(request.box_takes_typing_while_busy(false)); + assert!(request.box_takes_typing_while_busy(true)); + } + assert!(Request::Entry(kit(false)).box_takes_typing_while_busy(false)); + assert!(!Request::Entry(kit(false)).box_takes_typing_while_busy(true)); + for request in [readout(RevealMode::Held), biometric()] { + assert!(!request.box_takes_typing_while_busy(false)); + } + } + + #[test] + fn a_busy_answer_changes_nothing_and_only_a_refusal_blanks() { + assert_eq!(ShowCheck::Allowed.controls(false), (true, false)); + assert_eq!(ShowCheck::Refused.controls(true), (false, true)); + // A first frame starts unusable, so a busy one stays a no. + assert_eq!(ShowCheck::Busy.controls(false), (false, false)); + assert_eq!(ShowCheck::Busy.controls(true), (true, false)); + } + + #[test] + fn a_kept_refusal_focuses_the_box_it_asks_for() { + assert_eq!(kept_refusal_box(&[]), None); + // The only box: Master Password or Recovery Key, empty or not. + assert_eq!(kept_refusal_box(&[true]), Some(0)); + assert_eq!(kept_refusal_box(&[false]), Some(0)); + // Type your current Master Password first, with New and Confirm filled. + assert_eq!(kept_refusal_box(&[true, false, false]), Some(0)); + // Nothing typed yet is a length problem, and New comes first. + assert_eq!(kept_refusal_box(&[true, true]), Some(0)); + // A weak password: its next word goes in New. + assert_eq!(kept_refusal_box(&[false, false, false]), Some(1)); + assert_eq!(kept_refusal_box(&[false, false]), Some(0)); + } + + #[test] + fn only_the_recovery_key_retype_shows_what_is_typed() { + assert!(Request::Entry(kit(false)).shows_typing()); + assert!(Request::Entry(kit(false)).is_kit()); + assert!(!Request::Entry(credential(false)).shows_typing()); + assert!(!Request::Entry(credential(true)).shows_typing()); + assert!(!Request::Entry(credential(true)).is_kit()); + assert!(!Request::Entry(new_password(true)).shows_typing()); + assert!(!biometric().shows_typing()); + } + + #[test] + fn plaintext_mirrors_are_offered_only_where_the_owner_chose_them() { + assert!(Request::Entry(credential(true)).offers_show_typing()); + for request in [ + Request::Entry(credential(false)), + Request::Entry(new_password(true)), + Request::Entry(kit(false)), + readout(RevealMode::Held), + biometric(), + ] { + assert!(!request.offers_show_typing()); + } + assert!(Request::Entry(new_password(false)).offers_hold_to_show()); + assert!(Request::Entry(new_password(true)).offers_hold_to_show()); + for request in [ + Request::Entry(credential(false)), + Request::Entry(credential(true)), + Request::Entry(kit(false)), + readout(RevealMode::Timed), + biometric(), + ] { + assert!(!request.offers_hold_to_show()); + } + } + #[test] fn recovery_confirmation_never_returns_recovery_text() { - let prompt = Prompt::RecoveryKit { - title: "Fake kit".into(), - detail: String::new(), - recovery: Zeroizing::new("FAKE-FAKE-FAKE-FAKE-FAKE-ABCD-EF01".into()), - }; + let prompt = kit(false); assert!(prompt.reply(fields(&["WRNG-GRPS"])).is_err()); assert!(matches!( prompt.reply(fields(&["abcd efOl"])), Ok(Reply::RecoveryKitConfirmed) )); - assert!(prompt.reply(fields(&["FAKE-ABCD-EF01"])).is_err()); + let refusal = refused(prompt.reply(fields(&["FAKE-ABCD-EF01"]))); + assert_eq!(refusal.focus, RefusalFocus::ClearAndFocusRetype); + assert_eq!( + refusal.message, + "That doesn't match. Look at the last two groups on your paper and type them again." + ); } #[test] fn new_password_requires_strength_and_exact_confirmation() { - let prompt = Prompt::NewPassword { - title: "Fake setup".into(), - detail: String::new(), - current_password: true, - }; + let prompt = new_password(true); assert!(prompt.reply(fields(&["fake-old", "weak", "weak"])).is_err()); assert!( prompt @@ -477,14 +1266,480 @@ mod tests { } #[test] - fn input_shape_and_size_are_bounded() { - let prompt = Prompt::Credential { - title: "Fake action".into(), - detail: String::new(), - label: "Master Password".into(), + fn new_password_refusals_say_what_to_do_and_where_to_go() { + let prompt = new_password(true); + let fake = policy::suggest_passphrase(); + + let refusal = refused(prompt.reply(fields(&["", &fake, &fake]))); + assert_eq!(refusal.message, "Type your current Master Password first."); + assert_eq!(refusal.focus, RefusalFocus::Keep); + + let refusal = refused(prompt.reply(fields(&["fake-old", &fake, ""]))); + assert_eq!(refusal.message, "Type the same password in the second box."); + assert_eq!(refusal.focus, RefusalFocus::FocusConfirm); + + let refusal = refused(prompt.reply(fields(&["fake-old", &fake, "fake-other"]))); + assert_eq!( + refusal.message, + "The two passwords are different. Type both again." + ); + assert_eq!(refusal.focus, RefusalFocus::ClearAndFocusNew); + + let refusal = refused(prompt.reply(fields(&["fake-old", "fake-short", "fake-short"]))); + assert_eq!( + refusal.message, + "Use at least 15 characters. 5 or 6 random words work well, or press Suggest a password." + ); + assert_eq!(refusal.focus, RefusalFocus::Keep); + assert!(!refusal.message.contains("fake-short")); + + // Nothing typed yet is a length problem, not a mismatch. + let refusal = refused(new_password(false).reply(fields(&["", ""]))); + assert!(refusal.message.contains("15 characters")); + assert_eq!(refusal.focus, RefusalFocus::Keep); + } + + #[test] + fn a_hint_without_a_next_step_gets_one() { + let hints = |feedback: &[&str]| policy::Assessment { + acceptable: false, + score: 0, + feedback: feedback.iter().map(|hint| (*hint).to_owned()).collect(), }; + assert_eq!( + weak_password_text(&hints(&[ + "Fake hint, or press Suggest a password.", + "Ignored" + ])), + "Fake hint, or press Suggest a password." + ); + assert_eq!( + weak_password_text(&hints(&["Fake warning.", "Ignored"])), + "Fake warning. Add another unusual word, or press Suggest a password." + ); + assert_eq!( + weak_password_text(&hints(&[])), + "Choose a longer password. 5 or 6 random words work well, or press Suggest a password." + ); + } + + #[test] + fn an_empty_box_is_refused_but_a_space_is_a_real_attempt() { + assert_eq!( + refused(credential(false).reply(fields(&[""]))).message, + "Type your Master Password first." + ); + assert_eq!( + refused(credential(true).reply(fields(&[""]))).message, + "Type your Recovery Key first." + ); + assert!(matches!( + credential(false).reply(fields(&[" "])), + Ok(Reply::Credential(_)) + )); + } + + #[test] + fn recovery_key_typos_keep_the_panel_and_name_only_counts_and_groups() { + let prompt = credential(true); + let typed = "QQQ"; + let refusal = refused(prompt.reply(fields(&[typed]))); + assert_eq!( + refusal.message, + "A Recovery Key has 28 characters. You typed 3." + ); + assert_eq!(refusal.focus, RefusalFocus::Keep); + // Spaces and dashes aren't characters of the key. + assert_eq!( + refused(prompt.reply(fields(&[" - "]))).message, + "A Recovery Key has 28 characters. You typed 0." + ); + + let bad_symbol = "QQQQ-QQQQ-QQUQ-QQQQ-QQQQ-QQQQ-QQQQ"; + assert_eq!( + refused(prompt.reply(fields(&[bad_symbol]))).message, + "Group 3 has a character a Recovery Key can't contain. Check it on your paper." + ); + let too_long = "QQQQ-QQQQ-QQQQ-QQQQ-QQQQ-QQQQ-QQQQ-U"; + assert_eq!( + refused(prompt.reply(fields(&[too_long]))).message, + "A Recovery Key has 28 characters. You typed 29." + ); + let checksum = "QQQQ-QQQQ-QQQQ-QQQQ-QQQQ-QQQQ-QQQQ"; + let refusal = refused(prompt.reply(fields(&[checksum]))); + assert_eq!( + refusal.message, + "Something in this key is wrong. Check each group against your paper." + ); + + for value in [typed, bad_symbol, too_long, checksum] { + let message = refused(prompt.reply(fields(&[value]))).message; + assert!(!message.contains('Q'), "{message}"); + assert!(!problem_text(&message).contains('Q')); + } + + let fake_key = vault_format::RecoveryKey::generate().unwrap(); + assert!(matches!( + prompt.reply(fields(&[&fake_key.expose_display().to_lowercase()])), + Ok(Reply::Credential(_)) + )); + // Only a Recovery Key box parses: a password is never judged as one. + assert!(matches!( + credential(false).reply(fields(&["fake-password"])), + Ok(Reply::Credential(_)) + )); + } + + #[test] + fn recovery_progress_and_mirror_show_only_what_they_should() { + assert_eq!(recovery_progress(""), "0 of 28 characters"); + assert_eq!(recovery_progress("ab-cd ef"), "6 of 28 characters"); + assert_eq!(recovery_progress(&"Q".repeat(40)), "40 of 28 characters"); + + assert_eq!(recovery_mirror("").as_str(), ""); + assert_eq!(recovery_mirror("abcd").as_str(), "ABCD"); + assert_eq!(recovery_mirror("abcd e").as_str(), "ABCD-E"); + // What the app reads: look-alikes as their symbols, anything else hidden. + assert_eq!(recovery_mirror("oilu").as_str(), "011?"); + assert_eq!(recovery_mirror("a!é漢").as_str(), "A???"); + let capped = recovery_mirror(&"Q".repeat(40)); + assert_eq!(capped.as_str(), "QQQQ-QQQQ-QQQQ-QQQQ-QQQQ-QQQQ-QQQQ"); + assert!(capped.capacity() >= capped.len()); + } + + #[test] + fn input_shape_and_size_are_bounded() { + let prompt = credential(false); assert!(prompt.reply(Vec::new()).is_err()); - assert!(prompt.reply(fields(&[&"x".repeat(4097)])).is_err()); + assert_eq!( + refused(prompt.reply(fields(&[&"x".repeat(4097)]))).message, + "That is too long. Use fewer characters." + ); + } + + #[test] + fn readouts_and_touch_id_never_submit_and_their_text_says_so() { + assert_eq!( + refused(readout(RevealMode::Timed).reply(Vec::new())).message, + "Press Close to finish." + ); + assert_eq!( + refused(biometric().reply(Vec::new())).message, + "Use Touch ID, or choose Cancel." + ); + assert_eq!(problem_text("Fake."), "Problem: Fake."); + } + + #[test] + fn feedback_names_the_key_this_computer_has() { + let key = if cfg!(windows) { "Enter" } else { "Return" }; + assert_eq!( + Request::Entry(kit(false)).feedback_text(), + format!( + "You can see what you type here. Press {key} to finish. Press Esc twice to cancel." + ) + ); + assert_eq!( + Request::Entry(new_password(false)).feedback_text(), + format!("{key} moves to the next box, then finishes. Esc cancels.") + ); + assert_eq!( + Request::Entry(credential(false)).feedback_text(), + format!( + "Press {key} to continue, or Esc to cancel. Switching to another app clears this box." + ) + ); + for mode in [RevealMode::Held, RevealMode::Timed] { + assert_eq!( + readout(mode).feedback_text(), + "Press Esc or Close when you finish." + ); + } + assert_eq!( + biometric().feedback_text(), + "Touch the sensor now. Switching to another app or pressing Escape cancels." + ); + } + + #[test] + fn time_row_says_what_ends_and_what_that_means() { + let row = |request: &Request, time: TimeLeft| request.time_row_text(&time); + let password = Request::Entry(credential(false)); + assert_eq!(row(&password, TimeLeft::Busy), None); + assert_eq!(row(&password, TimeLeft::Quiet), Some(String::new())); + assert_eq!( + row(&password, left(1, TimeLimit::Panel)).unwrap(), + "This box closes in 1 second. You would need to type again." + ); + assert_eq!( + row(&password, left(9, TimeLimit::AutoLock)).unwrap(), + "The app locks in 9 seconds if you stop typing." + ); + assert_eq!( + row( + &Request::Entry(new_password(true)), + left(9, TimeLimit::Panel) + ) + .unwrap(), + "This box closes in 9 seconds. You would need to type again." + ); + + let setup = Request::Entry(kit(false)); + assert_eq!( + row(&setup, left(5, TimeLimit::Panel)).unwrap(), + "This step ends in 5 seconds. Then no Vault is made and this key is never used." + ); + assert_eq!( + row(&setup, left(5, TimeLimit::AutoLock)).unwrap(), + "The app locks in 5 seconds. Typing in the box above gives you more time. If it locks, no Vault is made and this key is never used." + ); + let rotation = Request::Entry(kit(true)); + for limit in [TimeLimit::Panel, TimeLimit::AutoLock] { + let text = row(&rotation, left(5, limit)).unwrap(); + assert!(text.contains("your old password and old Recovery Key still work")); + assert!(!text.contains("stops working"), "{text}"); + } + assert!( + row(&rotation, left(5, TimeLimit::AutoLock)) + .unwrap() + .contains("more time. If it locks, nothing changes:") + ); + assert_eq!( + row(&setup, TimeLeft::KitCalm { idle_minutes: 5 }).unwrap(), + "If you don't type for 5 minutes, the app locks. This step ends after 10 minutes in all." + ); + assert_eq!( + row(&setup, TimeLeft::KitCalm { idle_minutes: 1 }).unwrap(), + "If you don't type for 1 minute, the app locks. This step ends after 10 minutes in all." + ); + for idle_minutes in [10, 60] { + assert_eq!( + row(&setup, TimeLeft::KitCalm { idle_minutes }).unwrap(), + "This step ends after 10 minutes in all." + ); + } + assert_eq!( + row(&password, TimeLeft::KitCalm { idle_minutes: 5 }), + Some(String::new()) + ); + + let held = readout(RevealMode::Held); + for limit in [TimeLimit::Panel, TimeLimit::AutoLock] { + let text = row(&held, left(30, limit)).unwrap(); + assert_eq!( + text, + "This screen closes in 30 seconds. You will need your Master Password to show it again." + ); + assert!(!text.contains("typ") && !text.contains("key") && !text.contains("stay")); + } + assert_eq!( + row(&readout(RevealMode::Timed), left(30, TimeLimit::Panel)), + Some(String::new()) + ); + assert_eq!( + row(&biometric(), left(30, TimeLimit::Panel)), + Some(String::new()) + ); + } + + #[test] + fn timed_readout_counts_read_correctly() { + for (seconds, time) in [(1, "1 second"), (2, "2 seconds"), (20, "20 seconds")] { + for (resets, rest) in [ + (3, "You can reset the timer 3 more times."), + (2, "You can reset the timer 2 more times."), + (1, "You can reset the timer 1 more time."), + ( + 0, + "You can't reset the timer again. Enter your Master Password to show it again.", + ), + ] { + assert_eq!( + timed_readout_text(seconds, resets), + format!("Hides in {time}. {rest}") + ); + } + } + } + + #[test] + fn the_kit_cancel_asks_once_and_a_bounce_is_not_a_second_press() { + let start = Instant::now(); + let at = |ms| start + Duration::from_millis(ms); + + let mut arm = CancelArm::default(); + assert!(!arm.expired(at(0))); + assert_eq!(arm.press(at(0), true), ArmDecision::Arm); + assert_eq!(arm.press(at(299), true), ArmDecision::Ignore); + assert_eq!(arm.press(at(300), true), ArmDecision::Cancel); + + // A double-click on Cancel is one press. + let mut arm = CancelArm::default(); + assert_eq!(arm.press(at(0), false), ArmDecision::Arm); + assert_eq!(arm.press(at(499), false), ArmDecision::Ignore); + assert_eq!(arm.press(at(500), false), ArmDecision::Cancel); + + // Ten seconds later it asks again. + let mut arm = CancelArm::default(); + assert_eq!(arm.press(at(0), true), ArmDecision::Arm); + assert!(!arm.expired(at(10_000))); + assert_eq!(arm.press(at(10_000), true), ArmDecision::Cancel); + let mut arm = CancelArm::default(); + assert_eq!(arm.press(at(0), true), ArmDecision::Arm); + assert!(arm.expired(at(10_001))); + assert_eq!(arm.press(at(10_001), true), ArmDecision::Arm); + assert_eq!(arm.press(at(10_400), true), ArmDecision::Cancel); + + // Typing disarms. + let mut arm = CancelArm::default(); + assert_eq!(arm.press(at(0), true), ArmDecision::Arm); + arm.disarm(); + assert!(!arm.expired(at(20_000))); + assert_eq!(arm.press(at(400), true), ArmDecision::Arm); + } + + fn settled(live: &mut LiveCheck) -> bool { + for _ in 0..1_000 { + if live.poll() { + return true; + } + std::thread::sleep(Duration::from_millis(5)); + } + false + } + + #[test] + fn live_line_waits_for_a_pause_and_counts_like_the_rule() { + let start = Instant::now(); + let mut live = LiveCheck::default(); + assert!(!live.due(start)); + live.edited(start); + assert!(!live.due(start + Duration::from_millis(299))); + assert!(live.due(start + Duration::from_millis(300))); + // A held key edits again and again: the line waits for the release, then reads the boxes. + live.edited(start + Duration::from_millis(200)); + assert!(!live.due(start + Duration::from_millis(300))); + assert!(live.due(start + Duration::from_millis(500))); + // Decomposed letters count once, like the Vault's NFC form. + let fake = "e\u{301}e\u{301}e\u{301}"; + assert_eq!(policy::char_count(fake), 3); + assert_eq!(live.line(fake, ""), "3 of 15 characters"); + assert!(!live.due(start + Duration::from_secs(1))); + assert_eq!( + live.line(fake, fake), + "3 of 15 characters. The two passwords match." + ); + assert_eq!( + live.line(fake, "fake"), + "3 of 15 characters. The two passwords don't match yet." + ); + } + + #[test] + fn live_line_reports_strength_only_for_the_latest_text() { + let start = Instant::now(); + let strong = policy::suggest_passphrase(); + let mut live = LiveCheck::default(); + live.edited(start); + assert_eq!(live.line(&strong, ""), "Checking…"); + assert!(settled(&mut live)); + assert_eq!(live.line(&strong, ""), "Strong enough."); + assert_eq!( + live.line(&strong, &strong), + "Strong enough. The two passwords match." + ); + + // An edit to New forgets the answer: nothing is reused for other text. + let weak = "fake fake fake fake fake"; + live.edited(start); + assert_eq!( + live.line(weak, "other"), + "Checking… The two passwords don't match yet." + ); + assert!(settled(&mut live)); + assert_eq!( + live.line(weak, ""), + "Not strong enough yet. Add another unusual word." + ); + } + + #[test] + fn an_edit_to_confirm_keeps_the_answer_about_new() { + let start = Instant::now(); + let strong = policy::suggest_passphrase(); + let mut live = LiveCheck::default(); + // Change password: Current, New, Confirm. + live.box_edited(start, 3, Some(1)); + assert_eq!(live.line(&strong, ""), "Checking…"); + assert!(settled(&mut live)); + let generation = live.generation; + + live.box_edited(start, 3, Some(2)); + assert!(live.due(start + Duration::from_millis(300))); + assert_eq!( + live.line(&strong, "other"), + "Strong enough. The two passwords don't match yet." + ); + assert_eq!(live.generation, generation); + assert!(!live.running); + + // A worker running for the same New still delivers. + live.strength = None; + live.running = true; + live.box_edited(start, 3, Some(2)); + *live.slot.lock().unwrap() = Some((generation, true)); + assert!(live.poll()); + assert_eq!(live.strength, Some(true)); + + // New, or a box that can't be told apart, forgets it. + for index in [Some(1), None] { + live.strength = Some(true); + live.box_edited(start, 3, index); + assert_eq!(live.strength, None); + assert_eq!( + live.line(&strong, "other"), + "Checking… The two passwords don't match yet." + ); + assert!(settled(&mut live)); + } + } + + #[test] + fn a_stale_strength_answer_is_dropped() { + let start = Instant::now(); + let mut live = LiveCheck::default(); + live.edited(start); + let old = live.generation; + // The answer for older text arrives after another edit. + live.running = true; + live.edited(start); + *live.slot.lock().unwrap() = Some((old, true)); + assert!(!live.poll()); + assert_eq!(live.strength, None); + assert!(!live.running); + + // Text that had to wait for the worker gets its turn. + live.running = true; + let strong = policy::suggest_passphrase(); + assert_eq!(live.line(&strong, ""), "Checking…"); + assert!(live.waiting); + *live.slot.lock().unwrap() = Some((old, true)); + assert!(live.poll()); + assert_eq!(live.strength, None); + assert_eq!(live.line(&strong, ""), "Checking…"); + assert!(settled(&mut live)); + assert_eq!(live.line(&strong, ""), "Strong enough."); + } + + #[test] + fn live_line_skips_all_checks_on_oversize_text() { + let oversize = "x".repeat(4097); + let mut live = LiveCheck::default(); + live.edited(Instant::now()); + let too_long = "That is too long. Use fewer characters."; + assert_eq!(live.line(&oversize, ""), too_long); + assert_eq!(live.line("fake", &oversize), too_long); + assert!(!live.running); } #[test] diff --git a/src-tauri/src/native_credential/mac.rs b/src-tauri/src/native_credential/mac.rs index 2229e84..9bc353d 100644 --- a/src-tauri/src/native_credential/mac.rs +++ b/src-tauri/src/native_credential/mac.rs @@ -1,27 +1,45 @@ //! AppKit controls replace the content view of the already protected NSWindow. The original //! WebView stays retained, but cannot receive input or draw over the native credential panel. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; +use std::ops::Range; use std::time::{Duration, Instant}; use objc2::rc::Retained; -use objc2::runtime::{AnyObject, Sel}; -use objc2::{MainThreadOnly, class, define_class, msg_send, sel}; +use objc2::runtime::{AnyClass, AnyObject, Sel}; +use objc2::{ClassType, MainThreadOnly, class, define_class, msg_send, sel}; use objc2_app_kit::{ - NSAutoresizingMaskOptions, NSBezelStyle, NSButton, NSFont, NSResponder, NSScrollView, - NSSecureTextField, NSTextField, NSView, NSWindow, + NSAutoresizingMaskOptions, NSBezelStyle, NSButton, NSButtonType, NSColor, NSFont, NSResponder, + NSScrollView, NSSecureTextField, NSTextField, NSTextFieldCell, NSTextView, NSView, NSWindow, }; use objc2_foundation::{ - MainThreadMarker, NSObject, NSObjectProtocol, NSPoint, NSRect, NSRunLoopCommonModes, NSSize, - NSString, ns_string, + MainThreadMarker, NSArray, NSObject, NSObjectProtocol, NSPoint, NSRect, NSRunLoopCommonModes, + NSSize, NSString, ns_string, }; use zeroize::Zeroizing; use super::{ - Activity, Authorization, Callback, PresentationState, PresentationUpdate, Prompt, PromptError, - Reply, Request, RevealMode, readout_text, + Activity, ArmDecision, Authorization, CAPS_LOCK_TEXT, Callback, CancelArm, HOLD_TO_SHOW_LABEL, + KIT_ARMED, KIT_PAPER_NOTE, LiveCheck, MayShow, PresentationState, PresentationUpdate, Prompt, + PromptError, RESET_BUTTON, RefusalFocus, Reply, Request, RevealMode, SHOW_REFUSED_TEXT, + SHOW_TYPING_LABEL, SUGGEST_BUTTON, SUGGESTED_TEXT, SUGGESTION_ASK, SUGGESTION_REMOVED, + ShowCheck, Suggestion, TimeLeft, TimeLimit, TimeProbe, kept_refusal_box, problem_text, + readout_text, recovery_mirror, recovery_progress, timed_readout_text, }; -thread_local! { static ACTIVE: RefCell> = const { RefCell::new(None) }; } +/// Long enough for the fullest Wallet warning, short enough to leave the rows below it on screen. +const DETAIL_MAX_HEIGHT: f64 = 216.0; +const HOLD_LIMIT: Duration = Duration::from_secs(10); +/// Show what I type turns itself off after this long with no real key press. +const TYPED_IDLE_LIMIT: Duration = Duration::from_secs(60); + +thread_local! { + static ACTIVE: RefCell> = const { RefCell::new(None) }; + /// True while this file changes a box's text itself: AppKit's notice of that isn't an edit. + static SETTING_BOXES: Cell = const { Cell::new(false) }; + /// The retype box's own field editor. Here, not in the Panel, because AppKit asks for it from + /// inside other calls. `finish` blanks and drops it. + static RETYPE_EDITOR: RefCell>> = const { RefCell::new(None) }; +} struct Panel { id: u64, @@ -31,14 +49,46 @@ struct Panel { _view: Retained, _target: Retained, _authentication: Option>, - fields: Vec>, + fields: Vec>, sensitive_labels: Vec>, feedback: Retained, + time_row: Retained, + time_row_shown: String, + /// The probe's last answer: a busy core gives none, and Caps Lock can change without one. + time_text: String, prompt: Request, completion: Callback, activity: Option, + time_left: TimeProbe, + may_show: MayShow, presentation: PresentationState, - suggestion: Option>, + cancel_arm: CancelArm, + last_error: Option, + /// A refusal or the removed-suggestion notice is on screen. It stays until the next edit. + error_shown: bool, + live: LiveCheck, + /// Counts and yes/no answers only. Comes back after a brief suspension. + live_line: Option, + suggestion: Option, + show_typing: Option>, + typed_hold: Option>, + /// A snapshot while held. Not in `sensitive_labels`, so Restore can never refill it. + hold_mirror: Option>, + /// Which box the hold mirror shows: that box's own label, never anything typed. + hold_caption: Option>, + hold_until: Option, + /// The New or Confirm box typed in last, by index. Never Current. A hold falls back on it. + last_edited: Option, + /// New and Confirm were empty when last read. Tracked so the timer never reads the boxes. + boxes_empty: bool, + /// The last clear answer from `may_show`. Starts as no, so a busy first frame stays a no. + show_usable: bool, + /// Why the two controls are grey. No secret, so it isn't in `sensitive_labels`. + show_refused: Option>, + /// The last real key press while Show what I type is ticked. None while it isn't. + typed_key_at: Option, + /// A key landed in a blip, so the count and mirror wait for the next good check. + lines_stale: bool, readout: Option>, readout_scroll: Option>, readout_shown: bool, @@ -57,52 +107,43 @@ define_class!( unsafe impl NSObjectProtocol for Target {} impl Target { #[unsafe(method(submit:))] - fn submit(&self, _sender: Option<&AnyObject>) { if activity(true) { submit(); } } + fn submit(&self, _sender: Option<&AnyObject>) { + // The Return key equivalent gets here too. Holding Return must not submit again. + if !current_event_repeats() && activity(true) { submit(); } + } #[unsafe(method(cancel:))] - fn cancel(&self, _sender: Option<&AnyObject>) { activity(true); finish(Err(PromptError::Cancelled)); } + fn cancel(&self, _sender: Option<&AnyObject>) { owner_cancel(); } #[unsafe(method(suggest:))] - fn suggest(&self, _sender: Option<&AnyObject>) { if activity(true) { suggest(); } } + fn suggest(&self, sender: Option<&AnyObject>) { if activity(true) { suggest(sender); } } #[unsafe(method(extend:))] fn extend(&self, _sender: Option<&AnyObject>) { if activity(true) { extend(); } } + #[unsafe(method(showTyping:))] + fn show_typing(&self, _sender: Option<&AnyObject>) { show_typing_changed(); } #[unsafe(method(tick:))] fn tick(&self, _sender: Option<&AnyObject>) { tick(); } #[unsafe(method(focusLost:))] - fn focus_lost(&self, _notification: &AnyObject) { finish(Err(PromptError::Cancelled)); } + fn focus_lost(&self, _notification: &AnyObject) { finish(Err(PromptError::Interrupted)); } #[unsafe(method(control:textView:doCommandBySelector:))] fn command(&self, _control: &AnyObject, _editor: &AnyObject, command: Sel) -> bool { - if command == sel!(cancelOperation:) { - activity(true); finish(Err(PromptError::Cancelled)); true } - else if command == sel!(insertNewline:) { activity(true); submit(); true } + if command == sel!(cancelOperation:) { owner_cancel(); true } + else if command == sel!(insertNewline:) { return_pressed(); true } else { false } } #[unsafe(method(controlTextDidChange:))] - fn changed(&self, _notification: &AnyObject) { - forget_suggestion(); - activity(edit_activity_is_genuine()); - } + fn changed(&self, notification: &AnyObject) { text_changed(notification); } #[unsafe(method(controlTextDidBeginEditing:))] fn editing(&self, _notification: &AnyObject) { - ACTIVE.with(|active| { - if let Some(panel) = active.borrow().as_ref() { - for field in &panel.fields { - // SAFETY: currentEditor is an optional NSTextView belonging to this - // live field. Only documented NSTextView assistance switches are sent. - unsafe { - let editor: Option> = msg_send![field, currentEditor]; - if let Some(editor) = editor { - let _: () = msg_send![&editor, setAllowsUndo: false]; - let _: () = msg_send![&editor, setAutomaticSpellingCorrectionEnabled: false]; - let _: () = msg_send![&editor, setAutomaticTextReplacementEnabled: false]; - let _: () = msg_send![&editor, setAutomaticQuoteSubstitutionEnabled: false]; - let _: () = msg_send![&editor, setAutomaticDashSubstitutionEnabled: false]; - let _: () = msg_send![&editor, setAutomaticTextCompletionEnabled: false]; - let _: () = msg_send![&editor, setContinuousSpellCheckingEnabled: false]; - let _: () = msg_send![&editor, setGrammarCheckingEnabled: false]; - } - } - } - } + // A fallback: `present` already did this for the first box. + let boxes = ACTIVE.with(|active| { + let active = active.try_borrow().ok()?; + let panel = active.as_ref()?; + Some((panel.fields.clone(), panel.prompt.shows_typing())) }); + if let Some((fields, roman_only)) = boxes { + for field in &fields { + quiet_editor(field, roman_only); + } + } } } ); @@ -114,9 +155,11 @@ define_class!( struct HoldButton; unsafe impl NSObjectProtocol for HoldButton {} impl HoldButton { + #[unsafe(method(canBecomeKeyView))] + fn can_become_key_view(&self) -> bool { self.isEnabled() } #[unsafe(method(mouseDown:))] fn mouse_down(&self, _event: &AnyObject) { - if !activity(true) { return; } + if !self.isEnabled() || !activity(true) { return; } self.highlight(true); held(true); } @@ -124,10 +167,14 @@ define_class!( fn mouse_up(&self, _event: &AnyObject) { self.highlight(false); held(false); + return_hold_focus(self); } #[unsafe(method(keyDown:))] fn key_down(&self, event: &AnyObject) { - if !activity(!unsafe { msg_send![event, isARepeat] }) { return; } + // SAFETY: keyDown: receives an NSEvent and this is its documented BOOL property. + let repeated: bool = unsafe { msg_send![event, isARepeat] }; + if !activity(!repeated) { return; } + if typed_hold_key(event, repeated) { return; } if reveal_navigation(event) { return; } // SAFETY: keyDown: receives an NSEvent and this is its documented string property. let text: Option> = unsafe { msg_send![event, charactersIgnoringModifiers] }; @@ -137,8 +184,13 @@ define_class!( } #[unsafe(method(keyUp:))] fn key_up(&self, event: &AnyObject) { - if !activity(true) { return; } - if is_space_event(event) { held(false); } + let passed = activity(true); + // Whatever the check says: a release must always end what the press showed. + if is_space_event(event) { + held(false); + return_hold_focus(self); + } + if !passed { return; } // SAFETY: Forward the unmodified live NSEvent through the native button. unsafe { let _: () = msg_send![super(self), keyUp: event]; } } @@ -151,6 +203,139 @@ define_class!( } ); +// With Keyboard navigation off, the Mac's default, Tab skips a plain button and tickbox. Cancel and +// Continue stay plain: Escape and Return already reach them. +define_class!( + #[unsafe(super = NSButton)] + #[thread_kind = MainThreadOnly] + #[name = "EncryptedNoteCredentialKeyButton"] + struct KeyButton; + unsafe impl NSObjectProtocol for KeyButton {} + impl KeyButton { + #[unsafe(method(canBecomeKeyView))] + fn can_become_key_view(&self) -> bool { self.isEnabled() } + } +); + +// The Recovery Kit's retype box isn't a secure field, so AppKit would let its text reach the +// ordinary clipboard and system text tools. It gets its own field editor that sends nothing out. +// Typing, paste and Select All stay. tao owns the window delegate, so the cell supplies it. +define_class!( + #[unsafe(super = NSTextView)] + #[thread_kind = MainThreadOnly] + #[name = "EncryptedNoteCredentialRetypeEditor"] + struct RetypeEditor; + unsafe impl NSObjectProtocol for RetypeEditor {} + impl RetypeEditor { + #[unsafe(method(copy:))] + fn copy(&self, _sender: Option<&AnyObject>) {} + #[unsafe(method(cut:))] + fn cut(&self, _sender: Option<&AnyObject>) {} + #[unsafe(method(validateMenuItem:))] + fn validate_menu_item(&self, item: &AnyObject) -> bool { + // SAFETY: AppKit passes a live NSMenuItem, and action is its documented property. + let action: Option = unsafe { msg_send![item, action] }; + if action.is_some_and(|action| action == sel!(copy:) || action == sel!(cut:)) { + false + } else { + // SAFETY: Forward the unchanged menu item to NSTextView's own check. + unsafe { msg_send![super(self), validateMenuItem: item] } + } + } + #[unsafe(method_id(menuForEvent:))] + fn menu_for_event(&self, _event: &AnyObject) -> Option> { None } + // The app menu's Services asks here. A return type alone only brings text in. + #[unsafe(method_id(validRequestorForSendType:returnType:))] + fn valid_requestor( + &self, + send: Option<&AnyObject>, + take: Option<&AnyObject>, + ) -> Option> { + if send.is_some() { + None + } else { + // SAFETY: Forward the unchanged pasteboard types to NSTextView's own check. + unsafe { msg_send![super(self), validRequestorForSendType: send, returnType: take] } + } + } + #[unsafe(method(dragSelectionWithEvent:offset:slideBack:))] + fn drag_selection(&self, _event: &AnyObject, _offset: NSSize, _slide_back: bool) -> bool { + false + } + #[unsafe(method_id(writablePasteboardTypes))] + fn writable_pasteboard_types(&self) -> Retained> { NSArray::new() } + // Writing Tools off (NSWritingToolsBehaviorNone). A getter, because AppKit sets this again + // each time the box takes focus. Older macOS, which has no such property, never asks. + #[unsafe(method(writingToolsBehavior))] + fn writing_tools_behavior(&self) -> isize { -1 } + #[unsafe(method(setWritingToolsBehavior:))] + fn set_writing_tools_behavior(&self, _behavior: isize) {} + // Look Up would put this text in a system window that Capture Hiding doesn't cover. + #[unsafe(method(quickLookWithEvent:))] + fn quick_look_with_event(&self, _event: &AnyObject) {} + #[unsafe(method(quickLookPreviewItems:))] + fn quick_look_preview_items(&self, _sender: Option<&AnyObject>) {} + // A force click's own route to Look Up. NSSecureTextView closes it with these two. They are + // private names: if Apple changes them, only the physical check would notice. + #[unsafe(method_id(_immediateActionAnimationControllerForCharacterAtIndex:withRecognizer:))] + fn immediate_action_controller( + &self, + _index: usize, + _recognizer: Option<&AnyObject>, + ) -> Option> { + None + } + #[unsafe(method_id(_revealAnimationControllerForCharacterAtIndex:))] + fn reveal_controller(&self, _index: usize) -> Option> { None } + } +); + +define_class!( + #[unsafe(super = NSTextFieldCell)] + #[thread_kind = MainThreadOnly] + #[name = "EncryptedNoteCredentialRetypeCell"] + struct RetypeCell; + unsafe impl NSObjectProtocol for RetypeCell {} + impl RetypeCell { + #[unsafe(method_id(fieldEditorForView:))] + fn field_editor_for_view(&self, _view: &AnyObject) -> Option> { + let mtm = self.mtm(); + RETYPE_EDITOR.with(|editor| { + let mut editor = editor.try_borrow_mut().ok()?; + Some(editor.get_or_insert_with(|| new_retype_editor(mtm)).clone()) + }) + } + } +); + +define_class!( + #[unsafe(super = NSTextField)] + #[thread_kind = MainThreadOnly] + #[name = "EncryptedNoteCredentialRetypeField"] + struct RetypeField; + unsafe impl NSObjectProtocol for RetypeField {} + impl RetypeField { + // A new field makes this cell with a plain field's settings. + #[unsafe(method(cellClass))] + fn cell_class() -> &'static AnyClass { RetypeCell::class() } + } +); + +fn new_retype_editor(mtm: MainThreadMarker) -> Retained { + // SAFETY: NSTextView's documented initializer on a fresh allocation of this subclass, then its + // documented setters. + unsafe { + let editor: Retained = + msg_send![RetypeEditor::alloc(mtm), initWithFrame: rect(0.0, 0.0, 0.0, 0.0)]; + let _: () = msg_send![&editor, setFieldEditor: true]; + let _: () = msg_send![&editor, setRichText: false]; + let _: () = msg_send![&editor, setImportsGraphics: false]; + // Some macOS versions add a force-click recognizer at init. Typing and clicks don't use it. + let _: () = msg_send![&editor, setGestureRecognizers: &*NSArray::::new()]; + editor + } +} + impl Target { fn new(mtm: MainThreadMarker) -> Retained { // SAFETY: NSObject init has no additional requirements. @@ -200,6 +385,8 @@ pub(super) fn present( id: u64, prompt: Request, on_activity: Activity, + time_left: TimeProbe, + may_show: MayShow, completion: Callback, ) -> Result<(), PromptError> { let mtm = MainThreadMarker::new().ok_or(PromptError::Unavailable)?; @@ -218,32 +405,35 @@ pub(super) fn present( return Err(PromptError::Unavailable); } let original_focus = window.firstResponder(); - let view = NSView::initWithFrame(NSView::alloc(mtm), original.bounds()); - view.setAutoresizingMask( + let root = NSView::initWithFrame(NSView::alloc(mtm), original.bounds()); + root.setAutoresizingMask( NSAutoresizingMaskOptions::ViewWidthSizable | NSAutoresizingMaskOptions::ViewHeightSizable, ); let target = Target::new(mtm); - let bounds = view.bounds(); + let bounds = root.bounds(); + // Rows go downward from 0 in this full-width container. Its real height is known only after + // the last row, so it gets that height, and its place in the window, at the end. + let view = NSView::initWithFrame(NSView::alloc(mtm), rect(0.0, 0.0, bounds.size.width, 0.0)); + view.setAutoresizingMask( + NSAutoresizingMaskOptions::ViewWidthSizable + | NSAutoresizingMaskOptions::ViewMinYMargin + | NSAutoresizingMaskOptions::ViewMaxYMargin, + ); + view.setAutoresizesSubviews(false); let width = (bounds.size.width - 64.0).clamp(240.0, 560.0); let x = (bounds.size.width - width) / 2.0; - let mut y = bounds.size.height - 28.0; + let mut y = 0.0; let (title, detail) = prompt.text(); let heading = label(&view, title, x, place(&mut y, 30.0, 8.0), width, 30.0, mtm); heading.setFont(Some(&NSFont::boldSystemFontOfSize(21.0))); - let detail_height = match &prompt { - Request::Entry(Prompt::NewPassword { .. }) => 48.0, - Request::Entry(Prompt::RecoveryKit { .. }) | Request::Biometric(_) => 64.0, - _ => 160.0, - }; - label( - &view, - detail, + let detail_label = label(&view, detail, x, 0.0, width, 0.0, mtm); + let detail_height = fitted_height(&detail_label, width).min(DETAIL_MAX_HEIGHT); + detail_label.setFrame(rect( x, place(&mut y, detail_height, 8.0), width, detail_height, - mtm, - ); + )); let mut authentication = None; if let Request::Biometric(biometric) = &prompt { let pointer = crate::biometry::authentication_view(biometric.request).cast::(); @@ -324,34 +514,33 @@ pub(super) fn present( readout_scroll = Some(scroll); let button_y = place(&mut y, 32.0, 12.0); if reveal.mode == RevealMode::Held { - let button = unsafe { - msg_send![HoldButton::alloc(mtm), initWithFrame: rect(x, button_y, 230.0, 32.0)] - }; - let button: Retained = button; - anchor(&button); - button.setTitle(ns_string!("Hold to show (or hold Space)")); - button.setBezelStyle(NSBezelStyle::Push); - view.addSubview(&button); - hold_button = Some(button); + hold_button = Some(new_hold_button( + &view, + "Hold to show (or hold Space)", + rect(x, button_y, 230.0, 32.0), + mtm, + )); } else { - extend_button = Some(button( + let extend = key_button( &view, - "Extend by 20 seconds", + RESET_BUTTON, rect(x, button_y, 200.0, 30.0), &target, sel!(extend:), mtm, - )); + ); + extend.setBezelStyle(NSBezelStyle::Push); + extend_button = Some(extend); } } if let Request::Entry(Prompt::RecoveryKit { recovery, .. }) = &prompt { label( &view, - "Write this Recovery Key on paper and store it somewhere private.", + KIT_PAPER_NOTE, x, - place(&mut y, 32.0, 6.0), + place(&mut y, 48.0, 6.0), width, - 32.0, + 48.0, mtm, ); let key = label( @@ -363,7 +552,7 @@ pub(super) fn present( 32.0, mtm, ); - key.setFont(Some(&NSFont::monospacedSystemFontOfSize_weight(16.0, 0.0))); + key.setFont(Some(&NSFont::monospacedSystemFontOfSize_weight(20.0, 0.0))); sensitive_labels.push(key); } let mut fields = Vec::new(); @@ -377,12 +566,21 @@ pub(super) fn present( 22.0, mtm, ); - let field = NSSecureTextField::initWithFrame( - NSSecureTextField::alloc(mtm), - rect(x, place(&mut y, 28.0, 8.0), width, 28.0), - ); + let frame = rect(x, place(&mut y, 28.0, 8.0), width, 28.0); + let field: Retained = if prompt.shows_typing() { + // SAFETY: NSTextField's documented initializer on a fresh allocation of this subclass. + let field: Retained = + unsafe { msg_send![RetypeField::alloc(mtm), initWithFrame: frame] }; + let field = Retained::into_super(field); + field.setFont(Some(&NSFont::monospacedSystemFontOfSize_weight(16.0, 0.0))); + field.setPlaceholderString(Some(ns_string!("XXXX-XXXX"))); + field + } else { + let field = NSSecureTextField::initWithFrame(NSSecureTextField::alloc(mtm), frame); + field.setFont(Some(&NSFont::systemFontOfSize(15.0))); + Retained::into_super(field) + }; anchor(&field); - field.setFont(Some(&NSFont::systemFontOfSize(15.0))); // SAFETY: The retained target implements these exact NSTextField delegate methods and // outlives the native controls. Accessibility labels contain action text, never input. unsafe { @@ -394,52 +592,117 @@ pub(super) fn present( } fields.push(field); } + let mut show_typing = None; + let mut show_refused = None; + if prompt.offers_show_typing() { + // The box stays secure. The tickbox and the separate read-only line share one row. + let row_y = place(&mut y, 24.0, 6.0); + let checkbox = key_button( + &view, + SHOW_TYPING_LABEL, + rect(x, row_y, 150.0, 24.0), + &target, + sel!(showTyping:), + mtm, + ); + // The settings of NSButton's own checkbox initializer, which can't make a subclass. + checkbox.setButtonType(NSButtonType::Switch); + checkbox.setBezelStyle(NSBezelStyle::FlexiblePush); + sensitive_labels.push(typed_line( + &view, + rect(x + 156.0, row_y, width - 156.0, 24.0), + mtm, + )); + show_typing = Some(Retained::into_super(checkbox)); + let refused = label(&view, SHOW_REFUSED_TEXT, x, 0.0, width, 36.0, mtm); + refused.setFrame(rect(x, place(&mut y, 36.0, 4.0), width, 36.0)); + refused.setHidden(true); + show_refused = Some(refused); + } let mut suggestion_button = None; + let mut typed_hold = None; + let mut hold_mirror = None; + let mut hold_caption = None; if matches!(&prompt, Request::Entry(Prompt::NewPassword { .. })) { - suggestion_button = Some(button( + let row_y = place(&mut y, 30.0, 4.0); + let suggest = key_button( &view, - "Suggest passphrase", - rect(x, place(&mut y, 30.0, 4.0), 170.0, 30.0), + SUGGEST_BUTTON, + rect(x, row_y, 170.0, 30.0), &target, sel!(suggest:), mtm, + ); + suggest.setBezelStyle(NSBezelStyle::Push); + suggestion_button = Some(suggest); + typed_hold = prompt.offers_hold_to_show().then(|| { + new_hold_button( + &view, + HOLD_TO_SHOW_LABEL, + rect(x + 178.0, row_y, 230.0, 30.0), + mtm, + ) + }); + hold_caption = Some(label( + &view, + "", + x, + place(&mut y, 18.0, 0.0), + width, + 18.0, + mtm, )); - let suggestion = label(&view, "", x, place(&mut y, 36.0, 4.0), width, 36.0, mtm); - suggestion.setFont(Some(&NSFont::monospacedSystemFontOfSize_weight(12.0, 0.0))); + let row_y = place(&mut y, 48.0, 4.0); + let suggestion = label(&view, "", x, row_y, width, 48.0, mtm); + suggestion.setFont(Some(&NSFont::monospacedSystemFontOfSize_weight(16.0, 0.0))); sensitive_labels.push(suggestion); + // The same row: a hold is refused while a suggestion is on screen. + hold_mirror = Some(typed_line(&view, rect(x, row_y, width, 48.0), mtm)); + // The same row again: nothing typed shows while this does, and it waits for a suggestion. + let refused = label(&view, SHOW_REFUSED_TEXT, x, row_y, width, 48.0, mtm); + refused.setHidden(true); + show_refused = Some(refused); } let feedback = label( &view, - feedback_text(&prompt), + &if prompt.offers_show_typing() { + recovery_feedback(&prompt, "") + } else { + prompt.feedback_text() + }, x, place(&mut y, 36.0, 6.0), width, 36.0, mtm, ); + let time_row = label(&view, "", x, 0.0, width, 0.0, mtm); + let time_height = time_row_height(&prompt, &time_row, width); + time_row.setFrame(rect(x, place(&mut y, time_height, 4.0), width, time_height)); let button_y = place(&mut y, 30.0, 0.0); + let is_reveal = matches!(&prompt, Request::Reveal { .. }); + // A readout has one button, which keeps Escape. Nothing here hints that a copy can be undone. let cancel = button( &view, - "Cancel", - rect(x, button_y, 100.0, 30.0), + if is_reveal { "Close" } else { "Cancel" }, + if is_reveal { + rect(x + width - 112.0, button_y, 112.0, 30.0) + } else { + rect(x, button_y, 100.0, 30.0) + }, &target, sel!(cancel:), mtm, ); cancel.setKeyEquivalent(ns_string!("\u{1b}")); - let is_reveal = matches!(&prompt, Request::Reveal { .. }); // Touch ID has nothing to submit: the sensor is the only way forward. - let accept = (!matches!(&prompt, Request::Biometric(_))).then(|| { + let accept = matches!(&prompt, Request::Entry(_)).then(|| { let accept = button( &view, - if is_reveal { "Close" } else { "Continue" }, + "Continue", rect(x + width - 112.0, button_y, 112.0, 30.0), &target, - if is_reveal { - sel!(cancel:) - } else { - sel!(submit:) - }, + sel!(submit:), mtm, ); accept.setKeyEquivalent(ns_string!("\r")); @@ -449,12 +712,18 @@ pub(super) fn present( for field in &fields { key_views.push(&**field); } + if let Some(checkbox) = &show_typing { + key_views.push(&**checkbox); + } if let Some(scroll) = &readout_scroll { key_views.push(&**scroll); } if let Some(button) = &suggestion_button { key_views.push(&**button); } + if let Some(button) = &typed_hold { + key_views.push(&**button); + } if let Some(button) = &hold_button { key_views.push(&**button); } @@ -474,6 +743,19 @@ pub(super) fn present( last.setNextKeyView(Some(first)); } } + // Now the rows are placed: center the group, with room for the Overlay title bar. The bounds + // origin puts row 0 at the container's top; subviews must not autoresize during this. + let height = -y; + let top = ((bounds.size.height - height) / 2.0).max(28.0); + view.setFrame(rect( + 0.0, + bounds.size.height - top - height, + bounds.size.width, + height, + )); + view.setBoundsOrigin(NSPoint::new(0.0, -height)); + view.setAutoresizesSubviews(true); + root.addSubview(&view); let expires = matches!(&prompt, Request::Reveal { reveal, .. } if reveal.mode == RevealMode::Timed) .then(|| Instant::now() + Duration::from_secs(20)); @@ -494,17 +776,38 @@ pub(super) fn present( window: window.clone(), original, original_focus, - _view: view.clone(), + _view: root.clone(), _target: target, _authentication: authentication, fields, sensitive_labels, feedback, + time_row, + time_row_shown: String::new(), + time_text: String::new(), prompt, completion, activity: Some(on_activity), + time_left, + may_show, presentation: PresentationState::default(), + cancel_arm: CancelArm::default(), + last_error: None, + error_shown: false, + live: LiveCheck::default(), + live_line: None, suggestion: None, + show_typing, + typed_hold, + hold_mirror, + hold_caption, + hold_until: None, + last_edited: None, + boxes_empty: true, + show_usable: false, + show_refused, + typed_key_at: None, + lines_stale: false, readout, readout_scroll, readout_shown, @@ -517,14 +820,23 @@ pub(super) fn present( if !ACTIVE.with(|active| active.borrow().as_ref().is_some_and(|panel| panel.id == id)) { return Err(PromptError::Cancelled); } - window.setContentView(Some(&view)); + update_show_controls(); ACTIVE.with(|active| { - if let Some(panel) = active.borrow().as_ref() - && let Some(field) = panel.fields.first() - { - window.makeFirstResponder(Some(field)); + if let Some(panel) = active.borrow_mut().as_mut() { + update_time_row(panel); } }); + window.setContentView(Some(&root)); + let first = ACTIVE.with(|active| { + let active = active.borrow(); + let panel = active.as_ref()?; + Some((panel.fields.first()?.clone(), panel.prompt.shows_typing())) + }); + if let Some((field, roman_only)) = first { + window.makeFirstResponder(Some(&field)); + // Now, not at the first edit: the first letter must already arrive as typed. + quiet_editor(&field, roman_only); + } if let Some(button) = &hold_button { window.makeFirstResponder(Some(button)); } else if let Some(scroll) = ACTIVE.with(|active| { @@ -538,10 +850,166 @@ pub(super) fn present( Ok(()) } -fn feedback_text(prompt: &Request) -> &'static str { - match prompt { - Request::Biometric(_) => "Touch ID stays in this window. Switching apps or Escape cancels.", - _ => "Credentials stay in native controls. Escape cancels.", +/// Text assistance off for this box's editor. Reads no text. `roman_only` is for the visible +/// retype box: a secure field never starts an input method's composition, so keep that one the +/// same and letters arrive as typed. +fn quiet_editor(field: &NSTextField, roman_only: bool) { + // SAFETY: currentEditor is an optional NSTextView belonging to this live field. Only + // documented NSTextView assistance switches are sent. + unsafe { + let editor: Option> = msg_send![field, currentEditor]; + let Some(editor) = editor else { + return; + }; + let _: () = msg_send![&editor, setAllowsUndo: false]; + let _: () = msg_send![&editor, setAutomaticSpellingCorrectionEnabled: false]; + let _: () = msg_send![&editor, setAutomaticTextReplacementEnabled: false]; + let _: () = msg_send![&editor, setAutomaticQuoteSubstitutionEnabled: false]; + let _: () = msg_send![&editor, setAutomaticDashSubstitutionEnabled: false]; + let _: () = msg_send![&editor, setAutomaticTextCompletionEnabled: false]; + let _: () = msg_send![&editor, setContinuousSpellCheckingEnabled: false]; + let _: () = msg_send![&editor, setGrammarCheckingEnabled: false]; + if roman_only { + let context: Option> = msg_send![&editor, inputContext]; + if let Some(context) = context { + let roman = NSArray::from_retained_slice(&[NSString::from_str( + "NSAllRomanInputSourcesLocaleIdentifier", + )]); + let _: () = msg_send![&context, setAllowedInputSourceLocales: &*roman]; + } + } + } +} + +/// What this label needs at this width, measured by its own cell. Set its font first. +fn fitted_height(label: &NSTextField, width: f64) -> f64 { + // SAFETY: cell is this live label's NSCell, and cellSizeForBounds: only measures. + let size: NSSize = unsafe { + let cell: Retained = msg_send![label, cell]; + msg_send![&cell, cellSizeForBounds: rect(0.0, 0.0, width, 10_000.0)] + }; + size.height.ceil() +} + +/// Two lines. The Recovery Kit's longest warning can need a third, and must not be cut off. +fn time_row_height(prompt: &Request, row: &NSTextField, width: f64) -> f64 { + let longest = prompt + .time_row_text(&TimeLeft::Left { + seconds: 60, + limit: TimeLimit::AutoLock, + }) + .unwrap_or_default(); + row.setStringValue(&NSString::from_str(&longest)); + let height = fitted_height(row, width).max(40.0); + row.setStringValue(ns_string!("")); + height +} + +fn new_hold_button( + view: &NSView, + caption: &str, + frame: NSRect, + mtm: MainThreadMarker, +) -> Retained { + // SAFETY: NSButton's documented initializer on a fresh allocation of this subclass. + let button: Retained = + unsafe { msg_send![HoldButton::alloc(mtm), initWithFrame: frame] }; + anchor(&button); + button.setTitle(&NSString::from_str(caption)); + button.setBezelStyle(NSBezelStyle::Push); + view.addSubview(&button); + button +} + +fn key_button( + view: &NSView, + caption: &str, + frame: NSRect, + target: &Target, + action: Sel, + mtm: MainThreadMarker, +) -> Retained { + // SAFETY: NSButton's documented initializer on a fresh allocation of this subclass. Target + // implements the given selector, is retained by Panel, and outlives the button. + let button: Retained = unsafe { + let button: Retained = msg_send![KeyButton::alloc(mtm), initWithFrame: frame]; + button.setTarget(Some(target)); + button.setAction(Some(action)); + button + }; + anchor(&button); + button.setTitle(&NSString::from_str(caption)); + view.addSubview(&button); + button +} + +/// A line for what the owner typed, off the accessibility tree: a secure box's own value stays +/// empty there, and this must not be the way round that. Public only for the test target +/// `tests/mac_typed_line.rs`, because AppKit views need the main thread and libtest never runs a +/// test on it. +pub fn typed_line(view: &NSView, frame: NSRect, mtm: MainThreadMarker) -> Retained { + let line = label( + view, + "", + frame.origin.x, + frame.origin.y, + frame.size.width, + frame.size.height, + mtm, + ); + line.setFont(Some(&NSFont::monospacedSystemFontOfSize_weight(16.0, 0.0))); + hide_from_accessibility(&line); + line +} + +fn hide_from_accessibility(label: &NSTextField) { + // AppKit exposes a label through its cell, so the label's own flag isn't enough. + // SAFETY: The documented NSAccessibility setter on this live label and its live cell. + unsafe { + let _: () = msg_send![label, setAccessibilityElement: false]; + let cell: Retained = msg_send![label, cell]; + let _: () = msg_send![&cell, setAccessibilityElement: false]; + } +} + +/// Every write to the feedback row. Resetting the color each time keeps Please wait… from red. +fn set_feedback(panel: &Panel, text: &str, is_error: bool) { + panel.feedback.setStringValue(&NSString::from_str(text)); + let color = if is_error { + NSColor::systemRedColor() + } else { + NSColor::labelColor() + }; + panel.feedback.setTextColor(Some(&color)); +} + +/// The row under a Recovery Key box: a count only, then the usual hint. +fn recovery_feedback(prompt: &Request, value: &str) -> String { + format!("{}. {}", recovery_progress(value), prompt.feedback_text()) +} + +/// The feedback row when no refusal or question is waiting to be read. +fn normal_feedback(panel: &Panel) -> String { + if panel.suggestion.is_some() { + return SUGGESTED_TEXT.to_owned(); + } + if let Some(line) = &panel.live_line { + return line.clone(); + } + if panel.prompt.offers_show_typing() + && let Some(field) = panel.fields.first() + { + let value = Zeroizing::new(field.stringValue().to_string()); + return recovery_feedback(&panel.prompt, &value); + } + panel.prompt.feedback_text() +} + +fn restore_feedback(panel: &mut Panel) { + panel.error_shown = panel.last_error.is_some(); + match &panel.last_error { + Some(message) => set_feedback(panel, &problem_text(message), true), + None => set_feedback(panel, &normal_feedback(panel), false), } } @@ -557,17 +1025,26 @@ fn place(y: &mut f64, height: f64, gap: f64) -> f64 { bottom } +/// The NSEventType AppKit is handling, and whether it's an auto-repeated key. +fn current_event() -> Option<(usize, bool)> { + // SAFETY: Called on AppKit's main thread. currentEvent is an optional live NSEvent, and + // isARepeat is queried only for a key-down event. + unsafe { + let app: Retained = msg_send![class!(NSApplication), sharedApplication]; + let event: Option> = msg_send![&app, currentEvent]; + let event = event?; + let kind: usize = msg_send![&event, type]; + Some((kind, kind == 10 && msg_send![&event, isARepeat])) + } +} + +fn current_event_repeats() -> bool { + current_event().is_some_and(|(_, repeated)| repeated) +} + fn activity(genuine: bool) -> bool { // A repeated key revalidates without keeping Auto-lock alive. - let genuine = genuine - && !unsafe { - let app: Retained = msg_send![class!(NSApplication), sharedApplication]; - let event: Option> = msg_send![&app, currentEvent]; - event.is_some_and(|event| { - let kind: usize = msg_send![&event, type]; - kind == 10 && msg_send![&event, isARepeat] - }) - }; + let genuine = genuine && !current_event_repeats(); // Remove the hook while invoking it, so a rejected lease can dismiss/reenter without // holding the native panel RefCell borrow. Reinsert only into the exact same request. let pending = ACTIVE.with(|active| { @@ -589,10 +1066,12 @@ fn activity(genuine: bool) -> bool { } fn apply_authorization(id: u64, authorization: Authorization) -> bool { + let now = Instant::now(); let update = ACTIVE.with(|active| { let mut active = active.try_borrow_mut().ok()?; let panel = active.as_mut().filter(|panel| panel.id == id)?; - let update = panel.presentation.authorize(authorization); + let grace = panel.prompt.rides_out_a_blip(); + let update = panel.presentation.authorize(authorization, now, grace); match update { PresentationUpdate::Clear => { for label in &panel.sensitive_labels { @@ -602,13 +1081,28 @@ fn apply_authorization(id: u64, authorization: Authorization) -> bool { set_readout(readout, scroll, ""); } panel.readout_shown = false; - panel.feedback.setStringValue(ns_string!("Please wait…")); + // The key is hidden for now, so its visible retyped part must be too. Alpha, not + // hidden: a hidden box would lose focus, and the owner may be typing. + if panel.prompt.shows_typing() { + for field in &panel.fields { + field.setAlphaValue(0.0); + } + } + hide_typed(panel); + panel.cancel_arm.disarm(); + if let Some(suggestion) = panel.suggestion.as_mut() { + suggestion.acknowledged = false; + } + set_feedback(panel, "Please wait…", false); } PresentationUpdate::Restore => { if let Request::Entry(Prompt::RecoveryKit { recovery, .. }) = &panel.prompt && let Some(label) = panel.sensitive_labels.first() { label.setStringValue(&NSString::from_str(recovery)); + for field in &panel.fields { + field.setAlphaValue(1.0); + } } if let (Request::Reveal { reveal, .. }, Some(readout), Some(scroll)) = (&panel.prompt, &panel.readout, &panel.readout_scroll) @@ -630,19 +1124,18 @@ fn apply_authorization(id: u64, authorization: Authorization) -> bool { if let Some(suggestion) = &panel.suggestion && let Some(label) = panel.sensitive_labels.last() { - label.setStringValue(&NSString::from_str(suggestion)); + label.setStringValue(&NSString::from_str(&suggestion.text)); } - // Restore only a generated candidate, never a manually typed password. - panel - .feedback - .setStringValue(&NSString::from_str(feedback_text(&panel.prompt))); + // Restore only a generated candidate. What the owner typed stays off screen: both + // mirrors stay blank until asked for again. + restore_feedback(panel); } _ => (), } Some(update) }); if update == Some(PresentationUpdate::Close) { - finish(Err(PromptError::Cancelled)); + finish(Err(PromptError::Interrupted)); } matches!( update, @@ -650,29 +1143,150 @@ fn apply_authorization(id: u64, authorization: Authorization) -> bool { ) } -fn edit_activity_is_genuine() -> bool { - // Only input events can renew an edit lease; programmatic notifications revalidate. - // SAFETY: AppKit invokes the text-field delegate on the main thread; currentEvent - // is an optional live NSEvent, and isARepeat is queried only for a key-down event. - unsafe { - let app: Retained = msg_send![class!(NSApplication), sharedApplication]; - let event: Option> = msg_send![&app, currentEvent]; - let Some(event) = event else { - return false; +/// Both opt-in mirrors off and blank. Neither comes back by itself. +fn hide_typed(panel: &mut Panel) { + if let Some(checkbox) = &panel.show_typing { + // SAFETY: NSButton's documented state setter; 0 is NSControlStateValueOff. Sends no action. + unsafe { + let _: () = msg_send![checkbox, setState: 0isize]; + } + if let Some(mirror) = panel.sensitive_labels.first() { + mirror.setStringValue(ns_string!("")); + } + } + panel.typed_key_at = None; + end_hold(panel); +} + +fn end_hold(panel: &mut Panel) { + panel.hold_until = None; + if let Some(mirror) = &panel.hold_mirror { + mirror.setStringValue(ns_string!("")); + } + if let Some(caption) = &panel.hold_caption { + caption.setStringValue(ns_string!("")); + } + if let Some(button) = &panel.typed_hold { + button.highlight(false); + } +} + +/// Nothing typed shows while a known sharing app runs. A busy core changes nothing here: it says +/// nothing about sharing apps, and a long one ends in `Clear`. Holds no ACTIVE borrow while it +/// disables controls: that can end a control's focus, which calls back. +fn update_show_controls() { + let controls = ACTIVE.with(|active| { + let mut active = active.try_borrow_mut().ok()?; + let panel = active.as_mut()?; + if panel.show_typing.is_none() && panel.typed_hold.is_none() { + return None; + } + let check = (panel.may_show)(); + let (usable, blank) = check.controls(panel.show_usable); + panel.show_usable = usable; + if blank { + hide_typed(panel); + } + Some(( + usable, + // A press with nothing to show would do nothing and say nothing. + usable && !panel.boxes_empty && panel.suggestion.is_none(), + (check != ShowCheck::Busy) + .then(|| panel.show_refused.clone()) + .flatten() + .map(|line| { + ( + line, + check == ShowCheck::Refused && panel.suggestion.is_none(), + ) + }), + panel.show_typing.clone(), + panel.typed_hold.clone(), + )) + }); + let Some((usable, hold_usable, refused, show_typing, typed_hold)) = controls else { + return; + }; + if let Some(checkbox) = &show_typing { + checkbox.setEnabled(usable); + } + if let Some(button) = &typed_hold { + button.setEnabled(hold_usable); + } + if let Some((line, show)) = &refused { + line.setHidden(!show); + } +} + +fn show_typing_changed() { + let passed = activity(true); + let now = Instant::now(); + ACTIVE.with(|active| { + let Ok(mut active) = active.try_borrow_mut() else { + return; }; - let kind: usize = msg_send![&event, type]; - let repeated = kind == 10 && msg_send![&event, isARepeat]; - edit_event_is_genuine(Some(kind), repeated) + let Some(panel) = active.as_mut() else { + return; + }; + // A click never shows something on a guess: anything but a clear yes unticks it. + if !passed || (panel.may_show)() != ShowCheck::Allowed { + hide_typed(panel); + return; + } + let ticked = panel.show_typing.as_ref().is_some_and(|checkbox| { + // SAFETY: NSButton's documented state getter; 1 is NSControlStateValueOn. + let state: isize = unsafe { msg_send![&**checkbox, state] }; + state == 1 + }); + panel.typed_key_at = ticked.then_some(now); + }); + if passed { + refresh_recovery_lines(); } } +/// The count and, when ticked, the mirror. Call only after activity() returned true. +fn refresh_recovery_lines() { + ACTIVE.with(|active| { + let Ok(active) = active.try_borrow() else { + return; + }; + let Some(panel) = active.as_ref() else { + return; + }; + let (true, Some(field)) = (panel.prompt.offers_show_typing(), panel.fields.first()) else { + return; + }; + let value = Zeroizing::new(field.stringValue().to_string()); + if !panel.presentation.suspended && !panel.error_shown { + set_feedback(panel, &recovery_feedback(&panel.prompt, &value), false); + } + if let (Some(checkbox), Some(mirror)) = (&panel.show_typing, panel.sensitive_labels.first()) + { + // SAFETY: NSButton's documented state getter; 1 is NSControlStateValueOn. + let state: isize = unsafe { msg_send![checkbox, state] }; + let shown = if state == 1 { + recovery_mirror(&value) + } else { + Zeroizing::new(String::new()) + }; + mirror.setStringValue(&NSString::from_str(&shown)); + } + }); +} + +fn edit_activity_is_genuine() -> bool { + // Only input events can renew an edit lease; programmatic notifications revalidate. + current_event().is_some_and(|(kind, repeated)| edit_event_is_genuine(Some(kind), repeated)) +} + fn edit_event_is_genuine(kind: Option, repeated: bool) -> bool { // NSEventType: keyDown, or mouse buttons used by native context-menu Paste. matches!(kind, Some(1 | 2 | 3 | 4 | 25 | 26)) || kind == Some(10) && !repeated } fn anchor(view: &NSView) { - // Keep controls centered and at the same distance from the top when the main + // Keep a row centered in its container, which itself stays centered when the main // protected window changes size. Fixed widths fit its configured minimum size. view.setAutoresizingMask( NSAutoresizingMaskOptions::ViewMinXMargin @@ -729,37 +1343,424 @@ fn button( button } +fn same_object(a: &A, b: &B) -> bool { + std::ptr::eq( + std::ptr::from_ref(a).cast::<()>(), + std::ptr::from_ref(b).cast::<()>(), + ) +} + +/// Only a positive match counts: anything unknown is treated as New or Confirm. +fn is_current_box(prompt: &Request, index: Option) -> bool { + index == Some(0) + && matches!( + prompt, + Request::Entry(Prompt::NewPassword { + current_password: true, + .. + }) + ) +} + +/// Which boxes to clear, and which to focus, after a refusal. `count` is how many boxes there are. +fn refusal_targets(count: usize, focus: RefusalFocus) -> Option<(Range, usize)> { + match focus { + RefusalFocus::Keep => None, + RefusalFocus::ClearAndFocusNew => { + let new = count.checked_sub(2)?; + Some((new..count, new)) + } + RefusalFocus::FocusConfirm => { + let confirm = count.checked_sub(1)?; + Some((confirm..confirm, confirm)) + } + RefusalFocus::ClearAndFocusRetype => { + count.checked_sub(1)?; + Some((0..count, 0)) + } + } +} + +/// Clears through the field editor and then the cell, like `finish`. AppKit can call back from +/// these calls, so the caller holds no ACTIVE borrow. Setting a cell can end editing, so the +/// caller also puts the focus back. +fn clear_boxes(fields: &[Retained]) { + SETTING_BOXES.set(true); + for field in fields { + // SAFETY: currentEditor is the optional live NSTextView for this field; setString clears + // its current input. AppKit may retain OS-owned copies that cannot be wiped by Rust. + unsafe { + let editor: Option> = msg_send![field, currentEditor]; + if let Some(editor) = editor { + let _: () = msg_send![&editor, setString: ns_string!("")]; + } + } + field.setStringValue(ns_string!("")); + } + SETTING_BOXES.set(false); +} + fn submit() { if !activity(false) { return; } if ACTIVE.with(|active| { - active - .borrow() - .as_ref() - .is_some_and(|panel| matches!(&panel.prompt, Request::Reveal { .. })) + active.try_borrow().ok().is_some_and(|active| { + active + .as_ref() + .is_some_and(|panel| matches!(&panel.prompt, Request::Reveal { .. })) + }) }) { finish(Err(PromptError::Cancelled)); return; } - let reply = ACTIVE.with(|active| { - let active = active.borrow(); - let panel = active.as_ref()?; + let outcome = ACTIVE.with(|active| { + let mut active = active.try_borrow_mut().ok()?; + let panel = active.as_mut()?; + panel.cancel_arm.disarm(); let values = panel .fields .iter() .map(|field| Zeroizing::new(field.stringValue().to_string())) .collect(); match panel.prompt.reply(values) { - Ok(reply) => Some(reply), - Err(message) => { - panel.feedback.setStringValue(&NSString::from_str(message)); - None + Ok(reply) => { + if let Some(suggestion) = panel.suggestion.as_mut() + && !suggestion.acknowledged + { + suggestion.acknowledged = true; + set_feedback(panel, SUGGESTION_ASK, false); + return None; + } + Some(Ok(reply)) } + Err(refusal) => { + set_feedback(panel, &problem_text(&refusal.message), true); + panel.error_shown = true; + let targets = refusal_targets(panel.fields.len(), refusal.focus); + panel.last_error = Some(refusal.message.into_owned()); + let Some((clear, focus)) = targets else { + return Some(Err(None)); + }; + if !clear.is_empty() { + panel.live_line = None; + panel.last_edited = None; + panel.boxes_empty = true; + } + Some(Err(Some(( + panel.window.clone(), + panel.fields[clear].to_vec(), + panel.fields[focus].clone(), + )))) + } + } + }); + match outcome { + Some(Ok(reply)) => finish(Ok(reply)), + Some(Err(Some((window, clear, focus)))) => { + clear_boxes(&clear); + window.makeFirstResponder(Some(&focus)); + } + Some(Err(None)) => focus_kept_refusal(), + None => {} + } +} + +/// After a refusal that keeps the boxes. A button with the keyboard would eat the correction typed +/// next, but a caret already in a box stays where it is. Checks only the lengths. +fn focus_kept_refusal() { + let target = ACTIVE.with(|active| { + let active = active.try_borrow().ok()?; + let panel = active.as_ref()?; + let mut empty = Vec::with_capacity(panel.fields.len()); + for field in &panel.fields { + // SAFETY: currentEditor is this live field's optional field editor. Nothing is read. + let editor: Option> = unsafe { msg_send![&**field, currentEditor] }; + if editor.is_some() { + return None; + } + empty.push(field.stringValue().length() == 0); } + let field = panel.fields.get(kept_refusal_box(&empty)?)?; + Some((panel.window.clone(), field.clone())) }); - if let Some(reply) = reply { - finish(Ok(reply)); + if let Some((window, field)) = target { + focus_at_end(&window, &field); + } +} + +/// Focusing selects the whole box, and the next key would replace it. AppKit can call back from +/// here, so the caller holds no ACTIVE borrow. +fn focus_at_end(window: &NSWindow, field: &NSTextField) { + window.makeFirstResponder(Some(field)); + // SAFETY: currentEditor is the optional live NSTextView for this field, and this is + // NSResponder's documented caret action. Nothing is read. + unsafe { + let editor: Option> = msg_send![field, currentEditor]; + if let Some(editor) = editor { + let _: () = msg_send![&editor, moveToEndOfDocument: Option::<&AnyObject>::None]; + } + } +} + +fn return_pressed() { + if current_event_repeats() || !activity(true) { + return; + } + // Length only: nothing here copies what was typed. + let empty = ACTIVE.with(|active| { + let active = active.try_borrow().ok()?; + let panel = active.as_ref()?; + if !matches!(&panel.prompt, Request::Entry(Prompt::NewPassword { .. })) { + return None; + } + let field = panel + .fields + .iter() + .find(|field| field.stringValue().length() == 0)?; + Some((panel.window.clone(), field.clone())) + }); + match empty { + Some((window, field)) => { + window.makeFirstResponder(Some(&field)); + } + None => submit(), + } +} + +/// The owner's Cancel or Escape. On the Recovery Kit the first press only asks, because this key +/// is shown only now. Losing focus, a denied lease and `cancel(id)` stay immediate. +fn owner_cancel() { + let is_kit = ACTIVE.with(|active| { + active + .try_borrow() + .ok() + .is_some_and(|active| active.as_ref().is_some_and(|panel| panel.prompt.is_kit())) + }); + if !is_kit { + activity(true); + finish(Err(PromptError::Cancelled)); + return; + } + if current_event_repeats() { + return; + } + // Denied has already closed the panel. A blanked Kit rejects this press like any other action. + if !activity(true) && !kit_on_hold() { + return; + } + let from_key = current_event().is_some_and(|(kind, _)| kind == 10); + let decision = ACTIVE.with(|active| { + let mut active = active.try_borrow_mut().ok()?; + let panel = active.as_mut()?; + let decision = panel.cancel_arm.press(Instant::now(), from_key); + if decision == ArmDecision::Arm { + set_feedback(panel, KIT_ARMED, false); + } + Some(decision) + }); + if decision == Some(ArmDecision::Cancel) { + finish(Err(PromptError::Cancelled)); + } +} + +/// True right after `activity` said no for a brief core lock on the Kit: `Hold` blanked nothing. +fn kit_on_hold() -> bool { + ACTIVE.with(|active| { + active.try_borrow().ok().is_some_and(|active| { + active + .as_ref() + .is_some_and(|panel| panel.prompt.is_kit() && !panel.presentation.suspended) + }) + }) +} + +fn text_changed(notification: &AnyObject) { + if SETTING_BOXES.get() { + return; + } + // SAFETY: A control's text notification has that control as its documented object. + let edited: Option> = unsafe { msg_send![notification, object] }; + let genuine = edit_activity_is_genuine(); + let now = Instant::now(); + let forgotten = ACTIVE.with(|active| { + // Never borrow_mut here: AppKit can send this from inside another call. + let mut active = active.try_borrow_mut().ok()?; + let panel = active.as_mut()?; + let armed = panel.cancel_arm.armed_at.is_some(); + if !panel.presentation.suspended && (panel.error_shown || armed) { + panel.error_shown = false; + panel.last_error = None; + set_feedback(panel, &normal_feedback(panel), false); + } + panel.cancel_arm.disarm(); + if genuine && panel.typed_key_at.is_some() { + panel.typed_key_at = Some(now); + } + let index = edited.as_deref().and_then(|edited| { + panel + .fields + .iter() + .position(|field| same_object(&**field, edited)) + }); + if !matches!(&panel.prompt, Request::Entry(Prompt::NewPassword { .. })) + || is_current_box(&panel.prompt, index) + { + return None; + } + if panel.suggestion.take().is_none() { + panel.last_edited = index; + panel.boxes_empty = false; + // Every edit, repeats too: `genuine` is only about keeping Auto-lock alive. + panel.live.box_edited(now, panel.fields.len(), index); + return None; + } + panel.live_line = None; + panel.last_edited = None; + panel.boxes_empty = true; + panel.error_shown = true; + let new_box = panel.fields.len().checked_sub(2)?; + Some(( + panel.window.clone(), + panel.fields[new_box..].to_vec(), + index.and_then(|index| panel.fields.get(index)).cloned(), + )) + }); + if let Some((window, boxes, edited)) = forgotten { + clear_boxes(&boxes); + if let Some(edited) = &edited { + window.makeFirstResponder(Some(edited)); + } + ACTIVE.with(|active| { + if let Ok(active) = active.try_borrow() + && let Some(panel) = active.as_ref() + { + if let Some(label) = panel.sensitive_labels.last() { + label.setStringValue(ns_string!("")); + } + if !panel.presentation.suspended { + set_feedback(panel, SUGGESTION_REMOVED, false); + } + } + }); + } + if activity(genuine) { + refresh_recovery_lines(); + } else { + ACTIVE.with(|active| { + if let Ok(mut active) = active.try_borrow_mut() + && let Some(panel) = active.as_mut() + { + panel.lines_stale = true; + } + }); + } +} + +/// The box a hold shows: the New or Confirm box with the focus and some text, otherwise the one +/// typed in last. `new_box` is where New sits, and nothing before it is ever shown. +fn hold_target( + new_box: usize, + focused_with_text: Option, + last_edited: Option, +) -> Option { + focused_with_text + .filter(|focused| *focused >= new_box) + .or(last_edited) +} + +/// A snapshot at press, with no updates while held. A press never shows something on a guess, so +/// only a clear yes counts here. +fn hold_typed(panel: &mut Panel) { + if panel.hold_until.is_some() + || panel.suggestion.is_some() + || (panel.may_show)() != ShowCheck::Allowed + { + return; + } + let new_box = panel.fields.len().saturating_sub(2); + let focused = panel + .fields + .iter() + .enumerate() + .skip(new_box) + .find(|(_, field)| { + // SAFETY: currentEditor is this live field's optional field editor. Nothing is read. + let editor: Option> = + unsafe { msg_send![&***field, currentEditor] }; + editor.is_some() && field.stringValue().length() > 0 + }) + .map(|(index, _)| index); + let Some(index) = hold_target(new_box, focused, panel.last_edited) else { + return; + }; + let Some(field) = panel.fields.get(index) else { + return; + }; + let typed = Zeroizing::new(field.stringValue().to_string()); + if let Some(mirror) = &panel.hold_mirror { + mirror.setStringValue(&NSString::from_str(&typed)); + } + if let (Some(caption), Some(name)) = (&panel.hold_caption, panel.prompt.labels().get(index)) { + caption.setStringValue(&NSString::from_str(name)); + } + panel.hold_until = Some(Instant::now() + HOLD_LIMIT); +} + +/// A keyboard hold leaves focus on the button. A mouse hold never moved it. +fn return_hold_focus(button: &HoldButton) { + let target = ACTIVE.with(|active| { + let active = active.try_borrow().ok()?; + let panel = active.as_ref()?; + let field = panel + .last_edited + .and_then(|index| panel.fields.get(index))?; + Some((panel.window.clone(), field.clone())) + }); + let Some((window, field)) = target else { + return; + }; + if !window + .firstResponder() + .is_some_and(|responder| same_object(&*responder, button)) + { + return; + } + focus_at_end(&window, &field); +} + +/// The new-password hold button isn't a readout's control: Return and Escape aren't Close here. +fn typed_hold_key(event: &AnyObject, repeated: bool) -> bool { + let holds_typing = ACTIVE.with(|active| { + active.try_borrow().ok().is_some_and(|active| { + active + .as_ref() + .is_some_and(|panel| panel.prompt.offers_hold_to_show()) + }) + }); + if !holds_typing { + return false; + } + // SAFETY: This callback receives a live NSEvent from the native hold control. + let text: Option> = unsafe { msg_send![event, charactersIgnoringModifiers] }; + let Some(text) = text else { + return false; + }; + match text.to_string().as_str() { + "\u{1b}" | "\r" | " " if repeated => true, + "\u{1b}" => { + owner_cancel(); + true + } + "\r" => { + return_pressed(); + true + } + " " => { + held(true); + true + } + _ => false, } } @@ -768,10 +1769,20 @@ fn held(show: bool) { return; } ACTIVE.with(|active| { - let mut active = active.borrow_mut(); + let Ok(mut active) = active.try_borrow_mut() else { + return; + }; let Some(panel) = active.as_mut() else { return; }; + if panel.prompt.offers_hold_to_show() { + if show { + hold_typed(panel); + } else { + end_hold(panel); + } + return; + } if let (Request::Reveal { reveal, .. }, Some(readout), Some(scroll)) = (&panel.prompt, &panel.readout, &panel.readout_scroll) && reveal.mode == RevealMode::Held @@ -822,15 +1833,17 @@ fn reveal_navigation(event: &AnyObject) -> bool { true } " " => { - let is_held = ACTIVE.with(|active| active.borrow().as_ref().is_some_and(|panel| matches!(&panel.prompt, Request::Reveal { reveal, .. } if reveal.mode == RevealMode::Held))); + let is_held = ACTIVE.with(|active| active.try_borrow().ok().is_some_and(|active| active.as_ref().is_some_and(|panel| matches!(&panel.prompt, Request::Reveal { reveal, .. } if reveal.mode == RevealMode::Held)))); if is_held { held(true); } is_held } "\t" | "\u{19}" => { - let window = - ACTIVE.with(|active| active.borrow().as_ref().map(|panel| panel.window.clone())); + let window = ACTIVE.with(|active| { + let active = active.try_borrow().ok()?; + active.as_ref().map(|panel| panel.window.clone()) + }); if let Some(window) = window { // SAFETY: The retained main window owns the complete native key-view loop. unsafe { @@ -847,7 +1860,9 @@ fn reveal_navigation(event: &AnyObject) -> bool { } key @ ("\u{f700}" | "\u{f701}" | "\u{f72c}" | "\u{f72d}" | "\u{f729}" | "\u{f72b}") => { ACTIVE.with(|active| { - let active = active.borrow(); + let Ok(active) = active.try_borrow() else { + return; + }; let Some(panel) = active.as_ref() else { return; }; @@ -880,7 +1895,7 @@ fn extend() { return; } let pending = ACTIVE.with(|active| { - let mut active = active.borrow_mut(); + let mut active = active.try_borrow_mut().ok()?; let panel = active.as_mut()?; let Request::Reveal { reveal, extend } = &mut panel.prompt else { return None; @@ -893,7 +1908,9 @@ fn extend() { if let Some((id, hook)) = pending { let authorization = hook(); let present = ACTIVE.with(|active| { - let mut active = active.borrow_mut(); + let Ok(mut active) = active.try_borrow_mut() else { + return false; + }; let Some(panel) = active.as_mut().filter(|panel| panel.id == id) else { return false; }; @@ -913,71 +1930,185 @@ fn extend() { tick(); } +fn caps_lock_on() -> bool { + // SAFETY: NSEvent's documented class property; bit 16 is NSEventModifierFlagCapsLock. + let flags: usize = unsafe { msg_send![class!(NSEvent), modifierFlags] }; + flags & (1 << 16) != 0 +} + +/// Capitals change a password. They don't change a Recovery Key. +fn caps_lock_matters(prompt: &Request) -> bool { + matches!( + prompt, + Request::Entry( + Prompt::Credential { + recovery: false, + .. + } | Prompt::NewPassword { .. } + ) + ) +} + +fn time_row_line(caps_lock: bool, time: &str) -> String { + match (caps_lock, time.is_empty()) { + (false, _) => time.to_owned(), + (true, true) => CAPS_LOCK_TEXT.to_owned(), + (true, false) => format!("{CAPS_LOCK_TEXT} {time}"), + } +} + +/// The probe is read only: asking never counts as activity. +fn update_time_row(panel: &mut Panel) { + // None is a busy core: keep the last answer. + if let Some(text) = panel.prompt.time_row_text(&(panel.time_left)()) { + panel.time_text = text; + } + let caps_lock = caps_lock_matters(&panel.prompt) && caps_lock_on(); + let line = time_row_line(caps_lock, &panel.time_text); + if line != panel.time_row_shown { + panel.time_row.setStringValue(&NSString::from_str(&line)); + panel.time_row_shown = line; + } +} + +/// At typing pauses, from New and Confirm only. Nothing here approves anything: Continue always +/// runs the real check. +fn update_live_line(panel: &mut Panel, now: Instant) { + if !matches!(&panel.prompt, Request::Entry(Prompt::NewPassword { .. })) + || panel.suggestion.is_some() + || panel.presentation.suspended + || panel.error_shown + { + return; + } + if !panel.live.poll() && !panel.live.due(now) { + return; + } + let [.., new, confirm] = panel.fields.as_slice() else { + return; + }; + let new = Zeroizing::new(new.stringValue().to_string()); + let confirm = Zeroizing::new(confirm.stringValue().to_string()); + panel.boxes_empty = new.is_empty() && confirm.is_empty(); + let line = panel.live.line(&new, &confirm); + set_feedback(panel, &line, false); + panel.live_line = Some(line); +} + +/// True once Show what I type has had no real key press for too long. +fn typed_view_expired(typed_key_at: Option, now: Instant) -> bool { + typed_key_at.is_some_and(|at| now.saturating_duration_since(at) >= TYPED_IDLE_LIMIT) +} + fn tick() { if !activity(false) { return; } + let now = Instant::now(); + let stale = ACTIVE.with(|active| { + let mut active = active.try_borrow_mut().ok()?; + let panel = active.as_mut()?; + if panel.cancel_arm.expired(now) { + panel.cancel_arm.disarm(); + restore_feedback(panel); + } + update_time_row(panel); + if panel.hold_until.is_some_and(|until| now >= until) { + end_hold(panel); + } + if typed_view_expired(panel.typed_key_at, now) { + hide_typed(panel); + } + update_live_line(panel, now); + Some(std::mem::take(&mut panel.lines_stale)) + }); + if stale == Some(true) { + refresh_recovery_lines(); + } + update_show_controls(); let expired = ACTIVE.with(|active| { - let active = active.borrow(); - let Some(panel) = active.as_ref() else { - return false; - }; - let Some(expires) = panel.expires else { - return false; + let active = active.try_borrow().ok()?; + let panel = active.as_ref()?; + let expires = panel.expires?; + let Request::Reveal { reveal, .. } = &panel.prompt else { + return None; }; - let remaining = expires.saturating_duration_since(Instant::now()); - let text = format!( - "Hides in {} seconds. {} extensions remaining.", - remaining.as_secs() + 1, - 3 - panel.extensions - ); - panel.feedback.setStringValue(&NSString::from_str(&text)); - remaining.is_zero() + let remaining = expires.min(reveal.closes_at).saturating_duration_since(now); + let text = timed_readout_text(remaining.as_secs() + 1, 3 - panel.extensions); + set_feedback(panel, &text, false); + Some(remaining.is_zero()) }); - if expired { + if expired == Some(true) { finish(Err(PromptError::Cancelled)); } } -fn forget_suggestion() { - ACTIVE.with(|active| { - // Programmatic suggestion installation can trigger native editor notifications. - // Its builder already holds this borrow and installs the new candidate afterward. - if let Ok(mut active) = active.try_borrow_mut() - && let Some(panel) = active.as_mut() - && panel.suggestion.take().is_some() - && let Some(label) = panel.sensitive_labels.last() - { - label.setStringValue(ns_string!("")); +fn suggest(sender: Option<&AnyObject>) { + if !activity(false) { + return; + } + let boxes = ACTIVE.with(|active| { + let active = active.try_borrow().ok()?; + let panel = active.as_ref()?; + if !matches!(&panel.prompt, Request::Entry(Prompt::NewPassword { .. })) { + return None; } + let new_box = panel.fields.len().checked_sub(2)?; + Some(panel.fields[new_box..].to_vec()) }); -} - -fn suggest() { - if !activity(false) { + let Some(boxes) = boxes else { return; + }; + let suggestion = policy::suggest_passphrase(); + let string = NSString::from_str(&suggestion); + // AppKit can call back from these, so no ACTIVE borrow is held. + SETTING_BOXES.set(true); + for field in &boxes { + field.setStringValue(&string); } + SETTING_BOXES.set(false); ACTIVE.with(|active| { - let mut active = active.borrow_mut(); - let Some(panel) = active.as_mut() else { + let Ok(mut active) = active.try_borrow_mut() else { return; }; - if !matches!(&panel.prompt, Request::Entry(Prompt::NewPassword { .. })) { + let Some(panel) = active.as_mut() else { return; - } - let suggestion = policy::suggest_passphrase(); - let string = NSString::from_str(&suggestion); - for field in panel.fields.iter().rev().take(2) { - field.setStringValue(&string); - } + }; + end_hold(panel); if let Some(label) = panel.sensitive_labels.last() { label.setStringValue(&string); } - panel.suggestion = Some(suggestion); - panel.feedback.setStringValue(ns_string!( - "Suggested passphrase meets the strength rules. Write it somewhere private." - )); + panel.suggestion = Some(Suggestion { + text: suggestion, + acknowledged: false, + }); + panel.error_shown = false; + panel.last_error = None; + panel.live_line = None; + panel.last_edited = None; + panel.boxes_empty = false; + set_feedback(panel, SUGGESTED_TEXT, false); }); + // Off the button, or the next Space replaces a password the owner may have just written down. + // An empty Current box comes first: typing there leaves the suggestion alone. A click never + // gave the button the keyboard, so it leaves the focus where it was. + let target = ACTIVE.with(|active| { + let active = active.try_borrow().ok()?; + let panel = active.as_ref()?; + let (responder, sender) = panel.window.firstResponder().zip(sender)?; + if !same_object(&*responder, sender) { + return None; + } + let field = panel + .fields + .iter() + .find(|field| field.stringValue().length() == 0) + .or_else(|| panel.fields.get(panel.fields.len().checked_sub(2)?))?; + Some((panel.window.clone(), field.clone())) + }); + if let Some((window, field)) = target { + window.makeFirstResponder(Some(&field)); + } } fn finish(result: Result) { @@ -1000,6 +2131,13 @@ fn finish(result: Result) { unsafe { let editor: Option> = msg_send![field, currentEditor]; if let Some(editor) = editor { + // Don't leave the keyboard on ABC. + if panel.prompt.shows_typing() { + let context: Option> = msg_send![&editor, inputContext]; + if let Some(context) = context { + let _: () = msg_send![&context, setAllowedInputSourceLocales: Option::<&AnyObject>::None]; + } + } let _: () = msg_send![&editor, setString: ns_string!("")]; } let _: () = msg_send![field, setDelegate: Option::<&AnyObject>::None]; @@ -1009,6 +2147,9 @@ fn finish(result: Result) { for label in &panel.sensitive_labels { label.setStringValue(ns_string!("")); } + if let Some(mirror) = &panel.hold_mirror { + mirror.setStringValue(ns_string!("")); + } if let Some(readout) = &panel.readout { // SAFETY: Clear the native NSTextView storage, including its accessibility value, // before the view is removed. Undo and selection were never enabled. @@ -1021,6 +2162,12 @@ fn finish(result: Result) { if let Some(responder) = &panel.original_focus { panel.window.makeFirstResponder(Some(responder)); } + if let Some(editor) = RETYPE_EDITOR.with(|editor| editor.borrow_mut().take()) { + // SAFETY: A live NSTextView that no box is editing with any more. + unsafe { + let _: () = msg_send![&editor, setString: ns_string!("")]; + } + } panel.completion.finish(result); } @@ -1038,7 +2185,33 @@ pub(super) fn cancel(id: u64) -> bool { #[cfg(test)] mod tests { - use super::edit_event_is_genuine; + use super::{ + HoldButton, KeyButton, RetypeCell, RetypeEditor, RetypeField, TYPED_IDLE_LIMIT, + caps_lock_matters, edit_event_is_genuine, hold_target, is_current_box, refusal_targets, + time_row_line, typed_view_expired, + }; + use crate::native_credential::{Prompt, RefusalFocus, Request}; + use objc2::runtime::{AnyClass, Sel}; + use objc2::{ClassType, sel}; + use objc2_app_kit::NSTextView; + use std::time::{Duration, Instant}; + + fn credential(recovery: bool) -> Request { + Request::Entry(Prompt::Credential { + title: "Fake action".into(), + detail: String::new(), + label: "Fake label".into(), + recovery, + }) + } + + fn new_password(current_password: bool) -> Request { + Request::Entry(Prompt::NewPassword { + title: "Fake setup".into(), + detail: String::new(), + current_password, + }) + } #[test] fn credential_edits_count_input_but_not_repeats_or_programmatic_notifications() { @@ -1051,4 +2224,108 @@ mod tests { assert!(!edit_event_is_genuine(event, false)); } } + + #[test] + fn only_a_positive_match_is_the_current_box() { + let change = new_password(true); + assert!(is_current_box(&change, Some(0))); + assert!(!is_current_box(&change, Some(1))); + assert!(!is_current_box(&change, Some(2))); + assert!(!is_current_box(&change, None)); + // Setup has no Current box: its first box is New. + assert!(!is_current_box(&new_password(false), Some(0))); + assert!(!is_current_box(&credential(false), Some(0))); + } + + #[test] + fn a_hold_shows_the_focused_box_or_the_last_edited_and_never_current() { + // Change password: Current, New, Confirm. Both typed, New clicked again. + assert_eq!(hold_target(1, Some(1), Some(2)), Some(1)); + assert_eq!(hold_target(1, Some(2), Some(1)), Some(2)); + // Focus elsewhere, or in an empty box. + assert_eq!(hold_target(1, None, Some(2)), Some(2)); + // Focus in Current never shows it. + assert_eq!(hold_target(1, Some(0), Some(2)), Some(2)); + assert_eq!(hold_target(1, Some(0), None), None); + // Setup has no Current box. + assert_eq!(hold_target(0, Some(0), Some(1)), Some(0)); + assert_eq!(hold_target(0, None, None), None); + } + + #[test] + fn show_what_i_type_ends_after_a_minute_without_a_key() { + let now = Instant::now(); + assert!(!typed_view_expired(None, now)); + assert!(!typed_view_expired(Some(now), now)); + let just_under = TYPED_IDLE_LIMIT - Duration::from_millis(1); + assert!(!typed_view_expired(Some(now), now + just_under)); + assert!(typed_view_expired(Some(now), now + TYPED_IDLE_LIMIT)); + } + + #[test] + fn a_refusal_never_clears_the_current_box() { + assert_eq!(refusal_targets(3, RefusalFocus::Keep), None); + assert_eq!( + refusal_targets(3, RefusalFocus::ClearAndFocusNew), + Some((1..3, 1)) + ); + assert_eq!( + refusal_targets(2, RefusalFocus::ClearAndFocusNew), + Some((0..2, 0)) + ); + assert_eq!( + refusal_targets(3, RefusalFocus::FocusConfirm), + Some((2..2, 2)) + ); + assert_eq!( + refusal_targets(1, RefusalFocus::ClearAndFocusRetype), + Some((0..1, 0)) + ); + assert_eq!(refusal_targets(0, RefusalFocus::ClearAndFocusRetype), None); + assert_eq!(refusal_targets(1, RefusalFocus::ClearAndFocusNew), None); + } + + #[test] + fn caps_lock_comes_first_and_only_where_capitals_matter() { + assert_eq!(time_row_line(false, ""), ""); + assert_eq!(time_row_line(false, "Fake time."), "Fake time."); + assert_eq!(time_row_line(true, ""), "Caps Lock is on."); + assert_eq!( + time_row_line(true, "Fake time."), + "Caps Lock is on. Fake time." + ); + assert!(caps_lock_matters(&credential(false))); + assert!(caps_lock_matters(&new_password(false))); + assert!(!caps_lock_matters(&credential(true))); + } + + /// A debug build panics here if an override's types don't match AppKit's. + #[test] + fn the_native_control_classes_register() { + KeyButton::class(); + HoldButton::class(); + RetypeEditor::class(); + RetypeCell::class(); + RetypeField::class(); + } + + /// Whether Look Up really stays shut takes a real gesture: this only shows the doors are ours. + #[test] + fn the_retype_editor_answers_look_up_itself() { + let implementation = |class: &AnyClass, selector: Sel| { + class + .instance_method(selector) + .map(|method| method.implementation() as *const ()) + }; + for selector in [ + sel!(quickLookWithEvent:), + sel!(quickLookPreviewItems:), + sel!(_immediateActionAnimationControllerForCharacterAtIndex:withRecognizer:), + sel!(_revealAnimationControllerForCharacterAtIndex:), + ] { + let ours = implementation(RetypeEditor::class(), selector); + assert!(ours.is_some()); + assert_ne!(ours, implementation(NSTextView::class(), selector)); + } + } } diff --git a/src-tauri/src/native_credential/windows.rs b/src-tauri/src/native_credential/windows.rs index bbdb0ea..c5f497c 100644 --- a/src-tauri/src/native_credential/windows.rs +++ b/src-tauri/src/native_credential/windows.rs @@ -5,8 +5,10 @@ use std::cell::RefCell; use std::time::{Duration, Instant}; use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM}; +use windows::Win32::System::SystemServices::SS_NOPREFIX; use windows::Win32::System::Threading::GetCurrentThreadId; use windows::Win32::UI::Controls::SetScrollInfo; +use windows::Win32::UI::Input::Ime::{HIMC, ImmAssociateContext}; use windows::Win32::UI::Input::KeyboardAndMouse::{ EnableWindow, GetFocus, GetKeyState, IsWindowEnabled, ReleaseCapture, SetCapture, SetFocus, }; @@ -16,8 +18,11 @@ use windows::core::{PCWSTR, w}; use zeroize::Zeroizing; use super::{ - Activity, Authorization, Callback, PresentationState, PresentationUpdate, Prompt, PromptError, - Reply, Request, RevealMode, readout_text, + Activity, ArmDecision, Authorization, CAPS_LOCK_TEXT, Callback, CancelArm, KIT_ARMED, + KIT_PAPER_NOTE, LiveCheck, MayShow, PresentationState, PresentationUpdate, Prompt, PromptError, + RESET_BUTTON, Refusal, RefusalFocus, Reply, Request, RevealMode, SUGGEST_BUTTON, + SUGGESTED_TEXT, SUGGESTION_ASK, SUGGESTION_REMOVED, Suggestion, TimeLeft, TimeLimit, TimeProbe, + kept_refusal_box, problem_text, readout_text, recovery_progress, timed_readout_text, }; const SUBCLASS: usize = 0x454e_4352; @@ -27,10 +32,18 @@ const SUGGEST: usize = 102; const HOLD: usize = 103; const EXTEND: usize = 104; const READOUT: usize = 105; +/// Boxes take control ids from here up, in the order of `Panel::fields`. +const FIRST_FIELD: usize = 200; const REVEAL_TIMER: usize = 0xec20; // Standard EDIT messages, defined by WinUser.h. This is the plain EDIT, never RichEdit. const EM_LIMITTEXT: u32 = 0x00c5; +const EM_SETPASSWORDCHAR: u32 = 0x00cc; const EM_EMPTYUNDOBUFFER: u32 = 0x00cd; +const EM_SETCUEBANNER: u32 = 0x1501; +const BULLET: usize = 0x25cf; +// Three lines and two, by the same estimate as `detail_height`. +const KIT_NOTE_HEIGHT: i32 = 60; +const FEEDBACK_HEIGHT: i32 = 40; thread_local! { static ACTIVE: RefCell> = const { RefCell::new(None) }; } @@ -43,32 +56,71 @@ struct Panel { fields: Vec, sensitive_labels: Vec, feedback: HWND, + time_row: HWND, key_loop: Vec, prompt: Request, activity: Option, + time_left: TimeProbe, presentation: PresentationState, - suggestion: Option>, + suggestion: Option, + /// Set while this code writes to the boxes: those EN_CHANGE messages aren't the owner's edits. + installing: bool, + /// The last refusal, shown again after a Retry. Holds counts and group numbers only. + last_error: Option, + /// The row holds a refusal or notice to read, so the live lines leave it alone until an edit. + error_shown: bool, + cancel_arm: CancelArm, + live: LiveCheck, + /// The newest live line, so a Retry or a cleared refusal doesn't lose it. Holds no typed text. + live_line: Option, completion: Callback, readout: Option, readout_viewport: Option, readout_shown: bool, readout_width: i32, layout_center: i32, + layout_top: i32, + panel_height: i32, extensions: u8, expires: Option, } +impl Panel { + /// The feedback row when no refusal or question is waiting to be read. + fn normal_feedback(&self) -> String { + if self.suggestion.is_some() { + return SUGGESTED_TEXT.to_owned(); + } + if let Some(line) = &self.live_line { + return line.clone(); + } + self.prompt.feedback_text() + } + + /// What the row goes back to after a Retry, or after a first Cancel on the Kit expires. + fn restored_feedback(&mut self) -> String { + self.error_shown = self.last_error.is_some(); + match &self.last_error { + Some(message) => problem_text(message), + None => self.normal_feedback(), + } + } +} + #[derive(Clone, Copy)] struct OriginalChild { window: HWND, enabled: bool, } +// may_show is for the Show what I type tickbox, which is Mac only. pub(super) fn present( window: &tauri::WebviewWindow, id: u64, prompt: Request, on_activity: Activity, + time_left: TimeProbe, + _may_show: MayShow, completion: Callback, ) -> Result<(), PromptError> { let owner = window @@ -111,8 +163,10 @@ pub(super) fn present( id, prompt, on_activity, + time_left, completion, bounds.right, + bounds.bottom, ); let panel = match result { Ok(panel) => panel, @@ -151,6 +205,7 @@ pub(super) fn present( if !ACTIVE.with(|active| active.borrow().as_ref().is_some_and(|panel| panel.id == id)) { return Err(PromptError::Cancelled); } + show_recovery_progress(id); // SAFETY: No credential-bearing controls are visible until construction and registration // succeeded. Original children are restored only after all native text has been cleared. unsafe { @@ -191,20 +246,21 @@ fn build( id: u64, prompt: Request, activity: Activity, + time_left: TimeProbe, completion: Callback, window_width: i32, + window_height: i32, ) -> Result { let width = (window_width - 64).clamp(240, 560); let x = (window_width - width) / 2; - let mut y = 28; let (title, detail) = prompt.text(); + let detail_height = detail_height(detail, width); + let time_height = time_row_height(&prompt, width); + let height = panel_height(&prompt, detail_height, time_height); + let top = panel_top(window_height, height); + let mut y = top; label(overlay, title, x, y, width, 30)?; y += 38; - let detail_height = match &prompt { - Request::Entry(Prompt::NewPassword { .. }) => 48, - Request::Entry(Prompt::RecoveryKit { .. }) => 64, - _ => 160, - }; label(overlay, detail, x, y, width, detail_height)?; y += detail_height + 8; let mut sensitive_labels = Vec::new(); @@ -257,21 +313,14 @@ fn build( HOLD, )?); } else { - key_loop.push(button(overlay, "Extend by 20 seconds", x, y, 200, EXTEND)?); + key_loop.push(button(overlay, RESET_BUTTON, x, y, 200, EXTEND)?); expires = Some(Instant::now() + Duration::from_secs(20)); } y += 44; } if let Request::Entry(Prompt::RecoveryKit { recovery, .. }) = &prompt { - label( - overlay, - "Write this Recovery Key on paper and store it somewhere private.", - x, - y, - width, - 32, - )?; - y += 38; + label(overlay, KIT_PAPER_NOTE, x, y, width, KIT_NOTE_HEIGHT)?; + y += KIT_NOTE_HEIGHT + 6; sensitive_labels.push(label(overlay, recovery, x, y, width, 32)?); y += 40; } @@ -280,20 +329,41 @@ fn build( // A preceding STATIC label is the native accessibility label for the standard EDIT. label(overlay, caption, x, y, width, 22)?; y += 24; + let masking = if prompt.shows_typing() { + 0 + } else { + ES_PASSWORD + }; let field = control( overlay, w!("EDIT"), "", - WS_VISIBLE - | WS_TABSTOP - | WS_BORDER - | WINDOW_STYLE((ES_PASSWORD | ES_AUTOHSCROLL) as u32), + WS_VISIBLE | WS_TABSTOP | WS_BORDER | WINDOW_STYLE((masking | ES_AUTOHSCROLL) as u32), x, y, width, 28, - 200 + fields.len(), + FIRST_FIELD + fields.len(), )?; + if prompt.shows_typing() { + // A visible box would otherwise hand what is typed to an input method's own windows + // and any cloud input service. A null context turns the input method off for it. + // SAFETY: field is a live EDIT on this thread; no context is created or destroyed. + unsafe { + ImmAssociateContext(field, HIMC::default()); + } + let cue = wide("XXXX-XXXX"); + // SAFETY: The EDIT copies this NUL-terminated text before SendMessageW returns. + // wParam 1 keeps the cue while the box has focus, which it has from the start. + unsafe { + SendMessageW( + field, + EM_SETCUEBANNER, + Some(WPARAM(1)), + Some(LPARAM(cue.as_ptr() as isize)), + ); + } + } // SAFETY: This is a standard live EDIT; set an explicit input/undo bound. unsafe { SendMessageW(field, EM_LIMITTEXT, Some(WPARAM(4096)), None); @@ -303,30 +373,38 @@ fn build( } key_loop.extend_from_slice(&fields); if matches!(&prompt, Request::Entry(Prompt::NewPassword { .. })) { - key_loop.push(button(overlay, "Suggest passphrase", x, y, 170, SUGGEST)?); + key_loop.push(button(overlay, SUGGEST_BUTTON, x, y, 170, SUGGEST)?); y += 34; - sensitive_labels.push(label(overlay, "", x, y, width, 36)?); - y += 40; + sensitive_labels.push(label(overlay, "", x, y, width, 48)?); + y += 52; } let feedback = label( overlay, - "Credentials stay in native controls. Escape cancels.", + &prompt.feedback_text(), x, y, width, - 36, + FEEDBACK_HEIGHT, )?; - y += 42; - key_loop.push(button(overlay, "Cancel", x, y, 100, CANCEL)?); - let is_reveal = matches!(&prompt, Request::Reveal { .. }); - key_loop.push(button( - overlay, - if is_reveal { "Close" } else { "Continue" }, - x + width - 112, - y, - 112, - ACCEPT, - )?); + y += FEEDBACK_HEIGHT + 6; + // Not in sensitive_labels: this row only ever says how much time is left. + let time_row = label(overlay, "", x, y, width, time_height)?; + y += time_height + 4; + debug_assert_eq!(y + 30 - top, height); + // A readout has one button, which keeps Escape. Nothing here hints that a copy can be undone. + if matches!(&prompt, Request::Reveal { .. }) { + key_loop.push(button(overlay, "Close", x + width - 112, y, 112, CANCEL)?); + } else { + key_loop.push(button(overlay, "Cancel", x, y, 100, CANCEL)?); + key_loop.push(button( + overlay, + "Continue", + x + width - 112, + y, + 112, + ACCEPT, + )?); + } Ok(Panel { id, owner, @@ -336,22 +414,77 @@ fn build( fields, sensitive_labels, feedback, + time_row, key_loop, prompt, completion, activity: Some(activity), + time_left, presentation: PresentationState::default(), suggestion: None, + installing: false, + last_error: None, + error_shown: false, + cancel_arm: CancelArm::default(), + live: LiveCheck::default(), + live_line: None, readout, readout_viewport, readout_shown, readout_width, layout_center: x, + layout_top: top, + panel_height: height, extensions: 0, expires, }) } +/// An estimate from the text's length, because nothing here can measure or see the Win32 font: +/// about 8 pixels a character and 20 a line. Line breaks and wide characters are counted. +fn detail_height(detail: &str, width: i32) -> i32 { + let per_line = usize::try_from(width / 8).unwrap_or(1).max(1); + let lines: usize = detail + .split('\n') + .map(|line| { + let columns: usize = line.chars().map(|c| if c.is_ascii() { 1 } else { 2 }).sum(); + columns.div_ceil(per_line).max(1) + }) + .sum(); + i32::try_from(lines) + .unwrap_or(i32::MAX / 20) + .saturating_mul(20) + .min(216) +} + +/// Two lines, or more if this panel's longest warning needs them, so it is never cut off. +fn time_row_height(prompt: &Request, width: i32) -> i32 { + let longest = prompt + .time_row_text(&TimeLeft::Left { + seconds: 60, + limit: TimeLimit::AutoLock, + }) + .unwrap_or_default(); + detail_height(&longest, width).max(40) +} + +/// Every row `build` places, top to bottom. Its debug assertion keeps the two in step. +fn panel_height(prompt: &Request, detail_height: i32, time_height: i32) -> i32 { + let extra = match prompt { + Request::Reveal { .. } => 124 + 44, + Request::Entry(Prompt::RecoveryKit { .. }) => KIT_NOTE_HEIGHT + 6 + 40, + Request::Entry(Prompt::NewPassword { .. }) => 34 + 52, + _ => 0, + }; + let boxes = i32::try_from(prompt.labels().len()).unwrap_or(0); + 38 + detail_height + 8 + extra + boxes * 60 + FEEDBACK_HEIGHT + 6 + time_height + 4 + 30 +} + +/// Centered, but a panel taller than the window keeps its title in view. +fn panel_top(window_height: i32, panel_height: i32) -> i32 { + ((window_height - panel_height) / 2).max(28) +} + // This thin CreateWindowExW wrapper intentionally preserves Win32 style/position/ID // arguments; caller labels and native control classes remain visible at each creation. #[allow(clippy::too_many_arguments)] @@ -396,11 +529,12 @@ fn label( width: i32, height: i32, ) -> Result { + // SS_NOPREFIX: a STATIC would otherwise hide each & in a value and underline the next letter. control( parent, w!("STATIC"), text, - WS_VISIBLE, + WS_VISIBLE | WINDOW_STYLE(SS_NOPREFIX.0), x, y, width, @@ -440,6 +574,28 @@ fn set_text(control: HWND, text: &str) { let _ = unsafe { SetWindowTextW(control, PCWSTR(text.as_ptr())) }; } +/// For rows the timer rewrites, which hold app wording only: the same text again would flicker. +fn set_text_if_changed(control: HWND, text: &str) { + let mut shown = [0u16; 512]; + // SAFETY: This STATIC belongs to this thread, and GetWindowTextW gets the buffer's real size. + let count = unsafe { GetWindowTextW(control, &mut shown) }; + let same = usize::try_from(count) + .ok() + .and_then(|count| shown.get(..count)) + .is_some_and(|shown| shown.iter().copied().eq(text.encode_utf16())); + if !same { + set_text(control, text); + } +} + +fn clear_box(field: HWND) { + set_text(field, ""); + // SAFETY: This live EDIT's undo buffer is cleared along with its text. + unsafe { + SendMessageW(field, EM_EMPTYUNDOBUFFER, None, None); + } +} + fn destroy_overlay(overlay: HWND) { // Clear every partially created child too: a failed construction must not leave a // Recovery Key or suggestion in a native label until user32 frees its backing storage. @@ -485,15 +641,20 @@ fn visible_children(owner: HWND) -> Vec { children } -fn read_text(field: HWND) -> Result, &'static str> { +fn read_text(field: HWND) -> Result, Refusal> { let mut buffer = Zeroizing::new(vec![0u16; 4097]); // SAFETY: This EDIT belongs to this process/thread. The live 4097-element buffer includes // the terminator; GetWindowTextW receives its actual size and cannot write past it. let count = unsafe { GetWindowTextW(field, &mut buffer) }; - let count = usize::try_from(count).map_err(|_| "The native field could not be read.")?; + let count = usize::try_from(count) + .map_err(|_| Refusal::keep("That box couldn't be read. Clear it and type it again."))?; String::from_utf16(&buffer[..count]) .map(Zeroizing::new) - .map_err(|_| "Use valid text in the credential field.") + .map_err(|_| { + Refusal::keep( + "That text has characters the app can't read. Clear the box and type it again.", + ) + }) } fn submit() { @@ -501,16 +662,16 @@ fn submit() { return; } let target = ACTIVE.with(|active| { - let active = active.try_borrow().ok()?; - let panel = active.as_ref()?; + let mut active = active.try_borrow_mut().ok()?; + let panel = active.as_mut()?; + panel.cancel_arm.disarm(); Some(( panel.id, panel.fields.clone(), - panel.feedback, matches!(&panel.prompt, Request::Reveal { .. }), )) }); - let Some((id, fields, feedback, is_reveal)) = target else { + let Some((id, fields, is_reveal)) = target else { return; }; if is_reveal { @@ -527,12 +688,318 @@ fn submit() { Some(values.and_then(|values| panel.prompt.reply(values))) }); match reply { - Some(Ok(reply)) => finish(Ok(reply)), - Some(Err(message)) if current(id) => set_text(feedback, message), + Some(Ok(reply)) => { + // A suggested password is used only after the owner was asked if it's written down. + let ask = ACTIVE.with(|active| { + let mut active = active.try_borrow_mut().ok()?; + let panel = active.as_mut().filter(|panel| panel.id == id)?; + let suggestion = panel.suggestion.as_mut()?; + if suggestion.acknowledged { + return None; + } + suggestion.acknowledged = true; + Some(panel.feedback) + }); + match ask { + Some(feedback) => set_text(feedback, SUGGESTION_ASK), + None => finish(Ok(reply)), + } + } + Some(Err(refusal)) if current(id) => refuse(id, refusal), _ => (), } } +/// Clears a box first and shows the message last: EN_CHANGE runs inside the clearing. +fn refuse(id: u64, refusal: Refusal) { + let target = ACTIVE.with(|active| { + let mut active = active.try_borrow_mut().ok()?; + let panel = active.as_mut().filter(|panel| panel.id == id)?; + let kept = refusal.focus == RefusalFocus::Keep; + let (clear, focus) = match refusal.focus { + RefusalFocus::Keep => (Vec::new(), None), + RefusalFocus::ClearAndFocusNew => { + let boxes = panel.fields.rchunks(2).next().unwrap_or_default(); + (boxes.to_vec(), boxes.first().copied()) + } + RefusalFocus::FocusConfirm => (Vec::new(), panel.fields.last().copied()), + RefusalFocus::ClearAndFocusRetype => { + (panel.fields.clone(), panel.fields.first().copied()) + } + }; + if !clear.is_empty() { + panel.installing = true; + panel.live_line = None; + } + panel.last_error = Some(refusal.message.to_string()); + panel.error_shown = true; + Some(( + clear, + focus, + panel.feedback, + kept.then(|| panel.fields.clone()), + )) + }); + let Some((clear, focus, feedback, kept)) = target else { + return; + }; + for field in clear { + if !current(id) { + return; + } + clear_box(field); + } + installed(id); + // A clicked button keeps the focus unless it is moved, and it would eat what is typed next. + let focus = focus.or_else(|| kept.and_then(|boxes| kept_refusal_focus(&boxes))); + if let Some(focus) = focus + && current(id) + { + // SAFETY: focus is a live native panel control on this thread. + let _ = unsafe { SetFocus(Some(focus)) }; + } + if current(id) { + set_text(feedback, &problem_text(&refusal.message)); + } +} + +/// None when the focus is already in a box: Return there stays there. Checks only the lengths. +fn kept_refusal_focus(boxes: &[HWND]) -> Option { + // SAFETY: GetFocus reads this thread's focused control, and each box is a live EDIT on it. + let (focused, empty) = unsafe { + ( + GetFocus(), + boxes + .iter() + .map(|field| GetWindowTextLengthW(*field) == 0) + .collect::>(), + ) + }; + if boxes.contains(&focused) { + return None; + } + boxes.get(kept_refusal_box(&empty)?).copied() +} + +/// Our own writes to the boxes are over: the next EN_CHANGE is the owner's. +fn installed(id: u64) { + ACTIVE.with(|active| { + if let Ok(mut active) = active.try_borrow_mut() + && let Some(panel) = active.as_mut().filter(|panel| panel.id == id) + { + panel.installing = false; + } + }); +} + +/// The empty box Return should move to on the new-password panel. Checks only the length: +/// reading the text would make another copy of it. +fn first_empty_box() -> Option { + let fields = ACTIVE.with(|active| { + let active = active.try_borrow().ok()?; + let panel = active.as_ref()?; + matches!(&panel.prompt, Request::Entry(Prompt::NewPassword { .. })) + .then(|| panel.fields.clone()) + })?; + // SAFETY: Each field is a live EDIT on this thread; only its length is read. + fields + .into_iter() + .find(|field| unsafe { GetWindowTextLengthW(*field) } == 0) +} + +/// Return, after its key-down passed the activity check. It moves to an empty box, then submits. +/// On a readout `submit` closes, so Return never presses Hold or Reset. It never presses Suggest +/// either: that would replace a password the owner may have just written down. +fn return_key(control_id: usize) { + if (control_id >= FIRST_FIELD || control_id == SUGGEST) + && let Some(field) = first_empty_box() + { + // SAFETY: field is a live native panel control on this thread. + let _ = unsafe { SetFocus(Some(field)) }; + return; + } + submit(); +} + +/// Cancel or Escape, once the caller has dropped auto-repeat. Only the Recovery Kit asks first, +/// because the key on screen stops working if this step ends. +fn owner_cancel(from_key: bool) { + let kit = ACTIVE.with(|active| { + active + .try_borrow() + .ok() + .is_some_and(|active| active.as_ref().is_some_and(|panel| panel.prompt.is_kit())) + }); + if !kit { + finish(Err(PromptError::Cancelled)); + return; + } + // Denied has already closed the panel. A blanked Kit rejects this press like any other action. + if !activity(true) && !kit_on_hold() { + return; + } + let decision = ACTIVE.with(|active| { + let mut active = active.try_borrow_mut().ok()?; + let panel = active.as_mut()?; + Some(( + panel.id, + panel.cancel_arm.press(Instant::now(), from_key), + panel.feedback, + panel.fields.first().copied(), + )) + }); + let Some((id, decision, feedback, field)) = decision else { + return; + }; + match decision { + ArmDecision::Cancel => { + finish(Err(PromptError::Cancelled)); + return; + } + ArmDecision::Arm => set_text(feedback, KIT_ARMED), + ArmDecision::Ignore => (), + } + // The row says "keep typing", and a clicked button keeps the focus and eats the keys. Ignore + // too: the second half of a double-click takes the focus back. + if let Some(field) = field + && current(id) + { + // SAFETY: field is a live native panel control on this thread. + let _ = unsafe { SetFocus(Some(field)) }; + } +} + +/// True right after `activity` said no for a brief core lock on the Kit: `Hold` blanked nothing. +fn kit_on_hold() -> bool { + ACTIVE.with(|active| { + active.try_borrow().ok().is_some_and(|active| { + active + .as_ref() + .is_some_and(|panel| panel.prompt.is_kit() && !panel.presentation.suspended) + }) + }) +} + +/// True right after `activity` said no for a busy core: Denied has already closed the panel. +fn box_takes_typing(control_id: usize) -> bool { + control_id >= FIRST_FIELD + && ACTIVE.with(|active| { + active.try_borrow().ok().is_some_and(|active| { + active.as_ref().is_some_and(|panel| { + panel + .prompt + .box_takes_typing_while_busy(panel.presentation.suspended) + }) + }) + }) +} + +/// What an EDIT takes from WM_CHAR as typing. Control codes such as Ctrl+V are not typing. +fn is_plain_typing(code: usize) -> bool { + code == 0x08 || (code >= 0x20 && code != 0x7f) +} + +/// EN_CHANGE. It also runs inside SetWindowTextW: our own writes set `installing` first, and +/// `finish` clears its boxes after taking the panel, so ACTIVE is empty for those. +fn field_edited(control_id: usize) { + let edit = ACTIVE.with(|active| { + let mut active = active.try_borrow_mut().ok()?; + let panel = active.as_mut()?; + if panel.installing { + return None; + } + let suspended = panel.presentation.suspended; + let mut text = None; + if !suspended && (panel.error_shown || panel.cancel_arm.armed_at.is_some()) { + panel.error_shown = false; + panel.last_error = None; + text = Some(panel.normal_feedback()); + } + panel.cancel_arm.disarm(); + let current_box = control_id == FIRST_FIELD + && matches!( + &panel.prompt, + Request::Entry(Prompt::NewPassword { + current_password: true, + .. + }) + ); + // Only an edit that is positively the Current box leaves a suggestion on screen. + if panel.suggestion.is_some() && !current_box { + panel.suggestion = None; + panel.installing = true; + panel.live_line = None; + panel.error_shown = true; + return Some(( + panel.id, + panel.fields.rchunks(2).next().unwrap_or_default().to_vec(), + panel.sensitive_labels.last().copied(), + panel.feedback, + (!suspended).then(|| SUGGESTION_REMOVED.to_owned()), + )); + } + if !current_box && matches!(&panel.prompt, Request::Entry(Prompt::NewPassword { .. })) { + let index = control_id.checked_sub(FIRST_FIELD); + panel + .live + .box_edited(Instant::now(), panel.fields.len(), index); + } + Some((panel.id, Vec::new(), None, panel.feedback, text)) + }); + let Some((id, boxes, label, feedback, text)) = edit else { + return; + }; + for field in boxes { + if !current(id) { + return; + } + clear_box(field); + } + if let Some(label) = label + && current(id) + { + set_text(label, ""); + } + installed(id); + if let Some(text) = text + && current(id) + { + set_text(feedback, &text); + } + show_recovery_progress(id); +} + +/// The count line under a Recovery Key box. It reads the box but shows no character of it. +fn show_recovery_progress(id: u64) { + let target = ACTIVE.with(|active| { + let active = active.try_borrow().ok()?; + let panel = active.as_ref().filter(|panel| panel.id == id)?; + let recovery = matches!( + &panel.prompt, + Request::Entry(Prompt::Credential { recovery: true, .. }) + ); + if !recovery || panel.presentation.suspended || panel.error_shown { + return None; + } + Some(( + *panel.fields.first()?, + panel.feedback, + panel.prompt.feedback_text(), + )) + }); + let Some((field, feedback, hint)) = target else { + return; + }; + let Ok(value) = read_text(field) else { + return; + }; + let text = format!("{}. {hint}", recovery_progress(&value)); + drop(value); + if current(id) { + set_text(feedback, &text); + } +} + struct ReadoutPaint { readout: HWND, viewport: HWND, @@ -714,10 +1181,19 @@ fn tick() { if !activity(false) { return; } + revert_expired_arm(); + update_time_row(); + update_live_line(); let pending = ACTIVE.with(|active| { let active = active.try_borrow().ok()?; let panel = active.as_ref()?; - let remaining = panel.expires?.saturating_duration_since(Instant::now()); + let Request::Reveal { reveal, .. } = &panel.prompt else { + return None; + }; + let remaining = panel + .expires? + .min(reveal.closes_at) + .saturating_duration_since(Instant::now()); Some((panel.id, panel.feedback, remaining, panel.extensions)) }); if let Some((id, feedback, remaining, extensions)) = pending { @@ -727,29 +1203,110 @@ fn tick() { if remaining.is_zero() { finish(Err(PromptError::Cancelled)); } else { - set_text( + set_text_if_changed( feedback, - &format!( - "Hides in {} seconds. {} extensions remaining.", - remaining.as_secs() + 1, - 3 - extensions - ), + &timed_readout_text(remaining.as_secs() + 1, 3 - extensions), ); } } } -fn forget_suggestion() { +/// Ten seconds after a first Cancel on the Kit, that press no longer counts. +fn revert_expired_arm() { + let revert = ACTIVE.with(|active| { + let mut active = active.try_borrow_mut().ok()?; + let panel = active.as_mut()?; + if !panel.cancel_arm.expired(Instant::now()) { + return None; + } + panel.cancel_arm.disarm(); + Some((panel.id, panel.feedback, panel.restored_feedback())) + }); + if let Some((id, feedback, text)) = revert + && current(id) + { + set_text(feedback, &text); + } +} + +fn time_row_line(caps_lock: bool, time: &str) -> String { + match (caps_lock, time.is_empty()) { + (true, true) => CAPS_LOCK_TEXT.to_owned(), + (true, false) => format!("{CAPS_LOCK_TEXT} {time}"), + (false, _) => time.to_owned(), + } +} + +fn update_time_row() { + // SAFETY: GetKeyState only reads keyboard state. The low bit is whether Caps Lock is on. + let caps_lock = (unsafe { GetKeyState(0x14) } & 1) != 0; + let row = ACTIVE.with(|active| { + let active = active.try_borrow().ok()?; + let panel = active.as_ref()?; + // The probe only tries its locks and never counts as activity. Busy leaves the row. + let time = panel.prompt.time_row_text(&(panel.time_left)())?; + // Capitals change a password. They don't change a Recovery Key. + let matters = matches!( + &panel.prompt, + Request::Entry( + Prompt::Credential { + recovery: false, + .. + } | Prompt::NewPassword { .. } + ) + ); + Some(( + panel.id, + panel.time_row, + time_row_line(caps_lock && matters, &time), + )) + }); + if let Some((id, row, line)) = row + && current(id) + { + set_text_if_changed(row, &line); + } +} + +/// New-password advice at typing pauses. It reads New and Confirm only, and approves nothing: +/// Continue always runs the real check. +fn update_live_line() { + let now = Instant::now(); let target = ACTIVE.with(|active| { let mut active = active.try_borrow_mut().ok()?; let panel = active.as_mut()?; - panel.suggestion.take()?; - Some((panel.id, *panel.sensitive_labels.last()?)) + if !matches!(&panel.prompt, Request::Entry(Prompt::NewPassword { .. })) + || panel.suggestion.is_some() + || panel.presentation.suspended + || panel.error_shown + { + return None; + } + if !panel.live.poll() && !panel.live.due(now) { + return None; + } + let [.., new, confirm] = panel.fields[..] else { + return None; + }; + Some((panel.id, new, confirm, panel.feedback)) + }); + let Some((id, new, confirm, feedback)) = target else { + return; + }; + let (Ok(new), Ok(confirm)) = (read_text(new), read_text(confirm)) else { + return; + }; + let line = ACTIVE.with(|active| { + let mut active = active.try_borrow_mut().ok()?; + let panel = active.as_mut().filter(|panel| panel.id == id)?; + let line = panel.live.line(&new, &confirm); + panel.live_line = Some(line.clone()); + Some(line) }); - if let Some((id, label)) = target + if let Some(line) = line && current(id) { - set_text(label, ""); + set_text(feedback, &line); } } @@ -758,11 +1315,17 @@ fn suggest() { return; } let target = ACTIVE.with(|active| { - let active = active.try_borrow().ok()?; - let panel = active.as_ref()?; + let mut active = active.try_borrow_mut().ok()?; + let panel = active.as_mut()?; if !matches!(&panel.prompt, Request::Entry(Prompt::NewPassword { .. })) { return None; } + // EN_CHANGE fires between the two writes below, so the old one goes first. + panel.suggestion = None; + panel.last_error = None; + panel.error_shown = false; + panel.live_line = None; + panel.installing = true; Some(( panel.id, panel @@ -779,6 +1342,8 @@ fn suggest() { let Some((id, fields, label, feedback)) = target else { return; }; + // Reversed above, so New is last. + let new_box = fields.last().copied(); let suggestion = policy::suggest_passphrase(); // EDIT EN_CHANGE notifications run synchronously here, with no ACTIVE borrow held. for field in fields { @@ -794,7 +1359,11 @@ fn suggest() { let Some(panel) = active.as_mut().filter(|panel| panel.id == id) else { return false; }; - panel.suggestion = Some(suggestion.clone()); + panel.installing = false; + panel.suggestion = Some(Suggestion { + text: suggestion.clone(), + acknowledged: false, + }); true }); if !stored || !activity(false) { @@ -806,10 +1375,15 @@ fn suggest() { set_text(label, &suggestion); } if current(id) { - set_text( - feedback, - "Suggested passphrase meets the strength rules. Write it somewhere private.", - ); + set_text(feedback, SUGGESTED_TEXT); + } + // Off the button, or the next Space replaces a password the owner may have just written down. + // An empty Current box comes first: typing there leaves the suggestion alone. + if let Some(field) = first_empty_box().or(new_box) + && current(id) + { + // SAFETY: field is a live native panel control on this thread. + let _ = unsafe { SetFocus(Some(field)) }; } } @@ -819,11 +1393,7 @@ fn finish(result: Result) { return; }; for field in &panel.fields { - set_text(*field, ""); - // SAFETY: This live EDIT's undo buffer is cleared before destruction. - unsafe { - SendMessageW(*field, EM_EMPTYUNDOBUFFER, None, None); - } + clear_box(*field); } for label in &panel.sensitive_labels { set_text(*label, ""); @@ -874,11 +1444,11 @@ unsafe extern "system" fn overlay_proc( return LRESULT(0); } if message == WM_COMMAND && (wparam.0 >> 16) == 0x0300 { - forget_suggestion(); + field_edited(wparam.0 & 0xffff); } if message == WM_COMMAND && (wparam.0 >> 16) == 0 { if (wparam.0 & 0xffff) == CANCEL { - finish(Err(PromptError::Cancelled)); + owner_cancel(false); return LRESULT(0); } if !activity(true) { @@ -886,7 +1456,6 @@ unsafe extern "system" fn overlay_proc( } match wparam.0 & 0xffff { ACCEPT => submit(), - CANCEL => finish(Err(PromptError::Cancelled)), SUGGEST => suggest(), EXTEND => extend(), _ => {} @@ -921,10 +1490,11 @@ unsafe extern "system" fn owner_proc( _id: usize, _data: usize, ) -> LRESULT { - if message == WM_CLOSE - || message == WM_NCDESTROY - || (message == WM_ACTIVATE && (wparam.0 & 0xffff) == 0) - { + if message == WM_ACTIVATE && (wparam.0 & 0xffff) == 0 { + // Another app or window took focus: the panel closed by itself, not by the owner. + finish(Err(PromptError::Interrupted)); + } + if message == WM_CLOSE || message == WM_NCDESTROY { finish(Err(PromptError::Cancelled)); } if message == WM_SIZE { @@ -932,14 +1502,17 @@ unsafe extern "system" fn owner_proc( // SAFETY: The local rectangle is writable, and this callback belongs to our owner. if unsafe { GetClientRect(hwnd, &mut bounds) }.is_ok() { let resize = ACTIVE.with(|active| { - let mut active = active.borrow_mut(); + let mut active = active.try_borrow_mut().ok()?; let panel = active.as_mut()?; let center = (bounds.right - (panel.readout_width + 24)) / 2; - let delta = center - panel.layout_center; + let top = panel_top(bounds.bottom, panel.panel_height); + let across = center - panel.layout_center; + let down = top - panel.layout_top; panel.layout_center = center; - Some((panel.overlay, delta)) + panel.layout_top = top; + Some((panel.overlay, across, down)) }); - if let Some((overlay, delta)) = resize { + if let Some((overlay, across, down)) = resize { // SAFETY: The overlay and its direct children belong to this UI thread. // Move children after dropping ACTIVE's borrow: user32 may dispatch messages. unsafe { @@ -952,7 +1525,7 @@ unsafe extern "system" fn owner_proc( bounds.bottom, SWP_NOACTIVATE, ); - if delta != 0 { + if across != 0 || down != 0 { let mut outer = RECT::default(); if GetWindowRect(overlay, &mut outer).is_ok() { let mut next = GetWindow(overlay, GW_CHILD).ok(); @@ -961,8 +1534,8 @@ unsafe extern "system" fn owner_proc( if GetWindowRect(child, &mut rect).is_ok() { let _ = MoveWindow( child, - rect.left - outer.left + delta, - rect.top - outer.top, + rect.left - outer.left + across, + rect.top - outer.top + down, rect.right - rect.left, rect.bottom - rect.top, true, @@ -1013,15 +1586,22 @@ unsafe extern "system" fn input_proc( if !handles_input(message) { return unsafe { DefSubclassProc(hwnd, message, wparam, lparam) }; } - if message == WM_KEYDOWN && wparam.0 == 0x1b { - finish(Err(PromptError::Cancelled)); + // Bit 30 identifies an already-held key. Repeated key-downs must not defeat Auto-lock, + // and holding Return or Escape presses nothing. + let repeat = matches!(message, WM_KEYDOWN | WM_CHAR) && (lparam.0 & (1 << 30)) != 0; + // SAFETY: GetDlgCtrlID reads the native child ID assigned during construction. + let control_id = usize::try_from(unsafe { GetDlgCtrlID(hwnd) }).unwrap_or(0); + if message == WM_KEYDOWN && (wparam.0 == 0x1b || (wparam.0 == 0x0d && control_id == CANCEL)) { + if !repeat { + owner_cancel(true); + } return LRESULT(0); } - // Bit 30 identifies an already-held key. Repeated key-downs must not defeat Auto-lock. + // A busy core must not eat what is being typed in a box. if (matches!(message, WM_LBUTTONDOWN | WM_NCLBUTTONDOWN) - || (message == WM_KEYDOWN && (lparam.0 & (1 << 30)) == 0) - || (message == WM_CHAR && (lparam.0 & (1 << 30)) == 0)) + || (matches!(message, WM_KEYDOWN | WM_CHAR) && !repeat)) && !activity(true) + && !(message == WM_CHAR && is_plain_typing(wparam.0) && box_takes_typing(control_id)) { return LRESULT(0); } @@ -1050,9 +1630,7 @@ unsafe extern "system" fn input_proc( return LRESULT(0); } } - // SAFETY: GetDlgCtrlID reads the native child ID assigned during construction. - let control_id = unsafe { GetDlgCtrlID(hwnd) }; - let is_hold = control_id == HOLD as i32 || (control_id == READOUT as i32 && ACTIVE.with(|active| active.try_borrow().ok().is_some_and(|active| active.as_ref().is_some_and(|panel| matches!(&panel.prompt, Request::Reveal { reveal, .. } if reveal.mode == RevealMode::Held))))); + let is_hold = control_id == HOLD || (control_id == READOUT && ACTIVE.with(|active| active.try_borrow().ok().is_some_and(|active| active.as_ref().is_some_and(|panel| matches!(&panel.prompt, Request::Reveal { reveal, .. } if reveal.mode == RevealMode::Held))))); if is_hold { if message == WM_LBUTTONDOWN { // SAFETY: Capture keeps mouse-up delivery on this control if the pointer leaves it. @@ -1104,17 +1682,15 @@ unsafe extern "system" fn input_proc( return LRESULT(0); } match wparam.0 { - 0x1b => { - finish(Err(PromptError::Cancelled)); - return LRESULT(0); - } 0x0d => { - submit(); + if !repeat { + return_key(control_id); + } return LRESULT(0); } 0x09 => { let next = ACTIVE.with(|active| { - let active = active.borrow(); + let active = active.try_borrow().ok()?; let panel = active.as_ref()?; let at = panel.key_loop.iter().position(|control| *control == hwnd)?; let count = panel.key_loop.len(); @@ -1165,9 +1741,13 @@ fn apply_authorization(id: u64, authorization: Authorization) -> bool { let paint = ACTIVE.with(|active| { let mut active = active.try_borrow_mut().ok()?; let panel = active.as_mut().filter(|panel| panel.id == id)?; - let update = panel.presentation.authorize(authorization); + let grace = panel.prompt.rides_out_a_blip(); + let update = panel + .presentation + .authorize(authorization, Instant::now(), grace); let mut texts = Vec::new(); let mut readout = None; + let mut mask = None; match update { PresentationUpdate::Clear => { texts.extend( @@ -1185,6 +1765,15 @@ fn apply_authorization(id: u64, authorization: Authorization) -> bool { }); } panel.readout_shown = false; + panel.cancel_arm.disarm(); + if let Some(suggestion) = &mut panel.suggestion { + suggestion.acknowledged = false; + } + // The key is hidden for now, so its visible retyped part must be too. Hiding the + // box instead would move focus to the overlay, where no keys work. + if panel.prompt.shows_typing() { + mask = panel.fields.first().map(|field| (*field, BULLET)); + } texts.push((panel.feedback, Zeroizing::new("Please wait…".into()))); } PresentationUpdate::Restore => { @@ -1217,24 +1806,32 @@ fn apply_authorization(id: u64, authorization: Authorization) -> bool { if let Some(suggestion) = &panel.suggestion && let Some(label) = panel.sensitive_labels.last() { - texts.push((*label, suggestion.clone())); + texts.push((*label, suggestion.text.clone())); + } + if panel.prompt.shows_typing() { + mask = panel.fields.first().map(|field| (*field, 0)); } - texts.push(( - panel.feedback, - Zeroizing::new("Credentials stay in native controls. Escape cancels.".into()), - )); + texts.push((panel.feedback, Zeroizing::new(panel.restored_feedback()))); } _ => (), } - Some((update, texts, readout)) + Some((update, texts, readout, mask)) }); - let Some((update, texts, readout)) = paint else { + let Some((update, texts, readout, mask)) = paint else { return false; }; if update == PresentationUpdate::Close { - finish(Err(PromptError::Cancelled)); + finish(Err(PromptError::Interrupted)); return false; } + if let Some((field, character)) = mask + && current(id) + { + // SAFETY: This live EDIT redraws its own text; the text and the focus stay as they are. + unsafe { + SendMessageW(field, EM_SETPASSWORDCHAR, Some(WPARAM(character)), None); + } + } // HWNDs and wiping strings are owned snapshots. Never retain a RefCell borrow while // SetWindowTextW/SetScrollInfo/MoveWindow synchronously dispatch native callbacks. for (target, text) in texts { @@ -1246,6 +1843,9 @@ fn apply_authorization(id: u64, authorization: Authorization) -> bool { if let Some(readout) = readout { paint_readout(id, readout); } + if update == PresentationUpdate::Restore { + show_recovery_progress(id); + } current(id) && matches!( update, @@ -1255,11 +1855,88 @@ fn apply_authorization(id: u64, authorization: Authorization) -> bool { #[cfg(test)] mod tests { - use super::handles_input; + use super::{ + CAPS_LOCK_TEXT, FEEDBACK_HEIGHT, KIT_ARMED, KIT_NOTE_HEIGHT, KIT_PAPER_NOTE, Prompt, + Request, SUGGESTED_TEXT, detail_height, handles_input, is_plain_typing, panel_height, + panel_top, time_row_height, time_row_line, + }; use windows::Win32::UI::WindowsAndMessaging::{ WM_CHAR, WM_KEYDOWN, WM_KILLFOCUS, WM_SIZE, WM_STYLECHANGED, WM_STYLECHANGING, WM_WINDOWPOSCHANGED, WM_WINDOWPOSCHANGING, }; + use zeroize::Zeroizing; + + #[test] + fn a_longer_detail_gets_more_lines_up_to_the_cap() { + assert_eq!(detail_height("", 560), 20); + assert_eq!(detail_height(&"x".repeat(70), 560), 20); + assert_eq!(detail_height(&"x".repeat(71), 560), 40); + assert_eq!(detail_height("fake\nfake", 560), 40); + assert_eq!(detail_height(&"漢".repeat(36), 560), 40); + assert_eq!(detail_height(&"x".repeat(5_000), 560), 216); + } + + #[test] + fn the_fixed_rows_hold_their_longest_text_by_this_estimate() { + assert!(detail_height(KIT_PAPER_NOTE, 560) <= KIT_NOTE_HEIGHT); + assert!(detail_height(KIT_ARMED, 560) <= FEEDBACK_HEIGHT); + assert!(detail_height(SUGGESTED_TEXT, 560) <= FEEDBACK_HEIGHT); + } + + #[test] + fn panels_center_but_keep_their_title_in_view() { + assert_eq!(panel_top(1000, 400), 300); + assert_eq!(panel_top(456, 400), 28); + assert_eq!(panel_top(300, 400), 28); + } + + #[test] + fn the_fullest_panels_fit_the_smallest_window() { + let new_password = Request::Entry(Prompt::NewPassword { + title: "Fake".into(), + detail: "fake ".repeat(47), + current_password: true, + }); + let kit = Request::Entry(Prompt::RecoveryKit { + title: "Fake".into(), + detail: "fake ".repeat(68), + recovery: Zeroizing::new("FAKE-FAKE-FAKE-FAKE-FAKE-FAKE-FAKE".into()), + rotation: true, + }); + assert_eq!(time_row_height(&new_password, 560), 40); + assert_eq!(time_row_height(&kit, 560), 60); + for request in [new_password, kit] { + let (_, detail) = request.text(); + let height = panel_height( + &request, + detail_height(detail, 560), + time_row_height(&request, 560), + ); + assert!(28 + height <= 560, "{height}"); + } + } + + #[test] + fn caps_lock_comes_before_the_time() { + assert_eq!(time_row_line(false, ""), ""); + assert_eq!(time_row_line(false, "Fake time."), "Fake time."); + assert_eq!(time_row_line(true, ""), CAPS_LOCK_TEXT); + assert_eq!( + time_row_line(true, "Fake time."), + format!("{CAPS_LOCK_TEXT} Fake time.") + ); + } + + #[test] + fn only_typing_passes_while_the_core_is_busy() { + for code in [b'a' as usize, b' ' as usize, 0x08, 0x6f22] { + assert!(is_plain_typing(code)); + } + // Return, Tab, Escape, Ctrl+V and Delete. + for code in [13, 9, 27, 0x16, 0x7f] { + assert!(!is_plain_typing(code)); + } + } #[test] fn native_layout_reentry_bypasses_panel_input_policy() { diff --git a/src-tauri/src/platform.rs b/src-tauri/src/platform.rs index 3571893..82cd757 100644 --- a/src-tauri/src/platform.rs +++ b/src-tauri/src/platform.rs @@ -10,6 +10,7 @@ //! * [`default_backup_folder`]: where the Backup save panel starts. //! * [`clear_leftover_copy`]: at startup, clear a copy the last run didn't live to clear. //! * [`real_home_dir`]: the owner's home folder, even inside the Mac App Sandbox. +//! * [`reveal_in_file_manager`]: show the Vault in Finder or File Explorer (main thread only). //! * [`continuous_ms`]: a monotonic clock that keeps counting while the computer sleeps. //! * [`ms_since_last_input`]: how long ago the keyboard, mouse or trackpad was last used in //! any app, for the opt-in "count activity anywhere" Auto-lock setting (macOS only). A key @@ -319,6 +320,18 @@ mod mac { external_volume(Path::new("/Volumes")).unwrap_or_else(|| home.to_path_buf()) } + /// Select `target` in a Finder window. An empty root means Finder's main viewer. False if + /// Finder refused it. + pub fn reveal_in_file_manager(target: &Path) -> bool { + let Some(path) = target.to_str() else { + return false; + }; + NSWorkspace::sharedWorkspace().selectFile_inFileViewerRootedAtPath( + Some(&NSString::from_str(path)), + &NSString::from_str(""), + ) + } + /// The first real mount point under `/Volumes`: a directory (not the symlink to the startup /// disk) on a different device from `/`. fn external_volume(volumes: &Path) -> Option { @@ -629,14 +642,16 @@ mod win { use windows::Win32::System::SystemServices::{ GUID_CONSOLE_DISPLAY_STATE, GUID_LIDSWITCH_STATE_CHANGE, }; - use windows::Win32::UI::Shell::{DefSubclassProc, RemoveWindowSubclass, SetWindowSubclass}; + use windows::Win32::UI::Shell::{ + DefSubclassProc, RemoveWindowSubclass, SetWindowSubclass, ShellExecuteW, + }; use windows::Win32::UI::WindowsAndMessaging::{ DEVICE_NOTIFY_WINDOW_HANDLE, GWL_EXSTYLE, GetForegroundWindow, GetWindowDisplayAffinity, GetWindowLongW, GetWindowThreadProcessId, KillTimer, LWA_ALPHA, PBT_APMSUSPEND, PBT_POWERSETTINGCHANGE, SIZE_MAXIMIZED, SIZE_MINIMIZED, SIZE_RESTORED, STYLESTRUCT, - SWP_SHOWWINDOW, SetLayeredWindowAttributes, SetTimer, SetWindowDisplayAffinity, - SetWindowLongW, WDA_EXCLUDEFROMCAPTURE, WINDOWPOS, WM_DESTROY, WM_NCDESTROY, - WM_POWERBROADCAST, WM_SIZE, WM_STYLECHANGING, WM_TIMER, WM_WINDOWPOSCHANGED, + SW_SHOWNORMAL, SWP_SHOWWINDOW, SetLayeredWindowAttributes, SetTimer, + SetWindowDisplayAffinity, SetWindowLongW, WDA_EXCLUDEFROMCAPTURE, WINDOWPOS, WM_DESTROY, + WM_NCDESTROY, WM_POWERBROADCAST, WM_SIZE, WM_STYLECHANGING, WM_TIMER, WM_WINDOWPOSCHANGED, WM_WTSSESSION_CHANGE, WS_EX_LAYERED, WTS_CONSOLE_DISCONNECT, WTS_REMOTE_DISCONNECT, WTS_SESSION_LOCK, WTS_SESSION_LOGOFF, }; @@ -1026,6 +1041,11 @@ mod win { } } + /// Whether the foreground window is one of this app's own, including a native panel. + pub fn app_in_front() -> bool { + foreground_process_is(std::process::id()) + } + /// Exclude the window from capture and make it a (fully opaque) layered window. /// /// tao sets the affinity at creation; setting it again here is only a guard in case something @@ -1165,6 +1185,26 @@ mod win { pub fn default_backup_folder(home: &Path) -> PathBuf { home.to_path_buf() } + + /// Open File Explorer on `folder`. Only the `explore` verb, never `open` or the default + /// verb, and no fallback, so this can't run a file. The shell reports success above 32. + pub fn reveal_in_file_manager(folder: &Path) -> bool { + use std::os::windows::ffi::OsStrExt; + let wide: Vec = folder.as_os_str().encode_wide().chain(Some(0)).collect(); + // SAFETY: `wide` is NUL-terminated and outlives the call; the other strings are a + // constant or null, which this call allows. + let result = unsafe { + ShellExecuteW( + None, + w!("explore"), + PCWSTR(wide.as_ptr()), + PCWSTR::null(), + PCWSTR::null(), + SW_SHOWNORMAL, + ) + }; + result.0 as isize > 32 + } } #[cfg(not(any(target_os = "macos", windows)))] @@ -1222,4 +1262,8 @@ mod other { pub fn default_backup_folder(home: &Path) -> PathBuf { home.to_path_buf() } + + pub fn reveal_in_file_manager(_target: &Path) -> bool { + false + } } diff --git a/src-tauri/src/quick_runtime.rs b/src-tauri/src/quick_runtime.rs index 257f667..9aae278 100644 --- a/src-tauri/src/quick_runtime.rs +++ b/src-tauri/src/quick_runtime.rs @@ -1,7 +1,7 @@ //! Serializes local biometric custody outside the core mutex; no key is an IPC value. use crate::commands::CoreState; use crate::core::native::{NativeAction, NativeResult}; -use crate::core::{ApiError, AppCore}; +use crate::core::{ApiError, AppCore, MSG_TOUCH_ID_TURNED_OFF}; use crate::native_credential::{self, Authorization, Biometric, PromptError}; use crate::{biometry, commands, native_coordinator}; use std::path::PathBuf; @@ -159,19 +159,31 @@ async fn core_work( .map_err(|_| unavailable())? } -async fn failed(app: &AppHandle, error: biometry::Error) { - let disable = !matches!(error, biometry::Error::Cancelled); - let _ = core_work(app, move |core| { +/// What may keep Touch ID on after a failure: a cancel, or a touch that simply didn't work. +fn keeps_registration(error: biometry::Error) -> bool { + matches!( + error, + biometry::Error::Cancelled | biometry::Error::TryAgain + ) +} + +/// True only when this call really turned Touch ID off: it can skip that for a late result after +/// a Lock, and a failed settings write leaves it undone. +async fn failed(app: &AppHandle, error: biometry::Error) -> bool { + let disable = !keeps_registration(error); + core_work(app, move |core| { // A late result must not cancel a new native action after an owner Lock. - if core.quick_unlock_pending() { - core.cancel_all_native(); - if disable { - core.disable_quick_unlock()?; - } + if !core.quick_unlock_pending() { + return Ok(false); + } + core.cancel_all_native(); + if disable { + core.disable_quick_unlock()?; } - Ok(()) + Ok(disable) }) - .await; + .await + .unwrap_or(false) } fn panel_id(request: u64) -> u64 { @@ -232,15 +244,15 @@ async fn present_touch_id( let id = panel_id(request); let biometric = Biometric { title: if enrollment { - "Enable Touch ID for this Vault" + "Turn on Touch ID for this Vault" } else { "Unlock with Touch ID" } .to_owned(), detail: if enrollment { - "Touch the sensor to finish enabling Touch ID. Your Master Password keeps working." + "Touch the sensor to finish turning on Touch ID. Your Master Password keeps working." } else { - "Touch the sensor to unlock your Vault. You can still use your Master Password instead." + "Touch the sensor to unlock your Vault. To use your Master Password instead, choose Cancel." } .to_owned(), request, @@ -336,7 +348,7 @@ pub async fn enable(app: AppHandle) -> Result<(), ApiError> { } Err(error) => { failed(&app, error).await; - Err(map_error(error)) + Err(enable_failure(error)) } }; if result.is_ok() { @@ -381,8 +393,8 @@ pub async fn unlock(app: AppHandle) -> Result<(), ApiError> { Err(cancelled()) } Err(error) => { - failed(&app, error).await; - Err(map_error(error)) + let turned_off = failed(&app, error).await; + Err(unlock_failure(error, turned_off)) } } } @@ -392,11 +404,31 @@ pub async fn disable(app: AppHandle) -> Result<(), ApiError> { core_work(&app, AppCore::disable_quick_unlock).await } -fn map_error(error: biometry::Error) -> ApiError { +/// Fixed strings only, never OS error text. "Turned off" is said only when that really happened. +fn unlock_failure(error: biometry::Error, turned_off: bool) -> ApiError { + match error { + biometry::Error::Cancelled => cancelled(), + biometry::Error::TryAgain => ApiError { + code: "invalid_input", + message: "Touch ID didn't work this time. Try again or use your Master Password." + .to_owned(), + }, + _ if turned_off => ApiError { + code: "invalid_input", + message: MSG_TOUCH_ID_TURNED_OFF.to_owned(), + }, + _ => unavailable(), + } +} +fn enable_failure(error: biometry::Error) -> ApiError { if error == biometry::Error::Cancelled { cancelled() } else { - unavailable() + ApiError { + code: "invalid_input", + message: "Touch ID couldn't be turned on. It stays off, and your Master Password still works." + .to_owned(), + } } } fn cancelled() -> ApiError { @@ -414,10 +446,54 @@ fn unavailable() -> ApiError { #[cfg(test)] mod tests { - use super::{TOUCH_ID_PANEL, panel_id, touch_id_authorization}; - use crate::biometry::Cancellation; + use super::{ + MSG_TOUCH_ID_TURNED_OFF, TOUCH_ID_PANEL, cancelled, enable_failure, keeps_registration, + panel_id, touch_id_authorization, unavailable, unlock_failure, + }; + use crate::biometry::{Cancellation, Error}; use crate::native_credential::Authorization; + #[test] + fn only_a_cancel_or_a_missed_touch_keeps_touch_id_on() { + for kept in [Error::Cancelled, Error::TryAgain] { + assert!(keeps_registration(kept), "{kept:?}"); + } + for ended in [Error::Failed, Error::InvalidPayload, Error::Missing] { + assert!(!keeps_registration(ended), "{ended:?}"); + } + } + + #[test] + fn an_unlock_failure_says_turned_off_only_when_it_really_was() { + assert_eq!(unlock_failure(Error::Cancelled, false), cancelled()); + assert_eq!(unlock_failure(Error::Cancelled, true), cancelled()); + assert_eq!(unlock_failure(Error::Failed, false), unavailable()); + let off = unlock_failure(Error::Failed, true); + assert_eq!(off.code, "invalid_input"); + assert_eq!(off.message, MSG_TOUCH_ID_TURNED_OFF); + for turned_off in [false, true] { + let again = unlock_failure(Error::TryAgain, turned_off); + assert_eq!(again.code, "invalid_input"); + assert_eq!( + again.message, + "Touch ID didn't work this time. Try again or use your Master Password." + ); + } + } + + #[test] + fn an_enable_failure_says_touch_id_stays_off() { + assert_eq!(enable_failure(Error::Cancelled), cancelled()); + for error in [Error::Failed, Error::TryAgain, Error::CleanupFailed] { + let failure = enable_failure(error); + assert_eq!(failure.code, "invalid_input"); + assert_eq!( + failure.message, + "Touch ID couldn't be turned on. It stays off, and your Master Password still works." + ); + } + } + #[test] fn touch_id_panel_stays_only_for_its_pending_unguarded_live_request() { assert_eq!( diff --git a/src-tauri/tests/core.rs b/src-tauri/tests/core.rs index 02bf2f7..75ec050 100644 --- a/src-tauri/tests/core.rs +++ b/src-tauri/tests/core.rs @@ -44,6 +44,9 @@ impl Clock for FakeClock { } START_UNIX + i64::try_from(self.now_ms() / 1000).unwrap() } + fn continuous_ms(&self) -> Option { + Some(self.now_ms()) + } } #[derive(Default)] @@ -4714,6 +4717,67 @@ fn native_creation_kit(app: &mut App) -> Zeroizing { shown.recovery_key.unwrap() } +#[test] +fn recovery_kit_step_gives_time_to_copy_the_key() { + let mut app = App::new(); + let key = native_creation_kit(&mut app); + let operation = app + .core + .begin_native(NativeAction::ConfirmRecoveryKit) + .unwrap(); + // Longer than the two-minute limit of other native steps, with no input while writing. + app.advance(3 * 60_000); + app.core.tick(); + assert_eq!(app.core.native_pending_id(), Some(operation.id)); + let job = app + .core + .prepare_native( + operation.id, + NativeCredentials::KitConfirmation(pw(&last_groups(&key))), + ) + .unwrap(); + app.core.finish_native(job.run()).unwrap(); + assert_eq!(app.phase(), "unlocked"); + assert!(vault_file(&app).is_file()); +} + +#[test] +fn recovery_kit_step_still_ends_at_its_limit_and_saves_nothing() { + let mut app = App::new(); + native_creation_kit(&mut app); + let operation = app + .core + .begin_native(NativeAction::ConfirmRecoveryKit) + .unwrap(); + for _ in 0..3 { + app.advance(4 * 60_000); + app.core.activity(); + app.core.tick(); + } + assert_ne!(app.core.native_pending_id(), Some(operation.id)); + assert!(!vault_file(&app).exists()); +} + +#[test] +fn other_native_steps_keep_their_two_minute_limit() { + let mut app = App::new(); + let key = native_creation_kit(&mut app); + native_run( + &mut app.core, + NativeAction::ConfirmRecoveryKit, + NativeCredentials::KitConfirmation(pw(&last_groups(&key))), + ) + .unwrap(); + app.core.lock(); + let operation = app + .core + .begin_native(NativeAction::Unlock { recovery: false }) + .unwrap(); + app.advance(2 * 60_000); + app.core.tick(); + assert_ne!(app.core.native_pending_id(), Some(operation.id)); +} + #[test] fn create_kit_guard_native_confirmation_resets_only_after_save() { let mut app = App::new(); @@ -5072,10 +5136,46 @@ fn native_wrong_password_throttle_survives_restart_and_cancel_does_not_bypass_it ); } let mut app = app.restart(); + // Told before the panel opens, so nothing typed is wasted. + assert_eq!( + code( + app.core + .begin_native(NativeAction::Unlock { recovery: false }) + ), + "retry_later" + ); + assert_eq!(app.core.native_pending_id(), None); + assert_eq!(app.core.app_status().failed_attempts, 5); + app.advance(60_000); + native_run( + &mut app.core, + NativeAction::Unlock { recovery: false }, + NativeCredentials::Password(pw(PASSWORD)), + ) + .unwrap(); +} + +#[test] +fn a_wrong_password_while_a_panel_is_open_is_still_caught_on_submit() { + let mut app = App::new(); + unlocked(&mut app); + app.core.lock(); + for _ in 0..2 { + assert_eq!( + code(app.core.unlock(password(WRONG_PASSWORD))), + "wrong_credential" + ); + } let operation = app .core .begin_native(NativeAction::Unlock { recovery: false }) .unwrap(); + // The third failure starts a wait and leaves the pending operation alone. + assert_eq!( + code(app.core.unlock(password(WRONG_PASSWORD))), + "wrong_credential" + ); + assert_eq!(app.core.native_pending_id(), Some(operation.id)); assert_eq!( code( app.core @@ -5083,15 +5183,433 @@ fn native_wrong_password_throttle_survives_restart_and_cancel_does_not_bypass_it ), "retry_later" ); + assert_eq!(app.core.app_status().failed_attempts, 3); + assert_eq!(app.phase(), "locked"); +} + +/// Three wrong Master Passwords on a Wallet action leave a one-second wait with the Vault open. +fn start_a_wait_while_unlocked(app: &mut App, pk: &str) { + for _ in 0..3 { + assert_eq!( + code(native_run( + &mut app.core, + NativeAction::CopyField { + id: pk.to_owned(), + field: "key".into(), + }, + NativeCredentials::Password(pw(WRONG_PASSWORD)), + )), + "wrong_credential" + ); + } + assert_eq!(app.phase(), "unlocked"); + assert!(app.core.app_status().retry_in_ms.is_some()); +} + +#[test] +fn key_rotation_is_refused_during_a_wait_and_still_turns_touch_id_off() { + let mut app = App::new(); + unlocked(&mut app); + let n = sample_notes(&mut app); + app.core.set_quick_unlock_availability(true, None); + let _ = app.core.take_quick_unlock_cleanup(); + let NativeResult::QuickUnlockEnrollment(enrollment) = native_run( + &mut app.core, + NativeAction::EnrollQuickUnlock, + NativeCredentials::Password(pw(PASSWORD)), + ) + .unwrap() else { + panic!("expected enrollment") + }; + let payload = enrollment + .key + .with_keychain_payload(|bytes| Zeroizing::new(bytes.to_vec())); + let returned = vault_format::QuickUnlockKey::from_keychain_payload(payload).unwrap(); + app.core + .finish_quick_unlock_enrollment(enrollment.verify(returned)) + .unwrap(); + assert!(app.core.app_status().quick_unlock.enabled); + + start_a_wait_while_unlocked(&mut app, &n.pk); + assert!(app.core.app_status().quick_unlock.enabled); + assert_eq!( + code( + app.core + .begin_native(NativeAction::ChangePassword { rotate: true }) + ), + "retry_later" + ); + assert_eq!(app.core.native_pending_id(), None); + assert_eq!(app.core.app_status().failed_attempts, 3); + assert!(!app.core.app_status().quick_unlock.enabled); +} + +#[test] +fn a_refused_second_look_during_a_wait_still_revokes_the_earlier_preview() { + let (mut app, next) = native_safety_preview_with_another_copy(); + let n = sample_notes(&mut app); + native_run( + &mut app.core, + NativeAction::InspectSafetyCopy { + id: next.clone(), + recovery: false, + }, + NativeCredentials::Password(pw(PASSWORD)), + ) + .unwrap(); + start_a_wait_while_unlocked(&mut app, &n.pk); + // A wrong Wallet password leaves a preview alone; the refused look below must not. + let before = std::fs::read(vault_file(&app)).unwrap(); + assert_eq!( + code(app.core.begin_native(NativeAction::InspectSafetyCopy { + id: next, + recovery: false, + })), + "retry_later" + ); + assert_eq!(app.core.native_pending_id(), None); + assert_eq!(code(app.core.restore_safety_copy()), "invalid_input"); + assert_eq!(code(app.core.replace_with_backup()), "invalid_input"); + assert_eq!(std::fs::read(vault_file(&app)).unwrap(), before); +} + +fn native_is_open(app: &mut App, id: u64, after_ms: u64) -> bool { + app.advance(after_ms); + app.core.tick(); + app.core.native_pending_id() == Some(id) +} + +#[test] +fn choosing_a_new_password_has_ten_minutes() { + let mut app = App::new(); + let operation = app + .core + .begin_native(NativeAction::CreateVault { + params: KdfParams::TESTING, + }) + .unwrap(); + assert_eq!(operation.limit_ms, 10 * 60_000); + assert!(native_is_open(&mut app, operation.id, 3 * 60_000)); + assert!(native_is_open(&mut app, operation.id, 7 * 60_000 - 1)); + assert!(!native_is_open(&mut app, operation.id, 1)); + assert!(!vault_file(&app).exists()); + + let mut app = App::new(); + unlocked(&mut app); + for rotate in [false, true] { + let operation = app + .core + .begin_native(NativeAction::ChangePassword { rotate }) + .unwrap(); + assert_eq!(operation.limit_ms, 10 * 60_000); + for _ in 0..2 { + // Auto-lock is its own limit; typing keeps it away. + app.advance(4 * 60_000); + app.core.activity(); + app.core.tick(); + assert_eq!(app.core.native_pending_id(), Some(operation.id)); + } + app.advance(2 * 60_000 - 1); + app.core.activity(); + assert!(native_is_open(&mut app, operation.id, 0)); + assert!(!native_is_open(&mut app, operation.id, 1)); + assert_eq!(app.phase(), "unlocked"); + } +} + +#[test] +fn typing_a_recovery_key_has_five_minutes() { + let mut app = App::new(); + let key = unlocked(&mut app); + app.core.lock(); + let operation = app + .core + .begin_native(NativeAction::Unlock { recovery: true }) + .unwrap(); + assert_eq!(operation.limit_ms, 5 * 60_000); + assert!(native_is_open(&mut app, operation.id, 5 * 60_000 - 1)); + assert!(!native_is_open(&mut app, operation.id, 1)); + assert_eq!(app.phase(), "locked"); + assert_eq!(app.core.app_status().failed_attempts, 0); + + // The same entry with a password keeps two minutes. + let operation = app + .core + .begin_native(NativeAction::Unlock { recovery: false }) + .unwrap(); + assert_eq!(operation.limit_ms, 2 * 60_000); app.core.cancel_native(operation.id); - assert_eq!(app.core.app_status().failed_attempts, 5); + + let (_source, backup) = a_backup(); + let mut app = App::new(); + app.core.set_backup_to_open(backup).unwrap(); + let operation = app + .core + .begin_native(NativeAction::InspectBackup { recovery: true }) + .unwrap(); + assert_eq!(operation.limit_ms, 5 * 60_000); + app.core.cancel_native(operation.id); + let operation = app + .core + .begin_native(NativeAction::InspectBackup { recovery: false }) + .unwrap(); + assert_eq!(operation.limit_ms, 2 * 60_000); + drop(key); +} + +#[test] +fn an_entry_sent_at_its_last_moment_still_commits() { + let mut app = App::new(); + let operation = app + .core + .begin_native(NativeAction::CreateVault { + params: KdfParams::TESTING, + }) + .unwrap(); + app.advance(10 * 60_000 - 1); + let job = app + .core + .prepare_native(operation.id, native_new_password(PASSWORD)) + .unwrap(); + // Key derivation can take a while. The limit from the submit is two minutes. + app.advance(2 * 60_000 - 1); + let completion = job.run(); + assert!(matches!( + app.core.finish_native(completion), + Ok(NativeResult::RecoveryKit(_)) + )); + assert_eq!(app.phase(), "confirm_recovery_kit"); + + let mut app = App::new(); + let key = unlocked(&mut app); + app.core.lock(); + let operation = app + .core + .begin_native(NativeAction::Unlock { recovery: true }) + .unwrap(); + app.advance(5 * 60_000 - 1); + let job = app + .core + .prepare_native(operation.id, NativeCredentials::RecoveryKey(pw(&key))) + .unwrap(); app.advance(60_000); - native_run( + app.core.finish_native(job.run()).unwrap(); + assert_eq!(app.phase(), "needs_new_password"); + + // Past those two minutes nothing commits. + let mut app = App::new(); + let operation = app + .core + .begin_native(NativeAction::CreateVault { + params: KdfParams::TESTING, + }) + .unwrap(); + app.advance(60_000); + let job = app + .core + .prepare_native(operation.id, native_new_password(PASSWORD)) + .unwrap(); + app.advance(9 * 60_000); + assert_eq!(code(app.core.finish_native(job.run())), "invalid_input"); + assert_eq!(app.phase(), "no_vault"); + assert_eq!(app.core.app_status().failed_attempts, 0); +} + +#[test] +fn a_wallet_prompt_gets_no_extended_window() { + let mut app = App::new(); + unlocked(&mut app); + let n = sample_notes(&mut app); + let operation = app + .core + .begin_native(NativeAction::CopyField { + id: n.pk, + field: "key".into(), + }) + .unwrap(); + assert_eq!(operation.limit_ms, 2 * 60_000); + app.advance(2 * 60_000 - 1); + let job = app + .core + .prepare_native(operation.id, NativeCredentials::Password(pw(PASSWORD))) + .unwrap(); + app.advance(1); + assert_eq!(code(app.core.finish_native(job.run())), "invalid_input"); + assert!(app.clipboard().is_none()); + assert_eq!(app.core.app_status().failed_attempts, 0); +} + +#[test] +fn the_recovery_kit_step_gets_no_time_beyond_its_own() { + let mut app = App::new(); + let key = native_creation_kit(&mut app); + let operation = app + .core + .begin_native(NativeAction::ConfirmRecoveryKit) + .unwrap(); + assert_eq!(operation.limit_ms, 10 * 60_000); + for _ in 0..2 { + app.advance(4 * 60_000); + app.core.activity(); + } + app.advance(2 * 60_000 - 1); + let job = app + .core + .prepare_native( + operation.id, + NativeCredentials::KitConfirmation(pw(&last_groups(&key))), + ) + .unwrap(); + app.advance(1); + assert_eq!(code(app.core.finish_native(job.run())), "invalid_input"); + assert!(!vault_file(&app).exists()); +} + +const MSG_COPY_UNREADABLE_START: &str = "That file isn't a complete, readable Vault"; + +#[test] +fn a_file_that_cant_be_a_vault_is_refused_before_any_credential() { + for unlocked_first in [false, true] { + let mut app = App::new(); + if unlocked_first { + unlocked(&mut app); + } + let path = app.temp.path().join("fake-not-a-vault.enote"); + std::fs::write(&path, b"fake-not-a-vault").unwrap(); + app.core.set_backup_to_open(path).unwrap(); + let error = app + .core + .begin_native(NativeAction::InspectBackup { recovery: false }) + .err() + .unwrap(); + assert_eq!(error.code, "damaged"); + assert!(error.message.starts_with(MSG_COPY_UNREADABLE_START)); + assert!( + error + .message + .contains("This computer's Vault wasn't touched.") + ); + assert_eq!(app.core.native_pending_id(), None); + assert_eq!(app.core.app_status().failed_attempts, 0); + } +} + +#[test] +fn a_damaged_safety_copy_is_refused_the_same_way() { + let (mut app, next) = native_safety_preview_with_another_copy(); + let path = app.data_dir().join("safety").join(&next); + std::fs::write(&path, b"fake-not-a-vault").unwrap(); + let error = app + .core + .begin_native(NativeAction::InspectSafetyCopy { + id: next, + recovery: false, + }) + .err() + .unwrap(); + assert_eq!(error.code, "damaged"); + assert!(error.message.starts_with(MSG_COPY_UNREADABLE_START)); + assert_eq!(app.core.native_pending_id(), None); +} + +#[test] +fn a_flipped_byte_in_a_backup_is_reported_as_the_file_and_not_as_this_vault() { + let (_source, backup) = a_backup(); + let mut bytes = std::fs::read(&backup).unwrap(); + *bytes.last_mut().unwrap() ^= 1; + std::fs::write(&backup, bytes).unwrap(); + let mut app = App::new(); + app.core.set_backup_to_open(backup).unwrap(); + let error = native_run( &mut app.core, - NativeAction::Unlock { recovery: false }, + NativeAction::InspectBackup { recovery: false }, NativeCredentials::Password(pw(PASSWORD)), ) + .err() + .unwrap(); + assert_eq!(error.code, "damaged"); + assert!(error.message.starts_with(MSG_COPY_UNREADABLE_START)); + assert_eq!(app.core.app_status().failed_attempts, 0); +} + +#[test] +fn copying_an_ordinary_hidden_field_keeps_it_shown_and_other_actions_still_hide_it() { + let mut app = App::new(); + unlocked(&mut app); + let n = sample_notes(&mut app); + app.core.show_field(&n.login, "password").unwrap(); + let shown = app.core.app_status().reveal; + assert!(shown.is_some()); + native_run( + &mut app.core, + NativeAction::CopyField { + id: n.login.clone(), + field: "password".into(), + }, + NativeCredentials::None, + ) + .unwrap(); + assert_eq!(app.clipboard().as_deref(), Some("fake-login-pass")); + assert_eq!(app.core.app_status().reveal, shown); + + // A Wallet copy opens a panel, so it hides first. + let operation = app + .core + .begin_native(NativeAction::CopyField { + id: n.pk.clone(), + field: "key".into(), + }) + .unwrap(); + assert!(operation.requires_credentials); + assert!(app.core.app_status().reveal.is_none()); + app.core.cancel_native(operation.id); + + // So does anything else that goes through here. + app.core.show_field(&n.login, "password").unwrap(); + native_run( + &mut app.core, + NativeAction::UpdateNote { + id: n.login.clone(), + input: edit("Fake login", json!({"username": "fake-user"}), json!({})), + }, + NativeCredentials::None, + ) .unwrap(); + assert!(app.core.app_status().reveal.is_none()); +} + +#[test] +fn restoring_a_pre_rotation_safety_copy_brings_back_its_credentials() { + let mut app = App::new(); + let old_key = unlocked(&mut app); + let new_key = app + .core + .change_password(pw(PASSWORD), pw(NEW_PASSWORD), true) + .unwrap() + .recovery_key + .unwrap(); + app.core + .confirm_recovery_kit(&last_groups(&new_key)) + .unwrap(); + let copies = app.core.list_safety_copies().unwrap(); + app.core + .inspect_safety_copy(&copies[0].id, password(PASSWORD)) + .unwrap(); + app.core.restore_safety_copy().unwrap(); + app.core.lock(); + + assert_eq!( + code(app.core.unlock(password(NEW_PASSWORD))), + "wrong_credential" + ); + assert_eq!( + code(app.core.unlock(recovery(&new_key))), + "wrong_credential" + ); + app.core.unlock(password(PASSWORD)).unwrap(); + app.core.lock(); + app.core.unlock(recovery(&old_key)).unwrap(); + assert_eq!(app.phase(), "needs_new_password"); } #[test] diff --git a/src-tauri/tests/mac_typed_line.rs b/src-tauri/tests/mac_typed_line.rs new file mode 100644 index 0000000..de2c871 --- /dev/null +++ b/src-tauri/tests/mac_typed_line.rs @@ -0,0 +1,58 @@ +//! A line that holds typed text must not be readable through accessibility, where a secure box +//! gives nothing. AppKit views need the main thread and libtest never runs a test on it, so this +//! target has its own `main` (`harness = false` in Cargo.toml). Fake text only; no window opens. +//! An app reading another window on screen is the physical check in native-credential-design.md. +#![allow(unsafe_code)] + +#[cfg(not(target_os = "macos"))] +fn main() {} + +#[cfg(target_os = "macos")] +fn main() { + use encrypted_note_app::native_credential::typed_line; + use objc2::rc::Retained; + use objc2::runtime::AnyObject; + use objc2::{MainThreadOnly, msg_send}; + use objc2_app_kit::{NSTextField, NSView}; + use objc2_foundation::{MainThreadMarker, NSArray, NSPoint, NSRect, NSSize, NSString}; + + const FAKE: &str = "FAKE-TYPED-TEXT"; + let mtm = MainThreadMarker::new().expect("main runs on the main thread"); + let frame = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(400.0, 100.0)); + let fake = NSString::from_str(FAKE); + // What accessibility finds under this view. + let readable = |parent: &NSView| -> Vec { + // SAFETY: Documented NSAccessibility getters on live objects. accessibilityValue is an + // NSString for a label. + unsafe { + let children: Option>> = + msg_send![parent, accessibilityChildren]; + children.map_or_else(Vec::new, |children| { + children + .iter() + .filter_map(|child| { + let value: Option> = + msg_send![&*child, accessibilityValue]; + Some(Retained::cast_unchecked::(value?).to_string()) + }) + .collect() + }) + } + }; + + // A plain label shows that the walk can find text, so the check below can fail. + let plain_parent = NSView::initWithFrame(NSView::alloc(mtm), frame); + let plain = NSTextField::labelWithString(&fake, mtm); + plain_parent.addSubview(&plain); + assert_eq!(readable(&plain_parent), [FAKE]); + + let parent = NSView::initWithFrame(NSView::alloc(mtm), frame); + let line = typed_line(&parent, frame, mtm); + assert!(readable(&parent).is_empty(), "nothing typed yet"); + line.setStringValue(&fake); + assert_eq!(line.stringValue().to_string(), FAKE); + assert!( + readable(&parent).is_empty(), + "the typed line is readable through accessibility" + ); +} diff --git a/src-tauri/tests/quick_unlock.rs b/src-tauri/tests/quick_unlock.rs index 99fc0c1..3a84618 100644 --- a/src-tauri/tests/quick_unlock.rs +++ b/src-tauri/tests/quick_unlock.rs @@ -383,6 +383,43 @@ fn wrong_item_invalidates_registration_without_resetting_password_failures() { assert_eq!(app.core.app_status().phase, "locked"); } +#[test] +fn a_failed_touch_id_unlock_that_turned_it_off_says_so() { + let mut app = App::new(); + let _ = app.enroll(); + let mut other = App::new(); + let wrong_item = other.enroll(); + app.core.lock(); + let error = app.unlock_with(&wrong_item).unwrap_err(); + assert_eq!(error.code, "invalid_input"); + assert!( + error.message.contains("so it was turned off"), + "{}", + error.message + ); + assert!(!app.core.app_status().quick_unlock.enabled); +} + +#[test] +fn a_failed_touch_id_unlock_never_says_turned_off_when_the_settings_write_failed() { + let mut app = App::new(); + let _ = app.enroll(); + let mut other = App::new(); + let wrong_item = other.enroll(); + app.core.lock(); + let completion = app + .core + .prepare_quick_unlock() + .unwrap() + .open(item(&wrong_item)); + let settings = app.temp.path().join("Vault.noindex/settings.json"); + std::fs::remove_file(&settings).unwrap(); + std::fs::create_dir(&settings).unwrap(); + let error = app.core.finish_quick_unlock(completion).unwrap_err(); + assert_eq!(error.code, "io"); + assert!(!error.message.contains("turned off"), "{}", error.message); +} + #[test] fn biometric_session_preserves_the_password_attempt_count_and_delay() { let mut app = App::new(); diff --git a/src-tauri/tests/vault_reveal.rs b/src-tauri/tests/vault_reveal.rs new file mode 100644 index 0000000..c8bdbd4 --- /dev/null +++ b/src-tauri/tests/vault_reveal.rs @@ -0,0 +1,187 @@ +//! Show in Finder or File Explorer: when the core refuses it and what it points at. Public Rust +//! interfaces and fake values only; nothing here opens a file manager. + +use encrypted_note_app::core::native::{NativeAction, NativeCredentials, NativeResult}; +use encrypted_note_app::core::{ApiError, AppCore, Clipboard, Clock, Config, NoteInput}; +use std::{ + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; +use vault_format::{KdfParams, Limits}; +use zeroize::Zeroizing; + +const PASSWORD: &str = "fake-lantern-quartz-mosaic-velvet-orbit"; +const HOUR_MS: u64 = 60 * 60_000; + +fn text(value: &str) -> Zeroizing { + Zeroizing::new(value.to_owned()) +} + +struct FakeClock { + ms: Arc, +} +impl Clock for FakeClock { + fn now_ms(&self) -> u64 { + self.ms.load(Ordering::SeqCst) + } + fn unix_secs(&self) -> i64 { + 1_790_000_000 + (self.now_ms() / 1_000) as i64 + } + fn continuous_ms(&self) -> Option { + Some(self.now_ms()) + } +} +struct FakeClipboard; +impl Clipboard for FakeClipboard { + fn write(&mut self, _: &str) -> Result { + Ok(1) + } + fn clear_if(&mut self, _: u64) -> bool { + true + } +} +struct App { + core: AppCore, + _temp: tempfile::TempDir, + ms: Arc, +} +impl App { + /// A new fake Vault, Unlocked. + fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let ms = Arc::new(AtomicU64::new(1_000)); + let mut core = AppCore::new(Config { + data_dir: temp.path().join("Vault.noindex"), + home_dir: temp.path().to_owned(), + limits: Limits::TESTING, + clock: Box::new(FakeClock { ms: ms.clone() }), + clipboard: Box::new(FakeClipboard), + activity_anywhere_supported: false, + }) + .unwrap(); + core.set_quick_unlock_availability(true, None); + // The platform runtime consumes startup cleanup before accepting enrollment. + let _ = core.take_quick_unlock_cleanup(); + let key = core + .create_vault(text(PASSWORD), KdfParams::TESTING) + .unwrap() + .recovery_key + .unwrap(); + core.confirm_recovery_kit(&key[key.len() - 9..]).unwrap(); + Self { + core, + _temp: temp, + ms, + } + } + fn root(&self) -> PathBuf { + PathBuf::from(self.core.vault_location()) + } + fn refused_with(&mut self) -> &'static str { + self.core.vault_reveal_target().unwrap_err().code + } + fn assert_target_is_inside_the_vault_folder(&mut self) { + let target = self.core.vault_reveal_target().unwrap(); + assert!(target.starts_with(self.root()), "{}", target.display()); + } +} + +#[test] +fn the_target_is_inside_the_vault_folder() { + let mut app = App::new(); + let target = app.core.vault_reveal_target().unwrap(); + #[cfg(target_os = "macos")] + assert_eq!(target, app.root().join("vault.enote")); + #[cfg(not(target_os = "macos"))] + assert_eq!(target, app.root()); +} + +#[cfg(target_os = "macos")] +#[test] +fn a_link_in_place_of_the_vault_file_is_never_selected() { + let mut app = App::new(); + let vault = app.root().join("vault.enote"); + let elsewhere = app.root().with_file_name("fake-elsewhere"); + std::fs::rename(&vault, &elsewhere).unwrap(); + std::os::unix::fs::symlink(&elsewhere, &vault).unwrap(); + assert_eq!(app.core.vault_reveal_target().unwrap(), app.root()); +} + +#[test] +fn refused_when_locked() { + let mut app = App::new(); + app.core.lock(); + assert_eq!(app.refused_with(), "locked"); +} + +#[test] +fn refused_when_the_auto_lock_time_has_passed() { + let mut app = App::new(); + app.ms.fetch_add(24 * HOUR_MS, Ordering::SeqCst); + assert_eq!(app.refused_with(), "locked"); +} + +#[test] +fn refused_while_the_sharing_guard_is_up() { + let mut app = App::new(); + app.core.guard_update(&[guard::RunningApp { + bundle_id: Some("us.zoom.xos".into()), + exe_name: Some("Zoom.exe".into()), + }]); + assert_eq!(app.refused_with(), "sharing_guard_up"); + app.core.guard_update(&[]); + app.assert_target_is_inside_the_vault_folder(); +} + +#[test] +fn refused_while_a_quick_unlock_is_pending() { + let mut app = App::new(); + let operation = app + .core + .begin_native(NativeAction::EnrollQuickUnlock) + .unwrap(); + let job = app + .core + .prepare_native(operation.id, NativeCredentials::Password(text(PASSWORD))) + .unwrap(); + let NativeResult::QuickUnlockEnrollment(_enrollment) = + app.core.finish_native(job.run()).unwrap() + else { + panic!("expected enrollment"); + }; + assert!(app.core.quick_unlock_pending()); + assert_eq!(app.refused_with(), "invalid_input"); + app.core.cancel_all_native(); + app.assert_target_is_inside_the_vault_folder(); +} + +#[test] +fn refused_while_a_native_operation_is_pending() { + let mut app = App::new(); + let operation = app + .core + .begin_native(NativeAction::ChangePassword { rotate: false }) + .unwrap(); + assert_eq!(app.refused_with(), "invalid_input"); + app.core.cancel_native(operation.id); + app.assert_target_is_inside_the_vault_folder(); +} + +#[test] +fn refused_while_a_value_is_shown() { + let mut app = App::new(); + let input: NoteInput = serde_json::from_value(serde_json::json!({ + "kind": "api_key", "title": "Fake reveal key", + "visible": {"env_var": "FAKE_REVEAL_KEY"}, + "hidden": {"key": "fake-reveal-key-value"} + })) + .unwrap(); + let id = app.core.create_note(input, None).unwrap(); + app.core.show_field(&id, "key").unwrap(); + assert_eq!(app.refused_with(), "invalid_input"); + app.core.hide_field(); + app.assert_target_is_inside_the_vault_folder(); +} diff --git a/src/App.tsx b/src/App.tsx index b19c88c..44c08ac 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,10 @@ import { useEffect, useRef, useState } from "react"; -import { activity, appStatus, cancelNativeOperation, dismissSharingGuard, lock, onStatusChanged, type AppStatus } from "./api"; +import { activity, appStatus, cancelNativeOperation, dismissSharingGuard, isApiError, lock, onStatusChanged, type AppStatus } from "./api"; import { cmdKey, ErrorLine, mac, TitlebarDrag, useAction } from "./components"; import { Home, START, type Place } from "./Home"; import { Icon } from "./icons"; import type { Draft } from "./Note"; -import { Locked, NativeRecoveryKit, NewPasswordScreen, NoVault } from "./Start"; +import { Locked, NativeRecoveryKit, NewPasswordScreen, NoVault, type SetupStep } from "./Start"; import { strings as s } from "./strings"; export function App() { @@ -22,6 +22,10 @@ export function App() { // that is already drawn starts again instead of keeping the place it was given. const [restarts, setRestarts] = useState(0); const lockedOnPurpose = useRef(false); + // Whether the Recovery Key step was drawn since the last command that could reach it reported. + const kitSeen = useRef(false); + // Where first run goes back to when NoVault is drawn again. Never the open dialog. + const setupStep = useRef("welcome"); const phase = status?.phase; const forgetPlace = () => setPlace((p) => ({ ...START, sort: p.sort })); // A Backup or Safety Copy was installed, or the password changed: start afresh. @@ -38,6 +42,20 @@ export function App() { lockedOnPurpose.current = false; }); }; + // Told by the command that creates a Vault or changes keys, once it has answered. The result + // comes only from there: a phase change can't say it, because the same jump to Locked can + // follow a save that worked, and then this message would throw away a key that works. + const afterKit = (notSaved: string, e?: unknown) => { + const shown = kitSeen.current; + kitSeen.current = false; + if (!shown) return; + if (e === undefined) { + setOperationOutcome(null); + return; + } + const cause = isApiError(e) && e.code !== "cancelled" && e.code !== "interrupted" ? `${e.message} ` : ""; + setOperationOutcome(cause + notSaved); + }; useEffect(() => { // Rust pushes every change; polling each second also keeps the countdowns ticking. A poll @@ -72,10 +90,17 @@ export function App() { }, []); useEffect(() => { + if (phase === "confirm_recovery_kit") { + kitSeen.current = true; + setOperationOutcome(null); + setupStep.current = "create"; + } else if (phase !== "no_vault") setupStep.current = "welcome"; // The place survives an Auto-lock and an app-switch lock, but not a lock the owner asked for, // nor a Vault that is new, was opened with the Recovery Key or is getting new keys. if (phase === "locked" ? lockedOnPurpose.current : phase !== "unlocked") forgetPlace(); lockedOnPurpose.current = false; + // lockNow has already emptied the draft, so a lock the owner asked for stays quiet here. + if (phase === "locked" && draft.current) setOperationOutcome((m) => (m ? `${m} ${s.lockedDraftLost}` : s.lockedDraftLost)); // A draft survives the Shield only: never a lock of any kind, nor any other way out of Home. if (phase !== "unlocked") draft.current = null; // Cmd/Ctrl+L works whenever the Vault is open. @@ -91,22 +116,42 @@ export function App() { return () => window.removeEventListener("keydown", onKey); }, [phase]); + const lockEvents = status?.lock_events_unavailable ?? []; + // Kept for after the Shield: it can say what the owner was doing, and the Shield is on screen + // because something may be capturing it. + const outcomeNow = status?.sharing_guard.up ? null : operationOutcome; + const notices = status?.save_durability_uncertain || status?.settings_unreadable || lockEvents.length > 0 || outcomeNow; + // Home draws its own drag areas over this strip. return (
- {status?.save_durability_uncertain &&

{s.saveDurabilityWarning}

} - {status?.settings_unreadable &&

{s.settingsUnreadable}

} - {status && status.lock_events_unavailable.length > 0 && ( -

{s.lockEventsUnavailable(status.lock_events_unavailable.map((event) => s.lockEventNames[event]).join(", "))}

+ {notices && ( +
+ {status?.save_durability_uncertain &&

{s.saveDurabilityWarning}

} + {status?.settings_unreadable &&

{s.settingsUnreadable}

} + {lockEvents.length > 0 && ( +

{s.lockEventsUnavailable(lockEvents.map((event) => s.lockEventNames[event]).join(", "))}

+ )} + {outcomeNow &&

{outcomeNow}

} +
)} - {operationOutcome &&

{operationOutcome}

} + {/* Says a sudden lock or Shield to a screen reader. Fixed text; always here, because a status + line is only read out when it changes. */} +

+ {status?.sharing_guard.up ? s.shieldTitle : phase === "locked" ? s.vaultLockedAnnounce : null} +

{status && ( { + setupStep.current = step; + }} + afterKit={afterKit} // A reader, not the draft itself: a prop would go on holding the draft after a lock // has emptied the ref. readDraft={() => draft.current} @@ -128,12 +173,17 @@ export function App() { ); } -function Screen({ status, place, onPlace, restarts, readDraft, onDraft, onLock, onOperationOutcome, onOutcomeOutside }: { +function Screen({ status, place, onPlace, restarts, setupStep, onSetupStep, afterKit, readDraft, onDraft, onLock, onOperationOutcome, onOutcomeOutside }: { status: AppStatus; place: Place; onPlace: (place: Place) => void; /** Keys Home: when it changes, Home starts again from `place`. */ restarts: number; + /** Where NoVault starts when it appears, and how it says where it is now. */ + setupStep: SetupStep; + onSetupStep: (step: SetupStep) => void; + /** Says how a Recovery Key step ended. Not an outcome: it moves nobody and restarts nothing. */ + afterKit: (notSaved: string, e?: unknown) => void; /** Read by Home once, when it appears; see Home. */ readDraft: () => Draft | null; onDraft: (draft: Draft | null) => void; @@ -152,7 +202,7 @@ function Screen({ status, place, onPlace, restarts, readDraft, onDraft, onLock, if (shield) return ; switch (status.phase) { case "no_vault": - return ; + return ; case "confirm_recovery_kit": return ; case "locked": @@ -170,6 +220,7 @@ function Screen({ status, place, onPlace, restarts, readDraft, onDraft, onLock, onDraft={onDraft} onLock={onLock} onOperationOutcome={onOperationOutcome} + afterKit={afterKit} /> ); } @@ -187,6 +238,9 @@ function Shield({ apps, afterUnlock }: { apps: string[]; afterUnlock: boolean })

{s.shieldTitle}

{s.shieldApps(apps.join(", "))}

{s.shieldText}

+ {/* One fixed sentence: text that changed with the phase or a draft could show, in a captured + frame, that a half-typed Note exists. */} +

{s.shieldClosed}

+ + ) : ( +
+

{s.emptyKind(s.kindPlurals[nav.kind])}

+ +
+ ); + else if (nav.type === "favorites" && nothingListed) detail =

{s.noFavorites}

; + else if (notes?.length) detail =

{s.pickNote}

; const markedNote = marked ? shown.find((n) => n.id === marked) : undefined; // Counted against today's date at every draw (the status redraws this screen each second), so @@ -497,11 +549,26 @@ export function Home({ status, place, onPlace, readDraft, onDraft, onLock, onOpe )} + {/* Out here, not in the Editor's form: a click can't submit it, and it can't scroll away. The + time stays away from a screen reader, or it would be read out every second. */} + {(pane?.type === "new" || pane?.type === "edit") && + dirty.current && + status.lock_in_ms !== null && + status.lock_in_ms <= Math.min(60000, status.settings.idle_minutes * 15000) && ( +
+ + {s.lockSoonDraft} + + +
+ )} {status.backup.reminder_due && nav.type !== "settings" && (
{s.backupReminder} - +
)} {(expiredKeys > 0 || expiringKeys > 0) && nav.type !== "settings" && ( @@ -513,7 +580,13 @@ export function Home({ status, place, onPlace, readDraft, onDraft, onLock, onOpe )} {status.purge_trash_failed &&
{s.trashPurgeFailed}
} {nav.type === "settings" ? ( - + setSettingsTarget(null)} + /> ) : nav.type === "trash" ? ( - {notes?.length === 0 && (query || nav.type !== "all") &&

{query ? s.noMatches : s.nothingHere}

} + {notes?.length === 0 && query &&

{s.noMatches}

}
{detail}
)}
- {s.statusLine} + {s.statusEncrypted} + {" · "} + {nav.type !== "settings" ? ( + + ) : ( + s.statusSaved + )} + {" · "} + {s.statusOffline} {status.lock_in_ms !== null && ` · ${s.locksIn(minSec(status.lock_in_ms), !!status.settings.count_activity_anywhere)}`} {" · "} {unbacked && nav.type !== "settings" ? ( - ) : ( @@ -593,7 +676,7 @@ export function Home({ status, place, onPlace, readDraft, onDraft, onLock, onOpe )} - {capture ? s.captureOn(status.platform === "mac") : capture === false ? s.captureOff : s.captureUnknown} + {capture ? s.captureOn : capture === false ? s.captureOff : s.captureUnknown}
@@ -632,6 +715,25 @@ export function Home({ status, place, onPlace, readDraft, onDraft, onLock, onOpe ); } +/** After Move to Trash: the way straight back. Like Restore in Trash, it asks for no password. */ +function Trashed({ id, onRestored }: { id: string; onRestored: () => void }) { + const { busy, error, run } = useAction(); + const restore = () => + run(async () => { + await restoreNote(id); + onRestored(); + }); + return ( +
+

{s.movedToTrash}

+ + +
+ ); +} + /** The Notes in Trash, or one of them opened to read (`openId`): Show and Copy work there, so a * key can be read without restoring the Note, which would restart its 30 days. */ function Trash(props: { @@ -694,6 +796,10 @@ function Trash(props: { }); // A key still held down from the press that moved the keyboard here doesn't press these too. const noRepeat = (e: KeyEvent) => e.repeat && e.preventDefault(); + // What the question on screen is about. The native panel can't list titles, so Empty Trash with + // a Wallet Kind in it names them in the question. + const removing = notes?.filter((n) => !confirm || n.id === confirm) ?? []; + const removesWallet = removing.some((n) => isWalletKind(n.kind)); return (
{openId ? ( @@ -768,12 +874,11 @@ function Trash(props: { {confirm !== null && ( n.title)) : s.emptyTrashText} confirmLabel={confirm ? s.deleteForever : s.emptyTrash} onConfirm={() => { setConfirm(null); - const removing = notes?.filter((n) => !confirm || n.id === confirm) ?? []; - if (removing.some((n) => isWalletKind(n.kind))) setAskPassword({ id: confirm, titles: removing.map((n) => n.title) }); + if (removesWallet) setAskPassword({ id: confirm, titles: removing.map((n) => n.title) }); else act(() => (confirm ? deleteForever(confirm) : emptyTrash()), confirm); }} onClose={() => setConfirm(null)} @@ -783,7 +888,6 @@ function Trash(props: { { await (askPassword.id ? deleteForever(askPassword.id) : emptyTrash()); props.onChanged(); diff --git a/src/Note.tsx b/src/Note.tsx index db7008c..b7aef94 100644 --- a/src/Note.tsx +++ b/src/Note.tsx @@ -1,6 +1,6 @@ // One Note: the detail view (Show / Copy of Hidden Fields) and the editor for every Kind. -import { Fragment, useEffect, useId, useRef, useState, type ClipboardEvent as PasteEvent, type FormEvent } from "react"; +import { Fragment, useEffect, useId, useRef, useState, type ClipboardEvent as PasteEvent, type FormEvent, type KeyboardEvent as KeyEvent } from "react"; import { appendText, cancelNativeOperation, @@ -37,12 +37,21 @@ import { strings as s } from "./strings"; const FORM: Record = { seed_phrase: ["word_count", "words", "wallet_name", "address", "derivation_path", "passphrase"], private_key: ["chain", "address", "key"], - login: ["website", "username", "purpose", "password"], - api_key: ["service", "key_id", "purpose", "url", "expires", "env_var", "key", "secret"], + login: ["website", "username", "password", "purpose"], + api_key: ["service", "key", "secret", "key_id", "purpose", "url", "expires", "env_var"], text: ["body"], }; const HIDDEN = ["words", "passphrase", "key", "secret", "password", "body"]; const OPTIONAL = ["wallet_name", "passphrase", "address", "derivation_path", "username", "purpose", "key_id", "url", "expires", "env_var", "secret"]; +// The fields the Editor folds under one line, in FORM's order. FORM still lists them. +const MORE: Partial> = { + seed_phrase: ["address", "derivation_path", "passphrase"], + api_key: ["secret", "key_id", "purpose", "url", "expires", "env_var"], +}; +// The Hidden Fields whose typed text the Editor can show (ADR-0006). Never a Wallet Kind. +const SHOWN_WHILE_TYPING: Partial> = { login: ["password"], api_key: ["key", "secret"] }; +// From the press. Typing doesn't extend it. +const SHOWN_WHILE_TYPING_MS = 30_000; // The Hidden Field a Note of the Kind is for. (A Seed Phrase can't be saved without its words.) const MAIN: Partial> = { private_key: "key", login: "password", api_key: "key", text: "body" }; // The field a new Note's title is taken from when the title was left blank. @@ -85,6 +94,33 @@ const seedLike = (text: string, list: string[]) => { const words = text.trim().toLowerCase().split(/\s+/); return (words.length === 12 || words.length === 24) && words.every((w) => list.includes(w)); }; +/** A date with single digits or slashes, written the way the core stores it: 2027/1/5 becomes + * 2027-01-05. Anything else stays as typed. So do spaces alone: the core trims them, and a stored + * value sent back as it is means "keep". */ +const fixExpiry = (value: string) => { + const m = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/.exec(value.trim()); + const fixed = m && `${m[1]}-${m[2]!.padStart(2, "0")}-${m[3]!.padStart(2, "0")}`; + return fixed && fixed !== value.trim() ? fixed : value; +}; +/** The same rule as is_calendar_date in crates/notes/src/rules.rs. */ +const isCalendarDate = (value: string) => { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!m) return false; + const year = Number(m[1]); + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][Number(m[2]) - 1]; + return days !== undefined && Number(m[3]) >= 1 && Number(m[3]) <= days; +}; +/** What crates/notes/src/rules.rs would refuse in a visible field, said before Save. Fixed text, + * never the value. It never stops a save: the core stays the authority. */ +const visibleProblem = (f: string, value: string) => { + const v = value.trim(); + if (!v) return null; + if (f === "expires") return isCalendarDate(fixExpiry(v)) ? null : s.expiryProblem; + if (f === "env_var") return /^[A-Za-z_][A-Za-z0-9_]*$/.test(v) && v.length <= 128 ? null : s.envVarProblem; + if (f === "purpose") return [...v].length > 200 ? s.purposeProblem : null; + return null; +}; /** What a duplicate's Editor starts from: a title and visible fields, never a Hidden Field. */ export interface Prefill { @@ -121,6 +157,11 @@ function useShown(noteId: string, status: AppStatus) { const generation = useRef(0); // Whether the last status named a reveal of this Note. const revealed = useRef(false); + // Whether a value came to this page. Set in keep(), not after a render, so a blur right after a + // Show still ends it. It stays false for a Wallet Kind, whose values never enter the page: a + // blur there is its own panel opening, and hideField would cancel that panel. Real focus loss + // cancels native work in Rust. + const held = useRef(false); useEffect(() => { const r = status.reveal; const mine = r?.note_id === noteId; @@ -135,7 +176,10 @@ function useShown(noteId: string, status: AppStatus) { const drop = () => { generation.current++; setShown(null); - hideField().catch(() => {}); + if (held.current) { + held.current = false; + hideField().catch(() => {}); + } }; window.addEventListener("blur", drop); return () => { @@ -149,6 +193,7 @@ function useShown(noteId: string, status: AppStatus) { version: () => ++generation.current, keep: (field: string, value: string, version = generation.current) => { if (version === generation.current && document.hasFocus()) { + held.current = true; setShown({ field, value }); return true; } @@ -156,6 +201,7 @@ function useShown(noteId: string, status: AppStatus) { return false; }, hide: () => { + held.current = false; generation.current++; setShown(null); hideField().catch(() => {}); @@ -219,7 +265,8 @@ export function Detail( /** A new Note of the same Kind, started from this one's title and visible fields. */ onDuplicate: (kind: Kind, seed: Prefill) => void; onChanged: () => void; - onTrashed: () => void; + /** `id`: the Note that went to Trash, so the result can offer to bring it back. */ + onTrashed: (id: string) => void; } /** A Note in Trash: Show and Copy under the usual rules, nothing that changes it. */ | { readOnly: true } @@ -460,7 +507,7 @@ export function Detail( - + @@ -593,12 +638,21 @@ export function Editor(props: { const [warning, setWarning] = useState(null); const [addressIsKey, setAddressIsKey] = useState(false); const [pasteOverflow, setPasteOverflow] = useState(false); + const [moreOpen, setMoreOpen] = useState(false); + // Visible boxes that have had their say: the keyboard left them, or a save was tried. + const [checked, setChecked] = useState([]); + // What a Move button last did: the box it emptied and where the value went. + const [moved, setMoved] = useState<{ from: string; to: string } | null>(null); + // The Hidden Field whose typed text is shown under its box. Not in Draft, on purpose. + const [peek, setPeek] = useState(null); const [step, setStep] = useState<"explainer" | "password" | null>(null); // Save and add another: what the save under way does once it has worked. const another = useRef(false); // The chip that stands in for the key box while it holds a pasted multi-line key. After Clear // the box comes back with the keyboard in it. const chips = useRef>({}); + // The Title and visible boxes: a Move button puts the keyboard back in the box it emptied. + const visibleBoxes = useRef>({}); const pastedField = useRef(null); const [clearedField, setClearedField] = useState(null); // "Saved" is filled in after the first render: a status line is read out when its text changes, @@ -625,13 +679,35 @@ export function Editor(props: { // Only the listed chains have a key format an address can be compared with. const address = kind === "private_key" && chain !== "other" ? (visible.address ?? "").trim() : ""; const wordsOk = kind !== "seed_phrase" || phrase !== "" || (!!note && words.every((w) => !w.trim())); - const missing = !title.trim() ? s.addTitle : wordsOk ? null : s.fillWords(words.length, !!note); + // Whether a Service or Website can name a new Note. Not a value shaped like a key or a Seed + // Phrase, and not before the word list that tells has arrived. + const canTitle = (v: string) => !note && !!v && list.length > 0 && !keyLike(v) && !seedLike(v, list); + const titleSource = () => { + const f = TITLE_FROM[kind]; + const v = f ? (visible[f] ?? "").trim() : ""; + return canTitle(v) ? v : ""; + }; + // The title a save sends: a blank Title takes its name from the Service or Website. + const finalTitle = title.trim() || titleSource(); + const titleField = note ? undefined : TITLE_FROM[kind]; + const missing = !finalTitle + ? titleField && !(visible[titleField] ?? "").trim() + ? s.addTitleOr(label(kind, titleField)) + : s.addTitle + : wordsOk + ? null + : s.fillWords(words.length, !!note); const canSave = !busy && !missing && !pasteOverflow; const main = note ? undefined : MAIN[kind]; // Whether a Hidden Field holds something typed. const typed = (f: string) => (f === "words" ? words.some(Boolean) : !!hidden[f]); // Hidden Fields of a Wallet Kind that the draft came back without and that are still blank. const again = (draft?.again ?? []).filter((f) => !typed(f) && !clear[f]); + const more = MORE[kind] ?? []; + // The fold can't close over something Save will write or remove, or the box that has the keyboard. + const mustOpen = more.some((f) => visible[f]?.trim() || typed(f) || filled(f) || clear[f] || again.includes(f) || f === props.focusField || f === clearedField); + // Only text typed here, on one line, in a box not ticked for removal. Never a stored value. + const peekable =(f: string) => !!SHOWN_WHILE_TYPING[kind]?.includes(f) && !!hidden[f] && !clear[f] && lineCount(hidden[f]) < 2; useEffect(() => { // A duplicate, or a draft that came back, holds something unsaved from the start, so leaving @@ -666,6 +742,29 @@ export function Editor(props: { pastedField.current = null; }, [hidden.key, hidden.secret]); + // Once open it stays open, so deleting the last character can't close it under the keyboard. + useEffect(() => { + if (mustOpen) setMoreOpen(true); + }, [mustOpen]); + + // Typed text under its box goes on window blur and 30 seconds after the press. Its button then + // gives up the keyboard: left there, a stray Space later would show the text again. + useEffect(() => { + if (!peek) return; + const end = () => setPeek(null); + const timer = setTimeout(end, SHOWN_WHILE_TYPING_MS); + window.addEventListener("blur", end); + return () => { + clearTimeout(timer); + window.removeEventListener("blur", end); + const at = document.activeElement; + if (at instanceof HTMLElement && at.dataset.peek === peek) at.blur(); + }; + }, [peek]); + useEffect(() => { + if (peek && !peekable(peek)) setPeek(null); + }, [peek, hidden, clear]); + // The address is always visible. No hint from the key check means it is shaped like a key. useEffect(() => { if (!address) return setAddressIsKey(false); @@ -707,11 +806,11 @@ export function Editor(props: { if (note && add) { // Only what was typed is sent. The title goes first: saved a second time after a failure // it changes nothing, where text added a second time would be there twice. - if (title !== note.title) await updateNote(note.id, { title, visible: {}, hidden: {} }); + if (finalTitle !== note.title) await updateNote(note.id, { title: finalTitle, visible: {}, hidden: {} }); if (hidden.body) await appendText(note.id, hidden.body); return props.onSaved(note.id); } - const input = { title, visible: {} as Record, hidden: {} as Record }; + const input = { title: finalTitle, visible: {} as Record, hidden: {} as Record }; for (const f of FORM[kind]) { if (f === "words") { if (phrase) input.hidden.words = phrase; @@ -720,6 +819,8 @@ export function Editor(props: { if (clear[f]) input.hidden[f] = ""; else if (hidden[f]) input.hidden[f] = hidden[f]; } else if (f === "chain") input.visible.chain = chain === "other" ? chainName.trim() || "other" : chain; + // Here as well as on blur: Cmd/Ctrl+S and Enter never leave the box. + else if (f === "expires") input.visible[f] = fixExpiry(visible[f] ?? ""); else if (f !== "word_count") input.visible[f] = visible[f] ?? ""; } if (note) await updateNote(note.id, input); @@ -732,6 +833,7 @@ export function Editor(props: { // Wallet Kinds ask for the Master Password on every save, "Save and add another" (`again`) too. function begin(again: boolean) { + setChecked((c) => c.concat(FORM[kind].filter((f) => visibleProblem(f, visible[f] ?? "")))); another.current = again; if (kind === "seed_phrase" && !props.status.settings.seed_explainer_seen) setStep("explainer"); else if (isWalletKind(kind)) setStep("password"); @@ -741,17 +843,39 @@ export function Editor(props: { e.preventDefault(); begin(false); } + // In a new Note whose main Hidden Field is still blank, Enter in a box goes on to the next box + // instead of saving too early; after that it saves as usual. It never goes from a password box + // to one that shows what is typed, so a key can't be carried into plain sight. + const enterGoesOn = (e: KeyEvent) => { + if (e.key !== "Enter" || e.metaKey || e.ctrlKey || e.defaultPrevented || e.nativeEvent.isComposing || e.keyCode === 229) return; + const at = e.target; + if (!(at instanceof HTMLInputElement) || !main || hidden[main]) return; + e.preventDefault(); + if (e.repeat) return; + const all = [...e.currentTarget.querySelectorAll("input, textarea")]; + const next = all.slice(all.indexOf(at) + 1).find((b) => !b.disabled && b.offsetParent !== null && b.type !== "radio" && b.type !== "checkbox"); + if (next && (at.type !== "password" || next.type === "password")) next.focus(); + }; // Cmd/Ctrl+S and Cmd/Ctrl+Enter save, as the Save button does (in a Text body Enter is a new // line). The keys do nothing else here, also while Save is off: never the browser's own Save. // Heard on the window for as long as the Editor is there, not on the form: the keyboard is not // always inside it (after a click on blank space, or on a checkbox or radio in WebKit). The - // handler is set up once and reads what the Editor holds now through the ref. + // handler is set up once and reads what the Editor holds now through the refs. const saveNow = useRef(() => {}); saveNow.current = () => { if (canSave) begin(false); }; + const cancelNow = useRef(() => {}); + cancelNow.current = props.onCancel; useEffect(() => { const onKey = (e: KeyboardEvent) => { + // Escape leaves the Editor: not a held key, an input method's, or one meant for a dialog or + // the search box. + if (e.key === "Escape") { + if (e.repeat || e.isComposing || e.keyCode === 229) return; + if (document.querySelector("[role=dialog]") || document.activeElement?.matches("[data-search]")) return; + return cancelNow.current(); + } if (!cmdKey(e) || e.shiftKey || (e.key.toLowerCase() !== "s" && e.key !== "Enter")) return; if (document.querySelector("[role=dialog]")) return; e.preventDefault(); @@ -774,26 +898,262 @@ export function Editor(props: { // title box, where it can still be changed. Not a value that looks like a key or a Seed Phrase. const titleFrom = (value: string) => { const v = value.trim(); - if (!note && !title.trim() && v && !keyLike(v) && !seedLike(v, list)) setTitle(v); + if (!title.trim() && canTitle(v)) setTitle(v); }; // A paste-first slip puts a key or a Seed Phrase into a field that is always visible and - // searchable. The line names the field, never what is in it. - const slip = (f: string, value: string) => { + // searchable. The line names the field, never what is in it. `to`: the Hidden Field a button can + // move it to, where that is its obvious place and nothing there would be overwritten. + const slip = (f: string, value: string): { text: string; to: string | null } | null => { const name = f === "title" ? s.title : label(kind, f); - const text = - (f === "title" || f === "wallet_name") && seedLike(value, list) - ? s.looksLikeSeed(name) - : (f === "title" || f === "service" || f === "username") && keyLike(value) - ? s.looksLikeKey(name) - : f === "address" && address && addressIsKey - ? s.looksLikePrivateKey(name) - : null; - return text && {text}; + if (seedLike(value, list)) { + // Never into a field that isn't a Wallet Kind's. + if (kind !== "seed_phrase") return { text: s.looksLikeSeedManual(name), to: null }; + const free = (f === "title" || f === "wallet_name") && !filled("words") && words.every((w) => !w.trim()); + return { text: s.looksLikeSeed(name), to: free ? "words" : null }; + } + if (!(f === "address" ? address && addressIsKey : keyLike(value))) return null; + const target = MAIN[kind]; + if (!target) return { text: s.looksLikeKey(name), to: null }; + const free = ["title", "service", "username", "address"].includes(f) && !hidden[target] && !filled(target) && !clear[target]; + if (!free) return { text: s.looksLikeKeyManual(name, label(kind, target)), to: null }; + return { text: f === "address" ? s.looksLikePrivateKey(name) : s.looksLikeKey(name), to: target }; + }; + const move = (f: string, value: string, to: string) => { + if (to === "words") { + // Not through the grid's own paste: this sets the word count too, and is never too long. + setWords(value.trim().toLowerCase().split(/\s+/)); + setPasteOverflow(false); + } else setHidden({ ...hidden, [to]: value.trim() }); + if (f === "title") setTitle(""); + else setVisible({ ...visible, [f]: "" }); + setMoved({ from: f, to }); + props.onDirty(); + visibleBoxes.current[f]?.focus(); + }; + // Under a visible box, never inside its Field, which is a label: the Move button and what it did. + // The status line is there before its text, so that the text is read out when it comes. + const moveLine = (f: string, value: string, found: { to: string | null } | null) => { + const to = found?.to; + return ( + <> + {to && ( + + )} + {(to || moved?.from === f) && ( +

+ {moved?.from === f && (moved.to === "words" ? s.movedToWords(!finalTitle) : s.movedToBox(label(kind, moved.to), !finalTitle))} +

+ )} + + ); + }; + const titleSlip = slip("title", title); + + const fieldBox = (f: string) => { + const name = OPTIONAL.includes(f) ? `${label(kind, f)} ${s.optional}` : label(kind, f); + if (f === "word_count") + return ( +
+ setWords((ws) => Array.from({ length: n }, (_, i) => ws[i] ?? ""))} + /> + +
+ ); + if (f === "words") return setPasteOverflow(true)} onAccepted={() => setPasteOverflow(false)} />; + if (f === "chain") + return ( + + setVisible({ ...visible, chain: c })} /> + {chain === "other" && ( + + setChainName(e.target.value)} + // An obvious name for a listed chain chooses that chain. + onBlur={() => { + const listed = CHAIN_NAMES.get(chainName.trim().toLowerCase()); + if (!listed) return; + setVisible({ ...visible, chain: listed }); + setChainName(""); + }} + {...noAssist} + /> + + )} + + ); + if (!HIDDEN.includes(f)) { + const shownValue = visible[f] ?? ""; + const found = slip(f, shownValue); + const problem = checked.includes(f) ? visibleProblem(f, shownValue) : null; + return ( + // A plain box, not only the Field: moveLine goes beside the label, and the box stays the + // same one when a button comes, so the input keeps the keyboard. +
+ + {found && {found.text}} + {problem && {problem}} + + } + hint={s.hints[kind][f]} + > + { + visibleBoxes.current[f] = el; + }} + value={shownValue} + placeholder={f === "expires" ? s.expiryPlaceholder : undefined} + onChange={(e) => { + setVisible({ ...visible, [f]: e.target.value }); + if (moved?.from === f) setMoved(null); + }} + onBlur={() => { + if (f === TITLE_FROM[kind]) titleFrom(shownValue); + if (f === "expires" && fixExpiry(shownValue) !== shownValue) setVisible({ ...visible, [f]: fixExpiry(shownValue) }); + setChecked((c) => (c.includes(f) ? c : [...c, f])); + }} + {...noAssist} + /> + + {moveLine(f, shownValue, found)} +
+ ); + } + const value = hidden[f] ?? ""; + const had = filled(f); + // The text of a Text, in Add mode: what is typed joins what is there, so there is + // nothing to keep by leaving the box blank and nothing to remove. + const adds = f === "body" && add; + const edgeSpace = /^\s|\s$/.test(value); + const inputProps = { + value, + // Only a field that has a value has one to keep. + placeholder: adds ? s.typeToAdd : had && !clear[f] ? s.leaveBlankToKeep : "", + disabled: !!clear[f] && !adds, + onChange: (e: { target: { value: string } }) => setHidden({ ...hidden, [f]: e.target.value }), + autoFocus: f === props.focusField || f === clearedField, + }; + const peeking = peek === f && peekable(f); + return ( + // Field is a label, and so is the checkbox: they sit one under the other in a plain box. +
+ {f === "body" && canAdd && ( + { + setAdding(mode === "add"); + if (mode === "add") setClear({ ...clear, body: false }); + }} + /> + )} + {(f === "key" || f === "secret") && lineCount(value) > 1 ? ( + // A group, not a Field: a click on a label's text would press the button in it. +
+ + {name} + +
{ chips.current[f] = el; }} tabIndex={-1}> + {s.multilineKey(lineCount(value), [...value].length)} + +
+
+ ) : ( + + {/* The core trims a one-line key or secret, and keeps every other value as typed. */} + {value && ( + + {edgeSpace && (f === "key" || f === "secret") ? s.editorEdgeSpace([...value].length) : s.typedCount([...value].length, edgeSpace, f === "body")} + + )} + {adds && had && {s.addsText}} + {value && f === "body" && had && !adds && {s.replacesText}} + + ) + } + > + {f === "body" ? ( +