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..dd10f43 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,15 @@ 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: 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 +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 +139,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..aedd646 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -207,7 +207,37 @@ 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 (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. + + > 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 + 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..bb9b647 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -32,14 +32,21 @@ 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. 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, NewPost, OpenPost(i64), /// Move to the Trash, then highlight `select` in the list. @@ -81,6 +88,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,27 +208,39 @@ 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())) + Screen::Setup(setup::Setup::new(vault_path.clone(), touch_id::available())) }; 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 +323,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 @@ -409,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), @@ -417,6 +438,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)?; @@ -512,12 +552,25 @@ 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) => { + // 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 +582,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 +699,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,12 +764,25 @@ 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; 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)) } @@ -711,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(); @@ -784,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") }; @@ -811,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/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/setup.rs b/src/tui/setup.rs index 62715ff..fca9a22 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 { "✓" } 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("[✓] 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 8ef118c..6667639 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, - /// Where the Unlock button was drawn, for clicks. + /// Busy waiting for a fingerprint, rather than checking the password. + waiting: bool, + /// Where the Unlock and Touch ID buttons were drawn, for clicks. button: Rect, + touch_id_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."); + } + + 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 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())]); } @@ -87,28 +146,29 @@ 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. - 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.field.is_empty() || self.busy { - Style::new().dim() - } else { - Style::new().bold() - }; - let block = Block::bordered().border_style(style); - let label = Paragraph::new(Line::from("Unlock").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![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 { @@ -122,13 +182,16 @@ 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 }) } Event::Key(key) => { if self.field.handle_key(key) { self.error = None; + self.notice = None; } Action::None } @@ -137,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, @@ -157,8 +224,76 @@ impl Unlock { self.error = None; Action::None } + 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()), } } } + +/// 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; + 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("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")); + 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();