diff --git a/Cargo.lock b/Cargo.lock index c8b0964d..6928d9a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1895,6 +1895,7 @@ dependencies = [ "rmpv", "rsa", "rustix", + "security-framework", "serde", "serde_json", "serde_path_to_error", diff --git a/Cargo.toml b/Cargo.toml index d348fe04..e3c0baed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ reqwest = { version = "0.12.28", default-features = false, features = [ rmpv = "1.3.0" rsa = "0.9.9" rustix = { version = "1.1.3", features = [ + "event", "termios", "process", "pipe", @@ -88,6 +89,9 @@ arboard = { version = "3.6.1", default-features = false, features = [ "wayland-data-control", ], optional = true } +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = { version = "3.5.1", features = ["OSX_10_13"] } + [features] default = ["clipboard"] clipboard = ["arboard"] diff --git a/README.md b/README.md index eb9074b8..87222caa 100644 --- a/README.md +++ b/README.md @@ -87,15 +87,18 @@ configuration options: * `notifications_url`: The URL of the Bitwarden notifications server to use. If unset, will use the `/notifications` path on the configured `base_url`, or `https://notifications.bitwarden.com/` if no `base_url` is set. -* `lock_timeout`: The number of seconds to keep the master keys in memory for - before requiring the password to be entered again. Defaults to `3600` (one - hour). +* `lock_timeout`: The number of seconds to keep the master keys in memory before + requiring the database to be unlocked again. Defaults to `3600` (one hour). * `sync_interval`: `rbw` will automatically sync the database from the server at an interval of this many seconds, while the agent is running. Setting this value to `0` disables this behavior. Defaults to `3600` (one hour). * `pinentry`: The [pinentry](https://www.gnupg.org/related_software/pinentry/index.html) executable to use. Defaults to `pinentry`. +* `touch_id_unlock`: On macOS, allow Touch ID to unlock the database again + after the master password has unlocked it once in the current agent process. + The Secure Enclave key and wrapped vault key are kept only in memory and are + discarded when the agent exits. Defaults to `false`. ### Profiles @@ -123,6 +126,29 @@ out by running `rbw purge`, and you can explicitly lock the database by running `rbw help` can be used to get more information about the available functionality. +### Touch ID + +On macOS, `rbw` can use Touch ID to unlock the database again after it has been +unlocked once with the master password in the current agent process. Enable it +with: + +```sh +rbw config set touch_id_unlock true +``` + +The first unlock after enabling this setting, rebooting, or running `rbw +stop-agent` still requires the master password. During that agent process, +automatic timeout and `rbw lock` discard the decrypted vault keys but allow the +next unlock to use Touch ID. `rbw purge`, a logout notification from the server, +or a change to the account's protected vault key also discards the Touch ID +session. + +The private key is generated in the Secure Enclave and is not exportable. Both +it and the wrapped vault key exist only for the lifetime of `rbw-agent`; no +master password or additional vault key material is written to disk. Entries +configured to require master password reprompt continue to require the master +password. + Run `rbw get ` to get your passwords. If you also want to get the username or the note associated, you can use the flag `--full`. You can also use the flag `--field={field}` to get whatever default or custom field you want. The `--raw` diff --git a/src/actions.rs b/src/actions.rs index 79d304d4..786d6583 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -110,23 +110,30 @@ pub fn unlock( Err(e) => return Err(e), }; + unlock_with_key(key, protected_private_key, protected_org_keys) +} + +pub fn unlock_with_key( + key: crate::locked::Keys, + protected_private_key: &str, + protected_org_keys: &std::collections::HashMap, +) -> Result<( + crate::locked::Keys, + std::collections::HashMap, +)> { let protected_private_key = crate::cipherstring::CipherString::new(protected_private_key)?; - let private_key = - match protected_private_key.decrypt_locked_symmetric(&key) { - Ok(private_key) => crate::locked::PrivateKey::new(private_key), - Err(e) => return Err(e), - }; + let private_key = crate::locked::PrivateKey::new( + protected_private_key.decrypt_locked_symmetric(&key)?, + ); let mut org_keys = std::collections::HashMap::new(); for (org_id, protected_org_key) in protected_org_keys { let protected_org_key = crate::cipherstring::CipherString::new(protected_org_key)?; - let org_key = - match protected_org_key.decrypt_locked_asymmetric(&private_key) { - Ok(org_key) => crate::locked::Keys::new(org_key), - Err(e) => return Err(e), - }; + let org_key = crate::locked::Keys::new( + protected_org_key.decrypt_locked_asymmetric(&private_key)?, + ); org_keys.insert(org_id.clone(), org_key); } diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 9ddd2ad9..a9e2055b 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -361,9 +361,7 @@ async fn login_success( match res { Ok((keys, org_keys)) => { - let mut state = state.lock().await; - state.priv_key = Some(keys); - state.org_keys = Some(org_keys); + unlock_success(state, keys, org_keys, &protected_key).await?; } Err(e) => return Err(e).context("failed to unlock database"), } @@ -375,6 +373,9 @@ async fn unlock_state( state: std::sync::Arc>, environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { + let unlock_lock = state.lock().await.unlock_lock.clone(); + let _unlock_guard = unlock_lock.lock().await; + if state.lock().await.needs_unlock() { let db = load_db().await?; @@ -391,18 +392,90 @@ async fn unlock_state( let memory = db.memory; let parallelism = db.parallelism; - let Some(protected_key) = db.protected_key else { + let Some(protected_key) = db.protected_key.as_deref() else { return Err(anyhow::anyhow!( "failed to find protected key in db" )); }; - let Some(protected_private_key) = db.protected_private_key else { + let Some(protected_private_key) = db.protected_private_key.as_deref() + else { return Err(anyhow::anyhow!( "failed to find protected private key in db" )); }; - let email = config_email().await?; + let config = rbw::config::Config::load_async().await?; + let email = config.email.ok_or_else(|| { + anyhow::anyhow!("failed to find email address in config") + })?; + + #[cfg(target_os = "macos")] + { + let biometric_unlock = { + let state = state.lock().await; + if state.touch_id_unlock { + state.biometric_unlock.clone() + } else { + None + } + }; + if let Some(biometric_unlock) = biometric_unlock { + if biometric_unlock.matches(protected_key) { + let biometric_unlock_for_task = biometric_unlock.clone(); + let biometric_key = + tokio::task::spawn_blocking(move || { + biometric_unlock_for_task.unlock() + }) + .await + .context("failed to join Touch ID task")?; + match biometric_key { + Ok(key) => match rbw::actions::unlock_with_key( + key, + protected_private_key, + &db.protected_org_keys, + ) { + Ok((keys, org_keys)) => { + if set_biometric_unlocked( + state, + &biometric_unlock, + keys, + org_keys, + ) + .await + { + return Ok(()); + } + return Err(anyhow::anyhow!( + "Touch ID session was revoked" + )); + } + Err(error) => { + log::warn!( + "failed to unlock with cached vault key: \ + {error}" + ); + state.lock().await.biometric_unlock = None; + } + }, + Err(crate::biometric::UnlockError::Canceled) => { + return Err(anyhow::anyhow!( + "Touch ID was canceled" + )); + } + Err( + crate::biometric::UnlockError::PasswordRequested, + ) => {} + Err(crate::biometric::UnlockError::Unavailable( + error, + )) => { + log::warn!("Touch ID is unavailable: {error}"); + } + } + } else { + state.lock().await.biometric_unlock = None; + } + } + } let mut err_msg = None; for i in 1_u8..=3 { @@ -433,12 +506,13 @@ async fn unlock_state( iterations, memory, parallelism, - &protected_key, - &protected_private_key, + protected_key, + protected_private_key, &db.protected_org_keys, ) { Ok((keys, org_keys)) => { - unlock_success(state, keys, org_keys).await?; + unlock_success(state, keys, org_keys, protected_key) + .await?; break; } Err(rbw::error::Error::IncorrectPassword { message }) => { @@ -474,13 +548,71 @@ async fn unlock_success( state: std::sync::Arc>, keys: rbw::locked::Keys, org_keys: std::collections::HashMap, + protected_key: &str, ) -> anyhow::Result<()> { + #[cfg(not(target_os = "macos"))] + let _ = protected_key; + + #[cfg(target_os = "macos")] + let biometric_unlock = { + let enabled = state.lock().await.touch_id_unlock; + if enabled { + let keys = keys.clone(); + let protected_key = protected_key.to_string(); + match tokio::task::spawn_blocking(move || { + crate::biometric::Session::new(&keys, &protected_key) + }) + .await + { + Ok(Ok(session)) => Some(std::sync::Arc::new(session)), + Ok(Err(error)) => { + log::warn!( + "failed to initialize Touch ID unlock: {error}" + ); + None + } + Err(error) => { + log::warn!( + "failed to join Touch ID initialization task: {error}" + ); + None + } + } + } else { + None + } + }; + let mut state = state.lock().await; state.priv_key = Some(keys); state.org_keys = Some(org_keys); + #[cfg(target_os = "macos")] + { + state.biometric_unlock = biometric_unlock; + } Ok(()) } +#[cfg(target_os = "macos")] +async fn set_biometric_unlocked( + state: std::sync::Arc>, + biometric_unlock: &std::sync::Arc, + keys: rbw::locked::Keys, + org_keys: std::collections::HashMap, +) -> bool { + let mut state = state.lock().await; + let is_current_session = + state.biometric_unlock.as_ref().is_some_and(|session| { + std::sync::Arc::ptr_eq(session, biometric_unlock) + }); + if !is_current_session { + return false; + } + state.priv_key = Some(keys); + state.org_keys = Some(org_keys); + true +} + pub async fn lock( sock: &mut crate::sock::Sock, state: std::sync::Arc>, @@ -527,6 +659,11 @@ pub async fn sync( ) = rbw::actions::sync(&access_token, &refresh_token) .await .context("failed to sync database from server")?; + #[cfg(target_os = "macos")] + let protected_key_changed = db + .protected_key + .as_deref() + .is_some_and(|old_protected_key| old_protected_key != protected_key); state.lock().await.set_master_password_reprompt(&entries); if let Some(access_token) = access_token { db.access_token = Some(access_token); @@ -536,6 +673,14 @@ pub async fn sync( db.protected_org_keys = protected_org_keys; db.entries = entries; save_db(&db).await?; + #[cfg(target_os = "macos")] + if protected_key_changed { + // Changing the password wrapper does not necessarily rotate the live + // vault key. Revoke only the cached alternative unlock route; changing + // the cross-platform lock state here would be an unrelated behavior + // change. A later unlock also checks the digest before using a session. + state.lock().await.biometric_unlock = None; + } if let Err(e) = subscribe_to_notifications(state.clone()).await { eprintln!("failed to subscribe to notifications: {e}"); diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index 1691ed51..e95662af 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -27,6 +27,7 @@ impl Agent { pub enum Event { Request(std::io::Result), Timeout(()), + Logout(()), Sync(()), } @@ -42,7 +43,7 @@ impl Agent { notifications, ) .map(|message| match message { - crate::notifications::Message::Logout => Event::Timeout(()), + crate::notifications::Message::Logout => Event::Logout(()), crate::notifications::Message::Sync => Event::Sync(()), }) .boxed(); @@ -86,6 +87,9 @@ impl Agent { Event::Timeout(()) => { self.state.lock().await.clear(); } + Event::Logout(()) => { + self.state.lock().await.clear_all(); + } Event::Sync(()) => { let state = self.state.clone(); tokio::spawn(async move { diff --git a/src/bin/rbw-agent/biometric.rs b/src/bin/rbw-agent/biometric.rs new file mode 100644 index 00000000..bc32fd85 --- /dev/null +++ b/src/bin/rbw-agent/biometric.rs @@ -0,0 +1,512 @@ +use std::io::{Read as _, Write as _}; +use std::os::fd::BorrowedFd; +use std::time::{Duration, Instant}; + +use sha2::Digest as _; +use zeroize::Zeroize as _; + +use security_framework::access_control::{ProtectionMode, SecAccessControl}; +use security_framework::key::{ + Algorithm, GenerateKeyOptions, KeyType, SecKey, Token, +}; +use security_framework::passwords::AccessControlOptions; + +pub const HELPER_ARGUMENT: &str = "--biometric-helper"; + +const VAULT_KEY_LEN: usize = 64; +const REQUEST_UNLOCK: u8 = 1; +const RESPONSE_SUCCESS: u8 = 0; +const RESPONSE_CANCELED: u8 = 1; +const RESPONSE_PASSWORD_REQUESTED: u8 = 2; +const RESPONSE_UNAVAILABLE: u8 = 3; +const RESPONSE_HARDENED: u8 = 4; +const RESPONSE_READY: u8 = 5; +const HELPER_INIT_TIMEOUT: Duration = Duration::from_secs(10); +const LOCAL_AUTHENTICATION_ERROR_DOMAIN: &str = + "com.apple.LocalAuthentication"; +const OS_STATUS_ERROR_DOMAIN: &str = "NSOSStatusErrorDomain"; + +// Security framework does not present Touch ID reliably from the daemon's +// Tokio workers. This private child owns the Secure Enclave key and performs +// authentication on its main thread, communicating only over anonymous pipes. +pub struct Session { + helper: std::sync::Mutex, + protected_key_digest: [u8; 32], +} + +struct Helper { + child: std::process::Child, + stdin: std::process::ChildStdin, + stdout: std::process::ChildStdout, +} + +struct KeyMaterial { + private_key: SecKey, + wrapped_vault_key: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum UnlockError { + #[error("Touch ID was canceled")] + Canceled, + #[error("master password requested")] + PasswordRequested, + #[error("Touch ID is unavailable: {0}")] + Unavailable(String), +} + +impl Session { + pub fn new( + vault_key: &rbw::locked::Keys, + protected_key: &str, + ) -> Result { + let mut helper = Helper::spawn()?; + + // The helper acknowledges only after PT_DENY_ATTACH and RLIMIT_CORE + // succeed. Never place the vault key in the pipe before that boundary. + if helper.read_init_response()? != RESPONSE_HARDENED { + return Err(UnlockError::Unavailable( + "biometric helper did not enter its hardened state" + .to_string(), + )); + } + helper + .stdin + .write_all(vault_key.as_bytes()) + .map_err(unavailable)?; + helper.stdin.flush().map_err(unavailable)?; + if helper.read_init_response()? != RESPONSE_READY { + return Err(UnlockError::Unavailable( + "biometric helper failed to initialize".to_string(), + )); + } + + Ok(Self { + helper: std::sync::Mutex::new(helper), + protected_key_digest: protected_key_digest(protected_key), + }) + } + + pub fn matches(&self, protected_key: &str) -> bool { + self.protected_key_digest == protected_key_digest(protected_key) + } + + pub fn unlock(&self) -> Result { + let mut helper = self.helper.lock().map_err(|_| { + UnlockError::Unavailable( + "biometric helper lock was poisoned".to_string(), + ) + })?; + helper + .stdin + .write_all(&[REQUEST_UNLOCK]) + .map_err(unavailable)?; + helper.stdin.flush().map_err(unavailable)?; + + let mut response = [0_u8; 1]; + helper + .stdout + .read_exact(&mut response) + .map_err(unavailable)?; + match response[0] { + RESPONSE_SUCCESS => { + let mut key = rbw::locked::Vec::new(); + key.zero(); + if let Err(error) = helper + .stdout + .read_exact(&mut key.data_mut()[..VAULT_KEY_LEN]) + { + return Err(unavailable(error)); + } + key.truncate(VAULT_KEY_LEN); + Ok(rbw::locked::Keys::new(key)) + } + RESPONSE_CANCELED => Err(UnlockError::Canceled), + RESPONSE_PASSWORD_REQUESTED => { + Err(UnlockError::PasswordRequested) + } + RESPONSE_UNAVAILABLE => Err(UnlockError::Unavailable( + "biometric helper could not unlock the vault key".to_string(), + )), + response => Err(UnlockError::Unavailable(format!( + "biometric helper returned unknown response {response}" + ))), + } + } +} + +impl Helper { + fn spawn() -> Result { + let executable = std::env::current_exe().map_err(unavailable)?; + let mut child = std::process::Command::new(executable) + .arg(HELPER_ARGUMENT) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn() + .map_err(unavailable)?; + let Some(stdin) = child.stdin.take() else { + let _ = child.kill(); + let _ = child.wait(); + return Err(UnlockError::Unavailable( + "failed to open biometric helper stdin".to_string(), + )); + }; + let Some(stdout) = child.stdout.take() else { + let _ = child.kill(); + let _ = child.wait(); + return Err(UnlockError::Unavailable( + "failed to open biometric helper stdout".to_string(), + )); + }; + Ok(Self { + child, + stdin, + stdout, + }) + } + + fn read_init_response(&mut self) -> Result { + read_byte_with_timeout(&mut self.stdout, HELPER_INIT_TIMEOUT) + .map_err(unavailable) + } +} + +impl Drop for Helper { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl KeyMaterial { + fn new(vault_key: &[u8]) -> Result { + let flags = AccessControlOptions::PRIVATE_KEY_USAGE + | AccessControlOptions::BIOMETRY_CURRENT_SET; + let access_control = SecAccessControl::create_with_protection( + Some(ProtectionMode::AccessibleWhenPasscodeSetThisDeviceOnly), + flags.bits(), + ) + .map_err(unavailable)?; + + let mut options = GenerateKeyOptions::default(); + // Deliberately omit a keychain Location. security-framework then sets + // kSecAttrIsPermanent=false, so the key dies with this helper process + // and needs no signing entitlement or cleanup after a crash. + options + .set_key_type(KeyType::ec_sec_prime_random()) + .set_size_in_bits(256) + .set_label("rbw session Touch ID unlock") + .set_token(Token::SecureEnclave) + .set_access_control(access_control); + + let private_key = SecKey::new(&options).map_err(unavailable)?; + let public_key = private_key.public_key().ok_or_else(|| { + UnlockError::Unavailable( + "failed to copy the Secure Enclave public key".to_string(), + ) + })?; + let wrapped_vault_key = public_key + .encrypt_data( + Algorithm::ECIESEncryptionCofactorX963SHA256AESGCM, + vault_key, + ) + .map_err(unavailable)?; + + Ok(Self { + private_key, + wrapped_vault_key, + }) + } + + fn unlock(&self) -> Result { + let mut plaintext = self + .private_key + .decrypt_data( + Algorithm::ECIESEncryptionCofactorX963SHA256AESGCM, + &self.wrapped_vault_key, + ) + .map_err(|error| { + classify_authentication_error( + &error.domain().to_string(), + error.code(), + error.to_string(), + ) + })?; + if plaintext.len() != VAULT_KEY_LEN { + plaintext.zeroize(); + return Err(UnlockError::Unavailable( + "Secure Enclave returned an invalid vault key".to_string(), + )); + } + // security-framework necessarily returns a pageable Vec copied from + // CFData. Minimize that unavoidable window before zeroizing it. + let mut locked = rbw::locked::Vec::new(); + locked.zero(); + locked.data_mut()[..VAULT_KEY_LEN].copy_from_slice(&plaintext); + locked.truncate(VAULT_KEY_LEN); + plaintext.zeroize(); + Ok(locked) + } +} + +pub fn helper_main() -> anyhow::Result<()> { + // Bypass stdio's global user-space buffers so plaintext key bytes cannot + // remain in a Stdin/Stdout allocation after the pipe operation completes. + // SAFETY: these descriptors remain open for the helper process lifetime. + let stdin = unsafe { BorrowedFd::borrow_raw(libc::STDIN_FILENO) }; + // SAFETY: same as above. + let stdout = unsafe { BorrowedFd::borrow_raw(libc::STDOUT_FILENO) }; + + write_all_fd(stdout, &[RESPONSE_HARDENED])?; + let mut vault_key = rbw::locked::Vec::new(); + vault_key.zero(); + read_exact_fd(stdin, &mut vault_key.data_mut()[..VAULT_KEY_LEN])?; + let key_material = KeyMaterial::new(&vault_key.data()[..VAULT_KEY_LEN]); + drop(vault_key); + + let key_material = match key_material { + Ok(key_material) => key_material, + Err(error) => { + eprintln!("failed to initialize biometric helper: {error}"); + write_all_fd(stdout, &[RESPONSE_UNAVAILABLE])?; + return Ok(()); + } + }; + write_all_fd(stdout, &[RESPONSE_READY])?; + + loop { + let mut request = [0_u8; 1]; + match read_exact_fd(stdin, &mut request) { + Ok(()) => {} + Err(error) + if error.kind() == std::io::ErrorKind::UnexpectedEof => + { + return Ok(()) + } + Err(error) => return Err(error.into()), + } + if request[0] != REQUEST_UNLOCK { + anyhow::bail!("unknown biometric helper request {}", request[0]); + } + + match key_material.unlock() { + Ok(plaintext) => { + write_all_fd(stdout, &[RESPONSE_SUCCESS])?; + let result = write_all_fd(stdout, plaintext.data()); + drop(plaintext); + result?; + } + Err(UnlockError::Canceled) => { + write_all_fd(stdout, &[RESPONSE_CANCELED])?; + } + Err(UnlockError::PasswordRequested) => { + write_all_fd(stdout, &[RESPONSE_PASSWORD_REQUESTED])?; + } + Err(UnlockError::Unavailable(error)) => { + eprintln!("Touch ID is unavailable: {error}"); + write_all_fd(stdout, &[RESPONSE_UNAVAILABLE])?; + } + } + } +} + +fn protected_key_digest(protected_key: &str) -> [u8; 32] { + sha2::Sha256::digest(protected_key).into() +} + +fn classify_authentication_error( + domain: &str, + code: isize, + description: String, +) -> UnlockError { + // Error numbers overlap between LocalAuthentication and OSStatus. Only + // explicit user choices suppress the master-password fallback; system and + // app cancellation are environmental failures and should fall back. + match (domain, code) { + (LOCAL_AUTHENTICATION_ERROR_DOMAIN, -2) + | (OS_STATUS_ERROR_DOMAIN, -128) => UnlockError::Canceled, + (LOCAL_AUTHENTICATION_ERROR_DOMAIN, -3) => { + UnlockError::PasswordRequested + } + _ => UnlockError::Unavailable(description), + } +} + +fn read_byte_with_timeout( + reader: &mut R, + timeout: Duration, +) -> std::io::Result { + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "timed out waiting for biometric helper", + )); + } + let timeout = rustix::event::Timespec { + tv_sec: i64::try_from(remaining.as_secs()).unwrap(), + tv_nsec: remaining.subsec_nanos().into(), + }; + let ready = { + let mut descriptor = [rustix::event::PollFd::new( + reader, + rustix::event::PollFlags::IN, + )]; + rustix::event::poll(&mut descriptor, Some(&timeout)) + }; + match ready { + Ok(0) => { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "timed out waiting for biometric helper", + )) + } + Ok(_) => { + let mut response = [0_u8; 1]; + reader.read_exact(&mut response)?; + return Ok(response[0]); + } + Err(error) if error == rustix::io::Errno::INTR => {} + Err(error) => return Err(error.into()), + } + } +} + +fn read_exact_fd( + fd: BorrowedFd<'_>, + mut buffer: &mut [u8], +) -> std::io::Result<()> { + while !buffer.is_empty() { + match rustix::io::read(fd, &mut *buffer) { + Ok(0) => { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "biometric helper pipe closed", + )) + } + Ok(read) => buffer = &mut buffer[read..], + Err(error) if error == rustix::io::Errno::INTR => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +fn write_all_fd( + fd: BorrowedFd<'_>, + mut buffer: &[u8], +) -> std::io::Result<()> { + while !buffer.is_empty() { + match rustix::io::write(fd, buffer) { + Ok(0) => { + return Err(std::io::Error::new( + std::io::ErrorKind::WriteZero, + "failed to write biometric helper pipe", + )) + } + Ok(written) => buffer = &buffer[written..], + Err(error) if error == rustix::io::Errno::INTR => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +fn unavailable(error: impl std::fmt::Display) -> UnlockError { + UnlockError::Unavailable(error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::fd::AsFd as _; + + #[test] + fn protected_key_digest_is_stable_and_distinguishes_keys() { + assert_eq!( + protected_key_digest("protected key"), + protected_key_digest("protected key") + ); + assert_ne!( + protected_key_digest("protected key"), + protected_key_digest("rotated key") + ); + } + + #[test] + fn authentication_errors_preserve_user_intent() { + assert!(matches!( + classify_authentication_error( + LOCAL_AUTHENTICATION_ERROR_DOMAIN, + -2, + "canceled".to_string() + ), + UnlockError::Canceled + )); + assert!(matches!( + classify_authentication_error( + LOCAL_AUTHENTICATION_ERROR_DOMAIN, + -3, + "fallback".to_string() + ), + UnlockError::PasswordRequested + )); + assert!(matches!( + classify_authentication_error( + LOCAL_AUTHENTICATION_ERROR_DOMAIN, + -4, + "system canceled".to_string() + ), + UnlockError::Unavailable(_) + )); + assert!(matches!( + classify_authentication_error( + OS_STATUS_ERROR_DOMAIN, + -4, + "unimplemented".to_string() + ), + UnlockError::Unavailable(_) + )); + assert!(matches!( + classify_authentication_error( + OS_STATUS_ERROR_DOMAIN, + -128, + "canceled".to_string() + ), + UnlockError::Canceled + )); + } + + #[test] + fn unbuffered_pipe_io_roundtrips() { + let (reader, writer) = rustix::pipe::pipe().unwrap(); + write_all_fd(writer.as_fd(), b"vault key bytes").unwrap(); + let mut output = [0_u8; 15]; + read_exact_fd(reader.as_fd(), &mut output).unwrap(); + assert_eq!(&output, b"vault key bytes"); + } + + #[test] + fn helper_response_wait_is_bounded() { + let (reader, _writer) = rustix::pipe::pipe().unwrap(); + let mut reader = std::fs::File::from(reader); + let error = + read_byte_with_timeout(&mut reader, Duration::from_millis(10)) + .unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::TimedOut); + } + + #[test] + fn helper_response_is_read_before_timeout() { + let (reader, writer) = rustix::pipe::pipe().unwrap(); + write_all_fd(writer.as_fd(), &[RESPONSE_HARDENED]).unwrap(); + let mut reader = std::fs::File::from(reader); + assert_eq!( + read_byte_with_timeout(&mut reader, Duration::from_secs(1)) + .unwrap(), + RESPONSE_HARDENED + ); + } +} diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index 225fb436..ab5d8f81 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -2,6 +2,8 @@ use anyhow::Context as _; mod actions; mod agent; +#[cfg(target_os = "macos")] +mod biometric; mod daemon; mod debugger; mod notifications; @@ -41,6 +43,11 @@ async fn tokio_main( notifications_handler, master_password_reprompt: std::collections::HashSet::new(), master_password_reprompt_initialized: false, + unlock_lock: std::sync::Arc::new(tokio::sync::Mutex::new(())), + #[cfg(target_os = "macos")] + touch_id_unlock: config.touch_id_unlock, + #[cfg(target_os = "macos")] + biometric_unlock: None, last_environment: rbw::protocol::Environment::default(), #[cfg(feature = "clipboard")] clipboard: arboard::Clipboard::new() @@ -61,6 +68,17 @@ async fn tokio_main( } fn real_main() -> anyhow::Result<()> { + #[cfg(target_os = "macos")] + if std::env::args().nth(1).as_deref() + == Some(crate::biometric::HELPER_ARGUMENT) + { + // Do not acknowledge the helper or accept vault key material until + // both debugger attachment and core dumps have been disabled. + debugger::disable_tracing() + .context("failed to secure biometric helper")?; + return crate::biometric::helper_main(); + } + env_logger::Builder::from_env( env_logger::Env::default().default_filter_or("info"), ) diff --git a/src/bin/rbw-agent/state.rs b/src/bin/rbw-agent/state.rs index 15ba565d..5c6ed974 100644 --- a/src/bin/rbw-agent/state.rs +++ b/src/bin/rbw-agent/state.rs @@ -11,6 +11,13 @@ pub struct State { pub notifications_handler: crate::notifications::Handler, pub master_password_reprompt: std::collections::HashSet<[u8; 32]>, pub master_password_reprompt_initialized: bool, + pub unlock_lock: std::sync::Arc>, + #[cfg(target_os = "macos")] + // Configuration changes stop the agent, so this startup snapshot avoids + // rereading config on every successful password unlock. + pub touch_id_unlock: bool, + #[cfg(target_os = "macos")] + pub biometric_unlock: Option>, // this is stored here specifically for the use of the ssh agent, because // requests made to the ssh agent don't include an environment, and so we @@ -49,6 +56,14 @@ impl State { self.timeout.clear(); } + pub fn clear_all(&mut self) { + self.clear(); + #[cfg(target_os = "macos")] + { + self.biometric_unlock = None; + } + } + pub fn set_sync_timeout(&self) { self.sync_timeout.set(self.sync_timeout_duration); } diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index bddf0efe..019526d8 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1269,6 +1269,18 @@ pub fn config_set(key: &str, value: &str) -> anyhow::Result<()> { config.sync_interval = interval; } "pinentry" => config.pinentry = value.to_string(), + "touch_id_unlock" => { + let enabled = value + .parse() + .context("failed to parse value for touch_id_unlock")?; + #[cfg(not(target_os = "macos"))] + if enabled { + return Err(anyhow::anyhow!( + "touch_id_unlock is only supported on macOS" + )); + } + config.touch_id_unlock = enabled; + } _ => return Err(anyhow::anyhow!("invalid config key: {key}")), } config.save()?; @@ -1298,6 +1310,7 @@ pub fn config_unset(key: &str) -> anyhow::Result<()> { config.lock_timeout = rbw::config::default_lock_timeout(); } "pinentry" => config.pinentry = rbw::config::default_pinentry(), + "touch_id_unlock" => config.touch_id_unlock = false, _ => return Err(anyhow::anyhow!("invalid config key: {key}")), } config.save()?; diff --git a/src/config.rs b/src/config.rs index 248c603c..7833a535 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,6 +18,8 @@ pub struct Config { pub sync_interval: u64, #[serde(default = "default_pinentry")] pub pinentry: String, + #[serde(default)] + pub touch_id_unlock: bool, pub client_cert_path: Option, // backcompat, no longer generated in new configs #[serde(skip_serializing)] @@ -36,6 +38,7 @@ impl Default for Config { lock_timeout: default_lock_timeout(), sync_interval: default_sync_interval(), pinentry: default_pinentry(), + touch_id_unlock: false, client_cert_path: None, device_id: None, } @@ -247,3 +250,14 @@ pub async fn device_id(config: &Config) -> Result { Ok(id) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn old_config_defaults_touch_id_unlock_to_false() { + let config: Config = serde_json::from_str("{}").unwrap(); + assert!(!config.touch_id_unlock); + } +} diff --git a/src/locked.rs b/src/locked.rs index ce031510..5388752c 100644 --- a/src/locked.rs +++ b/src/locked.rs @@ -6,8 +6,10 @@ static REGION_LOCK_WORKS: std::sync::OnceLock = std::sync::OnceLock::new(); pub struct Vec { - data: Box>, + // Fields are dropped in declaration order after `Vec::drop`. Keep the + // guard first so it unlocks the allocation before `data` is deallocated. _lock: Option, + data: Box>, } impl Default for Vec { @@ -31,7 +33,7 @@ impl Default for Vec { } }, }; - Self { data, _lock: lock } + Self { _lock: lock, data } } } @@ -109,6 +111,10 @@ impl Keys { pub fn mac_key(&self) -> &[u8] { &self.keys.data()[32..64] } + + pub fn as_bytes(&self) -> &[u8] { + &self.keys.data()[0..64] + } } #[derive(Clone)] @@ -163,3 +169,20 @@ impl ApiKey { self.client_secret.password() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_as_bytes_excludes_trailing_buffer_data() { + let mut data = Vec::new(); + data.extend(0_u8..80); + let keys = Keys::new(data); + + assert_eq!( + keys.as_bytes(), + &(0_u8..64).collect::>() + ); + } +}