From 3d4d4d6a03b63233fe303f8ae8fd4b62900e0fa1 Mon Sep 17 00:00:00 2001 From: Aakash N S <1560745+aakashns@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:12:43 +0700 Subject: [PATCH 1/3] Unlock with Touch ID on Macs On a Mac with Touch ID, turn on "Touch ID" in Settings and writui asks for your fingerprint when it starts, instead of the password. After locking with Ctrl+O, press Enter (or click "Touch ID") to unlock with a fingerprint. The password always works too: type it, or cancel the fingerprint prompt. Touch ID applies to this Mac and this vault only, and keeps working across upgrades without asking for your Mac's password. Your vault's key is sealed with a key in the Mac's Secure Enclave, which only opens it after a fingerprint, and kept in ~/.config/writui/touch-id/. Adding a new fingerprint to the Mac stops it working until you next unlock with the password, which sets it up again. Unlocking with Touch ID is also instant, as the password no longer needs to be checked. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 27 +++- Cargo.toml | 10 +- README.md | 10 +- docs/DECISIONS.md | 40 +++++ docs/PLAN.md | 4 +- docs/SPEC.md | 16 +- site/content/_index.md | 3 +- src/config.rs | 7 + src/main.rs | 1 + src/touch_id.rs | 326 +++++++++++++++++++++++++++++++++++++++++ src/tui/mod.rs | 87 ++++++++++- src/tui/settings.rs | 77 +++++++--- src/tui/unlock.rs | 139 ++++++++++++++++-- src/vault/mod.rs | 52 +++++-- 14 files changed, 751 insertions(+), 48 deletions(-) create mode 100644 src/touch_id.rs diff --git a/Cargo.lock b/Cargo.lock index 77e4a0c..88e86bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1433,6 +1433,26 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-local-authentication" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48e0b8b339e0d9d2ed4416b7f93f9d4daadff7d4dd797f89867cde11aeac607" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2917,7 +2937,7 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "writui" -version = "0.17.0" +version = "0.18.0" dependencies = [ "anyhow", "arboard", @@ -2925,6 +2945,11 @@ dependencies = [ "clap", "dirs", "jiff", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-local-authentication", + "objc2-security", "pulldown-cmark", "rand 0.10.3", "ratatui", diff --git a/Cargo.toml b/Cargo.toml index 5e05428..3274aaa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "writui" -version = "0.17.0" +version = "0.18.0" edition = "2024" description = "A terminal writing app. No nonsense. Markdown. Local. Encrypted." license = "MIT" @@ -26,6 +26,14 @@ unicode-width = "0.2.2" ureq = { version = "3.4.2", default-features = false, features = ["rustls", "json"] } zeroize = "1.9.0" +# Touch ID, through Apple's frameworks. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6.3" +objc2-core-foundation = { version = "0.3.2", default-features = false, features = ["std", "CFBase", "CFData", "CFDictionary", "CFError", "CFNumber", "CFString"] } +objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSString", "NSError"] } +objc2-local-authentication = { version = "0.3.2", default-features = false, features = ["std", "LAContext"] } +objc2-security = { version = "0.3.2", default-features = false, features = ["std", "SecAccessControl", "SecBase", "SecItem", "SecKey"] } + [dev-dependencies] tempfile = "3.27.0" diff --git a/README.md b/README.md index b5e200c..ef53a27 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,14 @@ The first time, writui asks you to choose a password (twice) and creates your vault: the encrypted file that holds all your posts. After that, it asks for the password each time it starts. +On a Mac with Touch ID, you can unlock with your fingerprint instead: turn +on "Touch ID" in Settings (`s` on the list). writui then asks for your +fingerprint when it starts, and Enter asks again after locking; the +password still works too. It applies to this Mac only, and adding a new +fingerprint to the Mac turns it off until you next unlock with the +password. Your vault's key, sealed so that only this Mac's Secure Enclave +can open it after a fingerprint, is kept in `~/.config/writui/touch-id/`. + - **Posts** are listed with the most recently changed first. Open one with Enter or a click, start a new one with `n` (or Enter: "New" is highlighted when the list opens). The list's shortcuts are single keys, @@ -130,7 +138,7 @@ the password each time it starts. either way round: click "Sort" on the right of the search and pick one, or press Tab (Shift+Tab) to go through them. - **Lock** with Ctrl+O, wherever you are: writui asks for the password - again, then takes you back to where you were. + (or your fingerprint) again, then takes you back to where you were. - **Zen writing:** the editor shows only your words. Hold Ctrl to see the post's word count and when you started it. (This needs a terminal that can report Ctrl on its own, like Alacritty, Ghostty, kitty, WezTerm or a recent diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5ed4d8b..314aa81 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -686,3 +686,43 @@ The creator also rejected a promotion plan that waited two weeks for a directory listing before attempting another placement. Prepare suitable opportunities concurrently and progress approved recruitment continuously; human response time must not block research, preparation, or improvements. + +## Touch ID: a Secure Enclave key, not the keychain + +The creator asked to unlock with a fingerprint. The obvious route, the +vault's key in the macOS keychain behind Touch ID, doesn't suit writui: +keychain items with biometric protection need the app to be signed with +an Apple developer identity and entitlements, and plain keychain items are +tied to the binary's code signature. writui's releases are only ad-hoc +signed, so every upgrade would make macOS ask for the Mac's password before +the new binary could read the item. (Letting any app read it would avoid +that, but then any program running as the user could read the key without +a fingerprint.) + +Instead, turning Touch ID on makes a P-256 key in the Secure Enclave that's +"not permanent" (so not in the keychain, needing no entitlements), with +access control `privateKeyUsage | biometryCurrentSet`, and encrypts the +vault's raw 32-byte SQLCipher key to it (ECIES, X9.63 SHA-256, AES-GCM). +The Secure Enclave gives back an opaque reference to its key (attribute +`toid`, Apple's `kSecAttrTokenOID`), which brings the same key back when +passed to `SecKeyCreateRandomKey`; this is what CryptoKit's +`SecureEnclave…PrivateKey(dataRepresentation:)` does. The reference and the +encrypted key go in `/touch-id/`, mode 0600. They're useless off this Mac, +and decrypting needs a fingerprint every time, enforced by the Secure +Enclave rather than by writui. Tested working from an unsigned binary, and +across binaries (so across upgrades). Bindings: the `objc2-security` / +`objc2-local-authentication` crates, macOS only; elsewhere Touch ID is +simply unavailable. + +`biometryCurrentSet` (rather than `biometryAny` or `userPresence`) means +adding a fingerprint voids the key, so someone who knows the Mac's password +can't enrol their own finger to read the vault. The prompt has no "Use +Password…" fallback: the vault's password goes into writui. When the key +stops working, or doesn't open the vault (it was replaced), the next +password unlock quietly makes a new one. + +`Vault` now keeps its key in memory while open (SQLCipher holds a copy +anyway), so Touch ID can be turned on from Settings without retyping the +password. Opening with the key skips Argon2, so Touch ID unlocks are +instant. Changing the password, when it lands, must redo the Touch ID file. diff --git a/docs/PLAN.md b/docs/PLAN.md index 0cb0540..f1c4436 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -33,7 +33,8 @@ Ctrl/Cmd+arrow jumps in `0.15.0`, and in `0.16.0` the logo as a big banner on the password screens, boxed password fields, Esc to clear or quit, and the whole repo run through `cargo fmt` (checked in CI). `0.17.0` narrows the logo, adds an Unlock button, trims the setup screen, and makes Esc on -the list quit without asking. Next: +the list quit without asking. `0.18.0` unlocks with Touch ID on Macs +(asked for out of order). Next: 1. **Try online** (writui.com/try), below: the creator wants it next, and it must work on a phone. Probably two or three PRs, in the order listed. @@ -193,6 +194,7 @@ The creator's batch of ideas, after M3. ## Later - [ ] Homebrew tap (maybe) +- [x] Unlock with Touch ID on Macs (`0.18.0`) - [ ] Auto-lock after inactivity - [ ] Change password - [ ] Diffs between versions diff --git a/docs/SPEC.md b/docs/SPEC.md index 5d92ae6..9a8eba5 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -207,7 +207,21 @@ in [DECISIONS.md](DECISIONS.md); the build order lives in [PLAN.md](PLAN.md). So "Create your vault" no longer has its own paragraph about the password being unrecoverable: the Overview's first two points say it. -- Change password — planned. + + > can we make it so that the password can be stored in the mac keychain + > or whatever so that i can unlock with my fingerprint? + +- **Touch ID** (Macs with it): Settings has a "Touch ID" switch, shown only + where the Mac can use it. Once it's on, starting writui asks for a + fingerprint straight away; after locking (Ctrl+O), Enter, or the button + (labelled "Touch ID" until a password is typed), asks for one. Cancelling + the prompt leaves the password to type, which always works too. It's per + Mac and per vault, kept outside the vault, and survives upgrades. It's + never offered over SSH. If it stops working (e.g. a fingerprint was + added, which deliberately voids it), writui says so, and the next + password unlock sets it up again. Where it's available but off, the + unlock screen's Overview gets one more line pointing to Settings. +- Change password — planned (it must set Touch ID up again). ## The screens diff --git a/site/content/_index.md b/site/content/_index.md index 2484438..d4aa504 100644 --- a/site/content/_index.md +++ b/site/content/_index.md @@ -6,7 +6,8 @@ - **Pick up where you left off.** Your writing saves automatically. Reopen a post and your cursor is right where you left it. - **Keep your writing private.** Your posts stay on your computer in a - password-encrypted vault. Lock it when you step away. + password-encrypted vault. Lock it when you step away; unlock with + Touch ID. - **Try another draft. Keep the first.** Save a named version before you make changes. Read or restore it whenever you want. - **Use the controls you know.** Click, scroll and select, or use keyboard diff --git a/src/config.rs b/src/config.rs index 0f5209b..00659d9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -65,3 +65,10 @@ fn expand_tilde(path: PathBuf) -> PathBuf { _ => path, } } + +/// Where Touch ID keeps what it needs to unlock each vault: next to the +/// config file, as it's needed before unlocking and belongs to this Mac. +pub fn touch_id_dir() -> Result { + let config = config_path()?; + Ok(config.parent().unwrap_or(Path::new(".")).join("touch-id")) +} diff --git a/src/main.rs b/src/main.rs index ecf5d67..c35769d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod config; mod files; +mod touch_id; mod tui; mod upgrade; mod vault; diff --git a/src/touch_id.rs b/src/touch_id.rs new file mode 100644 index 0000000..1360866 --- /dev/null +++ b/src/touch_id.rs @@ -0,0 +1,326 @@ +//! Unlocking with Touch ID, on Macs that have it. +//! +//! Turning it on makes a key in the Mac's Secure Enclave that can only be +//! used after a fingerprint, and encrypts the vault's key with it. Both are +//! kept in a small file outside the vault (see `config::touch_id_dir`): +//! the Secure Enclave key only as a reference that's useless on any other +//! Mac. Unlocking asks for a fingerprint to decrypt the vault's key, which +//! then opens the vault without the password (and without Argon2). +//! +//! No keychain: keychain items are tied to the app's code signature, and +//! writui isn't signed with a stable one, so every upgrade would ask for +//! the Mac's password before Touch ID could be used. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use sha2::{Digest, Sha256}; + +use crate::config; +use crate::vault::{KEY_LEN, Key}; + +/// The first line of a Touch ID file, saying what it is. +const HEADER: &str = "writui Touch ID key, version 1"; + +/// Why unlocking with Touch ID didn't work. +pub enum Failure { + /// The prompt was cancelled: type the password instead. + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + Cancelled, + /// Something went wrong, said in a few words. + Other(String), +} + +/// Whether Touch ID can be used here: a Mac with fingerprints set up, and +/// writui running on it rather than over SSH (where the prompt would show +/// on a screen nobody's looking at). +pub fn available() -> bool { + let remote = std::env::var_os("SSH_CONNECTION").is_some(); + !remote && platform::available() +} + +/// Whether Touch ID is on for the vault at `vault`. +pub fn is_on(vault: &Path) -> bool { + key_file(vault).is_ok_and(|file| file.exists()) +} + +/// Turn Touch ID on for the vault at `vault`, which opens with `key`. +/// Turning it on again starts over with a new Secure Enclave key. +pub fn turn_on(vault: &Path, key: &Key) -> Result<()> { + let (reference, sealed) = platform::seal(key.as_ref())?; + let file = key_file(vault)?; + let dir = file.parent().context("no folder for the Touch ID key")?; + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + builder.create(dir).with_context(|| format!("creating {}", dir.display()))?; + let text = format!("{HEADER}\n{}\n{}\n", hex(&reference), hex(&sealed)); + write_private(&file, &text).with_context(|| format!("writing {}", file.display())) +} + +/// Turn Touch ID off for the vault at `vault`. +pub fn turn_off(vault: &Path) -> Result<()> { + let file = key_file(vault)?; + match fs::remove_file(&file) { + Err(err) if err.kind() != std::io::ErrorKind::NotFound => { + Err(err).with_context(|| format!("removing {}", file.display())) + } + _ => Ok(()), + } +} + +/// Ask for a fingerprint and give back the vault's key. Blocks until the +/// prompt is answered. +pub fn unlock(vault: &Path) -> Result { + let other = |err: anyhow::Error| Failure::Other(format!("{err:#}")); + let file = key_file(vault).map_err(other)?; + let text = fs::read_to_string(&file) + .with_context(|| format!("reading {}", file.display())) + .map_err(other)?; + let mut lines = text.lines(); + let (Some(HEADER), Some(reference), Some(sealed)) = (lines.next(), lines.next(), lines.next()) + else { + return Err(Failure::Other(format!("{} isn't a Touch ID key", file.display()))); + }; + let (Some(reference), Some(sealed)) = (unhex(reference), unhex(sealed)) else { + return Err(Failure::Other(format!("{} is damaged", file.display()))); + }; + let opened = platform::open(&reference, &sealed)?; + let mut key = Key::default(); + if opened.len() != KEY_LEN { + return Err(Failure::Other("the Touch ID key is the wrong length".into())); + } + key.copy_from_slice(&opened); + Ok(key) +} + +/// The Touch ID file for the vault at `vault`, named after where the vault +/// is (so each vault has its own, and moving a vault turns it off). +fn key_file(vault: &Path) -> Result { + let vault = fs::canonicalize(vault).unwrap_or_else(|_| vault.to_path_buf()); + let digest = Sha256::digest(vault.as_os_str().as_encoded_bytes()); + Ok(config::touch_id_dir()?.join(hex(&digest[..16]))) +} + +fn write_private(path: &Path, text: &str) -> std::io::Result<()> { + let mut options = fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + std::io::Write::write_all(&mut options.open(path)?, text.as_bytes()) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn unhex(text: &str) -> Option> { + if !text.len().is_multiple_of(2) { + return None; + } + (0..text.len()).step_by(2).map(|i| u8::from_str_radix(text.get(i..i + 2)?, 16).ok()).collect() +} + +/// Everywhere but macOS: no Touch ID. +#[cfg(not(target_os = "macos"))] +mod platform { + use super::Failure; + use anyhow::{Result, bail}; + + pub fn available() -> bool { + false + } + + pub fn seal(_key: &[u8]) -> Result<(Vec, Vec)> { + bail!("Touch ID is only on Macs") + } + + pub fn open(_reference: &[u8], _sealed: &[u8]) -> Result>, Failure> { + Err(Failure::Other("Touch ID is only on Macs".into())) + } +} + +/// The Secure Enclave, through Apple's Security and LocalAuthentication +/// frameworks. +#[cfg(target_os = "macos")] +mod platform { + use std::ptr::{self, NonNull}; + + use anyhow::{Result, anyhow}; + use objc2::rc::{Retained, autoreleasepool}; + use objc2_core_foundation::{ + CFBoolean, CFData, CFDictionary, CFError, CFNumber, CFRetained, CFString, CFType, + }; + use objc2_foundation::NSString; + use objc2_local_authentication::{LAContext, LAPolicy}; + use objc2_security::{ + SecAccessControl, SecAccessControlCreateFlags, SecKey, kSecAttrAccessControl, + kSecAttrAccessibleWhenUnlockedThisDeviceOnly, kSecAttrIsPermanent, kSecAttrKeySizeInBits, + kSecAttrKeyType, kSecAttrKeyTypeECSECPrimeRandom, kSecAttrTokenID, + kSecAttrTokenIDSecureEnclave, + kSecKeyAlgorithmECIESEncryptionCofactorVariableIVX963SHA256AESGCM as ECIES, + kSecPrivateKeyAttrs, kSecUseAuthenticationContext, + }; + use zeroize::Zeroizing; + + use super::Failure; + + /// The key attribute holding a Secure Enclave key's reference (Apple's + /// `kSecAttrTokenOID`, which isn't in the public headers). Given back to + /// the Secure Enclave, the reference brings the same key back. + const TOKEN_OID: &str = "toid"; + + /// What the Touch ID prompt says writui is trying to do. + const REASON: &str = "unlock your writing"; + + pub fn available() -> bool { + autoreleasepool(|_| { + let context = unsafe { LAContext::new() }; + unsafe { + context.canEvaluatePolicy_error(LAPolicy::DeviceOwnerAuthenticationWithBiometrics) + } + .is_ok() + }) + } + + /// Make a Secure Enclave key that needs a fingerprint (one of those set + /// up now: adding another one makes it stop working), and encrypt `key` + /// with it. Gives back the Secure Enclave key's reference and the + /// encrypted key. + pub fn seal(key: &[u8]) -> Result<(Vec, Vec)> { + autoreleasepool(|_| unsafe { + let mut error = ptr::null_mut(); + let flags = SecAccessControlCreateFlags::PrivateKeyUsage + | SecAccessControlCreateFlags::BiometryCurrentSet; + let access = SecAccessControl::with_flags( + None, + cf(kSecAttrAccessibleWhenUnlockedThisDeviceOnly), + flags, + &mut error, + ) + .ok_or_else(|| anyhow!("{}", describe(error)))?; + let private = CFDictionary::::from_slices( + &[kSecAttrIsPermanent, kSecAttrAccessControl], + &[cf(CFBoolean::new(false)), cf(&*access)], + ); + let size = CFNumber::new_i32(256); + let params = CFDictionary::::from_slices( + &[kSecAttrKeyType, kSecAttrKeySizeInBits, kSecAttrTokenID, kSecPrivateKeyAttrs], + &[ + cf(kSecAttrKeyTypeECSECPrimeRandom), + cf(&*size), + cf(kSecAttrTokenIDSecureEnclave), + cf(&*private), + ], + ); + let enclave_key = SecKey::new_random_key(params.as_opaque(), &mut error) + .ok_or_else(|| anyhow!("making a Secure Enclave key: {}", describe(error)))?; + let attributes = + enclave_key.attributes().ok_or_else(|| anyhow!("no Secure Enclave key"))?; + let attributes: &CFDictionary = attributes.cast_unchecked(); + let reference = attributes + .get(&CFString::from_static_str(TOKEN_OID)) + .and_then(|value| value.downcast::().ok()) + .ok_or_else(|| anyhow!("the Secure Enclave key has no reference"))?; + let public = + enclave_key.public_key().ok_or_else(|| anyhow!("no Secure Enclave public key"))?; + let sealed = public + .encrypted_data(ECIES, &CFData::from_bytes(key), &mut error) + .ok_or_else(|| anyhow!("encrypting the key: {}", describe(error)))?; + Ok((reference.to_vec(), sealed.to_vec())) + }) + } + + /// Bring back the Secure Enclave key from its reference, and decrypt + /// `sealed` with it: this is when the fingerprint is asked for. + pub fn open(reference: &[u8], sealed: &[u8]) -> Result>, Failure> { + autoreleasepool(|_| unsafe { + let context = LAContext::new(); + context.setLocalizedReason(&NSString::from_str(REASON)); + // No "Use Password…" button: the vault's password is typed + // into writui instead. + context.setLocalizedFallbackTitle(Some(&NSString::from_str(""))); + let context_cf: &CFType = &*(Retained::as_ptr(&context) as *const CFType); + let private = CFDictionary::::from_slices( + &[kSecAttrIsPermanent], + &[cf(CFBoolean::new(false))], + ); + let size = CFNumber::new_i32(256); + let reference = CFData::from_bytes(reference); + let token_oid = CFString::from_static_str(TOKEN_OID); + let params = CFDictionary::::from_slices( + &[ + kSecAttrKeyType, + kSecAttrKeySizeInBits, + kSecAttrTokenID, + &token_oid, + kSecUseAuthenticationContext, + kSecPrivateKeyAttrs, + ], + &[ + cf(kSecAttrKeyTypeECSECPrimeRandom), + cf(&*size), + cf(kSecAttrTokenIDSecureEnclave), + cf(&*reference), + context_cf, + cf(&*private), + ], + ); + let mut error = ptr::null_mut(); + let Some(enclave_key) = SecKey::new_random_key(params.as_opaque(), &mut error) else { + return Err(failure(error)); + }; + match enclave_key.decrypted_data(ECIES, &CFData::from_bytes(sealed), &mut error) { + Some(key) => Ok(Zeroizing::new(key.to_vec())), + None => Err(failure(error)), + } + }) + } + + fn cf + ?Sized>(value: &T) -> &CFType { + value.as_ref() + } + + fn take(error: *mut CFError) -> Option> { + NonNull::new(error).map(|error| unsafe { CFRetained::from_raw(error) }) + } + + fn describe(error: *mut CFError) -> String { + match take(error) { + Some(error) => error.description().map(|text| text.to_string()).unwrap_or_default(), + None => "unknown error".into(), + } + } + + /// Cancelling the prompt (or the system cancelling it, e.g. when the + /// Mac locks) is not an error: the password still works. + fn failure(error: *mut CFError) -> Failure { + let Some(error) = take(error) else { return Failure::Other("unknown error".into()) }; + let domain = error.domain().map(|domain| domain.to_string()).unwrap_or_default(); + let code = error.code(); + // LAError: user cancel, user fallback, system cancel, app cancel. + let la_cancel = + domain == "com.apple.LocalAuthentication" && [-2, -3, -4, -9].contains(&code); + // errSecUserCanceled. + let sec_cancel = domain == "NSOSStatusErrorDomain" && code == -128; + if la_cancel || sec_cancel { + return Failure::Cancelled; + } + Failure::Other(error.description().map(|text| text.to_string()).unwrap_or_default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_goes_both_ways() { + let bytes = [0u8, 1, 0xab, 0xff]; + assert_eq!(hex(&bytes), "0001abff"); + assert_eq!(unhex("0001abff").unwrap(), bytes); + assert!(unhex("abc").is_none() && unhex("zz").is_none()); + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 4e4b619..5dc1242 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -32,6 +32,7 @@ use ratatui::{DefaultTerminal, Frame}; use zeroize::Zeroizing; use crate::files; +use crate::touch_id; use crate::vault::{ExportSettings, OpenError, Settings, Vault, title_from_first_line}; /// What a screen asks the app to do in response to an event. @@ -40,6 +41,8 @@ pub enum Action { Quit, CreateVault(Zeroizing), Unlock(Zeroizing), + /// Unlock with a fingerprint. + UnlockTouchId, NewPost, OpenPost(i64), /// Move to the Trash, then highlight `select` in the list. @@ -81,6 +84,8 @@ pub enum Action { SortBy(list::Sort), /// Lock writui: back to the password, then to the same screen. Lock, + /// Turn unlocking with Touch ID on or off. + SetTouchId(bool), } /// Which post an imported file goes into. @@ -199,13 +204,24 @@ struct App { title: String, /// The screen last remembered in the vault (see `screen_name`). remembered: Option, + /// Touch ID is on but didn't work: set it up again once the password + /// has unlocked the vault. + redo_touch_id: bool, quit: bool, } impl App { fn new(vault_path: PathBuf, ctrl_reported: bool) -> Self { + let mut pending = None; let screen = if vault_path.exists() { - Screen::Unlock(unlock::Unlock::default()) + let touch_id = touch_id_here(&vault_path); + let mut screen = unlock::Unlock::new(touch_id); + // Ask for a fingerprint straight away, once the screen is up. + if touch_id == unlock::TouchId::On { + screen.wait_for_touch_id(); + pending = Some(Action::UnlockTouchId); + } + Screen::Unlock(screen) } else { Screen::Setup(setup::Setup::new(vault_path.clone())) }; @@ -213,13 +229,14 @@ impl App { vault_path, vault: None, screen, - pending: None, + pending, quit_unsaved: false, bar_cursor: false, ctrl_reported, ctrl_held: false, title: String::new(), remembered: None, + redo_touch_id: false, quit: false, } } @@ -302,7 +319,7 @@ impl App { self.remember_screen(); self.vault = None; self.quit_unsaved = false; - self.screen = Screen::Unlock(unlock::Unlock::locked()); + self.screen = Screen::Unlock(unlock::Unlock::locked(touch_id_here(&self.vault_path))); } /// Right after unlocking: the screen writui was left on, if it's @@ -417,6 +434,25 @@ impl App { } self.pending = Some(action); } + Action::UnlockTouchId => { + if let Screen::Unlock(screen) = &mut self.screen { + screen.wait_for_touch_id(); + } + self.pending = Some(action); + } + Action::SetTouchId(on) => { + let vault = self.vault()?; + let result = match on { + true => touch_id::turn_on(&self.vault_path, vault.key()), + false => touch_id::turn_off(&self.vault_path), + }; + if let Screen::Settings(screen) = &mut self.screen { + match result { + Ok(()) => screen.touch_id_set(on), + Err(err) => screen.set_error(format!("Couldn't change Touch ID: {err:#}")), + } + } + } Action::NewPost => { let id = self.vault()?.create_post()?; self.open_post(id)?; @@ -518,6 +554,12 @@ impl App { } Action::Unlock(password) => match Vault::open(&self.vault_path, &password) { Ok(vault) => { + // Only a convenience: the next unlock asks again if + // this doesn't work either. + if self.redo_touch_id { + let _ = touch_id::turn_on(&self.vault_path, vault.key()); + self.redo_touch_id = false; + } self.vault = Some(vault); self.resume() } @@ -529,6 +571,33 @@ impl App { } Err(OpenError::Other(err)) => Err(err), }, + Action::UnlockTouchId => { + let failed = match touch_id::unlock(&self.vault_path) { + Ok(key) => match Vault::open_with_key(&self.vault_path, key) { + Ok(vault) => { + self.vault = Some(vault); + return self.resume(); + } + // E.g. the vault was swapped for another one. + Err(OpenError::WrongPassword) => { + Some("its key doesn't open this vault".to_string()) + } + Err(OpenError::Other(err)) => return Err(err), + }, + Err(touch_id::Failure::Cancelled) => None, + Err(touch_id::Failure::Other(why)) => Some(why), + }; + if let Screen::Unlock(screen) = &mut self.screen { + match failed { + Some(why) => { + screen.touch_id_failed(&why); + self.redo_touch_id = true; + } + None => screen.touch_id_cancelled(), + } + } + Ok(()) + } _ => self.apply(action), } } @@ -619,7 +688,8 @@ impl App { fn show_settings(&mut self) -> Result<()> { let values = self.vault()?.settings()?; - self.screen = Screen::Settings(Box::new(settings::Settings::new(values))); + let touch_id = touch_id::available().then(|| touch_id::is_on(&self.vault_path)); + self.screen = Screen::Settings(Box::new(settings::Settings::new(values, touch_id))); Ok(()) } @@ -683,6 +753,15 @@ impl App { } } +/// Whether the vault at `path` can be unlocked with Touch ID here. +fn touch_id_here(path: &Path) -> unlock::TouchId { + match (touch_id::available(), touch_id::is_on(path)) { + (false, _) => unlock::TouchId::Unavailable, + (true, false) => unlock::TouchId::Off, + (true, true) => unlock::TouchId::On, + } +} + #[cfg(test)] mod tests { use ratatui::crossterm::event::KeyEvent; diff --git a/src/tui/settings.rs b/src/tui/settings.rs index 3adaf8c..cfad025 100644 --- a/src/tui/settings.rs +++ b/src/tui/settings.rs @@ -1,6 +1,6 @@ -//! The Settings screen: a few things to set, kept in the vault. Each row -//! shows a setting's value; Enter (or a click) changes it in a dialog, or -//! switches it on or off. +//! The Settings screen: a few things to set, kept in the vault (but for +//! Touch ID, which belongs to the Mac). Each row shows a setting's value; +//! Enter (or a click) changes it in a dialog, or switches it on or off. use ratatui::Frame; use ratatui::crossterm::event::{Event, KeyCode, MouseButton, MouseEventKind}; @@ -23,10 +23,9 @@ enum Field { Author, LineWidth, NerdFont, + TouchId, } -const FIELDS: [Field; 3] = [Field::Author, Field::LineWidth, Field::NerdFont]; - #[derive(Clone, Copy)] enum Cmd { Change, @@ -38,6 +37,9 @@ enum Cmd { pub struct Settings { values: Values, + /// Whether Touch ID is on, where this Mac has it. + touch_id: Option, + fields: Vec, state: ListState, /// Changing the highlighted setting. dialog: Option>, @@ -48,9 +50,15 @@ pub struct Settings { } impl Settings { - pub fn new(values: Values) -> Self { + pub fn new(values: Values, touch_id: Option) -> Self { + let mut fields = vec![Field::Author, Field::LineWidth, Field::NerdFont]; + if touch_id.is_some() { + fields.push(Field::TouchId); + } Settings { values, + touch_id, + fields, state: ListState::default().with_selected(Some(0)), dialog: None, notice: None, @@ -66,8 +74,23 @@ impl Settings { self.error = Some(error); } + /// Touch ID was switched on or off. + pub fn touch_id_set(&mut self, on: bool) { + self.touch_id = Some(on); + self.error = None; + self.notice = Some(match on { + true => "Touch ID is on: writui asks for a fingerprint when it starts.".into(), + false => "Touch ID is off: unlocking takes the password.".into(), + }); + } + fn field(&self) -> Field { - FIELDS[self.state.selected().unwrap_or(0).min(FIELDS.len() - 1)] + self.fields[self.state.selected().unwrap_or(0).min(self.fields.len() - 1)] + } + + /// Fields switched on or off, rather than changed in a dialog. + fn switched(field: Field) -> bool { + matches!(field, Field::NerdFont | Field::TouchId) } pub fn render(&mut self, frame: &mut Frame) { @@ -85,7 +108,9 @@ impl Settings { let col = column(rows, COLUMN_WIDTH); let width = col.width as usize; - let items: Vec = FIELDS + let on_off = |on| Span::raw(if on { "on" } else { "off" }); + let items: Vec = self + .fields .iter() .map(|field| { let (name, value) = match field { @@ -96,9 +121,8 @@ impl Settings { Field::LineWidth => { ("Line width", Span::raw(format!("{} characters", self.values.line_width))) } - Field::NerdFont => { - ("Nerd Font", Span::raw(if self.values.nerd_font { "on" } else { "off" })) - } + Field::NerdFont => ("Nerd Font", on_off(self.values.nerd_font)), + Field::TouchId => ("Touch ID", on_off(self.touch_id == Some(true))), }; let value = Span::styled( truncate(&value.content, width.saturating_sub(name.len() + 2)), @@ -112,7 +136,7 @@ impl Settings { ])) }) .collect(); - self.rows_area = Rect { height: (FIELDS.len() as u16).min(col.height), ..col }; + self.rows_area = Rect { height: (self.fields.len() as u16).min(col.height), ..col }; frame.render_stateful_widget( ListWidget::new(items).highlight_style(Style::new().reversed()), self.rows_area, @@ -134,7 +158,7 @@ impl Settings { self.hints.render(frame, hint_area, &hints); return; } - let change = if self.field() == Field::NerdFont { "switch" } else { "change" }; + let change = if Self::switched(self.field()) { "switch" } else { "change" }; self.hints.render( frame, hint_area, @@ -189,7 +213,8 @@ impl Settings { fn move_by(&mut self, delta: isize) -> Action { let current = self.state.selected().unwrap_or(0) as isize; - self.state.select(Some((current + delta).clamp(0, FIELDS.len() as isize - 1) as usize)); + self.state + .select(Some((current + delta).clamp(0, self.fields.len() as isize - 1) as usize)); Action::None } @@ -203,6 +228,9 @@ impl Settings { values.nerd_font = !values.nerd_font; self.save(values) } + Cmd::Change if self.field() == Field::TouchId => { + Action::SetTouchId(self.touch_id != Some(true)) + } Cmd::Change => { self.dialog = Some(self.dialog_for(self.field())); Action::None @@ -226,7 +254,7 @@ impl Settings { return Action::None; } }, - Field::NerdFont => {} + Field::NerdFont | Field::TouchId => {} } self.save(values) } @@ -282,7 +310,7 @@ impl Settings { .with_input("68", 3) .with_text(&self.values.line_width.to_string()) } - Field::NerdFont => unreachable!("switched on or off, not asked"), + Field::NerdFont | Field::TouchId => unreachable!("switched on or off, not asked"), } } } @@ -317,7 +345,7 @@ mod tests { #[test] fn settings_are_changed_in_a_dialog() { - let mut settings = Settings::new(Values::default()); + let mut settings = Settings::new(Values::default(), None); let text = screen(&mut settings); assert!( text.contains("Author") && text.contains("none") && text.contains("68 characters"), @@ -363,4 +391,19 @@ mod tests { // Esc goes back. assert!(matches!(press(&mut settings, KeyCode::Esc), Action::BackToList(None))); } + + #[test] + fn touch_id_is_switched_where_the_mac_has_it() { + assert!(!screen(&mut Settings::new(Values::default(), None)).contains("Touch ID")); + let mut settings = Settings::new(Values::default(), Some(false)); + for _ in 0..3 { + press(&mut settings, KeyCode::Down); + } + assert!(screen(&mut settings).contains("Touch ID")); + assert!(matches!(press(&mut settings, KeyCode::Enter), Action::SetTouchId(true))); + settings.touch_id_set(true); + let text = screen(&mut settings); + assert!(text.contains("Touch ID is on"), "{text}"); + assert!(matches!(press(&mut settings, KeyCode::Enter), Action::SetTouchId(false))); + } } diff --git a/src/tui/unlock.rs b/src/tui/unlock.rs index 8ef118c..ba964bb 100644 --- a/src/tui/unlock.rs +++ b/src/tui/unlock.rs @@ -1,4 +1,5 @@ -//! Every later run: enter the password to unlock the vault. +//! Every later run: enter the password to unlock the vault, or use +//! Touch ID where it's on. use ratatui::Frame; use ratatui::crossterm::event::{Event, KeyCode, MouseButton, MouseEventKind}; @@ -11,11 +12,24 @@ use super::Action; use super::hints::{HintBar, hint}; use super::widgets::{ COLUMN_WIDTH, PASSWORD_BOX, PasswordField, centered, dev_password_note, overview, title, + truncate, }; +/// Whether this vault can be unlocked with Touch ID here. +#[derive(Clone, Copy, Default, PartialEq)] +pub enum TouchId { + /// Not on this computer (or not right now, e.g. over SSH). + #[default] + Unavailable, + /// This Mac has it, but it's not turned on for this vault. + Off, + On, +} + #[derive(Clone, Copy)] enum Cmd { Unlock, + TouchId, Clear, Quit, } @@ -25,36 +39,77 @@ pub struct Unlock { field: PasswordField, /// writui was locked (Ctrl+O), rather than just started. locked: bool, - error: Option<&'static str>, + touch_id: TouchId, + error: Option, + notice: Option<&'static str>, busy: bool, + /// Busy waiting for a fingerprint, rather than checking the password. + waiting: bool, /// Where the Unlock button was drawn, for clicks. button: Rect, hints: HintBar, } impl Unlock { - pub fn locked() -> Self { - Unlock { locked: true, ..Unlock::default() } + pub fn new(touch_id: TouchId) -> Self { + Unlock { touch_id, ..Unlock::default() } + } + + pub fn locked(touch_id: TouchId) -> Self { + Unlock { locked: true, touch_id, ..Unlock::default() } } pub fn set_busy(&mut self, busy: bool) { self.busy = busy; } + /// The Touch ID prompt is up. + pub fn wait_for_touch_id(&mut self) { + self.busy = true; + self.waiting = true; + self.error = None; + self.notice = None; + } + + pub fn touch_id_cancelled(&mut self) { + self.busy = false; + self.waiting = false; + self.notice = Some("Touch ID cancelled. Enter tries again, or type your password."); + } + + pub fn touch_id_failed(&mut self, why: &str) { + self.busy = false; + self.waiting = false; + self.error = Some(format!("Touch ID didn't work: {why}")); + } + pub fn wrong_password(&mut self) { self.busy = false; self.field.clear(); - self.error = Some("Wrong password."); + self.notice = None; + self.error = Some("Wrong password.".into()); + } + + /// Enter (or the button) asks for a fingerprint, rather than trying + /// the password. + fn uses_touch_id(&self) -> bool { + self.touch_id == TouchId::On && self.field.is_empty() } pub fn render(&mut self, frame: &mut Frame) { let [body, hint_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area()); - let status = if self.busy { + let status = if self.busy && self.waiting { + Line::from("Waiting for Touch ID…".dim()) + } else if self.busy { Line::from("Unlocking…".dim()) - } else if let Some(error) = self.error { - Line::from(error.red()) + } else if let Some(error) = &self.error { + Line::from(truncate(error, COLUMN_WIDTH as usize).red()) + } else if let Some(notice) = self.notice { + Line::from(notice.dim()) + } else if self.locked && self.uses_touch_id() { + Line::from("Locked. Press Enter to unlock with Touch ID.".dim()) } else if self.locked { Line::from("Locked. Unlock to pick up where you were.".dim()) } else { @@ -71,6 +126,10 @@ impl Unlock { Line::default(), ]; rest.extend(overview()); + if self.touch_id == TouchId::Off { + let tip = "· On this Mac, Settings can turn on unlocking with Touch ID."; + rest.push(Line::from(tip.dim())); + } if let Some(note) = dev_password_note() { rest.extend([Line::default(), Line::from(note.dim())]); } @@ -88,7 +147,7 @@ impl Unlock { // The Unlock button, right of the password box and as tall as it, // left out if the window is too narrow for all of it. Dimmed while - // there's no password for it to try. + // there's no password for it to try, unless it's for Touch ID. let button = Rect::new(field_area.right() + 2, field_area.y, 12, height); self.button = if area.contains(Position::new(button.right() - 1, button.bottom() - 1)) { button @@ -96,19 +155,23 @@ impl Unlock { Rect::default() }; if !self.button.is_empty() { - let style = if self.field.is_empty() || self.busy { + let style = if self.busy || (self.field.is_empty() && !self.uses_touch_id()) { Style::new().dim() } else { Style::new().bold() }; + let label = if self.uses_touch_id() { "Touch ID" } else { "Unlock" }; let block = Block::bordered().border_style(style); - let label = Paragraph::new(Line::from("Unlock").style(style)).centered(); + let label = Paragraph::new(Line::from(label).style(style)).centered(); frame.render_widget(label, block.inner(button)); frame.render_widget(block, button); } // Esc clears the password, or quits if there's nothing to clear. - let mut hints = vec![hint("Enter", "unlock", Cmd::Unlock)]; + let mut hints = vec![match self.uses_touch_id() { + true => hint("Enter", "Touch ID", Cmd::TouchId), + false => hint("Enter", "unlock", Cmd::Unlock), + }]; if self.field.is_empty() { hints.push(hint("Esc", "quit", Cmd::Quit)); } else { @@ -129,6 +192,7 @@ impl Unlock { Event::Key(key) => { if self.field.handle_key(key) { self.error = None; + self.notice = None; } Action::None } @@ -157,8 +221,59 @@ impl Unlock { self.error = None; Action::None } + Cmd::Unlock | Cmd::TouchId if self.uses_touch_id() => Action::UnlockTouchId, + Cmd::TouchId => Action::None, Cmd::Unlock if self.field.is_empty() => Action::None, Cmd::Unlock => Action::Unlock(self.field.value().clone()), } } } + +#[cfg(test)] +mod tests { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::crossterm::event::{KeyEvent, KeyModifiers}; + + use super::*; + + fn screen(unlock: &mut Unlock) -> String { + let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap(); + terminal.draw(|frame| unlock.render(frame)).unwrap(); + let buffer = terminal.backend().buffer(); + (0..40) + .map(|y| (0..100).map(|x| buffer[(x, y)].symbol()).collect::()) + .collect::>() + .join("\n") + } + + fn press(unlock: &mut Unlock, code: KeyCode) -> Action { + unlock.handle(Event::Key(KeyEvent::new(code, KeyModifiers::NONE))) + } + + #[test] + fn enter_uses_touch_id_until_a_password_is_typed() { + let mut unlock = Unlock::locked(TouchId::On); + let text = screen(&mut unlock); + assert!(text.contains("Touch ID") && text.contains("Enter Touch ID"), "{text}"); + assert!(matches!(press(&mut unlock, KeyCode::Enter), Action::UnlockTouchId)); + unlock.touch_id_cancelled(); + assert!(screen(&mut unlock).contains("Touch ID cancelled")); + press(&mut unlock, KeyCode::Char('p')); + assert!(screen(&mut unlock).contains("Enter unlock")); + let Action::Unlock(password) = press(&mut unlock, KeyCode::Enter) else { + panic!("not unlocking with the password") + }; + assert_eq!(password.as_str(), "p"); + } + + #[test] + fn touch_id_is_suggested_only_where_it_is_off() { + let tip = "Settings can turn on unlocking with Touch ID"; + assert!(screen(&mut Unlock::new(TouchId::Off)).contains(tip)); + let mut unlock = Unlock::new(TouchId::Unavailable); + let text = screen(&mut unlock); + assert!(!text.contains(tip) && !text.contains("Touch ID"), "{text}"); + assert!(matches!(press(&mut unlock, KeyCode::Enter), Action::None)); + } +} diff --git a/src/vault/mod.rs b/src/vault/mod.rs index 86d70ed..382a91c 100644 --- a/src/vault/mod.rs +++ b/src/vault/mod.rs @@ -5,7 +5,8 @@ //! Encryption: the password goes through Argon2id with a random 16-byte salt //! to produce a 256-bit key, which is handed to SQLCipher as a raw key along //! with the salt. SQLCipher stores that salt in the first 16 bytes of the -//! file, so the vault stays a single self-contained file. +//! file, so the vault stays a single self-contained file. The key itself +//! can also open the vault (Touch ID keeps it, see `touch_id`). mod migrations; @@ -21,7 +22,10 @@ use rusqlite::{Connection, ErrorCode, OpenFlags, OptionalExtension, params}; use zeroize::Zeroizing; const SALT_LEN: usize = 16; -const KEY_LEN: usize = 32; +pub const KEY_LEN: usize = 32; + +/// The key that opens a vault, derived from its password. +pub type Key = Zeroizing<[u8; KEY_LEN]>; /// Argon2id cost: (memory in KiB, iterations, lanes). Changing this makes /// existing vaults unopenable unless the old values are kept as a fallback. @@ -38,6 +42,9 @@ pub const DELETED_RETENTION_DAYS: i64 = 30; pub struct Vault { conn: Connection, + /// Kept while the vault is open, so Touch ID can be turned on without + /// asking for the password again. (SQLCipher holds a copy anyway.) + key: Key, } #[derive(Debug)] @@ -149,16 +156,24 @@ impl Vault { let mut conn = Connection::open(path) .with_context(|| format!("creating vault at {}", path.display()))?; make_private(path)?; - apply_key(&conn, password, &salt)?; + let key = derive_key(password, &salt)?; + apply_key(&conn, &key, &salt)?; migrations::run(&mut conn, path)?; - Ok(Vault { conn }) + Ok(Vault { conn, key }) } /// Open an existing vault with its password. pub fn open(path: &Path, password: &str) -> Result { + let key = derive_key(password, &read_salt(path)?)?; + Vault::open_with_key(path, key) + } + + /// Open an existing vault with its key, rather than the password the + /// key comes from. A key that doesn't fit is a wrong password. + pub fn open_with_key(path: &Path, key: Key) -> Result { let salt = read_salt(path)?; let mut conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?; - apply_key(&conn, password, &salt)?; + apply_key(&conn, &key, &salt)?; // SQLCipher only notices a wrong key on the first real read. match conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())) { Ok(()) => {} @@ -168,11 +183,16 @@ impl Vault { Err(err) => return Err(err.into()), } migrations::run(&mut conn, path)?; - let vault = Vault { conn }; + let vault = Vault { conn, key }; vault.purge_expired()?; Ok(vault) } + /// The key that opens this vault. + pub fn key(&self) -> &Key { + &self.key + } + /// All posts (not counting the Trash), most recently updated first. pub fn list_posts(&self) -> Result> { self.summaries("deleted_at IS NULL ORDER BY updated_at DESC, id DESC") @@ -551,7 +571,7 @@ pub fn with_title_prefix(body: &str) -> String { format!("{TITLE_PREFIX}{rest}") } -fn derive_key(password: &str, salt: &[u8]) -> Result> { +fn derive_key(password: &str, salt: &[u8]) -> Result { let (memory, iterations, lanes) = KDF_COST; let params = Params::new(memory, iterations, lanes, Some(KEY_LEN)) .map_err(|err| anyhow!("invalid key derivation parameters: {err}"))?; @@ -562,8 +582,7 @@ fn derive_key(password: &str, salt: &[u8]) -> Result> { Ok(key) } -fn apply_key(conn: &Connection, password: &str, salt: &[u8; SALT_LEN]) -> Result<()> { - let key = derive_key(password, salt)?; +fn apply_key(conn: &Connection, key: &Key, salt: &[u8; SALT_LEN]) -> Result<()> { // Raw key + explicit salt: "x'<64 hex chars of key><32 hex chars of salt>'". let pragma = Zeroizing::new(format!( "PRAGMA key = \"x'{}{}'\";", @@ -657,6 +676,21 @@ mod tests { assert!(matches!(Vault::open(&path, "battery staple"), Err(OpenError::WrongPassword))); } + #[test] + fn the_key_opens_the_vault_too() { + let (_dir, path) = temp_vault(); + let vault = Vault::create(&path, "correct horse").unwrap(); + let id = vault.create_post().unwrap(); + let key = vault.key().clone(); + drop(vault); + assert_eq!(Vault::open(&path, "correct horse").unwrap().key(), &key); + let vault = Vault::open_with_key(&path, key).unwrap(); + assert!(vault.post(id).is_ok()); + drop(vault); + let wrong = Zeroizing::new([7; KEY_LEN]); + assert!(matches!(Vault::open_with_key(&path, wrong), Err(OpenError::WrongPassword))); + } + #[test] fn file_is_encrypted_and_starts_with_salt() { let (_dir, path) = temp_vault(); From 3bfc96575234a53ba9d4f81f31c9d57c3e97b64a Mon Sep 17 00:00:00 2001 From: Aakash N S <1560745+aakashns@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:29:04 +0700 Subject: [PATCH 2/3] Touch ID when creating the vault, and its own button On a Mac with Touch ID, "Create your vault" has an "Unlock with Touch ID" box under the password boxes: click it, or press Ctrl+T, to tick it, and the new vault unlocks with your fingerprint from the start. The unlock screen keeps its "Unlock" button, with a separate "Touch ID" button next to it once Touch ID is on. Cancelling the fingerprint prompt now just says "Touch ID cancelled." Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++-- docs/SPEC.md | 18 +++++++-- src/tui/mod.rs | 33 ++++++++++----- src/tui/setup.rs | 100 +++++++++++++++++++++++++++++++++++++++++++--- src/tui/unlock.rs | 74 +++++++++++++++++++++------------- 5 files changed, 185 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index ef53a27..dd10f43 100644 --- a/README.md +++ b/README.md @@ -117,9 +117,10 @@ The first time, writui asks you to choose a password (twice) and creates your vault: the encrypted file that holds all your posts. After that, it asks for the password each time it starts. -On a Mac with Touch ID, you can unlock with your fingerprint instead: turn -on "Touch ID" in Settings (`s` on the list). writui then asks for your -fingerprint when it starts, and Enter asks again after locking; the +On a Mac with Touch ID, you can unlock with your fingerprint instead: tick +"Unlock with Touch ID" when you create your vault, or turn on "Touch ID" in +Settings (`s` on the list) later. writui then asks for your fingerprint +when it starts; after locking, press Enter or click "Touch ID". The password still works too. It applies to this Mac only, and adding a new fingerprint to the Mac turns it off until you next unlock with the password. Your vault's key, sealed so that only this Mac's Secure Enclave diff --git a/docs/SPEC.md b/docs/SPEC.md index 9a8eba5..b479eb4 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -213,9 +213,21 @@ in [DECISIONS.md](DECISIONS.md); the build order lives in [PLAN.md](PLAN.md). - **Touch ID** (Macs with it): Settings has a "Touch ID" switch, shown only where the Mac can use it. Once it's on, starting writui asks for a - fingerprint straight away; after locking (Ctrl+O), Enter, or the button - (labelled "Touch ID" until a password is typed), asks for one. Cancelling - the prompt leaves the password to type, which always works too. It's per + fingerprint straight away; after locking (Ctrl+O), Enter (with no + password typed) or the "Touch ID" button asks for one. Cancelling the + prompt says "Touch ID cancelled." and leaves the password to type, which + always works too. + + > okay, just one more thing, i want a [ ] Unlock with Touch ID checkbox + > on the screen where you are setting up the password. And remove the + > "Enter tries again, or type your password" comment. Also, keep the + > "Unlock" button on the password page, looks like you just replaced it + > with " + + "Create your vault" has a "[ ] Unlock with Touch ID" box under the + password boxes, where the Mac can use it, unticked at first; a click or + Ctrl+T ticks it. The unlock screen keeps its "Unlock" button, with a + separate "Touch ID" button right of it where Touch ID is on. It's per Mac and per vault, kept outside the vault, and survives upgrades. It's never offered over SSH. If it stops working (e.g. a fingerprint was added, which deliberately voids it), writui says so, and the next diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 5dc1242..bb9b647 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -39,7 +39,11 @@ use crate::vault::{ExportSettings, OpenError, Settings, Vault, title_from_first_ pub enum Action { None, Quit, - CreateVault(Zeroizing), + /// Create the vault, maybe with Touch ID on. + CreateVault { + password: Zeroizing, + touch_id: bool, + }, Unlock(Zeroizing), /// Unlock with a fingerprint. UnlockTouchId, @@ -223,7 +227,7 @@ impl App { } Screen::Unlock(screen) } else { - Screen::Setup(setup::Setup::new(vault_path.clone())) + Screen::Setup(setup::Setup::new(vault_path.clone(), touch_id::available())) }; App { vault_path, @@ -426,7 +430,7 @@ impl App { self.quit_unsaved = true; } } - Action::CreateVault(_) | Action::Unlock(_) => { + Action::CreateVault { .. } | Action::Unlock(_) => { match &mut self.screen { Screen::Setup(screen) => screen.set_busy(true), Screen::Unlock(screen) => screen.set_busy(true), @@ -548,9 +552,16 @@ impl App { fn perform(&mut self, action: Action) -> Result<()> { match action { - Action::CreateVault(password) => { - self.vault = Some(Vault::create(&self.vault_path, &password)?); - self.show_list(None, None) + Action::CreateVault { password, touch_id } => { + let vault = Vault::create(&self.vault_path, &password)?; + let notice = match touch_id { + true => touch_id::turn_on(&self.vault_path, vault.key()).err().map(|err| { + format!("Your vault is ready, but Touch ID couldn't be turned on: {err:#}") + }), + false => None, + }; + self.vault = Some(vault); + self.show_list(None, notice) } Action::Unlock(password) => match Vault::open(&self.vault_path, &password) { Ok(vault) => { @@ -768,6 +779,10 @@ mod tests { use super::*; + fn create_vault() -> Action { + Action::CreateVault { password: Zeroizing::new("pw".into()), touch_id: false } + } + fn key(code: KeyCode, modifiers: KeyModifiers, kind: KeyEventKind) -> Event { Event::Key(KeyEvent::new_with_kind(code, modifiers, kind)) } @@ -790,7 +805,7 @@ mod tests { fn imports_make_a_post_and_ask_before_updating_it() { let dir = tempfile::tempdir().unwrap(); let mut app = App::new(dir.path().join("writui.db"), false); - app.perform(Action::CreateVault(Zeroizing::new("pw".into()))).unwrap(); + app.perform(create_vault()).unwrap(); let file = dir.path().join("hello.md"); std::fs::write(&file, "+++\ntitle = \"Hello\"\ndate = 2026-01-01\n+++\n\nFirst text.\n") .unwrap(); @@ -863,7 +878,7 @@ mod tests { app }; let mut app = App::new(path.clone(), false); - app.perform(Action::CreateVault(Zeroizing::new("pw".into()))).unwrap(); + app.perform(create_vault()).unwrap(); app.apply(Action::NewPost).unwrap(); app.remember_screen(); let Screen::Editor(editor) = &app.screen else { panic!("not opened") }; @@ -890,7 +905,7 @@ mod tests { fn ctrl_o_locks_and_unlocking_comes_back() { let dir = tempfile::tempdir().unwrap(); let mut app = App::new(dir.path().join("writui.db"), false); - app.perform(Action::CreateVault(Zeroizing::new("pw".into()))).unwrap(); + app.perform(create_vault()).unwrap(); app.apply(Action::NewPost).unwrap(); app.remember_screen(); app.handle(key(KeyCode::Char('x'), KeyModifiers::NONE, KeyEventKind::Press)); diff --git a/src/tui/setup.rs b/src/tui/setup.rs index 62715ff..9a7ea6c 100644 --- a/src/tui/setup.rs +++ b/src/tui/setup.rs @@ -1,9 +1,10 @@ -//! First run: create the vault and choose its password. +//! First run: create the vault and choose its password (and, on Macs +//! with it, whether to unlock with Touch ID). use std::path::PathBuf; use ratatui::Frame; -use ratatui::crossterm::event::{Event, KeyCode, MouseButton, MouseEventKind}; +use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers, MouseButton, MouseEventKind}; use ratatui::layout::{Constraint, Layout, Position, Rect}; use ratatui::style::Stylize; use ratatui::text::Line; @@ -23,6 +24,7 @@ const LABELS: [&str; 2] = ["Password", "Confirm password"]; enum Cmd { Submit, SwitchField, + ToggleTouchId, Clear, Quit, } @@ -33,19 +35,26 @@ pub struct Setup { focus: usize, error: Option<&'static str>, busy: bool, + /// The "Unlock with Touch ID" box: `None` where Touch ID isn't + /// available, else whether it's ticked. + touch_id: Option, field_areas: [Rect; 2], + /// Where the Touch ID box was drawn, for clicks. + touch_id_area: Rect, hints: HintBar, } impl Setup { - pub fn new(vault_path: PathBuf) -> Self { + pub fn new(vault_path: PathBuf, touch_id_available: bool) -> Self { Setup { vault_path, fields: Default::default(), focus: 0, error: None, busy: false, + touch_id: touch_id_available.then_some(false), field_areas: Default::default(), + touch_id_area: Rect::default(), hints: HintBar::default(), } } @@ -75,20 +84,32 @@ impl Setup { Line::default(), Line::default(), Line::default(), + ]; + let touch_id_line = rest.len(); + if let Some(on) = self.touch_id { + let label = format!("[{}] Unlock with Touch ID", if on { "x" } else { " " }); + rest.extend([Line::from(label), Line::default()]); + } + rest.extend([ status, Line::from(truncate(&path, COLUMN_WIDTH as usize).dim()), Line::default(), - ]; + ]); rest.extend(overview()); if let Some(note) = dev_password_note() { rest.extend([Line::default(), Line::from(note.dim())]); } let mut lines = title(body, rest.len()); let first_field_line = lines.len() as u16 + 3; + let touch_id_line = (lines.len() + touch_id_line) as u16; lines.extend(rest); let area = centered(body, COLUMN_WIDTH, lines.len() as u16); frame.render_widget(Paragraph::new(lines), area); + self.touch_id_area = match self.touch_id { + Some(_) => Rect::new(area.x, area.y + touch_id_line, 24, 1).intersection(area), + None => Rect::default(), + }; // Narrow windows get narrower boxes, still side by side. let (width, height) = PASSWORD_BOX; let width = width.min(area.width.saturating_sub(2) / 2); @@ -108,6 +129,9 @@ impl Setup { hint("Enter", "create vault", Cmd::Submit), hint("Tab", "next field", Cmd::SwitchField), ]; + if self.touch_id.is_some() { + hints.push(hint("Ctrl+T", "Touch ID", Cmd::ToggleTouchId)); + } if self.fields.iter().all(PasswordField::is_empty) { hints.push(hint("Esc", "quit", Cmd::Quit)); } else { @@ -121,6 +145,12 @@ impl Setup { return Action::None; } match event { + Event::Key(key) + if key.code == KeyCode::Char('t') + && key.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::SUPER) => + { + self.run(Cmd::ToggleTouchId) + } Event::Key(key) => match key.code { KeyCode::Enter if self.focus == 0 && self.fields[1].is_empty() => { self.focus = 1; @@ -150,6 +180,9 @@ impl Setup { if let Some(i) = self.field_areas.iter().position(|a| a.contains(pos)) { self.focus = i; } + if self.touch_id_area.contains(pos) { + return self.run(Cmd::ToggleTouchId); + } match self.hints.hit(mouse.column, mouse.row) { Some(cmd) => self.run(cmd), None => Action::None, @@ -166,6 +199,10 @@ impl Setup { self.focus = 1 - self.focus; Action::None } + Cmd::ToggleTouchId => { + self.touch_id = self.touch_id.map(|on| !on); + Action::None + } Cmd::Clear => { self.fields.iter_mut().for_each(PasswordField::clear); self.focus = 0; @@ -184,9 +221,62 @@ impl Setup { self.focus = 1; Action::None } else { - Action::CreateVault(password.value().clone()) + Action::CreateVault { + password: password.value().clone(), + touch_id: self.touch_id == Some(true), + } } } } } } + +#[cfg(test)] +mod tests { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::crossterm::event::KeyEvent; + + use super::*; + + fn screen(setup: &mut Setup) -> String { + let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap(); + terminal.draw(|frame| setup.render(frame)).unwrap(); + let buffer = terminal.backend().buffer(); + (0..40) + .map(|y| (0..100).map(|x| buffer[(x, y)].symbol()).collect::()) + .collect::>() + .join("\n") + } + + fn press(setup: &mut Setup, code: KeyCode, modifiers: KeyModifiers) -> Action { + setup.handle(Event::Key(KeyEvent::new(code, modifiers))) + } + + fn type_text(setup: &mut Setup, text: &str) { + for ch in text.chars() { + press(setup, KeyCode::Char(ch), KeyModifiers::NONE); + } + } + + #[test] + fn touch_id_can_be_ticked_where_the_mac_has_it() { + let mut setup = Setup::new(PathBuf::from("/tmp/writui.db"), false); + assert!(!screen(&mut setup).contains("Touch ID")); + + let mut setup = Setup::new(PathBuf::from("/tmp/writui.db"), true); + let text = screen(&mut setup); + assert!(text.contains("[ ] Unlock with Touch ID") && text.contains("Ctrl+T"), "{text}"); + press(&mut setup, KeyCode::Char('t'), KeyModifiers::CONTROL); + assert!(screen(&mut setup).contains("[x] Unlock with Touch ID")); + type_text(&mut setup, "password"); + press(&mut setup, KeyCode::Tab, KeyModifiers::NONE); + type_text(&mut setup, "password"); + let Action::CreateVault { password, touch_id } = + press(&mut setup, KeyCode::Enter, KeyModifiers::NONE) + else { + panic!("not created") + }; + assert_eq!((password.as_str(), touch_id), ("password", true)); + } +} diff --git a/src/tui/unlock.rs b/src/tui/unlock.rs index ba964bb..6667639 100644 --- a/src/tui/unlock.rs +++ b/src/tui/unlock.rs @@ -45,8 +45,9 @@ pub struct Unlock { busy: bool, /// Busy waiting for a fingerprint, rather than checking the password. waiting: bool, - /// Where the Unlock button was drawn, for clicks. + /// Where the Unlock and Touch ID buttons were drawn, for clicks. button: Rect, + touch_id_button: Rect, hints: HintBar, } @@ -74,7 +75,7 @@ impl Unlock { pub fn touch_id_cancelled(&mut self) { self.busy = false; self.waiting = false; - self.notice = Some("Touch ID cancelled. Enter tries again, or type your password."); + self.notice = Some("Touch ID cancelled."); } pub fn touch_id_failed(&mut self, why: &str) { @@ -90,8 +91,7 @@ impl Unlock { self.error = Some("Wrong password.".into()); } - /// Enter (or the button) asks for a fingerprint, rather than trying - /// the password. + /// Enter asks for a fingerprint, rather than trying the password. fn uses_touch_id(&self) -> bool { self.touch_id == TouchId::On && self.field.is_empty() } @@ -146,26 +146,23 @@ impl Unlock { } // The Unlock button, right of the password box and as tall as it, - // left out if the window is too narrow for all of it. Dimmed while - // there's no password for it to try, unless it's for Touch ID. - let button = Rect::new(field_area.right() + 2, field_area.y, 12, height); - self.button = if area.contains(Position::new(button.right() - 1, button.bottom() - 1)) { - button - } else { - Rect::default() + // then the Touch ID button where it's on. Each is left out if the + // window is too narrow for all of it. Unlock is dimmed while there's + // no password for it to try. + let fits = |button: Rect| { + let fits = area.contains(Position::new(button.right() - 1, button.bottom() - 1)); + if fits { button } else { Rect::default() } }; - if !self.button.is_empty() { - let style = if self.busy || (self.field.is_empty() && !self.uses_touch_id()) { - Style::new().dim() - } else { - Style::new().bold() - }; - let label = if self.uses_touch_id() { "Touch ID" } else { "Unlock" }; - let block = Block::bordered().border_style(style); - let label = Paragraph::new(Line::from(label).style(style)).centered(); - frame.render_widget(label, block.inner(button)); - frame.render_widget(block, button); - } + self.button = fits(Rect::new(field_area.right() + 2, field_area.y, 12, height)); + let dim = self.busy || self.field.is_empty(); + draw_button(frame, self.button, "Unlock", dim); + self.touch_id_button = match self.touch_id { + TouchId::On if !self.button.is_empty() => { + fits(Rect::new(self.button.right() + 2, field_area.y, 12, height)) + } + _ => Rect::default(), + }; + draw_button(frame, self.touch_id_button, "Touch ID", self.busy); // Esc clears the password, or quits if there's nothing to clear. let mut hints = vec![match self.uses_touch_id() { @@ -185,7 +182,9 @@ impl Unlock { return Action::None; } match event { - Event::Key(key) if key.code == KeyCode::Enter => self.run(Cmd::Unlock), + Event::Key(key) if key.code == KeyCode::Enter => { + self.run(if self.uses_touch_id() { Cmd::TouchId } else { Cmd::Unlock }) + } Event::Key(key) if key.code == KeyCode::Esc => { self.run(if self.field.is_empty() { Cmd::Quit } else { Cmd::Clear }) } @@ -201,9 +200,13 @@ impl Unlock { Action::None } Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => { - if self.button.contains(Position::new(mouse.column, mouse.row)) { + let pos = Position::new(mouse.column, mouse.row); + if self.button.contains(pos) { return self.run(Cmd::Unlock); } + if self.touch_id_button.contains(pos) { + return self.run(Cmd::TouchId); + } match self.hints.hit(mouse.column, mouse.row) { Some(cmd) => self.run(cmd), None => Action::None, @@ -221,7 +224,7 @@ impl Unlock { self.error = None; Action::None } - Cmd::Unlock | Cmd::TouchId if self.uses_touch_id() => Action::UnlockTouchId, + Cmd::TouchId if self.touch_id == TouchId::On => Action::UnlockTouchId, Cmd::TouchId => Action::None, Cmd::Unlock if self.field.is_empty() => Action::None, Cmd::Unlock => Action::Unlock(self.field.value().clone()), @@ -229,6 +232,18 @@ impl Unlock { } } +/// A button as tall as the password box, at `area` (if it's not empty). +fn draw_button(frame: &mut Frame, area: Rect, label: &str, dim: bool) { + if area.is_empty() { + return; + } + let style = if dim { Style::new().dim() } else { Style::new().bold() }; + let block = Block::bordered().border_style(style); + let label = Paragraph::new(Line::from(label).style(style)).centered(); + frame.render_widget(label, block.inner(area)); + frame.render_widget(block, area); +} + #[cfg(test)] mod tests { use ratatui::Terminal; @@ -255,7 +270,12 @@ mod tests { fn enter_uses_touch_id_until_a_password_is_typed() { let mut unlock = Unlock::locked(TouchId::On); let text = screen(&mut unlock); - assert!(text.contains("Touch ID") && text.contains("Enter Touch ID"), "{text}"); + assert!( + text.contains("Unlock") + && text.contains("│ Touch ID │") + && text.contains("Enter Touch ID"), + "{text}" + ); assert!(matches!(press(&mut unlock, KeyCode::Enter), Action::UnlockTouchId)); unlock.touch_id_cancelled(); assert!(screen(&mut unlock).contains("Touch ID cancelled")); From 4c08510bf0a36aa040594a9341f042332ad1768b Mon Sep 17 00:00:00 2001 From: Aakash N S <1560745+aakashns@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:36:19 +0700 Subject: [PATCH 3/3] A tick in the Touch ID box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticked, the box on "Create your vault" reads "[✓] Unlock with Touch ID". Co-Authored-By: Claude Opus 5.5 --- docs/SPEC.md | 6 +++++- src/tui/setup.rs | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index b479eb4..aedd646 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -226,7 +226,11 @@ in [DECISIONS.md](DECISIONS.md); the build order lives in [PLAN.md](PLAN.md). "Create your vault" has a "[ ] Unlock with Touch ID" box under the password boxes, where the Mac can use it, unticked at first; a click or - Ctrl+T ticks it. The unlock screen keeps its "Unlock" button, with a + Ctrl+T ticks it. + + > you know, i don't want an x in th echeckbox, i want a tick + + Ticked, it reads "[✓] Unlock with Touch ID". The unlock screen keeps its "Unlock" button, with a separate "Touch ID" button right of it where Touch ID is on. It's per Mac and per vault, kept outside the vault, and survives upgrades. It's never offered over SSH. If it stops working (e.g. a fingerprint was diff --git a/src/tui/setup.rs b/src/tui/setup.rs index 9a7ea6c..fca9a22 100644 --- a/src/tui/setup.rs +++ b/src/tui/setup.rs @@ -87,7 +87,7 @@ impl Setup { ]; let touch_id_line = rest.len(); if let Some(on) = self.touch_id { - let label = format!("[{}] Unlock with Touch ID", if on { "x" } else { " " }); + let label = format!("[{}] Unlock with Touch ID", if on { "✓" } else { " " }); rest.extend([Line::from(label), Line::default()]); } rest.extend([ @@ -268,7 +268,7 @@ mod tests { let text = screen(&mut setup); assert!(text.contains("[ ] Unlock with Touch ID") && text.contains("Ctrl+T"), "{text}"); press(&mut setup, KeyCode::Char('t'), KeyModifiers::CONTROL); - assert!(screen(&mut setup).contains("[x] Unlock with Touch ID")); + assert!(screen(&mut setup).contains("[✓] Unlock with Touch ID")); type_text(&mut setup, "password"); press(&mut setup, KeyCode::Tab, KeyModifiers::NONE); type_text(&mut setup, "password");