From 4c1389ea7fa94b0e499521321b91f60cdb155f8f Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 27 Apr 2026 22:53:51 +0200 Subject: [PATCH 001/273] Enable asking for confirmation on SSH signature request Add the capability to ask for confirmation on each SSH signature request. This is done by adding a pinentry::confirm function, a `confirm_ssh` option that defaults to None and a condition under the `sign` method in SshAgent's implementation of the ssh_agent_lib Session trait --- README.md | 2 ++ src/bin/rbw-agent/ssh_agent.rs | 33 ++++++++++++++++++ src/bin/rbw/commands.rs | 2 ++ src/config.rs | 6 ++++ src/pinentry.rs | 63 ++++++++++++++++++++++++++++++---- 5 files changed, 99 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index eb9074b8..a2367444 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,8 @@ configuration options: * `pinentry`: The [pinentry](https://www.gnupg.org/related_software/pinentry/index.html) executable to use. Defaults to `pinentry`. +* `confirm_ssh`: If set to `true` will ask for confirmation for SSH signature +requests. If unset defaults to not asking. ### Profiles diff --git a/src/bin/rbw-agent/ssh_agent.rs b/src/bin/rbw-agent/ssh_agent.rs index 4d0bb50c..7a5b1b3b 100644 --- a/src/bin/rbw-agent/ssh_agent.rs +++ b/src/bin/rbw-agent/ssh_agent.rs @@ -3,6 +3,16 @@ use signature::{RandomizedSigner as _, SignatureEncoding as _, Signer as _}; const SSH_AGENT_RSA_SHA2_256: u32 = 2; const SSH_AGENT_RSA_SHA2_512: u32 = 4; +async fn config_pinentry() -> anyhow::Result { + let config = rbw::config::Config::load_async().await?; + Ok(config.pinentry) +} + +async fn config_confirm_ssh() -> anyhow::Result { + let config = rbw::config::Config::load_async().await?; + Ok(config.confirm_ssh.is_some_and(|o| o == true)) +} + #[derive(Clone)] pub struct SshAgent { state: std::sync::Arc>, @@ -67,6 +77,29 @@ impl ssh_agent_lib::agent::Session for SshAgent { ssh_agent_lib::error::AgentError::Other(e.into()) })?; + if config_confirm_ssh().await.map_err(|_| { + ssh_agent_lib::error::AgentError::Other( + "Unable to load configuration".into(), + ) + })? { + let confirmed = rbw::pinentry::confirm( + &config_pinentry() + .await + .map_err(|_| ssh_agent_lib::error::AgentError::Failure)?, + "Allow SSH key use?", + &self.state.lock().await.last_environment, + true, + ) + .await + .map_err(|_| ssh_agent_lib::error::AgentError::Failure)?; + + if !confirmed { + return Err(ssh_agent_lib::error::AgentError::Other( + "User did not confirm".into(), + )); + } + } + match private_key.key_data() { ssh_agent_lib::ssh_key::private::KeypairData::Ed25519(key) => key .try_sign(&request.data) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index bddf0efe..5383e12c 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1269,6 +1269,7 @@ pub fn config_set(key: &str, value: &str) -> anyhow::Result<()> { config.sync_interval = interval; } "pinentry" => config.pinentry = value.to_string(), + "confirm_ssh" => config.confirm_ssh = Some(value == "true"), _ => return Err(anyhow::anyhow!("invalid config key: {key}")), } config.save()?; @@ -1298,6 +1299,7 @@ pub fn config_unset(key: &str) -> anyhow::Result<()> { config.lock_timeout = rbw::config::default_lock_timeout(); } "pinentry" => config.pinentry = rbw::config::default_pinentry(), + "confirm_ssh" => config.confirm_ssh = rbw::config::default_confirm_ssh(), _ => return Err(anyhow::anyhow!("invalid config key: {key}")), } config.save()?; diff --git a/src/config.rs b/src/config.rs index 248c603c..2dddf724 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,6 +18,7 @@ pub struct Config { pub sync_interval: u64, #[serde(default = "default_pinentry")] pub pinentry: String, + pub confirm_ssh: Option, pub client_cert_path: Option, // backcompat, no longer generated in new configs #[serde(skip_serializing)] @@ -36,6 +37,7 @@ impl Default for Config { lock_timeout: default_lock_timeout(), sync_interval: default_sync_interval(), pinentry: default_pinentry(), + confirm_ssh: None, client_cert_path: None, device_id: None, } @@ -54,6 +56,10 @@ pub fn default_pinentry() -> String { "pinentry".to_string() } +pub fn default_confirm_ssh() -> Option { + None +} + impl Config { pub fn new() -> Self { Self::default() diff --git a/src/pinentry.rs b/src/pinentry.rs index ab316d72..ab2a0230 100644 --- a/src/pinentry.rs +++ b/src/pinentry.rs @@ -2,16 +2,13 @@ use crate::prelude::*; use std::convert::TryFrom as _; -use tokio::io::AsyncWriteExt as _; +use tokio::{io::AsyncWriteExt as _, process::Child}; -pub async fn getpin( +fn spawn_pinentry( pinentry: &str, - prompt: &str, - desc: &str, - err: Option<&str>, environment: &crate::protocol::Environment, grab: bool, -) -> Result { +) -> Result { let mut opts = tokio::process::Command::new(pinentry); opts.stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()); @@ -42,9 +39,22 @@ pub async fn getpin( } opts.envs(env_vars); - let mut child = opts.spawn().map_err(|source| Error::Spawn { source })?; + let child = opts.spawn().map_err(|source| Error::Spawn { source })?; // unwrap is safe because we specified stdin as piped in the command opts // above + + Ok(child) +} + +pub async fn getpin( + pinentry: &str, + prompt: &str, + desc: &str, + err: Option<&str>, + environment: &crate::protocol::Environment, + grab: bool, +) -> Result { + let mut child = spawn_pinentry(pinentry, environment, grab)?; let mut stdin = child.stdin.take().unwrap(); let mut ncommands = 1; @@ -97,6 +107,45 @@ pub async fn getpin( Ok(crate::locked::Password::new(buf)) } +pub async fn confirm( + pinentry: &str, + desc: &str, + environment: &crate::protocol::Environment, + grab: bool, +) -> Result { + let mut child = spawn_pinentry(pinentry, environment, grab)?; + let mut stdin = child.stdin.take().unwrap(); + + let mut ncommands = 1; + stdin + .write_all(b"SETTITLE rbw\n") + .await + .map_err(|source| Error::WriteStdin { source })?; + ncommands += 1; + stdin + .write_all(format!("SETDESC {desc}\n").as_bytes()) + .await + .map_err(|source| Error::WriteStdin { source })?; + ncommands += 1; + stdin + .write_all(b"CONFIRM\n") + .await + .map_err(|source| Error::WriteStdin { source })?; + ncommands += 1; + drop(stdin); + + let mut buf = [0u8; 64]; + read_password(ncommands, &mut buf, child.stdout.as_mut().unwrap()) + .await?; + + child + .wait() + .await + .map_err(|source| Error::PinentryWait { source })?; + + Ok(true) +} + async fn read_password( mut ncommands: u8, data: &mut [u8], From 673d664c93df1acfa28f5f0ea36a6632db631157 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 7 May 2026 18:26:27 +0200 Subject: [PATCH 002/273] read client cert in one shot --- src/api.rs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/api.rs b/src/api.rs index a817fb26..17351354 100644 --- a/src/api.rs +++ b/src/api.rs @@ -863,19 +863,13 @@ impl Client { env!("CARGO_PKG_VERSION") ); if let Some(client_cert_path) = self.client_cert_path.as_ref() { - let mut buf = Vec::new(); - let mut f = tokio::fs::File::open(client_cert_path) - .await - .map_err(|e| Error::LoadClientCert { - source: e, - file: client_cert_path.clone(), + let buf = + tokio::fs::read(client_cert_path).await.map_err(|e| { + Error::LoadClientCert { + source: e, + file: client_cert_path.clone(), + } })?; - f.read_to_end(&mut buf).await.map_err(|e| { - Error::LoadClientCert { - source: e, - file: client_cert_path.clone(), - } - })?; let pem = reqwest::Identity::from_pem(&buf) .map_err(|e| Error::CreateReqwestClient { source: e })?; Ok(reqwest::Client::builder() From 49fc525fa0c52b22034e066a3654b0a89c34fb07 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 7 May 2026 21:37:39 +0200 Subject: [PATCH 003/273] remove is-terminal --- Cargo.lock | 18 ------------------ Cargo.toml | 1 - src/edit.rs | 4 +--- 3 files changed, 1 insertion(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c8b0964d..6571e1d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -869,12 +869,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - [[package]] name = "hkdf" version = "0.12.4" @@ -1154,17 +1148,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "is-wsl" version = "0.4.0" @@ -1880,7 +1863,6 @@ dependencies = [ "hkdf", "hmac", "humantime", - "is-terminal", "libc", "log", "open", diff --git a/Cargo.toml b/Cargo.toml index d348fe04..a10f19c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,6 @@ futures-util = "0.3.31" hkdf = "0.12.4" hmac = { version = "0.12.1", features = ["std"] } humantime = "2.3.0" -is-terminal = "0.4.17" libc = "0.2.178" log = "0.4.29" open = "5.3.3" diff --git a/src/edit.rs b/src/edit.rs index 7295a93a..f18084ba 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -1,8 +1,6 @@ use crate::prelude::*; -use std::io::{Read as _, Write as _}; - -use is_terminal::IsTerminal as _; +use std::io::{IsTerminal as _, Read as _, Write as _}; pub fn edit(contents: &str, help: &str) -> Result { if !std::io::stdin().is_terminal() { From 037d88150d3431b64f06bb07a58ea8c76152e545 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 7 May 2026 21:38:14 +0200 Subject: [PATCH 004/273] remove duplicated code in Client Create ClientRequest and ClientBlockingRequest for organizing http api calls --- src/api.rs | 271 +++++++++++++++++++++++++++-------------------------- 1 file changed, 140 insertions(+), 131 deletions(-) diff --git a/src/api.rs b/src/api.rs index 17351354..1a9642b6 100644 --- a/src/api.rs +++ b/src/api.rs @@ -6,7 +6,6 @@ use crate::prelude::*; use rand::distr::SampleString as _; use sha2::Digest as _; -use tokio::io::AsyncReadExt as _; use crate::json::{ DeserializeJsonWithPath as _, DeserializeJsonWithPathAsync as _, @@ -362,13 +361,6 @@ struct ConnectErrorResErrorModel { message: String, } -#[derive(serde::Serialize, Debug)] -struct ConnectRefreshTokenReq { - grant_type: String, - client_id: String, - refresh_token: String, -} - #[derive(serde::Deserialize, Debug)] struct ConnectRefreshTokenRes { access_token: String, @@ -804,11 +796,6 @@ struct FoldersResData { name: String, } -#[derive(serde::Serialize, Debug)] -struct FoldersPostReq { - name: String, -} - // Used for the Bitwarden-Client-Name header. Accepted values: // https://github.com/bitwarden/server/blob/main/src/Core/Enums/BitwardenClient.cs const BITWARDEN_CLIENT: &str = "cli"; @@ -816,6 +803,106 @@ const BITWARDEN_CLIENT: &str = "cli"; // DeviceType.LinuxDesktop, as per Bitwarden API device types. const DEVICE_TYPE: u8 = 8; +enum ClientRequest<'a> { + Prelogin(PreloginReq), + ConnectToken(ConnectTokenReq), + Login(ConnectTokenReq, &'a str), + SendEmailLogin(SendEmailLoginReq, &'a str), + Sync(&'a str), + ExchangeRefreshToken(&'a str), +} + +impl<'a> ClientRequest<'a> { + async fn req(self, client: &Client) -> Result { + let http_client = client.reqwest_client().await?; + + let rb = match self { + Self::Prelogin(r) => http_client + .post(client.identity_url("/accounts/prelogin")) + .json(&r), + Self::ConnectToken(r) => http_client + .post(client.identity_url("/connect/token")) + .form(&r), + Self::Login(r, email) => http_client + .post(client.identity_url("/connect/token")) + .form(&r) + .header( + "auth-email", + crate::base64::encode_url_safe_no_pad(email), + ), + Self::SendEmailLogin(r, email) => http_client + .post(client.api_url("/two-factor/send-email-login")) + .json(&r) + .header( + "auth-email", + crate::base64::encode_url_safe_no_pad(email), + ), + Self::Sync(access_token) => http_client + .get(client.api_url("/sync")) + .header("Authorization", format!("Bearer {access_token}")) + // This is necessary for vaultwarden to include the ssh keys in the response + .header("Bitwarden-Client-Version", "2024.12.0"), + Self::ExchangeRefreshToken(refresh_token) => http_client + .post(client.identity_url("/connect/token")) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", "cli"), + ("refresh_token", refresh_token), + ]), + }; + + Ok(rb + .send() + .await + .map_err(|source| Error::Reqwest { source })?) + } +} + +enum ClientBlockingRequest<'a> { + Add(&'a str, CiphersPostReq), + Edit(&'a str, &'a str, CiphersPutReq), + Remove(&'a str, &'a str), + Folders(&'a str), + CreateFolder(&'a str, &'a str), + ExchangeRefreshToken(&'a str), +} + +impl<'a> ClientBlockingRequest<'a> { + fn req(self, client: &Client) -> Result { + let http_client = reqwest::blocking::Client::new(); + + let rb = match self { + Self::Add(access_token, r) => http_client + .post(client.api_url("/ciphers")) + .header("Authorization", format!("Bearer {access_token}")) + .json(&r), + Self::Edit(access_token, id, r) => http_client + .put(client.api_url(&format!("/ciphers/{id}"))) + .header("Authorization", format!("Bearer {access_token}")) + .json(&r), + Self::Remove(access_token, id) => http_client + .delete(client.api_url(&format!("/ciphers/{id}"))) + .header("Authorization", format!("Bearer {access_token}")), + Self::Folders(access_token) => http_client + .get(client.api_url("/folders")) + .header("Authorization", format!("Bearer {access_token}")), + Self::CreateFolder(access_token, name) => http_client + .post(client.api_url("/folders")) + .header("Authorization", format!("Bearer {access_token}")) + .json(&serde_json::json!({"name": name})), + Self::ExchangeRefreshToken(refresh_token) => http_client + .post(client.identity_url("/connect/token")) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", "cli"), + ("refresh_token", refresh_token), + ]), + }; + + Ok(rb.send().map_err(|source| Error::Reqwest { source })?) + } +} + #[derive(Debug)] pub struct Client { base_url: String, @@ -891,22 +978,19 @@ impl Client { &self, email: &str, ) -> Result<(KdfType, u32, Option, Option)> { - let prelogin = PreloginReq { + let res: PreloginRes = ClientRequest::Prelogin(PreloginReq { email: email.to_string(), - }; - let client = self.reqwest_client().await?; - let res = client - .post(self.identity_url("/accounts/prelogin")) - .json(&prelogin) - .send() - .await - .map_err(|source| Error::Reqwest { source })?; - let prelogin_res: PreloginRes = res.json_with_path().await?; + }) + .req(self) + .await? + .json_with_path() + .await?; + Ok(( - prelogin_res.kdf, - prelogin_res.kdf_iterations, - prelogin_res.kdf_memory, - prelogin_res.kdf_parallelism, + res.kdf, + res.kdf_iterations, + res.kdf_memory, + res.kdf_parallelism, )) } @@ -938,13 +1022,7 @@ impl Client { two_factor_token: None, two_factor_provider: None, }; - let client = self.reqwest_client().await?; - let res = client - .post(self.identity_url("/connect/token")) - .form(&connect_req) - .send() - .await - .map_err(|source| Error::Reqwest { source })?; + let res = ClientRequest::ConnectToken(connect_req).req(self).await?; if res.status() == reqwest::StatusCode::OK { Ok(()) } else { @@ -1017,17 +1095,7 @@ impl Client { }, }; - let client = self.reqwest_client().await?; - let res = client - .post(self.identity_url("/connect/token")) - .form(&connect_req) - .header( - "auth-email", - crate::base64::encode_url_safe_no_pad(email), - ) - .send() - .await - .map_err(|source| Error::Reqwest { source })?; + let res = ClientRequest::Login(connect_req, email).req(self).await?; if res.status() == reqwest::StatusCode::OK { let connect_res: ConnectTokenRes = res.json_with_path().await?; @@ -1060,24 +1128,17 @@ impl Client { device_id: &str, sso_email_2fa_session_token: &str, ) -> Result<()> { - let send_email_login_req = SendEmailLoginReq { - email: email.to_string(), - device_identifier: device_id.to_string(), - sso_email_2fa_session_token: sso_email_2fa_session_token - .to_string(), - }; - - let client = self.reqwest_client().await?; - let res = client - .post(self.api_url("/two-factor/send-email-login")) - .json(&send_email_login_req) - .header( - "auth-email", - crate::base64::encode_url_safe_no_pad(email), - ) - .send() - .await - .map_err(|source| Error::Reqwest { source })?; + let res = ClientRequest::SendEmailLogin( + SendEmailLoginReq { + email: email.to_string(), + device_identifier: device_id.to_string(), + sso_email_2fa_session_token: sso_email_2fa_session_token + .to_string(), + }, + email, + ) + .req(self) + .await?; if res.status() == reqwest::StatusCode::OK { Ok(()) @@ -1148,15 +1209,7 @@ impl Client { std::collections::HashMap, Vec, )> { - let client = self.reqwest_client().await?; - let res = client - .get(self.api_url("/sync")) - .header("Authorization", format!("Bearer {access_token}")) - // This is necessary for vaultwarden to include the ssh keys in the response - .header("Bitwarden-Client-Version", "2024.12.0") - .send() - .await - .map_err(|source| Error::Reqwest { source })?; + let res = ClientRequest::Sync(access_token).req(self).await?; match res.status() { reqwest::StatusCode::OK => { let sync_res: SyncRes = res.json_with_path().await?; @@ -1293,13 +1346,8 @@ impl Client { } crate::db::EntryData::SshKey { .. } => unreachable!(), } - let client = reqwest::blocking::Client::new(); - let res = client - .post(self.api_url("/ciphers")) - .header("Authorization", format!("Bearer {access_token}")) - .json(&req) - .send() - .map_err(|source| Error::Reqwest { source })?; + + let res = ClientBlockingRequest::Add(access_token, req).req(self)?; match res.status() { reqwest::StatusCode::OK => Ok(()), reqwest::StatusCode::UNAUTHORIZED => { @@ -1443,13 +1491,9 @@ impl Client { } crate::db::EntryData::SshKey { .. } => unreachable!(), } - let client = reqwest::blocking::Client::new(); - let res = client - .put(self.api_url(&format!("/ciphers/{id}"))) - .header("Authorization", format!("Bearer {access_token}")) - .json(&req) - .send() - .map_err(|source| Error::Reqwest { source })?; + + let res = + ClientBlockingRequest::Edit(access_token, id, req).req(self)?; match res.status() { reqwest::StatusCode::OK => Ok(()), reqwest::StatusCode::UNAUTHORIZED => { @@ -1462,12 +1506,8 @@ impl Client { } pub fn remove(&self, access_token: &str, id: &str) -> Result<()> { - let client = reqwest::blocking::Client::new(); - let res = client - .delete(self.api_url(&format!("/ciphers/{id}"))) - .header("Authorization", format!("Bearer {access_token}")) - .send() - .map_err(|source| Error::Reqwest { source })?; + let res = + ClientBlockingRequest::Remove(access_token, id).req(self)?; match res.status() { reqwest::StatusCode::OK => Ok(()), reqwest::StatusCode::UNAUTHORIZED => { @@ -1483,12 +1523,7 @@ impl Client { &self, access_token: &str, ) -> Result> { - let client = reqwest::blocking::Client::new(); - let res = client - .get(self.api_url("/folders")) - .header("Authorization", format!("Bearer {access_token}")) - .send() - .map_err(|source| Error::Reqwest { source })?; + let res = ClientBlockingRequest::Folders(access_token).req(self)?; match res.status() { reqwest::StatusCode::OK => { let folders_res: FoldersRes = res.json_with_path()?; @@ -1512,16 +1547,8 @@ impl Client { access_token: &str, name: &str, ) -> Result { - let req = FoldersPostReq { - name: name.to_string(), - }; - let client = reqwest::blocking::Client::new(); - let res = client - .post(self.api_url("/folders")) - .header("Authorization", format!("Bearer {access_token}")) - .json(&req) - .send() - .map_err(|source| Error::Reqwest { source })?; + let res = ClientBlockingRequest::CreateFolder(access_token, name) + .req(self)?; match res.status() { reqwest::StatusCode::OK => { let folders_res: FoldersResData = res.json_with_path()?; @@ -1540,17 +1567,8 @@ impl Client { &self, refresh_token: &str, ) -> Result { - let connect_req = ConnectRefreshTokenReq { - grant_type: "refresh_token".to_string(), - client_id: "cli".to_string(), - refresh_token: refresh_token.to_string(), - }; - let client = reqwest::blocking::Client::new(); - let res = client - .post(self.identity_url("/connect/token")) - .form(&connect_req) - .send() - .map_err(|source| Error::Reqwest { source })?; + let res = ClientBlockingRequest::ExchangeRefreshToken(refresh_token) + .req(self)?; let connect_res: ConnectRefreshTokenRes = res.json_with_path()?; Ok(connect_res.access_token) } @@ -1559,18 +1577,9 @@ impl Client { &self, refresh_token: &str, ) -> Result { - let connect_req = ConnectRefreshTokenReq { - grant_type: "refresh_token".to_string(), - client_id: "cli".to_string(), - refresh_token: refresh_token.to_string(), - }; - let client = self.reqwest_client().await?; - let res = client - .post(self.identity_url("/connect/token")) - .form(&connect_req) - .send() - .await - .map_err(|source| Error::Reqwest { source })?; + let res = ClientRequest::ExchangeRefreshToken(refresh_token) + .req(self) + .await?; let connect_res: ConnectRefreshTokenRes = res.json_with_path().await?; Ok(connect_res.access_token) From 1d8d118cc9809e01b55edfea69d69f0aa248e12c Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 7 May 2026 22:37:03 +0200 Subject: [PATCH 005/273] implement TryFrom for Error Removed some duplicated code too --- src/api.rs | 160 +++++++++++++++++++++++++++++------------------------ 1 file changed, 88 insertions(+), 72 deletions(-) diff --git a/src/api.rs b/src/api.rs index 1a9642b6..c7372be5 100644 --- a/src/api.rs +++ b/src/api.rs @@ -355,6 +355,72 @@ struct ConnectErrorRes { sso_email_2fa_session_token: Option, } +impl TryFrom for Error { + type Error = (); + + fn try_from( + value: ConnectErrorRes, + ) -> std::result::Result { + let error_desc = value.error_description.as_deref(); + match value.error.as_str() { + "invalid_grant" => match error_desc { + Some("invalid_username_or_password") => { + if let Some(error_model) = value.error_model.as_ref() { + let message = + error_model.message.as_str().to_string(); + return Ok(Error::IncorrectPassword { message }); + } + } + Some("Two factor required.") => { + if let Some(providers) = + value.two_factor_providers.as_ref() + { + return Ok(Error::TwoFactorRequired { + providers: providers.clone(), + sso_email_2fa_session_token: value + .sso_email_2fa_session_token + .clone(), + }); + } + } + Some("Captcha required.") => { + return Ok(Error::RegistrationRequired); + } + _ => {} + }, + "invalid_client" => { + return Ok(Error::IncorrectApiKey); + } + "" => { + // bitwarden_rs returns an empty error and error_description for + // this case, for some reason + if error_desc.is_none() || error_desc == Some("") { + if let Some(error_model) = value.error_model.as_ref() { + let message = + error_model.message.as_str().to_string(); + match message.as_str() { + "Username or password is incorrect. Try again" + | "TOTP code is not a number" => { + return Ok(Error::IncorrectPassword { message }); + } + s => { + if s.starts_with( + "Invalid TOTP code! Server time: ", + ) { + return Ok(Error::IncorrectPassword { message }); + } + } + } + } + } + } + _ => {} + } + + Err(()) + } +} + #[derive(serde::Deserialize, Debug)] struct ConnectErrorResErrorModel { #[serde(rename = "Message", alias = "message")] @@ -1028,13 +1094,18 @@ impl Client { } else { let code = res.status().as_u16(); match res.text().await { - Ok(body) => match body.clone().json_with_path() { - Ok(json) => Err(classify_login_error(&json, code)), - Err(e) => { - log::warn!("{e}: {body}"); - Err(Error::RequestFailed { status: code }) + Ok(body) => { + match body.clone().json_with_path::() { + Ok(err) => Err(err.try_into().unwrap_or_else(|_| { + log::warn!("unexpected error received during login: {self:?}"); + Error::RequestFailed { status: code } + })), + Err(e) => { + log::warn!("{e}: {body}"); + Err(Error::RequestFailed { status: code }) + } } - }, + } Err(e) => { log::warn!("failed to read response body: {e}"); Err(Error::RequestFailed { status: code }) @@ -1107,13 +1178,18 @@ impl Client { } else { let code = res.status().as_u16(); match res.text().await { - Ok(body) => match body.clone().json_with_path() { - Ok(json) => Err(classify_login_error(&json, code)), - Err(e) => { - log::warn!("{e}: {body}"); - Err(Error::RequestFailed { status: code }) + Ok(body) => { + match body.clone().json_with_path::() { + Ok(err) => Err(err.try_into().unwrap_or_else(|_| { + log::warn!("unexpected error received during login: {self:?}"); + Error::RequestFailed { status: code } + })), + Err(e) => { + log::warn!("{e}: {body}"); + Err(Error::RequestFailed { status: code }) + } } - }, + } Err(e) => { log::warn!("failed to read response body: {e}"); Err(Error::RequestFailed { status: code }) @@ -1709,63 +1785,3 @@ fn sso_query_code( Ok(sso_code.clone()) } - -fn classify_login_error(error_res: &ConnectErrorRes, code: u16) -> Error { - let error_desc = error_res.error_description.clone(); - let error_desc = error_desc.as_deref(); - match error_res.error.as_str() { - "invalid_grant" => match error_desc { - Some("invalid_username_or_password") => { - if let Some(error_model) = error_res.error_model.as_ref() { - let message = error_model.message.as_str().to_string(); - return Error::IncorrectPassword { message }; - } - } - Some("Two factor required.") => { - if let Some(providers) = - error_res.two_factor_providers.as_ref() - { - return Error::TwoFactorRequired { - providers: providers.clone(), - sso_email_2fa_session_token: error_res - .sso_email_2fa_session_token - .clone(), - }; - } - } - Some("Captcha required.") => { - return Error::RegistrationRequired; - } - _ => {} - }, - "invalid_client" => { - return Error::IncorrectApiKey; - } - "" => { - // bitwarden_rs returns an empty error and error_description for - // this case, for some reason - if error_desc.is_none() || error_desc == Some("") { - if let Some(error_model) = error_res.error_model.as_ref() { - let message = error_model.message.as_str().to_string(); - match message.as_str() { - "Username or password is incorrect. Try again" - | "TOTP code is not a number" => { - return Error::IncorrectPassword { message }; - } - s => { - if s.starts_with( - "Invalid TOTP code! Server time: ", - ) { - return Error::IncorrectPassword { message }; - } - } - } - } - } - } - _ => {} - } - - log::warn!("unexpected error received during login: {error_res:?}"); - Error::RequestFailed { status: code } -} From 335c4ff46782d9d0bd15da33dab8d332594ea61f Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 7 May 2026 22:42:19 +0200 Subject: [PATCH 006/273] fix print of self --- src/api.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/api.rs b/src/api.rs index c7372be5..9bb7fcf9 100644 --- a/src/api.rs +++ b/src/api.rs @@ -356,7 +356,7 @@ struct ConnectErrorRes { } impl TryFrom for Error { - type Error = (); + type Error = ConnectErrorRes; fn try_from( value: ConnectErrorRes, @@ -417,7 +417,7 @@ impl TryFrom for Error { _ => {} } - Err(()) + Err(value) } } @@ -1096,8 +1096,8 @@ impl Client { match res.text().await { Ok(body) => { match body.clone().json_with_path::() { - Ok(err) => Err(err.try_into().unwrap_or_else(|_| { - log::warn!("unexpected error received during login: {self:?}"); + Ok(err) => Err(err.try_into().unwrap_or_else(|err| { + log::warn!("unexpected error received during login: {err:?}"); Error::RequestFailed { status: code } })), Err(e) => { @@ -1180,8 +1180,8 @@ impl Client { match res.text().await { Ok(body) => { match body.clone().json_with_path::() { - Ok(err) => Err(err.try_into().unwrap_or_else(|_| { - log::warn!("unexpected error received during login: {self:?}"); + Ok(err) => Err(err.try_into().unwrap_or_else(|err| { + log::warn!("unexpected error received during login: {err:?}"); Error::RequestFailed { status: code } })), Err(e) => { From fe6bca7613576c9cca7b2ea689d327dc8e76d7b0 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 7 May 2026 23:01:56 +0200 Subject: [PATCH 007/273] bring rustfmt back to default width --- .rustfmt.toml | 2 +- src/actions.rs | 91 +--- src/api.rs | 456 ++++++----------- src/base64.rs | 4 +- src/bin/rbw-agent/actions.rs | 182 ++----- src/bin/rbw-agent/agent.rs | 44 +- src/bin/rbw-agent/daemon.rs | 3 +- src/bin/rbw-agent/debugger.rs | 14 +- src/bin/rbw-agent/main.rs | 60 +-- src/bin/rbw-agent/notifications.rs | 38 +- src/bin/rbw-agent/sock.rs | 11 +- src/bin/rbw-agent/ssh_agent.rs | 50 +- src/bin/rbw-agent/state.rs | 13 +- src/bin/rbw-agent/timeout.rs | 8 +- src/bin/rbw/actions.rs | 27 +- src/bin/rbw/commands.rs | 771 +++++++---------------------- src/bin/rbw/main.rs | 36 +- src/bin/rbw/sock.rs | 8 +- src/cipherstring.rs | 75 +-- src/config.rs | 66 ++- src/db.rs | 107 ++-- src/dirs.rs | 28 +- src/edit.rs | 6 +- src/identity.rs | 7 +- src/json.rs | 20 +- src/locked.rs | 7 +- src/pinentry.rs | 23 +- src/protocol.rs | 34 +- src/pwgen.rs | 8 +- 29 files changed, 663 insertions(+), 1536 deletions(-) diff --git a/.rustfmt.toml b/.rustfmt.toml index 7b6182f2..a311b9da 100644 --- a/.rustfmt.toml +++ b/.rustfmt.toml @@ -1,2 +1,2 @@ edition = "2021" -max_width = 78 +newline_style = "Unix" diff --git a/src/actions.rs b/src/actions.rs index 79d304d4..4ac3abd8 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -1,9 +1,6 @@ use crate::prelude::*; -pub async fn register( - email: &str, - apikey: crate::locked::ApiKey, -) -> Result<()> { +pub async fn register(email: &str, apikey: crate::locked::ApiKey) -> Result<()> { let (client, config) = api_client_async().await?; client @@ -28,17 +25,10 @@ pub async fn login( String, )> { let (client, config) = api_client_async().await?; - let (kdf, iterations, memory, parallelism) = - client.prelogin(email).await?; + let (kdf, iterations, memory, parallelism) = client.prelogin(email).await?; - let identity = crate::identity::Identity::new( - email, - &password, - kdf, - iterations, - memory, - parallelism, - )?; + let identity = + crate::identity::Identity::new(email, &password, kdf, iterations, memory, parallelism)?; let (access_token, refresh_token, protected_key) = client .login( email, @@ -61,10 +51,7 @@ pub async fn login( )) } -pub async fn send_two_factor_email( - email: &str, - sso_email_2fa_session_token: &str, -) -> Result<()> { +pub async fn send_two_factor_email(email: &str, sso_email_2fa_session_token: &str) -> Result<()> { let (client, config) = api_client_async().await?; client .send_email_login( @@ -89,17 +76,10 @@ pub fn unlock( crate::locked::Keys, std::collections::HashMap, )> { - let identity = crate::identity::Identity::new( - email, - password, - kdf, - iterations, - memory, - parallelism, - )?; + let identity = + crate::identity::Identity::new(email, password, kdf, iterations, memory, parallelism)?; - let protected_key = - crate::cipherstring::CipherString::new(protected_key)?; + let protected_key = crate::cipherstring::CipherString::new(protected_key)?; let key = match protected_key.decrypt_locked_symmetric(&identity.keys) { Ok(master_keys) => crate::locked::Keys::new(master_keys), Err(Error::InvalidMac) => { @@ -110,23 +90,19 @@ pub fn unlock( Err(e) => return Err(e), }; - 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 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 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 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), + }; org_keys.insert(org_id.clone(), org_key); } @@ -145,14 +121,10 @@ pub async fn sync( Vec, ), )> { - with_exchange_refresh_token_async( - access_token, - refresh_token, - |access_token| { - let access_token = access_token.to_string(); - Box::pin(async move { sync_once(&access_token).await }) - }, - ) + with_exchange_refresh_token_async(access_token, refresh_token, |access_token| { + let access_token = access_token.to_string(); + Box::pin(async move { sync_once(&access_token).await }) + }) .await } @@ -246,11 +218,7 @@ fn edit_once( Ok(()) } -pub fn remove( - access_token: &str, - refresh_token: &str, - id: &str, -) -> Result<(Option, ())> { +pub fn remove(access_token: &str, refresh_token: &str, id: &str) -> Result<(Option, ())> { with_exchange_refresh_token(access_token, refresh_token, |access_token| { remove_once(access_token, id) }) @@ -316,19 +284,15 @@ async fn with_exchange_refresh_token_async( f: F, ) -> Result<(Option, T)> where - F: Fn( - &str, - ) -> std::pin::Pin< - Box> + Send>, - > + Send + F: Fn(&str) -> std::pin::Pin> + Send>> + + Send + Sync, T: Send, { match f(access_token).await { Ok(t) => Ok((None, t)), Err(Error::RequestUnauthorized) => { - let access_token = - exchange_refresh_token_async(refresh_token).await?; + let access_token = exchange_refresh_token_async(refresh_token).await?; let t = f(&access_token).await?; Ok((Some(access_token), t)) } @@ -357,8 +321,7 @@ fn api_client() -> Result<(crate::api::Client, crate::config::Config)> { Ok((client, config)) } -async fn api_client_async( -) -> Result<(crate::api::Client, crate::config::Config)> { +async fn api_client_async() -> Result<(crate::api::Client, crate::config::Config)> { let config = crate::config::Config::load_async().await?; let client = crate::api::Client::new( &config.base_url(), diff --git a/src/api.rs b/src/api.rs index 9bb7fcf9..828267cb 100644 --- a/src/api.rs +++ b/src/api.rs @@ -7,18 +7,10 @@ use crate::prelude::*; use rand::distr::SampleString as _; use sha2::Digest as _; -use crate::json::{ - DeserializeJsonWithPath as _, DeserializeJsonWithPathAsync as _, -}; +use crate::json::{DeserializeJsonWithPath as _, DeserializeJsonWithPathAsync as _}; #[derive( - serde_repr::Serialize_repr, - serde_repr::Deserialize_repr, - Debug, - Copy, - Clone, - PartialEq, - Eq, + serde_repr::Serialize_repr, serde_repr::Deserialize_repr, Debug, Copy, Clone, PartialEq, Eq, )] #[repr(u8)] pub enum UriMatchType { @@ -61,10 +53,12 @@ pub enum TwoFactorProviderType { impl TwoFactorProviderType { pub fn message(&self) -> &str { match *self { - Self::Authenticator => "Enter the 6 digit verification code from your authenticator app.", + Self::Authenticator => { + "Enter the 6 digit verification code from your authenticator app." + } Self::Yubikey => "Insert your Yubikey and push the button.", Self::Email => "Enter the PIN you received via email.", - _ => "Enter the code." + _ => "Enter the code.", } } @@ -91,32 +85,22 @@ impl<'de> serde::Deserialize<'de> for TwoFactorProviderType { impl serde::de::Visitor<'_> for TwoFactorProviderTypeVisitor { type Value = TwoFactorProviderType; - fn expecting( - &self, - formatter: &mut std::fmt::Formatter, - ) -> std::fmt::Result { + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("two factor provider id") } - fn visit_str( - self, - value: &str, - ) -> std::result::Result + fn visit_str(self, value: &str) -> std::result::Result where E: serde::de::Error, { value.parse().map_err(serde::de::Error::custom) } - fn visit_u64( - self, - value: u64, - ) -> std::result::Result + fn visit_u64(self, value: u64) -> std::result::Result where E: serde::de::Error, { - std::convert::TryFrom::try_from(value) - .map_err(serde::de::Error::custom) + std::convert::TryFrom::try_from(value).map_err(serde::de::Error::custom) } } @@ -177,32 +161,22 @@ impl<'de> serde::Deserialize<'de> for KdfType { impl serde::de::Visitor<'_> for KdfTypeVisitor { type Value = KdfType; - fn expecting( - &self, - formatter: &mut std::fmt::Formatter, - ) -> std::fmt::Result { + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("kdf id") } - fn visit_str( - self, - value: &str, - ) -> std::result::Result + fn visit_str(self, value: &str) -> std::result::Result where E: serde::de::Error, { value.parse().map_err(serde::de::Error::custom) } - fn visit_u64( - self, - value: u64, - ) -> std::result::Result + fn visit_u64(self, value: u64) -> std::result::Result where E: serde::de::Error, { - std::convert::TryFrom::try_from(value) - .map_err(serde::de::Error::custom) + std::convert::TryFrom::try_from(value).map_err(serde::de::Error::custom) } } @@ -237,10 +211,7 @@ impl std::str::FromStr for KdfType { } impl serde::Serialize for KdfType { - fn serialize( - &self, - serializer: S, - ) -> std::result::Result + fn serialize(&self, serializer: S) -> std::result::Result where S: serde::Serializer, { @@ -253,13 +224,7 @@ impl serde::Serialize for KdfType { } #[derive( - serde_repr::Serialize_repr, - serde_repr::Deserialize_repr, - Debug, - Copy, - Clone, - PartialEq, - Eq, + serde_repr::Serialize_repr, serde_repr::Deserialize_repr, Debug, Copy, Clone, PartialEq, Eq, )] #[repr(u8)] pub enum CipherRepromptType { @@ -348,38 +313,28 @@ struct ConnectErrorRes { error_model: Option, #[serde(rename = "TwoFactorProviders", alias = "twoFactorProviders")] two_factor_providers: Option>, - #[serde( - rename = "SsoEmail2faSessionToken", - alias = "ssoEmail2faSessionToken" - )] + #[serde(rename = "SsoEmail2faSessionToken", alias = "ssoEmail2faSessionToken")] sso_email_2fa_session_token: Option, } impl TryFrom for Error { type Error = ConnectErrorRes; - fn try_from( - value: ConnectErrorRes, - ) -> std::result::Result { + fn try_from(value: ConnectErrorRes) -> std::result::Result { let error_desc = value.error_description.as_deref(); match value.error.as_str() { "invalid_grant" => match error_desc { Some("invalid_username_or_password") => { if let Some(error_model) = value.error_model.as_ref() { - let message = - error_model.message.as_str().to_string(); + let message = error_model.message.as_str().to_string(); return Ok(Error::IncorrectPassword { message }); } } Some("Two factor required.") => { - if let Some(providers) = - value.two_factor_providers.as_ref() - { + if let Some(providers) = value.two_factor_providers.as_ref() { return Ok(Error::TwoFactorRequired { providers: providers.clone(), - sso_email_2fa_session_token: value - .sso_email_2fa_session_token - .clone(), + sso_email_2fa_session_token: value.sso_email_2fa_session_token.clone(), }); } } @@ -396,22 +351,19 @@ impl TryFrom for Error { // this case, for some reason if error_desc.is_none() || error_desc == Some("") { if let Some(error_model) = value.error_model.as_ref() { - let message = - error_model.message.as_str().to_string(); + let message = error_model.message.as_str().to_string(); match message.as_str() { - "Username or password is incorrect. Try again" - | "TOTP code is not a number" => { - return Ok(Error::IncorrectPassword { message }); - } - s => { - if s.starts_with( - "Invalid TOTP code! Server time: ", - ) { + "Username or password is incorrect. Try again" + | "TOTP code is not a number" => { return Ok(Error::IncorrectPassword { message }); } + s => { + if s.starts_with("Invalid TOTP code! Server time: ") { + return Ok(Error::IncorrectPassword { message }); + } + } } } - } } } _ => {} @@ -437,10 +389,7 @@ struct SendEmailLoginReq { email: String, #[serde(rename = "DeviceIdentifier", alias = "deviceIdentifier")] device_identifier: String, - #[serde( - rename = "SsoEmail2faSessionToken", - alias = "ssoEmail2faSessionToken" - )] + #[serde(rename = "SsoEmail2faSessionToken", alias = "ssoEmail2faSessionToken")] sso_email_2fa_session_token: String, } @@ -489,62 +438,51 @@ struct SyncResCipher { } impl SyncResCipher { - fn to_entry( - &self, - folders: &[SyncResFolder], - ) -> Option { + fn to_entry(&self, folders: &[SyncResFolder]) -> Option { if self.deleted_date.is_some() { return None; } - let history = - self.password_history - .as_ref() - .map_or_else(Vec::new, |history| { - history - .iter() - .filter_map(|entry| { - // Gets rid of entries with a non-existent - // password - entry.password.clone().map(|p| { - crate::db::HistoryEntry { - last_used_date: entry - .last_used_date - .clone(), - password: p, - } - }) + let history = self + .password_history + .as_ref() + .map_or_else(Vec::new, |history| { + history + .iter() + .filter_map(|entry| { + // Gets rid of entries with a non-existent + // password + entry.password.clone().map(|p| crate::db::HistoryEntry { + last_used_date: entry.last_used_date.clone(), + password: p, }) - .collect() - }); + }) + .collect() + }); - let (folder, folder_id) = - self.folder_id.as_ref().map_or((None, None), |folder_id| { - let mut folder_name = None; - for folder in folders { - if &folder.id == folder_id { - folder_name = Some(folder.name.clone()); - } + let (folder, folder_id) = self.folder_id.as_ref().map_or((None, None), |folder_id| { + let mut folder_name = None; + for folder in folders { + if &folder.id == folder_id { + folder_name = Some(folder.name.clone()); } - (folder_name, Some(folder_id)) - }); + } + (folder_name, Some(folder_id)) + }); let data = if let Some(login) = &self.login { crate::db::EntryData::Login { username: login.username.clone(), password: login.password.clone(), totp: login.totp.clone(), - uris: login.uris.as_ref().map_or_else( - std::vec::Vec::new, - |uris| { - uris.iter() - .filter_map(|uri| { - uri.uri.clone().map(|s| crate::db::Uri { - uri: s, - match_type: uri.match_type, - }) + uris: login.uris.as_ref().map_or_else(std::vec::Vec::new, |uris| { + uris.iter() + .filter_map(|uri| { + uri.uri.clone().map(|s| crate::db::Uri { + uri: s, + match_type: uri.match_type, }) - .collect() - }, - ), + }) + .collect() + }), } } else if let Some(card) = &self.card { crate::db::EntryData::Card { @@ -724,13 +662,7 @@ struct CipherSshKey { } #[derive( - serde_repr::Serialize_repr, - serde_repr::Deserialize_repr, - Debug, - Clone, - Copy, - PartialEq, - Eq, + serde_repr::Serialize_repr, serde_repr::Deserialize_repr, Debug, Clone, Copy, PartialEq, Eq, )] #[repr(u16)] pub enum FieldType { @@ -741,13 +673,7 @@ pub enum FieldType { } #[derive( - serde_repr::Serialize_repr, - serde_repr::Deserialize_repr, - Debug, - Clone, - Copy, - PartialEq, - Eq, + serde_repr::Serialize_repr, serde_repr::Deserialize_repr, Debug, Clone, Copy, PartialEq, Eq, )] #[repr(u16)] pub enum LinkedIdType { @@ -892,17 +818,11 @@ impl<'a> ClientRequest<'a> { Self::Login(r, email) => http_client .post(client.identity_url("/connect/token")) .form(&r) - .header( - "auth-email", - crate::base64::encode_url_safe_no_pad(email), - ), + .header("auth-email", crate::base64::encode_url_safe_no_pad(email)), Self::SendEmailLogin(r, email) => http_client .post(client.api_url("/two-factor/send-email-login")) .json(&r) - .header( - "auth-email", - crate::base64::encode_url_safe_no_pad(email), - ), + .header("auth-email", crate::base64::encode_url_safe_no_pad(email)), Self::Sync(access_token) => http_client .get(client.api_url("/sync")) .header("Authorization", format!("Bearer {access_token}")) @@ -988,8 +908,7 @@ impl Client { base_url: base_url.to_string(), identity_url: identity_url.to_string(), ui_url: ui_url.to_string(), - client_cert_path: client_cert_path - .map(std::path::Path::to_path_buf), + client_cert_path: client_cert_path.map(std::path::Path::to_path_buf), } } @@ -1007,22 +926,17 @@ impl Client { "Device-Type", // unwrap is safe here because DEVICE_TYPE is a number and digits // are valid ASCII - axum::http::HeaderValue::from_str(&DEVICE_TYPE.to_string()) - .unwrap(), - ); - let user_agent = format!( - "{}/{}", - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_VERSION") + axum::http::HeaderValue::from_str(&DEVICE_TYPE.to_string()).unwrap(), ); + let user_agent = format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); if let Some(client_cert_path) = self.client_cert_path.as_ref() { let buf = - tokio::fs::read(client_cert_path).await.map_err(|e| { - Error::LoadClientCert { + tokio::fs::read(client_cert_path) + .await + .map_err(|e| Error::LoadClientCert { source: e, file: client_cert_path.clone(), - } - })?; + })?; let pem = reqwest::Identity::from_pem(&buf) .map_err(|e| Error::CreateReqwestClient { source: e })?; Ok(reqwest::Client::builder() @@ -1040,10 +954,7 @@ impl Client { } } - pub async fn prelogin( - &self, - email: &str, - ) -> Result<(KdfType, u32, Option, Option)> { + pub async fn prelogin(&self, email: &str) -> Result<(KdfType, u32, Option, Option)> { let res: PreloginRes = ClientRequest::Prelogin(PreloginReq { email: email.to_string(), }) @@ -1067,20 +978,14 @@ impl Client { apikey: &crate::locked::ApiKey, ) -> Result<()> { let connect_req = ConnectTokenReq { - auth: ConnectTokenAuth::ClientCredentials( - ConnectTokenClientCredentials { - username: email.to_string(), - client_secret: String::from_utf8( - apikey.client_secret().to_vec(), - ) - .unwrap(), - }, - ), + auth: ConnectTokenAuth::ClientCredentials(ConnectTokenClientCredentials { + username: email.to_string(), + client_secret: String::from_utf8(apikey.client_secret().to_vec()).unwrap(), + }), grant_type: "client_credentials".to_string(), scope: "api".to_string(), // XXX unwraps here are not necessarily safe - client_id: String::from_utf8(apikey.client_id().to_vec()) - .unwrap(), + client_id: String::from_utf8(apikey.client_id().to_vec()).unwrap(), device_type: u32::from(DEVICE_TYPE), device_identifier: device_id.to_string(), device_name: "rbw".to_string(), @@ -1094,18 +999,16 @@ impl Client { } else { let code = res.status().as_u16(); match res.text().await { - Ok(body) => { - match body.clone().json_with_path::() { - Ok(err) => Err(err.try_into().unwrap_or_else(|err| { - log::warn!("unexpected error received during login: {err:?}"); - Error::RequestFailed { status: code } - })), - Err(e) => { - log::warn!("{e}: {body}"); - Err(Error::RequestFailed { status: code }) - } + Ok(body) => match body.clone().json_with_path::() { + Ok(err) => Err(err.try_into().unwrap_or_else(|err| { + log::warn!("unexpected error received during login: {err:?}"); + Error::RequestFailed { status: code } + })), + Err(e) => { + log::warn!("{e}: {body}"); + Err(Error::RequestFailed { status: code }) } - } + }, Err(e) => { log::warn!("failed to read response body: {e}"); Err(Error::RequestFailed { status: code }) @@ -1141,10 +1044,8 @@ impl Client { device_identifier: device_id.to_string(), device_name: "rbw".to_string(), device_push_token: String::new(), - two_factor_token: two_factor_token - .map(std::string::ToString::to_string), - two_factor_provider: two_factor_provider - .map(|ty| ty as u32), + two_factor_token: two_factor_token.map(std::string::ToString::to_string), + two_factor_provider: two_factor_provider.map(|ty| ty as u32), } } None => ConnectTokenReq { @@ -1160,8 +1061,7 @@ impl Client { device_identifier: device_id.to_string(), device_name: "rbw".to_string(), device_push_token: String::new(), - two_factor_token: two_factor_token - .map(std::string::ToString::to_string), + two_factor_token: two_factor_token.map(std::string::ToString::to_string), two_factor_provider: two_factor_provider.map(|ty| ty as u32), }, }; @@ -1178,18 +1078,16 @@ impl Client { } else { let code = res.status().as_u16(); match res.text().await { - Ok(body) => { - match body.clone().json_with_path::() { - Ok(err) => Err(err.try_into().unwrap_or_else(|err| { - log::warn!("unexpected error received during login: {err:?}"); - Error::RequestFailed { status: code } - })), - Err(e) => { - log::warn!("{e}: {body}"); - Err(Error::RequestFailed { status: code }) - } + Ok(body) => match body.clone().json_with_path::() { + Ok(err) => Err(err.try_into().unwrap_or_else(|err| { + log::warn!("unexpected error received during login: {err:?}"); + Error::RequestFailed { status: code } + })), + Err(e) => { + log::warn!("{e}: {body}"); + Err(Error::RequestFailed { status: code }) } - } + }, Err(e) => { log::warn!("failed to read response body: {e}"); Err(Error::RequestFailed { status: code }) @@ -1208,8 +1106,7 @@ impl Client { SendEmailLoginReq { email: email.to_string(), device_identifier: device_id.to_string(), - sso_email_2fa_session_token: sso_email_2fa_session_token - .to_string(), + sso_email_2fa_session_token: sso_email_2fa_session_token.to_string(), }, email, ) @@ -1225,19 +1122,13 @@ impl Client { } } - async fn obtain_sso_code( - &self, - sso_id: &str, - ) -> Result<(String, String, String)> { - let state = - rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 64); - let sso_code_verifier = - rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 64); + async fn obtain_sso_code(&self, sso_id: &str) -> Result<(String, String, String)> { + let state = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 64); + let sso_code_verifier = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 64); let mut hasher = sha2::Sha256::new(); hasher.update(sso_code_verifier.clone()); - let code_challenge = - crate::base64::encode_url_safe_no_pad(hasher.finalize()); + let code_challenge = crate::base64::encode_url_safe_no_pad(hasher.finalize()); let port = find_free_port(8065, 8070).await?; @@ -1245,11 +1136,9 @@ impl Client { .await .map_err(|e| Error::CreateSSOCallbackServer { err: e })?; - let callback_server = - start_sso_callback_server(listener, state.as_str()); + let callback_server = start_sso_callback_server(listener, state.as_str()); - let callback_url = - "http://localhost:".to_string() + port.to_string().as_str(); + let callback_url = "http://localhost:".to_string() + port.to_string().as_str(); open::that( self.ui_url.clone() @@ -1308,9 +1197,7 @@ impl Client { ciphers, )) } - reqwest::StatusCode::UNAUTHORIZED => { - Err(Error::RequestUnauthorized) - } + reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), _ => Err(Error::RequestFailed { status: res.status().as_u16(), }), @@ -1426,9 +1313,7 @@ impl Client { let res = ClientBlockingRequest::Add(access_token, req).req(self)?; match res.status() { reqwest::StatusCode::OK => Ok(()), - reqwest::StatusCode::UNAUTHORIZED => { - Err(Error::RequestUnauthorized) - } + reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), _ => Err(Error::RequestFailed { status: res.status().as_u16(), }), @@ -1568,13 +1453,10 @@ impl Client { crate::db::EntryData::SshKey { .. } => unreachable!(), } - let res = - ClientBlockingRequest::Edit(access_token, id, req).req(self)?; + let res = ClientBlockingRequest::Edit(access_token, id, req).req(self)?; match res.status() { reqwest::StatusCode::OK => Ok(()), - reqwest::StatusCode::UNAUTHORIZED => { - Err(Error::RequestUnauthorized) - } + reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), _ => Err(Error::RequestFailed { status: res.status().as_u16(), }), @@ -1582,23 +1464,17 @@ impl Client { } pub fn remove(&self, access_token: &str, id: &str) -> Result<()> { - let res = - ClientBlockingRequest::Remove(access_token, id).req(self)?; + let res = ClientBlockingRequest::Remove(access_token, id).req(self)?; match res.status() { reqwest::StatusCode::OK => Ok(()), - reqwest::StatusCode::UNAUTHORIZED => { - Err(Error::RequestUnauthorized) - } + reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), _ => Err(Error::RequestFailed { status: res.status().as_u16(), }), } } - pub fn folders( - &self, - access_token: &str, - ) -> Result> { + pub fn folders(&self, access_token: &str) -> Result> { let res = ClientBlockingRequest::Folders(access_token).req(self)?; match res.status() { reqwest::StatusCode::OK => { @@ -1609,55 +1485,38 @@ impl Client { .map(|folder| (folder.id.clone(), folder.name.clone())) .collect()) } - reqwest::StatusCode::UNAUTHORIZED => { - Err(Error::RequestUnauthorized) - } + reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), _ => Err(Error::RequestFailed { status: res.status().as_u16(), }), } } - pub fn create_folder( - &self, - access_token: &str, - name: &str, - ) -> Result { - let res = ClientBlockingRequest::CreateFolder(access_token, name) - .req(self)?; + pub fn create_folder(&self, access_token: &str, name: &str) -> Result { + let res = ClientBlockingRequest::CreateFolder(access_token, name).req(self)?; match res.status() { reqwest::StatusCode::OK => { let folders_res: FoldersResData = res.json_with_path()?; Ok(folders_res.id) } - reqwest::StatusCode::UNAUTHORIZED => { - Err(Error::RequestUnauthorized) - } + reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), _ => Err(Error::RequestFailed { status: res.status().as_u16(), }), } } - pub fn exchange_refresh_token( - &self, - refresh_token: &str, - ) -> Result { - let res = ClientBlockingRequest::ExchangeRefreshToken(refresh_token) - .req(self)?; + pub fn exchange_refresh_token(&self, refresh_token: &str) -> Result { + let res = ClientBlockingRequest::ExchangeRefreshToken(refresh_token).req(self)?; let connect_res: ConnectRefreshTokenRes = res.json_with_path()?; Ok(connect_res.access_token) } - pub async fn exchange_refresh_token_async( - &self, - refresh_token: &str, - ) -> Result { + pub async fn exchange_refresh_token_async(&self, refresh_token: &str) -> Result { let res = ClientRequest::ExchangeRefreshToken(refresh_token) .req(self) .await?; - let connect_res: ConnectRefreshTokenRes = - res.json_with_path().await?; + let connect_res: ConnectRefreshTokenRes = res.json_with_path().await?; Ok(connect_res.access_token) } @@ -1708,14 +1567,9 @@ async fn start_sso_callback_server( .with_state(sso_handler_state); axum::serve(listener, app) - .with_graceful_shutdown(sso_server_graceful_shutdown( - sender, - shut_receiver, - )) + .with_graceful_shutdown(sso_server_graceful_shutdown(sender, shut_receiver)) .await - .map_err(|e| Error::FailedToProcessSSOCallback { - msg: e.to_string(), - })?; + .map_err(|e| Error::FailedToProcessSSOCallback { msg: e.to_string() })?; receiver.recv().await.unwrap() } @@ -1728,33 +1582,37 @@ async fn sso_server_graceful_shutdown( } async fn handle_sso_callback( - axum::extract::State(state): axum::extract::State< - std::sync::Arc, - >, - axum::extract::Query(params): axum::extract::Query< - std::collections::HashMap, - >, + axum::extract::State(state): axum::extract::State>, + axum::extract::Query(params): axum::extract::Query>, ) -> axum::http::Response { match sso_query_code(¶ms, state.state.as_str()) { Ok(sso_code) => { state.sender.send(Ok(sso_code)).await.unwrap(); - axum::http::Response::builder().status(axum::http::StatusCode::OK). - body( - "Success | rbw \ + axum::http::Response::builder() + .status(axum::http::StatusCode::OK) + .body( + "Success | rbw \

Successfully authenticated with rbw

\

You may now close this tab and return to the terminal.

\ - ".to_string()).unwrap() + " + .to_string(), + ) + .unwrap() } Err(e) => { state.sender.send(Err(e)).await.unwrap(); - axum::http::Response::builder().status(axum::http::StatusCode::BAD_REQUEST). - body( - "Failed | rbw \ + axum::http::Response::builder() + .status(axum::http::StatusCode::BAD_REQUEST) + .body( + "Failed | rbw \

Something went wrong logging into the rbw

\

You may now close this tab and return to the terminal.

\ - ".to_string()).unwrap() + " + .to_string(), + ) + .unwrap() } } } @@ -1763,23 +1621,23 @@ fn sso_query_code( params: &std::collections::HashMap, state: &str, ) -> Result { - let sso_code = - params - .get("code") - .ok_or(Error::FailedToProcessSSOCallback { - msg: "Could not obtain code from the URL".to_string(), - })?; - - let received_state = - params - .get("state") - .ok_or(Error::FailedToProcessSSOCallback { - msg: "Could not obtain state from the URL".to_string(), - })?; + let sso_code = params + .get("code") + .ok_or(Error::FailedToProcessSSOCallback { + msg: "Could not obtain code from the URL".to_string(), + })?; + + let received_state = params + .get("state") + .ok_or(Error::FailedToProcessSSOCallback { + msg: "Could not obtain state from the URL".to_string(), + })?; if received_state.split("_identifier=").next().unwrap() != state { return Err(Error::FailedToProcessSSOCallback { - msg: format!("SSO callback states do not match, sent: {state}, received: {received_state}"), + msg: format!( + "SSO callback states do not match, sent: {state}, received: {received_state}" + ), }); } diff --git a/src/base64.rs b/src/base64.rs index 86971bc8..1fe31159 100644 --- a/src/base64.rs +++ b/src/base64.rs @@ -8,8 +8,6 @@ pub fn encode_url_safe_no_pad>(input: T) -> String { base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(input) } -pub fn decode>( - input: T, -) -> Result, base64::DecodeError> { +pub fn decode>(input: T) -> Result, base64::DecodeError> { base64::engine::general_purpose::STANDARD.decode(input) } diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 9ddd2ad9..32774e03 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -9,8 +9,7 @@ pub async fn register( if db.needs_login() { let url_str = config_base_url().await?; - let url = reqwest::Url::parse(&url_str) - .context("failed to parse base url")?; + let url = reqwest::Url::parse(&url_str).context("failed to parse base url")?; let Some(host) = url.host_str() else { return Err(anyhow::anyhow!( "couldn't find host in rbw base url {url_str}" @@ -55,17 +54,12 @@ pub async fn register( } Err(rbw::error::Error::IncorrectPassword { message }) => { if i == 3 { - return Err(rbw::error::Error::IncorrectPassword { - message, - }) - .context("failed to log in to bitwarden instance"); + return Err(rbw::error::Error::IncorrectPassword { message }) + .context("failed to log in to bitwarden instance"); } err_msg = Some(message); } - Err(e) => { - return Err(e) - .context("failed to log in to bitwarden instance") - } + Err(e) => return Err(e).context("failed to log in to bitwarden instance"), } } } @@ -84,8 +78,7 @@ pub async fn login( if db.needs_login() { let url_str = config_base_url().await?; - let url = reqwest::Url::parse(&url_str) - .context("failed to parse base url")?; + let url = reqwest::Url::parse(&url_str).context("failed to parse base url")?; let Some(host) = url.host_str() else { return Err(anyhow::anyhow!( "couldn't find host in rbw base url {url_str}" @@ -113,9 +106,7 @@ pub async fn login( ) .await .context("failed to read password from pinentry")?; - match rbw::actions::login(&email, password.clone(), None, None) - .await - { + match rbw::actions::login(&email, password.clone(), None, None).await { Ok(( access_token, refresh_token, @@ -153,9 +144,7 @@ pub async fn login( for provider in supported_types { if providers.contains(&provider) { - if provider - == rbw::api::TwoFactorProviderType::Email - { + if provider == rbw::api::TwoFactorProviderType::Email { if let Some(sso_email_2fa_session_token) = sso_email_2fa_session_token { @@ -174,13 +163,7 @@ pub async fn login( memory, parallelism, protected_key, - ) = two_factor( - environment, - &email, - password.clone(), - provider, - ) - .await?; + ) = two_factor(environment, &email, password.clone(), provider).await?; login_success( state.clone(), access_token, @@ -204,17 +187,12 @@ pub async fn login( } Err(rbw::error::Error::IncorrectPassword { message }) => { if i == 3 { - return Err(rbw::error::Error::IncorrectPassword { - message, - }) - .context("failed to log in to bitwarden instance"); + return Err(rbw::error::Error::IncorrectPassword { message }) + .context("failed to log in to bitwarden instance"); } err_msg = Some(message); } - Err(e) => { - return Err(e) - .context("failed to log in to bitwarden instance") - } + Err(e) => return Err(e).context("failed to log in to bitwarden instance"), } } } @@ -257,16 +235,8 @@ async fn two_factor( ) .await .context("failed to read code from pinentry")?; - let code = std::str::from_utf8(code.password()) - .context("code was not valid utf8")?; - match rbw::actions::login( - email, - password.clone(), - Some(code), - Some(provider), - ) - .await - { + let code = std::str::from_utf8(code.password()).context("code was not valid utf8")?; + match rbw::actions::login(email, password.clone(), Some(code), Some(provider)).await { Ok(( access_token, refresh_token, @@ -288,10 +258,8 @@ async fn two_factor( } Err(rbw::error::Error::IncorrectPassword { message }) => { if i == 3 { - return Err(rbw::error::Error::IncorrectPassword { - message, - }) - .context("failed to log in to bitwarden instance"); + return Err(rbw::error::Error::IncorrectPassword { message }) + .context("failed to log in to bitwarden instance"); } err_msg = Some(message); } @@ -299,17 +267,12 @@ async fn two_factor( Err(rbw::error::Error::TwoFactorRequired { .. }) => { let message = "TOTP code is not a number".to_string(); if i == 3 { - return Err(rbw::error::Error::IncorrectPassword { - message, - }) - .context("failed to log in to bitwarden instance"); + return Err(rbw::error::Error::IncorrectPassword { message }) + .context("failed to log in to bitwarden instance"); } err_msg = Some(message); } - Err(e) => { - return Err(e) - .context("failed to log in to bitwarden instance") - } + Err(e) => return Err(e).context("failed to log in to bitwarden instance"), } } @@ -383,18 +346,14 @@ async fn unlock_state( }; let Some(iterations) = db.iterations else { - return Err(anyhow::anyhow!( - "failed to find number of iterations in db" - )); + return Err(anyhow::anyhow!("failed to find number of iterations in db")); }; let memory = db.memory; let parallelism = db.parallelism; let Some(protected_key) = db.protected_key else { - return Err(anyhow::anyhow!( - "failed to find protected key in db" - )); + return Err(anyhow::anyhow!("failed to find protected key in db")); }; let Some(protected_private_key) = db.protected_private_key else { return Err(anyhow::anyhow!( @@ -416,10 +375,7 @@ async fn unlock_state( let password = rbw::pinentry::getpin( &config_pinentry().await?, "Master Password", - &format!( - "Unlock the local database for '{}'", - rbw::dirs::profile() - ), + &format!("Unlock the local database for '{}'", rbw::dirs::profile()), err.as_deref(), environment, true, @@ -443,10 +399,8 @@ async fn unlock_state( } Err(rbw::error::Error::IncorrectPassword { message }) => { if i == 3 { - return Err(rbw::error::Error::IncorrectPassword { - message, - }) - .context("failed to unlock database"); + return Err(rbw::error::Error::IncorrectPassword { message }) + .context("failed to unlock database"); } err_msg = Some(message); } @@ -521,12 +475,10 @@ pub async fn sync( } else { return Err(anyhow::anyhow!("failed to find refresh token in db")); }; - let ( - access_token, - (protected_key, protected_private_key, protected_org_keys, entries), - ) = rbw::actions::sync(&access_token, &refresh_token) - .await - .context("failed to sync database from server")?; + let (access_token, (protected_key, protected_private_key, protected_org_keys, entries)) = + rbw::actions::sync(&access_token, &refresh_token) + .await + .context("failed to sync database from server")?; state.lock().await.set_master_password_reprompt(&entries); if let Some(access_token) = access_token { db.access_token = Some(access_token); @@ -566,13 +518,12 @@ async fn decrypt_cipher( )); }; let entry_key = if let Some(entry_key) = entry_key { - let key_cipherstring = - rbw::cipherstring::CipherString::new(entry_key) - .context("failed to parse individual item encryption key")?; + let key_cipherstring = rbw::cipherstring::CipherString::new(entry_key) + .context("failed to parse individual item encryption key")?; Some(rbw::locked::Keys::new( - key_cipherstring.decrypt_locked_symmetric(keys).context( - "failed to decrypt individual item encryption key", - )?, + key_cipherstring + .decrypt_locked_symmetric(keys) + .context("failed to decrypt individual item encryption key")?, )) } else { None @@ -592,18 +543,14 @@ async fn decrypt_cipher( }; let Some(iterations) = db.iterations else { - return Err(anyhow::anyhow!( - "failed to find number of iterations in db" - )); + return Err(anyhow::anyhow!("failed to find number of iterations in db")); }; let memory = db.memory; let parallelism = db.parallelism; let Some(protected_key) = db.protected_key else { - return Err(anyhow::anyhow!( - "failed to find protected key in db" - )); + return Err(anyhow::anyhow!("failed to find protected key in db")); }; let Some(protected_private_key) = db.protected_private_key else { return Err(anyhow::anyhow!( @@ -648,10 +595,8 @@ async fn decrypt_cipher( } Err(rbw::error::Error::IncorrectPassword { message }) => { if i == 3 { - return Err(rbw::error::Error::IncorrectPassword { - message, - }) - .context("failed to unlock database"); + return Err(rbw::error::Error::IncorrectPassword { message }) + .context("failed to unlock database"); } err_msg = Some(message); } @@ -680,9 +625,7 @@ pub async fn decrypt( entry_key: Option<&str>, org_id: Option<&str>, ) -> anyhow::Result<()> { - let plaintext = - decrypt_cipher(state, environment, cipherstring, entry_key, org_id) - .await?; + let plaintext = decrypt_cipher(state, environment, cipherstring, entry_key, org_id).await?; respond_decrypt(sock, plaintext).await?; Ok(()) @@ -700,11 +643,9 @@ pub async fn encrypt( "failed to find encryption keys in in-memory state" )); }; - let cipherstring = rbw::cipherstring::CipherString::encrypt_symmetric( - keys, - plaintext.as_bytes(), - ) - .context("failed to encrypt plaintext secret")?; + let cipherstring = + rbw::cipherstring::CipherString::encrypt_symmetric(keys, plaintext.as_bytes()) + .context("failed to encrypt plaintext secret")?; respond_encrypt(sock, cipherstring.to_string()).await?; @@ -719,9 +660,9 @@ pub async fn clipboard_store( ) -> anyhow::Result<()> { let mut state = state.lock().await; if let Some(clipboard) = &mut state.clipboard { - clipboard.set_text(text).map_err(|e| { - anyhow::anyhow!("couldn't store value to clipboard: {e}") - })?; + clipboard + .set_text(text) + .map_err(|e| anyhow::anyhow!("couldn't store value to clipboard: {e}"))?; } respond_ack(sock).await?; @@ -758,20 +699,14 @@ async fn respond_ack(sock: &mut crate::sock::Sock) -> anyhow::Result<()> { Ok(()) } -async fn respond_decrypt( - sock: &mut crate::sock::Sock, - plaintext: String, -) -> anyhow::Result<()> { +async fn respond_decrypt(sock: &mut crate::sock::Sock, plaintext: String) -> anyhow::Result<()> { sock.send(&rbw::protocol::Response::Decrypt { plaintext }) .await?; Ok(()) } -async fn respond_encrypt( - sock: &mut crate::sock::Sock, - cipherstring: String, -) -> anyhow::Result<()> { +async fn respond_encrypt(sock: &mut crate::sock::Sock, cipherstring: String) -> anyhow::Result<()> { sock.send(&rbw::protocol::Response::Encrypt { cipherstring }) .await?; @@ -829,10 +764,8 @@ pub async fn subscribe_to_notifications( .await .context("Config is missing")?; let email = config.email.clone().context("Config is missing email")?; - let db = rbw::db::Db::load_async(config.server_name().as_str(), &email) - .await?; - let access_token = - db.access_token.context("Error getting access token")?; + let db = rbw::db::Db::load_async(config.server_name().as_str(), &email).await?; + let access_token = db.access_token.context("Error getting access token")?; let websocket_url = format!( "{}/hub?access_token={}", @@ -919,17 +852,14 @@ pub async fn find_ssh_private_key( ) .await?; let public_key_bytes = - ssh_agent_lib::ssh_key::PublicKey::from_openssh( - &public_key_plaintext, - ) - .map_err(anyhow::Error::new)? - .to_bytes(); + ssh_agent_lib::ssh_key::PublicKey::from_openssh(&public_key_plaintext) + .map_err(anyhow::Error::new)? + .to_bytes(); if public_key_bytes == request_bytes { - let private_key_enc = - private_key.as_ref().ok_or_else(|| { - anyhow::anyhow!("Matching entry has no private key") - })?; + let private_key_enc = private_key + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Matching entry has no private key"))?; let private_key_plaintext = decrypt_cipher( state.clone(), @@ -940,10 +870,8 @@ pub async fn find_ssh_private_key( ) .await?; - return ssh_agent_lib::ssh_key::PrivateKey::from_openssh( - private_key_plaintext, - ) - .map_err(anyhow::Error::new); + return ssh_agent_lib::ssh_key::PrivateKey::from_openssh(private_key_plaintext) + .map_err(anyhow::Error::new); } } } diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index 1691ed51..5a71fd66 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -20,10 +20,7 @@ impl Agent { } } - pub async fn run( - self, - listener: tokio::net::UnixListener, - ) -> anyhow::Result<()> { + pub async fn run(self, listener: tokio::net::UnixListener) -> anyhow::Result<()> { pub enum Event { Request(std::io::Result), Timeout(()), @@ -37,10 +34,7 @@ impl Agent { .notifications_handler .get_channel() .await; - let notifications = - tokio_stream::wrappers::UnboundedReceiverStream::new( - notifications, - ) + let notifications = tokio_stream::wrappers::UnboundedReceiverStream::new(notifications) .map(|message| match message { crate::notifications::Message::Logout => Event::Timeout(()), crate::notifications::Message::Sync => Event::Sync(()), @@ -51,16 +45,12 @@ impl Agent { tokio_stream::wrappers::UnixListenerStream::new(listener) .map(Event::Request) .boxed(), - tokio_stream::wrappers::UnboundedReceiverStream::new( - self.timer_r, - ) - .map(Event::Timeout) - .boxed(), - tokio_stream::wrappers::UnboundedReceiverStream::new( - self.sync_timer_r, - ) - .map(Event::Sync) - .boxed(), + tokio_stream::wrappers::UnboundedReceiverStream::new(self.timer_r) + .map(Event::Timeout) + .boxed(), + tokio_stream::wrappers::UnboundedReceiverStream::new(self.sync_timer_r) + .map(Event::Sync) + .boxed(), notifications, ]); while let Some(event) = stream.next().await { @@ -71,8 +61,7 @@ impl Agent { ); let state = self.state.clone(); tokio::spawn(async move { - let res = - handle_request(&mut sock, state.clone()).await; + let res = handle_request(&mut sock, state.clone()).await; if let Err(e) = res { // unwrap is the only option here sock.send(&rbw::protocol::Response::Error { @@ -91,9 +80,7 @@ impl Agent { tokio::spawn(async move { // this could fail if we aren't logged in, but we // don't care about that - if let Err(e) = - crate::actions::sync(None, state.clone()).await - { + if let Err(e) = crate::actions::sync(None, state.clone()).await { eprintln!("failed to sync: {e:#}"); } }); @@ -163,18 +150,11 @@ async fn handle_request( true } rbw::protocol::Action::Encrypt { plaintext, org_id } => { - crate::actions::encrypt( - sock, - state.clone(), - plaintext, - org_id.as_deref(), - ) - .await?; + crate::actions::encrypt(sock, state.clone(), plaintext, org_id.as_deref()).await?; true } rbw::protocol::Action::ClipboardStore { text } => { - crate::actions::clipboard_store(sock, state.clone(), text) - .await?; + crate::actions::clipboard_store(sock, state.clone(), text).await?; true } rbw::protocol::Action::Quit => std::process::exit(0), diff --git a/src/bin/rbw-agent/daemon.rs b/src/bin/rbw-agent/daemon.rs index ebc17d35..0a612e29 100644 --- a/src/bin/rbw-agent/daemon.rs +++ b/src/bin/rbw-agent/daemon.rs @@ -28,8 +28,7 @@ pub fn daemonize(no_daemonize: bool) -> anyhow::Result> { rustix::fs::FlockOperation::NonBlockingLockExclusive, ) .context("failed to lock pid file")?; - writeln!(pidfile, "{}", std::process::id()) - .context("failed to write pid file")?; + writeln!(pidfile, "{}", std::process::id()).context("failed to write pid file")?; // don't close the pidfile until the process exits, to ensure it // stays locked std::mem::forget(pidfile); diff --git a/src/bin/rbw-agent/debugger.rs b/src/bin/rbw-agent/debugger.rs index 3a104b5a..11d26aa0 100644 --- a/src/bin/rbw-agent/debugger.rs +++ b/src/bin/rbw-agent/debugger.rs @@ -13,7 +13,9 @@ pub fn disable_tracing() -> anyhow::Result<()> { Ok(()) } else { let e = std::io::Error::last_os_error(); - Err(anyhow::anyhow!("failed to disable PTRACE_ATTACH, agent memory may be dumpable by other processes: {e}")) + Err(anyhow::anyhow!( + "failed to disable PTRACE_ATTACH, agent memory may be dumpable by other processes: {e}" + )) } } @@ -21,13 +23,12 @@ pub fn disable_tracing() -> anyhow::Result<()> { pub fn disable_tracing() -> anyhow::Result<()> { // safety: correct arguments to ptrace // https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man2/ptrace.2.html - let ret = unsafe { - libc::ptrace(libc::PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0) - }; + let ret = unsafe { libc::ptrace(libc::PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0) }; if ret != 0 { let e = std::io::Error::last_os_error(); return Err(anyhow::anyhow!( - "failed to deny debugger attach, agent memory may be readable by other processes: {}", e + "failed to deny debugger attach, agent memory may be readable by other processes: {}", + e )); } @@ -42,7 +43,8 @@ pub fn disable_tracing() -> anyhow::Result<()> { if ret != 0 { let e = std::io::Error::last_os_error(); return Err(anyhow::anyhow!( - "failed to disable core dumps, agent memory may be dumped to disk: {}", e + "failed to disable core dumps, agent memory may be dumped to disk: {}", + e )); } diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index 225fb436..bada8561 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -10,9 +10,7 @@ mod ssh_agent; mod state; mod timeout; -async fn tokio_main( - startup_ack: Option, -) -> anyhow::Result<()> { +async fn tokio_main(startup_ack: Option) -> anyhow::Result<()> { let listener = crate::sock::listen()?; if let Some(startup_ack) = startup_ack { @@ -20,38 +18,34 @@ async fn tokio_main( } let config = rbw::config::Config::load()?; - let timeout_duration = - std::time::Duration::from_secs(config.lock_timeout); - let sync_timeout_duration = - std::time::Duration::from_secs(config.sync_interval); + let timeout_duration = std::time::Duration::from_secs(config.lock_timeout); + let sync_timeout_duration = std::time::Duration::from_secs(config.sync_interval); let (timeout, timer_r) = crate::timeout::Timeout::new(); let (sync_timeout, sync_timer_r) = crate::timeout::Timeout::new(); if sync_timeout_duration > std::time::Duration::ZERO { sync_timeout.set(sync_timeout_duration); } let notifications_handler = crate::notifications::Handler::new(); - let state = - std::sync::Arc::new(tokio::sync::Mutex::new(crate::state::State { - priv_key: None, - org_keys: None, - timeout, - timeout_duration, - sync_timeout, - sync_timeout_duration, - notifications_handler, - master_password_reprompt: std::collections::HashSet::new(), - master_password_reprompt_initialized: false, - last_environment: rbw::protocol::Environment::default(), - #[cfg(feature = "clipboard")] - clipboard: arboard::Clipboard::new() - .inspect_err(|e| { - log::warn!("couldn't create clipboard context: {e}"); - }) - .ok(), - })); - - let agent = - crate::agent::Agent::new(timer_r, sync_timer_r, state.clone()); + let state = std::sync::Arc::new(tokio::sync::Mutex::new(crate::state::State { + priv_key: None, + org_keys: None, + timeout, + timeout_duration, + sync_timeout, + sync_timeout_duration, + notifications_handler, + master_password_reprompt: std::collections::HashSet::new(), + master_password_reprompt_initialized: false, + last_environment: rbw::protocol::Environment::default(), + #[cfg(feature = "clipboard")] + clipboard: arboard::Clipboard::new() + .inspect_err(|e| { + log::warn!("couldn't create clipboard context: {e}"); + }) + .ok(), + })); + + let agent = crate::agent::Agent::new(timer_r, sync_timer_r, state.clone()); let ssh_agent = crate::ssh_agent::SshAgent::new(state.clone()); @@ -61,10 +55,7 @@ async fn tokio_main( } fn real_main() -> anyhow::Result<()> { - env_logger::Builder::from_env( - env_logger::Env::default().default_filter_or("info"), - ) - .init(); + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); let no_daemonize = std::env::args() .nth(1) @@ -72,8 +63,7 @@ fn real_main() -> anyhow::Result<()> { rbw::dirs::make_all()?; - let startup_ack = - daemon::daemonize(no_daemonize).context("failed to daemonize")?; + let startup_ack = daemon::daemonize(no_daemonize).context("failed to daemonize")?; if let Err(e) = debugger::disable_tracing() { log::warn!("{e}"); diff --git a/src/bin/rbw-agent/notifications.rs b/src/bin/rbw-agent/notifications.rs index 44b4c29a..c54f4a83 100644 --- a/src/bin/rbw-agent/notifications.rs +++ b/src/bin/rbw-agent/notifications.rs @@ -16,9 +16,8 @@ pub struct Handler { >, >, read_handle: Option>, - sending_channels: std::sync::Arc< - tokio::sync::RwLock>>, - >, + sending_channels: + std::sync::Arc>>>, } impl Handler { @@ -26,23 +25,17 @@ impl Handler { Self { write: None, read_handle: None, - sending_channels: std::sync::Arc::new(tokio::sync::RwLock::new( - Vec::new(), - )), + sending_channels: std::sync::Arc::new(tokio::sync::RwLock::new(Vec::new())), } } - pub async fn connect( - &mut self, - url: String, - ) -> Result<(), Box> { + pub async fn connect(&mut self, url: String) -> Result<(), Box> { if self.is_connected() { self.disconnect().await?; } let (write, read_handle) = - subscribe_to_notifications(url, self.sending_channels.clone()) - .await?; + subscribe_to_notifications(url, self.sending_channels.clone()).await?; self.write = Some(write); self.read_handle = Some(read_handle); @@ -55,9 +48,7 @@ impl Handler { && !self.read_handle.as_ref().unwrap().is_finished() } - pub async fn disconnect( - &mut self, - ) -> Result<(), Box> { + pub async fn disconnect(&mut self) -> Result<(), Box> { self.sending_channels.write().await.clear(); if let Some(mut write) = self.write.take() { write @@ -71,9 +62,7 @@ impl Handler { Ok(()) } - pub async fn get_channel( - &self, - ) -> tokio::sync::mpsc::UnboundedReceiver { + pub async fn get_channel(&self) -> tokio::sync::mpsc::UnboundedReceiver { let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); self.sending_channels.write().await.push(tx); rx @@ -98,8 +87,7 @@ async fn subscribe_to_notifications( Box, > { let url = url::Url::parse(url.as_str())?; - let (ws_stream, _response) = - tokio_tungstenite::connect_async(url).await?; + let (ws_stream, _response) = tokio_tungstenite::connect_async(url).await?; let (mut write, read) = ws_stream.split(); write @@ -133,19 +121,15 @@ async fn subscribe_to_notifications( Ok((write, tokio::spawn(read_future))) } -fn parse_message( - message: tokio_tungstenite::tungstenite::Message, -) -> Option { - let tokio_tungstenite::tungstenite::Message::Binary(data) = message - else { +fn parse_message(message: tokio_tungstenite::tungstenite::Message) -> Option { + let tokio_tungstenite::tungstenite::Message::Binary(data) = message else { return None; }; // the first few bytes with the 0x80 bit set, plus one byte terminating the length contain the length of the message let len_buffer_length = data.iter().position(|&x| (x & 0x80) == 0)? + 1; - let unpacked_messagepack = - rmpv::decode::read_value(&mut &data[len_buffer_length..]).ok()?; + let unpacked_messagepack = rmpv::decode::read_value(&mut &data[len_buffer_length..]).ok()?; let unpacked_message = unpacked_messagepack.as_array()?; let message_type = unpacked_message.first()?.as_u64()?; diff --git a/src/bin/rbw-agent/sock.rs b/src/bin/rbw-agent/sock.rs index cfbaa55b..2320435d 100644 --- a/src/bin/rbw-agent/sock.rs +++ b/src/bin/rbw-agent/sock.rs @@ -8,10 +8,7 @@ impl Sock { Self(s) } - pub async fn send( - &mut self, - res: &rbw::protocol::Response, - ) -> anyhow::Result<()> { + pub async fn send(&mut self, res: &rbw::protocol::Response) -> anyhow::Result<()> { if let rbw::protocol::Response::Error { error } = res { log::warn!("{error}"); } @@ -32,8 +29,7 @@ impl Sock { pub async fn recv( &mut self, - ) -> anyhow::Result> - { + ) -> anyhow::Result> { let Self(sock) = self; let mut buf = tokio::io::BufStream::new(sock); let mut line = String::new(); @@ -49,8 +45,7 @@ pub fn listen() -> anyhow::Result { let path = rbw::dirs::socket_file(); // if the socket already doesn't exist, that's fine let _ = std::fs::remove_file(&path); - let sock = tokio::net::UnixListener::bind(&path) - .context("failed to listen on socket")?; + let sock = tokio::net::UnixListener::bind(&path).context("failed to listen on socket")?; log::debug!("listening on socket {}", path.to_string_lossy()); Ok(sock) } diff --git a/src/bin/rbw-agent/ssh_agent.rs b/src/bin/rbw-agent/ssh_agent.rs index 7a5b1b3b..1fe72913 100644 --- a/src/bin/rbw-agent/ssh_agent.rs +++ b/src/bin/rbw-agent/ssh_agent.rs @@ -19,9 +19,7 @@ pub struct SshAgent { } impl SshAgent { - pub fn new( - state: std::sync::Arc>, - ) -> Self { + pub fn new(state: std::sync::Arc>) -> Self { Self { state } } @@ -41,10 +39,7 @@ impl SshAgent { impl ssh_agent_lib::agent::Session for SshAgent { async fn request_identities( &mut self, - ) -> Result< - Vec, - ssh_agent_lib::error::AgentError, - > { + ) -> Result, ssh_agent_lib::error::AgentError> { crate::actions::get_ssh_public_keys(self.state.clone()) .await .map_err(|e| ssh_agent_lib::error::AgentError::Other(e.into()))? @@ -63,24 +58,15 @@ impl ssh_agent_lib::agent::Session for SshAgent { async fn sign( &mut self, request: ssh_agent_lib::proto::SignRequest, - ) -> Result< - ssh_agent_lib::ssh_key::Signature, - ssh_agent_lib::error::AgentError, - > { - let pubkey = - ssh_agent_lib::ssh_key::PublicKey::new(request.pubkey, ""); - - let private_key = - crate::actions::find_ssh_private_key(self.state.clone(), pubkey) - .await - .map_err(|e| { - ssh_agent_lib::error::AgentError::Other(e.into()) - })?; + ) -> Result { + let pubkey = ssh_agent_lib::ssh_key::PublicKey::new(request.pubkey, ""); + + let private_key = crate::actions::find_ssh_private_key(self.state.clone(), pubkey) + .await + .map_err(|e| ssh_agent_lib::error::AgentError::Other(e.into()))?; if config_confirm_ssh().await.map_err(|_| { - ssh_agent_lib::error::AgentError::Other( - "Unable to load configuration".into(), - ) + ssh_agent_lib::error::AgentError::Other("Unable to load configuration".into()) })? { let confirmed = rbw::pinentry::confirm( &config_pinentry() @@ -114,31 +100,23 @@ impl ssh_agent_lib::agent::Session for SshAgent { let mut rng = rand_8::rngs::OsRng; - let (algorithm, sig_bytes) = if request.flags - & SSH_AGENT_RSA_SHA2_512 - != 0 - { - let signing_key = - rsa::pkcs1v15::SigningKey::::new( - rsa_key, - ); + let (algorithm, sig_bytes) = if request.flags & SSH_AGENT_RSA_SHA2_512 != 0 { + let signing_key = rsa::pkcs1v15::SigningKey::::new(rsa_key); let signature = signing_key .try_sign_with_rng(&mut rng, &request.data) .map_err(ssh_agent_lib::error::AgentError::other)?; ("rsa-sha2-512", signature.to_bytes()) } else if request.flags & SSH_AGENT_RSA_SHA2_256 != 0 { - let signing_key = - rsa::pkcs1v15::SigningKey::::new( - rsa_key, - ); + let signing_key = rsa::pkcs1v15::SigningKey::::new(rsa_key); let signature = signing_key .try_sign_with_rng(&mut rng, &request.data) .map_err(ssh_agent_lib::error::AgentError::other)?; ("rsa-sha2-256", signature.to_bytes()) } else { - let signing_key = rsa::pkcs1v15::SigningKey::::new_unprefixed(rsa_key); + let signing_key = + rsa::pkcs1v15::SigningKey::::new_unprefixed(rsa_key); let signature = signing_key .try_sign_with_rng(&mut rng, &request.data) .map_err(ssh_agent_lib::error::AgentError::other)?; diff --git a/src/bin/rbw-agent/state.rs b/src/bin/rbw-agent/state.rs index 15ba565d..c1bd3fc1 100644 --- a/src/bin/rbw-agent/state.rs +++ b/src/bin/rbw-agent/state.rs @@ -2,8 +2,7 @@ use sha2::Digest as _; pub struct State { pub priv_key: Option, - pub org_keys: - Option>, + pub org_keys: Option>, pub timeout: crate::timeout::Timeout, pub timeout_duration: std::time::Duration, pub sync_timeout: crate::timeout::Timeout, @@ -74,10 +73,7 @@ impl State { // if the agent gets a request for any of those cipherstrings that it saw // marked as master password reprompt during the most recent sync, it // forces a reprompt. - pub fn set_master_password_reprompt( - &mut self, - entries: &[rbw::db::Entry], - ) { + pub fn set_master_password_reprompt(&mut self, entries: &[rbw::db::Entry]) { self.master_password_reprompt.clear(); let mut hasher = sha2::Sha256::new(); @@ -137,10 +133,7 @@ impl State { &self.last_environment } - pub fn set_last_environment( - &mut self, - environment: rbw::protocol::Environment, - ) { + pub fn set_last_environment(&mut self, environment: rbw::protocol::Environment) { self.last_environment = environment; } } diff --git a/src/bin/rbw-agent/timeout.rs b/src/bin/rbw-agent/timeout.rs index e2aba06d..b9e5f764 100644 --- a/src/bin/rbw-agent/timeout.rs +++ b/src/bin/rbw-agent/timeout.rs @@ -37,11 +37,9 @@ impl Timeout { (_, Event::Request(Action::Set(dur))) => { stream.insert( Streams::Timer, - futures_util::stream::once(tokio::time::sleep( - dur, - )) - .map(|()| Event::Timer) - .boxed(), + futures_util::stream::once(tokio::time::sleep(dur)) + .map(|()| Event::Timer) + .boxed(), ); } (_, Event::Request(Action::Clear)) => { diff --git a/src/bin/rbw/actions.rs b/src/bin/rbw/actions.rs index a0a34e8c..4c305440 100644 --- a/src/bin/rbw/actions.rs +++ b/src/bin/rbw/actions.rs @@ -25,17 +25,14 @@ pub fn unlocked() -> anyhow::Result<()> { let res = sock.recv()?; match res { rbw::protocol::Response::Ack => Ok(()), - rbw::protocol::Response::Error { error } => { - Err(anyhow::anyhow!("{error}")) - } + rbw::protocol::Response::Error { error } => Err(anyhow::anyhow!("{error}")), _ => Err(anyhow::anyhow!("unexpected message: {res:?}")), } } Err(e) => { if matches!( e.kind(), - std::io::ErrorKind::ConnectionRefused - | std::io::ErrorKind::NotFound + std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound ) { anyhow::bail!("agent not running"); } @@ -58,9 +55,7 @@ pub fn quit() -> anyhow::Result<()> { let pidfile = rbw::dirs::pid_file(); let mut pid = String::new(); std::fs::File::open(pidfile)?.read_to_string(&mut pid)?; - let Some(pid) = - rustix::process::Pid::from_raw(pid.trim_end().parse()?) - else { + let Some(pid) = rustix::process::Pid::from_raw(pid.trim_end().parse()?) else { anyhow::bail!("failed to read pid from pidfile"); }; sock.send(&rbw::protocol::Request::new( @@ -73,8 +68,7 @@ pub fn quit() -> anyhow::Result<()> { Err(e) => match e.kind() { // if the socket doesn't exist, or the socket exists but nothing // is listening on it, the agent must already be not running - std::io::ErrorKind::ConnectionRefused - | std::io::ErrorKind::NotFound => Ok(()), + std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound => Ok(()), _ => Err(e.into()), }, } @@ -105,10 +99,7 @@ pub fn decrypt( } } -pub fn encrypt( - plaintext: &str, - org_id: Option<&str>, -) -> anyhow::Result { +pub fn encrypt(plaintext: &str, org_id: Option<&str>) -> anyhow::Result { let mut sock = connect()?; sock.send(&rbw::protocol::Request::new( get_environment(), @@ -159,9 +150,7 @@ fn simple_action(action: rbw::protocol::Action) -> anyhow::Result<()> { let res = sock.recv()?; match res { rbw::protocol::Response::Ack => Ok(()), - rbw::protocol::Response::Error { error } => { - Err(anyhow::anyhow!("{error}")) - } + rbw::protocol::Response::Error { error } => Err(anyhow::anyhow!("{error}")), _ => Err(anyhow::anyhow!("unexpected message: {res:?}")), } } @@ -195,9 +184,7 @@ fn get_environment() -> rbw::protocol::Environment { }); let env_vars = std::env::vars_os() - .filter(|(var_name, _)| { - (*rbw::protocol::ENVIRONMENT_VARIABLES_OS).contains(var_name) - }) + .filter(|(var_name, _)| (*rbw::protocol::ENVIRONMENT_VARIABLES_OS).contains(var_name)) .collect(); rbw::protocol::Environment::new(tty, env_vars) } diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 5383e12c..a4bd14a5 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -205,10 +205,9 @@ struct DecryptedSearchCipher { impl DecryptedSearchCipher { fn display_name(&self) -> String { - self.user.as_ref().map_or_else( - || self.name.clone(), - |user| format!("{user}@{}", self.name), - ) + self.user + .as_ref() + .map_or_else(|| self.name.clone(), |user| format!("{user}@{}", self.name)) } fn matches( @@ -222,18 +221,14 @@ impl DecryptedSearchCipher { exact: bool, ) -> bool { let match_str = match (ignore_case, exact) { - (true, true) => |field: &str, search_term: &str| { - field.to_lowercase() == search_term.to_lowercase() - }, + (true, true) => { + |field: &str, search_term: &str| field.to_lowercase() == search_term.to_lowercase() + } (true, false) => |field: &str, search_term: &str| { field.to_lowercase().contains(&search_term.to_lowercase()) }, - (false, true) => { - |field: &str, search_term: &str| field == search_term - } - (false, false) => { - |field: &str, search_term: &str| field.contains(search_term) - } + (false, true) => |field: &str, search_term: &str| field == search_term, + (false, false) => |field: &str, search_term: &str| field.contains(search_term), }; match (self.folder.as_deref(), folder) { @@ -272,9 +267,7 @@ impl DecryptedSearchCipher { match needle { Needle::Uuid(uuid, s) => { - if uuid::Uuid::parse_str(&self.id) != Ok(*uuid) - && !match_str(&self.name, s) - { + if uuid::Uuid::parse_str(&self.id) != Ok(*uuid) && !match_str(&self.name, s) { return false; } } @@ -284,9 +277,11 @@ impl DecryptedSearchCipher { } } Needle::Uri(given_uri) => { - if self.uris.iter().all(|(uri, match_type)| { - !matches_url(uri, *match_type, given_uri) - }) { + if self + .uris + .iter() + .all(|(uri, match_type)| !matches_url(uri, *match_type, given_uri)) + { return false; } } @@ -350,24 +345,20 @@ struct DecryptedCipher { impl DecryptedCipher { fn display_short(&self, desc: &str, clipboard: bool) -> bool { match &self.data { - DecryptedData::Login { password, .. } => { - password.as_ref().map_or_else( - || { - eprintln!("entry for '{desc}' had no password"); - false - }, - |password| val_display_or_store(clipboard, password), - ) - } - DecryptedData::Card { number, .. } => { - number.as_ref().map_or_else( - || { - eprintln!("entry for '{desc}' had no card number"); - false - }, - |number| val_display_or_store(clipboard, number), - ) - } + DecryptedData::Login { password, .. } => password.as_ref().map_or_else( + || { + eprintln!("entry for '{desc}' had no password"); + false + }, + |password| val_display_or_store(clipboard, password), + ), + DecryptedData::Card { number, .. } => number.as_ref().map_or_else( + || { + eprintln!("entry for '{desc}' had no card number"); + false + }, + |number| val_display_or_store(clipboard, number), + ), DecryptedData::Identity { title, first_name, @@ -375,13 +366,12 @@ impl DecryptedCipher { last_name, .. } => { - let names: Vec<_> = - [title, first_name, middle_name, last_name] - .iter() - .copied() - .flatten() - .cloned() - .collect(); + let names: Vec<_> = [title, first_name, middle_name, last_name] + .iter() + .copied() + .flatten() + .cloned() + .collect(); if names.is_empty() { eprintln!("entry for '{desc}' had no name"); false @@ -396,15 +386,13 @@ impl DecryptedCipher { }, |notes| val_display_or_store(clipboard, notes), ), - DecryptedData::SshKey { public_key, .. } => { - public_key.as_ref().map_or_else( - || { - eprintln!("entry for '{desc}' had no public key"); - false - }, - |public_key| val_display_or_store(clipboard, public_key), - ) - } + DecryptedData::SshKey { public_key, .. } => public_key.as_ref().map_or_else( + || { + eprintln!("entry for '{desc}' had no public key"); + false + }, + |public_key| val_display_or_store(clipboard, public_key), + ), } } @@ -442,8 +430,7 @@ impl DecryptedCipher { } Ok(Field::Uris) => { if let Some(uris) = uris { - let uri_strs: Vec<_> = - uris.iter().map(|uri| uri.uri.clone()).collect(); + let uri_strs: Vec<_> = uris.iter().map(|uri| uri.uri.clone()).collect(); val_display_or_store(clipboard, &uri_strs.join("\n")); } } @@ -454,10 +441,7 @@ impl DecryptedCipher { for f in &self.fields { if let Some(name) = &f.name { if name.to_lowercase().as_str().contains(field) { - val_display_or_store( - clipboard, - f.value.as_deref().unwrap_or(""), - ); + val_display_or_store(clipboard, f.value.as_deref().unwrap_or("")); break; } } @@ -477,10 +461,7 @@ impl DecryptedCipher { } Ok(Field::Expiration) => { if let (Some(month), Some(year)) = (exp_month, exp_year) { - val_display_or_store( - clipboard, - &format!("{month}/{year}"), - ); + val_display_or_store(clipboard, &format!("{month}/{year}")); } } Ok(Field::ExpMonth) => { @@ -517,10 +498,7 @@ impl DecryptedCipher { for f in &self.fields { if let Some(name) = &f.name { if name.to_lowercase().as_str().contains(field) { - val_display_or_store( - clipboard, - f.value.as_deref().unwrap_or(""), - ); + val_display_or_store(clipboard, f.value.as_deref().unwrap_or("")); break; } } @@ -620,10 +598,7 @@ impl DecryptedCipher { for f in &self.fields { if let Some(name) = &f.name { if name.to_lowercase().as_str().contains(field) { - val_display_or_store( - clipboard, - f.value.as_deref().unwrap_or(""), - ); + val_display_or_store(clipboard, f.value.as_deref().unwrap_or("")); break; } } @@ -638,10 +613,7 @@ impl DecryptedCipher { for f in &self.fields { if let Some(name) = &f.name { if name.to_lowercase().as_str().contains(field) { - val_display_or_store( - clipboard, - f.value.as_deref().unwrap_or(""), - ); + val_display_or_store(clipboard, f.value.as_deref().unwrap_or("")); break; } } @@ -675,10 +647,7 @@ impl DecryptedCipher { for f in &self.fields { if let Some(name) = &f.name { if name.to_lowercase().as_str().contains(field) { - val_display_or_store( - clipboard, - f.value.as_deref().unwrap_or(""), - ); + val_display_or_store(clipboard, f.value.as_deref().unwrap_or("")); break; } } @@ -697,22 +666,14 @@ impl DecryptedCipher { .. } => { let mut displayed = self.display_short(desc, clipboard); - displayed |= - display_field("Username", username.as_deref(), clipboard); - displayed |= - display_field("TOTP Secret", totp.as_deref(), clipboard); + displayed |= display_field("Username", username.as_deref(), clipboard); + displayed |= display_field("TOTP Secret", totp.as_deref(), clipboard); if let Some(uris) = uris { for uri in uris { - displayed |= - display_field("URI", Some(&uri.uri), clipboard); - let match_type = - uri.match_type.map(|ty| format!("{ty}")); - displayed |= display_field( - "Match type", - match_type.as_deref(), - clipboard, - ); + displayed |= display_field("URI", Some(&uri.uri), clipboard); + let match_type = uri.match_type.map(|ty| format!("{ty}")); + displayed |= display_field("Match type", match_type.as_deref(), clipboard); } } @@ -742,20 +703,13 @@ impl DecryptedCipher { let mut displayed = false; displayed |= self.display_short(desc, clipboard); - if let (Some(exp_month), Some(exp_year)) = - (exp_month, exp_year) - { + if let (Some(exp_month), Some(exp_year)) = (exp_month, exp_year) { println!("Expiration: {exp_month}/{exp_year}"); displayed = true; } displayed |= display_field("CVV", code.as_deref(), clipboard); - displayed |= display_field( - "Name", - cardholder_name.as_deref(), - clipboard, - ); - displayed |= - display_field("Brand", brand.as_deref(), clipboard); + displayed |= display_field("Name", cardholder_name.as_deref(), clipboard); + displayed |= display_field("Brand", brand.as_deref(), clipboard); if let Some(notes) = &self.notes { if displayed { @@ -782,40 +736,19 @@ impl DecryptedCipher { } => { let mut displayed = self.display_short(desc, clipboard); - displayed |= - display_field("Address", address1.as_deref(), clipboard); - displayed |= - display_field("Address", address2.as_deref(), clipboard); - displayed |= - display_field("Address", address3.as_deref(), clipboard); - displayed |= - display_field("City", city.as_deref(), clipboard); - displayed |= - display_field("State", state.as_deref(), clipboard); - displayed |= display_field( - "Postcode", - postal_code.as_deref(), - clipboard, - ); - displayed |= - display_field("Country", country.as_deref(), clipboard); - displayed |= - display_field("Phone", phone.as_deref(), clipboard); - displayed |= - display_field("Email", email.as_deref(), clipboard); + displayed |= display_field("Address", address1.as_deref(), clipboard); + displayed |= display_field("Address", address2.as_deref(), clipboard); + displayed |= display_field("Address", address3.as_deref(), clipboard); + displayed |= display_field("City", city.as_deref(), clipboard); + displayed |= display_field("State", state.as_deref(), clipboard); + displayed |= display_field("Postcode", postal_code.as_deref(), clipboard); + displayed |= display_field("Country", country.as_deref(), clipboard); + displayed |= display_field("Phone", phone.as_deref(), clipboard); + displayed |= display_field("Email", email.as_deref(), clipboard); displayed |= display_field("SSN", ssn.as_deref(), clipboard); - displayed |= display_field( - "License", - license_number.as_deref(), - clipboard, - ); - displayed |= display_field( - "Passport", - passport_number.as_deref(), - clipboard, - ); - displayed |= - display_field("Username", username.as_deref(), clipboard); + displayed |= display_field("License", license_number.as_deref(), clipboard); + displayed |= display_field("Passport", passport_number.as_deref(), clipboard); + displayed |= display_field("Username", username.as_deref(), clipboard); if let Some(notes) = &self.notes { if displayed { @@ -829,11 +762,7 @@ impl DecryptedCipher { } DecryptedData::SshKey { fingerprint, .. } => { let mut displayed = self.display_short(desc, clipboard); - displayed |= display_field( - "Fingerprint", - fingerprint.as_deref(), - clipboard, - ); + displayed |= display_field("Fingerprint", fingerprint.as_deref(), clipboard); for field in &self.fields { displayed |= display_field( @@ -935,8 +864,7 @@ impl DecryptedCipher { if email.is_some() { println!("{}", Field::Email); } - if [address1, address2, address3].iter().any(|f| f.is_some()) - { + if [address1, address2, address3].iter().any(|f| f.is_some()) { // the display_field combines all these fields together. println!("address"); } @@ -1122,15 +1050,13 @@ fn matches_url( if let Some(self_host_port) = host_port(&self_url) { if self_url.scheme() == given_url.scheme() && (self_host_port == given_host_port - || given_host_port - .ends_with(&format!(".{self_host_port}"))) + || given_host_port.ends_with(&format!(".{self_host_port}"))) { return true; } } } - url == given_host_port - || given_host_port.ends_with(&format!(".{url}")) + url == given_host_port || given_host_port.ends_with(&format!(".{url}")) } rbw::api::UriMatchType::Host => { let Some(given_host_port) = host_port(given_url) else { @@ -1138,8 +1064,7 @@ fn matches_url( }; if let Ok(self_url) = url::Url::parse(url) { if let Some(self_host_port) = host_port(&self_url) { - if self_url.scheme() == given_url.scheme() - && self_host_port == given_host_port + if self_url.scheme() == given_url.scheme() && self_host_port == given_host_port { return true; } @@ -1147,13 +1072,10 @@ fn matches_url( } url == given_host_port } - rbw::api::UriMatchType::StartsWith => { - given_url.to_string().starts_with(url) - } + rbw::api::UriMatchType::StartsWith => given_url.to_string().starts_with(url), rbw::api::UriMatchType::Exact => { if given_url.path() == "/" { - given_url.to_string().trim_end_matches('/') - == url.trim_end_matches('/') + given_url.to_string().trim_end_matches('/') == url.trim_end_matches('/') } else { given_url.to_string() == url } @@ -1171,10 +1093,8 @@ fn matches_url( fn host_port(url: &url::Url) -> Option { let host = url.host_str()?; Some( - url.port().map_or_else( - || host.to_string(), - |port| format!("{host}:{port}"), - ), + url.port() + .map_or_else(|| host.to_string(), |port| format!("{host}:{port}")), ) } @@ -1237,8 +1157,7 @@ pub fn config_show() -> anyhow::Result<()> { } pub fn config_set(key: &str, value: &str) -> anyhow::Result<()> { - let mut config = rbw::config::Config::load() - .unwrap_or_else(|_| rbw::config::Config::new()); + let mut config = rbw::config::Config::load().unwrap_or_else(|_| rbw::config::Config::new()); match key { "email" => config.email = Some(value.to_string()), "sso_id" => config.sso_id = Some(value.to_string()), @@ -1249,8 +1168,7 @@ pub fn config_set(key: &str, value: &str) -> anyhow::Result<()> { config.notifications_url = Some(value.to_string()); } "client_cert_path" => { - config.client_cert_path = - Some(std::path::PathBuf::from(value.to_string())); + config.client_cert_path = Some(std::path::PathBuf::from(value.to_string())); } "lock_timeout" => { let timeout = value @@ -1285,8 +1203,7 @@ pub fn config_set(key: &str, value: &str) -> anyhow::Result<()> { } pub fn config_unset(key: &str) -> anyhow::Result<()> { - let mut config = rbw::config::Config::load() - .unwrap_or_else(|_| rbw::config::Config::new()); + let mut config = rbw::config::Config::load().unwrap_or_else(|_| rbw::config::Config::new()); match key { "email" => config.email = None, "sso_id" => config.sso_id = None, @@ -1407,9 +1324,8 @@ pub fn get( needle ); - let (_, decrypted) = - find_entry(&db, needle, user, folder, ignore_case) - .with_context(|| format!("couldn't find entry for '{desc}'"))?; + let (_, decrypted) = find_entry(&db, needle, user, folder, ignore_case) + .with_context(|| format!("couldn't find entry for '{desc}'"))?; if list_fields { decrypted.display_fields_list(); } else if raw { @@ -1440,18 +1356,18 @@ fn print_entry_list( .iter() .map(|field| match field { ListField::Id => entry.id.clone(), - ListField::Name => entry.name.as_ref().map_or_else( - String::new, - std::string::ToString::to_string, - ), - ListField::User => entry.user.as_ref().map_or_else( - String::new, - std::string::ToString::to_string, - ), - ListField::Folder => entry.folder.as_ref().map_or_else( - String::new, - std::string::ToString::to_string, - ), + ListField::Name => entry + .name + .as_ref() + .map_or_else(String::new, std::string::ToString::to_string), + ListField::User => entry + .user + .as_ref() + .map_or_else(String::new, std::string::ToString::to_string), + ListField::Folder => entry + .folder + .as_ref() + .map_or_else(String::new, std::string::ToString::to_string), ListField::Uri => { // "uri" is not listed in the TryFrom // implementation, so there's no way to try to @@ -1460,21 +1376,17 @@ fn print_entry_list( // string) unreachable!() } - ListField::EntryType => { - entry.entry_type.as_ref().map_or_else( - String::new, - std::string::ToString::to_string, - ) - } + ListField::EntryType => entry + .entry_type + .as_ref() + .map_or_else(String::new, std::string::ToString::to_string), }) .collect(); // write to stdout but don't panic when pipe get's closed // this happens when piping stdout in a shell match writeln!(&mut std::io::stdout(), "{}", values.join("\t")) { - Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => { - Ok(()) - } + Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()), res => res, }?; } @@ -1538,17 +1450,14 @@ pub fn code( needle ); - let (_, decrypted) = - find_entry(&db, needle, user, folder, ignore_case) - .with_context(|| format!("couldn't find entry for '{desc}'"))?; + let (_, decrypted) = find_entry(&db, needle, user, folder, ignore_case) + .with_context(|| format!("couldn't find entry for '{desc}'"))?; if let DecryptedData::Login { totp, .. } = decrypted.data { if let Some(totp) = totp { val_display_or_store(clipboard, &generate_totp(&totp)?); } else { - return Err(anyhow::anyhow!( - "entry does not contain a totp secret" - )); + return Err(anyhow::anyhow!("entry does not contain a totp secret")); } } else { return Err(anyhow::anyhow!("not a login entry")); @@ -1598,8 +1507,7 @@ pub fn add( let mut folder_id = None; if let Some(folder_name) = folder { - let (new_access_token, folders) = - rbw::actions::list_folders(&access_token, refresh_token)?; + let (new_access_token, folders) = rbw::actions::list_folders(&access_token, refresh_token)?; if let Some(new_access_token) = new_access_token { access_token.clone_from(&new_access_token); db.access_token = Some(new_access_token); @@ -1609,9 +1517,7 @@ pub fn add( let folders: Vec<(String, String)> = folders .iter() .cloned() - .map(|(id, name)| { - Ok((id, crate::actions::decrypt(&name, None, None)?)) - }) + .map(|(id, name)| Ok((id, crate::actions::decrypt(&name, None, None)?))) .collect::>()?; for (id, name) in folders { @@ -1704,9 +1610,7 @@ pub fn generate( let folders: Vec<(String, String)> = folders .iter() .cloned() - .map(|(id, name)| { - Ok((id, crate::actions::decrypt(&name, None, None)?)) - }) + .map(|(id, name)| Ok((id, crate::actions::decrypt(&name, None, None)?))) .collect::>()?; for (id, name) in folders { @@ -1770,14 +1674,12 @@ pub fn edit( name ); - let (entry, decrypted) = - find_entry(&db, name, username, folder, ignore_case) - .with_context(|| format!("couldn't find entry for '{desc}'"))?; + let (entry, decrypted) = find_entry(&db, name, username, folder, ignore_case) + .with_context(|| format!("couldn't find entry for '{desc}'"))?; let (data, fields, notes, history) = match &decrypted.data { DecryptedData::Login { password, .. } => { - let mut contents = - format!("{}\n", password.as_deref().unwrap_or("")); + let mut contents = format!("{}\n", password.as_deref().unwrap_or("")); if let Some(notes) = decrypted.notes { write!(contents, "\n{notes}\n").unwrap(); } @@ -1786,17 +1688,10 @@ pub fn edit( let (password, notes) = parse_editor(&contents); let password = password - .map(|password| { - crate::actions::encrypt( - &password, - entry.org_id.as_deref(), - ) - }) + .map(|password| crate::actions::encrypt(&password, entry.org_id.as_deref())) .transpose()?; let notes = notes - .map(|notes| { - crate::actions::encrypt(¬es, entry.org_id.as_deref()) - }) + .map(|notes| crate::actions::encrypt(¬es, entry.org_id.as_deref())) .transpose()?; let mut history = entry.history.clone(); let rbw::db::EntryData::Login { @@ -1813,9 +1708,7 @@ pub fn edit( let new_history_entry = rbw::db::HistoryEntry { last_used_date: format!( "{}", - humantime::format_rfc3339( - std::time::SystemTime::now() - ) + humantime::format_rfc3339(std::time::SystemTime::now()) ), password: prev_password, }; @@ -1833,19 +1726,16 @@ pub fn edit( DecryptedData::SecureNote => { let data = rbw::db::EntryData::SecureNote {}; - let editor_content = decrypted.notes.map_or_else( - || "\n".to_string(), - |notes| format!("{notes}\n"), - ); + let editor_content = decrypted + .notes + .map_or_else(|| "\n".to_string(), |notes| format!("{notes}\n")); let contents = rbw::edit::edit(&editor_content, HELP_NOTES)?; // prepend blank line to be parsed as pw by `parse_editor` let (_, notes) = parse_editor(&format!("\n{contents}\n")); let notes = notes - .map(|notes| { - crate::actions::encrypt(¬es, entry.org_id.as_deref()) - }) + .map(|notes| crate::actions::encrypt(¬es, entry.org_id.as_deref())) .transpose()?; (data, entry.fields, notes, entry.history) @@ -1898,8 +1788,7 @@ pub fn remove( let (entry, _) = find_entry(&db, name, username, folder, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; - if let (Some(access_token), ()) = - rbw::actions::remove(access_token, refresh_token, &entry.id)? + if let (Some(access_token), ()) = rbw::actions::remove(access_token, refresh_token, &entry.id)? { db.access_token = Some(access_token); save_db(&db)?; @@ -1977,9 +1866,7 @@ fn run_agent() -> anyhow::Result<()> { if !status.success() { if let Some(code) = status.code() { if code != 23 { - return Err(anyhow::anyhow!( - "failed to run rbw-agent: {status}" - )); + return Err(anyhow::anyhow!("failed to run rbw-agent: {status}")); } } } @@ -2031,13 +1918,9 @@ fn find_entry( let ciphers: Vec<(rbw::db::Entry, DecryptedSearchCipher)> = db .entries .iter() - .map(|entry| { - decrypt_search_cipher(entry) - .map(|decrypted| (entry.clone(), decrypted)) - }) + .map(|entry| decrypt_search_cipher(entry).map(|decrypted| (entry.clone(), decrypted))) .collect::>()?; - let (entry, _) = - find_entry_raw(&ciphers, &needle, username, folder, ignore_case)?; + let (entry, _) = find_entry_raw(&ciphers, &needle, username, folder, ignore_case)?; let decrypted_entry = decrypt_cipher(&entry)?; Ok((entry, decrypted_entry)) } @@ -2077,13 +1960,9 @@ fn find_entry_raw( let strict_folder_matches = find_matches(false, true, exact); let strict_username_matches = find_matches(true, false, exact); - if strict_folder_matches.len() == 1 - && strict_username_matches.len() != 1 - { + if strict_folder_matches.len() == 1 && strict_username_matches.len() != 1 { return Ok(strict_folder_matches[0].clone()); - } else if strict_folder_matches.len() != 1 - && strict_username_matches.len() == 1 - { + } else if strict_folder_matches.len() != 1 && strict_username_matches.len() == 1 { return Ok(strict_username_matches[0].clone()); } @@ -2202,15 +2081,9 @@ fn decrypt_list_cipher( }) } -fn decrypt_search_cipher( - entry: &rbw::db::Entry, -) -> anyhow::Result { +fn decrypt_search_cipher(entry: &rbw::db::Entry) -> anyhow::Result { let id = entry.id.clone(); - let name = crate::actions::decrypt( - &entry.name, - entry.key.as_deref(), - entry.org_id.as_deref(), - )?; + let name = crate::actions::decrypt(&entry.name, entry.key.as_deref(), entry.org_id.as_deref())?; let user = match &entry.data { rbw::db::EntryData::Login { username, .. } => decrypt_field( Field::Username, @@ -2230,13 +2103,7 @@ fn decrypt_search_cipher( let notes = entry .notes .as_ref() - .map(|notes| { - crate::actions::decrypt( - notes, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - }) + .map(|notes| crate::actions::decrypt(notes, entry.key.as_deref(), entry.org_id.as_deref())) .transpose(); let uris = if let rbw::db::EntryData::Login { uris, .. } = &entry.data { uris.iter() @@ -2263,13 +2130,7 @@ fn decrypt_search_cipher( field.value.as_ref() } }) - .map(|value| { - crate::actions::decrypt( - value, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - }) + .map(|value| crate::actions::decrypt(value, entry.key.as_deref(), entry.org_id.as_deref())) .collect::>()?; let notes = match notes { Ok(notes) => notes, @@ -2323,11 +2184,7 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { .name .as_ref() .map(|name| { - crate::actions::decrypt( - name, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) + crate::actions::decrypt(name, entry.key.as_deref(), entry.org_id.as_deref()) }) .transpose()?, value: field @@ -2348,13 +2205,7 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { let notes = entry .notes .as_ref() - .map(|notes| { - crate::actions::decrypt( - notes, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - }) + .map(|notes| crate::actions::decrypt(notes, entry.key.as_deref(), entry.org_id.as_deref())) .transpose(); let notes = match notes { Ok(notes) => notes, @@ -2616,11 +2467,7 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { Ok(DecryptedCipher { id: entry.id.clone(), folder, - name: crate::actions::decrypt( - &entry.name, - entry.key.as_deref(), - entry.org_id.as_deref(), - )?, + name: crate::actions::decrypt(&entry.name, entry.key.as_deref(), entry.org_id.as_deref())?, data, fields, notes, @@ -2653,10 +2500,7 @@ fn load_db() -> anyhow::Result { let config = rbw::config::Config::load()?; config.email.as_ref().map_or_else( || Err(anyhow::anyhow!("failed to find email address in config")), - |email| { - rbw::db::Db::load(&config.server_name(), email) - .map_err(anyhow::Error::new) - }, + |email| rbw::db::Db::load(&config.server_name(), email).map_err(anyhow::Error::new), ) } @@ -2675,10 +2519,7 @@ fn remove_db() -> anyhow::Result<()> { let config = rbw::config::Config::load()?; config.email.as_ref().map_or_else( || Err(anyhow::anyhow!("failed to find email address in config")), - |email| { - rbw::db::Db::remove(&config.server_name(), email) - .map_err(anyhow::Error::new) - }, + |email| rbw::db::Db::remove(&config.server_name(), email).map_err(anyhow::Error::new), ) } @@ -2710,33 +2551,29 @@ fn parse_totp_secret(secret: &str) -> anyhow::Result { match u.scheme() { "otpauth" => { if u.host_str() != Some("totp") { - return Err(anyhow::anyhow!( - "totp secret url must have totp host" - )); + return Err(anyhow::anyhow!("totp secret url must have totp host")); } - let query: std::collections::HashMap<_, _> = - u.query_pairs().collect(); + let query: std::collections::HashMap<_, _> = u.query_pairs().collect(); let secret = decode_totp_secret( - query.get("secret").ok_or_else(|| { - anyhow::anyhow!("totp secret url must have secret") - })?, + query + .get("secret") + .ok_or_else(|| anyhow::anyhow!("totp secret url must have secret"))?, )?; - let algorithm = query.get("algorithm").map_or_else( - || String::from("SHA1"), - std::string::ToString::to_string, - ); + let algorithm = query + .get("algorithm") + .map_or_else(|| String::from("SHA1"), std::string::ToString::to_string); let digits = match query.get("digits") { - Some(dig) => dig - .parse::() - .map_err(|_| anyhow::anyhow!("digits parameter in totp url must be a valid integer."))?, + Some(dig) => dig.parse::().map_err(|_| { + anyhow::anyhow!("digits parameter in totp url must be a valid integer.") + })?, None => 6, }; let period = match query.get("period") { - Some(dig) => { - dig.parse::().map_err(|_| anyhow::anyhow!("period parameter in totp url must be a valid integer."))? - } + Some(dig) => dig.parse::().map_err(|_| { + anyhow::anyhow!("period parameter in totp url must be a valid integer.") + })?, None => TOTP_DEFAULT_STEP, }; @@ -2773,9 +2610,7 @@ fn parse_totp_secret(secret: &str) -> anyhow::Result { // This function exists for the sake of making the generate_totp function less // densely packed and more readable -fn generate_totp_algorithm_type( - alg: &str, -) -> anyhow::Result { +fn generate_totp_algorithm_type(alg: &str) -> anyhow::Result { match alg { "SHA1" => Ok(totp_rs::Algorithm::SHA1), "SHA256" => Ok(totp_rs::Algorithm::SHA256), @@ -2798,8 +2633,7 @@ fn generate_totp(secret: &str) -> anyhow::Result { totp_params.secret, ) .generate_current()?), - "STEAM" => Ok(totp_rs::TOTP::new_steam(totp_params.secret) - .generate_current()?), + "STEAM" => Ok(totp_rs::TOTP::new_steam(totp_params.secret).generate_current()?), _ => Err(anyhow::anyhow!(format!( "{alg} is not a valid totp algorithm" ))), @@ -2888,25 +2722,11 @@ mod test { "BITWARDEN" ); assert!( - one_match( - entries, - "github", - Some("foo"), - Some("websites"), - 6, - false - ), + one_match(entries, "github", Some("foo"), Some("websites"), 6, false), "websites/foo@github" ); assert!( - one_match( - entries, - "GITHUB", - Some("foo"), - Some("websites"), - 6, - true - ), + one_match(entries, "GITHUB", Some("foo"), Some("websites"), 6, true), "websites/foo@GITHUB" ); assert!( @@ -3011,18 +2831,8 @@ mod test { make_entry("github", Some("foo"), None, &[]), make_entry("gitlab", Some("foo"), None, &[]), make_entry("gitlab", Some("bar"), None, &[]), - make_entry( - "12345678-1234-1234-1234-1234567890ab", - None, - None, - &[], - ), - make_entry( - "12345678-1234-1234-1234-1234567890AC", - None, - None, - &[], - ), + make_entry("12345678-1234-1234-1234-1234567890ab", None, None, &[]), + make_entry("12345678-1234-1234-1234-1234567890AC", None, None, &[]), make_entry("123456781234123412341234567890AD", None, None, &[]), ]; @@ -3111,19 +2921,9 @@ mod test { let entries = &[ make_entry("one", None, None, &[("https://one.com/", None)]), make_entry("two", None, None, &[("https://two.com/login", None)]), - make_entry( - "three", - None, - None, - &[("https://login.three.com/", None)], - ), + make_entry("three", None, None, &[("https://login.three.com/", None)]), make_entry("four", None, None, &[("four.com", None)]), - make_entry( - "five", - None, - None, - &[("https://five.com:8080/", None)], - ), + make_entry("five", None, None, &[("https://five.com:8080/", None)]), make_entry("six", None, None, &[("six.com:8080", None)]), make_entry("seven", None, None, &[("192.168.0.128:8080", None)]), ]; @@ -3133,14 +2933,7 @@ mod test { "one" ); assert!( - one_match( - entries, - "https://login.one.com/", - None, - None, - 0, - false - ), + one_match(entries, "https://login.one.com/", None, None, 0, false), "one" ); assert!( @@ -3160,26 +2953,12 @@ mod test { "two" ); assert!( - one_match( - entries, - "https://two.com/other-page", - None, - None, - 1, - false - ), + one_match(entries, "https://two.com/other-page", None, None, 1, false), "two" ); assert!( - one_match( - entries, - "https://login.three.com/", - None, - None, - 2, - false - ), + one_match(entries, "https://login.three.com/", None, None, 2, false), "three" ); assert!( @@ -3193,14 +2972,7 @@ mod test { ); assert!( - one_match( - entries, - "https://five.com:8080/", - None, - None, - 4, - false - ), + one_match(entries, "https://five.com:8080/", None, None, 4, false), "five" ); assert!( @@ -3217,14 +2989,7 @@ mod test { "six" ); assert!( - one_match( - entries, - "https://192.168.0.128:8080/", - None, - None, - 6, - false - ), + one_match(entries, "https://192.168.0.128:8080/", None, None, 6, false), "seven" ); assert!( @@ -3285,10 +3050,7 @@ mod test { "seven", None, None, - &[( - "192.168.0.128:8080", - Some(rbw::api::UriMatchType::Domain), - )], + &[("192.168.0.128:8080", Some(rbw::api::UriMatchType::Domain))], ), ]; @@ -3297,14 +3059,7 @@ mod test { "one" ); assert!( - one_match( - entries, - "https://login.one.com/", - None, - None, - 0, - false - ), + one_match(entries, "https://login.one.com/", None, None, 0, false), "one" ); assert!( @@ -3324,26 +3079,12 @@ mod test { "two" ); assert!( - one_match( - entries, - "https://two.com/other-page", - None, - None, - 1, - false - ), + one_match(entries, "https://two.com/other-page", None, None, 1, false), "two" ); assert!( - one_match( - entries, - "https://login.three.com/", - None, - None, - 2, - false - ), + one_match(entries, "https://login.three.com/", None, None, 2, false), "three" ); assert!( @@ -3357,14 +3098,7 @@ mod test { ); assert!( - one_match( - entries, - "https://five.com:8080/", - None, - None, - 4, - false - ), + one_match(entries, "https://five.com:8080/", None, None, 4, false), "five" ); assert!( @@ -3381,14 +3115,7 @@ mod test { "six" ); assert!( - one_match( - entries, - "https://192.168.0.128:8080/", - None, - None, - 6, - false - ), + one_match(entries, "https://192.168.0.128:8080/", None, None, 6, false), "seven" ); assert!( @@ -3410,10 +3137,7 @@ mod test { "two", None, None, - &[( - "https://two.com/login", - Some(rbw::api::UriMatchType::Host), - )], + &[("https://two.com/login", Some(rbw::api::UriMatchType::Host))], ), make_entry( "three", @@ -3434,10 +3158,7 @@ mod test { "five", None, None, - &[( - "https://five.com:8080/", - Some(rbw::api::UriMatchType::Host), - )], + &[("https://five.com:8080/", Some(rbw::api::UriMatchType::Host))], ), make_entry( "six", @@ -3478,26 +3199,12 @@ mod test { "two" ); assert!( - one_match( - entries, - "https://two.com/other-page", - None, - None, - 1, - false - ), + one_match(entries, "https://two.com/other-page", None, None, 1, false), "two" ); assert!( - one_match( - entries, - "https://login.three.com/", - None, - None, - 2, - false - ), + one_match(entries, "https://login.three.com/", None, None, 2, false), "three" ); assert!( @@ -3511,14 +3218,7 @@ mod test { ); assert!( - one_match( - entries, - "https://five.com:8080/", - None, - None, - 4, - false - ), + one_match(entries, "https://five.com:8080/", None, None, 4, false), "five" ); assert!( @@ -3535,14 +3235,7 @@ mod test { "six" ); assert!( - one_match( - entries, - "https://192.168.0.128:8080/", - None, - None, - 6, - false - ), + one_match(entries, "https://192.168.0.128:8080/", None, None, 6, false), "seven" ); assert!( @@ -3558,10 +3251,7 @@ mod test { "one", None, None, - &[( - "https://one.com/", - Some(rbw::api::UriMatchType::StartsWith), - )], + &[("https://one.com/", Some(rbw::api::UriMatchType::StartsWith))], ), make_entry( "two", @@ -3608,14 +3298,7 @@ mod test { "two" ); assert!( - one_match( - entries, - "https://two.com/login/sso", - None, - None, - 1, - false - ), + one_match(entries, "https://two.com/login/sso", None, None, 1, false), "two" ); assert!( @@ -3623,25 +3306,12 @@ mod test { "two" ); assert!( - no_matches( - entries, - "https://two.com/other-page", - None, - None, - false - ), + no_matches(entries, "https://two.com/other-page", None, None, false), "two" ); assert!( - one_match( - entries, - "https://login.three.com/", - None, - None, - 2, - false - ), + one_match(entries, "https://login.three.com/", None, None, 2, false), "three" ); assert!( @@ -3663,10 +3333,7 @@ mod test { "two", None, None, - &[( - "https://two.com/login", - Some(rbw::api::UriMatchType::Exact), - )], + &[("https://two.com/login", Some(rbw::api::UriMatchType::Exact))], ), make_entry( "three", @@ -3718,13 +3385,7 @@ mod test { "two" ); assert!( - no_matches( - entries, - "https://two.com/login/sso", - None, - None, - false - ), + no_matches(entries, "https://two.com/login/sso", None, None, false), "two" ); assert!( @@ -3732,25 +3393,12 @@ mod test { "two" ); assert!( - no_matches( - entries, - "https://two.com/other-page", - None, - None, - false - ), + no_matches(entries, "https://two.com/other-page", None, None, false), "two" ); assert!( - one_match( - entries, - "https://login.three.com/", - None, - None, - 2, - false - ), + one_match(entries, "https://login.three.com/", None, None, 2, false), "three" ); assert!( @@ -3832,14 +3480,7 @@ mod test { "two" ); assert!( - one_match( - entries, - "https://two.com/login/sso", - None, - None, - 1, - false - ), + one_match(entries, "https://two.com/login/sso", None, None, 1, false), "two" ); assert!( @@ -3847,25 +3488,12 @@ mod test { "two" ); assert!( - no_matches( - entries, - "https://two.com/other-page", - None, - None, - false - ), + no_matches(entries, "https://two.com/other-page", None, None, false), "two" ); assert!( - one_match( - entries, - "https://login.three.com/", - None, - None, - 2, - false - ), + one_match(entries, "https://login.three.com/", None, None, 2, false), "three" ); assert!( @@ -3891,10 +3519,7 @@ mod test { "two", None, None, - &[( - "https://two.com/login", - Some(rbw::api::UriMatchType::Never), - )], + &[("https://two.com/login", Some(rbw::api::UriMatchType::Never))], ), make_entry( "three", @@ -3953,24 +3578,12 @@ mod test { "two" ); assert!( - no_matches( - entries, - "https://two.com/other-page", - None, - None, - false - ), + no_matches(entries, "https://two.com/other-page", None, None, false), "two" ); assert!( - no_matches( - entries, - "https://login.three.com/", - None, - None, - false - ), + no_matches(entries, "https://login.three.com/", None, None, false), "three" ); assert!( @@ -4010,14 +3623,8 @@ mod test { None, None, &[ - ( - "https://one.com/", - Some(rbw::api::UriMatchType::Domain), - ), - ( - "https://two.com/", - Some(rbw::api::UriMatchType::Domain), - ), + ("https://one.com/", Some(rbw::api::UriMatchType::Domain)), + ("https://two.com/", Some(rbw::api::UriMatchType::Domain)), ], ), make_entry( @@ -4141,9 +3748,7 @@ mod test { folder_id: None, name: "this is the encrypted name".to_string(), data: rbw::db::EntryData::Login { - username: username.map(|_| { - "this is the encrypted username".to_string() - }), + username: username.map(|_| "this is the encrypted username".to_string()), password: None, uris: uris .iter() @@ -4168,9 +3773,7 @@ mod test { user: username.map(std::string::ToString::to_string), uris: uris .iter() - .map(|(uri, match_type)| { - ((*uri).to_string(), *match_type) - }) + .map(|(uri, match_type)| ((*uri).to_string(), *match_type)) .collect(), fields: vec![], notes: None, diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index ff2ec740..cd2fce97 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -131,11 +131,7 @@ enum Opt { name: String, #[arg(help = "Username for the password entry")] user: Option, - #[arg( - long, - help = "URI for the password entry", - number_of_values = 1 - )] + #[arg(long, help = "URI for the password entry", number_of_values = 1)] uri: Vec, #[arg(long, help = "Folder for the password entry")] folder: Option, @@ -161,11 +157,7 @@ enum Opt { name: Option, #[arg(help = "Username for the password entry")] user: Option, - #[arg( - long, - help = "URI for the password entry", - number_of_values = 1 - )] + #[arg(long, help = "URI for the password entry", number_of_values = 1)] uri: Vec, #[arg(long, help = "Folder for the password entry")] folder: Option, @@ -310,20 +302,16 @@ impl Config { fn main() { let opt = Opt::parse(); - env_logger::Builder::from_env( - env_logger::Env::default().default_filter_or("info"), - ) - .format(|buf, record| { - if let Some((terminal_size::Width(w), _)) = - terminal_size::terminal_size() - { - let out = format!("{}: {}", record.level(), record.args()); - writeln!(buf, "{}", textwrap::fill(&out, usize::from(w) - 1)) - } else { - writeln!(buf, "{}: {}", record.level(), record.args()) - } - }) - .init(); + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .format(|buf, record| { + if let Some((terminal_size::Width(w), _)) = terminal_size::terminal_size() { + let out = format!("{}: {}", record.level(), record.args()); + writeln!(buf, "{}", textwrap::fill(&out, usize::from(w) - 1)) + } else { + writeln!(buf, "{}: {}", record.level(), record.args()) + } + }) + .init(); let subcommand_name = opt.subcommand_name(); let res = match opt { diff --git a/src/bin/rbw/sock.rs b/src/bin/rbw/sock.rs index ab66e5dd..250edc1c 100644 --- a/src/bin/rbw/sock.rs +++ b/src/bin/rbw/sock.rs @@ -13,10 +13,7 @@ impl Sock { )?)) } - pub fn send( - &mut self, - msg: &rbw::protocol::Request, - ) -> anyhow::Result<()> { + pub fn send(&mut self, msg: &rbw::protocol::Request) -> anyhow::Result<()> { let Self(sock) = self; sock.write_all( serde_json::to_string(msg) @@ -35,7 +32,6 @@ impl Sock { let mut line = String::new(); buf.read_line(&mut line) .context("failed to read message from agent")?; - serde_json::from_str(&line) - .context("failed to parse message from agent") + serde_json::from_str(&line).context("failed to parse message from agent") } } diff --git a/src/cipherstring.rs b/src/cipherstring.rs index e42b676b..179b4431 100644 --- a/src/cipherstring.rs +++ b/src/cipherstring.rs @@ -1,8 +1,6 @@ use crate::prelude::*; -use aes::cipher::{ - BlockDecryptMut as _, BlockEncryptMut as _, KeyIvInit as _, -}; +use aes::cipher::{BlockDecryptMut as _, BlockEncryptMut as _, KeyIvInit as _}; use hmac::Mac as _; use pkcs8::DecodePrivateKey as _; use rand::RngCore as _; @@ -45,10 +43,7 @@ impl CipherString { let parts: Vec<&str> = contents.split('|').collect(); if parts.len() < 2 || parts.len() > 3 { return Err(Error::InvalidCipherString { - reason: format!( - "type 2 cipherstring with {} parts", - parts.len() - ), + reason: format!("type 2 cipherstring with {} parts", parts.len()), }); } @@ -56,14 +51,14 @@ impl CipherString { .map_err(|source| Error::InvalidBase64 { source })?; let ciphertext = crate::base64::decode(parts[1]) .map_err(|source| Error::InvalidBase64 { source })?; - let mac = - if parts.len() > 2 { - Some(crate::base64::decode(parts[2]).map_err( - |source| Error::InvalidBase64 { source }, - )?) - } else { - None - }; + let mac = if parts.len() > 2 { + Some( + crate::base64::decode(parts[2]) + .map_err(|source| Error::InvalidBase64 { source })?, + ) + } else { + None + }; Ok(Self::Symmetric { iv, @@ -85,30 +80,21 @@ impl CipherString { if ty < 6 { Err(Error::TooOldCipherStringType { ty: ty.to_string() }) } else { - Err(Error::UnimplementedCipherStringType { - ty: ty.to_string(), - }) + Err(Error::UnimplementedCipherStringType { ty: ty.to_string() }) } } } } - pub fn encrypt_symmetric( - keys: &crate::locked::Keys, - plaintext: &[u8], - ) -> Result { + pub fn encrypt_symmetric(keys: &crate::locked::Keys, plaintext: &[u8]) -> Result { let iv = random_iv(); - let cipher = cbc::Encryptor::::new( - keys.enc_key().into(), - iv.as_slice().into(), - ); - let ciphertext = - cipher.encrypt_padded_vec_mut::(plaintext); + let cipher = + cbc::Encryptor::::new(keys.enc_key().into(), iv.as_slice().into()); + let ciphertext = cipher.encrypt_padded_vec_mut::(plaintext); - let mut digest = - hmac::Hmac::::new_from_slice(keys.mac_key()) - .map_err(|source| Error::CreateHmac { source })?; + let mut digest = hmac::Hmac::::new_from_slice(keys.mac_key()) + .map_err(|source| Error::CreateHmac { source })?; digest.update(&iv); digest.update(&ciphertext); let mac = digest.finalize().into_bytes().as_slice().to_vec(); @@ -142,9 +128,7 @@ impl CipherString { .map_err(|source| Error::Decrypt { source }) } else { Err(Error::InvalidCipherString { - reason: - "found an asymmetric cipherstring, expecting symmetric" - .to_string(), + reason: "found an asymmetric cipherstring, expecting symmetric".to_string(), }) } } @@ -161,21 +145,14 @@ impl CipherString { { let mut res = crate::locked::Vec::new(); res.extend(ciphertext.iter().copied()); - let cipher = decrypt_common_symmetric( - keys, - iv, - ciphertext, - mac.as_deref(), - )?; + let cipher = decrypt_common_symmetric(keys, iv, ciphertext, mac.as_deref())?; cipher .decrypt_padded_mut::(res.data_mut()) .map_err(|source| Error::Decrypt { source })?; Ok(res) } else { Err(Error::InvalidCipherString { - reason: - "found an asymmetric cipherstring, expecting symmetric" - .to_string(), + reason: "found an asymmetric cipherstring, expecting symmetric".to_string(), }) } } @@ -186,8 +163,7 @@ impl CipherString { ) -> Result { if let Self::Asymmetric { ciphertext } = self { let privkey_data = private_key.private_key(); - let privkey_data = - pkcs7_unpad(privkey_data).ok_or(Error::Padding)?; + let privkey_data = pkcs7_unpad(privkey_data).ok_or(Error::Padding)?; let pkey = rsa::RsaPrivateKey::from_pkcs8_der(privkey_data) .map_err(|source| Error::RsaPkcs8 { source })?; let mut bytes = pkey @@ -204,9 +180,7 @@ impl CipherString { Ok(res) } else { Err(Error::InvalidCipherString { - reason: - "found a symmetric cipherstring, expecting asymmetric" - .to_string(), + reason: "found a symmetric cipherstring, expecting asymmetric".to_string(), }) } } @@ -219,9 +193,8 @@ fn decrypt_common_symmetric( mac: Option<&[u8]>, ) -> Result> { if let Some(mac) = mac { - let mut key = - hmac::Hmac::::new_from_slice(keys.mac_key()) - .map_err(|source| Error::CreateHmac { source })?; + let mut key = hmac::Hmac::::new_from_slice(keys.mac_key()) + .map_err(|source| Error::CreateHmac { source })?; key.update(iv); key.update(ciphertext); diff --git a/src/config.rs b/src/config.rs index 2dddf724..1ec45441 100644 --- a/src/config.rs +++ b/src/config.rs @@ -67,11 +67,9 @@ impl Config { pub fn load() -> Result { let file = crate::dirs::config_file(); - let mut fh = std::fs::File::open(&file).map_err(|source| { - Error::LoadConfig { - source, - file: file.clone(), - } + let mut fh = std::fs::File::open(&file).map_err(|source| Error::LoadConfig { + source, + file: file.clone(), })?; let mut json = String::new(); fh.read_to_string(&mut json) @@ -79,8 +77,8 @@ impl Config { source, file: file.clone(), })?; - let mut slf: Self = serde_json::from_str(&json) - .map_err(|source| Error::LoadConfigJson { source, file })?; + let mut slf: Self = + serde_json::from_str(&json).map_err(|source| Error::LoadConfigJson { source, file })?; if slf.lock_timeout == 0 { log::warn!("lock_timeout must be greater than 0"); slf.lock_timeout = default_lock_timeout(); @@ -91,21 +89,21 @@ impl Config { pub async fn load_async() -> Result { let file = crate::dirs::config_file(); let mut fh = - tokio::fs::File::open(&file).await.map_err(|source| { - Error::LoadConfigAsync { + tokio::fs::File::open(&file) + .await + .map_err(|source| Error::LoadConfigAsync { source, file: file.clone(), - } - })?; + })?; let mut json = String::new(); - fh.read_to_string(&mut json).await.map_err(|source| { - Error::LoadConfigAsync { + fh.read_to_string(&mut json) + .await + .map_err(|source| Error::LoadConfigAsync { source, file: file.clone(), - } - })?; - let mut slf: Self = serde_json::from_str(&json) - .map_err(|source| Error::LoadConfigJson { source, file })?; + })?; + let mut slf: Self = + serde_json::from_str(&json).map_err(|source| Error::LoadConfigJson { source, file })?; if slf.lock_timeout == 0 { log::warn!("lock_timeout must be greater than 0"); slf.lock_timeout = default_lock_timeout(); @@ -117,17 +115,13 @@ impl Config { let file = crate::dirs::config_file(); // unwrap is safe here because Self::filename is explicitly // constructed as a filename in a directory - std::fs::create_dir_all(file.parent().unwrap()).map_err( - |source| Error::SaveConfig { - source, - file: file.clone(), - }, - )?; - let mut fh = std::fs::File::create(&file).map_err(|source| { - Error::SaveConfig { - source, - file: file.clone(), - } + std::fs::create_dir_all(file.parent().unwrap()).map_err(|source| Error::SaveConfig { + source, + file: file.clone(), + })?; + let mut fh = std::fs::File::create(&file).map_err(|source| Error::SaveConfig { + source, + file: file.clone(), })?; fh.write_all( serde_json::to_string(self) @@ -238,18 +232,18 @@ pub async fn device_id(config: &Config) -> Result { || uuid::Uuid::new_v4().hyphenated().to_string(), String::to_string, ); - let mut fh = tokio::fs::File::create(&file).await.map_err(|e| { - Error::LoadDeviceId { + let mut fh = tokio::fs::File::create(&file) + .await + .map_err(|e| Error::LoadDeviceId { source: e, file: file.clone(), - } - })?; - fh.write_all(id.as_bytes()).await.map_err(|e| { - Error::LoadDeviceId { + })?; + fh.write_all(id.as_bytes()) + .await + .map_err(|e| Error::LoadDeviceId { source: e, file: file.clone(), - } - })?; + })?; Ok(id) } } diff --git a/src/db.rs b/src/db.rs index fec0af7c..398b4df5 100644 --- a/src/db.rs +++ b/src/db.rs @@ -4,9 +4,7 @@ use std::io::{Read as _, Write as _}; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; -#[derive( - serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, -)] +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)] pub struct Entry { pub id: String, pub org_id: Option, @@ -43,17 +41,11 @@ impl<'de> serde::Deserialize<'de> for Uri { impl<'de> serde::de::Visitor<'de> for StringOrUri { type Value = Uri; - fn expecting( - &self, - formatter: &mut std::fmt::Formatter, - ) -> std::fmt::Result { + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("uri") } - fn visit_str( - self, - value: &str, - ) -> std::result::Result + fn visit_str(self, value: &str) -> std::result::Result where E: serde::de::Error, { @@ -63,10 +55,7 @@ impl<'de> serde::Deserialize<'de> for Uri { }) } - fn visit_map( - self, - mut map: M, - ) -> std::result::Result + fn visit_map(self, mut map: M) -> std::result::Result where M: serde::de::MapAccess<'de>, { @@ -76,19 +65,13 @@ impl<'de> serde::Deserialize<'de> for Uri { match key { "uri" => { if uri.is_some() { - return Err( - serde::de::Error::duplicate_field("uri"), - ); + return Err(serde::de::Error::duplicate_field("uri")); } uri = Some(map.next_value()?); } "match_type" => { if match_type.is_some() { - return Err( - serde::de::Error::duplicate_field( - "match_type", - ), - ); + return Err(serde::de::Error::duplicate_field("match_type")); } match_type = map.next_value()?; } @@ -112,9 +95,7 @@ impl<'de> serde::Deserialize<'de> for Uri { } } -#[derive( - serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, -)] +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)] pub enum EntryData { Login { username: Option, @@ -157,9 +138,7 @@ pub enum EntryData { }, } -#[derive( - serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, -)] +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)] pub struct Field { pub ty: Option, pub name: Option, @@ -167,9 +146,7 @@ pub struct Field { pub linked_id: Option, } -#[derive( - serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq, -)] +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)] pub struct HistoryEntry { pub last_used_date: String, pub password: String, @@ -198,40 +175,38 @@ impl Db { pub fn load(server: &str, email: &str) -> Result { let file = crate::dirs::db_file(server, email); - let mut fh = - std::fs::File::open(&file).map_err(|source| Error::LoadDb { - source, - file: file.clone(), - })?; + let mut fh = std::fs::File::open(&file).map_err(|source| Error::LoadDb { + source, + file: file.clone(), + })?; let mut json = String::new(); fh.read_to_string(&mut json) .map_err(|source| Error::LoadDb { source, file: file.clone(), })?; - let slf: Self = serde_json::from_str(&json) - .map_err(|source| Error::LoadDbJson { source, file })?; + let slf: Self = + serde_json::from_str(&json).map_err(|source| Error::LoadDbJson { source, file })?; Ok(slf) } pub async fn load_async(server: &str, email: &str) -> Result { let file = crate::dirs::db_file(server, email); - let mut fh = - tokio::fs::File::open(&file).await.map_err(|source| { - Error::LoadDbAsync { - source, - file: file.clone(), - } + let mut fh = tokio::fs::File::open(&file) + .await + .map_err(|source| Error::LoadDbAsync { + source, + file: file.clone(), })?; let mut json = String::new(); - fh.read_to_string(&mut json).await.map_err(|source| { - Error::LoadDbAsync { + fh.read_to_string(&mut json) + .await + .map_err(|source| Error::LoadDbAsync { source, file: file.clone(), - } - })?; - let slf: Self = serde_json::from_str(&json) - .map_err(|source| Error::LoadDbJson { source, file })?; + })?; + let slf: Self = + serde_json::from_str(&json).map_err(|source| Error::LoadDbJson { source, file })?; Ok(slf) } @@ -240,17 +215,14 @@ impl Db { let file = crate::dirs::db_file(server, email); // unwrap is safe here because Self::filename is explicitly // constructed as a filename in a directory - std::fs::create_dir_all(file.parent().unwrap()).map_err( - |source| Error::SaveDb { - source, - file: file.clone(), - }, - )?; - let mut fh = - std::fs::File::create(&file).map_err(|source| Error::SaveDb { - source, - file: file.clone(), - })?; + std::fs::create_dir_all(file.parent().unwrap()).map_err(|source| Error::SaveDb { + source, + file: file.clone(), + })?; + let mut fh = std::fs::File::create(&file).map_err(|source| Error::SaveDb { + source, + file: file.clone(), + })?; fh.write_all( serde_json::to_string(self) .map_err(|source| Error::SaveDbJson { @@ -274,12 +246,11 @@ impl Db { source, file: file.clone(), })?; - let mut fh = - tokio::fs::File::create(&file).await.map_err(|source| { - Error::SaveDbAsync { - source, - file: file.clone(), - } + let mut fh = tokio::fs::File::create(&file) + .await + .map_err(|source| Error::SaveDbAsync { + source, + file: file.clone(), })?; fh.write_all( serde_json::to_string(self) diff --git a/src/dirs.rs b/src/dirs.rs index 079fc880..2c64757e 100644 --- a/src/dirs.rs +++ b/src/dirs.rs @@ -10,10 +10,7 @@ pub fn make_all() -> Result<()> { Ok(()) } -fn create_dir_all_with_permissions( - path: &std::path::Path, - mode: u32, -) -> Result<()> { +fn create_dir_all_with_permissions(path: &std::path::Path, mode: u32) -> Result<()> { // ensure the initial directory creation happens with the correct mode, // to avoid race conditions std::fs::DirBuilder::new() @@ -26,11 +23,12 @@ fn create_dir_all_with_permissions( })?; // but also make sure to forcibly set the mode, in case the directory // already existed - std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) - .map_err(|source| Error::CreateDirectory { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).map_err(|source| { + Error::CreateDirectory { source, file: path.to_path_buf(), - })?; + } + })?; Ok(()) } @@ -41,9 +39,7 @@ pub fn config_file() -> std::path::PathBuf { const INVALID_PATH: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS.add(b'/').add(b'%').add(b':'); pub fn db_file(server: &str, email: &str) -> std::path::PathBuf { - let server = - percent_encoding::percent_encode(server.as_bytes(), INVALID_PATH) - .to_string(); + let server = percent_encoding::percent_encode(server.as_bytes(), INVALID_PATH).to_string(); cache_dir().join(format!("{server}:{email}.json")) } @@ -72,26 +68,22 @@ pub fn ssh_agent_socket_file() -> std::path::PathBuf { } fn config_dir() -> std::path::PathBuf { - let project_dirs = - directories::ProjectDirs::from("", "", &profile()).unwrap(); + let project_dirs = directories::ProjectDirs::from("", "", &profile()).unwrap(); project_dirs.config_dir().to_path_buf() } fn cache_dir() -> std::path::PathBuf { - let project_dirs = - directories::ProjectDirs::from("", "", &profile()).unwrap(); + let project_dirs = directories::ProjectDirs::from("", "", &profile()).unwrap(); project_dirs.cache_dir().to_path_buf() } fn data_dir() -> std::path::PathBuf { - let project_dirs = - directories::ProjectDirs::from("", "", &profile()).unwrap(); + let project_dirs = directories::ProjectDirs::from("", "", &profile()).unwrap(); project_dirs.data_dir().to_path_buf() } fn runtime_dir() -> std::path::PathBuf { - let project_dirs = - directories::ProjectDirs::from("", "", &profile()).unwrap(); + let project_dirs = directories::ProjectDirs::from("", "", &profile()).unwrap(); project_dirs.runtime_dir().map_or_else( || { format!( diff --git a/src/edit.rs b/src/edit.rs index f18084ba..d78c14b4 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -26,11 +26,7 @@ pub fn edit(contents: &str, help: &str) -> Result { let (cmd, args) = if contains_shell_metacharacters(&editor) { let mut cmdline = std::ffi::OsString::new(); - cmdline.extend([ - editor.as_ref(), - std::ffi::OsStr::new(" "), - file.as_os_str(), - ]); + cmdline.extend([editor.as_ref(), std::ffi::OsStr::new(" "), file.as_os_str()]); let editor_args = vec![std::ffi::OsString::from("-c"), cmdline]; (std::path::Path::new("/bin/sh"), editor_args) diff --git a/src/identity.rs b/src/identity.rs index 96b2eecc..a26596c2 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -19,8 +19,8 @@ impl Identity { ) -> Result { let email = email.trim().to_lowercase(); - let iterations = std::num::NonZeroU32::new(iterations) - .ok_or(Error::Pbkdf2ZeroIterations)?; + let iterations = + std::num::NonZeroU32::new(iterations).ok_or(Error::Pbkdf2ZeroIterations)?; let mut keys = crate::locked::Vec::new(); keys.extend(std::iter::repeat_n(0, 64)); @@ -74,8 +74,7 @@ impl Identity { ) .map_err(|_| Error::Pbkdf2)?; - let hkdf = hkdf::Hkdf::::from_prk(enc_key) - .map_err(|_| Error::HkdfExpand)?; + let hkdf = hkdf::Hkdf::::from_prk(enc_key).map_err(|_| Error::HkdfExpand)?; hkdf.expand(b"enc", enc_key) .map_err(|_| Error::HkdfExpand)?; let mac_key = &mut keys.data_mut()[32..64]; diff --git a/src/json.rs b/src/json.rs index 500205c6..98d3f203 100644 --- a/src/json.rs +++ b/src/json.rs @@ -7,38 +7,30 @@ pub trait DeserializeJsonWithPath { impl DeserializeJsonWithPath for String { fn json_with_path(self) -> Result { let jd = &mut serde_json::Deserializer::from_str(&self); - serde_path_to_error::deserialize(jd) - .map_err(|source| Error::Json { source }) + serde_path_to_error::deserialize(jd).map_err(|source| Error::Json { source }) } } impl DeserializeJsonWithPath for reqwest::blocking::Response { fn json_with_path(self) -> Result { - let bytes = - self.bytes().map_err(|source| Error::Reqwest { source })?; + let bytes = self.bytes().map_err(|source| Error::Reqwest { source })?; let jd = &mut serde_json::Deserializer::from_slice(&bytes); - serde_path_to_error::deserialize(jd) - .map_err(|source| Error::Json { source }) + serde_path_to_error::deserialize(jd).map_err(|source| Error::Json { source }) } } pub trait DeserializeJsonWithPathAsync { #[allow(async_fn_in_trait)] - async fn json_with_path( - self, - ) -> Result; + async fn json_with_path(self) -> Result; } impl DeserializeJsonWithPathAsync for reqwest::Response { - async fn json_with_path( - self, - ) -> Result { + async fn json_with_path(self) -> Result { let bytes = self .bytes() .await .map_err(|source| Error::Reqwest { source })?; let jd = &mut serde_json::Deserializer::from_slice(&bytes); - serde_path_to_error::deserialize(jd) - .map_err(|source| Error::Json { source }) + serde_path_to_error::deserialize(jd).map_err(|source| Error::Json { source }) } } diff --git a/src/locked.rs b/src/locked.rs index ce031510..3dd9bf8e 100644 --- a/src/locked.rs +++ b/src/locked.rs @@ -2,8 +2,7 @@ use zeroize::Zeroize as _; const LEN: usize = 4096; -static REGION_LOCK_WORKS: std::sync::OnceLock = - std::sync::OnceLock::new(); +static REGION_LOCK_WORKS: std::sync::OnceLock = std::sync::OnceLock::new(); pub struct Vec { data: Box>, @@ -14,9 +13,7 @@ impl Default for Vec { fn default() -> Self { let data = Box::new(arrayvec::ArrayVec::<_, LEN>::new()); let lock = match REGION_LOCK_WORKS.get() { - Some(true) => { - Some(region::lock(data.as_ptr(), data.capacity()).unwrap()) - } + Some(true) => Some(region::lock(data.as_ptr(), data.capacity()).unwrap()), Some(false) => None, None => match region::lock(data.as_ptr(), data.capacity()) { Ok(lock) => { diff --git a/src/pinentry.rs b/src/pinentry.rs index ab2a0230..2a6a8242 100644 --- a/src/pinentry.rs +++ b/src/pinentry.rs @@ -20,9 +20,7 @@ fn spawn_pinentry( let env_vars = environment.env_vars(); // Not all pinentry appear to respect the --display flag, so we also keep the environment // variable. - if let Some(display) = - env_vars.get(std::ffi::OsString::from("DISPLAY").as_os_str()) - { + if let Some(display) = env_vars.get(std::ffi::OsString::from("DISPLAY").as_os_str()) { args.extend(["--display".into(), display.clone()]); } if !grab { @@ -91,12 +89,7 @@ pub async fn getpin( buf.zero(); // unwrap is safe because we specified stdout as piped in the command opts // above - let len = read_password( - ncommands, - buf.data_mut(), - child.stdout.as_mut().unwrap(), - ) - .await?; + let len = read_password(ncommands, buf.data_mut(), child.stdout.as_mut().unwrap()).await?; buf.truncate(len); child @@ -135,8 +128,7 @@ pub async fn confirm( drop(stdin); let mut buf = [0u8; 64]; - read_password(ncommands, &mut buf, child.stdout.as_mut().unwrap()) - .await?; + read_password(ncommands, &mut buf, child.stdout.as_mut().unwrap()).await?; child .wait() @@ -146,11 +138,7 @@ pub async fn confirm( Ok(true) } -async fn read_password( - mut ncommands: u8, - data: &mut [u8], - mut r: R, -) -> Result +async fn read_password(mut ncommands: u8, data: &mut [u8], mut r: R) -> Result where R: tokio::io::AsyncRead + tokio::io::AsyncReadExt + Unpin + Send, { @@ -245,8 +233,7 @@ fn percent_decode(buf: &mut [u8]) -> usize { if let Some(l) = char::from(buf[read_idx + 2]).to_digit(16) { // h and l were parsed from a single hex digit, so they // must be in the range 0-15, so these unwraps are safe - c = u8::try_from(h).unwrap() * 0x10 - + u8::try_from(l).unwrap(); + c = u8::try_from(h).unwrap() * 0x10 + u8::try_from(l).unwrap(); read_idx += 2; } } diff --git a/src/protocol.rs b/src/protocol.rs index ec0c06eb..e3b72c0d 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -73,14 +73,13 @@ pub const ENVIRONMENT_VARIABLES: &[&str] = &[ "PINENTRY_GEOM_HINT", ]; -pub static ENVIRONMENT_VARIABLES_OS: std::sync::LazyLock< - Vec, -> = std::sync::LazyLock::new(|| { - ENVIRONMENT_VARIABLES - .iter() - .map(std::ffi::OsString::from) - .collect() -}); +pub static ENVIRONMENT_VARIABLES_OS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| { + ENVIRONMENT_VARIABLES + .iter() + .map(std::ffi::OsString::from) + .collect() + }); #[derive(Hash, PartialEq, Eq, Debug, Clone)] struct SerializableOsString(std::ffi::OsString); @@ -104,10 +103,7 @@ impl<'de> serde::Deserialize<'de> for SerializableOsString { impl serde::de::Visitor<'_> for Visitor { type Value = SerializableOsString; - fn expecting( - &self, - formatter: &mut std::fmt::Formatter, - ) -> std::fmt::Result { + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("base64 encoded os string") } @@ -116,9 +112,8 @@ impl<'de> serde::Deserialize<'de> for SerializableOsString { E: serde::de::Error, { Ok(SerializableOsString(std::ffi::OsString::from_vec( - crate::base64::decode(s).map_err(|_| { - E::invalid_value(serde::de::Unexpected::Str(s), &self) - })?, + crate::base64::decode(s) + .map_err(|_| E::invalid_value(serde::de::Unexpected::Str(s), &self))?, ))) } } @@ -142,9 +137,7 @@ impl Environment { tty: tty.map(SerializableOsString), env_vars: env_vars .into_iter() - .map(|(k, v)| { - (SerializableOsString(k), SerializableOsString(v)) - }) + .map(|(k, v)| (SerializableOsString(k), SerializableOsString(v))) .collect(), } } @@ -153,10 +146,7 @@ impl Environment { self.tty.as_ref().map(|tty| tty.0.as_os_str()) } - pub fn env_vars( - &self, - ) -> std::collections::HashMap - { + pub fn env_vars(&self) -> std::collections::HashMap { self.env_vars .iter() .map(|(var, val)| (var.0.clone(), val.0.clone())) diff --git a/src/pwgen.rs b/src/pwgen.rs index b70fbdd9..9c00d088 100644 --- a/src/pwgen.rs +++ b/src/pwgen.rs @@ -2,8 +2,7 @@ use rand::seq::IteratorRandom as _; const SYMBOLS: &[u8] = b"!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"; const NUMBERS: &[u8] = b"0123456789"; -const LETTERS: &[u8] = - b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const LETTERS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; const NONCONFUSABLES: &[u8] = b"34678abcdefhjkmnpqrtuwxy"; #[derive(Debug, Eq, PartialEq, Copy, Clone)] @@ -48,10 +47,7 @@ pub fn pwgen(ty: Type, len: usize) -> String { }; let mut pass = vec![]; - pass.extend( - std::iter::repeat_with(|| alphabet.iter().choose(&mut rng).unwrap()) - .take(len), - ); + pass.extend(std::iter::repeat_with(|| alphabet.iter().choose(&mut rng).unwrap()).take(len)); // unwrap is safe because the method of generating passwords guarantees // valid utf8 String::from_utf8(pass).unwrap() From abd93a3458ea98765e0aace0fcd64c4ed9f84eab Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 7 May 2026 23:09:15 +0200 Subject: [PATCH 008/273] simplify --- src/api.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api.rs b/src/api.rs index 828267cb..505fecd4 100644 --- a/src/api.rs +++ b/src/api.rs @@ -351,7 +351,7 @@ impl TryFrom for Error { // this case, for some reason if error_desc.is_none() || error_desc == Some("") { if let Some(error_model) = value.error_model.as_ref() { - let message = error_model.message.as_str().to_string(); + let message = error_model.message.clone(); match message.as_str() { "Username or password is incorrect. Try again" | "TOTP code is not a number" => { From 80ed78be4de9e4fa2e82e7d9fe284aca0732c973 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 7 May 2026 23:16:33 +0200 Subject: [PATCH 009/273] remove PreloginReq struct in favor of serde_json::json! --- src/api.rs | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/api.rs b/src/api.rs index 505fecd4..c1d47cfe 100644 --- a/src/api.rs +++ b/src/api.rs @@ -232,11 +232,6 @@ pub enum CipherRepromptType { Password = 1, } -#[derive(serde::Serialize, Debug)] -struct PreloginReq { - email: String, -} - #[derive(serde::Deserialize, Debug)] struct PreloginRes { #[serde(rename = "Kdf", alias = "kdf")] @@ -796,7 +791,7 @@ const BITWARDEN_CLIENT: &str = "cli"; const DEVICE_TYPE: u8 = 8; enum ClientRequest<'a> { - Prelogin(PreloginReq), + Prelogin(&'a str), ConnectToken(ConnectTokenReq), Login(ConnectTokenReq, &'a str), SendEmailLogin(SendEmailLoginReq, &'a str), @@ -809,9 +804,9 @@ impl<'a> ClientRequest<'a> { let http_client = client.reqwest_client().await?; let rb = match self { - Self::Prelogin(r) => http_client + Self::Prelogin(email) => http_client .post(client.identity_url("/accounts/prelogin")) - .json(&r), + .json(&serde_json::json!({"email": email})), Self::ConnectToken(r) => http_client .post(client.identity_url("/connect/token")) .form(&r), @@ -955,13 +950,11 @@ impl Client { } pub async fn prelogin(&self, email: &str) -> Result<(KdfType, u32, Option, Option)> { - let res: PreloginRes = ClientRequest::Prelogin(PreloginReq { - email: email.to_string(), - }) - .req(self) - .await? - .json_with_path() - .await?; + let res: PreloginRes = ClientRequest::Prelogin(email) + .req(self) + .await? + .json_with_path() + .await?; Ok(( res.kdf, From 37130b5a8b30e5216faa0cbaef736818078abe79 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 7 May 2026 23:42:42 +0200 Subject: [PATCH 010/273] "use" some famous imports --- src/api.rs | 109 +++++++++++++++++++++++++++++------------------------ 1 file changed, 59 insertions(+), 50 deletions(-) diff --git a/src/api.rs b/src/api.rs index c1d47cfe..b052b7a7 100644 --- a/src/api.rs +++ b/src/api.rs @@ -2,10 +2,19 @@ // here, unfortunately #![allow(clippy::as_conversions)] +use std::{ + fmt::Display, + path::{Path, PathBuf}, + str::FromStr, + sync::Arc, +}; + use crate::prelude::*; use rand::distr::SampleString as _; +use serde::{Deserialize, Serialize}; use sha2::Digest as _; +use tokio::sync::mpsc; use crate::json::{DeserializeJsonWithPath as _, DeserializeJsonWithPathAsync as _}; @@ -22,7 +31,7 @@ pub enum UriMatchType { Never = 5, } -impl std::fmt::Display for UriMatchType { +impl Display for UriMatchType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { #[allow(clippy::enum_glob_use)] use UriMatchType::*; @@ -76,7 +85,7 @@ impl TwoFactorProviderType { } } -impl<'de> serde::Deserialize<'de> for TwoFactorProviderType { +impl<'de> Deserialize<'de> for TwoFactorProviderType { fn deserialize(deserializer: D) -> std::result::Result where D: serde::Deserializer<'de>, @@ -108,7 +117,7 @@ impl<'de> serde::Deserialize<'de> for TwoFactorProviderType { } } -impl std::convert::TryFrom for TwoFactorProviderType { +impl TryFrom for TwoFactorProviderType { type Error = Error; fn try_from(ty: u64) -> Result { @@ -128,7 +137,7 @@ impl std::convert::TryFrom for TwoFactorProviderType { } } -impl std::str::FromStr for TwoFactorProviderType { +impl FromStr for TwoFactorProviderType { type Err = Error; fn from_str(ty: &str) -> Result { @@ -152,7 +161,7 @@ pub enum KdfType { Argon2id = 1, } -impl<'de> serde::Deserialize<'de> for KdfType { +impl<'de> Deserialize<'de> for KdfType { fn deserialize(deserializer: D) -> std::result::Result where D: serde::Deserializer<'de>, @@ -184,7 +193,7 @@ impl<'de> serde::Deserialize<'de> for KdfType { } } -impl std::convert::TryFrom for KdfType { +impl TryFrom for KdfType { type Error = Error; fn try_from(ty: u64) -> Result { @@ -198,7 +207,7 @@ impl std::convert::TryFrom for KdfType { } } -impl std::str::FromStr for KdfType { +impl FromStr for KdfType { type Err = Error; fn from_str(ty: &str) -> Result { @@ -210,7 +219,7 @@ impl std::str::FromStr for KdfType { } } -impl serde::Serialize for KdfType { +impl Serialize for KdfType { fn serialize(&self, serializer: S) -> std::result::Result where S: serde::Serializer, @@ -232,7 +241,7 @@ pub enum CipherRepromptType { Password = 1, } -#[derive(serde::Deserialize, Debug)] +#[derive(Deserialize, Debug)] struct PreloginRes { #[serde(rename = "Kdf", alias = "kdf")] kdf: KdfType, @@ -244,7 +253,7 @@ struct PreloginRes { kdf_parallelism: Option, } -#[derive(serde::Serialize, Debug)] +#[derive(Serialize, Debug)] struct ConnectTokenReq { grant_type: String, scope: String, @@ -265,7 +274,7 @@ struct ConnectTokenReq { auth: ConnectTokenAuth, } -#[derive(serde::Serialize, Debug)] +#[derive(Serialize, Debug)] #[serde(untagged)] enum ConnectTokenAuth { Password(ConnectTokenPassword), @@ -273,26 +282,26 @@ enum ConnectTokenAuth { ClientCredentials(ConnectTokenClientCredentials), } -#[derive(serde::Serialize, Debug)] +#[derive(Serialize, Debug)] struct ConnectTokenPassword { username: String, password: String, } -#[derive(serde::Serialize, Debug)] +#[derive(Serialize, Debug)] struct ConnectTokenAuthCode { code: String, code_verifier: String, redirect_uri: String, } -#[derive(serde::Serialize, Debug)] +#[derive(Serialize, Debug)] struct ConnectTokenClientCredentials { username: String, client_secret: String, } -#[derive(serde::Deserialize, Debug)] +#[derive(Deserialize, Debug)] struct ConnectTokenRes { access_token: String, refresh_token: String, @@ -300,7 +309,7 @@ struct ConnectTokenRes { key: String, } -#[derive(serde::Deserialize, Debug)] +#[derive(Deserialize, Debug)] struct ConnectErrorRes { error: String, error_description: Option, @@ -368,18 +377,18 @@ impl TryFrom for Error { } } -#[derive(serde::Deserialize, Debug)] +#[derive(Deserialize, Debug)] struct ConnectErrorResErrorModel { #[serde(rename = "Message", alias = "message")] message: String, } -#[derive(serde::Deserialize, Debug)] +#[derive(Deserialize, Debug)] struct ConnectRefreshTokenRes { access_token: String, } -#[derive(serde::Serialize, Debug)] +#[derive(Serialize, Debug)] struct SendEmailLoginReq { email: String, #[serde(rename = "DeviceIdentifier", alias = "deviceIdentifier")] @@ -388,7 +397,7 @@ struct SendEmailLoginReq { sso_email_2fa_session_token: String, } -#[derive(serde::Deserialize, Debug)] +#[derive(Deserialize, Debug)] struct SyncRes { #[serde(rename = "Ciphers", alias = "ciphers")] ciphers: Vec, @@ -398,7 +407,7 @@ struct SyncRes { folders: Vec, } -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] struct SyncResCipher { #[serde(rename = "Id", alias = "id")] id: String, @@ -546,7 +555,7 @@ impl SyncResCipher { } } -#[derive(serde::Deserialize, Debug)] +#[derive(Deserialize, Debug)] struct SyncResProfile { #[serde(rename = "Key", alias = "key")] key: String, @@ -556,7 +565,7 @@ struct SyncResProfile { organizations: Vec, } -#[derive(serde::Deserialize, Debug)] +#[derive(Deserialize, Debug)] struct SyncResProfileOrganization { #[serde(rename = "Id", alias = "id")] id: String, @@ -564,7 +573,7 @@ struct SyncResProfileOrganization { key: String, } -#[derive(serde::Deserialize, Debug, Clone)] +#[derive(Deserialize, Debug, Clone)] struct SyncResFolder { #[serde(rename = "Id", alias = "id")] id: String, @@ -572,7 +581,7 @@ struct SyncResFolder { name: String, } -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] struct CipherLogin { #[serde(rename = "Username", alias = "username")] username: Option, @@ -584,7 +593,7 @@ struct CipherLogin { uris: Option>, } -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] struct CipherLoginUri { #[serde(rename = "Uri", alias = "uri")] uri: Option, @@ -592,7 +601,7 @@ struct CipherLoginUri { match_type: Option, } -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] struct CipherCard { #[serde(rename = "CardholderName", alias = "cardholderName")] cardholder_name: Option, @@ -608,7 +617,7 @@ struct CipherCard { code: Option, } -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] struct CipherIdentity { #[serde(rename = "Title", alias = "title")] title: Option, @@ -646,7 +655,7 @@ struct CipherIdentity { username: Option, } -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] struct CipherSshKey { #[serde(rename = "PrivateKey", alias = "privateKey")] private_key: Option, @@ -701,7 +710,7 @@ pub enum LinkedIdType { IdentityFullName = 418, } -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] struct CipherField { #[serde(rename = "Type", alias = "type")] ty: Option, @@ -715,10 +724,10 @@ struct CipherField { // this is just a name and some notes, both of which are already on the cipher // object -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] struct CipherSecureNote {} -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] struct SyncResPasswordHistory { #[serde(rename = "LastUsedDate", alias = "lastUsedDate")] last_used_date: String, @@ -726,7 +735,7 @@ struct SyncResPasswordHistory { password: Option, } -#[derive(serde::Serialize, Debug)] +#[derive(Serialize, Debug)] struct CiphersPostReq { #[serde(rename = "type")] ty: u32, // XXX what are the valid types? @@ -741,7 +750,7 @@ struct CiphersPostReq { secure_note: Option, } -#[derive(serde::Serialize, Debug)] +#[derive(Serialize, Debug)] struct CiphersPutReq { #[serde(rename = "type")] ty: u32, // XXX what are the valid types? @@ -761,7 +770,7 @@ struct CiphersPutReq { password_history: Vec, } -#[derive(serde::Serialize, Debug)] +#[derive(Serialize, Debug)] struct CiphersPutReqHistory { #[serde(rename = "LastUsedDate")] last_used_date: String, @@ -769,13 +778,13 @@ struct CiphersPutReqHistory { password: String, } -#[derive(serde::Deserialize, Debug)] +#[derive(Deserialize, Debug)] struct FoldersRes { #[serde(rename = "Data", alias = "data")] data: Vec, } -#[derive(serde::Deserialize, Debug)] +#[derive(Deserialize, Debug)] struct FoldersResData { #[serde(rename = "Id", alias = "id")] id: String, @@ -889,7 +898,7 @@ pub struct Client { base_url: String, identity_url: String, ui_url: String, - client_cert_path: Option, + client_cert_path: Option, } impl Client { @@ -897,13 +906,13 @@ impl Client { base_url: &str, identity_url: &str, ui_url: &str, - client_cert_path: Option<&std::path::Path>, + client_cert_path: Option<&Path>, ) -> Self { Self { base_url: base_url.to_string(), identity_url: identity_url.to_string(), ui_url: ui_url.to_string(), - client_cert_path: client_cert_path.map(std::path::Path::to_path_buf), + client_cert_path: client_cert_path.map(Path::to_path_buf), } } @@ -1540,19 +1549,19 @@ async fn find_free_port(bottom: u16, top: u16) -> Result { #[derive(Clone)] struct SSOHandlerState { state: String, - sender: tokio::sync::mpsc::Sender>, + sender: mpsc::Sender>, } async fn start_sso_callback_server( listener: tokio::net::TcpListener, state: &str, ) -> Result { - let (shut_sender, shut_receiver) = tokio::sync::mpsc::channel(1); - let (sender, mut receiver) = tokio::sync::mpsc::channel(1); + let (shut_tx, shut_rx) = mpsc::channel(1); + let (tx, mut rx) = mpsc::channel(1); - let sso_handler_state = std::sync::Arc::new(SSOHandlerState { + let sso_handler_state = Arc::new(SSOHandlerState { state: state.to_string(), - sender: shut_sender, + sender: shut_tx, }); let app = axum::Router::new() @@ -1560,22 +1569,22 @@ async fn start_sso_callback_server( .with_state(sso_handler_state); axum::serve(listener, app) - .with_graceful_shutdown(sso_server_graceful_shutdown(sender, shut_receiver)) + .with_graceful_shutdown(sso_server_graceful_shutdown(tx, shut_rx)) .await .map_err(|e| Error::FailedToProcessSSOCallback { msg: e.to_string() })?; - receiver.recv().await.unwrap() + rx.recv().await.unwrap() } async fn sso_server_graceful_shutdown( - sender: tokio::sync::mpsc::Sender>, - mut receiver: tokio::sync::mpsc::Receiver>, + sender: mpsc::Sender>, + mut receiver: mpsc::Receiver>, ) { sender.send(receiver.recv().await.unwrap()).await.unwrap(); } async fn handle_sso_callback( - axum::extract::State(state): axum::extract::State>, + axum::extract::State(state): axum::extract::State>, axum::extract::Query(params): axum::extract::Query>, ) -> axum::http::Response { match sso_query_code(¶ms, state.state.as_str()) { From 49abee6e6dac4b0c9d1a73db9aa4bce7b9cf3014 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 7 May 2026 23:50:04 +0200 Subject: [PATCH 011/273] remove useless connect structs --- src/api.rs | 47 +++++++++++++++++++---------------------------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/src/api.rs b/src/api.rs index b052b7a7..ecb8c12e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -277,28 +277,19 @@ struct ConnectTokenReq { #[derive(Serialize, Debug)] #[serde(untagged)] enum ConnectTokenAuth { - Password(ConnectTokenPassword), - AuthCode(ConnectTokenAuthCode), - ClientCredentials(ConnectTokenClientCredentials), -} - -#[derive(Serialize, Debug)] -struct ConnectTokenPassword { - username: String, - password: String, -} - -#[derive(Serialize, Debug)] -struct ConnectTokenAuthCode { - code: String, - code_verifier: String, - redirect_uri: String, -} - -#[derive(Serialize, Debug)] -struct ConnectTokenClientCredentials { - username: String, - client_secret: String, + Password { + username: String, + password: String, + }, + AuthCode { + code: String, + code_verifier: String, + redirect_uri: String, + }, + ClientCredentials { + username: String, + client_secret: String, + }, } #[derive(Deserialize, Debug)] @@ -980,10 +971,10 @@ impl Client { apikey: &crate::locked::ApiKey, ) -> Result<()> { let connect_req = ConnectTokenReq { - auth: ConnectTokenAuth::ClientCredentials(ConnectTokenClientCredentials { + auth: ConnectTokenAuth::ClientCredentials { username: email.to_string(), client_secret: String::from_utf8(apikey.client_secret().to_vec()).unwrap(), - }), + }, grant_type: "client_credentials".to_string(), scope: "api".to_string(), // XXX unwraps here are not necessarily safe @@ -1034,11 +1025,11 @@ impl Client { self.obtain_sso_code(sso_id).await?; ConnectTokenReq { - auth: ConnectTokenAuth::AuthCode(ConnectTokenAuthCode { + auth: ConnectTokenAuth::AuthCode { code: sso_code, code_verifier: sso_code_verifier, redirect_uri: callback_url, - }), + }, grant_type: "authorization_code".to_string(), scope: "api offline_access".to_string(), client_id: "cli".to_string(), @@ -1051,10 +1042,10 @@ impl Client { } } None => ConnectTokenReq { - auth: ConnectTokenAuth::Password(ConnectTokenPassword { + auth: ConnectTokenAuth::Password { username: email.to_string(), password: crate::base64::encode(password_hash.hash()), - }), + }, grant_type: "password".to_string(), scope: "api offline_access".to_string(), From 70941c4dcd0daf35ffd6885ea647825c6bf37f9d Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 7 May 2026 23:53:23 +0200 Subject: [PATCH 012/273] use DEVICE_TYPE instead of 8 --- src/api.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api.rs b/src/api.rs index ecb8c12e..69b7ac34 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1050,7 +1050,7 @@ impl Client { grant_type: "password".to_string(), scope: "api offline_access".to_string(), client_id: "cli".to_string(), - device_type: 8, + device_type: u32::from(DEVICE_TYPE), device_identifier: device_id.to_string(), device_name: "rbw".to_string(), device_push_token: String::new(), From c1b1ca0b8d174f6d55b3e7713aa5cac3d4fefe42 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 7 May 2026 23:54:10 +0200 Subject: [PATCH 013/273] borrow --- src/api.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api.rs b/src/api.rs index 69b7ac34..2f8a260a 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1120,7 +1120,7 @@ impl Client { let sso_code_verifier = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 64); let mut hasher = sha2::Sha256::new(); - hasher.update(sso_code_verifier.clone()); + hasher.update(&sso_code_verifier); let code_challenge = crate::base64::encode_url_safe_no_pad(hasher.finalize()); let port = find_free_port(8065, 8070).await?; From 8c232fef2bf50453e08a269a11f6275619e71550 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 8 May 2026 00:10:03 +0200 Subject: [PATCH 014/273] shrink into async block --- src/api.rs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/api.rs b/src/api.rs index 2f8a260a..84ae2b14 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1547,7 +1547,7 @@ async fn start_sso_callback_server( listener: tokio::net::TcpListener, state: &str, ) -> Result { - let (shut_tx, shut_rx) = mpsc::channel(1); + let (shut_tx, mut shut_rx) = mpsc::channel(1); let (tx, mut rx) = mpsc::channel(1); let sso_handler_state = Arc::new(SSOHandlerState { @@ -1560,20 +1560,15 @@ async fn start_sso_callback_server( .with_state(sso_handler_state); axum::serve(listener, app) - .with_graceful_shutdown(sso_server_graceful_shutdown(tx, shut_rx)) + .with_graceful_shutdown( + async move { tx.send(shut_rx.recv().await.unwrap()).await.unwrap() }, + ) .await .map_err(|e| Error::FailedToProcessSSOCallback { msg: e.to_string() })?; rx.recv().await.unwrap() } -async fn sso_server_graceful_shutdown( - sender: mpsc::Sender>, - mut receiver: mpsc::Receiver>, -) { - sender.send(receiver.recv().await.unwrap()).await.unwrap(); -} - async fn handle_sso_callback( axum::extract::State(state): axum::extract::State>, axum::extract::Query(params): axum::extract::Query>, From 4c87c403ab92e6488bf79a3024b6825ec1129601 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 8 May 2026 00:16:49 +0200 Subject: [PATCH 015/273] shrink edit --- src/actions.rs | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index 4ac3abd8..1fdc527d 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -178,7 +178,7 @@ pub fn edit( history: &[crate::db::HistoryEntry], ) -> Result<(Option, ())> { with_exchange_refresh_token(access_token, refresh_token, |access_token| { - edit_once( + api_client()?.0.edit( access_token, id, org_id, @@ -192,32 +192,6 @@ pub fn edit( }) } -fn edit_once( - access_token: &str, - id: &str, - org_id: Option<&str>, - name: &str, - data: &crate::db::EntryData, - fields: &[crate::db::Field], - notes: Option<&str>, - folder_uuid: Option<&str>, - history: &[crate::db::HistoryEntry], -) -> Result<()> { - let (client, _) = api_client()?; - client.edit( - access_token, - id, - org_id, - name, - data, - fields, - notes, - folder_uuid, - history, - )?; - Ok(()) -} - pub fn remove(access_token: &str, refresh_token: &str, id: &str) -> Result<(Option, ())> { with_exchange_refresh_token(access_token, refresh_token, |access_token| { remove_once(access_token, id) From 6429a09fb430595a72b16100af7af70fc5095f08 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 8 May 2026 01:29:57 +0200 Subject: [PATCH 016/273] remove dup code of Cipher requests --- src/api.rs | 325 ++++++++++++++++++++++------------------------------- 1 file changed, 133 insertions(+), 192 deletions(-) diff --git a/src/api.rs b/src/api.rs index 84ae2b14..1305cd09 100644 --- a/src/api.rs +++ b/src/api.rs @@ -769,6 +769,121 @@ struct CiphersPutReqHistory { password: String, } +struct CipherDataFields { + ty: u32, + login: Option, + card: Option, + identity: Option, + secure_note: Option, +} + +impl From<&crate::db::EntryData> for CipherDataFields { + fn from(data: &crate::db::EntryData) -> Self { + match data { + crate::db::EntryData::Login { + username, + password, + totp, + uris, + } => Self { + ty: 1, + login: Some(CipherLogin { + username: username.clone(), + password: password.clone(), + totp: totp.clone(), + uris: if uris.is_empty() { + None + } else { + Some( + uris.iter() + .map(|s| CipherLoginUri { + uri: Some(s.uri.clone()), + match_type: s.match_type, + }) + .collect(), + ) + }, + }), + card: None, + identity: None, + secure_note: None, + }, + crate::db::EntryData::Card { + cardholder_name, + number, + brand, + exp_month, + exp_year, + code, + } => Self { + ty: 3, + login: None, + card: Some(CipherCard { + cardholder_name: cardholder_name.clone(), + number: number.clone(), + brand: brand.clone(), + exp_month: exp_month.clone(), + exp_year: exp_year.clone(), + code: code.clone(), + }), + identity: None, + secure_note: None, + }, + crate::db::EntryData::Identity { + title, + first_name, + middle_name, + last_name, + address1, + address2, + address3, + city, + state, + postal_code, + country, + phone, + email, + ssn, + license_number, + passport_number, + username, + } => Self { + ty: 4, + login: None, + card: None, + identity: Some(CipherIdentity { + title: title.clone(), + first_name: first_name.clone(), + middle_name: middle_name.clone(), + last_name: last_name.clone(), + address1: address1.clone(), + address2: address2.clone(), + address3: address3.clone(), + city: city.clone(), + state: state.clone(), + postal_code: postal_code.clone(), + country: country.clone(), + phone: phone.clone(), + email: email.clone(), + ssn: ssn.clone(), + license_number: license_number.clone(), + passport_number: passport_number.clone(), + username: username.clone(), + }), + secure_note: None, + }, + crate::db::EntryData::SecureNote => Self { + ty: 2, + login: None, + card: None, + identity: None, + secure_note: Some(CipherSecureNote {}), + }, + crate::db::EntryData::SshKey { .. } => unreachable!(), + } + } +} + #[derive(Deserialize, Debug)] struct FoldersRes { #[serde(rename = "Data", alias = "data")] @@ -1205,105 +1320,21 @@ impl Client { notes: Option<&str>, folder_id: Option<&str>, ) -> Result<()> { - let mut req = CiphersPostReq { - ty: 1, + let fields = CipherDataFields::from(data); + + let req = CiphersPostReq { + ty: 1, // TODO: Bug? folder_id: folder_id.map(std::string::ToString::to_string), name: name.to_string(), notes: notes.map(std::string::ToString::to_string), - login: None, - card: None, - identity: None, - secure_note: None, + login: fields.login, + card: fields.card, + identity: fields.identity, + secure_note: fields.secure_note, }; - match data { - crate::db::EntryData::Login { - username, - password, - totp, - uris, - } => { - let uris = if uris.is_empty() { - None - } else { - Some( - uris.iter() - .map(|s| CipherLoginUri { - uri: Some(s.uri.clone()), - match_type: s.match_type, - }) - .collect(), - ) - }; - req.login = Some(CipherLogin { - username: username.clone(), - password: password.clone(), - totp: totp.clone(), - uris, - }); - } - crate::db::EntryData::Card { - cardholder_name, - number, - brand, - exp_month, - exp_year, - code, - } => { - req.card = Some(CipherCard { - cardholder_name: cardholder_name.clone(), - number: number.clone(), - brand: brand.clone(), - exp_month: exp_month.clone(), - exp_year: exp_year.clone(), - code: code.clone(), - }); - } - crate::db::EntryData::Identity { - title, - first_name, - middle_name, - last_name, - address1, - address2, - address3, - city, - state, - postal_code, - country, - phone, - email, - ssn, - license_number, - passport_number, - username, - } => { - req.identity = Some(CipherIdentity { - title: title.clone(), - first_name: first_name.clone(), - middle_name: middle_name.clone(), - last_name: last_name.clone(), - address1: address1.clone(), - address2: address2.clone(), - address3: address3.clone(), - city: city.clone(), - state: state.clone(), - postal_code: postal_code.clone(), - country: country.clone(), - phone: phone.clone(), - email: email.clone(), - ssn: ssn.clone(), - license_number: license_number.clone(), - passport_number: passport_number.clone(), - username: username.clone(), - }); - } - crate::db::EntryData::SecureNote => { - req.secure_note = Some(CipherSecureNote {}); - } - crate::db::EntryData::SshKey { .. } => unreachable!(), - } let res = ClientBlockingRequest::Add(access_token, req).req(self)?; + match res.status() { reqwest::StatusCode::OK => Ok(()), reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), @@ -1325,22 +1356,18 @@ impl Client { folder_uuid: Option<&str>, history: &[crate::db::HistoryEntry], ) -> Result<()> { - let mut req = CiphersPutReq { - ty: match data { - crate::db::EntryData::Login { .. } => 1, - crate::db::EntryData::SecureNote => 2, - crate::db::EntryData::Card { .. } => 3, - crate::db::EntryData::Identity { .. } => 4, - crate::db::EntryData::SshKey { .. } => unreachable!(), - }, + let cipher_fields = CipherDataFields::from(data); + + let req = CiphersPutReq { + ty: cipher_fields.ty, folder_id: folder_uuid.map(std::string::ToString::to_string), organization_id: org_id.map(std::string::ToString::to_string), name: name.to_string(), notes: notes.map(std::string::ToString::to_string), - login: None, - card: None, - identity: None, - secure_note: None, + login: cipher_fields.login, + card: cipher_fields.card, + identity: cipher_fields.identity, + secure_note: cipher_fields.secure_note, fields: fields .iter() .map(|field| CipherField { @@ -1358,95 +1385,9 @@ impl Client { }) .collect(), }; - match data { - crate::db::EntryData::Login { - username, - password, - totp, - uris, - } => { - let uris = if uris.is_empty() { - None - } else { - Some( - uris.iter() - .map(|s| CipherLoginUri { - uri: Some(s.uri.clone()), - match_type: s.match_type, - }) - .collect(), - ) - }; - req.login = Some(CipherLogin { - username: username.clone(), - password: password.clone(), - totp: totp.clone(), - uris, - }); - } - crate::db::EntryData::Card { - cardholder_name, - number, - brand, - exp_month, - exp_year, - code, - } => { - req.card = Some(CipherCard { - cardholder_name: cardholder_name.clone(), - number: number.clone(), - brand: brand.clone(), - exp_month: exp_month.clone(), - exp_year: exp_year.clone(), - code: code.clone(), - }); - } - crate::db::EntryData::Identity { - title, - first_name, - middle_name, - last_name, - address1, - address2, - address3, - city, - state, - postal_code, - country, - phone, - email, - ssn, - license_number, - passport_number, - username, - } => { - req.identity = Some(CipherIdentity { - title: title.clone(), - first_name: first_name.clone(), - middle_name: middle_name.clone(), - last_name: last_name.clone(), - address1: address1.clone(), - address2: address2.clone(), - address3: address3.clone(), - city: city.clone(), - state: state.clone(), - postal_code: postal_code.clone(), - country: country.clone(), - phone: phone.clone(), - email: email.clone(), - ssn: ssn.clone(), - license_number: license_number.clone(), - passport_number: passport_number.clone(), - username: username.clone(), - }); - } - crate::db::EntryData::SecureNote => { - req.secure_note = Some(CipherSecureNote {}); - } - crate::db::EntryData::SshKey { .. } => unreachable!(), - } let res = ClientBlockingRequest::Edit(access_token, id, req).req(self)?; + match res.status() { reqwest::StatusCode::OK => Ok(()), reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), From 431bebc7c264d632bf37817f16cd6f02068a20ac Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 8 May 2026 01:45:45 +0200 Subject: [PATCH 017/273] remove cipherdatafields and create EntryDataWirte struct to ease serialization --- src/api.rs | 200 ++++++++++++++++++++++++----------------------------- 1 file changed, 89 insertions(+), 111 deletions(-) diff --git a/src/api.rs b/src/api.rs index 1305cd09..ae194116 100644 --- a/src/api.rs +++ b/src/api.rs @@ -727,36 +727,26 @@ struct SyncResPasswordHistory { } #[derive(Serialize, Debug)] -struct CiphersPostReq { - #[serde(rename = "type")] - ty: u32, // XXX what are the valid types? +struct CiphersPostReq<'a> { #[serde(rename = "folderId")] folder_id: Option, name: String, notes: Option, - login: Option, - card: Option, - identity: Option, - #[serde(rename = "secureNote")] - secure_note: Option, + #[serde(flatten)] + data: EntryDataWire<'a>, // use lifetime parameter on the struct instead } #[derive(Serialize, Debug)] -struct CiphersPutReq { - #[serde(rename = "type")] - ty: u32, // XXX what are the valid types? +struct CiphersPutReq<'a> { #[serde(rename = "folderId")] folder_id: Option, #[serde(rename = "organizationId")] organization_id: Option, name: String, notes: Option, - login: Option, - card: Option, - identity: Option, + #[serde(flatten)] + data: EntryDataWire<'a>, fields: Vec, - #[serde(rename = "secureNote")] - secure_note: Option, #[serde(rename = "passwordHistory")] password_history: Vec, } @@ -769,45 +759,45 @@ struct CiphersPutReqHistory { password: String, } -struct CipherDataFields { - ty: u32, - login: Option, - card: Option, - identity: Option, - secure_note: Option, -} +#[derive(Debug)] +struct EntryDataWire<'a>(&'a crate::db::EntryData); -impl From<&crate::db::EntryData> for CipherDataFields { - fn from(data: &crate::db::EntryData) -> Self { - match data { +impl Serialize for EntryDataWire<'_> { + fn serialize( + &self, + serializer: S, + ) -> std::result::Result { + use serde::ser::SerializeMap; + let mut map = serializer.serialize_map(None)?; + match self.0.clone() { crate::db::EntryData::Login { username, password, totp, uris, - } => Self { - ty: 1, - login: Some(CipherLogin { - username: username.clone(), - password: password.clone(), - totp: totp.clone(), - uris: if uris.is_empty() { - None - } else { - Some( - uris.iter() - .map(|s| CipherLoginUri { - uri: Some(s.uri.clone()), - match_type: s.match_type, - }) - .collect(), - ) + } => { + map.serialize_entry("type", &1u32)?; + map.serialize_entry( + "login", + &CipherLogin { + username, + password, + totp, + uris: if uris.is_empty() { + None + } else { + Some( + uris.iter() + .map(|s| CipherLoginUri { + uri: Some(s.uri.clone()), + match_type: s.match_type, + }) + .collect(), + ) + }, }, - }), - card: None, - identity: None, - secure_note: None, - }, + )?; + } crate::db::EntryData::Card { cardholder_name, number, @@ -815,20 +805,20 @@ impl From<&crate::db::EntryData> for CipherDataFields { exp_month, exp_year, code, - } => Self { - ty: 3, - login: None, - card: Some(CipherCard { - cardholder_name: cardholder_name.clone(), - number: number.clone(), - brand: brand.clone(), - exp_month: exp_month.clone(), - exp_year: exp_year.clone(), - code: code.clone(), - }), - identity: None, - secure_note: None, - }, + } => { + map.serialize_entry("type", &3u32)?; + map.serialize_entry( + "card", + &CipherCard { + cardholder_name, + number, + brand, + exp_month, + exp_year, + code, + }, + )?; + } crate::db::EntryData::Identity { title, first_name, @@ -847,40 +837,40 @@ impl From<&crate::db::EntryData> for CipherDataFields { license_number, passport_number, username, - } => Self { - ty: 4, - login: None, - card: None, - identity: Some(CipherIdentity { - title: title.clone(), - first_name: first_name.clone(), - middle_name: middle_name.clone(), - last_name: last_name.clone(), - address1: address1.clone(), - address2: address2.clone(), - address3: address3.clone(), - city: city.clone(), - state: state.clone(), - postal_code: postal_code.clone(), - country: country.clone(), - phone: phone.clone(), - email: email.clone(), - ssn: ssn.clone(), - license_number: license_number.clone(), - passport_number: passport_number.clone(), - username: username.clone(), - }), - secure_note: None, - }, - crate::db::EntryData::SecureNote => Self { - ty: 2, - login: None, - card: None, - identity: None, - secure_note: Some(CipherSecureNote {}), - }, - crate::db::EntryData::SshKey { .. } => unreachable!(), + } => { + map.serialize_entry("type", &4u32)?; + map.serialize_entry( + "identity", + &CipherIdentity { + title, + first_name, + middle_name, + last_name, + address1, + address2, + address3, + city, + state, + postal_code, + country, + phone, + email, + ssn, + license_number, + passport_number, + username, + }, + )?; + } + crate::db::EntryData::SecureNote => { + map.serialize_entry("type", &2u32)?; + map.serialize_entry("secureNote", &CipherSecureNote {})?; + } + crate::db::EntryData::SshKey { .. } => { + return Err(serde::ser::Error::custom("SshKey not supported")); + } } + map.end() } } @@ -955,8 +945,8 @@ impl<'a> ClientRequest<'a> { } enum ClientBlockingRequest<'a> { - Add(&'a str, CiphersPostReq), - Edit(&'a str, &'a str, CiphersPutReq), + Add(&'a str, CiphersPostReq<'a>), + Edit(&'a str, &'a str, CiphersPutReq<'a>), Remove(&'a str, &'a str), Folders(&'a str), CreateFolder(&'a str, &'a str), @@ -1320,17 +1310,11 @@ impl Client { notes: Option<&str>, folder_id: Option<&str>, ) -> Result<()> { - let fields = CipherDataFields::from(data); - let req = CiphersPostReq { - ty: 1, // TODO: Bug? folder_id: folder_id.map(std::string::ToString::to_string), name: name.to_string(), notes: notes.map(std::string::ToString::to_string), - login: fields.login, - card: fields.card, - identity: fields.identity, - secure_note: fields.secure_note, + data: EntryDataWire(data), }; let res = ClientBlockingRequest::Add(access_token, req).req(self)?; @@ -1356,18 +1340,12 @@ impl Client { folder_uuid: Option<&str>, history: &[crate::db::HistoryEntry], ) -> Result<()> { - let cipher_fields = CipherDataFields::from(data); - let req = CiphersPutReq { - ty: cipher_fields.ty, folder_id: folder_uuid.map(std::string::ToString::to_string), organization_id: org_id.map(std::string::ToString::to_string), name: name.to_string(), notes: notes.map(std::string::ToString::to_string), - login: cipher_fields.login, - card: cipher_fields.card, - identity: cipher_fields.identity, - secure_note: cipher_fields.secure_note, + data: EntryDataWire(data), fields: fields .iter() .map(|field| CipherField { From c575ac446b801bc0cde366e32fe36c7ab77194eb Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 8 May 2026 01:52:45 +0200 Subject: [PATCH 018/273] remove dup code in login() --- src/api.rs | 50 +++++++++++++++++++++++--------------------------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/src/api.rs b/src/api.rs index ae194116..0729ba9f 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1124,48 +1124,44 @@ impl Client { two_factor_token: Option<&str>, two_factor_provider: Option, ) -> Result<(String, String, String)> { - let connect_req = match sso_id { + let (auth, grant_type, scope) = match sso_id { Some(sso_id) => { let (sso_code, sso_code_verifier, callback_url) = self.obtain_sso_code(sso_id).await?; - - ConnectTokenReq { - auth: ConnectTokenAuth::AuthCode { + ( + ConnectTokenAuth::AuthCode { code: sso_code, code_verifier: sso_code_verifier, redirect_uri: callback_url, }, - grant_type: "authorization_code".to_string(), - scope: "api offline_access".to_string(), - client_id: "cli".to_string(), - device_type: u32::from(DEVICE_TYPE), - device_identifier: device_id.to_string(), - device_name: "rbw".to_string(), - device_push_token: String::new(), - two_factor_token: two_factor_token.map(std::string::ToString::to_string), - two_factor_provider: two_factor_provider.map(|ty| ty as u32), - } + "authorization_code", + "api offline_access", + ) } - None => ConnectTokenReq { - auth: ConnectTokenAuth::Password { + None => ( + ConnectTokenAuth::Password { username: email.to_string(), password: crate::base64::encode(password_hash.hash()), }, + "password", + "api offline_access", + ), + }; - grant_type: "password".to_string(), - scope: "api offline_access".to_string(), - client_id: "cli".to_string(), - device_type: u32::from(DEVICE_TYPE), - device_identifier: device_id.to_string(), - device_name: "rbw".to_string(), - device_push_token: String::new(), - two_factor_token: two_factor_token.map(std::string::ToString::to_string), - two_factor_provider: two_factor_provider.map(|ty| ty as u32), - }, + let connect_req = ConnectTokenReq { + auth, + grant_type: grant_type.to_string(), + scope: scope.to_string(), + client_id: "cli".to_string(), + device_type: u32::from(DEVICE_TYPE), + device_identifier: device_id.to_string(), + device_name: "rbw".to_string(), + device_push_token: String::new(), + two_factor_token: two_factor_token.map(ToString::to_string), + two_factor_provider: two_factor_provider.map(|ty| ty as u32), }; let res = ClientRequest::Login(connect_req, email).req(self).await?; - if res.status() == reqwest::StatusCode::OK { let connect_res: ConnectTokenRes = res.json_with_path().await?; Ok(( From 65a6f6d225b9c5d835023de36d9db0e2ec879dbd Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 8 May 2026 02:02:13 +0200 Subject: [PATCH 019/273] remove useless SendEmailLoginReq struct --- src/api.rs | 38 ++++++++++++++------------------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/src/api.rs b/src/api.rs index 0729ba9f..1179f2e2 100644 --- a/src/api.rs +++ b/src/api.rs @@ -379,15 +379,6 @@ struct ConnectRefreshTokenRes { access_token: String, } -#[derive(Serialize, Debug)] -struct SendEmailLoginReq { - email: String, - #[serde(rename = "DeviceIdentifier", alias = "deviceIdentifier")] - device_identifier: String, - #[serde(rename = "SsoEmail2faSessionToken", alias = "ssoEmail2faSessionToken")] - sso_email_2fa_session_token: String, -} - #[derive(Deserialize, Debug)] struct SyncRes { #[serde(rename = "Ciphers", alias = "ciphers")] @@ -899,7 +890,7 @@ enum ClientRequest<'a> { Prelogin(&'a str), ConnectToken(ConnectTokenReq), Login(ConnectTokenReq, &'a str), - SendEmailLogin(SendEmailLoginReq, &'a str), + SendEmailLogin(&'a str, &'a str, &'a str), Sync(&'a str), ExchangeRefreshToken(&'a str), } @@ -919,10 +910,16 @@ impl<'a> ClientRequest<'a> { .post(client.identity_url("/connect/token")) .form(&r) .header("auth-email", crate::base64::encode_url_safe_no_pad(email)), - Self::SendEmailLogin(r, email) => http_client - .post(client.api_url("/two-factor/send-email-login")) - .json(&r) - .header("auth-email", crate::base64::encode_url_safe_no_pad(email)), + Self::SendEmailLogin(email, device_identifier, sso_email_2fa_session_token) => { + http_client + .post(client.api_url("/two-factor/send-email-login")) + .json(&serde_json::json!({ + "email": email, + "DeviceIdentifier": device_identifier, + "SsoEmail2faSessionToken": sso_email_2fa_session_token + })) + .header("auth-email", crate::base64::encode_url_safe_no_pad(email)) + } Self::Sync(access_token) => http_client .get(client.api_url("/sync")) .header("Authorization", format!("Bearer {access_token}")) @@ -1196,16 +1193,9 @@ impl Client { device_id: &str, sso_email_2fa_session_token: &str, ) -> Result<()> { - let res = ClientRequest::SendEmailLogin( - SendEmailLoginReq { - email: email.to_string(), - device_identifier: device_id.to_string(), - sso_email_2fa_session_token: sso_email_2fa_session_token.to_string(), - }, - email, - ) - .req(self) - .await?; + let res = ClientRequest::SendEmailLogin(email, device_id, sso_email_2fa_session_token) + .req(self) + .await?; if res.status() == reqwest::StatusCode::OK { Ok(()) From d0e3b075b9c6d450459e9118aee052314d70b7ab Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 8 May 2026 11:12:15 +0200 Subject: [PATCH 020/273] add shell.nix --- shell.nix | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 shell.nix diff --git a/shell.nix b/shell.nix new file mode 100644 index 00000000..57220399 --- /dev/null +++ b/shell.nix @@ -0,0 +1,6 @@ +{ pkgs ? import (fetchTarball + "https://github.com/NixOS/nixpkgs/archive/nixos-unstable.tar.gz") { } }: + +pkgs.mkShell { + buildInputs = with pkgs; [ rustc cargo rust-analyzer rustfmt clippy ]; +} From bab4b7480e8916da2b086ecc5ebc8da79bee62b6 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 8 May 2026 11:30:42 +0200 Subject: [PATCH 021/273] add flake.nix --- flake.lock | 27 +++++++++++++++++++++++++++ flake.nix | 18 ++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..c6670e17 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1777954456, + "narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..de949c9a --- /dev/null +++ b/flake.nix @@ -0,0 +1,18 @@ +{ + description = "rbw: unofficial bitwarden cli"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; + }; + + outputs = + { self, nixpkgs }: + { + + packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello; + + packages.x86_64-linux.default = self.packages.x86_64-linux.hello; + devShells.x86_64-linux.default = import ./shell.nix { pkgs = nixpkgs.legacyPackages.x86_64-linux; }; + + }; +} From f8563615eb3861c3b9b283a057775a499a2ce167 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 8 May 2026 11:46:32 +0200 Subject: [PATCH 022/273] remove "famous" prefixes --- src/api.rs | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/api.rs b/src/api.rs index 1179f2e2..198cf67e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -3,10 +3,7 @@ #![allow(clippy::as_conversions)] use std::{ - fmt::Display, - path::{Path, PathBuf}, - str::FromStr, - sync::Arc, + collections::HashMap, fmt::Display, path::{Path, PathBuf}, str::FromStr, sync::Arc }; use crate::prelude::*; @@ -459,7 +456,7 @@ impl SyncResCipher { username: login.username.clone(), password: login.password.clone(), totp: login.totp.clone(), - uris: login.uris.as_ref().map_or_else(std::vec::Vec::new, |uris| { + uris: login.uris.as_ref().map_or_else(Vec::new, |uris| { uris.iter() .filter_map(|uri| { uri.uri.clone().map(|s| crate::db::Uri { @@ -525,7 +522,7 @@ impl SyncResCipher { id: self.id.clone(), org_id: self.organization_id.clone(), folder, - folder_id: folder_id.map(std::string::ToString::to_string), + folder_id: folder_id.map(ToString::to_string), name: self.name.clone(), data, fields, @@ -1255,7 +1252,7 @@ impl Client { ) -> Result<( String, String, - std::collections::HashMap, + HashMap, Vec, )> { let res = ClientRequest::Sync(access_token).req(self).await?; @@ -1297,9 +1294,9 @@ impl Client { folder_id: Option<&str>, ) -> Result<()> { let req = CiphersPostReq { - folder_id: folder_id.map(std::string::ToString::to_string), + folder_id: folder_id.map(ToString::to_string), name: name.to_string(), - notes: notes.map(std::string::ToString::to_string), + notes: notes.map(ToString::to_string), data: EntryDataWire(data), }; @@ -1327,10 +1324,10 @@ impl Client { history: &[crate::db::HistoryEntry], ) -> Result<()> { let req = CiphersPutReq { - folder_id: folder_uuid.map(std::string::ToString::to_string), - organization_id: org_id.map(std::string::ToString::to_string), + folder_id: folder_uuid.map(ToString::to_string), + organization_id: org_id.map(ToString::to_string), name: name.to_string(), - notes: notes.map(std::string::ToString::to_string), + notes: notes.map(ToString::to_string), data: EntryDataWire(data), fields: fields .iter() @@ -1476,7 +1473,7 @@ async fn start_sso_callback_server( async fn handle_sso_callback( axum::extract::State(state): axum::extract::State>, - axum::extract::Query(params): axum::extract::Query>, + axum::extract::Query(params): axum::extract::Query>, ) -> axum::http::Response { match sso_query_code(¶ms, state.state.as_str()) { Ok(sso_code) => { @@ -1511,7 +1508,7 @@ async fn handle_sso_callback( } fn sso_query_code( - params: &std::collections::HashMap, + params: &HashMap, state: &str, ) -> Result { let sso_code = params From 4c4a93d4819a952a806862b1a4f21ba87a7372f0 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 8 May 2026 12:16:42 +0200 Subject: [PATCH 023/273] remove "famous" prefixes --- src/bin/rbw/commands.rs | 49 +++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index a4bd14a5..da17640e 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1,4 +1,12 @@ -use std::{fmt::Write as _, io::Write as _, os::unix::ffi::OsStrExt as _}; +use std::{ + collections::HashMap, + fmt::{Display, Write as _}, + io::Write as _, + os::unix::ffi::OsStrExt as _, + path::PathBuf, + str::FromStr, + time::SystemTime, +}; use anyhow::Context as _; @@ -23,7 +31,7 @@ pub enum Needle { Uuid(uuid::Uuid, String), } -impl std::fmt::Display for Needle { +impl Display for Needle { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let value = match &self { Self::Name(name) => name.clone(), @@ -86,7 +94,7 @@ enum Field { LastName, } -impl std::str::FromStr for Field { +impl FromStr for Field { type Err = anyhow::Error; fn from_str(s: &str) -> Result { @@ -172,7 +180,7 @@ impl Field { } } -impl std::fmt::Display for Field { +impl Display for Field { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } @@ -1121,7 +1129,7 @@ impl ListField { } } -impl std::convert::TryFrom<&String> for ListField { +impl TryFrom<&String> for ListField { type Error = anyhow::Error; fn try_from(s: &String) -> anyhow::Result { @@ -1168,7 +1176,7 @@ pub fn config_set(key: &str, value: &str) -> anyhow::Result<()> { config.notifications_url = Some(value.to_string()); } "client_cert_path" => { - config.client_cert_path = Some(std::path::PathBuf::from(value.to_string())); + config.client_cert_path = Some(PathBuf::from(value.to_string())); } "lock_timeout" => { let timeout = value @@ -1283,7 +1291,7 @@ pub fn list(fields: &[String], raw: bool) -> anyhow::Result<()> { } else { fields .iter() - .map(std::convert::TryFrom::try_from) + .map(TryFrom::try_from) .collect::>()? }; @@ -1359,15 +1367,15 @@ fn print_entry_list( ListField::Name => entry .name .as_ref() - .map_or_else(String::new, std::string::ToString::to_string), + .map_or_else(String::new, ToString::to_string), ListField::User => entry .user .as_ref() - .map_or_else(String::new, std::string::ToString::to_string), + .map_or_else(String::new, ToString::to_string), ListField::Folder => entry .folder .as_ref() - .map_or_else(String::new, std::string::ToString::to_string), + .map_or_else(String::new, ToString::to_string), ListField::Uri => { // "uri" is not listed in the TryFrom // implementation, so there's no way to try to @@ -1379,7 +1387,7 @@ fn print_entry_list( ListField::EntryType => entry .entry_type .as_ref() - .map_or_else(String::new, std::string::ToString::to_string), + .map_or_else(String::new, ToString::to_string), }) .collect(); @@ -1406,7 +1414,7 @@ pub fn search( } else { fields .iter() - .map(std::convert::TryFrom::try_from) + .map(TryFrom::try_from) .collect::>()? }; @@ -1424,7 +1432,7 @@ pub fn search( .map(|entry| entry.search_match(term, folder)) .unwrap_or(true) }) - .map(|entry| entry.map(std::convert::Into::into)) + .map(|entry| entry.map(Into::into)) .collect::>()?; entries.sort_unstable_by(|a, b| a.name.cmp(&b.name)); @@ -1706,10 +1714,7 @@ pub fn edit( if let Some(prev_password) = entry_password.clone() { let new_history_entry = rbw::db::HistoryEntry { - last_used_date: format!( - "{}", - humantime::format_rfc3339(std::time::SystemTime::now()) - ), + last_used_date: format!("{}", humantime::format_rfc3339(SystemTime::now())), password: prev_password, }; history.insert(0, new_history_entry); @@ -2478,7 +2483,7 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { fn parse_editor(contents: &str) -> (Option, Option) { let mut lines = contents.lines(); - let password = lines.next().map(std::string::ToString::to_string); + let password = lines.next().map(ToString::to_string); let mut notes: String = lines .skip_while(|line| line.is_empty()) @@ -2554,7 +2559,7 @@ fn parse_totp_secret(secret: &str) -> anyhow::Result { return Err(anyhow::anyhow!("totp secret url must have totp host")); } - let query: std::collections::HashMap<_, _> = u.query_pairs().collect(); + let query: HashMap<_, _> = u.query_pairs().collect(); let secret = decode_totp_secret( query @@ -2563,7 +2568,7 @@ fn parse_totp_secret(secret: &str) -> anyhow::Result { )?; let algorithm = query .get("algorithm") - .map_or_else(|| String::from("SHA1"), std::string::ToString::to_string); + .map_or_else(|| String::from("SHA1"), ToString::to_string); let digits = match query.get("digits") { Some(dig) => dig.parse::().map_err(|_| { anyhow::anyhow!("digits parameter in totp url must be a valid integer.") @@ -3768,9 +3773,9 @@ mod test { DecryptedSearchCipher { id: id.to_string(), entry_type: "Login".to_string(), - folder: folder.map(std::string::ToString::to_string), + folder: folder.map(ToString::to_string), name: name.to_string(), - user: username.map(std::string::ToString::to_string), + user: username.map(ToString::to_string), uris: uris .iter() .map(|(uri, match_type)| ((*uri).to_string(), *match_type)) From 1e5759915b7911cdd6b96e5c9e724e2cef34d640 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 8 May 2026 12:23:07 +0200 Subject: [PATCH 024/273] remove superfluous impl --- src/bin/rbw/commands.rs | 78 +++++++++++++++++++---------------------- 1 file changed, 36 insertions(+), 42 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index da17640e..57ef7e5a 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -139,50 +139,44 @@ impl FromStr for Field { } } -impl Field { - fn as_str(&self) -> &str { - match self { - Self::Notes => "notes", - Self::Username => "username", - Self::Password => "password", - Self::Totp => "totp", - Self::Uris => "uris", - Self::IdentityName => "identityname", - Self::City => "city", - Self::State => "state", - Self::PostalCode => "postcode", - Self::Country => "country", - Self::Phone => "phone", - Self::Ssn => "ssn", - Self::License => "license", - Self::Passport => "passport", - Self::CardNumber => "number", - Self::Expiration => "exp", - Self::ExpMonth => "exp_month", - Self::ExpYear => "exp_year", - Self::Cvv => "cvv", - Self::Cardholder => "cardholder", - Self::Brand => "brand", - Self::Name => "name", - Self::Email => "email", - Self::Address1 => "address1", - Self::Address2 => "address2", - Self::Address3 => "address3", - Self::Address => "address", - Self::Fingerprint => "fingerprint", - Self::PublicKey => "public_key", - Self::PrivateKey => "private_key", - Self::Title => "title", - Self::FirstName => "first_name", - Self::MiddleName => "middle_name", - Self::LastName => "last_name", - } - } -} - impl Display for Field { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) + match self { + Self::Notes => f.write_str("notes"), + Self::Username => f.write_str("username"), + Self::Password => f.write_str("password"), + Self::Totp => f.write_str("totp"), + Self::Uris => f.write_str("uris"), + Self::IdentityName => f.write_str("identityname"), + Self::City => f.write_str("city"), + Self::State => f.write_str("state"), + Self::PostalCode => f.write_str("postcode"), + Self::Country => f.write_str("country"), + Self::Phone => f.write_str("phone"), + Self::Ssn => f.write_str("ssn"), + Self::License => f.write_str("license"), + Self::Passport => f.write_str("passport"), + Self::CardNumber => f.write_str("number"), + Self::Expiration => f.write_str("exp"), + Self::ExpMonth => f.write_str("exp_month"), + Self::ExpYear => f.write_str("exp_year"), + Self::Cvv => f.write_str("cvv"), + Self::Cardholder => f.write_str("cardholder"), + Self::Brand => f.write_str("brand"), + Self::Name => f.write_str("name"), + Self::Email => f.write_str("email"), + Self::Address1 => f.write_str("address1"), + Self::Address2 => f.write_str("address2"), + Self::Address3 => f.write_str("address3"), + Self::Address => f.write_str("address"), + Self::Fingerprint => f.write_str("fingerprint"), + Self::PublicKey => f.write_str("public_key"), + Self::PrivateKey => f.write_str("private_key"), + Self::Title => f.write_str("title"), + Self::FirstName => f.write_str("first_name"), + Self::MiddleName => f.write_str("middle_name"), + Self::LastName => f.write_str("last_name"), + } } } From 76e9556e83449f67ed2f95a01f3bba40260ffc3d Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 8 May 2026 12:25:29 +0200 Subject: [PATCH 025/273] improve readability --- src/bin/rbw/commands.rs | 72 ++++++++++++++++++++--------------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 57ef7e5a..7202169d 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -141,42 +141,42 @@ impl FromStr for Field { impl Display for Field { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Notes => f.write_str("notes"), - Self::Username => f.write_str("username"), - Self::Password => f.write_str("password"), - Self::Totp => f.write_str("totp"), - Self::Uris => f.write_str("uris"), - Self::IdentityName => f.write_str("identityname"), - Self::City => f.write_str("city"), - Self::State => f.write_str("state"), - Self::PostalCode => f.write_str("postcode"), - Self::Country => f.write_str("country"), - Self::Phone => f.write_str("phone"), - Self::Ssn => f.write_str("ssn"), - Self::License => f.write_str("license"), - Self::Passport => f.write_str("passport"), - Self::CardNumber => f.write_str("number"), - Self::Expiration => f.write_str("exp"), - Self::ExpMonth => f.write_str("exp_month"), - Self::ExpYear => f.write_str("exp_year"), - Self::Cvv => f.write_str("cvv"), - Self::Cardholder => f.write_str("cardholder"), - Self::Brand => f.write_str("brand"), - Self::Name => f.write_str("name"), - Self::Email => f.write_str("email"), - Self::Address1 => f.write_str("address1"), - Self::Address2 => f.write_str("address2"), - Self::Address3 => f.write_str("address3"), - Self::Address => f.write_str("address"), - Self::Fingerprint => f.write_str("fingerprint"), - Self::PublicKey => f.write_str("public_key"), - Self::PrivateKey => f.write_str("private_key"), - Self::Title => f.write_str("title"), - Self::FirstName => f.write_str("first_name"), - Self::MiddleName => f.write_str("middle_name"), - Self::LastName => f.write_str("last_name"), - } + f.write_str(match self { + Self::Notes => "notes", + Self::Username => "username", + Self::Password => "password", + Self::Totp => "totp", + Self::Uris => "uris", + Self::IdentityName => "identityname", + Self::City => "city", + Self::State => "state", + Self::PostalCode => "postcode", + Self::Country => "country", + Self::Phone => "phone", + Self::Ssn => "ssn", + Self::License => "license", + Self::Passport => "passport", + Self::CardNumber => "number", + Self::Expiration => "exp", + Self::ExpMonth => "exp_month", + Self::ExpYear => "exp_year", + Self::Cvv => "cvv", + Self::Cardholder => "cardholder", + Self::Brand => "brand", + Self::Name => "name", + Self::Email => "email", + Self::Address1 => "address1", + Self::Address2 => "address2", + Self::Address3 => "address3", + Self::Address => "address", + Self::Fingerprint => "fingerprint", + Self::PublicKey => "public_key", + Self::PrivateKey => "private_key", + Self::Title => "title", + Self::FirstName => "first_name", + Self::MiddleName => "middle_name", + Self::LastName => "last_name", + }) } } From 46c05717422dd6a036d1c4f90ceb3e283f9fe822 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 8 May 2026 13:53:57 +0200 Subject: [PATCH 026/273] split display_short and remove duplicated code --- src/bin/rbw/commands.rs | 95 ++++++++++++++++++++--------------------- 1 file changed, 47 insertions(+), 48 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 7202169d..0295c4ed 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -345,22 +345,10 @@ struct DecryptedCipher { } impl DecryptedCipher { - fn display_short(&self, desc: &str, clipboard: bool) -> bool { + fn get_short(&self) -> Option { match &self.data { - DecryptedData::Login { password, .. } => password.as_ref().map_or_else( - || { - eprintln!("entry for '{desc}' had no password"); - false - }, - |password| val_display_or_store(clipboard, password), - ), - DecryptedData::Card { number, .. } => number.as_ref().map_or_else( - || { - eprintln!("entry for '{desc}' had no card number"); - false - }, - |number| val_display_or_store(clipboard, number), - ), + DecryptedData::Login { password, .. } => password.clone(), + DecryptedData::Card { number, .. } => number.clone(), DecryptedData::Identity { title, first_name, @@ -368,36 +356,44 @@ impl DecryptedCipher { last_name, .. } => { - let names: Vec<_> = [title, first_name, middle_name, last_name] + let names: Vec = [title, first_name, middle_name, last_name] .iter() .copied() .flatten() .cloned() .collect(); + if names.is_empty() { - eprintln!("entry for '{desc}' had no name"); - false + None } else { - val_display_or_store(clipboard, &names.join(" ")) + Some(names.join(" ")) } } - DecryptedData::SecureNote => self.notes.as_ref().map_or_else( - || { - eprintln!("entry for '{desc}' had no notes"); - false - }, - |notes| val_display_or_store(clipboard, notes), - ), - DecryptedData::SshKey { public_key, .. } => public_key.as_ref().map_or_else( - || { - eprintln!("entry for '{desc}' had no public key"); - false - }, - |public_key| val_display_or_store(clipboard, public_key), - ), + DecryptedData::SecureNote => self.notes.clone(), + DecryptedData::SshKey { public_key, .. } => public_key.clone(), } } + fn display_short(&self, desc: &str, clipboard: bool) -> bool { + let short = self.get_short(); + let Some(short) = short else { + // Would be cool if self.data had a method named main_field_name :D + eprintln!( + "entry for '{desc}' had no {}", + match &self.data { + DecryptedData::Login { .. } => "password", + DecryptedData::Card { .. } => "card number", + DecryptedData::Identity { .. } => "name", + DecryptedData::SecureNote => "notes", + DecryptedData::SshKey { .. } => "public key", + } + ); + return false; + }; + + val_display_or_store(clipboard, &short) + } + fn display_field(&self, desc: &str, field: &str, clipboard: bool) { let field = field.to_lowercase(); let field = field.as_str(); @@ -660,6 +656,7 @@ impl DecryptedCipher { } fn display_long(&self, desc: &str, clipboard: bool) { + let mut displayed = self.display_short(desc, clipboard); match &self.data { DecryptedData::Login { username, @@ -667,7 +664,6 @@ impl DecryptedCipher { uris, .. } => { - let mut displayed = self.display_short(desc, clipboard); displayed |= display_field("Username", username.as_deref(), clipboard); displayed |= display_field("TOTP Secret", totp.as_deref(), clipboard); @@ -702,9 +698,6 @@ impl DecryptedCipher { code, .. } => { - let mut displayed = false; - - displayed |= self.display_short(desc, clipboard); if let (Some(exp_month), Some(exp_year)) = (exp_month, exp_year) { println!("Expiration: {exp_month}/{exp_year}"); displayed = true; @@ -736,8 +729,6 @@ impl DecryptedCipher { username, .. } => { - let mut displayed = self.display_short(desc, clipboard); - displayed |= display_field("Address", address1.as_deref(), clipboard); displayed |= display_field("Address", address2.as_deref(), clipboard); displayed |= display_field("Address", address3.as_deref(), clipboard); @@ -759,11 +750,8 @@ impl DecryptedCipher { println!("{notes}"); } } - DecryptedData::SecureNote => { - self.display_short(desc, clipboard); - } + DecryptedData::SecureNote => {} DecryptedData::SshKey { fingerprint, .. } => { - let mut displayed = self.display_short(desc, clipboard); displayed |= display_field("Fingerprint", fingerprint.as_deref(), clipboard); for field in &self.fields { @@ -1332,12 +1320,23 @@ pub fn get( decrypted.display_fields_list(); } else if raw { decrypted.display_json(&desc)?; - } else if full { - decrypted.display_long(&desc, clipboard); - } else if let Some(field) = field { - decrypted.display_field(&desc, field, clipboard); } else { - decrypted.display_short(&desc, clipboard); + // if clipboard { + // match clipboard_store(password) { + // Ok(()) => true, + // Err(e) => { + // eprintln!("{e}"); + // false + // } + // } + // } + if full { + decrypted.display_long(&desc, clipboard); + } else if let Some(field) = field { + decrypted.display_field(&desc, field, clipboard); + } else { + decrypted.display_short(&desc, clipboard); + } } Ok(()) From 6d50048511b72f8dcbed7c199809e3df605c68de Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 8 May 2026 15:09:15 +0200 Subject: [PATCH 027/273] dedup notes printing code --- src/bin/rbw/commands.rs | 33 +++++++-------------------------- 1 file changed, 7 insertions(+), 26 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 0295c4ed..884aa71f 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -682,13 +682,6 @@ impl DecryptedCipher { clipboard, ); } - - if let Some(notes) = &self.notes { - if displayed { - println!(); - } - println!("{notes}"); - } } DecryptedData::Card { cardholder_name, @@ -705,13 +698,6 @@ impl DecryptedCipher { displayed |= display_field("CVV", code.as_deref(), clipboard); displayed |= display_field("Name", cardholder_name.as_deref(), clipboard); displayed |= display_field("Brand", brand.as_deref(), clipboard); - - if let Some(notes) = &self.notes { - if displayed { - println!(); - } - println!("{notes}"); - } } DecryptedData::Identity { address1, @@ -742,13 +728,6 @@ impl DecryptedCipher { displayed |= display_field("License", license_number.as_deref(), clipboard); displayed |= display_field("Passport", passport_number.as_deref(), clipboard); displayed |= display_field("Username", username.as_deref(), clipboard); - - if let Some(notes) = &self.notes { - if displayed { - println!(); - } - println!("{notes}"); - } } DecryptedData::SecureNote => {} DecryptedData::SshKey { fingerprint, .. } => { @@ -761,13 +740,15 @@ impl DecryptedCipher { clipboard, ); } + } + } - if let Some(notes) = &self.notes { - if displayed { - println!(); - } - println!("{notes}"); + if !matches!(&self.data, DecryptedData::SecureNote) { + if let Some(notes) = &self.notes { + if displayed { + println!(); } + println!("{notes}"); } } } From 271e0d75e9d86a92b16562826a84ab2e21950fbc Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 8 May 2026 16:29:44 +0200 Subject: [PATCH 028/273] add temporary get_field --- src/bin/rbw/commands.rs | 238 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 884aa71f..5387dccb 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -394,6 +394,244 @@ impl DecryptedCipher { val_display_or_store(clipboard, &short) } + fn get_field(&self, field: &str) -> Option { + let ret = match &self.data { + DecryptedData::Login { + username, + totp, + uris, + .. + } => match field.parse() { + Ok(Field::Notes) => &self.notes, + Ok(Field::Username) => username, + + Ok(Field::Totp) => { + if let Some(totp) = totp { + match generate_totp(totp) { + Ok(code) => { + &Some(code) + + // val_display_or_store(clipboard, &code); + } + Err(e) => { + eprintln!("{e}"); + &None + } + } + } else { + &None + } + } + Ok(Field::Uris) => { + if let Some(uris) = uris { + let uri_strs: Vec<_> = uris.iter().map(|uri| uri.uri.clone()).collect(); + // val_display_or_store(clipboard, &uri_strs.join("\n")); + &Some(uri_strs.join("\n")) + } else { + &None + } + } + Ok(Field::Password) => { + // self.display_short(desc, clipboard); + &self.get_short() + } + _ => { + let x: Vec = self + .fields + .iter() + .map(|f| { + if let Some(name) = &f.name { + if name.to_lowercase().contains(field) { + f.clone().value.unwrap_or("".to_string()) + } else { + "".to_string() + } + } else { + "".to_string() + } + }) + .collect(); + + &Some(x.join("\n")) + // for f in &self.fields { + // if let Some(name) = &f.name { + // if name.to_lowercase().as_str().contains(field) { + // val_display_or_store(clipboard, f.value.as_deref().unwrap_or("")); + // break; + // } + // } + // } + } + }, + DecryptedData::Card { + cardholder_name, + brand, + exp_month, + exp_year, + code, + .. + } => match field.parse() { + Ok(Field::CardNumber) => &self.get_short(), + Ok(Field::Expiration) => { + if let (Some(month), Some(year)) = (exp_month, exp_year) { + &Some(format!("{month}/{year}")) + //val_display_or_store(clipboard, &format!("{month}/{year}")); + } else { + &None + } + } + Ok(Field::ExpMonth) => &exp_month, + Ok(Field::ExpYear) => &exp_year, + Ok(Field::Cvv) => &code, + Ok(Field::Name | Field::Cardholder) => &cardholder_name, + Ok(Field::Brand) => &brand, + Ok(Field::Notes) => &self.notes, + _ => { + let x: Vec = self + .fields + .iter() + .map(|f| { + if let Some(name) = &f.name { + if name.to_lowercase().contains(field) { + f.clone().value.unwrap_or("".to_string()) + } else { + "".to_string() + } + } else { + "".to_string() + } + }) + .collect(); + + &Some(x.join("\n")) + } + }, + DecryptedData::Identity { + address1, + address2, + address3, + city, + state, + postal_code, + country, + phone, + email, + ssn, + license_number, + passport_number, + username, + .. + } => match field.parse() { + Ok(Field::Name) => &self.get_short(), + Ok(Field::Email) => &email, + Ok(Field::Address) => { + let mut strs = vec![]; + + if let Some(address1) = address1 { + strs.push(address1.clone()); + } + if let Some(address2) = address2 { + strs.push(address2.clone()); + } + if let Some(address3) = address3 { + strs.push(address3.clone()); + } + + if !strs.is_empty() { + &Some(strs.join("\n")) + //val_display_or_store(clipboard, &strs.join("\n")); + } else { + &None + } + } + Ok(Field::City) => &city, + Ok(Field::State) => &state, + Ok(Field::PostalCode) => &postal_code, + Ok(Field::Country) => &country, + Ok(Field::Phone) => &phone, + Ok(Field::Ssn) => &ssn, + Ok(Field::License) => &license_number, + Ok(Field::Passport) => &passport_number, + Ok(Field::Username) => &username, + Ok(Field::Notes) => &self.notes, + _ => { + let x: Vec = self + .fields + .iter() + .map(|f| { + if let Some(name) = &f.name { + if name.to_lowercase().contains(field) { + f.clone().value.unwrap_or("".to_string()) + } else { + "".to_string() + } + } else { + "".to_string() + } + }) + .collect(); + + &Some(x.join("\n")) + } + }, + + DecryptedData::SecureNote => match field.parse() { + Ok(Field::Notes) => &self.get_short(), + _ => { + let x: Vec = self + .fields + .iter() + .map(|f| { + if let Some(name) = &f.name { + if name.to_lowercase().contains(field) { + f.clone().value.unwrap_or("".to_string()) + } else { + "".to_string() + } + } else { + "".to_string() + } + }) + .collect(); + + &Some(x.join("\n")) + } + }, + + DecryptedData::SshKey { + fingerprint, + private_key, + .. + } => match field.parse() { + Ok(Field::Fingerprint) => &fingerprint, + Ok(Field::PublicKey) => &self.get_short(), + Ok(Field::PrivateKey) => &private_key, + Ok(Field::Notes) => &self.notes, + _ => { + let x: Vec = self + .fields + .iter() + .map(|f| { + if let Some(name) = &f.name { + if name.to_lowercase().contains(field) { + f.clone().value.unwrap_or("".to_string()) + } else { + "".to_string() + } + } else { + "".to_string() + } + }) + .collect(); + + &Some(x.join("\n")) + } + }, + }; + + ret.clone() + } + fn display_field(&self, desc: &str, field: &str, clipboard: bool) { let field = field.to_lowercase(); let field = field.as_str(); From bada984c78fc49723e18f954241e3a501e74ca65 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 8 May 2026 17:07:50 +0200 Subject: [PATCH 029/273] split display_field in two parts --- src/bin/rbw/commands.rs | 471 +++++++++------------------------------- 1 file changed, 101 insertions(+), 370 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 5387dccb..1f0a5435 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -394,65 +394,64 @@ impl DecryptedCipher { val_display_or_store(clipboard, &short) } - fn get_field(&self, field: &str) -> Option { - let ret = match &self.data { + /// This function is sh*t but I need it for now + fn get_fields(&self, field: &str) -> Vec { + let ret: Vec> = match &self.data { DecryptedData::Login { username, totp, uris, .. } => match field.parse() { - Ok(Field::Notes) => &self.notes, - Ok(Field::Username) => username, + Ok(Field::Notes) => vec![self.notes.clone()], + Ok(Field::Username) => vec![username.clone()], Ok(Field::Totp) => { if let Some(totp) = totp { match generate_totp(totp) { Ok(code) => { - &Some(code) + vec![Some(code)] // val_display_or_store(clipboard, &code); } Err(e) => { eprintln!("{e}"); - &None + vec![] } } } else { - &None + vec![] } } Ok(Field::Uris) => { if let Some(uris) = uris { let uri_strs: Vec<_> = uris.iter().map(|uri| uri.uri.clone()).collect(); // val_display_or_store(clipboard, &uri_strs.join("\n")); - &Some(uri_strs.join("\n")) + vec![Some(uri_strs.join("\n"))] } else { - &None + vec![] } } Ok(Field::Password) => { // self.display_short(desc, clipboard); - &self.get_short() + vec![self.get_short()] } _ => { - let x: Vec = self - .fields + self.fields .iter() .map(|f| { if let Some(name) = &f.name { if name.to_lowercase().contains(field) { - f.clone().value.unwrap_or("".to_string()) + f.value.clone() } else { - "".to_string() + None } } else { - "".to_string() + None } }) - .collect(); + .collect() - &Some(x.join("\n")) // for f in &self.fields { // if let Some(name) = &f.name { // if name.to_lowercase().as_str().contains(field) { @@ -471,40 +470,36 @@ impl DecryptedCipher { code, .. } => match field.parse() { - Ok(Field::CardNumber) => &self.get_short(), + Ok(Field::CardNumber) => vec![self.get_short()], Ok(Field::Expiration) => { if let (Some(month), Some(year)) = (exp_month, exp_year) { - &Some(format!("{month}/{year}")) + vec![Some(format!("{month}/{year}"))] //val_display_or_store(clipboard, &format!("{month}/{year}")); } else { - &None + vec![] } } - Ok(Field::ExpMonth) => &exp_month, - Ok(Field::ExpYear) => &exp_year, - Ok(Field::Cvv) => &code, - Ok(Field::Name | Field::Cardholder) => &cardholder_name, - Ok(Field::Brand) => &brand, - Ok(Field::Notes) => &self.notes, - _ => { - let x: Vec = self - .fields - .iter() - .map(|f| { - if let Some(name) = &f.name { - if name.to_lowercase().contains(field) { - f.clone().value.unwrap_or("".to_string()) - } else { - "".to_string() - } + Ok(Field::ExpMonth) => vec![exp_month.clone()], + Ok(Field::ExpYear) => vec![exp_year.clone()], + Ok(Field::Cvv) => vec![code.clone()], + Ok(Field::Name | Field::Cardholder) => vec![cardholder_name.clone()], + Ok(Field::Brand) => vec![brand.clone()], + Ok(Field::Notes) => vec![self.notes.clone()], + _ => self + .fields + .iter() + .map(|f| { + if let Some(name) = &f.name { + if name.to_lowercase().contains(field) { + f.value.clone() } else { - "".to_string() + None } - }) - .collect(); - - &Some(x.join("\n")) - } + } else { + None + } + }) + .collect(), }, DecryptedData::Identity { address1, @@ -522,8 +517,8 @@ impl DecryptedCipher { username, .. } => match field.parse() { - Ok(Field::Name) => &self.get_short(), - Ok(Field::Email) => &email, + Ok(Field::Name) => vec![self.get_short()], + Ok(Field::Email) => vec![email.clone()], Ok(Field::Address) => { let mut strs = vec![]; @@ -538,64 +533,56 @@ impl DecryptedCipher { } if !strs.is_empty() { - &Some(strs.join("\n")) + vec![Some(strs.join("\n"))] //val_display_or_store(clipboard, &strs.join("\n")); } else { - &None + vec![] } } - Ok(Field::City) => &city, - Ok(Field::State) => &state, - Ok(Field::PostalCode) => &postal_code, - Ok(Field::Country) => &country, - Ok(Field::Phone) => &phone, - Ok(Field::Ssn) => &ssn, - Ok(Field::License) => &license_number, - Ok(Field::Passport) => &passport_number, - Ok(Field::Username) => &username, - Ok(Field::Notes) => &self.notes, - _ => { - let x: Vec = self - .fields - .iter() - .map(|f| { - if let Some(name) = &f.name { - if name.to_lowercase().contains(field) { - f.clone().value.unwrap_or("".to_string()) - } else { - "".to_string() - } + Ok(Field::City) => vec![city.clone()], + Ok(Field::State) => vec![state.clone()], + Ok(Field::PostalCode) => vec![postal_code.clone()], + Ok(Field::Country) => vec![country.clone()], + Ok(Field::Phone) => vec![phone.clone()], + Ok(Field::Ssn) => vec![ssn.clone()], + Ok(Field::License) => vec![license_number.clone()], + Ok(Field::Passport) => vec![passport_number.clone()], + Ok(Field::Username) => vec![username.clone()], + Ok(Field::Notes) => vec![self.notes.clone()], + _ => self + .fields + .iter() + .map(|f| { + if let Some(name) = &f.name { + if name.to_lowercase().contains(field) { + f.value.clone() } else { - "".to_string() + None } - }) - .collect(); - - &Some(x.join("\n")) - } + } else { + None + } + }) + .collect(), }, DecryptedData::SecureNote => match field.parse() { - Ok(Field::Notes) => &self.get_short(), - _ => { - let x: Vec = self - .fields - .iter() - .map(|f| { - if let Some(name) = &f.name { - if name.to_lowercase().contains(field) { - f.clone().value.unwrap_or("".to_string()) - } else { - "".to_string() - } + Ok(Field::Notes) => vec![self.get_short()], + _ => self + .fields + .iter() + .map(|f| { + if let Some(name) = &f.name { + if name.to_lowercase().contains(field) { + f.value.clone() } else { - "".to_string() + None } - }) - .collect(); - - &Some(x.join("\n")) - } + } else { + None + } + }) + .collect(), }, DecryptedData::SshKey { @@ -603,294 +590,38 @@ impl DecryptedCipher { private_key, .. } => match field.parse() { - Ok(Field::Fingerprint) => &fingerprint, - Ok(Field::PublicKey) => &self.get_short(), - Ok(Field::PrivateKey) => &private_key, - Ok(Field::Notes) => &self.notes, - _ => { - let x: Vec = self - .fields - .iter() - .map(|f| { - if let Some(name) = &f.name { - if name.to_lowercase().contains(field) { - f.clone().value.unwrap_or("".to_string()) - } else { - "".to_string() - } + Ok(Field::Fingerprint) => vec![fingerprint.clone()], + Ok(Field::PublicKey) => vec![self.get_short()], + Ok(Field::PrivateKey) => vec![private_key.clone()], + Ok(Field::Notes) => vec![self.notes.clone()], + _ => self + .fields + .iter() + .map(|f| { + if let Some(name) = &f.name { + if name.to_lowercase().contains(field) { + f.value.clone() } else { - "".to_string() + None } - }) - .collect(); - - &Some(x.join("\n")) - } + } else { + None + } + }) + .collect(), }, }; - ret.clone() + ret.into_iter().flatten().collect() } fn display_field(&self, desc: &str, field: &str, clipboard: bool) { let field = field.to_lowercase(); let field = field.as_str(); - match &self.data { - DecryptedData::Login { - username, - totp, - uris, - .. - } => match field.parse() { - Ok(Field::Notes) => { - if let Some(notes) = &self.notes { - val_display_or_store(clipboard, notes); - } - } - Ok(Field::Username) => { - if let Some(username) = &username { - val_display_or_store(clipboard, username); - } - } - Ok(Field::Totp) => { - if let Some(totp) = totp { - match generate_totp(totp) { - Ok(code) => { - val_display_or_store(clipboard, &code); - } - Err(e) => { - eprintln!("{e}"); - } - } - } - } - Ok(Field::Uris) => { - if let Some(uris) = uris { - let uri_strs: Vec<_> = uris.iter().map(|uri| uri.uri.clone()).collect(); - val_display_or_store(clipboard, &uri_strs.join("\n")); - } - } - Ok(Field::Password) => { - self.display_short(desc, clipboard); - } - _ => { - for f in &self.fields { - if let Some(name) = &f.name { - if name.to_lowercase().as_str().contains(field) { - val_display_or_store(clipboard, f.value.as_deref().unwrap_or("")); - break; - } - } - } - } - }, - DecryptedData::Card { - cardholder_name, - brand, - exp_month, - exp_year, - code, - .. - } => match field.parse() { - Ok(Field::CardNumber) => { - self.display_short(desc, clipboard); - } - Ok(Field::Expiration) => { - if let (Some(month), Some(year)) = (exp_month, exp_year) { - val_display_or_store(clipboard, &format!("{month}/{year}")); - } - } - Ok(Field::ExpMonth) => { - if let Some(exp_month) = exp_month { - val_display_or_store(clipboard, exp_month); - } - } - Ok(Field::ExpYear) => { - if let Some(exp_year) = exp_year { - val_display_or_store(clipboard, exp_year); - } - } - Ok(Field::Cvv) => { - if let Some(code) = code { - val_display_or_store(clipboard, code); - } - } - Ok(Field::Name | Field::Cardholder) => { - if let Some(cardholder_name) = cardholder_name { - val_display_or_store(clipboard, cardholder_name); - } - } - Ok(Field::Brand) => { - if let Some(brand) = brand { - val_display_or_store(clipboard, brand); - } - } - Ok(Field::Notes) => { - if let Some(notes) = &self.notes { - val_display_or_store(clipboard, notes); - } - } - _ => { - for f in &self.fields { - if let Some(name) = &f.name { - if name.to_lowercase().as_str().contains(field) { - val_display_or_store(clipboard, f.value.as_deref().unwrap_or("")); - break; - } - } - } - } - }, - DecryptedData::Identity { - address1, - address2, - address3, - city, - state, - postal_code, - country, - phone, - email, - ssn, - license_number, - passport_number, - username, - .. - } => match field.parse() { - Ok(Field::Name) => { - self.display_short(desc, clipboard); - } - Ok(Field::Email) => { - if let Some(email) = email { - val_display_or_store(clipboard, email); - } - } - Ok(Field::Address) => { - let mut strs = vec![]; - if let Some(address1) = address1 { - strs.push(address1.clone()); - } - if let Some(address2) = address2 { - strs.push(address2.clone()); - } - if let Some(address3) = address3 { - strs.push(address3.clone()); - } - if !strs.is_empty() { - val_display_or_store(clipboard, &strs.join("\n")); - } - } - Ok(Field::City) => { - if let Some(city) = city { - val_display_or_store(clipboard, city); - } - } - Ok(Field::State) => { - if let Some(state) = state { - val_display_or_store(clipboard, state); - } - } - Ok(Field::PostalCode) => { - if let Some(postal_code) = postal_code { - val_display_or_store(clipboard, postal_code); - } - } - Ok(Field::Country) => { - if let Some(country) = country { - val_display_or_store(clipboard, country); - } - } - Ok(Field::Phone) => { - if let Some(phone) = phone { - val_display_or_store(clipboard, phone); - } - } - Ok(Field::Ssn) => { - if let Some(ssn) = ssn { - val_display_or_store(clipboard, ssn); - } - } - Ok(Field::License) => { - if let Some(license_number) = license_number { - val_display_or_store(clipboard, license_number); - } - } - Ok(Field::Passport) => { - if let Some(passport_number) = passport_number { - val_display_or_store(clipboard, passport_number); - } - } - Ok(Field::Username) => { - if let Some(username) = username { - val_display_or_store(clipboard, username); - } - } - Ok(Field::Notes) => { - if let Some(notes) = &self.notes { - val_display_or_store(clipboard, notes); - } - } - _ => { - for f in &self.fields { - if let Some(name) = &f.name { - if name.to_lowercase().as_str().contains(field) { - val_display_or_store(clipboard, f.value.as_deref().unwrap_or("")); - break; - } - } - } - } - }, - DecryptedData::SecureNote => match field.parse() { - Ok(Field::Notes) => { - self.display_short(desc, clipboard); - } - _ => { - for f in &self.fields { - if let Some(name) = &f.name { - if name.to_lowercase().as_str().contains(field) { - val_display_or_store(clipboard, f.value.as_deref().unwrap_or("")); - break; - } - } - } - } - }, - DecryptedData::SshKey { - fingerprint, - private_key, - .. - } => match field.parse() { - Ok(Field::Fingerprint) => { - if let Some(fingerprint) = fingerprint { - val_display_or_store(clipboard, fingerprint); - } - } - Ok(Field::PublicKey) => { - self.display_short(desc, clipboard); - } - Ok(Field::PrivateKey) => { - if let Some(private_key) = private_key { - val_display_or_store(clipboard, private_key); - } - } - Ok(Field::Notes) => { - if let Some(notes) = &self.notes { - val_display_or_store(clipboard, notes); - } - } - _ => { - for f in &self.fields { - if let Some(name) = &f.name { - if name.to_lowercase().as_str().contains(field) { - val_display_or_store(clipboard, f.value.as_deref().unwrap_or("")); - break; - } - } - } - } - }, - } + let fields = self.get_fields(field); + fields.iter().for_each(|f| { + val_display_or_store(clipboard, f); + }); } fn display_long(&self, desc: &str, clipboard: bool) { From 1e0a68d7a5fcd6326ae96293c4be561969540d5c Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 9 May 2026 17:43:42 +0200 Subject: [PATCH 030/273] shrink fields variable assignment --- src/bin/rbw/commands.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 1f0a5435..8e183d1f 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -616,9 +616,7 @@ impl DecryptedCipher { } fn display_field(&self, desc: &str, field: &str, clipboard: bool) { - let field = field.to_lowercase(); - let field = field.as_str(); - let fields = self.get_fields(field); + let fields = self.get_fields(&field.to_lowercase()); fields.iter().for_each(|f| { val_display_or_store(clipboard, f); }); From 8d17949807acdead57272ad02c24f17e284763f1 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 9 May 2026 18:10:24 +0200 Subject: [PATCH 031/273] move DecryptedData and DecryptedField up and rename fields to custom_fields --- src/bin/rbw/commands.rs | 128 ++++++++++++++++++++-------------------- 1 file changed, 64 insertions(+), 64 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 8e183d1f..ce288f93 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -332,6 +332,60 @@ impl From for DecryptedListCipher { } } +#[derive(Debug, Clone, serde::Serialize)] +#[serde(untagged)] +#[cfg_attr(test, derive(Eq, PartialEq))] +enum DecryptedData { + Login { + username: Option, + password: Option, + totp: Option, + uris: Option>, + }, + Card { + cardholder_name: Option, + number: Option, + brand: Option, + exp_month: Option, + exp_year: Option, + code: Option, + }, + Identity { + title: Option, + first_name: Option, + middle_name: Option, + last_name: Option, + address1: Option, + address2: Option, + address3: Option, + city: Option, + state: Option, + postal_code: Option, + country: Option, + phone: Option, + email: Option, + ssn: Option, + license_number: Option, + passport_number: Option, + username: Option, + }, + SecureNote, + SshKey { + public_key: Option, + fingerprint: Option, + private_key: Option, + }, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[cfg_attr(test, derive(Eq, PartialEq))] +struct DecryptedField { + name: Option, + value: Option, + #[serde(serialize_with = "serialize_field_type", rename = "type")] + ty: Option, +} + #[derive(Debug, Clone, serde::Serialize)] #[cfg_attr(test, derive(Eq, PartialEq))] struct DecryptedCipher { @@ -339,7 +393,7 @@ struct DecryptedCipher { folder: Option, name: String, data: DecryptedData, - fields: Vec, + custom_fields: Vec, notes: Option, history: Vec, } @@ -437,7 +491,7 @@ impl DecryptedCipher { vec![self.get_short()] } _ => { - self.fields + self.custom_fields .iter() .map(|f| { if let Some(name) = &f.name { @@ -486,7 +540,7 @@ impl DecryptedCipher { Ok(Field::Brand) => vec![brand.clone()], Ok(Field::Notes) => vec![self.notes.clone()], _ => self - .fields + .custom_fields .iter() .map(|f| { if let Some(name) = &f.name { @@ -550,7 +604,7 @@ impl DecryptedCipher { Ok(Field::Username) => vec![username.clone()], Ok(Field::Notes) => vec![self.notes.clone()], _ => self - .fields + .custom_fields .iter() .map(|f| { if let Some(name) = &f.name { @@ -569,7 +623,7 @@ impl DecryptedCipher { DecryptedData::SecureNote => match field.parse() { Ok(Field::Notes) => vec![self.get_short()], _ => self - .fields + .custom_fields .iter() .map(|f| { if let Some(name) = &f.name { @@ -595,7 +649,7 @@ impl DecryptedCipher { Ok(Field::PrivateKey) => vec![private_key.clone()], Ok(Field::Notes) => vec![self.notes.clone()], _ => self - .fields + .custom_fields .iter() .map(|f| { if let Some(name) = &f.name { @@ -642,7 +696,7 @@ impl DecryptedCipher { } } - for field in &self.fields { + for field in &self.custom_fields { displayed |= display_field( field.name.as_deref().unwrap_or("(null)"), Some(field.value.as_deref().unwrap_or("")), @@ -700,7 +754,7 @@ impl DecryptedCipher { DecryptedData::SshKey { fingerprint, .. } => { displayed |= display_field("Fingerprint", fingerprint.as_deref(), clipboard); - for field in &self.fields { + for field in &self.custom_fields { displayed |= display_field( field.name.as_deref().unwrap_or("(null)"), Some(field.value.as_deref().unwrap_or("")), @@ -853,7 +907,7 @@ impl DecryptedCipher { if self.notes.is_some() { println!("{}", Field::Notes); } - for f in &self.fields { + for f in &self.custom_fields { if let Some(name) = &f.name { println!("{name}"); } @@ -884,60 +938,6 @@ fn val_display_or_store(clipboard: bool, password: &str) -> bool { } } -#[derive(Debug, Clone, serde::Serialize)] -#[serde(untagged)] -#[cfg_attr(test, derive(Eq, PartialEq))] -enum DecryptedData { - Login { - username: Option, - password: Option, - totp: Option, - uris: Option>, - }, - Card { - cardholder_name: Option, - number: Option, - brand: Option, - exp_month: Option, - exp_year: Option, - code: Option, - }, - Identity { - title: Option, - first_name: Option, - middle_name: Option, - last_name: Option, - address1: Option, - address2: Option, - address3: Option, - city: Option, - state: Option, - postal_code: Option, - country: Option, - phone: Option, - email: Option, - ssn: Option, - license_number: Option, - passport_number: Option, - username: Option, - }, - SecureNote, - SshKey { - public_key: Option, - fingerprint: Option, - private_key: Option, - }, -} - -#[derive(Debug, Clone, serde::Serialize)] -#[cfg_attr(test, derive(Eq, PartialEq))] -struct DecryptedField { - name: Option, - value: Option, - #[serde(serialize_with = "serialize_field_type", rename = "type")] - ty: Option, -} - #[allow(clippy::trivially_copy_pass_by_ref, clippy::ref_option)] fn serialize_field_type( ty: &Option, @@ -2415,7 +2415,7 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { folder, name: crate::actions::decrypt(&entry.name, entry.key.as_deref(), entry.org_id.as_deref())?, data, - fields, + custom_fields: fields, notes, history, }) From e1c953be0583b2c5ba2a16759443b4ea515b68ca Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 9 May 2026 18:41:01 +0200 Subject: [PATCH 032/273] remove duplicated structure DecryptedData and substitute it with db::EntryData and rename structures around --- src/bin/rbw/commands.rs | 158 ++++++++++++++-------------------------- 1 file changed, 54 insertions(+), 104 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index ce288f93..d3c14438 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -9,6 +9,7 @@ use std::{ }; use anyhow::Context as _; +use rbw::db::{EntryData, Uri}; // The default number of seconds the generated TOTP // code lasts for before a new one must be generated @@ -332,78 +333,35 @@ impl From for DecryptedListCipher { } } +/// Custom field of each Record. #[derive(Debug, Clone, serde::Serialize)] -#[serde(untagged)] #[cfg_attr(test, derive(Eq, PartialEq))] -enum DecryptedData { - Login { - username: Option, - password: Option, - totp: Option, - uris: Option>, - }, - Card { - cardholder_name: Option, - number: Option, - brand: Option, - exp_month: Option, - exp_year: Option, - code: Option, - }, - Identity { - title: Option, - first_name: Option, - middle_name: Option, - last_name: Option, - address1: Option, - address2: Option, - address3: Option, - city: Option, - state: Option, - postal_code: Option, - country: Option, - phone: Option, - email: Option, - ssn: Option, - license_number: Option, - passport_number: Option, - username: Option, - }, - SecureNote, - SshKey { - public_key: Option, - fingerprint: Option, - private_key: Option, - }, -} - -#[derive(Debug, Clone, serde::Serialize)] -#[cfg_attr(test, derive(Eq, PartialEq))] -struct DecryptedField { +struct CustomField { name: Option, value: Option, #[serde(serialize_with = "serialize_field_type", rename = "type")] ty: Option, } +/// Structure that represents a decrypted entry. #[derive(Debug, Clone, serde::Serialize)] #[cfg_attr(test, derive(Eq, PartialEq))] -struct DecryptedCipher { +struct LocalEntry { id: String, folder: Option, name: String, - data: DecryptedData, - custom_fields: Vec, + data: EntryData, + custom_fields: Vec, notes: Option, history: Vec, } -impl DecryptedCipher { +impl LocalEntry { fn get_short(&self) -> Option { match &self.data { - DecryptedData::Login { password, .. } => password.clone(), - DecryptedData::Card { number, .. } => number.clone(), - DecryptedData::Identity { + EntryData::Login { password, .. } => password.clone(), + EntryData::Card { number, .. } => number.clone(), + EntryData::Identity { title, first_name, middle_name, @@ -423,8 +381,8 @@ impl DecryptedCipher { Some(names.join(" ")) } } - DecryptedData::SecureNote => self.notes.clone(), - DecryptedData::SshKey { public_key, .. } => public_key.clone(), + EntryData::SecureNote => self.notes.clone(), + EntryData::SshKey { public_key, .. } => public_key.clone(), } } @@ -435,11 +393,11 @@ impl DecryptedCipher { eprintln!( "entry for '{desc}' had no {}", match &self.data { - DecryptedData::Login { .. } => "password", - DecryptedData::Card { .. } => "card number", - DecryptedData::Identity { .. } => "name", - DecryptedData::SecureNote => "notes", - DecryptedData::SshKey { .. } => "public key", + EntryData::Login { .. } => "password", + EntryData::Card { .. } => "card number", + EntryData::Identity { .. } => "name", + EntryData::SecureNote => "notes", + EntryData::SshKey { .. } => "public key", } ); return false; @@ -451,7 +409,7 @@ impl DecryptedCipher { /// This function is sh*t but I need it for now fn get_fields(&self, field: &str) -> Vec { let ret: Vec> = match &self.data { - DecryptedData::Login { + EntryData::Login { username, totp, uris, @@ -478,7 +436,7 @@ impl DecryptedCipher { } } Ok(Field::Uris) => { - if let Some(uris) = uris { + if !uris.is_empty() { let uri_strs: Vec<_> = uris.iter().map(|uri| uri.uri.clone()).collect(); // val_display_or_store(clipboard, &uri_strs.join("\n")); vec![Some(uri_strs.join("\n"))] @@ -516,7 +474,7 @@ impl DecryptedCipher { // } } }, - DecryptedData::Card { + EntryData::Card { cardholder_name, brand, exp_month, @@ -555,7 +513,7 @@ impl DecryptedCipher { }) .collect(), }, - DecryptedData::Identity { + EntryData::Identity { address1, address2, address3, @@ -620,7 +578,7 @@ impl DecryptedCipher { .collect(), }, - DecryptedData::SecureNote => match field.parse() { + EntryData::SecureNote => match field.parse() { Ok(Field::Notes) => vec![self.get_short()], _ => self .custom_fields @@ -639,7 +597,7 @@ impl DecryptedCipher { .collect(), }, - DecryptedData::SshKey { + EntryData::SshKey { fingerprint, private_key, .. @@ -679,7 +637,7 @@ impl DecryptedCipher { fn display_long(&self, desc: &str, clipboard: bool) { let mut displayed = self.display_short(desc, clipboard); match &self.data { - DecryptedData::Login { + EntryData::Login { username, totp, uris, @@ -688,12 +646,10 @@ impl DecryptedCipher { displayed |= display_field("Username", username.as_deref(), clipboard); displayed |= display_field("TOTP Secret", totp.as_deref(), clipboard); - if let Some(uris) = uris { - for uri in uris { - displayed |= display_field("URI", Some(&uri.uri), clipboard); - let match_type = uri.match_type.map(|ty| format!("{ty}")); - displayed |= display_field("Match type", match_type.as_deref(), clipboard); - } + for uri in uris { + displayed |= display_field("URI", Some(&uri.uri), clipboard); + let match_type = uri.match_type.map(|ty| format!("{ty}")); + displayed |= display_field("Match type", match_type.as_deref(), clipboard); } for field in &self.custom_fields { @@ -704,7 +660,7 @@ impl DecryptedCipher { ); } } - DecryptedData::Card { + EntryData::Card { cardholder_name, brand, exp_month, @@ -720,7 +676,7 @@ impl DecryptedCipher { displayed |= display_field("Name", cardholder_name.as_deref(), clipboard); displayed |= display_field("Brand", brand.as_deref(), clipboard); } - DecryptedData::Identity { + EntryData::Identity { address1, address2, address3, @@ -750,8 +706,8 @@ impl DecryptedCipher { displayed |= display_field("Passport", passport_number.as_deref(), clipboard); displayed |= display_field("Username", username.as_deref(), clipboard); } - DecryptedData::SecureNote => {} - DecryptedData::SshKey { fingerprint, .. } => { + EntryData::SecureNote => {} + EntryData::SshKey { fingerprint, .. } => { displayed |= display_field("Fingerprint", fingerprint.as_deref(), clipboard); for field in &self.custom_fields { @@ -764,7 +720,7 @@ impl DecryptedCipher { } } - if !matches!(&self.data, DecryptedData::SecureNote) { + if !matches!(&self.data, EntryData::SecureNote) { if let Some(notes) = &self.notes { if displayed { println!(); @@ -777,7 +733,7 @@ impl DecryptedCipher { /// This implementation mirror the `fn display_fied` method on which field to list fn display_fields_list(&self) { match &self.data { - DecryptedData::Login { + EntryData::Login { username, password, totp, @@ -790,14 +746,14 @@ impl DecryptedCipher { if totp.is_some() { println!("{}", Field::Totp); } - if uris.is_some() { + if !uris.is_empty() { println!("{}", Field::Uris); } if password.is_some() { println!("{}", Field::Password); } } - DecryptedData::Card { + EntryData::Card { cardholder_name, number, brand, @@ -826,7 +782,7 @@ impl DecryptedCipher { } } - DecryptedData::Identity { + EntryData::Identity { address1, address2, address3, @@ -889,8 +845,8 @@ impl DecryptedCipher { } } - DecryptedData::SecureNote => (), // handled at the end - DecryptedData::SshKey { + EntryData::SecureNote => (), // handled at the end + EntryData::SshKey { fingerprint, public_key, .. @@ -967,13 +923,6 @@ struct DecryptedHistoryEntry { password: String, } -#[derive(Debug, Clone, serde::Serialize)] -#[cfg_attr(test, derive(Eq, PartialEq))] -struct DecryptedUri { - uri: String, - match_type: Option, -} - fn matches_url( url: &str, match_type: Option, @@ -1402,7 +1351,7 @@ pub fn code( let (_, decrypted) = find_entry(&db, needle, user, folder, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; - if let DecryptedData::Login { totp, .. } = decrypted.data { + if let EntryData::Login { totp, .. } = decrypted.data { if let Some(totp) = totp { val_display_or_store(clipboard, &generate_totp(&totp)?); } else { @@ -1627,7 +1576,7 @@ pub fn edit( .with_context(|| format!("couldn't find entry for '{desc}'"))?; let (data, fields, notes, history) = match &decrypted.data { - DecryptedData::Login { password, .. } => { + EntryData::Login { password, .. } => { let mut contents = format!("{}\n", password.as_deref().unwrap_or("")); if let Some(notes) = decrypted.notes { write!(contents, "\n{notes}\n").unwrap(); @@ -1669,7 +1618,7 @@ pub fn edit( }; (data, entry.fields, notes, history) } - DecryptedData::SecureNote => { + EntryData::SecureNote => { let data = rbw::db::EntryData::SecureNote {}; let editor_content = decrypted @@ -1851,7 +1800,7 @@ fn find_entry( username: Option<&str>, folder: Option<&str>, ignore_case: bool, -) -> anyhow::Result<(rbw::db::Entry, DecryptedCipher)> { +) -> anyhow::Result<(rbw::db::Entry, LocalEntry)> { if let Needle::Uuid(uuid, s) = needle { for cipher in &db.entries { if uuid::Uuid::parse_str(&cipher.id) == Ok(uuid) { @@ -2106,7 +2055,7 @@ fn decrypt_search_cipher(entry: &rbw::db::Entry) -> anyhow::Result anyhow::Result { +fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { // folder name should always be decrypted with the local key because // folders are local to a specific user's vault, not the organization let folder = entry @@ -2125,7 +2074,7 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { .fields .iter() .map(|field| { - Ok(DecryptedField { + Ok(CustomField { name: field .name .as_ref() @@ -2181,7 +2130,7 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { password, totp, uris, - } => DecryptedData::Login { + } => EntryData::Login { username: decrypt_field( Field::Username, username.as_deref(), @@ -2209,11 +2158,12 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { entry.key.as_deref(), entry.org_id.as_deref(), ) - .map(|uri| DecryptedUri { + .map(|uri| Uri { uri, match_type: s.match_type, }) }) + .flatten() .collect(), }, rbw::db::EntryData::Card { @@ -2223,7 +2173,7 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { exp_month, exp_year, code, - } => DecryptedData::Card { + } => EntryData::Card { cardholder_name: decrypt_field( Field::Cardholder, cardholder_name.as_deref(), @@ -2279,7 +2229,7 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { license_number, passport_number, username, - } => DecryptedData::Identity { + } => EntryData::Identity { title: decrypt_field( Field::Title, title.as_deref(), @@ -2383,12 +2333,12 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { entry.org_id.as_deref(), ), }, - rbw::db::EntryData::SecureNote => DecryptedData::SecureNote {}, + rbw::db::EntryData::SecureNote => EntryData::SecureNote {}, rbw::db::EntryData::SshKey { public_key, fingerprint, private_key, - } => DecryptedData::SshKey { + } => EntryData::SshKey { public_key: decrypt_field( Field::PublicKey, public_key.as_deref(), @@ -2410,7 +2360,7 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { }, }; - Ok(DecryptedCipher { + Ok(LocalEntry { id: entry.id.clone(), folder, name: crate::actions::decrypt(&entry.name, entry.key.as_deref(), entry.org_id.as_deref())?, From 3e4231bd00ac2782a152974eae6821ed02113826 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 9 May 2026 19:45:53 +0200 Subject: [PATCH 033/273] decouple Entry from commands Display logic has been transferred inside db, but it's temporary as it needs to be decoupled too. --- src/bin/rbw/commands.rs | 793 +++------------------------------------- src/db.rs | 788 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 779 insertions(+), 802 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index d3c14438..3009c783 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -4,7 +4,6 @@ use std::{ io::Write as _, os::unix::ffi::OsStrExt as _, path::PathBuf, - str::FromStr, time::SystemTime, }; @@ -57,130 +56,6 @@ pub fn parse_needle(arg: &str) -> Result { Ok(Needle::Name(arg.to_string())) } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -enum Field { - Notes, - Username, - Password, - Totp, - Uris, - IdentityName, - City, - State, - PostalCode, - Country, - Phone, - Ssn, - License, - Passport, - CardNumber, - Expiration, - ExpMonth, - ExpYear, - Cvv, - Cardholder, - Brand, - Name, - Email, - Address, - Address1, - Address2, - Address3, - Fingerprint, - PublicKey, - PrivateKey, - Title, - FirstName, - MiddleName, - LastName, -} - -impl FromStr for Field { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result { - Ok(match s.to_lowercase().as_str() { - "notes" | "note" => Self::Notes, - "username" | "user" => Self::Username, - "password" => Self::Password, - "totp" | "code" => Self::Totp, - "uris" | "urls" | "sites" => Self::Uris, - "identityname" => Self::IdentityName, - "city" => Self::City, - "state" => Self::State, - "postcode" | "zipcode" | "zip" => Self::PostalCode, - "country" => Self::Country, - "phone" => Self::Phone, - "ssn" => Self::Ssn, - "license" => Self::License, - "passport" => Self::Passport, - "number" | "card" => Self::CardNumber, - "exp" => Self::Expiration, - "exp_month" | "month" => Self::ExpMonth, - "exp_year" | "year" => Self::ExpYear, - // the word "code" got preceeded by Totp - "cvv" => Self::Cvv, - "cardholder" | "cardholder_name" => Self::Cardholder, - "brand" | "type" => Self::Brand, - "name" => Self::Name, - "email" => Self::Email, - "address1" => Self::Address1, - "address2" => Self::Address2, - "address3" => Self::Address3, - "address" => Self::Address, - "fingerprint" => Self::Fingerprint, - "public_key" => Self::PublicKey, - "private_key" => Self::PrivateKey, - "title" => Self::Title, - "first_name" => Self::FirstName, - "middle_name" => Self::MiddleName, - "last_name" => Self::LastName, - _ => anyhow::bail!("unknown field {s}"), - }) - } -} - -impl Display for Field { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::Notes => "notes", - Self::Username => "username", - Self::Password => "password", - Self::Totp => "totp", - Self::Uris => "uris", - Self::IdentityName => "identityname", - Self::City => "city", - Self::State => "state", - Self::PostalCode => "postcode", - Self::Country => "country", - Self::Phone => "phone", - Self::Ssn => "ssn", - Self::License => "license", - Self::Passport => "passport", - Self::CardNumber => "number", - Self::Expiration => "exp", - Self::ExpMonth => "exp_month", - Self::ExpYear => "exp_year", - Self::Cvv => "cvv", - Self::Cardholder => "cardholder", - Self::Brand => "brand", - Self::Name => "name", - Self::Email => "email", - Self::Address1 => "address1", - Self::Address2 => "address2", - Self::Address3 => "address3", - Self::Address => "address", - Self::Fingerprint => "fingerprint", - Self::PublicKey => "public_key", - Self::PrivateKey => "private_key", - Self::Title => "title", - Self::FirstName => "first_name", - Self::MiddleName => "middle_name", - Self::LastName => "last_name", - }) - } -} - #[derive(Debug, serde::Serialize)] struct DecryptedListCipher { id: String, @@ -333,552 +208,6 @@ impl From for DecryptedListCipher { } } -/// Custom field of each Record. -#[derive(Debug, Clone, serde::Serialize)] -#[cfg_attr(test, derive(Eq, PartialEq))] -struct CustomField { - name: Option, - value: Option, - #[serde(serialize_with = "serialize_field_type", rename = "type")] - ty: Option, -} - -/// Structure that represents a decrypted entry. -#[derive(Debug, Clone, serde::Serialize)] -#[cfg_attr(test, derive(Eq, PartialEq))] -struct LocalEntry { - id: String, - folder: Option, - name: String, - data: EntryData, - custom_fields: Vec, - notes: Option, - history: Vec, -} - -impl LocalEntry { - fn get_short(&self) -> Option { - match &self.data { - EntryData::Login { password, .. } => password.clone(), - EntryData::Card { number, .. } => number.clone(), - EntryData::Identity { - title, - first_name, - middle_name, - last_name, - .. - } => { - let names: Vec = [title, first_name, middle_name, last_name] - .iter() - .copied() - .flatten() - .cloned() - .collect(); - - if names.is_empty() { - None - } else { - Some(names.join(" ")) - } - } - EntryData::SecureNote => self.notes.clone(), - EntryData::SshKey { public_key, .. } => public_key.clone(), - } - } - - fn display_short(&self, desc: &str, clipboard: bool) -> bool { - let short = self.get_short(); - let Some(short) = short else { - // Would be cool if self.data had a method named main_field_name :D - eprintln!( - "entry for '{desc}' had no {}", - match &self.data { - EntryData::Login { .. } => "password", - EntryData::Card { .. } => "card number", - EntryData::Identity { .. } => "name", - EntryData::SecureNote => "notes", - EntryData::SshKey { .. } => "public key", - } - ); - return false; - }; - - val_display_or_store(clipboard, &short) - } - - /// This function is sh*t but I need it for now - fn get_fields(&self, field: &str) -> Vec { - let ret: Vec> = match &self.data { - EntryData::Login { - username, - totp, - uris, - .. - } => match field.parse() { - Ok(Field::Notes) => vec![self.notes.clone()], - Ok(Field::Username) => vec![username.clone()], - - Ok(Field::Totp) => { - if let Some(totp) = totp { - match generate_totp(totp) { - Ok(code) => { - vec![Some(code)] - - // val_display_or_store(clipboard, &code); - } - Err(e) => { - eprintln!("{e}"); - vec![] - } - } - } else { - vec![] - } - } - Ok(Field::Uris) => { - if !uris.is_empty() { - let uri_strs: Vec<_> = uris.iter().map(|uri| uri.uri.clone()).collect(); - // val_display_or_store(clipboard, &uri_strs.join("\n")); - vec![Some(uri_strs.join("\n"))] - } else { - vec![] - } - } - Ok(Field::Password) => { - // self.display_short(desc, clipboard); - vec![self.get_short()] - } - _ => { - self.custom_fields - .iter() - .map(|f| { - if let Some(name) = &f.name { - if name.to_lowercase().contains(field) { - f.value.clone() - } else { - None - } - } else { - None - } - }) - .collect() - - // for f in &self.fields { - // if let Some(name) = &f.name { - // if name.to_lowercase().as_str().contains(field) { - // val_display_or_store(clipboard, f.value.as_deref().unwrap_or("")); - // break; - // } - // } - // } - } - }, - EntryData::Card { - cardholder_name, - brand, - exp_month, - exp_year, - code, - .. - } => match field.parse() { - Ok(Field::CardNumber) => vec![self.get_short()], - Ok(Field::Expiration) => { - if let (Some(month), Some(year)) = (exp_month, exp_year) { - vec![Some(format!("{month}/{year}"))] - //val_display_or_store(clipboard, &format!("{month}/{year}")); - } else { - vec![] - } - } - Ok(Field::ExpMonth) => vec![exp_month.clone()], - Ok(Field::ExpYear) => vec![exp_year.clone()], - Ok(Field::Cvv) => vec![code.clone()], - Ok(Field::Name | Field::Cardholder) => vec![cardholder_name.clone()], - Ok(Field::Brand) => vec![brand.clone()], - Ok(Field::Notes) => vec![self.notes.clone()], - _ => self - .custom_fields - .iter() - .map(|f| { - if let Some(name) = &f.name { - if name.to_lowercase().contains(field) { - f.value.clone() - } else { - None - } - } else { - None - } - }) - .collect(), - }, - EntryData::Identity { - address1, - address2, - address3, - city, - state, - postal_code, - country, - phone, - email, - ssn, - license_number, - passport_number, - username, - .. - } => match field.parse() { - Ok(Field::Name) => vec![self.get_short()], - Ok(Field::Email) => vec![email.clone()], - Ok(Field::Address) => { - let mut strs = vec![]; - - if let Some(address1) = address1 { - strs.push(address1.clone()); - } - if let Some(address2) = address2 { - strs.push(address2.clone()); - } - if let Some(address3) = address3 { - strs.push(address3.clone()); - } - - if !strs.is_empty() { - vec![Some(strs.join("\n"))] - //val_display_or_store(clipboard, &strs.join("\n")); - } else { - vec![] - } - } - Ok(Field::City) => vec![city.clone()], - Ok(Field::State) => vec![state.clone()], - Ok(Field::PostalCode) => vec![postal_code.clone()], - Ok(Field::Country) => vec![country.clone()], - Ok(Field::Phone) => vec![phone.clone()], - Ok(Field::Ssn) => vec![ssn.clone()], - Ok(Field::License) => vec![license_number.clone()], - Ok(Field::Passport) => vec![passport_number.clone()], - Ok(Field::Username) => vec![username.clone()], - Ok(Field::Notes) => vec![self.notes.clone()], - _ => self - .custom_fields - .iter() - .map(|f| { - if let Some(name) = &f.name { - if name.to_lowercase().contains(field) { - f.value.clone() - } else { - None - } - } else { - None - } - }) - .collect(), - }, - - EntryData::SecureNote => match field.parse() { - Ok(Field::Notes) => vec![self.get_short()], - _ => self - .custom_fields - .iter() - .map(|f| { - if let Some(name) = &f.name { - if name.to_lowercase().contains(field) { - f.value.clone() - } else { - None - } - } else { - None - } - }) - .collect(), - }, - - EntryData::SshKey { - fingerprint, - private_key, - .. - } => match field.parse() { - Ok(Field::Fingerprint) => vec![fingerprint.clone()], - Ok(Field::PublicKey) => vec![self.get_short()], - Ok(Field::PrivateKey) => vec![private_key.clone()], - Ok(Field::Notes) => vec![self.notes.clone()], - _ => self - .custom_fields - .iter() - .map(|f| { - if let Some(name) = &f.name { - if name.to_lowercase().contains(field) { - f.value.clone() - } else { - None - } - } else { - None - } - }) - .collect(), - }, - }; - - ret.into_iter().flatten().collect() - } - - fn display_field(&self, desc: &str, field: &str, clipboard: bool) { - let fields = self.get_fields(&field.to_lowercase()); - fields.iter().for_each(|f| { - val_display_or_store(clipboard, f); - }); - } - - fn display_long(&self, desc: &str, clipboard: bool) { - let mut displayed = self.display_short(desc, clipboard); - match &self.data { - EntryData::Login { - username, - totp, - uris, - .. - } => { - displayed |= display_field("Username", username.as_deref(), clipboard); - displayed |= display_field("TOTP Secret", totp.as_deref(), clipboard); - - for uri in uris { - displayed |= display_field("URI", Some(&uri.uri), clipboard); - let match_type = uri.match_type.map(|ty| format!("{ty}")); - displayed |= display_field("Match type", match_type.as_deref(), clipboard); - } - - for field in &self.custom_fields { - displayed |= display_field( - field.name.as_deref().unwrap_or("(null)"), - Some(field.value.as_deref().unwrap_or("")), - clipboard, - ); - } - } - EntryData::Card { - cardholder_name, - brand, - exp_month, - exp_year, - code, - .. - } => { - if let (Some(exp_month), Some(exp_year)) = (exp_month, exp_year) { - println!("Expiration: {exp_month}/{exp_year}"); - displayed = true; - } - displayed |= display_field("CVV", code.as_deref(), clipboard); - displayed |= display_field("Name", cardholder_name.as_deref(), clipboard); - displayed |= display_field("Brand", brand.as_deref(), clipboard); - } - EntryData::Identity { - address1, - address2, - address3, - city, - state, - postal_code, - country, - phone, - email, - ssn, - license_number, - passport_number, - username, - .. - } => { - displayed |= display_field("Address", address1.as_deref(), clipboard); - displayed |= display_field("Address", address2.as_deref(), clipboard); - displayed |= display_field("Address", address3.as_deref(), clipboard); - displayed |= display_field("City", city.as_deref(), clipboard); - displayed |= display_field("State", state.as_deref(), clipboard); - displayed |= display_field("Postcode", postal_code.as_deref(), clipboard); - displayed |= display_field("Country", country.as_deref(), clipboard); - displayed |= display_field("Phone", phone.as_deref(), clipboard); - displayed |= display_field("Email", email.as_deref(), clipboard); - displayed |= display_field("SSN", ssn.as_deref(), clipboard); - displayed |= display_field("License", license_number.as_deref(), clipboard); - displayed |= display_field("Passport", passport_number.as_deref(), clipboard); - displayed |= display_field("Username", username.as_deref(), clipboard); - } - EntryData::SecureNote => {} - EntryData::SshKey { fingerprint, .. } => { - displayed |= display_field("Fingerprint", fingerprint.as_deref(), clipboard); - - for field in &self.custom_fields { - displayed |= display_field( - field.name.as_deref().unwrap_or("(null)"), - Some(field.value.as_deref().unwrap_or("")), - clipboard, - ); - } - } - } - - if !matches!(&self.data, EntryData::SecureNote) { - if let Some(notes) = &self.notes { - if displayed { - println!(); - } - println!("{notes}"); - } - } - } - - /// This implementation mirror the `fn display_fied` method on which field to list - fn display_fields_list(&self) { - match &self.data { - EntryData::Login { - username, - password, - totp, - uris, - .. - } => { - if username.is_some() { - println!("{}", Field::Username); - } - if totp.is_some() { - println!("{}", Field::Totp); - } - if !uris.is_empty() { - println!("{}", Field::Uris); - } - if password.is_some() { - println!("{}", Field::Password); - } - } - EntryData::Card { - cardholder_name, - number, - brand, - exp_month, - exp_year, - code, - .. - } => { - if number.is_some() { - println!("{}", Field::CardNumber); - } - if exp_month.is_some() { - println!("{}", Field::ExpMonth); - } - if exp_year.is_some() { - println!("{}", Field::ExpYear); - } - if code.is_some() { - println!("{}", Field::Cvv); - } - if cardholder_name.is_some() { - println!("{}", Field::Cardholder); - } - if brand.is_some() { - println!("{}", Field::Brand); - } - } - - EntryData::Identity { - address1, - address2, - address3, - city, - state, - postal_code, - country, - phone, - email, - ssn, - license_number, - passport_number, - username, - title, - first_name, - middle_name, - last_name, - .. - } => { - if [title, first_name, middle_name, last_name] - .iter() - .any(|f| f.is_some()) - { - // the display_field combines all these fields together. - println!("name"); - } - if email.is_some() { - println!("{}", Field::Email); - } - if [address1, address2, address3].iter().any(|f| f.is_some()) { - // the display_field combines all these fields together. - println!("address"); - } - if city.is_some() { - println!("{}", Field::City); - } - if state.is_some() { - println!("{}", Field::State); - } - if postal_code.is_some() { - println!("{}", Field::PostalCode); - } - if country.is_some() { - println!("{}", Field::Country); - } - if phone.is_some() { - println!("{}", Field::Phone); - } - if ssn.is_some() { - println!("{}", Field::Ssn); - } - if license_number.is_some() { - println!("{}", Field::License); - } - if passport_number.is_some() { - println!("{}", Field::Passport); - } - if username.is_some() { - println!("{}", Field::Username); - } - } - - EntryData::SecureNote => (), // handled at the end - EntryData::SshKey { - fingerprint, - public_key, - .. - } => { - if fingerprint.is_some() { - println!("{}", Field::Fingerprint); - } - if public_key.is_some() { - println!("{}", Field::PublicKey); - } - } - } - - if self.notes.is_some() { - println!("{}", Field::Notes); - } - for f in &self.custom_fields { - if let Some(name) = &f.name { - println!("{name}"); - } - } - } - - fn display_json(&self, desc: &str) -> anyhow::Result<()> { - serde_json::to_writer_pretty(std::io::stdout(), &self) - .context(format!("failed to write entry '{desc}' to stdout"))?; - println!(); - - Ok(()) - } -} - fn val_display_or_store(clipboard: bool, password: &str) -> bool { if clipboard { match clipboard_store(password) { @@ -894,35 +223,6 @@ fn val_display_or_store(clipboard: bool, password: &str) -> bool { } } -#[allow(clippy::trivially_copy_pass_by_ref, clippy::ref_option)] -fn serialize_field_type( - ty: &Option, - serializer: S, -) -> Result -where - S: serde::Serializer, -{ - match ty { - Some(ty) => { - let s = match ty { - rbw::api::FieldType::Text => "text", - rbw::api::FieldType::Hidden => "hidden", - rbw::api::FieldType::Boolean => "boolean", - rbw::api::FieldType::Linked => "linked", - }; - serializer.serialize_some(&Some(s)) - } - None => serializer.serialize_none(), - } -} - -#[derive(Debug, Clone, serde::Serialize)] -#[cfg_attr(test, derive(Eq, PartialEq))] -struct DecryptedHistoryEntry { - last_used_date: String, - password: String, -} - fn matches_url( url: &str, match_type: Option, @@ -1228,11 +528,11 @@ pub fn get( // } // } if full { - decrypted.display_long(&desc, clipboard); + decrypted.display_long(&desc, clipboard, val_display_or_store, display_field); } else if let Some(field) = field { - decrypted.display_field(&desc, field, clipboard); + decrypted.display_field(&desc, field, clipboard, val_display_or_store, generate_totp); } else { - decrypted.display_short(&desc, clipboard); + decrypted.display_short(&desc, clipboard, val_display_or_store); } } @@ -1800,7 +1100,7 @@ fn find_entry( username: Option<&str>, folder: Option<&str>, ignore_case: bool, -) -> anyhow::Result<(rbw::db::Entry, LocalEntry)> { +) -> anyhow::Result<(rbw::db::Entry, rbw::db::Entry)> { if let Needle::Uuid(uuid, s) = needle { for cipher in &db.entries { if uuid::Uuid::parse_str(&cipher.id) == Ok(uuid) { @@ -1880,7 +1180,7 @@ fn find_entry_raw( } fn decrypt_field( - name: Field, + name: rbw::db::FieldType, field: Option<&str>, entry_key: Option<&str>, org_id: Option<&str>, @@ -1915,7 +1215,7 @@ fn decrypt_list_cipher( let user = if fields.contains(&ListField::User) { match &entry.data { rbw::db::EntryData::Login { username, .. } => decrypt_field( - Field::Username, + rbw::db::FieldType::Username, username.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), @@ -1942,7 +1242,7 @@ fn decrypt_list_cipher( uris.iter() .filter_map(|s| { decrypt_field( - Field::Uris, + rbw::db::FieldType::Uris, Some(&s.uri), entry.key.as_deref(), entry.org_id.as_deref(), @@ -1981,7 +1281,7 @@ fn decrypt_search_cipher(entry: &rbw::db::Entry) -> anyhow::Result decrypt_field( - Field::Username, + rbw::db::FieldType::Username, username.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), @@ -2004,7 +1304,7 @@ fn decrypt_search_cipher(entry: &rbw::db::Entry) -> anyhow::Result anyhow::Result anyhow::Result { +fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { // folder name should always be decrypted with the local key because // folders are local to a specific user's vault, not the organization let folder = entry @@ -2074,7 +1374,7 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { .fields .iter() .map(|field| { - Ok(CustomField { + Ok(rbw::db::Field { name: field .name .as_ref() @@ -2094,6 +1394,7 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { }) .transpose()?, ty: field.ty, + linked_id: None, }) }) .collect::>()?; @@ -2113,7 +1414,7 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { .history .iter() .map(|history_entry| { - Ok(DecryptedHistoryEntry { + Ok(rbw::db::HistoryEntry { last_used_date: history_entry.last_used_date.clone(), password: crate::actions::decrypt( &history_entry.password, @@ -2132,19 +1433,19 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { uris, } => EntryData::Login { username: decrypt_field( - Field::Username, + rbw::db::FieldType::Username, username.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), password: decrypt_field( - Field::Password, + rbw::db::FieldType::Password, password.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), totp: decrypt_field( - Field::Totp, + rbw::db::FieldType::Totp, totp.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), @@ -2153,7 +1454,7 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { .iter() .map(|s| { decrypt_field( - Field::Uris, + rbw::db::FieldType::Uris, Some(&s.uri), entry.key.as_deref(), entry.org_id.as_deref(), @@ -2175,37 +1476,37 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { code, } => EntryData::Card { cardholder_name: decrypt_field( - Field::Cardholder, + rbw::db::FieldType::Cardholder, cardholder_name.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), number: decrypt_field( - Field::CardNumber, + rbw::db::FieldType::CardNumber, number.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), brand: decrypt_field( - Field::Brand, + rbw::db::FieldType::Brand, brand.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), exp_month: decrypt_field( - Field::ExpMonth, + rbw::db::FieldType::ExpMonth, exp_month.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), exp_year: decrypt_field( - Field::ExpYear, + rbw::db::FieldType::ExpYear, exp_year.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), code: decrypt_field( - Field::Cvv, + rbw::db::FieldType::Cvv, code.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), @@ -2231,103 +1532,103 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { username, } => EntryData::Identity { title: decrypt_field( - Field::Title, + rbw::db::FieldType::Title, title.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), first_name: decrypt_field( - Field::FirstName, + rbw::db::FieldType::FirstName, first_name.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), middle_name: decrypt_field( - Field::MiddleName, + rbw::db::FieldType::MiddleName, middle_name.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), last_name: decrypt_field( - Field::LastName, + rbw::db::FieldType::LastName, last_name.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), address1: decrypt_field( - Field::Address1, + rbw::db::FieldType::Address1, address1.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), address2: decrypt_field( - Field::Address2, + rbw::db::FieldType::Address2, address2.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), address3: decrypt_field( - Field::Address3, + rbw::db::FieldType::Address3, address3.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), city: decrypt_field( - Field::City, + rbw::db::FieldType::City, city.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), state: decrypt_field( - Field::State, + rbw::db::FieldType::State, state.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), postal_code: decrypt_field( - Field::PostalCode, + rbw::db::FieldType::PostalCode, postal_code.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), country: decrypt_field( - Field::Country, + rbw::db::FieldType::Country, country.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), phone: decrypt_field( - Field::Phone, + rbw::db::FieldType::Phone, phone.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), email: decrypt_field( - Field::Email, + rbw::db::FieldType::Email, email.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), ssn: decrypt_field( - Field::Ssn, + rbw::db::FieldType::Ssn, ssn.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), license_number: decrypt_field( - Field::License, + rbw::db::FieldType::License, license_number.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), passport_number: decrypt_field( - Field::Passport, + rbw::db::FieldType::Passport, passport_number.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), username: decrypt_field( - Field::Username, + rbw::db::FieldType::Username, username.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), @@ -2340,19 +1641,19 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { private_key, } => EntryData::SshKey { public_key: decrypt_field( - Field::PublicKey, + rbw::db::FieldType::PublicKey, public_key.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), fingerprint: decrypt_field( - Field::Fingerprint, + rbw::db::FieldType::Fingerprint, fingerprint.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), ), private_key: decrypt_field( - Field::PrivateKey, + rbw::db::FieldType::PrivateKey, private_key.as_deref(), entry.key.as_deref(), entry.org_id.as_deref(), @@ -2360,14 +1661,18 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { }, }; - Ok(LocalEntry { + Ok(rbw::db::Entry { id: entry.id.clone(), folder, + folder_id: None, + org_id: None, + key: None, name: crate::actions::decrypt(&entry.name, entry.key.as_deref(), entry.org_id.as_deref())?, data, - custom_fields: fields, + fields, notes, history, + master_password_reprompt: rbw::api::CipherRepromptType::None, }) } diff --git a/src/db.rs b/src/db.rs index 398b4df5..1f37df15 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,9 +1,195 @@ use crate::prelude::*; -use std::io::{Read as _, Write as _}; +use std::{ + fmt::Display, + io::{Read as _, Write as _}, + str::FromStr, +}; +use anyhow::Context as _; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum FieldType { + Notes, + Username, + Password, + Totp, + Uris, + IdentityName, + City, + State, + PostalCode, + Country, + Phone, + Ssn, + License, + Passport, + CardNumber, + Expiration, + ExpMonth, + ExpYear, + Cvv, + Cardholder, + Brand, + Name, + Email, + Address, + Address1, + Address2, + Address3, + Fingerprint, + PublicKey, + PrivateKey, + Title, + FirstName, + MiddleName, + LastName, +} + +impl FromStr for FieldType { + type Err = anyhow::Error; + + fn from_str(s: &str) -> anyhow::Result { + Ok(match s.to_lowercase().as_str() { + "notes" | "note" => Self::Notes, + "username" | "user" => Self::Username, + "password" => Self::Password, + "totp" | "code" => Self::Totp, + "uris" | "urls" | "sites" => Self::Uris, + "identityname" => Self::IdentityName, + "city" => Self::City, + "state" => Self::State, + "postcode" | "zipcode" | "zip" => Self::PostalCode, + "country" => Self::Country, + "phone" => Self::Phone, + "ssn" => Self::Ssn, + "license" => Self::License, + "passport" => Self::Passport, + "number" | "card" => Self::CardNumber, + "exp" => Self::Expiration, + "exp_month" | "month" => Self::ExpMonth, + "exp_year" | "year" => Self::ExpYear, + // the word "code" got preceeded by Totp + "cvv" => Self::Cvv, + "cardholder" | "cardholder_name" => Self::Cardholder, + "brand" | "type" => Self::Brand, + "name" => Self::Name, + "email" => Self::Email, + "address1" => Self::Address1, + "address2" => Self::Address2, + "address3" => Self::Address3, + "address" => Self::Address, + "fingerprint" => Self::Fingerprint, + "public_key" => Self::PublicKey, + "private_key" => Self::PrivateKey, + "title" => Self::Title, + "first_name" => Self::FirstName, + "middle_name" => Self::MiddleName, + "last_name" => Self::LastName, + _ => anyhow::bail!("unknown field {s}"), + }) + } +} + +impl Display for FieldType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Notes => "notes", + Self::Username => "username", + Self::Password => "password", + Self::Totp => "totp", + Self::Uris => "uris", + Self::IdentityName => "identityname", + Self::City => "city", + Self::State => "state", + Self::PostalCode => "postcode", + Self::Country => "country", + Self::Phone => "phone", + Self::Ssn => "ssn", + Self::License => "license", + Self::Passport => "passport", + Self::CardNumber => "number", + Self::Expiration => "exp", + Self::ExpMonth => "exp_month", + Self::ExpYear => "exp_year", + Self::Cvv => "cvv", + Self::Cardholder => "cardholder", + Self::Brand => "brand", + Self::Name => "name", + Self::Email => "email", + Self::Address1 => "address1", + Self::Address2 => "address2", + Self::Address3 => "address3", + Self::Address => "address", + Self::Fingerprint => "fingerprint", + Self::PublicKey => "public_key", + Self::PrivateKey => "private_key", + Self::Title => "title", + Self::FirstName => "first_name", + Self::MiddleName => "middle_name", + Self::LastName => "last_name", + }) + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)] +pub struct Field { + pub ty: Option, + pub name: Option, + pub value: Option, + pub linked_id: Option, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)] +pub enum EntryData { + Login { + username: Option, + password: Option, + totp: Option, + uris: Vec, + }, + Card { + cardholder_name: Option, + number: Option, + brand: Option, + exp_month: Option, + exp_year: Option, + code: Option, + }, + Identity { + title: Option, + first_name: Option, + middle_name: Option, + last_name: Option, + address1: Option, + address2: Option, + address3: Option, + city: Option, + state: Option, + postal_code: Option, + country: Option, + phone: Option, + email: Option, + ssn: Option, + license_number: Option, + passport_number: Option, + username: Option, + }, + SecureNote, + SshKey { + private_key: Option, + public_key: Option, + fingerprint: Option, + }, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)] +pub struct HistoryEntry { + pub last_used_date: String, + pub password: String, +} + #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)] pub struct Entry { pub id: String, @@ -23,6 +209,549 @@ impl Entry { pub fn master_password_reprompt(&self) -> bool { self.master_password_reprompt != crate::api::CipherRepromptType::None } + + fn get_short(&self) -> Option { + match &self.data { + EntryData::Login { password, .. } => password.clone(), + EntryData::Card { number, .. } => number.clone(), + EntryData::Identity { + title, + first_name, + middle_name, + last_name, + .. + } => { + let names: Vec = [title, first_name, middle_name, last_name] + .iter() + .copied() + .flatten() + .cloned() + .collect(); + + if names.is_empty() { + None + } else { + Some(names.join(" ")) + } + } + EntryData::SecureNote => self.notes.clone(), + EntryData::SshKey { public_key, .. } => public_key.clone(), + } + } + + pub fn display_short( + &self, + desc: &str, + clipboard: bool, + val_display_or_store: fn(bool, &str) -> bool, + ) -> bool { + let short = self.get_short(); + let Some(short) = short else { + // Would be cool if self.data had a method named main_field_name :D + eprintln!( + "entry for '{desc}' had no {}", + match &self.data { + EntryData::Login { .. } => "password", + EntryData::Card { .. } => "card number", + EntryData::Identity { .. } => "name", + EntryData::SecureNote => "notes", + EntryData::SshKey { .. } => "public key", + } + ); + return false; + }; + + val_display_or_store(clipboard, &short) + } + + /// This function is sh*t but I need it for now + fn get_fields( + &self, + field: &str, + generate_totp: fn(&str) -> anyhow::Result, + ) -> Vec { + let ret: Vec> = match &self.data { + EntryData::Login { + username, + totp, + uris, + .. + } => match field.parse() { + Ok(FieldType::Notes) => vec![self.notes.clone()], + Ok(FieldType::Username) => vec![username.clone()], + + Ok(FieldType::Totp) => { + if let Some(totp) = totp { + match generate_totp(totp) { + Ok(code) => { + vec![Some(code)] + + // val_display_or_store(clipboard, &code); + } + Err(e) => { + eprintln!("{e}"); + vec![] + } + } + } else { + vec![] + } + } + Ok(FieldType::Uris) => { + if !uris.is_empty() { + let uri_strs: Vec<_> = uris.iter().map(|uri| uri.uri.clone()).collect(); + // val_display_or_store(clipboard, &uri_strs.join("\n")); + vec![Some(uri_strs.join("\n"))] + } else { + vec![] + } + } + Ok(FieldType::Password) => { + // self.display_short(desc, clipboard); + vec![self.get_short()] + } + _ => { + self.fields + .iter() + .map(|f| { + if let Some(name) = &f.name { + if name.to_lowercase().contains(field) { + f.value.clone() + } else { + None + } + } else { + None + } + }) + .collect() + + // for f in &self.fields { + // if let Some(name) = &f.name { + // if name.to_lowercase().as_str().contains(field) { + // val_display_or_store(clipboard, f.value.as_deref().unwrap_or("")); + // break; + // } + // } + // } + } + }, + EntryData::Card { + cardholder_name, + brand, + exp_month, + exp_year, + code, + .. + } => match field.parse() { + Ok(FieldType::CardNumber) => vec![self.get_short()], + Ok(FieldType::Expiration) => { + if let (Some(month), Some(year)) = (exp_month, exp_year) { + vec![Some(format!("{month}/{year}"))] + //val_display_or_store(clipboard, &format!("{month}/{year}")); + } else { + vec![] + } + } + Ok(FieldType::ExpMonth) => vec![exp_month.clone()], + Ok(FieldType::ExpYear) => vec![exp_year.clone()], + Ok(FieldType::Cvv) => vec![code.clone()], + Ok(FieldType::Name | FieldType::Cardholder) => vec![cardholder_name.clone()], + Ok(FieldType::Brand) => vec![brand.clone()], + Ok(FieldType::Notes) => vec![self.notes.clone()], + _ => self + .fields + .iter() + .map(|f| { + if let Some(name) = &f.name { + if name.to_lowercase().contains(field) { + f.value.clone() + } else { + None + } + } else { + None + } + }) + .collect(), + }, + EntryData::Identity { + address1, + address2, + address3, + city, + state, + postal_code, + country, + phone, + email, + ssn, + license_number, + passport_number, + username, + .. + } => match field.parse() { + Ok(FieldType::Name) => vec![self.get_short()], + Ok(FieldType::Email) => vec![email.clone()], + Ok(FieldType::Address) => { + let mut strs = vec![]; + + if let Some(address1) = address1 { + strs.push(address1.clone()); + } + if let Some(address2) = address2 { + strs.push(address2.clone()); + } + if let Some(address3) = address3 { + strs.push(address3.clone()); + } + + if !strs.is_empty() { + vec![Some(strs.join("\n"))] + //val_display_or_store(clipboard, &strs.join("\n")); + } else { + vec![] + } + } + Ok(FieldType::City) => vec![city.clone()], + Ok(FieldType::State) => vec![state.clone()], + Ok(FieldType::PostalCode) => vec![postal_code.clone()], + Ok(FieldType::Country) => vec![country.clone()], + Ok(FieldType::Phone) => vec![phone.clone()], + Ok(FieldType::Ssn) => vec![ssn.clone()], + Ok(FieldType::License) => vec![license_number.clone()], + Ok(FieldType::Passport) => vec![passport_number.clone()], + Ok(FieldType::Username) => vec![username.clone()], + Ok(FieldType::Notes) => vec![self.notes.clone()], + _ => self + .fields + .iter() + .map(|f| { + if let Some(name) = &f.name { + if name.to_lowercase().contains(field) { + f.value.clone() + } else { + None + } + } else { + None + } + }) + .collect(), + }, + + EntryData::SecureNote => match field.parse() { + Ok(FieldType::Notes) => vec![self.get_short()], + _ => self + .fields + .iter() + .map(|f| { + if let Some(name) = &f.name { + if name.to_lowercase().contains(field) { + f.value.clone() + } else { + None + } + } else { + None + } + }) + .collect(), + }, + + EntryData::SshKey { + fingerprint, + private_key, + .. + } => match field.parse() { + Ok(FieldType::Fingerprint) => vec![fingerprint.clone()], + Ok(FieldType::PublicKey) => vec![self.get_short()], + Ok(FieldType::PrivateKey) => vec![private_key.clone()], + Ok(FieldType::Notes) => vec![self.notes.clone()], + _ => self + .fields + .iter() + .map(|f| { + if let Some(name) = &f.name { + if name.to_lowercase().contains(field) { + f.value.clone() + } else { + None + } + } else { + None + } + }) + .collect(), + }, + }; + + ret.into_iter().flatten().collect() + } + + pub fn display_field( + &self, + desc: &str, + field: &str, + clipboard: bool, + val_display_or_store: fn(bool, &str) -> bool, + generate_totp: fn(&str) -> anyhow::Result, + ) { + let fields = self.get_fields(&field.to_lowercase(), generate_totp); + fields.iter().for_each(|f| { + val_display_or_store(clipboard, f); + }); + } + + pub fn display_long( + &self, + desc: &str, + clipboard: bool, + val_display_or_store: fn(bool, &str) -> bool, + display_field: fn(&str, Option<&str>, bool) -> bool, + ) { + let mut displayed = self.display_short(desc, clipboard, val_display_or_store); + match &self.data { + EntryData::Login { + username, + totp, + uris, + .. + } => { + displayed |= display_field("Username", username.as_deref(), clipboard); + displayed |= display_field("TOTP Secret", totp.as_deref(), clipboard); + + for uri in uris { + displayed |= display_field("URI", Some(&uri.uri), clipboard); + let match_type = uri.match_type.map(|ty| format!("{ty}")); + displayed |= display_field("Match type", match_type.as_deref(), clipboard); + } + + for field in &self.fields { + displayed |= display_field( + field.name.as_deref().unwrap_or("(null)"), + Some(field.value.as_deref().unwrap_or("")), + clipboard, + ); + } + } + EntryData::Card { + cardholder_name, + brand, + exp_month, + exp_year, + code, + .. + } => { + if let (Some(exp_month), Some(exp_year)) = (exp_month, exp_year) { + println!("Expiration: {exp_month}/{exp_year}"); + displayed = true; + } + displayed |= display_field("CVV", code.as_deref(), clipboard); + displayed |= display_field("Name", cardholder_name.as_deref(), clipboard); + displayed |= display_field("Brand", brand.as_deref(), clipboard); + } + EntryData::Identity { + address1, + address2, + address3, + city, + state, + postal_code, + country, + phone, + email, + ssn, + license_number, + passport_number, + username, + .. + } => { + displayed |= display_field("Address", address1.as_deref(), clipboard); + displayed |= display_field("Address", address2.as_deref(), clipboard); + displayed |= display_field("Address", address3.as_deref(), clipboard); + displayed |= display_field("City", city.as_deref(), clipboard); + displayed |= display_field("State", state.as_deref(), clipboard); + displayed |= display_field("Postcode", postal_code.as_deref(), clipboard); + displayed |= display_field("Country", country.as_deref(), clipboard); + displayed |= display_field("Phone", phone.as_deref(), clipboard); + displayed |= display_field("Email", email.as_deref(), clipboard); + displayed |= display_field("SSN", ssn.as_deref(), clipboard); + displayed |= display_field("License", license_number.as_deref(), clipboard); + displayed |= display_field("Passport", passport_number.as_deref(), clipboard); + displayed |= display_field("Username", username.as_deref(), clipboard); + } + EntryData::SecureNote => {} + EntryData::SshKey { fingerprint, .. } => { + displayed |= display_field("Fingerprint", fingerprint.as_deref(), clipboard); + + for field in &self.fields { + displayed |= display_field( + field.name.as_deref().unwrap_or("(null)"), + Some(field.value.as_deref().unwrap_or("")), + clipboard, + ); + } + } + } + + if !matches!(&self.data, EntryData::SecureNote) { + if let Some(notes) = &self.notes { + if displayed { + println!(); + } + println!("{notes}"); + } + } + } + + /// This implementation mirror the `fn display_fied` method on which field to list + pub fn display_fields_list(&self) { + match &self.data { + EntryData::Login { + username, + password, + totp, + uris, + .. + } => { + if username.is_some() { + println!("{}", FieldType::Username); + } + if totp.is_some() { + println!("{}", FieldType::Totp); + } + if !uris.is_empty() { + println!("{}", FieldType::Uris); + } + if password.is_some() { + println!("{}", FieldType::Password); + } + } + EntryData::Card { + cardholder_name, + number, + brand, + exp_month, + exp_year, + code, + .. + } => { + if number.is_some() { + println!("{}", FieldType::CardNumber); + } + if exp_month.is_some() { + println!("{}", FieldType::ExpMonth); + } + if exp_year.is_some() { + println!("{}", FieldType::ExpYear); + } + if code.is_some() { + println!("{}", FieldType::Cvv); + } + if cardholder_name.is_some() { + println!("{}", FieldType::Cardholder); + } + if brand.is_some() { + println!("{}", FieldType::Brand); + } + } + + EntryData::Identity { + address1, + address2, + address3, + city, + state, + postal_code, + country, + phone, + email, + ssn, + license_number, + passport_number, + username, + title, + first_name, + middle_name, + last_name, + .. + } => { + if [title, first_name, middle_name, last_name] + .iter() + .any(|f| f.is_some()) + { + // the display_field combines all these fields together. + println!("name"); + } + if email.is_some() { + println!("{}", FieldType::Email); + } + if [address1, address2, address3].iter().any(|f| f.is_some()) { + // the display_field combines all these fields together. + println!("address"); + } + if city.is_some() { + println!("{}", FieldType::City); + } + if state.is_some() { + println!("{}", FieldType::State); + } + if postal_code.is_some() { + println!("{}", FieldType::PostalCode); + } + if country.is_some() { + println!("{}", FieldType::Country); + } + if phone.is_some() { + println!("{}", FieldType::Phone); + } + if ssn.is_some() { + println!("{}", FieldType::Ssn); + } + if license_number.is_some() { + println!("{}", FieldType::License); + } + if passport_number.is_some() { + println!("{}", FieldType::Passport); + } + if username.is_some() { + println!("{}", FieldType::Username); + } + } + + EntryData::SecureNote => (), // handled at the end + EntryData::SshKey { + fingerprint, + public_key, + .. + } => { + if fingerprint.is_some() { + println!("{}", FieldType::Fingerprint); + } + if public_key.is_some() { + println!("{}", FieldType::PublicKey); + } + } + } + + if self.notes.is_some() { + println!("{}", FieldType::Notes); + } + for f in &self.fields { + if let Some(name) = &f.name { + println!("{name}"); + } + } + } + + pub fn display_json(&self, desc: &str) -> anyhow::Result<()> { + serde_json::to_writer_pretty(std::io::stdout(), &self) + .context(format!("failed to write entry '{desc}' to stdout"))?; + println!(); + + Ok(()) + } } #[derive(serde::Serialize, Debug, Clone, Eq, PartialEq)] @@ -95,63 +824,6 @@ impl<'de> serde::Deserialize<'de> for Uri { } } -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)] -pub enum EntryData { - Login { - username: Option, - password: Option, - totp: Option, - uris: Vec, - }, - Card { - cardholder_name: Option, - number: Option, - brand: Option, - exp_month: Option, - exp_year: Option, - code: Option, - }, - Identity { - title: Option, - first_name: Option, - middle_name: Option, - last_name: Option, - address1: Option, - address2: Option, - address3: Option, - city: Option, - state: Option, - postal_code: Option, - country: Option, - phone: Option, - email: Option, - ssn: Option, - license_number: Option, - passport_number: Option, - username: Option, - }, - SecureNote, - SshKey { - private_key: Option, - public_key: Option, - fingerprint: Option, - }, -} - -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)] -pub struct Field { - pub ty: Option, - pub name: Option, - pub value: Option, - pub linked_id: Option, -} - -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)] -pub struct HistoryEntry { - pub last_used_date: String, - pub password: String, -} - #[derive(serde::Serialize, serde::Deserialize, Default, Debug)] pub struct Db { pub access_token: Option, From 4981782d9a0d50be06372c4665cc6f17647dbd33 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 9 May 2026 21:22:16 +0200 Subject: [PATCH 034/273] small comment to remember commenting about the pinentry on README --- src/bin/rbw-agent/actions.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 32774e03..06140906 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -569,6 +569,7 @@ async fn decrypt_cipher( } else { None }; + // TODO: Remember somewhere that only GUI pinentry work, since this is a daemon. let password = rbw::pinentry::getpin( &config_pinentry().await?, "Master Password", From 7fa8201869c08ad9b5839bd95d8a7afd5ab74a21 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 9 May 2026 21:29:12 +0200 Subject: [PATCH 035/273] decouple fields decrypting from decrypt_cipher fn --- src/bin/rbw/commands.rs | 57 +++++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 3009c783..995b6bfa 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1355,49 +1355,50 @@ fn decrypt_search_cipher(entry: &rbw::db::Entry) -> anyhow::Result anyhow::Result { - // folder name should always be decrypted with the local key because - // folders are local to a specific user's vault, not the organization - let folder = entry - .folder - .as_ref() - .map(|folder| crate::actions::decrypt(folder, None, None)) - .transpose(); - let folder = match folder { - Ok(folder) => folder, - Err(e) => { - log::warn!("failed to decrypt folder name: {e}"); - None - } - }; - let fields = entry - .fields +fn decrypt_fields( + fields: &[rbw::db::Field], + key: Option<&str>, + org_id: Option<&str>, +) -> anyhow::Result> { + fields .iter() .map(|field| { Ok(rbw::db::Field { name: field .name .as_ref() - .map(|name| { - crate::actions::decrypt(name, entry.key.as_deref(), entry.org_id.as_deref()) - }) + .map(|name| crate::actions::decrypt(name, key, org_id)) .transpose()?, value: field .value .as_ref() - .map(|value| { - crate::actions::decrypt( - value, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - }) + .map(|value| crate::actions::decrypt(value, key, org_id)) .transpose()?, ty: field.ty, linked_id: None, }) }) - .collect::>()?; + .collect::>() +} + +fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { + // folder name should always be decrypted with the local key because + // folders are local to a specific user's vault, not the organization + let folder = entry + .folder + .as_ref() + .map(|folder| crate::actions::decrypt(folder, None, None)) + .transpose(); + let folder = match folder { + Ok(folder) => folder, + Err(e) => { + log::warn!("failed to decrypt folder name: {e}"); + None + } + }; + + let fields = decrypt_fields(&entry.fields, entry.key.as_deref(), entry.org_id.as_deref())?; + let notes = entry .notes .as_ref() From 1f79cdd5ebee330910d9d8636872a953662a34e4 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 9 May 2026 21:41:09 +0200 Subject: [PATCH 036/273] improve decrypt_fields name --- src/bin/rbw/commands.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 995b6bfa..e3dbf991 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1355,7 +1355,7 @@ fn decrypt_search_cipher(entry: &rbw::db::Entry) -> anyhow::Result, org_id: Option<&str>, @@ -1397,7 +1397,8 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { } }; - let fields = decrypt_fields(&entry.fields, entry.key.as_deref(), entry.org_id.as_deref())?; + let fields = + decrypt_cipher_fields(&entry.fields, entry.key.as_deref(), entry.org_id.as_deref())?; let notes = entry .notes From ce1fb68aee05810db756f9505cc44fe2a9742dc9 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 9 May 2026 22:24:50 +0200 Subject: [PATCH 037/273] add gdb to shell --- shell.nix | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/shell.nix b/shell.nix index 57220399..3f1ad2bc 100644 --- a/shell.nix +++ b/shell.nix @@ -1,6 +1,14 @@ -{ pkgs ? import (fetchTarball - "https://github.com/NixOS/nixpkgs/archive/nixos-unstable.tar.gz") { } }: +{ + pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/nixos-unstable.tar.gz") { }, +}: pkgs.mkShell { - buildInputs = with pkgs; [ rustc cargo rust-analyzer rustfmt clippy ]; + buildInputs = with pkgs; [ + gdb + rustc + cargo + rust-analyzer + rustfmt + clippy + ]; } From 1df225ba26aa9bc77a81d897e67ea8c197c4bc4c Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 9 May 2026 22:29:36 +0200 Subject: [PATCH 038/273] move decrypt_field down --- src/bin/rbw/commands.rs | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index e3dbf991..757578db 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1179,25 +1179,6 @@ fn find_entry_raw( } } -fn decrypt_field( - name: rbw::db::FieldType, - field: Option<&str>, - entry_key: Option<&str>, - org_id: Option<&str>, -) -> Option { - let field = field - .as_ref() - .map(|field| crate::actions::decrypt(field, entry_key, org_id)) - .transpose(); - match field { - Ok(field) => field, - Err(e) => { - log::warn!("failed to decrypt {name}: {e}"); - None - } - } -} - fn decrypt_list_cipher( entry: &rbw::db::Entry, fields: &[ListField], @@ -1355,6 +1336,25 @@ fn decrypt_search_cipher(entry: &rbw::db::Entry) -> anyhow::Result, + entry_key: Option<&str>, + org_id: Option<&str>, +) -> Option { + let field = field + .as_ref() + .map(|field| crate::actions::decrypt(field, entry_key, org_id)) + .transpose(); + match field { + Ok(field) => field, + Err(e) => { + log::warn!("failed to decrypt {name}: {e}"); + None + } + } +} + fn decrypt_cipher_fields( fields: &[rbw::db::Field], key: Option<&str>, From 933c218917230f09e8c4b70a2f69eddc9141bb83 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 9 May 2026 22:39:20 +0200 Subject: [PATCH 039/273] partially remove duplicated code inside decrypt_cipher --- src/bin/rbw/commands.rs | 215 +++++++--------------------------------- 1 file changed, 34 insertions(+), 181 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 757578db..0002979b 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1427,6 +1427,10 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { }) .collect::>()?; + let df = |ft, val: Option<&str>| { + decrypt_field(ft, val, entry.key.as_deref(), entry.org_id.as_deref()) + }; + let data = match &entry.data { rbw::db::EntryData::Login { username, @@ -1434,34 +1438,13 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { totp, uris, } => EntryData::Login { - username: decrypt_field( - rbw::db::FieldType::Username, - username.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - password: decrypt_field( - rbw::db::FieldType::Password, - password.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - totp: decrypt_field( - rbw::db::FieldType::Totp, - totp.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), + username: df(rbw::db::FieldType::Username, username.as_deref()), + password: df(rbw::db::FieldType::Password, password.as_deref()), + totp: df(rbw::db::FieldType::Totp, totp.as_deref()), uris: uris .iter() .map(|s| { - decrypt_field( - rbw::db::FieldType::Uris, - Some(&s.uri), - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - .map(|uri| Uri { + df(rbw::db::FieldType::Uris, Some(&s.uri)).map(|uri| Uri { uri, match_type: s.match_type, }) @@ -1477,42 +1460,12 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { exp_year, code, } => EntryData::Card { - cardholder_name: decrypt_field( - rbw::db::FieldType::Cardholder, - cardholder_name.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - number: decrypt_field( - rbw::db::FieldType::CardNumber, - number.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - brand: decrypt_field( - rbw::db::FieldType::Brand, - brand.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - exp_month: decrypt_field( - rbw::db::FieldType::ExpMonth, - exp_month.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - exp_year: decrypt_field( - rbw::db::FieldType::ExpYear, - exp_year.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - code: decrypt_field( - rbw::db::FieldType::Cvv, - code.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), + cardholder_name: df(rbw::db::FieldType::Cardholder, cardholder_name.as_deref()), + number: df(rbw::db::FieldType::CardNumber, number.as_deref()), + brand: df(rbw::db::FieldType::Brand, brand.as_deref()), + exp_month: df(rbw::db::FieldType::ExpMonth, exp_month.as_deref()), + exp_year: df(rbw::db::FieldType::ExpYear, exp_year.as_deref()), + code: df(rbw::db::FieldType::Cvv, code.as_deref()), }, rbw::db::EntryData::Identity { title, @@ -1533,108 +1486,23 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { passport_number, username, } => EntryData::Identity { - title: decrypt_field( - rbw::db::FieldType::Title, - title.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - first_name: decrypt_field( - rbw::db::FieldType::FirstName, - first_name.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - middle_name: decrypt_field( - rbw::db::FieldType::MiddleName, - middle_name.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - last_name: decrypt_field( - rbw::db::FieldType::LastName, - last_name.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - address1: decrypt_field( - rbw::db::FieldType::Address1, - address1.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - address2: decrypt_field( - rbw::db::FieldType::Address2, - address2.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - address3: decrypt_field( - rbw::db::FieldType::Address3, - address3.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - city: decrypt_field( - rbw::db::FieldType::City, - city.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - state: decrypt_field( - rbw::db::FieldType::State, - state.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - postal_code: decrypt_field( - rbw::db::FieldType::PostalCode, - postal_code.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - country: decrypt_field( - rbw::db::FieldType::Country, - country.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - phone: decrypt_field( - rbw::db::FieldType::Phone, - phone.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - email: decrypt_field( - rbw::db::FieldType::Email, - email.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - ssn: decrypt_field( - rbw::db::FieldType::Ssn, - ssn.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - license_number: decrypt_field( - rbw::db::FieldType::License, - license_number.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - passport_number: decrypt_field( - rbw::db::FieldType::Passport, - passport_number.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - username: decrypt_field( - rbw::db::FieldType::Username, - username.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), + title: df(rbw::db::FieldType::Title, title.as_deref()), + first_name: df(rbw::db::FieldType::FirstName, first_name.as_deref()), + middle_name: df(rbw::db::FieldType::MiddleName, middle_name.as_deref()), + last_name: df(rbw::db::FieldType::LastName, last_name.as_deref()), + address1: df(rbw::db::FieldType::Address1, address1.as_deref()), + address2: df(rbw::db::FieldType::Address2, address2.as_deref()), + address3: df(rbw::db::FieldType::Address3, address3.as_deref()), + city: df(rbw::db::FieldType::City, city.as_deref()), + state: df(rbw::db::FieldType::State, state.as_deref()), + postal_code: df(rbw::db::FieldType::PostalCode, postal_code.as_deref()), + country: df(rbw::db::FieldType::Country, country.as_deref()), + phone: df(rbw::db::FieldType::Phone, phone.as_deref()), + email: df(rbw::db::FieldType::Email, email.as_deref()), + ssn: df(rbw::db::FieldType::Ssn, ssn.as_deref()), + license_number: df(rbw::db::FieldType::License, license_number.as_deref()), + passport_number: df(rbw::db::FieldType::Passport, passport_number.as_deref()), + username: df(rbw::db::FieldType::Username, username.as_deref()), }, rbw::db::EntryData::SecureNote => EntryData::SecureNote {}, rbw::db::EntryData::SshKey { @@ -1642,24 +1510,9 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { fingerprint, private_key, } => EntryData::SshKey { - public_key: decrypt_field( - rbw::db::FieldType::PublicKey, - public_key.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - fingerprint: decrypt_field( - rbw::db::FieldType::Fingerprint, - fingerprint.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - private_key: decrypt_field( - rbw::db::FieldType::PrivateKey, - private_key.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), + public_key: df(rbw::db::FieldType::PublicKey, public_key.as_deref()), + fingerprint: df(rbw::db::FieldType::Fingerprint, fingerprint.as_deref()), + private_key: df(rbw::db::FieldType::PrivateKey, private_key.as_deref()), }, }; From 5662ebb6bc63748065b56d136ea80c739bed0b1e Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 9 May 2026 23:40:01 +0200 Subject: [PATCH 040/273] add some comments and improve naming of decrypt fns --- src/bin/rbw/commands.rs | 54 ++++++++++++++++++++--------------------- src/db.rs | 2 ++ 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 0002979b..e890f608 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -56,6 +56,7 @@ pub fn parse_needle(arg: &str) -> Result { Ok(Needle::Name(arg.to_string())) } +/// It's a subset of db::Entry with only decrypted fields #[derive(Debug, serde::Serialize)] struct DecryptedListCipher { id: String, @@ -1195,7 +1196,7 @@ fn decrypt_list_cipher( }; let user = if fields.contains(&ListField::User) { match &entry.data { - rbw::db::EntryData::Login { username, .. } => decrypt_field( + rbw::db::EntryData::Login { username, .. } => decrypt_field_warn( rbw::db::FieldType::Username, username.as_deref(), entry.key.as_deref(), @@ -1222,7 +1223,7 @@ fn decrypt_list_cipher( rbw::db::EntryData::Login { uris, .. } => Some( uris.iter() .filter_map(|s| { - decrypt_field( + decrypt_field_warn( rbw::db::FieldType::Uris, Some(&s.uri), entry.key.as_deref(), @@ -1261,7 +1262,7 @@ fn decrypt_search_cipher(entry: &rbw::db::Entry) -> anyhow::Result decrypt_field( + rbw::db::EntryData::Login { username, .. } => decrypt_field_warn( rbw::db::FieldType::Username, username.as_deref(), entry.key.as_deref(), @@ -1284,7 +1285,7 @@ fn decrypt_search_cipher(entry: &rbw::db::Entry) -> anyhow::Result anyhow::Result, + entry_key: Option<&str>, + org_id: Option<&str>, +) -> anyhow::Result> { + string + .map(|f| crate::actions::decrypt(f, entry_key, org_id)) + .transpose() +} + +/// This accepts a optional field and optionally decrypts it? +fn decrypt_field_warn( name: rbw::db::FieldType, field: Option<&str>, - entry_key: Option<&str>, + key: Option<&str>, org_id: Option<&str>, ) -> Option { - let field = field - .as_ref() - .map(|field| crate::actions::decrypt(field, entry_key, org_id)) - .transpose(); - match field { - Ok(field) => field, - Err(e) => { - log::warn!("failed to decrypt {name}: {e}"); - None - } - } + decrypt_string(field, key, org_id).unwrap_or_else(|e| { + log::warn!("failed to decrypt {name}: {e}"); + None + }) } fn decrypt_cipher_fields( @@ -1364,16 +1370,8 @@ fn decrypt_cipher_fields( .iter() .map(|field| { Ok(rbw::db::Field { - name: field - .name - .as_ref() - .map(|name| crate::actions::decrypt(name, key, org_id)) - .transpose()?, - value: field - .value - .as_ref() - .map(|value| crate::actions::decrypt(value, key, org_id)) - .transpose()?, + name: decrypt_string(field.name.as_deref(), key, org_id)?, + value: decrypt_string(field.value.as_deref(), key, org_id)?, ty: field.ty, linked_id: None, }) @@ -1428,7 +1426,7 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { .collect::>()?; let df = |ft, val: Option<&str>| { - decrypt_field(ft, val, entry.key.as_deref(), entry.org_id.as_deref()) + decrypt_field_warn(ft, val, entry.key.as_deref(), entry.org_id.as_deref()) }; let data = match &entry.data { diff --git a/src/db.rs b/src/db.rs index 1f37df15..437d2869 100644 --- a/src/db.rs +++ b/src/db.rs @@ -205,6 +205,8 @@ pub struct Entry { pub master_password_reprompt: crate::api::CipherRepromptType, } +// Most impl fn don't belong here. I am talking of display ones, but looking to relocate them +// later in the refactor process. impl Entry { pub fn master_password_reprompt(&self) -> bool { self.master_password_reprompt != crate::api::CipherRepromptType::None From e3505ac53aca2dd73ec7b3e9dd9a5ba64f92ef0b Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 10 May 2026 15:30:46 +0200 Subject: [PATCH 041/273] add a nice comment on get_fields --- src/db.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/db.rs b/src/db.rs index 437d2869..96746e54 100644 --- a/src/db.rs +++ b/src/db.rs @@ -267,6 +267,11 @@ impl Entry { } /// This function is sh*t but I need it for now + /// Given a textual representation of a field, like "username", "password" or "card number", + /// check which type of entry EntryData is and extract the "username" or "cardnumber" field if + /// available from the "static" fields, else go check for the dynamic ones. + /// For example, if the EntryData is of type EntryData::Login, try to extract the username from the + /// static fields, but if the field param is "state", search for it through the dynamic ones. fn get_fields( &self, field: &str, From 9ff38ef916addd8cda0a588685d101e518863195 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 10 May 2026 15:47:08 +0200 Subject: [PATCH 042/273] decouple dynamic_field extraction logic --- src/db.rs | 107 +++++++++++------------------------------------------- 1 file changed, 22 insertions(+), 85 deletions(-) diff --git a/src/db.rs b/src/db.rs index 96746e54..f090816d 100644 --- a/src/db.rs +++ b/src/db.rs @@ -266,6 +266,23 @@ impl Entry { val_display_or_store(clipboard, &short) } + fn get_dynamic_fields(&self, name: &str) -> Vec> { + self.fields + .iter() + .map(|f| { + if let Some(fname) = &f.name { + if fname.to_lowercase().contains(name) { + f.value.clone() + } else { + None + } + } else { + None + } + }) + .collect() + } + /// This function is sh*t but I need it for now /// Given a textual representation of a field, like "username", "password" or "card number", /// check which type of entry EntryData is and extract the "username" or "cardnumber" field if @@ -317,31 +334,7 @@ impl Entry { // self.display_short(desc, clipboard); vec![self.get_short()] } - _ => { - self.fields - .iter() - .map(|f| { - if let Some(name) = &f.name { - if name.to_lowercase().contains(field) { - f.value.clone() - } else { - None - } - } else { - None - } - }) - .collect() - - // for f in &self.fields { - // if let Some(name) = &f.name { - // if name.to_lowercase().as_str().contains(field) { - // val_display_or_store(clipboard, f.value.as_deref().unwrap_or("")); - // break; - // } - // } - // } - } + _ => self.get_dynamic_fields(field), }, EntryData::Card { cardholder_name, @@ -366,21 +359,7 @@ impl Entry { Ok(FieldType::Name | FieldType::Cardholder) => vec![cardholder_name.clone()], Ok(FieldType::Brand) => vec![brand.clone()], Ok(FieldType::Notes) => vec![self.notes.clone()], - _ => self - .fields - .iter() - .map(|f| { - if let Some(name) = &f.name { - if name.to_lowercase().contains(field) { - f.value.clone() - } else { - None - } - } else { - None - } - }) - .collect(), + _ => self.get_dynamic_fields(field), }, EntryData::Identity { address1, @@ -430,40 +409,12 @@ impl Entry { Ok(FieldType::Passport) => vec![passport_number.clone()], Ok(FieldType::Username) => vec![username.clone()], Ok(FieldType::Notes) => vec![self.notes.clone()], - _ => self - .fields - .iter() - .map(|f| { - if let Some(name) = &f.name { - if name.to_lowercase().contains(field) { - f.value.clone() - } else { - None - } - } else { - None - } - }) - .collect(), + _ => self.get_dynamic_fields(field), }, EntryData::SecureNote => match field.parse() { Ok(FieldType::Notes) => vec![self.get_short()], - _ => self - .fields - .iter() - .map(|f| { - if let Some(name) = &f.name { - if name.to_lowercase().contains(field) { - f.value.clone() - } else { - None - } - } else { - None - } - }) - .collect(), + _ => self.get_dynamic_fields(field), }, EntryData::SshKey { @@ -475,21 +426,7 @@ impl Entry { Ok(FieldType::PublicKey) => vec![self.get_short()], Ok(FieldType::PrivateKey) => vec![private_key.clone()], Ok(FieldType::Notes) => vec![self.notes.clone()], - _ => self - .fields - .iter() - .map(|f| { - if let Some(name) = &f.name { - if name.to_lowercase().contains(field) { - f.value.clone() - } else { - None - } - } else { - None - } - }) - .collect(), + _ => self.get_dynamic_fields(field), }, }; From 79a87d08e64a316ba27b547e48c79745aa18a2e7 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 10 May 2026 16:02:49 +0200 Subject: [PATCH 043/273] make FieldType contain the Custom occurrence and just have it From<&str> instead of FromStr --- src/db.rs | 93 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 47 insertions(+), 46 deletions(-) diff --git a/src/db.rs b/src/db.rs index f090816d..f579e9aa 100644 --- a/src/db.rs +++ b/src/db.rs @@ -3,13 +3,12 @@ use crate::prelude::*; use std::{ fmt::Display, io::{Read as _, Write as _}, - str::FromStr, }; use anyhow::Context as _; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum FieldType { Notes, Username, @@ -45,13 +44,12 @@ pub enum FieldType { FirstName, MiddleName, LastName, + Custom(String), } -impl FromStr for FieldType { - type Err = anyhow::Error; - - fn from_str(s: &str) -> anyhow::Result { - Ok(match s.to_lowercase().as_str() { +impl From<&str> for FieldType { + fn from(s: &str) -> Self { + match s.to_lowercase().as_str() { "notes" | "note" => Self::Notes, "username" | "user" => Self::Username, "password" => Self::Password, @@ -87,8 +85,8 @@ impl FromStr for FieldType { "first_name" => Self::FirstName, "middle_name" => Self::MiddleName, "last_name" => Self::LastName, - _ => anyhow::bail!("unknown field {s}"), - }) + _ => Self::Custom(s.to_string()), + } } } @@ -129,6 +127,7 @@ impl Display for FieldType { Self::FirstName => "first_name", Self::MiddleName => "middle_name", Self::LastName => "last_name", + Self::Custom(name) => name, }) } } @@ -294,17 +293,17 @@ impl Entry { field: &str, generate_totp: fn(&str) -> anyhow::Result, ) -> Vec { + let ftype: FieldType = field.into(); let ret: Vec> = match &self.data { EntryData::Login { username, totp, uris, .. - } => match field.parse() { - Ok(FieldType::Notes) => vec![self.notes.clone()], - Ok(FieldType::Username) => vec![username.clone()], - - Ok(FieldType::Totp) => { + } => match &ftype { + FieldType::Notes => vec![self.notes.clone()], + FieldType::Username => vec![username.clone()], + FieldType::Totp => { if let Some(totp) = totp { match generate_totp(totp) { Ok(code) => { @@ -321,7 +320,7 @@ impl Entry { vec![] } } - Ok(FieldType::Uris) => { + FieldType::Uris => { if !uris.is_empty() { let uri_strs: Vec<_> = uris.iter().map(|uri| uri.uri.clone()).collect(); // val_display_or_store(clipboard, &uri_strs.join("\n")); @@ -330,10 +329,11 @@ impl Entry { vec![] } } - Ok(FieldType::Password) => { + FieldType::Password => { // self.display_short(desc, clipboard); vec![self.get_short()] } + // This should be Custom _ => self.get_dynamic_fields(field), }, EntryData::Card { @@ -343,9 +343,9 @@ impl Entry { exp_year, code, .. - } => match field.parse() { - Ok(FieldType::CardNumber) => vec![self.get_short()], - Ok(FieldType::Expiration) => { + } => match &ftype { + FieldType::CardNumber => vec![self.get_short()], + FieldType::Expiration => { if let (Some(month), Some(year)) = (exp_month, exp_year) { vec![Some(format!("{month}/{year}"))] //val_display_or_store(clipboard, &format!("{month}/{year}")); @@ -353,12 +353,13 @@ impl Entry { vec![] } } - Ok(FieldType::ExpMonth) => vec![exp_month.clone()], - Ok(FieldType::ExpYear) => vec![exp_year.clone()], - Ok(FieldType::Cvv) => vec![code.clone()], - Ok(FieldType::Name | FieldType::Cardholder) => vec![cardholder_name.clone()], - Ok(FieldType::Brand) => vec![brand.clone()], - Ok(FieldType::Notes) => vec![self.notes.clone()], + FieldType::ExpMonth => vec![exp_month.clone()], + FieldType::ExpYear => vec![exp_year.clone()], + FieldType::Cvv => vec![code.clone()], + FieldType::Name | FieldType::Cardholder => vec![cardholder_name.clone()], + FieldType::Brand => vec![brand.clone()], + FieldType::Notes => vec![self.notes.clone()], + // This should be Custom _ => self.get_dynamic_fields(field), }, EntryData::Identity { @@ -376,10 +377,10 @@ impl Entry { passport_number, username, .. - } => match field.parse() { - Ok(FieldType::Name) => vec![self.get_short()], - Ok(FieldType::Email) => vec![email.clone()], - Ok(FieldType::Address) => { + } => match &ftype { + FieldType::Name => vec![self.get_short()], + FieldType::Email => vec![email.clone()], + FieldType::Address => { let mut strs = vec![]; if let Some(address1) = address1 { @@ -399,21 +400,21 @@ impl Entry { vec![] } } - Ok(FieldType::City) => vec![city.clone()], - Ok(FieldType::State) => vec![state.clone()], - Ok(FieldType::PostalCode) => vec![postal_code.clone()], - Ok(FieldType::Country) => vec![country.clone()], - Ok(FieldType::Phone) => vec![phone.clone()], - Ok(FieldType::Ssn) => vec![ssn.clone()], - Ok(FieldType::License) => vec![license_number.clone()], - Ok(FieldType::Passport) => vec![passport_number.clone()], - Ok(FieldType::Username) => vec![username.clone()], - Ok(FieldType::Notes) => vec![self.notes.clone()], + FieldType::City => vec![city.clone()], + FieldType::State => vec![state.clone()], + FieldType::PostalCode => vec![postal_code.clone()], + FieldType::Country => vec![country.clone()], + FieldType::Phone => vec![phone.clone()], + FieldType::Ssn => vec![ssn.clone()], + FieldType::License => vec![license_number.clone()], + FieldType::Passport => vec![passport_number.clone()], + FieldType::Username => vec![username.clone()], + FieldType::Notes => vec![self.notes.clone()], _ => self.get_dynamic_fields(field), }, - EntryData::SecureNote => match field.parse() { - Ok(FieldType::Notes) => vec![self.get_short()], + EntryData::SecureNote => match &ftype { + FieldType::Notes => vec![self.get_short()], _ => self.get_dynamic_fields(field), }, @@ -421,11 +422,11 @@ impl Entry { fingerprint, private_key, .. - } => match field.parse() { - Ok(FieldType::Fingerprint) => vec![fingerprint.clone()], - Ok(FieldType::PublicKey) => vec![self.get_short()], - Ok(FieldType::PrivateKey) => vec![private_key.clone()], - Ok(FieldType::Notes) => vec![self.notes.clone()], + } => match &ftype { + FieldType::Fingerprint => vec![fingerprint.clone()], + FieldType::PublicKey => vec![self.get_short()], + FieldType::PrivateKey => vec![private_key.clone()], + FieldType::Notes => vec![self.notes.clone()], _ => self.get_dynamic_fields(field), }, }; From a65e2dd3d159519e3ab88f2a8503b2e62415975d Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 10 May 2026 16:46:00 +0200 Subject: [PATCH 044/273] pre-validate totp_rs::Algorithm and improve overall readability --- src/bin/rbw/commands.rs | 68 ++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index e890f608..12ae7ccd 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1579,7 +1579,7 @@ fn remove_db() -> anyhow::Result<()> { struct TotpParams { secret: Vec, - algorithm: String, + algorithm: totp_rs::Algorithm, digits: usize, period: u64, } @@ -1600,6 +1600,19 @@ fn decode_totp_secret(secret: &str) -> anyhow::Result> { Err(anyhow::anyhow!("totp secret was not valid base32")) } +// This function exists for the sake of making the generate_totp function less +// densely packed and more readable +fn generate_totp_algorithm_type(alg: &str) -> anyhow::Result { + use totp_rs::Algorithm::*; + match alg { + "SHA1" => Ok(SHA1), + "SHA256" => Ok(SHA256), + "SHA512" => Ok(SHA512), + "STEAM" => Ok(Steam), + _ => anyhow::bail!("{alg} is not a valid algorithm"), + } +} + fn parse_totp_secret(secret: &str) -> anyhow::Result { if let Ok(u) = url::Url::parse(secret) { match u.scheme() { @@ -1615,15 +1628,19 @@ fn parse_totp_secret(secret: &str) -> anyhow::Result { .get("secret") .ok_or_else(|| anyhow::anyhow!("totp secret url must have secret"))?, )?; - let algorithm = query - .get("algorithm") - .map_or_else(|| String::from("SHA1"), ToString::to_string); + + let algorithm = query.get("algorithm").map_or_else( + || Ok(totp_rs::Algorithm::SHA1), + |a| generate_totp_algorithm_type(&ToString::to_string(a)), + )?; + let digits = match query.get("digits") { Some(dig) => dig.parse::().map_err(|_| { anyhow::anyhow!("digits parameter in totp url must be a valid integer.") })?, None => 6, }; + let period = match query.get("period") { Some(dig) => dig.parse::().map_err(|_| { anyhow::anyhow!("period parameter in totp url must be a valid integer.") @@ -1643,7 +1660,7 @@ fn parse_totp_secret(secret: &str) -> anyhow::Result { Ok(TotpParams { secret: decode_totp_secret(steam_secret)?, - algorithm: String::from("STEAM"), + algorithm: totp_rs::Algorithm::Steam, digits: 5, period: TOTP_DEFAULT_STEP, }) @@ -1655,42 +1672,29 @@ fn parse_totp_secret(secret: &str) -> anyhow::Result { } else { Ok(TotpParams { secret: decode_totp_secret(secret)?, - algorithm: String::from("SHA1"), + algorithm: totp_rs::Algorithm::SHA1, digits: 6, period: TOTP_DEFAULT_STEP, }) } } -// This function exists for the sake of making the generate_totp function less -// densely packed and more readable -fn generate_totp_algorithm_type(alg: &str) -> anyhow::Result { - match alg { - "SHA1" => Ok(totp_rs::Algorithm::SHA1), - "SHA256" => Ok(totp_rs::Algorithm::SHA256), - "SHA512" => Ok(totp_rs::Algorithm::SHA512), - "STEAM" => Ok(totp_rs::Algorithm::Steam), - _ => Err(anyhow::anyhow!(format!("{alg} is not a valid algorithm"))), - } -} - fn generate_totp(secret: &str) -> anyhow::Result { + use totp_rs::{Algorithm::*, TOTP}; let totp_params = parse_totp_secret(secret)?; - let alg = totp_params.algorithm.as_str(); - match alg { - "SHA1" | "SHA256" | "SHA512" => Ok(totp_rs::TOTP::new_unchecked( - generate_totp_algorithm_type(alg)?, - totp_params.digits, - 1, // the library docs say this should be a 1 - totp_params.period, - totp_params.secret, - ) - .generate_current()?), - "STEAM" => Ok(totp_rs::TOTP::new_steam(totp_params.secret).generate_current()?), - _ => Err(anyhow::anyhow!(format!( - "{alg} is not a valid totp algorithm" - ))), + match totp_params.algorithm { + SHA1 | SHA256 | SHA512 => { + Ok(TOTP::new_unchecked( + totp_params.algorithm, + totp_params.digits, + 1, // the library docs say this should be a 1 + totp_params.period, + totp_params.secret, + ) + .generate_current()?) + } + Steam => Ok(TOTP::new_steam(totp_params.secret).generate_current()?), } } From 55acedb21f0b017b3b2c282e6705d1cd3ef85720 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 10 May 2026 17:49:45 +0200 Subject: [PATCH 045/273] dedup folder creation logic --- src/bin/rbw/commands.rs | 140 +++++++++++++++++++--------------------- 1 file changed, 67 insertions(+), 73 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 12ae7ccd..184e6338 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -665,6 +665,51 @@ pub fn code( Ok(()) } +fn find_or_create_folder( + access_token: &mut String, + refresh_token: &str, + db: &mut rbw::db::Db, + folder: &str, +) -> anyhow::Result { + let (new_access_token, folders) = rbw::actions::list_folders(&access_token, refresh_token)?; + + if let Some(new_access_token) = new_access_token { + access_token.clone_from(&new_access_token); + db.access_token = Some(new_access_token); + save_db(&db)?; + } + + let folders: Vec<(String, String)> = folders + .iter() + .cloned() + .map(|(id, name)| Ok((id, crate::actions::decrypt(&name, None, None)?))) + .collect::>()?; + + let folder_id = folders + .into_iter() + .find_map(|(id, name)| if name == folder { Some(id) } else { None }); + + let folder_id = if let Some(folder_id) = folder_id { + folder_id + } else { + let (new_access_token, id) = rbw::actions::create_folder( + &access_token, + refresh_token, + &crate::actions::encrypt(folder, None)?, + )?; + + if let Some(new_access_token) = new_access_token { + access_token.clone_from(&new_access_token); + db.access_token = Some(new_access_token); + save_db(&db)?; + } + + id + }; + + Ok(folder_id) +} + pub fn add( name: &str, username: Option<&str>, @@ -677,7 +722,7 @@ pub fn add( // unwrap is safe here because the call to unlock above is guaranteed to // populate these or error let mut access_token = db.access_token.as_ref().unwrap().clone(); - let refresh_token = db.refresh_token.as_ref().unwrap(); + let refresh_token = db.refresh_token.as_ref().unwrap().clone(); let name = crate::actions::encrypt(name, None)?; @@ -704,44 +749,19 @@ pub fn add( }) .collect::>()?; - let mut folder_id = None; - if let Some(folder_name) = folder { - let (new_access_token, folders) = rbw::actions::list_folders(&access_token, refresh_token)?; - if let Some(new_access_token) = new_access_token { - access_token.clone_from(&new_access_token); - db.access_token = Some(new_access_token); - save_db(&db)?; - } - - let folders: Vec<(String, String)> = folders - .iter() - .cloned() - .map(|(id, name)| Ok((id, crate::actions::decrypt(&name, None, None)?))) - .collect::>()?; - - for (id, name) in folders { - if name == folder_name { - folder_id = Some(id); - } - } - if folder_id.is_none() { - let (new_access_token, id) = rbw::actions::create_folder( - &access_token, - refresh_token, - &crate::actions::encrypt(folder_name, None)?, - )?; - if let Some(new_access_token) = new_access_token { - access_token.clone_from(&new_access_token); - db.access_token = Some(new_access_token); - save_db(&db)?; - } - folder_id = Some(id); - } - } + let folder_id = match folder { + Some(folder) => Some(find_or_create_folder( + &mut access_token, + &refresh_token, + &mut db, + folder, + )?), + None => None, + }; if let (Some(access_token), ()) = rbw::actions::add( &access_token, - refresh_token, + &refresh_token, &name, &rbw::db::EntryData::Login { username, @@ -779,7 +799,7 @@ pub fn generate( // unwrap is safe here because the call to unlock above is guaranteed // to populate these or error let mut access_token = db.access_token.as_ref().unwrap().clone(); - let refresh_token = db.refresh_token.as_ref().unwrap(); + let refresh_token = db.refresh_token.as_ref().unwrap().clone(); let name = crate::actions::encrypt(name, None)?; let username = username @@ -796,45 +816,19 @@ pub fn generate( }) .collect::>()?; - let mut folder_id = None; - if let Some(folder_name) = folder { - let (new_access_token, folders) = - rbw::actions::list_folders(&access_token, refresh_token)?; - if let Some(new_access_token) = new_access_token { - access_token.clone_from(&new_access_token); - db.access_token = Some(new_access_token); - save_db(&db)?; - } - - let folders: Vec<(String, String)> = folders - .iter() - .cloned() - .map(|(id, name)| Ok((id, crate::actions::decrypt(&name, None, None)?))) - .collect::>()?; - - for (id, name) in folders { - if name == folder_name { - folder_id = Some(id); - } - } - if folder_id.is_none() { - let (new_access_token, id) = rbw::actions::create_folder( - &access_token, - refresh_token, - &crate::actions::encrypt(folder_name, None)?, - )?; - if let Some(new_access_token) = new_access_token { - access_token.clone_from(&new_access_token); - db.access_token = Some(new_access_token); - save_db(&db)?; - } - folder_id = Some(id); - } - } + let folder_id = match folder { + Some(folder) => Some(find_or_create_folder( + &mut access_token, + &refresh_token, + &mut db, + folder, + )?), + None => None, + }; if let (Some(access_token), ()) = rbw::actions::add( &access_token, - refresh_token, + &refresh_token, &name, &rbw::db::EntryData::Login { username, From 4facca01a6ce40c0e3731bbcf48f71b76cce259a Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 10 May 2026 19:14:21 +0200 Subject: [PATCH 046/273] move display procedures back into commands.rs. still need dedup and decoupling --- src/bin/rbw/commands.rs | 297 ++++++++++++++++++++++++++++++++++++++-- src/db.rs | 293 +-------------------------------------- 2 files changed, 287 insertions(+), 303 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 184e6338..ebe1ed75 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -490,6 +490,285 @@ pub fn list(fields: &[String], raw: bool) -> anyhow::Result<()> { Ok(()) } +fn display_field(name: &str, field: Option<&str>, clipboard: bool) -> bool { + field.map_or_else( + || false, + |field| val_display_or_store(clipboard, &format!("{name}: {field}")), + ) +} + +pub fn display_entry_field(entry: &rbw::db::Entry, desc: &str, field: &str, clipboard: bool) { + let fields = entry.get_fields(&field.to_lowercase(), generate_totp); + fields.iter().for_each(|f| { + val_display_or_store(clipboard, f); + }); +} + +pub fn display_entry_short(entry: &rbw::db::Entry, desc: &str, clipboard: bool) -> bool { + let short = entry.get_short(); + let Some(short) = short else { + // Would be cool if self.data had a method named main_field_name :D + eprintln!( + "entry for '{desc}' had no {}", + match entry.data { + EntryData::Login { .. } => "password", + EntryData::Card { .. } => "card number", + EntryData::Identity { .. } => "name", + EntryData::SecureNote => "notes", + EntryData::SshKey { .. } => "public key", + } + ); + return false; + }; + + val_display_or_store(clipboard, &short) +} + +/// This needs to be simplified +pub fn display_entry_long(entry: &rbw::db::Entry, desc: &str, clipboard: bool) { + let mut displayed = display_entry_short(entry, desc, clipboard); + match &entry.data { + EntryData::Login { + username, + totp, + uris, + .. + } => { + displayed |= display_field("Username", username.as_deref(), clipboard); + displayed |= display_field("TOTP Secret", totp.as_deref(), clipboard); + + for uri in uris { + displayed |= display_field("URI", Some(&uri.uri), clipboard); + let match_type = uri.match_type.map(|ty| format!("{ty}")); + displayed |= display_field("Match type", match_type.as_deref(), clipboard); + } + + for field in &entry.fields { + displayed |= display_field( + field.name.as_deref().unwrap_or("(null)"), + Some(field.value.as_deref().unwrap_or("")), + clipboard, + ); + } + } + EntryData::Card { + cardholder_name, + brand, + exp_month, + exp_year, + code, + .. + } => { + if let (Some(exp_month), Some(exp_year)) = (exp_month, exp_year) { + println!("Expiration: {exp_month}/{exp_year}"); + displayed = true; + } + displayed |= display_field("CVV", code.as_deref(), clipboard); + displayed |= display_field("Name", cardholder_name.as_deref(), clipboard); + displayed |= display_field("Brand", brand.as_deref(), clipboard); + } + EntryData::Identity { + address1, + address2, + address3, + city, + state, + postal_code, + country, + phone, + email, + ssn, + license_number, + passport_number, + username, + .. + } => { + displayed |= display_field("Address", address1.as_deref(), clipboard); + displayed |= display_field("Address", address2.as_deref(), clipboard); + displayed |= display_field("Address", address3.as_deref(), clipboard); + displayed |= display_field("City", city.as_deref(), clipboard); + displayed |= display_field("State", state.as_deref(), clipboard); + displayed |= display_field("Postcode", postal_code.as_deref(), clipboard); + displayed |= display_field("Country", country.as_deref(), clipboard); + displayed |= display_field("Phone", phone.as_deref(), clipboard); + displayed |= display_field("Email", email.as_deref(), clipboard); + displayed |= display_field("SSN", ssn.as_deref(), clipboard); + displayed |= display_field("License", license_number.as_deref(), clipboard); + displayed |= display_field("Passport", passport_number.as_deref(), clipboard); + displayed |= display_field("Username", username.as_deref(), clipboard); + } + EntryData::SecureNote => {} + EntryData::SshKey { fingerprint, .. } => { + displayed |= display_field("Fingerprint", fingerprint.as_deref(), clipboard); + + for field in &entry.fields { + displayed |= display_field( + field.name.as_deref().unwrap_or("(null)"), + Some(field.value.as_deref().unwrap_or("")), + clipboard, + ); + } + } + } + + if !matches!(entry.data, EntryData::SecureNote) { + if let Some(notes) = &entry.notes { + if displayed { + println!(); + } + println!("{notes}"); + } + } +} + +/// This implementation mirror the `fn display_fied` method on which field to list +pub fn display_fields_list(entry: &rbw::db::Entry) { + match &entry.data { + EntryData::Login { + username, + password, + totp, + uris, + .. + } => { + if username.is_some() { + println!("{}", rbw::db::FieldType::Username); + } + if totp.is_some() { + println!("{}", rbw::db::FieldType::Totp); + } + if !uris.is_empty() { + println!("{}", rbw::db::FieldType::Uris); + } + if password.is_some() { + println!("{}", rbw::db::FieldType::Password); + } + } + EntryData::Card { + cardholder_name, + number, + brand, + exp_month, + exp_year, + code, + .. + } => { + if number.is_some() { + println!("{}", rbw::db::FieldType::CardNumber); + } + if exp_month.is_some() { + println!("{}", rbw::db::FieldType::ExpMonth); + } + if exp_year.is_some() { + println!("{}", rbw::db::FieldType::ExpYear); + } + if code.is_some() { + println!("{}", rbw::db::FieldType::Cvv); + } + if cardholder_name.is_some() { + println!("{}", rbw::db::FieldType::Cardholder); + } + if brand.is_some() { + println!("{}", rbw::db::FieldType::Brand); + } + } + + EntryData::Identity { + address1, + address2, + address3, + city, + state, + postal_code, + country, + phone, + email, + ssn, + license_number, + passport_number, + username, + title, + first_name, + middle_name, + last_name, + .. + } => { + if [title, first_name, middle_name, last_name] + .iter() + .any(|f| f.is_some()) + { + // the display_field combines all these fields together. + println!("name"); + } + if email.is_some() { + println!("{}", rbw::db::FieldType::Email); + } + if [address1, address2, address3].iter().any(|f| f.is_some()) { + // the display_field combines all these fields together. + println!("address"); + } + if city.is_some() { + println!("{}", rbw::db::FieldType::City); + } + if state.is_some() { + println!("{}", rbw::db::FieldType::State); + } + if postal_code.is_some() { + println!("{}", rbw::db::FieldType::PostalCode); + } + if country.is_some() { + println!("{}", rbw::db::FieldType::Country); + } + if phone.is_some() { + println!("{}", rbw::db::FieldType::Phone); + } + if ssn.is_some() { + println!("{}", rbw::db::FieldType::Ssn); + } + if license_number.is_some() { + println!("{}", rbw::db::FieldType::License); + } + if passport_number.is_some() { + println!("{}", rbw::db::FieldType::Passport); + } + if username.is_some() { + println!("{}", rbw::db::FieldType::Username); + } + } + + EntryData::SecureNote => (), // handled at the end + EntryData::SshKey { + fingerprint, + public_key, + .. + } => { + if fingerprint.is_some() { + println!("{}", rbw::db::FieldType::Fingerprint); + } + if public_key.is_some() { + println!("{}", rbw::db::FieldType::PublicKey); + } + } + } + + if entry.notes.is_some() { + println!("{}", rbw::db::FieldType::Notes); + } + for f in &entry.fields { + if let Some(name) = &f.name { + println!("{name}"); + } + } +} + +pub fn display_json(entry: &rbw::db::Entry, desc: &str) -> anyhow::Result<()> { + serde_json::to_writer_pretty(std::io::stdout(), entry) + .context(format!("failed to write entry '{desc}' to stdout"))?; + println!(); + + Ok(()) +} + #[allow(clippy::fn_params_excessive_bools)] pub fn get( needle: Needle, @@ -515,9 +794,9 @@ pub fn get( let (_, decrypted) = find_entry(&db, needle, user, folder, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; if list_fields { - decrypted.display_fields_list(); + display_fields_list(&decrypted); } else if raw { - decrypted.display_json(&desc)?; + display_json(&decrypted, &desc)?; } else { // if clipboard { // match clipboard_store(password) { @@ -529,17 +808,18 @@ pub fn get( // } // } if full { - decrypted.display_long(&desc, clipboard, val_display_or_store, display_field); + display_entry_long(&decrypted, &desc, clipboard); } else if let Some(field) = field { - decrypted.display_field(&desc, field, clipboard, val_display_or_store, generate_totp); + display_entry_field(&decrypted, &desc, field, clipboard); } else { - decrypted.display_short(&desc, clipboard, val_display_or_store); + display_entry_short(&decrypted, &desc, clipboard); } } Ok(()) } +/// Used in "search" and "list" fn print_entry_list( entries: &[DecryptedListCipher], fields: &[ListField], @@ -1692,13 +1972,6 @@ fn generate_totp(secret: &str) -> anyhow::Result { } } -fn display_field(name: &str, field: Option<&str>, clipboard: bool) -> bool { - field.map_or_else( - || false, - |field| val_display_or_store(clipboard, &format!("{name}: {field}")), - ) -} - #[cfg(test)] mod test { use super::*; diff --git a/src/db.rs b/src/db.rs index f579e9aa..32e11f4a 100644 --- a/src/db.rs +++ b/src/db.rs @@ -5,7 +5,6 @@ use std::{ io::{Read as _, Write as _}, }; -use anyhow::Context as _; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -211,7 +210,7 @@ impl Entry { self.master_password_reprompt != crate::api::CipherRepromptType::None } - fn get_short(&self) -> Option { + pub fn get_short(&self) -> Option { match &self.data { EntryData::Login { password, .. } => password.clone(), EntryData::Card { number, .. } => number.clone(), @@ -240,31 +239,6 @@ impl Entry { } } - pub fn display_short( - &self, - desc: &str, - clipboard: bool, - val_display_or_store: fn(bool, &str) -> bool, - ) -> bool { - let short = self.get_short(); - let Some(short) = short else { - // Would be cool if self.data had a method named main_field_name :D - eprintln!( - "entry for '{desc}' had no {}", - match &self.data { - EntryData::Login { .. } => "password", - EntryData::Card { .. } => "card number", - EntryData::Identity { .. } => "name", - EntryData::SecureNote => "notes", - EntryData::SshKey { .. } => "public key", - } - ); - return false; - }; - - val_display_or_store(clipboard, &short) - } - fn get_dynamic_fields(&self, name: &str) -> Vec> { self.fields .iter() @@ -288,7 +262,7 @@ impl Entry { /// available from the "static" fields, else go check for the dynamic ones. /// For example, if the EntryData is of type EntryData::Login, try to extract the username from the /// static fields, but if the field param is "state", search for it through the dynamic ones. - fn get_fields( + pub fn get_fields( &self, field: &str, generate_totp: fn(&str) -> anyhow::Result, @@ -434,269 +408,6 @@ impl Entry { ret.into_iter().flatten().collect() } - pub fn display_field( - &self, - desc: &str, - field: &str, - clipboard: bool, - val_display_or_store: fn(bool, &str) -> bool, - generate_totp: fn(&str) -> anyhow::Result, - ) { - let fields = self.get_fields(&field.to_lowercase(), generate_totp); - fields.iter().for_each(|f| { - val_display_or_store(clipboard, f); - }); - } - - pub fn display_long( - &self, - desc: &str, - clipboard: bool, - val_display_or_store: fn(bool, &str) -> bool, - display_field: fn(&str, Option<&str>, bool) -> bool, - ) { - let mut displayed = self.display_short(desc, clipboard, val_display_or_store); - match &self.data { - EntryData::Login { - username, - totp, - uris, - .. - } => { - displayed |= display_field("Username", username.as_deref(), clipboard); - displayed |= display_field("TOTP Secret", totp.as_deref(), clipboard); - - for uri in uris { - displayed |= display_field("URI", Some(&uri.uri), clipboard); - let match_type = uri.match_type.map(|ty| format!("{ty}")); - displayed |= display_field("Match type", match_type.as_deref(), clipboard); - } - - for field in &self.fields { - displayed |= display_field( - field.name.as_deref().unwrap_or("(null)"), - Some(field.value.as_deref().unwrap_or("")), - clipboard, - ); - } - } - EntryData::Card { - cardholder_name, - brand, - exp_month, - exp_year, - code, - .. - } => { - if let (Some(exp_month), Some(exp_year)) = (exp_month, exp_year) { - println!("Expiration: {exp_month}/{exp_year}"); - displayed = true; - } - displayed |= display_field("CVV", code.as_deref(), clipboard); - displayed |= display_field("Name", cardholder_name.as_deref(), clipboard); - displayed |= display_field("Brand", brand.as_deref(), clipboard); - } - EntryData::Identity { - address1, - address2, - address3, - city, - state, - postal_code, - country, - phone, - email, - ssn, - license_number, - passport_number, - username, - .. - } => { - displayed |= display_field("Address", address1.as_deref(), clipboard); - displayed |= display_field("Address", address2.as_deref(), clipboard); - displayed |= display_field("Address", address3.as_deref(), clipboard); - displayed |= display_field("City", city.as_deref(), clipboard); - displayed |= display_field("State", state.as_deref(), clipboard); - displayed |= display_field("Postcode", postal_code.as_deref(), clipboard); - displayed |= display_field("Country", country.as_deref(), clipboard); - displayed |= display_field("Phone", phone.as_deref(), clipboard); - displayed |= display_field("Email", email.as_deref(), clipboard); - displayed |= display_field("SSN", ssn.as_deref(), clipboard); - displayed |= display_field("License", license_number.as_deref(), clipboard); - displayed |= display_field("Passport", passport_number.as_deref(), clipboard); - displayed |= display_field("Username", username.as_deref(), clipboard); - } - EntryData::SecureNote => {} - EntryData::SshKey { fingerprint, .. } => { - displayed |= display_field("Fingerprint", fingerprint.as_deref(), clipboard); - - for field in &self.fields { - displayed |= display_field( - field.name.as_deref().unwrap_or("(null)"), - Some(field.value.as_deref().unwrap_or("")), - clipboard, - ); - } - } - } - - if !matches!(&self.data, EntryData::SecureNote) { - if let Some(notes) = &self.notes { - if displayed { - println!(); - } - println!("{notes}"); - } - } - } - - /// This implementation mirror the `fn display_fied` method on which field to list - pub fn display_fields_list(&self) { - match &self.data { - EntryData::Login { - username, - password, - totp, - uris, - .. - } => { - if username.is_some() { - println!("{}", FieldType::Username); - } - if totp.is_some() { - println!("{}", FieldType::Totp); - } - if !uris.is_empty() { - println!("{}", FieldType::Uris); - } - if password.is_some() { - println!("{}", FieldType::Password); - } - } - EntryData::Card { - cardholder_name, - number, - brand, - exp_month, - exp_year, - code, - .. - } => { - if number.is_some() { - println!("{}", FieldType::CardNumber); - } - if exp_month.is_some() { - println!("{}", FieldType::ExpMonth); - } - if exp_year.is_some() { - println!("{}", FieldType::ExpYear); - } - if code.is_some() { - println!("{}", FieldType::Cvv); - } - if cardholder_name.is_some() { - println!("{}", FieldType::Cardholder); - } - if brand.is_some() { - println!("{}", FieldType::Brand); - } - } - - EntryData::Identity { - address1, - address2, - address3, - city, - state, - postal_code, - country, - phone, - email, - ssn, - license_number, - passport_number, - username, - title, - first_name, - middle_name, - last_name, - .. - } => { - if [title, first_name, middle_name, last_name] - .iter() - .any(|f| f.is_some()) - { - // the display_field combines all these fields together. - println!("name"); - } - if email.is_some() { - println!("{}", FieldType::Email); - } - if [address1, address2, address3].iter().any(|f| f.is_some()) { - // the display_field combines all these fields together. - println!("address"); - } - if city.is_some() { - println!("{}", FieldType::City); - } - if state.is_some() { - println!("{}", FieldType::State); - } - if postal_code.is_some() { - println!("{}", FieldType::PostalCode); - } - if country.is_some() { - println!("{}", FieldType::Country); - } - if phone.is_some() { - println!("{}", FieldType::Phone); - } - if ssn.is_some() { - println!("{}", FieldType::Ssn); - } - if license_number.is_some() { - println!("{}", FieldType::License); - } - if passport_number.is_some() { - println!("{}", FieldType::Passport); - } - if username.is_some() { - println!("{}", FieldType::Username); - } - } - - EntryData::SecureNote => (), // handled at the end - EntryData::SshKey { - fingerprint, - public_key, - .. - } => { - if fingerprint.is_some() { - println!("{}", FieldType::Fingerprint); - } - if public_key.is_some() { - println!("{}", FieldType::PublicKey); - } - } - } - - if self.notes.is_some() { - println!("{}", FieldType::Notes); - } - for f in &self.fields { - if let Some(name) = &f.name { - println!("{name}"); - } - } - } - - pub fn display_json(&self, desc: &str) -> anyhow::Result<()> { - serde_json::to_writer_pretty(std::io::stdout(), &self) - .context(format!("failed to write entry '{desc}' to stdout"))?; - println!(); - - Ok(()) - } } #[derive(serde::Serialize, Debug, Clone, Eq, PartialEq)] From c59878c499c1d9154dd77affc0641488489f67f0 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 10 May 2026 19:28:59 +0200 Subject: [PATCH 047/273] improve naming and spacing of display fns --- src/bin/rbw/commands.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index ebe1ed75..71a8abe9 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -525,7 +525,7 @@ pub fn display_entry_short(entry: &rbw::db::Entry, desc: &str, clipboard: bool) } /// This needs to be simplified -pub fn display_entry_long(entry: &rbw::db::Entry, desc: &str, clipboard: bool) { +pub fn display_entry_full(entry: &rbw::db::Entry, desc: &str, clipboard: bool) { let mut displayed = display_entry_short(entry, desc, clipboard); match &entry.data { EntryData::Login { @@ -622,7 +622,7 @@ pub fn display_entry_long(entry: &rbw::db::Entry, desc: &str, clipboard: bool) { } /// This implementation mirror the `fn display_fied` method on which field to list -pub fn display_fields_list(entry: &rbw::db::Entry) { +pub fn display_entry_fields_list(entry: &rbw::db::Entry) { match &entry.data { EntryData::Login { username, @@ -761,7 +761,7 @@ pub fn display_fields_list(entry: &rbw::db::Entry) { } } -pub fn display_json(entry: &rbw::db::Entry, desc: &str) -> anyhow::Result<()> { +pub fn display_entry_json(entry: &rbw::db::Entry, desc: &str) -> anyhow::Result<()> { serde_json::to_writer_pretty(std::io::stdout(), entry) .context(format!("failed to write entry '{desc}' to stdout"))?; println!(); @@ -793,10 +793,11 @@ pub fn get( let (_, decrypted) = find_entry(&db, needle, user, folder, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; + if list_fields { - display_fields_list(&decrypted); + display_entry_fields_list(&decrypted); } else if raw { - display_json(&decrypted, &desc)?; + display_entry_json(&decrypted, &desc)?; } else { // if clipboard { // match clipboard_store(password) { @@ -808,7 +809,7 @@ pub fn get( // } // } if full { - display_entry_long(&decrypted, &desc, clipboard); + display_entry_full(&decrypted, &desc, clipboard); } else if let Some(field) = field { display_entry_field(&decrypted, &desc, field, clipboard); } else { From 6c034b640c5f6b197ecbac002dd7abf50c768fe1 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 10 May 2026 19:36:23 +0200 Subject: [PATCH 048/273] fix missing message in case no field is found in display_entry_field --- src/bin/rbw/commands.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 71a8abe9..6cd90feb 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -499,9 +499,14 @@ fn display_field(name: &str, field: Option<&str>, clipboard: bool) -> bool { pub fn display_entry_field(entry: &rbw::db::Entry, desc: &str, field: &str, clipboard: bool) { let fields = entry.get_fields(&field.to_lowercase(), generate_totp); - fields.iter().for_each(|f| { - val_display_or_store(clipboard, f); - }); + if fields.is_empty() { + // TODO: This is not 100% compatible text output with the project before refactor. + println!("entry for '{desc}' had no default field"); + } else { + fields.iter().for_each(|f| { + val_display_or_store(clipboard, f); + }); + } } pub fn display_entry_short(entry: &rbw::db::Entry, desc: &str, clipboard: bool) -> bool { From c212ee76953751950303905bed1550e4ac7204db Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 10 May 2026 21:03:58 +0200 Subject: [PATCH 049/273] small comment regarding "short" --- src/db.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/db.rs b/src/db.rs index 32e11f4a..2370b2b1 100644 --- a/src/db.rs +++ b/src/db.rs @@ -210,6 +210,8 @@ impl Entry { self.master_password_reprompt != crate::api::CipherRepromptType::None } + /// The "short" is the first field that comes to mind when speaking of a entry, like the + /// password for the Login , the number for the Card, etc. pub fn get_short(&self) -> Option { match &self.data { EntryData::Login { password, .. } => password.clone(), @@ -407,7 +409,6 @@ impl Entry { ret.into_iter().flatten().collect() } - } #[derive(serde::Serialize, Debug, Clone, Eq, PartialEq)] From 989474255a16cac1d4da5c1a0bd115ea35045a99 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 10 May 2026 21:06:23 +0200 Subject: [PATCH 050/273] improve naming and comment --- src/bin/rbw/commands.rs | 2 +- src/db.rs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 6cd90feb..b05a0918 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -498,7 +498,7 @@ fn display_field(name: &str, field: Option<&str>, clipboard: bool) -> bool { } pub fn display_entry_field(entry: &rbw::db::Entry, desc: &str, field: &str, clipboard: bool) { - let fields = entry.get_fields(&field.to_lowercase(), generate_totp); + let fields = entry.get_field(&field.to_lowercase(), generate_totp); if fields.is_empty() { // TODO: This is not 100% compatible text output with the project before refactor. println!("entry for '{desc}' had no default field"); diff --git a/src/db.rs b/src/db.rs index 2370b2b1..88c1b34b 100644 --- a/src/db.rs +++ b/src/db.rs @@ -263,8 +263,9 @@ impl Entry { /// check which type of entry EntryData is and extract the "username" or "cardnumber" field if /// available from the "static" fields, else go check for the dynamic ones. /// For example, if the EntryData is of type EntryData::Login, try to extract the username from the - /// static fields, but if the field param is "state", search for it through the dynamic ones. - pub fn get_fields( + /// static username field, but if the field param is not within the static fields, search for it through the dynamic ones. + /// The dynamic fields are the user's added ones and labeled as "Custom field" in GUI apps. + pub fn get_field( &self, field: &str, generate_totp: fn(&str) -> anyhow::Result, From bb21e4829adbf109c83bd567c75892d6956e6d7e Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 10 May 2026 22:23:51 +0200 Subject: [PATCH 051/273] move --full printing logic into Display trait impl for Entry --- src/bin/rbw/commands.rs | 105 ++----------------------------- src/db.rs | 134 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 99 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index b05a0918..695a4e74 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -501,7 +501,7 @@ pub fn display_entry_field(entry: &rbw::db::Entry, desc: &str, field: &str, clip let fields = entry.get_field(&field.to_lowercase(), generate_totp); if fields.is_empty() { // TODO: This is not 100% compatible text output with the project before refactor. - println!("entry for '{desc}' had no default field"); + eprintln!("entry for '{desc}' had no default field"); } else { fields.iter().for_each(|f| { val_display_or_store(clipboard, f); @@ -529,103 +529,6 @@ pub fn display_entry_short(entry: &rbw::db::Entry, desc: &str, clipboard: bool) val_display_or_store(clipboard, &short) } -/// This needs to be simplified -pub fn display_entry_full(entry: &rbw::db::Entry, desc: &str, clipboard: bool) { - let mut displayed = display_entry_short(entry, desc, clipboard); - match &entry.data { - EntryData::Login { - username, - totp, - uris, - .. - } => { - displayed |= display_field("Username", username.as_deref(), clipboard); - displayed |= display_field("TOTP Secret", totp.as_deref(), clipboard); - - for uri in uris { - displayed |= display_field("URI", Some(&uri.uri), clipboard); - let match_type = uri.match_type.map(|ty| format!("{ty}")); - displayed |= display_field("Match type", match_type.as_deref(), clipboard); - } - - for field in &entry.fields { - displayed |= display_field( - field.name.as_deref().unwrap_or("(null)"), - Some(field.value.as_deref().unwrap_or("")), - clipboard, - ); - } - } - EntryData::Card { - cardholder_name, - brand, - exp_month, - exp_year, - code, - .. - } => { - if let (Some(exp_month), Some(exp_year)) = (exp_month, exp_year) { - println!("Expiration: {exp_month}/{exp_year}"); - displayed = true; - } - displayed |= display_field("CVV", code.as_deref(), clipboard); - displayed |= display_field("Name", cardholder_name.as_deref(), clipboard); - displayed |= display_field("Brand", brand.as_deref(), clipboard); - } - EntryData::Identity { - address1, - address2, - address3, - city, - state, - postal_code, - country, - phone, - email, - ssn, - license_number, - passport_number, - username, - .. - } => { - displayed |= display_field("Address", address1.as_deref(), clipboard); - displayed |= display_field("Address", address2.as_deref(), clipboard); - displayed |= display_field("Address", address3.as_deref(), clipboard); - displayed |= display_field("City", city.as_deref(), clipboard); - displayed |= display_field("State", state.as_deref(), clipboard); - displayed |= display_field("Postcode", postal_code.as_deref(), clipboard); - displayed |= display_field("Country", country.as_deref(), clipboard); - displayed |= display_field("Phone", phone.as_deref(), clipboard); - displayed |= display_field("Email", email.as_deref(), clipboard); - displayed |= display_field("SSN", ssn.as_deref(), clipboard); - displayed |= display_field("License", license_number.as_deref(), clipboard); - displayed |= display_field("Passport", passport_number.as_deref(), clipboard); - displayed |= display_field("Username", username.as_deref(), clipboard); - } - EntryData::SecureNote => {} - EntryData::SshKey { fingerprint, .. } => { - displayed |= display_field("Fingerprint", fingerprint.as_deref(), clipboard); - - for field in &entry.fields { - displayed |= display_field( - field.name.as_deref().unwrap_or("(null)"), - Some(field.value.as_deref().unwrap_or("")), - clipboard, - ); - } - } - } - - if !matches!(entry.data, EntryData::SecureNote) { - if let Some(notes) = &entry.notes { - if displayed { - println!(); - } - println!("{notes}"); - } - } -} - /// This implementation mirror the `fn display_fied` method on which field to list pub fn display_entry_fields_list(entry: &rbw::db::Entry) { match &entry.data { @@ -814,7 +717,11 @@ pub fn get( // } // } if full { - display_entry_full(&decrypted, &desc, clipboard); + if decrypted.get_short().is_none() { + eprintln!("entry for '{desc}' had no default field"); + } + + print!("{decrypted}"); } else if let Some(field) = field { display_entry_field(&decrypted, &desc, field, clipboard); } else { diff --git a/src/db.rs b/src/db.rs index 88c1b34b..98c5e5d3 100644 --- a/src/db.rs +++ b/src/db.rs @@ -412,12 +412,146 @@ impl Entry { } } +fn writefield( + f: &mut std::fmt::Formatter<'_>, + label: &str, + field: &Option, + displayed: &mut bool, +) -> std::fmt::Result { + if let Some(field) = field { + *displayed = true; + writeln!(f, "{label}: {field}") + } else { + Ok(()) + } +} + +impl Display for Entry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(short) = self.get_short() { + writeln!(f, "{short}")?; + } + + let mut d = false; + + match &self.data { + EntryData::Login { + username, + totp, + uris, + .. + } => { + writefield(f, "Username", username, &mut d)?; + writefield(f, "TOTP Secret", totp, &mut d)?; + + for uri in uris { + d = true; + write!(f, "{uri}")?; + } + + for field in &self.fields { + d = true; + writeln!( + f, + "{}: {}", + field.name.as_deref().unwrap_or("(null)"), + field.value.as_deref().unwrap_or("") + )?; + } + } + EntryData::Card { + cardholder_name, + brand, + exp_month, + exp_year, + code, + .. + } => { + if let (Some(m), Some(y)) = (exp_month, exp_year) { + writefield(f, "Expiration", &Some(format!("{m}/{y}")), &mut d)?; + } + + writefield(f, "CVV", code, &mut d)?; + writefield(f, "Name", cardholder_name, &mut d)?; + writefield(f, "Brand", brand, &mut d)?; + } + EntryData::Identity { + address1, + address2, + address3, + city, + state, + postal_code, + country, + phone, + email, + ssn, + license_number, + passport_number, + username, + .. + } => { + writefield(f, "Address", address1, &mut d)?; + writefield(f, "Address", address2, &mut d)?; + writefield(f, "Address", address3, &mut d)?; + writefield(f, "City", city, &mut d)?; + writefield(f, "State", state, &mut d)?; + writefield(f, "Postcode", postal_code, &mut d)?; + writefield(f, "Country", country, &mut d)?; + writefield(f, "Phone", phone, &mut d)?; + writefield(f, "Email", email, &mut d)?; + writefield(f, "SSN", ssn, &mut d)?; + writefield(f, "License", license_number, &mut d)?; + writefield(f, "Passport", passport_number, &mut d)?; + writefield(f, "Username", username, &mut d)?; + } + EntryData::SecureNote => {} + EntryData::SshKey { fingerprint, .. } => { + writefield(f, "Fingerprint", fingerprint, &mut d)?; + + for field in &self.fields { + d = true; + writeln!( + f, + "{}: {}", + field.name.as_deref().unwrap_or("(null)"), + field.value.as_deref().unwrap_or("") + )?; + } + } + } + + if !matches!(self.data, EntryData::SecureNote) { + if let Some(notes) = &self.notes { + if d { + println!(); + } + println!("{notes}"); + } + } + + Ok(()) + } +} + #[derive(serde::Serialize, Debug, Clone, Eq, PartialEq)] pub struct Uri { pub uri: String, pub match_type: Option, } +impl Display for Uri { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "URI: {}", &self.uri)?; + + if let Some(ty) = self.match_type { + writeln!(f, "Match type: {ty}")?; + } + + Ok(()) + } +} + // backwards compatibility impl<'de> serde::Deserialize<'de> for Uri { fn deserialize(deserializer: D) -> std::result::Result From 4dce25c07168d1100ec779220ebdf142710a171a Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 10 May 2026 22:57:14 +0200 Subject: [PATCH 052/273] enable copying to clipboard and add some useful comments around --- src/bin/rbw/commands.rs | 53 +++++++++++++++++++++++------------------ src/db.rs | 5 ++++ 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 695a4e74..06694e09 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -490,26 +490,19 @@ pub fn list(fields: &[String], raw: bool) -> anyhow::Result<()> { Ok(()) } -fn display_field(name: &str, field: Option<&str>, clipboard: bool) -> bool { - field.map_or_else( - || false, - |field| val_display_or_store(clipboard, &format!("{name}: {field}")), - ) -} - -pub fn display_entry_field(entry: &rbw::db::Entry, desc: &str, field: &str, clipboard: bool) { +pub fn display_entry_field(entry: &rbw::db::Entry, desc: &str, field: &str) { let fields = entry.get_field(&field.to_lowercase(), generate_totp); if fields.is_empty() { // TODO: This is not 100% compatible text output with the project before refactor. eprintln!("entry for '{desc}' had no default field"); } else { fields.iter().for_each(|f| { - val_display_or_store(clipboard, f); + println!("{f}"); }); } } -pub fn display_entry_short(entry: &rbw::db::Entry, desc: &str, clipboard: bool) -> bool { +pub fn display_entry_short(entry: &rbw::db::Entry, desc: &str) -> bool { let short = entry.get_short(); let Some(short) = short else { // Would be cool if self.data had a method named main_field_name :D @@ -526,7 +519,8 @@ pub fn display_entry_short(entry: &rbw::db::Entry, desc: &str, clipboard: bool) return false; }; - val_display_or_store(clipboard, &short) + println!("{short}"); + true } /// This implementation mirror the `fn display_fied` method on which field to list @@ -707,25 +701,38 @@ pub fn get( } else if raw { display_entry_json(&decrypted, &desc)?; } else { - // if clipboard { - // match clipboard_store(password) { - // Ok(()) => true, - // Err(e) => { - // eprintln!("{e}"); - // false - // } - // } - // } + let short = decrypted.get_short(); + + if clipboard { + if let Some(field) = &field { + let value = decrypted.get_field(field, generate_totp); + if let Err(e) = clipboard_store(&value.join(" ")) { + eprintln!("{e}"); + } + } else if let Some(short) = &short { + if let Err(e) = clipboard_store(short) { + eprintln!("{e}"); + } + } + } + if full { - if decrypted.get_short().is_none() { + // NOTE: In the previous version this printed "password", etc, the name of the "short" + // field. + if short.is_none() { eprintln!("entry for '{desc}' had no default field"); } + // NOTE: This printing is 99% backwards compatible, but the previous version was putting + // EVERY field in the clipboard sequentially, leaving only the last at the end of course. + // This behavior is unwanted, unnecessary and makes the code messy and for these reason + // it has been removed. Now when specifying --clipboard, only the "short" field or the + // --field value gets copied. print!("{decrypted}"); } else if let Some(field) = field { - display_entry_field(&decrypted, &desc, field, clipboard); + display_entry_field(&decrypted, &desc, field); } else { - display_entry_short(&decrypted, &desc, clipboard); + display_entry_short(&decrypted, &desc); } } diff --git a/src/db.rs b/src/db.rs index 98c5e5d3..23628165 100644 --- a/src/db.rs +++ b/src/db.rs @@ -241,6 +241,8 @@ impl Entry { } } + /// Get all the custom fields defined by the user with the same name. Yes there can be more + /// than one custom field with the same name. Don't ask me why. fn get_dynamic_fields(&self, name: &str) -> Vec> { self.fields .iter() @@ -426,6 +428,9 @@ fn writefield( } } +/// Display impl is a bit messy as we need to support previous output format. +/// I would, for example, yank this displayed bool and always print Notes after ---. +/// I would avoid printing the "short" field this way too, but rather print it as a normal field. impl Display for Entry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if let Some(short) = self.get_short() { From 5728b0272675fdf5ccb333dc5736ffbffebcb941 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 11 May 2026 12:02:39 +0200 Subject: [PATCH 053/273] remove val_display_or_store --- src/bin/rbw/commands.rs | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 06694e09..4207f992 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -209,21 +209,6 @@ impl From for DecryptedListCipher { } } -fn val_display_or_store(clipboard: bool, password: &str) -> bool { - if clipboard { - match clipboard_store(password) { - Ok(()) => true, - Err(e) => { - eprintln!("{e}"); - false - } - } - } else { - println!("{password}"); - true - } -} - fn matches_url( url: &str, match_type: Option, @@ -854,7 +839,14 @@ pub fn code( if let EntryData::Login { totp, .. } = decrypted.data { if let Some(totp) = totp { - val_display_or_store(clipboard, &generate_totp(&totp)?); + let code = generate_totp(&totp)?; + if clipboard { + if let Err(e) = clipboard_store(&code) { + eprintln!("{e}"); + } + } else { + println!("{code}"); + } } else { return Err(anyhow::anyhow!("entry does not contain a totp secret")); } From 4983b70dedccbc2d1f307edb2f70e6b8c48a4689 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 11 May 2026 14:15:21 +0200 Subject: [PATCH 054/273] make difference between Encrypted and Decrypted entries --- src/actions.rs | 6 ++--- src/api.rs | 20 +++++++++------- src/bin/rbw-agent/state.rs | 2 +- src/bin/rbw/commands.rs | 48 ++++++++++++++++++++------------------ src/db.rs | 19 +++++++++++---- 5 files changed, 55 insertions(+), 40 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index 1fdc527d..292fca5e 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -1,4 +1,4 @@ -use crate::prelude::*; +use crate::{db::Encrypted, prelude::*}; pub async fn register(email: &str, apikey: crate::locked::ApiKey) -> Result<()> { let (client, config) = api_client_async().await?; @@ -118,7 +118,7 @@ pub async fn sync( String, String, std::collections::HashMap, - Vec, + Vec>, ), )> { with_exchange_refresh_token_async(access_token, refresh_token, |access_token| { @@ -134,7 +134,7 @@ async fn sync_once( String, String, std::collections::HashMap, - Vec, + Vec>, )> { let (client, _) = api_client_async().await?; client.sync(access_token).await diff --git a/src/api.rs b/src/api.rs index 198cf67e..694fc6c6 100644 --- a/src/api.rs +++ b/src/api.rs @@ -3,10 +3,14 @@ #![allow(clippy::as_conversions)] use std::{ - collections::HashMap, fmt::Display, path::{Path, PathBuf}, str::FromStr, sync::Arc + collections::HashMap, + fmt::Display, + path::{Path, PathBuf}, + str::FromStr, + sync::Arc, }; -use crate::prelude::*; +use crate::{db::Encrypted, prelude::*}; use rand::distr::SampleString as _; use serde::{Deserialize, Serialize}; @@ -421,7 +425,7 @@ struct SyncResCipher { } impl SyncResCipher { - fn to_entry(&self, folders: &[SyncResFolder]) -> Option { + fn to_entry(&self, folders: &[SyncResFolder]) -> Option> { if self.deleted_date.is_some() { return None; } @@ -518,7 +522,7 @@ impl SyncResCipher { }) .collect() }); - Some(crate::db::Entry { + Some(crate::db::Entry:: { id: self.id.clone(), org_id: self.organization_id.clone(), folder, @@ -530,6 +534,7 @@ impl SyncResCipher { history, key: self.key.clone(), master_password_reprompt: self.reprompt, + _state: std::marker::PhantomData, }) } } @@ -1253,7 +1258,7 @@ impl Client { String, String, HashMap, - Vec, + Vec>, )> { let res = ClientRequest::Sync(access_token).req(self).await?; match res.status() { @@ -1507,10 +1512,7 @@ async fn handle_sso_callback( } } -fn sso_query_code( - params: &HashMap, - state: &str, -) -> Result { +fn sso_query_code(params: &HashMap, state: &str) -> Result { let sso_code = params .get("code") .ok_or(Error::FailedToProcessSSOCallback { diff --git a/src/bin/rbw-agent/state.rs b/src/bin/rbw-agent/state.rs index c1bd3fc1..3e9d5400 100644 --- a/src/bin/rbw-agent/state.rs +++ b/src/bin/rbw-agent/state.rs @@ -73,7 +73,7 @@ impl State { // if the agent gets a request for any of those cipherstrings that it saw // marked as master password reprompt during the most recent sync, it // forces a reprompt. - pub fn set_master_password_reprompt(&mut self, entries: &[rbw::db::Entry]) { + pub fn set_master_password_reprompt(&mut self, entries: &[rbw::db::Entry]) { self.master_password_reprompt.clear(); let mut hasher = sha2::Sha256::new(); diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 4207f992..936becc4 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -8,7 +8,7 @@ use std::{ }; use anyhow::Context as _; -use rbw::db::{EntryData, Uri}; +use rbw::db::{Decrypted, Encrypted, EntryData, Uri}; // The default number of seconds the generated TOTP // code lasts for before a new one must be generated @@ -475,7 +475,7 @@ pub fn list(fields: &[String], raw: bool) -> anyhow::Result<()> { Ok(()) } -pub fn display_entry_field(entry: &rbw::db::Entry, desc: &str, field: &str) { +pub fn display_entry_field(entry: &rbw::db::Entry, desc: &str, field: &str) { let fields = entry.get_field(&field.to_lowercase(), generate_totp); if fields.is_empty() { // TODO: This is not 100% compatible text output with the project before refactor. @@ -487,7 +487,7 @@ pub fn display_entry_field(entry: &rbw::db::Entry, desc: &str, field: &str) { } } -pub fn display_entry_short(entry: &rbw::db::Entry, desc: &str) -> bool { +pub fn display_entry_short(entry: &rbw::db::Entry, desc: &str) -> bool { let short = entry.get_short(); let Some(short) = short else { // Would be cool if self.data had a method named main_field_name :D @@ -509,7 +509,7 @@ pub fn display_entry_short(entry: &rbw::db::Entry, desc: &str) -> bool { } /// This implementation mirror the `fn display_fied` method on which field to list -pub fn display_entry_fields_list(entry: &rbw::db::Entry) { +pub fn display_entry_fields_list(entry: &rbw::db::Entry) { match &entry.data { EntryData::Login { username, @@ -648,7 +648,7 @@ pub fn display_entry_fields_list(entry: &rbw::db::Entry) { } } -pub fn display_entry_json(entry: &rbw::db::Entry, desc: &str) -> anyhow::Result<()> { +pub fn display_entry_json(entry: &rbw::db::Entry, desc: &str) -> anyhow::Result<()> { serde_json::to_writer_pretty(std::io::stdout(), entry) .context(format!("failed to write entry '{desc}' to stdout"))?; println!(); @@ -1287,34 +1287,34 @@ fn find_entry( username: Option<&str>, folder: Option<&str>, ignore_case: bool, -) -> anyhow::Result<(rbw::db::Entry, rbw::db::Entry)> { +) -> anyhow::Result<(rbw::db::Entry, rbw::db::Entry)> { if let Needle::Uuid(uuid, s) = needle { for cipher in &db.entries { if uuid::Uuid::parse_str(&cipher.id) == Ok(uuid) { - return Ok((cipher.clone(), decrypt_cipher(cipher)?)); + return Ok((cipher.clone(), decrypt_entry(&cipher)?)); } } needle = Needle::Name(s); } - let ciphers: Vec<(rbw::db::Entry, DecryptedSearchCipher)> = db + let ciphers: Vec<(rbw::db::Entry, DecryptedSearchCipher)> = db .entries .iter() .map(|entry| decrypt_search_cipher(entry).map(|decrypted| (entry.clone(), decrypted))) .collect::>()?; let (entry, _) = find_entry_raw(&ciphers, &needle, username, folder, ignore_case)?; - let decrypted_entry = decrypt_cipher(&entry)?; + let decrypted_entry = decrypt_entry(&entry)?; Ok((entry, decrypted_entry)) } fn find_entry_raw( - entries: &[(rbw::db::Entry, DecryptedSearchCipher)], + entries: &[(rbw::db::Entry, DecryptedSearchCipher)], needle: &Needle, username: Option<&str>, folder: Option<&str>, ignore_case: bool, -) -> anyhow::Result<(rbw::db::Entry, DecryptedSearchCipher)> { - let mut matches: Vec<(rbw::db::Entry, DecryptedSearchCipher)> = vec![]; +) -> anyhow::Result<(rbw::db::Entry, DecryptedSearchCipher)> { + let mut matches: Vec<(rbw::db::Entry, DecryptedSearchCipher)> = vec![]; let find_matches = |strict_username, strict_folder, exact| { entries @@ -1367,7 +1367,7 @@ fn find_entry_raw( } fn decrypt_list_cipher( - entry: &rbw::db::Entry, + entry: &rbw::db::Entry, fields: &[ListField], ) -> anyhow::Result { let id = entry.id.clone(); @@ -1444,7 +1444,7 @@ fn decrypt_list_cipher( }) } -fn decrypt_search_cipher(entry: &rbw::db::Entry) -> anyhow::Result { +fn decrypt_search_cipher(entry: &rbw::db::Entry) -> anyhow::Result { let id = entry.id.clone(); let name = crate::actions::decrypt(&entry.name, entry.key.as_deref(), entry.org_id.as_deref())?; let user = match &entry.data { @@ -1565,7 +1565,7 @@ fn decrypt_cipher_fields( .collect::>() } -fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { +fn decrypt_entry(entry: &rbw::db::Entry) -> anyhow::Result> { // folder name should always be decrypted with the local key because // folders are local to a specific user's vault, not the organization let folder = entry @@ -1700,7 +1700,7 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { }, }; - Ok(rbw::db::Entry { + Ok(rbw::db::Entry:: { id: entry.id.clone(), folder, folder_id: None, @@ -1712,6 +1712,7 @@ fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { notes, history, master_password_reprompt: rbw::api::CipherRepromptType::None, + _state: std::marker::PhantomData, }) } @@ -2898,7 +2899,7 @@ mod test { #[track_caller] fn one_match( - entries: &[(rbw::db::Entry, DecryptedSearchCipher)], + entries: &[(rbw::db::Entry, DecryptedSearchCipher)], needle: &str, username: Option<&str>, folder: Option<&str>, @@ -2920,7 +2921,7 @@ mod test { #[track_caller] fn no_matches( - entries: &[(rbw::db::Entry, DecryptedSearchCipher)], + entries: &[(rbw::db::Entry, DecryptedSearchCipher)], needle: &str, username: Option<&str>, folder: Option<&str>, @@ -2942,7 +2943,7 @@ mod test { #[track_caller] fn many_matches( - entries: &[(rbw::db::Entry, DecryptedSearchCipher)], + entries: &[(rbw::db::Entry, DecryptedSearchCipher)], needle: &str, username: Option<&str>, folder: Option<&str>, @@ -2964,8 +2965,8 @@ mod test { #[track_caller] fn entries_eq( - a: &(rbw::db::Entry, DecryptedSearchCipher), - b: &(rbw::db::Entry, DecryptedSearchCipher), + a: &(rbw::db::Entry, DecryptedSearchCipher), + b: &(rbw::db::Entry, DecryptedSearchCipher), ) -> bool { a.0 == b.0 && a.1 == b.1 } @@ -2975,10 +2976,10 @@ mod test { username: Option<&str>, folder: Option<&str>, uris: &[(&str, Option)], - ) -> (rbw::db::Entry, DecryptedSearchCipher) { + ) -> (rbw::db::Entry, DecryptedSearchCipher) { let id = uuid::Uuid::new_v4(); ( - rbw::db::Entry { + rbw::db::Entry:: { id: id.to_string(), org_id: None, folder: folder.map(|_| "encrypted folder name".to_string()), @@ -3001,6 +3002,7 @@ mod test { history: vec![], key: None, master_password_reprompt: rbw::api::CipherRepromptType::None, + _state: std::marker::PhantomData }, DecryptedSearchCipher { id: id.to_string(), diff --git a/src/db.rs b/src/db.rs index 23628165..ce52a898 100644 --- a/src/db.rs +++ b/src/db.rs @@ -188,8 +188,15 @@ pub struct HistoryEntry { pub password: String, } +// These are markers for type state pattern +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Encrypted; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Decrypted; + #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)] -pub struct Entry { +pub struct Entry { pub id: String, pub org_id: Option, pub folder: Option, @@ -201,15 +208,19 @@ pub struct Entry { pub history: Vec, pub key: Option, pub master_password_reprompt: crate::api::CipherRepromptType, + #[serde(skip)] + pub _state: std::marker::PhantomData, } // Most impl fn don't belong here. I am talking of display ones, but looking to relocate them // later in the refactor process. -impl Entry { +impl Entry { pub fn master_password_reprompt(&self) -> bool { self.master_password_reprompt != crate::api::CipherRepromptType::None } +} +impl Entry { /// The "short" is the first field that comes to mind when speaking of a entry, like the /// password for the Login , the number for the Card, etc. pub fn get_short(&self) -> Option { @@ -431,7 +442,7 @@ fn writefield( /// Display impl is a bit messy as we need to support previous output format. /// I would, for example, yank this displayed bool and always print Notes after ---. /// I would avoid printing the "short" field this way too, but rather print it as a normal field. -impl Display for Entry { +impl Display for Entry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if let Some(short) = self.get_short() { writeln!(f, "{short}")?; @@ -634,7 +645,7 @@ pub struct Db { pub protected_private_key: Option, pub protected_org_keys: std::collections::HashMap, - pub entries: Vec, + pub entries: Vec>, } impl Db { From 416f47b87c9af1cc5471562c546736a370c47e0e Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 13 May 2026 21:02:59 +0200 Subject: [PATCH 055/273] add some comments and change some structure names --- src/actions.rs | 2 +- src/api.rs | 4 +-- src/bin/rbw/commands.rs | 66 ++++++++++++++++++++--------------------- src/db.rs | 5 ++-- 4 files changed, 38 insertions(+), 39 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index 292fca5e..8d196d45 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -172,7 +172,7 @@ pub fn edit( org_id: Option<&str>, name: &str, data: &crate::db::EntryData, - fields: &[crate::db::Field], + fields: &[crate::db::DynamicField], notes: Option<&str>, folder_uuid: Option<&str>, history: &[crate::db::HistoryEntry], diff --git a/src/api.rs b/src/api.rs index 694fc6c6..935e3040 100644 --- a/src/api.rs +++ b/src/api.rs @@ -514,7 +514,7 @@ impl SyncResCipher { let fields = self.fields.as_ref().map_or_else(Vec::new, |fields| { fields .iter() - .map(|field| crate::db::Field { + .map(|field| crate::db::DynamicField { ty: field.ty, name: field.name.clone(), value: field.value.clone(), @@ -1323,7 +1323,7 @@ impl Client { org_id: Option<&str>, name: &str, data: &crate::db::EntryData, - fields: &[crate::db::Field], + fields: &[crate::db::DynamicField], notes: Option<&str>, folder_uuid: Option<&str>, history: &[crate::db::HistoryEntry], diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 936becc4..f04ce0b4 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -58,19 +58,20 @@ pub fn parse_needle(arg: &str) -> Result { /// It's a subset of db::Entry with only decrypted fields #[derive(Debug, serde::Serialize)] -struct DecryptedListCipher { +struct ListEntry { id: String, + #[serde(rename = "type")] + entry_type: Option, + folder: Option, name: Option, user: Option, - folder: Option, uris: Option>, - #[serde(rename = "type")] - entry_type: Option, } +/// TODO: This could be re-used as ListEntry as they have all fields #[derive(Debug, Clone, serde::Serialize)] #[cfg_attr(test, derive(Eq, PartialEq))] -struct DecryptedSearchCipher { +struct SearchEntry { id: String, #[serde(rename = "type")] entry_type: String, @@ -82,7 +83,7 @@ struct DecryptedSearchCipher { notes: Option, } -impl DecryptedSearchCipher { +impl SearchEntry { fn display_name(&self) -> String { self.user .as_ref() @@ -196,8 +197,8 @@ impl DecryptedSearchCipher { } } -impl From for DecryptedListCipher { - fn from(value: DecryptedSearchCipher) -> Self { +impl From for ListEntry { + fn from(value: SearchEntry) -> Self { Self { id: value.id, entry_type: Some(value.entry_type), @@ -271,6 +272,7 @@ fn host_port(url: &url::Url) -> Option { ) } +// TODO: This could be a dup of FieldType? #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ListField { Id, @@ -463,7 +465,7 @@ pub fn list(fields: &[String], raw: bool) -> anyhow::Result<()> { unlock()?; let db = load_db()?; - let mut entries: Vec = db + let mut entries: Vec = db .entries .iter() .map(|entry| decrypt_list_cipher(entry, &fields)) @@ -725,11 +727,7 @@ pub fn get( } /// Used in "search" and "list" -fn print_entry_list( - entries: &[DecryptedListCipher], - fields: &[ListField], - raw: bool, -) -> anyhow::Result<()> { +fn print_entry_list(entries: &[ListEntry], fields: &[ListField], raw: bool) -> anyhow::Result<()> { if raw { serde_json::to_writer_pretty(std::io::stdout(), &entries) .context("failed to write entries to stdout".to_string())?; @@ -798,7 +796,7 @@ pub fn search( let db = load_db()?; - let mut entries: Vec = db + let mut entries: Vec = db .entries .iter() .map(decrypt_search_cipher) @@ -1297,7 +1295,7 @@ fn find_entry( needle = Needle::Name(s); } - let ciphers: Vec<(rbw::db::Entry, DecryptedSearchCipher)> = db + let ciphers: Vec<(rbw::db::Entry, SearchEntry)> = db .entries .iter() .map(|entry| decrypt_search_cipher(entry).map(|decrypted| (entry.clone(), decrypted))) @@ -1308,13 +1306,13 @@ fn find_entry( } fn find_entry_raw( - entries: &[(rbw::db::Entry, DecryptedSearchCipher)], + entries: &[(rbw::db::Entry, SearchEntry)], needle: &Needle, username: Option<&str>, folder: Option<&str>, ignore_case: bool, -) -> anyhow::Result<(rbw::db::Entry, DecryptedSearchCipher)> { - let mut matches: Vec<(rbw::db::Entry, DecryptedSearchCipher)> = vec![]; +) -> anyhow::Result<(rbw::db::Entry, SearchEntry)> { + let mut matches: Vec<(rbw::db::Entry, SearchEntry)> = vec![]; let find_matches = |strict_username, strict_folder, exact| { entries @@ -1369,7 +1367,7 @@ fn find_entry_raw( fn decrypt_list_cipher( entry: &rbw::db::Entry, fields: &[ListField], -) -> anyhow::Result { +) -> anyhow::Result { let id = entry.id.clone(); let name = if fields.contains(&ListField::Name) { Some(crate::actions::decrypt( @@ -1434,7 +1432,7 @@ fn decrypt_list_cipher( }) .map(str::to_string); - Ok(DecryptedListCipher { + Ok(ListEntry { id, name, user, @@ -1444,7 +1442,7 @@ fn decrypt_list_cipher( }) } -fn decrypt_search_cipher(entry: &rbw::db::Entry) -> anyhow::Result { +fn decrypt_search_cipher(entry: &rbw::db::Entry) -> anyhow::Result { let id = entry.id.clone(); let name = crate::actions::decrypt(&entry.name, entry.key.as_deref(), entry.org_id.as_deref())?; let user = match &entry.data { @@ -1511,7 +1509,7 @@ fn decrypt_search_cipher(entry: &rbw::db::Entry) -> anyhow::Result, org_id: Option<&str>, -) -> anyhow::Result> { +) -> anyhow::Result> { fields .iter() .map(|field| { - Ok(rbw::db::Field { + Ok(rbw::db::DynamicField { name: decrypt_string(field.name.as_deref(), key, org_id)?, value: decrypt_string(field.value.as_deref(), key, org_id)?, ty: field.ty, @@ -2899,7 +2897,7 @@ mod test { #[track_caller] fn one_match( - entries: &[(rbw::db::Entry, DecryptedSearchCipher)], + entries: &[(rbw::db::Entry, SearchEntry)], needle: &str, username: Option<&str>, folder: Option<&str>, @@ -2921,7 +2919,7 @@ mod test { #[track_caller] fn no_matches( - entries: &[(rbw::db::Entry, DecryptedSearchCipher)], + entries: &[(rbw::db::Entry, SearchEntry)], needle: &str, username: Option<&str>, folder: Option<&str>, @@ -2943,7 +2941,7 @@ mod test { #[track_caller] fn many_matches( - entries: &[(rbw::db::Entry, DecryptedSearchCipher)], + entries: &[(rbw::db::Entry, SearchEntry)], needle: &str, username: Option<&str>, folder: Option<&str>, @@ -2965,8 +2963,8 @@ mod test { #[track_caller] fn entries_eq( - a: &(rbw::db::Entry, DecryptedSearchCipher), - b: &(rbw::db::Entry, DecryptedSearchCipher), + a: &(rbw::db::Entry, SearchEntry), + b: &(rbw::db::Entry, SearchEntry), ) -> bool { a.0 == b.0 && a.1 == b.1 } @@ -2976,7 +2974,7 @@ mod test { username: Option<&str>, folder: Option<&str>, uris: &[(&str, Option)], - ) -> (rbw::db::Entry, DecryptedSearchCipher) { + ) -> (rbw::db::Entry, SearchEntry) { let id = uuid::Uuid::new_v4(); ( rbw::db::Entry:: { @@ -3002,9 +3000,9 @@ mod test { history: vec![], key: None, master_password_reprompt: rbw::api::CipherRepromptType::None, - _state: std::marker::PhantomData + _state: std::marker::PhantomData, }, - DecryptedSearchCipher { + SearchEntry { id: id.to_string(), entry_type: "Login".to_string(), folder: folder.map(ToString::to_string), diff --git a/src/db.rs b/src/db.rs index ce52a898..e45c756e 100644 --- a/src/db.rs +++ b/src/db.rs @@ -131,8 +131,9 @@ impl Display for FieldType { } } +/// Used to describe custom fields in the application. #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)] -pub struct Field { +pub struct DynamicField { pub ty: Option, pub name: Option, pub value: Option, @@ -203,7 +204,7 @@ pub struct Entry { pub folder_id: Option, pub name: String, pub data: EntryData, - pub fields: Vec, + pub fields: Vec, pub notes: Option, pub history: Vec, pub key: Option, From 83aa2956d06a7895374c65f321ed2740c03c1bac Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 13 May 2026 21:58:40 +0200 Subject: [PATCH 056/273] rename decrypt custom fields --- src/bin/rbw/commands.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index f04ce0b4..8650544c 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1545,7 +1545,7 @@ fn decrypt_field_warn( }) } -fn decrypt_cipher_fields( +fn decrypt_entry_custom_fields( fields: &[rbw::db::DynamicField], key: Option<&str>, org_id: Option<&str>, @@ -1580,7 +1580,7 @@ fn decrypt_entry(entry: &rbw::db::Entry) -> anyhow::Result Date: Wed, 13 May 2026 23:59:58 +0200 Subject: [PATCH 057/273] abstract entry decryption and move it into db.rs --- src/bin/rbw/commands.rs | 189 ++++------------------------------------ src/db.rs | 159 +++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 171 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 8650544c..3240d431 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -56,6 +56,22 @@ pub fn parse_needle(arg: &str) -> Result { Ok(Needle::Name(arg.to_string())) } +struct Decrypter {} + +impl rbw::db::Decrypter for Decrypter { + fn decrypt_field( + &mut self, + entry: &rbw::db::Entry, + field: &str, + ) -> anyhow::Result { + Ok(crate::actions::decrypt( + field, + entry.key.as_deref(), + entry.org_id.as_deref(), + )?) + } +} + /// It's a subset of db::Entry with only decrypted fields #[derive(Debug, serde::Serialize)] struct ListEntry { @@ -1289,7 +1305,7 @@ fn find_entry( if let Needle::Uuid(uuid, s) = needle { for cipher in &db.entries { if uuid::Uuid::parse_str(&cipher.id) == Ok(uuid) { - return Ok((cipher.clone(), decrypt_entry(&cipher)?)); + return Ok((cipher.clone(), cipher.decrypt(&mut Decrypter {})?)); } } needle = Needle::Name(s); @@ -1301,7 +1317,7 @@ fn find_entry( .map(|entry| decrypt_search_cipher(entry).map(|decrypted| (entry.clone(), decrypted))) .collect::>()?; let (entry, _) = find_entry_raw(&ciphers, &needle, username, folder, ignore_case)?; - let decrypted_entry = decrypt_entry(&entry)?; + let decrypted_entry = entry.decrypt(&mut Decrypter {})?; Ok((entry, decrypted_entry)) } @@ -1545,175 +1561,6 @@ fn decrypt_field_warn( }) } -fn decrypt_entry_custom_fields( - fields: &[rbw::db::DynamicField], - key: Option<&str>, - org_id: Option<&str>, -) -> anyhow::Result> { - fields - .iter() - .map(|field| { - Ok(rbw::db::DynamicField { - name: decrypt_string(field.name.as_deref(), key, org_id)?, - value: decrypt_string(field.value.as_deref(), key, org_id)?, - ty: field.ty, - linked_id: None, - }) - }) - .collect::>() -} - -fn decrypt_entry(entry: &rbw::db::Entry) -> anyhow::Result> { - // folder name should always be decrypted with the local key because - // folders are local to a specific user's vault, not the organization - let folder = entry - .folder - .as_ref() - .map(|folder| crate::actions::decrypt(folder, None, None)) - .transpose(); - let folder = match folder { - Ok(folder) => folder, - Err(e) => { - log::warn!("failed to decrypt folder name: {e}"); - None - } - }; - - let fields = - decrypt_entry_custom_fields(&entry.fields, entry.key.as_deref(), entry.org_id.as_deref())?; - - let notes = entry - .notes - .as_ref() - .map(|notes| crate::actions::decrypt(notes, entry.key.as_deref(), entry.org_id.as_deref())) - .transpose(); - let notes = match notes { - Ok(notes) => notes, - Err(e) => { - log::warn!("failed to decrypt notes: {e}"); - None - } - }; - let history = entry - .history - .iter() - .map(|history_entry| { - Ok(rbw::db::HistoryEntry { - last_used_date: history_entry.last_used_date.clone(), - password: crate::actions::decrypt( - &history_entry.password, - entry.key.as_deref(), - entry.org_id.as_deref(), - )?, - }) - }) - .collect::>()?; - - let df = |ft, val: Option<&str>| { - decrypt_field_warn(ft, val, entry.key.as_deref(), entry.org_id.as_deref()) - }; - - let data = match &entry.data { - rbw::db::EntryData::Login { - username, - password, - totp, - uris, - } => EntryData::Login { - username: df(rbw::db::FieldType::Username, username.as_deref()), - password: df(rbw::db::FieldType::Password, password.as_deref()), - totp: df(rbw::db::FieldType::Totp, totp.as_deref()), - uris: uris - .iter() - .map(|s| { - df(rbw::db::FieldType::Uris, Some(&s.uri)).map(|uri| Uri { - uri, - match_type: s.match_type, - }) - }) - .flatten() - .collect(), - }, - rbw::db::EntryData::Card { - cardholder_name, - number, - brand, - exp_month, - exp_year, - code, - } => EntryData::Card { - cardholder_name: df(rbw::db::FieldType::Cardholder, cardholder_name.as_deref()), - number: df(rbw::db::FieldType::CardNumber, number.as_deref()), - brand: df(rbw::db::FieldType::Brand, brand.as_deref()), - exp_month: df(rbw::db::FieldType::ExpMonth, exp_month.as_deref()), - exp_year: df(rbw::db::FieldType::ExpYear, exp_year.as_deref()), - code: df(rbw::db::FieldType::Cvv, code.as_deref()), - }, - rbw::db::EntryData::Identity { - title, - first_name, - middle_name, - last_name, - address1, - address2, - address3, - city, - state, - postal_code, - country, - phone, - email, - ssn, - license_number, - passport_number, - username, - } => EntryData::Identity { - title: df(rbw::db::FieldType::Title, title.as_deref()), - first_name: df(rbw::db::FieldType::FirstName, first_name.as_deref()), - middle_name: df(rbw::db::FieldType::MiddleName, middle_name.as_deref()), - last_name: df(rbw::db::FieldType::LastName, last_name.as_deref()), - address1: df(rbw::db::FieldType::Address1, address1.as_deref()), - address2: df(rbw::db::FieldType::Address2, address2.as_deref()), - address3: df(rbw::db::FieldType::Address3, address3.as_deref()), - city: df(rbw::db::FieldType::City, city.as_deref()), - state: df(rbw::db::FieldType::State, state.as_deref()), - postal_code: df(rbw::db::FieldType::PostalCode, postal_code.as_deref()), - country: df(rbw::db::FieldType::Country, country.as_deref()), - phone: df(rbw::db::FieldType::Phone, phone.as_deref()), - email: df(rbw::db::FieldType::Email, email.as_deref()), - ssn: df(rbw::db::FieldType::Ssn, ssn.as_deref()), - license_number: df(rbw::db::FieldType::License, license_number.as_deref()), - passport_number: df(rbw::db::FieldType::Passport, passport_number.as_deref()), - username: df(rbw::db::FieldType::Username, username.as_deref()), - }, - rbw::db::EntryData::SecureNote => EntryData::SecureNote {}, - rbw::db::EntryData::SshKey { - public_key, - fingerprint, - private_key, - } => EntryData::SshKey { - public_key: df(rbw::db::FieldType::PublicKey, public_key.as_deref()), - fingerprint: df(rbw::db::FieldType::Fingerprint, fingerprint.as_deref()), - private_key: df(rbw::db::FieldType::PrivateKey, private_key.as_deref()), - }, - }; - - Ok(rbw::db::Entry:: { - id: entry.id.clone(), - folder, - folder_id: None, - org_id: None, - key: None, - name: crate::actions::decrypt(&entry.name, entry.key.as_deref(), entry.org_id.as_deref())?, - data, - fields, - notes, - history, - master_password_reprompt: rbw::api::CipherRepromptType::None, - _state: std::marker::PhantomData, - }) -} - fn parse_editor(contents: &str) -> (Option, Option) { let mut lines = contents.lines(); diff --git a/src/db.rs b/src/db.rs index e45c756e..b0644eab 100644 --- a/src/db.rs +++ b/src/db.rs @@ -426,6 +426,165 @@ impl Entry { } } +pub trait Decrypter { + fn decrypt_field(&mut self, entry: &Entry, field: &str) -> anyhow::Result; +} + +impl Entry { + fn decrypt_optstring( + &self, + optstring: &Option, + decrypter: &mut impl Decrypter, + ) -> anyhow::Result> { + Ok(match optstring { + Some(s) => Some(decrypter.decrypt_field(&self, s)?), + None => None, + }) + } + + pub fn decrypt_custom_fields( + &self, + decrypter: &mut impl Decrypter, + ) -> anyhow::Result> { + self.fields + .iter() + .map(|field| { + Ok(DynamicField { + name: self.decrypt_optstring(&field.name, decrypter)?, + value: self.decrypt_optstring(&field.value, decrypter)?, + ty: field.ty, + linked_id: None, // TODO: Check if None here is correct + }) + }) + .collect() + } + + pub fn decrypt(&self, decrypter: &mut impl Decrypter) -> anyhow::Result> { + // folder name should always be decrypted with the local key because + // folders are local to a specific user's vault, not the organization + let folder = self.decrypt_optstring(&self.folder, decrypter)?; + + let fields = self.decrypt_custom_fields(decrypter)?; + + let notes = self.decrypt_optstring(&self.notes, decrypter)?; + + let history = self + .history + .iter() + .map(|he| { + Ok(HistoryEntry { + last_used_date: he.last_used_date.clone(), + password: decrypter.decrypt_field(&self, &he.password)?, + }) + }) + .collect::>()?; + + let mut df = |ft, val: &Option| self.decrypt_optstring(&val, decrypter); + + let data = match &self.data { + EntryData::Login { + username, + password, + totp, + uris, + } => EntryData::Login { + username: df(FieldType::Username, username)?, + password: df(FieldType::Password, password)?, + totp: df(FieldType::Totp, totp)?, + uris: uris + .iter() + .map(|s| { + Ok(df(FieldType::Uris, &Some(s.uri.clone()))?.map(|uri| Uri { + uri, + match_type: s.match_type, + })) + }) + .collect::>>>()? + .into_iter() + .flatten() + .collect(), + }, + EntryData::Card { + cardholder_name, + number, + brand, + exp_month, + exp_year, + code, + } => EntryData::Card { + cardholder_name: df(FieldType::Cardholder, cardholder_name)?, + number: df(FieldType::CardNumber, number)?, + brand: df(FieldType::Brand, brand)?, + exp_month: df(FieldType::ExpMonth, exp_month)?, + exp_year: df(FieldType::ExpYear, exp_year)?, + code: df(FieldType::Cvv, code)?, + }, + EntryData::Identity { + title, + first_name, + middle_name, + last_name, + address1, + address2, + address3, + city, + state, + postal_code, + country, + phone, + email, + ssn, + license_number, + passport_number, + username, + } => EntryData::Identity { + title: df(FieldType::Title, title)?, + first_name: df(FieldType::FirstName, first_name)?, + middle_name: df(FieldType::MiddleName, middle_name)?, + last_name: df(FieldType::LastName, last_name)?, + address1: df(FieldType::Address1, address1)?, + address2: df(FieldType::Address2, address2)?, + address3: df(FieldType::Address3, address3)?, + city: df(FieldType::City, city)?, + state: df(FieldType::State, state)?, + postal_code: df(FieldType::PostalCode, postal_code)?, + country: df(FieldType::Country, country)?, + phone: df(FieldType::Phone, phone)?, + email: df(FieldType::Email, email)?, + ssn: df(FieldType::Ssn, ssn)?, + license_number: df(FieldType::License, license_number)?, + passport_number: df(FieldType::Passport, passport_number)?, + username: df(FieldType::Username, username)?, + }, + EntryData::SecureNote => EntryData::SecureNote {}, + EntryData::SshKey { + public_key, + fingerprint, + private_key, + } => EntryData::SshKey { + public_key: df(FieldType::PublicKey, public_key)?, + fingerprint: df(FieldType::Fingerprint, fingerprint)?, + private_key: df(FieldType::PrivateKey, private_key)?, + }, + }; + + Ok(Entry:: { + id: self.id.clone(), + folder, + folder_id: None, + org_id: None, + key: None, + name: decrypter.decrypt_field(&self, &self.name)?, + data, + fields, + notes, + history, + master_password_reprompt: crate::api::CipherRepromptType::None, + _state: std::marker::PhantomData, + }) + } +} + fn writefield( f: &mut std::fmt::Formatter<'_>, label: &str, From e208243fd87d886c5d36a41ed05996e17af7860a Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 18 May 2026 21:46:50 +0200 Subject: [PATCH 058/273] specify missing field name. breaks previous output format --- src/bin/rbw/commands.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 3240d431..40008abf 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -497,7 +497,7 @@ pub fn display_entry_field(entry: &rbw::db::Entry, desc: &str, field: let fields = entry.get_field(&field.to_lowercase(), generate_totp); if fields.is_empty() { // TODO: This is not 100% compatible text output with the project before refactor. - eprintln!("entry for '{desc}' had no default field"); + eprintln!("entry for '{desc}' had no {field} field"); } else { fields.iter().for_each(|f| { println!("{f}"); From 36a1ce896c139e55b49650e91b237398c64b9603 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 18 May 2026 22:34:11 +0200 Subject: [PATCH 059/273] remove warning for unused ft --- src/db.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db.rs b/src/db.rs index b0644eab..201503be 100644 --- a/src/db.rs +++ b/src/db.rs @@ -479,7 +479,7 @@ impl Entry { }) .collect::>()?; - let mut df = |ft, val: &Option| self.decrypt_optstring(&val, decrypter); + let mut df = |_ft, val: &Option| self.decrypt_optstring(&val, decrypter); let data = match &self.data { EntryData::Login { From f6b4118763132051f03917dd8bcb7d121eae5815 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 18 May 2026 22:38:13 +0200 Subject: [PATCH 060/273] convert decrypt_search_cipher into impl TryFrom and move some decryption logic into Entry --- src/bin/rbw/commands.rs | 132 ++++++++++++++++------------------------ src/db.rs | 25 +++++++- 2 files changed, 78 insertions(+), 79 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 40008abf..d88c076d 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -8,7 +8,7 @@ use std::{ }; use anyhow::Context as _; -use rbw::db::{Decrypted, Encrypted, EntryData, Uri}; +use rbw::db::{Decrypted, Encrypted, EntryData}; // The default number of seconds the generated TOTP // code lasts for before a new one must be generated @@ -815,11 +815,11 @@ pub fn search( let mut entries: Vec = db .entries .iter() - .map(decrypt_search_cipher) + .map(TryInto::try_into) .filter(|entry| { entry .as_ref() - .map(|entry| entry.search_match(term, folder)) + .map(|entry: &SearchEntry| entry.search_match(term, folder)) .unwrap_or(true) }) .map(|entry| entry.map(Into::into)) @@ -1314,7 +1314,7 @@ fn find_entry( let ciphers: Vec<(rbw::db::Entry, SearchEntry)> = db .entries .iter() - .map(|entry| decrypt_search_cipher(entry).map(|decrypted| (entry.clone(), decrypted))) + .map(|entry| entry.try_into().map(|decrypted| (entry.clone(), decrypted))) .collect::>()?; let (entry, _) = find_entry_raw(&ciphers, &needle, username, folder, ignore_case)?; let decrypted_entry = entry.decrypt(&mut Decrypter {})?; @@ -1458,83 +1458,59 @@ fn decrypt_list_cipher( }) } -fn decrypt_search_cipher(entry: &rbw::db::Entry) -> anyhow::Result { - let id = entry.id.clone(); - let name = crate::actions::decrypt(&entry.name, entry.key.as_deref(), entry.org_id.as_deref())?; - let user = match &entry.data { - rbw::db::EntryData::Login { username, .. } => decrypt_field_warn( - rbw::db::FieldType::Username, - username.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - _ => None, - }; - // folder name should always be decrypted with the local key because - // folders are local to a specific user's vault, not the organization - let folder = entry - .folder - .as_ref() - .map(|folder| crate::actions::decrypt(folder, None, None)) - .transpose()?; - let notes = entry - .notes - .as_ref() - .map(|notes| crate::actions::decrypt(notes, entry.key.as_deref(), entry.org_id.as_deref())) - .transpose(); - let uris = if let rbw::db::EntryData::Login { uris, .. } = &entry.data { - uris.iter() - .filter_map(|s| { - decrypt_field_warn( - rbw::db::FieldType::Uris, - Some(&s.uri), - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - .map(|uri| (uri, s.match_type)) +impl TryFrom<&rbw::db::Entry> for SearchEntry { + type Error = anyhow::Error; + + fn try_from(entry: &rbw::db::Entry) -> Result { + let mut dec = Decrypter {}; + + let user = match &entry.data { + EntryData::Login { username, .. } => entry.decrypt_optstring(username, &mut dec)?, + _ => None, + }; + + let name = entry.decrypt_string(&entry.name, &mut dec)?; + let folder = entry.decrypt_optstring(&entry.folder, &mut dec)?; + let notes = entry.decrypt_optstring(&entry.notes, &mut dec)?; + + let uris = entry + .decrypt_uris(&mut dec)? + .into_iter() + .map(|u| (u.uri, u.match_type)) + .collect(); + + let fields = entry + .decrypt_custom_fields(&mut dec)? + .into_iter() + .filter_map(|f| { + if f.ty == Some(rbw::api::FieldType::Hidden) { + None + } else { + f.value + } }) - .collect() - } else { - vec![] - }; - let fields = entry - .fields - .iter() - .filter_map(|field| { - if field.ty == Some(rbw::api::FieldType::Hidden) { - None - } else { - field.value.as_ref() - } + .collect(); + + let entry_type = (match &entry.data { + rbw::db::EntryData::Login { .. } => "Login", + rbw::db::EntryData::Identity { .. } => "Identity", + rbw::db::EntryData::SshKey { .. } => "SSH Key", + rbw::db::EntryData::SecureNote => "Note", + rbw::db::EntryData::Card { .. } => "Card", }) - .map(|value| crate::actions::decrypt(value, entry.key.as_deref(), entry.org_id.as_deref())) - .collect::>()?; - let notes = match notes { - Ok(notes) => notes, - Err(e) => { - log::warn!("failed to decrypt notes: {e}"); - None - } - }; - let entry_type = (match &entry.data { - rbw::db::EntryData::Login { .. } => "Login", - rbw::db::EntryData::Identity { .. } => "Identity", - rbw::db::EntryData::SshKey { .. } => "SSH Key", - rbw::db::EntryData::SecureNote => "Note", - rbw::db::EntryData::Card { .. } => "Card", - }) - .to_string(); + .to_string(); - Ok(SearchEntry { - id, - entry_type, - folder, - name, - user, - uris, - fields, - notes, - }) + Ok(SearchEntry { + id: entry.id.clone(), + entry_type, + folder, + name, + user, + uris, + fields, + notes, + }) + } } /// This accepts a optional string and optionally decrypts it? diff --git a/src/db.rs b/src/db.rs index 201503be..d7c54cd7 100644 --- a/src/db.rs +++ b/src/db.rs @@ -431,7 +431,15 @@ pub trait Decrypter { } impl Entry { - fn decrypt_optstring( + pub fn decrypt_string( + &self, + s: &str, + decrypter: &mut impl Decrypter, + ) -> anyhow::Result { + decrypter.decrypt_field(&self, &s) + } + + pub fn decrypt_optstring( &self, optstring: &Option, decrypter: &mut impl Decrypter, @@ -459,6 +467,21 @@ impl Entry { .collect() } + pub fn decrypt_uris(&self, decrypter: &mut impl Decrypter) -> anyhow::Result> { + match &self.data { + EntryData::Login { uris, .. } => Ok(uris + .iter() + .map(|u| -> anyhow::Result { + Ok(Uri { + uri: decrypter.decrypt_field(&self, &u.uri)?, + match_type: u.match_type, + }) + }) + .collect::>>()?), + _ => Ok(vec![]), + } + } + pub fn decrypt(&self, decrypter: &mut impl Decrypter) -> anyhow::Result> { // folder name should always be decrypted with the local key because // folders are local to a specific user's vault, not the organization From 8217cfc3f8fe895b73073a3a69e293af2901b330 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 18 May 2026 23:04:21 +0200 Subject: [PATCH 061/273] re-use the TryFrom trait impl for SearchEntry even if incurring in small performance penalty for now --- src/bin/rbw/commands.rs | 105 +--------------------------------------- 1 file changed, 2 insertions(+), 103 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index d88c076d..25f049c7 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -484,7 +484,8 @@ pub fn list(fields: &[String], raw: bool) -> anyhow::Result<()> { let mut entries: Vec = db .entries .iter() - .map(|entry| decrypt_list_cipher(entry, &fields)) + .map(TryInto::::try_into) + .map(|entry| entry.map(Into::into)) .collect::>()?; entries.sort_unstable_by(|a, b| a.name.cmp(&b.name)); @@ -1380,84 +1381,6 @@ fn find_entry_raw( } } -fn decrypt_list_cipher( - entry: &rbw::db::Entry, - fields: &[ListField], -) -> anyhow::Result { - let id = entry.id.clone(); - let name = if fields.contains(&ListField::Name) { - Some(crate::actions::decrypt( - &entry.name, - entry.key.as_deref(), - entry.org_id.as_deref(), - )?) - } else { - None - }; - let user = if fields.contains(&ListField::User) { - match &entry.data { - rbw::db::EntryData::Login { username, .. } => decrypt_field_warn( - rbw::db::FieldType::Username, - username.as_deref(), - entry.key.as_deref(), - entry.org_id.as_deref(), - ), - _ => None, - } - } else { - None - }; - let folder = if fields.contains(&ListField::Folder) { - // folder name should always be decrypted with the local key because - // folders are local to a specific user's vault, not the organization - entry - .folder - .as_ref() - .map(|folder| crate::actions::decrypt(folder, None, None)) - .transpose()? - } else { - None - }; - let uris = if fields.contains(&ListField::Uri) { - match &entry.data { - rbw::db::EntryData::Login { uris, .. } => Some( - uris.iter() - .filter_map(|s| { - decrypt_field_warn( - rbw::db::FieldType::Uris, - Some(&s.uri), - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - }) - .collect(), - ), - _ => None, - } - } else { - None - }; - let entry_type = fields - .contains(&ListField::EntryType) - .then_some(match &entry.data { - rbw::db::EntryData::Login { .. } => "Login", - rbw::db::EntryData::Identity { .. } => "Identity", - rbw::db::EntryData::SshKey { .. } => "SSH Key", - rbw::db::EntryData::SecureNote => "Note", - rbw::db::EntryData::Card { .. } => "Card", - }) - .map(str::to_string); - - Ok(ListEntry { - id, - name, - user, - folder, - uris, - entry_type, - }) -} - impl TryFrom<&rbw::db::Entry> for SearchEntry { type Error = anyhow::Error; @@ -1513,30 +1436,6 @@ impl TryFrom<&rbw::db::Entry> for SearchEntry { } } -/// This accepts a optional string and optionally decrypts it? -fn decrypt_string( - string: Option<&str>, - entry_key: Option<&str>, - org_id: Option<&str>, -) -> anyhow::Result> { - string - .map(|f| crate::actions::decrypt(f, entry_key, org_id)) - .transpose() -} - -/// This accepts a optional field and optionally decrypts it? -fn decrypt_field_warn( - name: rbw::db::FieldType, - field: Option<&str>, - key: Option<&str>, - org_id: Option<&str>, -) -> Option { - decrypt_string(field, key, org_id).unwrap_or_else(|e| { - log::warn!("failed to decrypt {name}: {e}"); - None - }) -} - fn parse_editor(contents: &str) -> (Option, Option) { let mut lines = contents.lines(); From 87a0f7ec23bd1582d8c7b333ea70ef66430d8100 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 18 May 2026 23:09:27 +0200 Subject: [PATCH 062/273] search is now a subset of list --- src/bin/rbw/commands.rs | 30 ++++-------------------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 25f049c7..583a1af6 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -468,32 +468,6 @@ pub fn sync() -> anyhow::Result<()> { Ok(()) } -pub fn list(fields: &[String], raw: bool) -> anyhow::Result<()> { - let fields: Vec = if raw { - ListField::all() - } else { - fields - .iter() - .map(TryFrom::try_from) - .collect::>()? - }; - - unlock()?; - - let db = load_db()?; - let mut entries: Vec = db - .entries - .iter() - .map(TryInto::::try_into) - .map(|entry| entry.map(Into::into)) - .collect::>()?; - entries.sort_unstable_by(|a, b| a.name.cmp(&b.name)); - - print_entry_list(&entries, &fields, raw)?; - - Ok(()) -} - pub fn display_entry_field(entry: &rbw::db::Entry, desc: &str, field: &str) { let fields = entry.get_field(&field.to_lowercase(), generate_totp); if fields.is_empty() { @@ -832,6 +806,10 @@ pub fn search( Ok(()) } +pub fn list(fields: &[String], raw: bool) -> anyhow::Result<()> { + search("", fields, None, raw) +} + pub fn code( needle: Needle, user: Option<&str>, From ac122bf6db0a01cc79ce17702922edd5a365a759 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 18 May 2026 23:22:46 +0200 Subject: [PATCH 063/273] remove nested if --- src/bin/rbw/commands.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 583a1af6..9f234003 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -187,10 +187,8 @@ impl SearchEntry { } fn search_match(&self, term: &str, folder: Option<&str>) -> bool { - if let Some(folder) = folder { - if self.folder.as_deref() != Some(folder) { - return false; - } + if folder.is_some() && self.folder.as_deref() != folder { + return false; } let mut fields = vec![self.name.clone()]; From 245b02e01fccf130315c83f5b20391b0117e8d28 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 18 May 2026 23:40:15 +0200 Subject: [PATCH 064/273] convert search_match to functional --- src/bin/rbw/commands.rs | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 9f234003..48b4f4f4 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -191,23 +191,14 @@ impl SearchEntry { return false; } - let mut fields = vec![self.name.clone()]; - if let Some(notes) = &self.notes { - fields.push(notes.clone()); - } - if let Some(user) = &self.user { - fields.push(user.clone()); - } - fields.extend(self.uris.iter().map(|(uri, _)| uri).cloned()); - fields.extend(self.fields.iter().cloned()); - - for field in fields { - if field.to_lowercase().contains(&term.to_lowercase()) { - return true; - } - } + let term = term.to_lowercase(); - false + [Some(&self.name), self.notes.as_ref(), self.user.as_ref()] + .into_iter() + .flatten() + .chain(self.uris.iter().map(|(uri, _)| uri)) + .chain(self.fields.iter()) + .any(|f| f.to_lowercase().contains(&term)) } } From 442107f2585bd27a8f7214847cb09d398b1aee05 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 19 May 2026 21:34:39 +0200 Subject: [PATCH 065/273] collect to Vec<&str> instead of Vec --- src/bin/rbw/commands.rs | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 48b4f4f4..6f83d8fc 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -714,22 +714,13 @@ fn print_entry_list(entries: &[ListEntry], fields: &[ListField], raw: bool) -> a println!(); } else { for entry in entries { - let values: Vec = fields + let values: Vec<&str> = fields .iter() .map(|field| match field { - ListField::Id => entry.id.clone(), - ListField::Name => entry - .name - .as_ref() - .map_or_else(String::new, ToString::to_string), - ListField::User => entry - .user - .as_ref() - .map_or_else(String::new, ToString::to_string), - ListField::Folder => entry - .folder - .as_ref() - .map_or_else(String::new, ToString::to_string), + ListField::Id => &entry.id, + ListField::Name => entry.name.as_deref().unwrap_or(""), + ListField::User => entry.user.as_deref().unwrap_or(""), + ListField::Folder => entry.folder.as_deref().unwrap_or(""), ListField::Uri => { // "uri" is not listed in the TryFrom // implementation, so there's no way to try to @@ -738,10 +729,7 @@ fn print_entry_list(entries: &[ListEntry], fields: &[ListField], raw: bool) -> a // string) unreachable!() } - ListField::EntryType => entry - .entry_type - .as_ref() - .map_or_else(String::new, ToString::to_string), + ListField::EntryType => entry.entry_type.as_deref().unwrap_or(""), }) .collect(); From 2e4b6cb05be35084cc7d90b70155bc05658a9d5c Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 19 May 2026 21:38:35 +0200 Subject: [PATCH 066/273] remove ListEntry --- src/bin/rbw/commands.rs | 38 ++++++++------------------------------ 1 file changed, 8 insertions(+), 30 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 6f83d8fc..e9723f6e 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -72,19 +72,6 @@ impl rbw::db::Decrypter for Decrypter { } } -/// It's a subset of db::Entry with only decrypted fields -#[derive(Debug, serde::Serialize)] -struct ListEntry { - id: String, - #[serde(rename = "type")] - entry_type: Option, - folder: Option, - name: Option, - user: Option, - uris: Option>, -} - -/// TODO: This could be re-used as ListEntry as they have all fields #[derive(Debug, Clone, serde::Serialize)] #[cfg_attr(test, derive(Eq, PartialEq))] struct SearchEntry { @@ -202,19 +189,6 @@ impl SearchEntry { } } -impl From for ListEntry { - fn from(value: SearchEntry) -> Self { - Self { - id: value.id, - entry_type: Some(value.entry_type), - name: Some(value.name), - user: value.user, - folder: value.folder, - uris: Some(value.uris.into_iter().map(|(s, _)| s).collect()), - } - } -} - fn matches_url( url: &str, match_type: Option, @@ -707,7 +681,11 @@ pub fn get( } /// Used in "search" and "list" -fn print_entry_list(entries: &[ListEntry], fields: &[ListField], raw: bool) -> anyhow::Result<()> { +fn print_entry_list( + entries: &[SearchEntry], + fields: &[ListField], + raw: bool, +) -> anyhow::Result<()> { if raw { serde_json::to_writer_pretty(std::io::stdout(), &entries) .context("failed to write entries to stdout".to_string())?; @@ -718,7 +696,7 @@ fn print_entry_list(entries: &[ListEntry], fields: &[ListField], raw: bool) -> a .iter() .map(|field| match field { ListField::Id => &entry.id, - ListField::Name => entry.name.as_deref().unwrap_or(""), + ListField::Name => &entry.name, ListField::User => entry.user.as_deref().unwrap_or(""), ListField::Folder => entry.folder.as_deref().unwrap_or(""), ListField::Uri => { @@ -729,7 +707,7 @@ fn print_entry_list(entries: &[ListEntry], fields: &[ListField], raw: bool) -> a // string) unreachable!() } - ListField::EntryType => entry.entry_type.as_deref().unwrap_or(""), + ListField::EntryType => &entry.entry_type, }) .collect(); @@ -764,7 +742,7 @@ pub fn search( let db = load_db()?; - let mut entries: Vec = db + let mut entries: Vec = db .entries .iter() .map(TryInto::try_into) From c5eafbb597cda421cf5d2b3be9cc47ed5402de3b Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 19 May 2026 22:20:13 +0200 Subject: [PATCH 067/273] move "get fields list" logic into Entry impl and remove two functions from commands.rs --- src/bin/rbw/commands.rs | 157 ++-------------------------------------- src/db.rs | 146 +++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 150 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index e9723f6e..e2a27cb6 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -464,154 +464,6 @@ pub fn display_entry_short(entry: &rbw::db::Entry, desc: &str) -> boo true } -/// This implementation mirror the `fn display_fied` method on which field to list -pub fn display_entry_fields_list(entry: &rbw::db::Entry) { - match &entry.data { - EntryData::Login { - username, - password, - totp, - uris, - .. - } => { - if username.is_some() { - println!("{}", rbw::db::FieldType::Username); - } - if totp.is_some() { - println!("{}", rbw::db::FieldType::Totp); - } - if !uris.is_empty() { - println!("{}", rbw::db::FieldType::Uris); - } - if password.is_some() { - println!("{}", rbw::db::FieldType::Password); - } - } - EntryData::Card { - cardholder_name, - number, - brand, - exp_month, - exp_year, - code, - .. - } => { - if number.is_some() { - println!("{}", rbw::db::FieldType::CardNumber); - } - if exp_month.is_some() { - println!("{}", rbw::db::FieldType::ExpMonth); - } - if exp_year.is_some() { - println!("{}", rbw::db::FieldType::ExpYear); - } - if code.is_some() { - println!("{}", rbw::db::FieldType::Cvv); - } - if cardholder_name.is_some() { - println!("{}", rbw::db::FieldType::Cardholder); - } - if brand.is_some() { - println!("{}", rbw::db::FieldType::Brand); - } - } - - EntryData::Identity { - address1, - address2, - address3, - city, - state, - postal_code, - country, - phone, - email, - ssn, - license_number, - passport_number, - username, - title, - first_name, - middle_name, - last_name, - .. - } => { - if [title, first_name, middle_name, last_name] - .iter() - .any(|f| f.is_some()) - { - // the display_field combines all these fields together. - println!("name"); - } - if email.is_some() { - println!("{}", rbw::db::FieldType::Email); - } - if [address1, address2, address3].iter().any(|f| f.is_some()) { - // the display_field combines all these fields together. - println!("address"); - } - if city.is_some() { - println!("{}", rbw::db::FieldType::City); - } - if state.is_some() { - println!("{}", rbw::db::FieldType::State); - } - if postal_code.is_some() { - println!("{}", rbw::db::FieldType::PostalCode); - } - if country.is_some() { - println!("{}", rbw::db::FieldType::Country); - } - if phone.is_some() { - println!("{}", rbw::db::FieldType::Phone); - } - if ssn.is_some() { - println!("{}", rbw::db::FieldType::Ssn); - } - if license_number.is_some() { - println!("{}", rbw::db::FieldType::License); - } - if passport_number.is_some() { - println!("{}", rbw::db::FieldType::Passport); - } - if username.is_some() { - println!("{}", rbw::db::FieldType::Username); - } - } - - EntryData::SecureNote => (), // handled at the end - EntryData::SshKey { - fingerprint, - public_key, - .. - } => { - if fingerprint.is_some() { - println!("{}", rbw::db::FieldType::Fingerprint); - } - if public_key.is_some() { - println!("{}", rbw::db::FieldType::PublicKey); - } - } - } - - if entry.notes.is_some() { - println!("{}", rbw::db::FieldType::Notes); - } - for f in &entry.fields { - if let Some(name) = &f.name { - println!("{name}"); - } - } -} - -pub fn display_entry_json(entry: &rbw::db::Entry, desc: &str) -> anyhow::Result<()> { - serde_json::to_writer_pretty(std::io::stdout(), entry) - .context(format!("failed to write entry '{desc}' to stdout"))?; - println!(); - - Ok(()) -} - #[allow(clippy::fn_params_excessive_bools)] pub fn get( needle: Needle, @@ -638,9 +490,14 @@ pub fn get( .with_context(|| format!("couldn't find entry for '{desc}'"))?; if list_fields { - display_entry_fields_list(&decrypted); + decrypted + .get_fields_list() + .iter() + .for_each(|field| println!("{field}")); } else if raw { - display_entry_json(&decrypted, &desc)?; + serde_json::to_writer_pretty(std::io::stdout(), &decrypted) + .context(format!("failed to write entry '{desc}' to stdout"))?; + println!(); } else { let short = decrypted.get_short(); diff --git a/src/db.rs b/src/db.rs index d7c54cd7..1cc2e1c5 100644 --- a/src/db.rs +++ b/src/db.rs @@ -272,6 +272,152 @@ impl Entry { .collect() } + /// Ugly function. Its job could be handled semi-automatically by the type system. + /// Doesn't need to be "Decrypted" to work. + pub fn get_fields_list(&self) -> Vec { + let mut r: Vec = vec![]; + + match &self.data { + EntryData::Login { + username, + password, + totp, + uris, + .. + } => { + if username.is_some() { + r.push(FieldType::Username.to_string()); + } + if totp.is_some() { + r.push(FieldType::Totp.to_string()); + } + if !uris.is_empty() { + r.push(FieldType::Uris.to_string()); + } + if password.is_some() { + r.push(FieldType::Password.to_string()); + } + } + EntryData::Card { + cardholder_name, + number, + brand, + exp_month, + exp_year, + code, + .. + } => { + if number.is_some() { + r.push(FieldType::CardNumber.to_string()); + } + if exp_month.is_some() { + r.push(FieldType::ExpMonth.to_string()); + } + if exp_year.is_some() { + r.push(FieldType::ExpYear.to_string()); + } + if code.is_some() { + r.push(FieldType::Cvv.to_string()); + } + if cardholder_name.is_some() { + r.push(FieldType::Cardholder.to_string()); + } + if brand.is_some() { + r.push(FieldType::Brand.to_string()); + } + } + + EntryData::Identity { + address1, + address2, + address3, + city, + state, + postal_code, + country, + phone, + email, + ssn, + license_number, + passport_number, + username, + title, + first_name, + middle_name, + last_name, + .. + } => { + if [title, first_name, middle_name, last_name] + .iter() + .any(|f| f.is_some()) + { + // the display_field combines all these fields together. + r.push("name".to_string()); + } + if email.is_some() { + r.push(FieldType::Email.to_string()); + } + if [address1, address2, address3].iter().any(|f| f.is_some()) { + // the display_field combines all these fields together. + r.push("address".to_string()); + } + if city.is_some() { + r.push(FieldType::City.to_string()); + } + if state.is_some() { + r.push(FieldType::State.to_string()); + } + if postal_code.is_some() { + r.push(FieldType::PostalCode.to_string()); + } + if country.is_some() { + r.push(FieldType::Country.to_string()); + } + if phone.is_some() { + r.push(FieldType::Phone.to_string()); + } + if ssn.is_some() { + r.push(FieldType::Ssn.to_string()); + } + if license_number.is_some() { + r.push(FieldType::License.to_string()); + } + if passport_number.is_some() { + r.push(FieldType::Passport.to_string()); + } + if username.is_some() { + r.push(FieldType::Username.to_string()); + } + } + + EntryData::SecureNote => (), // handled at the end + EntryData::SshKey { + fingerprint, + public_key, + .. + } => { + if fingerprint.is_some() { + r.push(FieldType::Fingerprint.to_string()); + } + if public_key.is_some() { + r.push(FieldType::PublicKey.to_string()); + } + } + } + + if self.notes.is_some() { + r.push(FieldType::Notes.to_string()); + } + + for f in &self.fields { + if let Some(name) = &f.name { + r.push(name.clone()); + } + } + + r + } + /// This function is sh*t but I need it for now /// Given a textual representation of a field, like "username", "password" or "card number", /// check which type of entry EntryData is and extract the "username" or "cardnumber" field if From a08eb7a74b3401e75ec26712fd5dd0e3216beff8 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 19 May 2026 22:27:34 +0200 Subject: [PATCH 068/273] move alloc to use of all() --- src/bin/rbw/commands.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index e2a27cb6..9a38e129 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -263,8 +263,8 @@ enum ListField { } impl ListField { - fn all() -> Vec { - vec![ + fn all() -> &'static [Self] { + &[ Self::Id, Self::Name, Self::User, @@ -587,7 +587,7 @@ pub fn search( raw: bool, ) -> anyhow::Result<()> { let fields: Vec = if raw { - ListField::all() + ListField::all().to_vec() } else { fields .iter() From e7dc42480134fe5a19b01d8bc3c2b3173d1e6d5d Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 19 May 2026 23:25:57 +0200 Subject: [PATCH 069/273] simplify matches_url --- src/bin/rbw/commands.rs | 36 ++++++++++-------------------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 9a38e129..f8b8e5d3 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -195,7 +195,11 @@ fn matches_url( given_url: &url::Url, ) -> bool { match match_type.unwrap_or(rbw::api::UriMatchType::Domain) { - rbw::api::UriMatchType::Domain => { + rbw::api::UriMatchType::Domain | rbw::api::UriMatchType::Host => { + let is_domain = matches!( + match_type.unwrap_or(rbw::api::UriMatchType::Domain), + rbw::api::UriMatchType::Domain + ); let Some(given_host_port) = host_port(given_url) else { return false; }; @@ -203,41 +207,21 @@ fn matches_url( if let Some(self_host_port) = host_port(&self_url) { if self_url.scheme() == given_url.scheme() && (self_host_port == given_host_port - || given_host_port.ends_with(&format!(".{self_host_port}"))) + || (is_domain + && given_host_port.ends_with(&format!(".{self_host_port}")))) { return true; } } } - url == given_host_port || given_host_port.ends_with(&format!(".{url}")) - } - rbw::api::UriMatchType::Host => { - let Some(given_host_port) = host_port(given_url) else { - return false; - }; - if let Ok(self_url) = url::Url::parse(url) { - if let Some(self_host_port) = host_port(&self_url) { - if self_url.scheme() == given_url.scheme() && self_host_port == given_host_port - { - return true; - } - } - } - url == given_host_port + url == given_host_port || (is_domain && given_host_port.ends_with(&format!(".{url}"))) } rbw::api::UriMatchType::StartsWith => given_url.to_string().starts_with(url), rbw::api::UriMatchType::Exact => { - if given_url.path() == "/" { - given_url.to_string().trim_end_matches('/') == url.trim_end_matches('/') - } else { - given_url.to_string() == url - } + given_url.to_string().trim_end_matches('/') == url.trim_end_matches('/') } rbw::api::UriMatchType::RegularExpression => { - let Ok(rx) = regex::Regex::new(url) else { - return false; - }; - rx.is_match(given_url.as_ref()) + regex::Regex::new(url).map_or(false, |rx| rx.is_match(given_url.as_ref())) } rbw::api::UriMatchType::Never => false, } From 8ae10394769288ead5e804f09cec734ddb09d5e6 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 19 May 2026 23:26:37 +0200 Subject: [PATCH 070/273] move host_port up --- src/bin/rbw/commands.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index f8b8e5d3..5d9ca673 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -189,6 +189,14 @@ impl SearchEntry { } } +fn host_port(url: &url::Url) -> Option { + let host = url.host_str()?; + Some( + url.port() + .map_or_else(|| host.to_string(), |port| format!("{host}:{port}")), + ) +} + fn matches_url( url: &str, match_type: Option, @@ -227,14 +235,6 @@ fn matches_url( } } -fn host_port(url: &url::Url) -> Option { - let host = url.host_str()?; - Some( - url.port() - .map_or_else(|| host.to_string(), |port| format!("{host}:{port}")), - ) -} - // TODO: This could be a dup of FieldType? #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ListField { From 7cd310e33427915139297f74511b25381add0122 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 19 May 2026 23:45:30 +0200 Subject: [PATCH 071/273] remove duplicated code for adding a entry --- src/bin/rbw/commands.rs | 62 ++++++----------------------------------- src/bin/rbw/main.rs | 1 + 2 files changed, 10 insertions(+), 53 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 5d9ca673..36902b3f 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -696,6 +696,7 @@ pub fn add( username: Option<&str>, uris: &[(String, Option)], folder: Option<&str>, + password: Option<&str>, ) -> anyhow::Result<()> { unlock()?; @@ -711,9 +712,14 @@ pub fn add( .map(|username| crate::actions::encrypt(username, None)) .transpose()?; - let contents = rbw::edit::edit("", HELP_PW)?; + let (password, notes) = match password { + Some(password) => (Some(password.to_string()), None), + None => { + let contents = rbw::edit::edit("", HELP_PW)?; + parse_editor(&contents) + } + }; - let (password, notes) = parse_editor(&contents); let password = password .map(|password| crate::actions::encrypt(&password, None)) .transpose()?; @@ -774,57 +780,7 @@ pub fn generate( println!("{password}"); if let Some(name) = name { - unlock()?; - - let mut db = load_db()?; - // unwrap is safe here because the call to unlock above is guaranteed - // to populate these or error - let mut access_token = db.access_token.as_ref().unwrap().clone(); - let refresh_token = db.refresh_token.as_ref().unwrap().clone(); - - let name = crate::actions::encrypt(name, None)?; - let username = username - .map(|username| crate::actions::encrypt(username, None)) - .transpose()?; - let password = crate::actions::encrypt(&password, None)?; - let uris: Vec<_> = uris - .iter() - .map(|uri| { - Ok(rbw::db::Uri { - uri: crate::actions::encrypt(&uri.0, None)?, - match_type: uri.1, - }) - }) - .collect::>()?; - - let folder_id = match folder { - Some(folder) => Some(find_or_create_folder( - &mut access_token, - &refresh_token, - &mut db, - folder, - )?), - None => None, - }; - - if let (Some(access_token), ()) = rbw::actions::add( - &access_token, - &refresh_token, - &name, - &rbw::db::EntryData::Login { - username, - password: Some(password), - uris, - totp: None, - }, - None, - folder_id.as_deref(), - )? { - db.access_token = Some(access_token); - save_db(&db)?; - } - - crate::actions::sync()?; + add(name, username, uris, folder, Some(&password))?; } Ok(()) diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index cd2fce97..d2af2b6c 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -382,6 +382,7 @@ fn main() { .map(|uri| (uri.clone(), None)) .collect::>(), folder.as_deref(), + None, ), Opt::Generate { len, From 7a67bd343d57f3c118a1156ddd6e255903c5a2a6 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 19 May 2026 23:48:03 +0200 Subject: [PATCH 072/273] move parse_editor up near add/generate/edit --- src/bin/rbw/commands.rs | 42 ++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 36902b3f..6b335bb4 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -691,6 +691,27 @@ fn find_or_create_folder( Ok(folder_id) } +fn parse_editor(contents: &str) -> (Option, Option) { + let mut lines = contents.lines(); + + let password = lines.next().map(ToString::to_string); + + let mut notes: String = lines + .skip_while(|line| line.is_empty()) + .filter(|line| !line.starts_with('#')) + .fold(String::new(), |mut notes, line| { + notes.push_str(line); + notes.push('\n'); + notes + }); + while notes.ends_with('\n') { + notes.pop(); + } + let notes = if notes.is_empty() { None } else { Some(notes) }; + + (password, notes) +} + pub fn add( name: &str, username: Option<&str>, @@ -1166,27 +1187,6 @@ impl TryFrom<&rbw::db::Entry> for SearchEntry { } } -fn parse_editor(contents: &str) -> (Option, Option) { - let mut lines = contents.lines(); - - let password = lines.next().map(ToString::to_string); - - let mut notes: String = lines - .skip_while(|line| line.is_empty()) - .filter(|line| !line.starts_with('#')) - .fold(String::new(), |mut notes, line| { - notes.push_str(line); - notes.push('\n'); - notes - }); - while notes.ends_with('\n') { - notes.pop(); - } - let notes = if notes.is_empty() { None } else { Some(notes) }; - - (password, notes) -} - fn load_db() -> anyhow::Result { let config = rbw::config::Config::load()?; config.email.as_ref().map_or_else( From 31e71214b250443cc032431a211ef4a0a57b4e31 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 20 May 2026 09:51:00 +0200 Subject: [PATCH 073/273] Decrypter -> RemoteDecrypter and add a description for it --- src/bin/rbw/commands.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 6b335bb4..57a6a4eb 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -56,9 +56,11 @@ pub fn parse_needle(arg: &str) -> Result { Ok(Needle::Name(arg.to_string())) } -struct Decrypter {} +/// This Decrypter implementation will send the encrypted string to the agent and wait for it to +/// decrypt it. +struct RemoteDecrypter {} -impl rbw::db::Decrypter for Decrypter { +impl rbw::db::Decrypter for RemoteDecrypter { fn decrypt_field( &mut self, entry: &rbw::db::Entry, @@ -1057,7 +1059,7 @@ fn find_entry( if let Needle::Uuid(uuid, s) = needle { for cipher in &db.entries { if uuid::Uuid::parse_str(&cipher.id) == Ok(uuid) { - return Ok((cipher.clone(), cipher.decrypt(&mut Decrypter {})?)); + return Ok((cipher.clone(), cipher.decrypt(&mut RemoteDecrypter {})?)); } } needle = Needle::Name(s); @@ -1069,7 +1071,7 @@ fn find_entry( .map(|entry| entry.try_into().map(|decrypted| (entry.clone(), decrypted))) .collect::>()?; let (entry, _) = find_entry_raw(&ciphers, &needle, username, folder, ignore_case)?; - let decrypted_entry = entry.decrypt(&mut Decrypter {})?; + let decrypted_entry = entry.decrypt(&mut RemoteDecrypter {})?; Ok((entry, decrypted_entry)) } @@ -1136,7 +1138,7 @@ impl TryFrom<&rbw::db::Entry> for SearchEntry { type Error = anyhow::Error; fn try_from(entry: &rbw::db::Entry) -> Result { - let mut dec = Decrypter {}; + let mut dec = RemoteDecrypter {}; let user = match &entry.data { EntryData::Login { username, .. } => entry.decrypt_optstring(username, &mut dec)?, From 97fd80c16abd8243b4cb27cb061b274973a99a22 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 20 May 2026 10:09:38 +0200 Subject: [PATCH 074/273] add DecryptRemote error type --- src/bin/rbw/commands.rs | 11 +++++------ src/db.rs | 8 ++------ src/error.rs | 3 +++ 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 57a6a4eb..0532369e 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -65,12 +65,11 @@ impl rbw::db::Decrypter for RemoteDecrypter { &mut self, entry: &rbw::db::Entry, field: &str, - ) -> anyhow::Result { - Ok(crate::actions::decrypt( - field, - entry.key.as_deref(), - entry.org_id.as_deref(), - )?) + ) -> rbw::error::Result { + Ok( + crate::actions::decrypt(field, entry.key.as_deref(), entry.org_id.as_deref()) + .map_err(|_e| rbw::error::Error::DecryptRemote)?, + ) } } diff --git a/src/db.rs b/src/db.rs index 1cc2e1c5..04a529c5 100644 --- a/src/db.rs +++ b/src/db.rs @@ -573,15 +573,11 @@ impl Entry { } pub trait Decrypter { - fn decrypt_field(&mut self, entry: &Entry, field: &str) -> anyhow::Result; + fn decrypt_field(&mut self, entry: &Entry, field: &str) -> Result; } impl Entry { - pub fn decrypt_string( - &self, - s: &str, - decrypter: &mut impl Decrypter, - ) -> anyhow::Result { + pub fn decrypt_string(&self, s: &str, decrypter: &mut impl Decrypter) -> Result { decrypter.decrypt_field(&self, &s) } diff --git a/src/error.rs b/src/error.rs index b7789a92..f5461c91 100644 --- a/src/error.rs +++ b/src/error.rs @@ -24,6 +24,9 @@ pub enum Error { #[error("failed to decrypt")] Decrypt { source: block_padding::UnpadError }, + #[error("failed to decrypt remotely")] + DecryptRemote, + #[error("failed to find free port in {range}")] FailedToFindFreePort { range: String }, From 1581c426d622f08dcc35038232fb0c042e804172 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 20 May 2026 10:11:22 +0200 Subject: [PATCH 075/273] remove almost any anyhow::Result from db.rs --- src/db.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/db.rs b/src/db.rs index 04a529c5..6bc0d76f 100644 --- a/src/db.rs +++ b/src/db.rs @@ -585,7 +585,7 @@ impl Entry { &self, optstring: &Option, decrypter: &mut impl Decrypter, - ) -> anyhow::Result> { + ) -> Result> { Ok(match optstring { Some(s) => Some(decrypter.decrypt_field(&self, s)?), None => None, @@ -595,7 +595,7 @@ impl Entry { pub fn decrypt_custom_fields( &self, decrypter: &mut impl Decrypter, - ) -> anyhow::Result> { + ) -> Result> { self.fields .iter() .map(|field| { @@ -609,22 +609,22 @@ impl Entry { .collect() } - pub fn decrypt_uris(&self, decrypter: &mut impl Decrypter) -> anyhow::Result> { + pub fn decrypt_uris(&self, decrypter: &mut impl Decrypter) -> Result> { match &self.data { EntryData::Login { uris, .. } => Ok(uris .iter() - .map(|u| -> anyhow::Result { + .map(|u| -> Result { Ok(Uri { uri: decrypter.decrypt_field(&self, &u.uri)?, match_type: u.match_type, }) }) - .collect::>>()?), + .collect::>>()?), _ => Ok(vec![]), } } - pub fn decrypt(&self, decrypter: &mut impl Decrypter) -> anyhow::Result> { + pub fn decrypt(&self, decrypter: &mut impl Decrypter) -> Result> { // folder name should always be decrypted with the local key because // folders are local to a specific user's vault, not the organization let folder = self.decrypt_optstring(&self.folder, decrypter)?; @@ -642,7 +642,7 @@ impl Entry { password: decrypter.decrypt_field(&self, &he.password)?, }) }) - .collect::>()?; + .collect::>()?; let mut df = |_ft, val: &Option| self.decrypt_optstring(&val, decrypter); @@ -664,7 +664,7 @@ impl Entry { match_type: s.match_type, })) }) - .collect::>>>()? + .collect::>>>()? .into_iter() .flatten() .collect(), From 0debe2acd5c7f70e290a1b4ebd3ff4c684625aa5 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 20 May 2026 10:44:03 +0200 Subject: [PATCH 076/273] remove Ok(()) at the end of some functions --- src/bin/rbw/commands.rs | 73 ++++++++++++----------------------------- 1 file changed, 21 insertions(+), 52 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 0532369e..d028df51 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -336,9 +336,7 @@ pub fn config_set(key: &str, value: &str) -> anyhow::Result<()> { // be running (since this may be the user running `rbw config set // base_url` as the first operation), and stop_agent() already handles the // agent not running case gracefully. - stop_agent()?; - - Ok(()) + stop_agent() } pub fn config_unset(key: &str) -> anyhow::Result<()> { @@ -365,55 +363,41 @@ pub fn config_unset(key: &str) -> anyhow::Result<()> { // be running (since this may be the user running `rbw config set // base_url` as the first operation), and stop_agent() already handles the // agent not running case gracefully. - stop_agent()?; - - Ok(()) + stop_agent() } fn clipboard_store(val: &str) -> anyhow::Result<()> { ensure_agent()?; - crate::actions::clipboard_store(val)?; - - Ok(()) + crate::actions::clipboard_store(val) } pub fn register() -> anyhow::Result<()> { ensure_agent()?; - crate::actions::register()?; - - Ok(()) + crate::actions::register() } pub fn login() -> anyhow::Result<()> { ensure_agent()?; - crate::actions::login()?; - - Ok(()) + crate::actions::login() } pub fn unlock() -> anyhow::Result<()> { ensure_agent()?; crate::actions::login()?; - crate::actions::unlock()?; - - Ok(()) + crate::actions::unlock() } pub fn unlocked() -> anyhow::Result<()> { // not ensure_agent, because we don't want `rbw unlocked` to start the // agent if it's not running let _ = check_agent_version(); - crate::actions::unlocked()?; - - Ok(()) + crate::actions::unlocked() } pub fn sync() -> anyhow::Result<()> { ensure_agent()?; crate::actions::login()?; - crate::actions::sync()?; - - Ok(()) + crate::actions::sync() } pub fn display_entry_field(entry: &rbw::db::Entry, desc: &str, field: &str) { @@ -598,9 +582,7 @@ pub fn search( .collect::>()?; entries.sort_unstable_by(|a, b| a.name.cmp(&b.name)); - print_entry_list(&entries, &fields, raw)?; - - Ok(()) + print_entry_list(&entries, &fields, raw) } pub fn list(fields: &[String], raw: bool) -> anyhow::Result<()> { @@ -785,9 +767,7 @@ pub fn add( save_db(&db)?; } - crate::actions::sync()?; - - Ok(()) + crate::actions::sync() } pub fn generate( @@ -801,11 +781,10 @@ pub fn generate( let password = rbw::pwgen::pwgen(ty, len); println!("{password}"); - if let Some(name) = name { - add(name, username, uris, folder, Some(&password))?; + match name { + Some(name) => add(name, username, uris, folder, Some(&password)), + None => Ok(()), } - - Ok(()) } pub fn edit( @@ -912,8 +891,7 @@ pub fn edit( save_db(&db)?; } - crate::actions::sync()?; - Ok(()) + crate::actions::sync() } pub fn remove( @@ -943,9 +921,7 @@ pub fn remove( save_db(&db)?; } - crate::actions::sync()?; - - Ok(()) + crate::actions::sync() } pub fn history( @@ -975,23 +951,17 @@ pub fn history( pub fn lock() -> anyhow::Result<()> { ensure_agent()?; - crate::actions::lock()?; - - Ok(()) + crate::actions::lock() } pub fn purge() -> anyhow::Result<()> { stop_agent()?; - remove_db()?; - - Ok(()) + remove_db() } pub fn stop_agent() -> anyhow::Result<()> { - crate::actions::quit()?; - - Ok(()) + crate::actions::quit() } fn ensure_agent() -> anyhow::Result<()> { @@ -1000,8 +970,7 @@ fn ensure_agent() -> anyhow::Result<()> { return Ok(()); } run_agent()?; - check_agent_version()?; - Ok(()) + check_agent_version() } fn run_agent() -> anyhow::Result<()> { @@ -1035,9 +1004,9 @@ fn check_agent_version() -> anyhow::Result<()> { let agent_version = version_or_quit()?; if agent_version != client_version { crate::actions::quit()?; - return Err(anyhow::anyhow!( + anyhow::bail!( "client protocol version is {client_version} but agent protocol version is {agent_version}" - )); + ); } Ok(()) } From bc79f4455cad5746c155df14e4837f6f16d977a1 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 20 May 2026 11:41:29 +0200 Subject: [PATCH 077/273] simplify a bit load_db, remove_db and save_db --- src/bin/rbw/commands.rs | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index d028df51..2417540d 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1157,31 +1157,29 @@ impl TryFrom<&rbw::db::Entry> for SearchEntry { } } -fn load_db() -> anyhow::Result { +fn with_config(f: impl FnOnce(&str, &str) -> anyhow::Result) -> anyhow::Result { let config = rbw::config::Config::load()?; - config.email.as_ref().map_or_else( - || Err(anyhow::anyhow!("failed to find email address in config")), - |email| rbw::db::Db::load(&config.server_name(), email).map_err(anyhow::Error::new), - ) + let Some(email) = &config.email else { + anyhow::bail!("failed to find email address in config"); + }; + + f(&config.server_name(), email) +} + +fn load_db() -> anyhow::Result { + with_config(|server_name, email| { + rbw::db::Db::load(server_name, email).map_err(anyhow::Error::new) + }) } fn save_db(db: &rbw::db::Db) -> anyhow::Result<()> { - let config = rbw::config::Config::load()?; - config.email.as_ref().map_or_else( - || Err(anyhow::anyhow!("failed to find email address in config")), - |email| { - db.save(&config.server_name(), email) - .map_err(anyhow::Error::new) - }, - ) + with_config(|server_name, email| db.save(server_name, email).map_err(anyhow::Error::new)) } fn remove_db() -> anyhow::Result<()> { - let config = rbw::config::Config::load()?; - config.email.as_ref().map_or_else( - || Err(anyhow::anyhow!("failed to find email address in config")), - |email| rbw::db::Db::remove(&config.server_name(), email).map_err(anyhow::Error::new), - ) + with_config(|server_name, email| { + rbw::db::Db::remove(server_name, email).map_err(anyhow::Error::new) + }) } struct TotpParams { From b82ec0b47286430ce3fb5ffe395b813f109d3ecd Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 20 May 2026 11:42:24 +0200 Subject: [PATCH 078/273] move TryFrom impl for SearchEntry near its structure --- src/bin/rbw/commands.rs | 110 ++++++++++++++++++++-------------------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 2417540d..d3b98596 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -190,6 +190,61 @@ impl SearchEntry { } } +impl TryFrom<&rbw::db::Entry> for SearchEntry { + type Error = anyhow::Error; + + fn try_from(entry: &rbw::db::Entry) -> Result { + let mut dec = RemoteDecrypter {}; + + let user = match &entry.data { + EntryData::Login { username, .. } => entry.decrypt_optstring(username, &mut dec)?, + _ => None, + }; + + let name = entry.decrypt_string(&entry.name, &mut dec)?; + let folder = entry.decrypt_optstring(&entry.folder, &mut dec)?; + let notes = entry.decrypt_optstring(&entry.notes, &mut dec)?; + + let uris = entry + .decrypt_uris(&mut dec)? + .into_iter() + .map(|u| (u.uri, u.match_type)) + .collect(); + + let fields = entry + .decrypt_custom_fields(&mut dec)? + .into_iter() + .filter_map(|f| { + if f.ty == Some(rbw::api::FieldType::Hidden) { + None + } else { + f.value + } + }) + .collect(); + + let entry_type = (match &entry.data { + rbw::db::EntryData::Login { .. } => "Login", + rbw::db::EntryData::Identity { .. } => "Identity", + rbw::db::EntryData::SshKey { .. } => "SSH Key", + rbw::db::EntryData::SecureNote => "Note", + rbw::db::EntryData::Card { .. } => "Card", + }) + .to_string(); + + Ok(SearchEntry { + id: entry.id.clone(), + entry_type, + folder, + name, + user, + uris, + fields, + notes, + }) + } +} + fn host_port(url: &url::Url) -> Option { let host = url.host_str()?; Some( @@ -1102,61 +1157,6 @@ fn find_entry_raw( } } -impl TryFrom<&rbw::db::Entry> for SearchEntry { - type Error = anyhow::Error; - - fn try_from(entry: &rbw::db::Entry) -> Result { - let mut dec = RemoteDecrypter {}; - - let user = match &entry.data { - EntryData::Login { username, .. } => entry.decrypt_optstring(username, &mut dec)?, - _ => None, - }; - - let name = entry.decrypt_string(&entry.name, &mut dec)?; - let folder = entry.decrypt_optstring(&entry.folder, &mut dec)?; - let notes = entry.decrypt_optstring(&entry.notes, &mut dec)?; - - let uris = entry - .decrypt_uris(&mut dec)? - .into_iter() - .map(|u| (u.uri, u.match_type)) - .collect(); - - let fields = entry - .decrypt_custom_fields(&mut dec)? - .into_iter() - .filter_map(|f| { - if f.ty == Some(rbw::api::FieldType::Hidden) { - None - } else { - f.value - } - }) - .collect(); - - let entry_type = (match &entry.data { - rbw::db::EntryData::Login { .. } => "Login", - rbw::db::EntryData::Identity { .. } => "Identity", - rbw::db::EntryData::SshKey { .. } => "SSH Key", - rbw::db::EntryData::SecureNote => "Note", - rbw::db::EntryData::Card { .. } => "Card", - }) - .to_string(); - - Ok(SearchEntry { - id: entry.id.clone(), - entry_type, - folder, - name, - user, - uris, - fields, - notes, - }) - } -} - fn with_config(f: impl FnOnce(&str, &str) -> anyhow::Result) -> anyhow::Result { let config = rbw::config::Config::load()?; let Some(email) = &config.email else { From 7e4f2f491a9fc6095d63c8ad65f3b9e0178f4e58 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 20 May 2026 12:00:35 +0200 Subject: [PATCH 079/273] impl TryFrom secret to TotpParams instead of simple fn --- src/bin/rbw/commands.rs | 126 +++++++++++++++++++++------------------- 1 file changed, 65 insertions(+), 61 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index d3b98596..4ee86280 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1218,75 +1218,79 @@ fn generate_totp_algorithm_type(alg: &str) -> anyhow::Result } } -fn parse_totp_secret(secret: &str) -> anyhow::Result { - if let Ok(u) = url::Url::parse(secret) { - match u.scheme() { - "otpauth" => { - if u.host_str() != Some("totp") { - return Err(anyhow::anyhow!("totp secret url must have totp host")); - } - - let query: HashMap<_, _> = u.query_pairs().collect(); - - let secret = decode_totp_secret( - query - .get("secret") - .ok_or_else(|| anyhow::anyhow!("totp secret url must have secret"))?, - )?; - - let algorithm = query.get("algorithm").map_or_else( - || Ok(totp_rs::Algorithm::SHA1), - |a| generate_totp_algorithm_type(&ToString::to_string(a)), - )?; - - let digits = match query.get("digits") { - Some(dig) => dig.parse::().map_err(|_| { - anyhow::anyhow!("digits parameter in totp url must be a valid integer.") - })?, - None => 6, - }; - - let period = match query.get("period") { - Some(dig) => dig.parse::().map_err(|_| { - anyhow::anyhow!("period parameter in totp url must be a valid integer.") - })?, - None => TOTP_DEFAULT_STEP, - }; +impl std::str::FromStr for TotpParams { + type Err = anyhow::Error; + + fn from_str(secret: &str) -> anyhow::Result { + if let Ok(u) = url::Url::parse(secret) { + match u.scheme() { + "otpauth" => { + if u.host_str() != Some("totp") { + return Err(anyhow::anyhow!("totp secret url must have totp host")); + } - Ok(TotpParams { - secret, - algorithm, - digits, - period, - }) - } - "steam" => { - let steam_secret = u.host_str().unwrap(); - - Ok(TotpParams { - secret: decode_totp_secret(steam_secret)?, - algorithm: totp_rs::Algorithm::Steam, - digits: 5, - period: TOTP_DEFAULT_STEP, - }) + let query: HashMap<_, _> = u.query_pairs().collect(); + + let secret = decode_totp_secret( + query + .get("secret") + .ok_or_else(|| anyhow::anyhow!("totp secret url must have secret"))?, + )?; + + let algorithm = query.get("algorithm").map_or_else( + || Ok(totp_rs::Algorithm::SHA1), + |a| generate_totp_algorithm_type(&ToString::to_string(a)), + )?; + + let digits = match query.get("digits") { + Some(dig) => dig.parse::().map_err(|_| { + anyhow::anyhow!("digits parameter in totp url must be a valid integer.") + })?, + None => 6, + }; + + let period = match query.get("period") { + Some(dig) => dig.parse::().map_err(|_| { + anyhow::anyhow!("period parameter in totp url must be a valid integer.") + })?, + None => TOTP_DEFAULT_STEP, + }; + + Ok(Self { + secret, + algorithm, + digits, + period, + }) + } + "steam" => { + let steam_secret = u.host_str().unwrap(); + + Ok(Self { + secret: decode_totp_secret(steam_secret)?, + algorithm: totp_rs::Algorithm::Steam, + digits: 5, + period: TOTP_DEFAULT_STEP, + }) + } + _ => Err(anyhow::anyhow!( + "totp secret url must have 'otpauth' or 'steam' scheme" + )), } - _ => Err(anyhow::anyhow!( - "totp secret url must have 'otpauth' or 'steam' scheme" - )), + } else { + Ok(Self { + secret: decode_totp_secret(secret)?, + algorithm: totp_rs::Algorithm::SHA1, + digits: 6, + period: TOTP_DEFAULT_STEP, + }) } - } else { - Ok(TotpParams { - secret: decode_totp_secret(secret)?, - algorithm: totp_rs::Algorithm::SHA1, - digits: 6, - period: TOTP_DEFAULT_STEP, - }) } } fn generate_totp(secret: &str) -> anyhow::Result { use totp_rs::{Algorithm::*, TOTP}; - let totp_params = parse_totp_secret(secret)?; + let totp_params: TotpParams = secret.parse()?; match totp_params.algorithm { SHA1 | SHA256 | SHA512 => { From 15127dde8901c7f0940769c2f06c974160d13e0b Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 20 May 2026 12:00:53 +0200 Subject: [PATCH 080/273] remove to_string for algo type --- src/bin/rbw/commands.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 4ee86280..48eef642 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1239,7 +1239,7 @@ impl std::str::FromStr for TotpParams { let algorithm = query.get("algorithm").map_or_else( || Ok(totp_rs::Algorithm::SHA1), - |a| generate_totp_algorithm_type(&ToString::to_string(a)), + |a| generate_totp_algorithm_type(a), )?; let digits = match query.get("digits") { From 8a6955b1b5ec0868e16bc281de676134cd5734a2 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 20 May 2026 14:04:31 +0200 Subject: [PATCH 081/273] remove otpauth parsing code and use totp_rs crate implementation instead --- Cargo.lock | 2 + Cargo.toml | 2 +- src/bin/rbw/commands.rs | 125 ++++++---------------------------------- 3 files changed, 21 insertions(+), 108 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6571e1d6..9290ea18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2696,6 +2696,8 @@ dependencies = [ "hmac", "sha1", "sha2", + "url", + "urlencoding", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a10f19c4..4c43ce7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,7 +77,7 @@ tokio-tungstenite = { version = "0.28", features = [ "url", ] } tokio = { version = "1.48.0", features = ["full"] } -totp-rs = { version = "5.7.0", features = ["steam"] } +totp-rs = { version = "5.7.0", features = ["steam", "otpauth"] } url = "2.5.7" urlencoding = "2.1.3" uuid = { version = "1.19.0", features = ["v4"] } diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 48eef642..49a74aa2 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1,5 +1,4 @@ use std::{ - collections::HashMap, fmt::{Display, Write as _}, io::Write as _, os::unix::ffi::OsStrExt as _, @@ -1182,13 +1181,6 @@ fn remove_db() -> anyhow::Result<()> { }) } -struct TotpParams { - secret: Vec, - algorithm: totp_rs::Algorithm, - digits: usize, - period: u64, -} - fn decode_totp_secret(secret: &str) -> anyhow::Result> { let secret = secret.trim().replace(' ', ""); let alphabets = [ @@ -1205,106 +1197,25 @@ fn decode_totp_secret(secret: &str) -> anyhow::Result> { Err(anyhow::anyhow!("totp secret was not valid base32")) } -// This function exists for the sake of making the generate_totp function less -// densely packed and more readable -fn generate_totp_algorithm_type(alg: &str) -> anyhow::Result { - use totp_rs::Algorithm::*; - match alg { - "SHA1" => Ok(SHA1), - "SHA256" => Ok(SHA256), - "SHA512" => Ok(SHA512), - "STEAM" => Ok(Steam), - _ => anyhow::bail!("{alg} is not a valid algorithm"), - } -} - -impl std::str::FromStr for TotpParams { - type Err = anyhow::Error; - - fn from_str(secret: &str) -> anyhow::Result { - if let Ok(u) = url::Url::parse(secret) { - match u.scheme() { - "otpauth" => { - if u.host_str() != Some("totp") { - return Err(anyhow::anyhow!("totp secret url must have totp host")); - } - - let query: HashMap<_, _> = u.query_pairs().collect(); - - let secret = decode_totp_secret( - query - .get("secret") - .ok_or_else(|| anyhow::anyhow!("totp secret url must have secret"))?, - )?; - - let algorithm = query.get("algorithm").map_or_else( - || Ok(totp_rs::Algorithm::SHA1), - |a| generate_totp_algorithm_type(a), - )?; - - let digits = match query.get("digits") { - Some(dig) => dig.parse::().map_err(|_| { - anyhow::anyhow!("digits parameter in totp url must be a valid integer.") - })?, - None => 6, - }; - - let period = match query.get("period") { - Some(dig) => dig.parse::().map_err(|_| { - anyhow::anyhow!("period parameter in totp url must be a valid integer.") - })?, - None => TOTP_DEFAULT_STEP, - }; - - Ok(Self { - secret, - algorithm, - digits, - period, - }) - } - "steam" => { - let steam_secret = u.host_str().unwrap(); - - Ok(Self { - secret: decode_totp_secret(steam_secret)?, - algorithm: totp_rs::Algorithm::Steam, - digits: 5, - period: TOTP_DEFAULT_STEP, - }) - } - _ => Err(anyhow::anyhow!( - "totp secret url must have 'otpauth' or 'steam' scheme" - )), - } - } else { - Ok(Self { - secret: decode_totp_secret(secret)?, - algorithm: totp_rs::Algorithm::SHA1, - digits: 6, - period: TOTP_DEFAULT_STEP, - }) - } - } -} - fn generate_totp(secret: &str) -> anyhow::Result { - use totp_rs::{Algorithm::*, TOTP}; - let totp_params: TotpParams = secret.parse()?; - - match totp_params.algorithm { - SHA1 | SHA256 | SHA512 => { - Ok(TOTP::new_unchecked( - totp_params.algorithm, - totp_params.digits, - 1, // the library docs say this should be a 1 - totp_params.period, - totp_params.secret, - ) - .generate_current()?) - } - Steam => Ok(TOTP::new_steam(totp_params.secret).generate_current()?), - } + // Small hack that is not RFC compliant but helps with some services. + // Most authenticators have this built-in, included official Bitwarden clients. + let secret = secret.replace("algorithm=sha", "algorithm=SHA"); + + let totp = match totp_rs::TOTP::from_url(&secret) { + Ok(totp) => totp, + Err(_e) => totp_rs::TOTP::new_unchecked( + totp_rs::Algorithm::SHA1, + 6, + 1, + TOTP_DEFAULT_STEP, + decode_totp_secret(&secret)?, + None, + "".to_string(), + ), + }; + + Ok(totp.generate_current()?) } #[cfg(test)] From 07c152745f5df057a7aaacc3a1c881fd17760545 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 20 May 2026 15:40:16 +0200 Subject: [PATCH 082/273] add update_token for shared behavior and make contents immutable --- src/bin/rbw/commands.rs | 39 ++++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 49a74aa2..ceffe64e 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -749,6 +749,14 @@ fn parse_editor(contents: &str) -> (Option, Option) { (password, notes) } +fn update_token(db: &mut rbw::db::Db, new_token: Option) -> anyhow::Result<()> { + if let Some(token) = new_token { + db.access_token = Some(token); + save_db(db)?; + } + Ok(()) +} + pub fn add( name: &str, username: Option<&str>, @@ -804,7 +812,7 @@ pub fn add( None => None, }; - if let (Some(access_token), ()) = rbw::actions::add( + let (new_token, ()) = rbw::actions::add( &access_token, &refresh_token, &name, @@ -816,10 +824,9 @@ pub fn add( }, notes.as_deref(), folder_id.as_deref(), - )? { - db.access_token = Some(access_token); - save_db(&db)?; - } + )?; + + update_token(&mut db, new_token)?; crate::actions::sync() } @@ -864,10 +871,13 @@ pub fn edit( let (data, fields, notes, history) = match &decrypted.data { EntryData::Login { password, .. } => { - let mut contents = format!("{}\n", password.as_deref().unwrap_or("")); - if let Some(notes) = decrypted.notes { - write!(contents, "\n{notes}\n").unwrap(); - } + let contents = format!( + "{}\n{}", + password.as_deref().unwrap_or(""), + &decrypted + .notes + .map_or("".to_string(), |n| format!("\n{n}\n")) + ); let contents = rbw::edit::edit(&contents, HELP_PW)?; @@ -910,7 +920,7 @@ pub fn edit( let editor_content = decrypted .notes - .map_or_else(|| "\n".to_string(), |notes| format!("{notes}\n")); + .map_or("\n".to_string(), |notes| format!("{notes}\n")); let contents = rbw::edit::edit(&editor_content, HELP_NOTES)?; // prepend blank line to be parsed as pw by `parse_editor` @@ -929,7 +939,7 @@ pub fn edit( } }; - if let (Some(access_token), ()) = rbw::actions::edit( + let (new_token, ()) = rbw::actions::edit( access_token, refresh_token, &entry.id, @@ -940,10 +950,9 @@ pub fn edit( notes.as_deref(), entry.folder_id.as_deref(), &history, - )? { - db.access_token = Some(access_token); - save_db(&db)?; - } + )?; + + update_token(&mut db, new_token)?; crate::actions::sync() } From bd5b8dcfe1838c59175c1b5449fa8287a224f7cc Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 20 May 2026 15:42:06 +0200 Subject: [PATCH 083/273] improve readability --- src/bin/rbw/commands.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index ceffe64e..9d9a5121 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -871,13 +871,11 @@ pub fn edit( let (data, fields, notes, history) = match &decrypted.data { EntryData::Login { password, .. } => { - let contents = format!( - "{}\n{}", - password.as_deref().unwrap_or(""), - &decrypted - .notes - .map_or("".to_string(), |n| format!("\n{n}\n")) - ); + let password = password.as_deref().unwrap_or(""); + let notes = decrypted + .notes + .map_or("".to_string(), |n| format!("\n{n}\n")); + let contents = format!("{password}\n{notes}"); let contents = rbw::edit::edit(&contents, HELP_PW)?; From a43e12b4155bd61c264d9656f6baeae25fbdd949 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 20 May 2026 15:55:04 +0200 Subject: [PATCH 084/273] simpler notes parser --- src/bin/rbw/commands.rs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 9d9a5121..77b11d26 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -733,17 +733,12 @@ fn parse_editor(contents: &str) -> (Option, Option) { let password = lines.next().map(ToString::to_string); - let mut notes: String = lines + let notes: String = lines .skip_while(|line| line.is_empty()) .filter(|line| !line.starts_with('#')) - .fold(String::new(), |mut notes, line| { - notes.push_str(line); - notes.push('\n'); - notes - }); - while notes.ends_with('\n') { - notes.pop(); - } + .map(|s| s.to_string()) + .collect::>() + .join("\n"); let notes = if notes.is_empty() { None } else { Some(notes) }; (password, notes) From a4af570d2510ee42ac12622cb735b8f12a73be61 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 20 May 2026 16:25:13 +0200 Subject: [PATCH 085/273] make Decrypter's entry Option and simplify folder_id calculation --- src/bin/rbw/commands.rs | 24 +++++++++++------------- src/db.rs | 12 ++++++------ 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 77b11d26..e3206aff 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1,13 +1,9 @@ use std::{ - fmt::{Display, Write as _}, - io::Write as _, - os::unix::ffi::OsStrExt as _, - path::PathBuf, - time::SystemTime, + fmt::Display, io::Write as _, os::unix::ffi::OsStrExt as _, path::PathBuf, time::SystemTime, }; use anyhow::Context as _; -use rbw::db::{Decrypted, Encrypted, EntryData}; +use rbw::db::{Decrypted, Decrypter, Encrypted, EntryData}; // The default number of seconds the generated TOTP // code lasts for before a new one must be generated @@ -62,13 +58,15 @@ struct RemoteDecrypter {} impl rbw::db::Decrypter for RemoteDecrypter { fn decrypt_field( &mut self, - entry: &rbw::db::Entry, + entry: Option<&rbw::db::Entry>, field: &str, ) -> rbw::error::Result { - Ok( - crate::actions::decrypt(field, entry.key.as_deref(), entry.org_id.as_deref()) - .map_err(|_e| rbw::error::Error::DecryptRemote)?, + Ok(crate::actions::decrypt( + field, + entry.map_or(None, |e| e.key.as_deref()), + entry.map_or(None, |e| e.org_id.as_deref()), ) + .map_err(|_e| rbw::error::Error::DecryptRemote)?) } } @@ -689,6 +687,7 @@ fn find_or_create_folder( db: &mut rbw::db::Db, folder: &str, ) -> anyhow::Result { + let mut dec = RemoteDecrypter {}; let (new_access_token, folders) = rbw::actions::list_folders(&access_token, refresh_token)?; if let Some(new_access_token) = new_access_token { @@ -698,9 +697,8 @@ fn find_or_create_folder( } let folders: Vec<(String, String)> = folders - .iter() - .cloned() - .map(|(id, name)| Ok((id, crate::actions::decrypt(&name, None, None)?))) + .into_iter() + .map(|(id, name)| Ok((id, dec.decrypt_field(None, &name)?))) .collect::>()?; let folder_id = folders diff --git a/src/db.rs b/src/db.rs index 6bc0d76f..f18050f0 100644 --- a/src/db.rs +++ b/src/db.rs @@ -573,12 +573,12 @@ impl Entry { } pub trait Decrypter { - fn decrypt_field(&mut self, entry: &Entry, field: &str) -> Result; + fn decrypt_field(&mut self, entry: Option<&Entry>, field: &str) -> Result; } impl Entry { pub fn decrypt_string(&self, s: &str, decrypter: &mut impl Decrypter) -> Result { - decrypter.decrypt_field(&self, &s) + decrypter.decrypt_field(Some(&self), &s) } pub fn decrypt_optstring( @@ -587,7 +587,7 @@ impl Entry { decrypter: &mut impl Decrypter, ) -> Result> { Ok(match optstring { - Some(s) => Some(decrypter.decrypt_field(&self, s)?), + Some(s) => Some(decrypter.decrypt_field(Some(&self), s)?), None => None, }) } @@ -615,7 +615,7 @@ impl Entry { .iter() .map(|u| -> Result { Ok(Uri { - uri: decrypter.decrypt_field(&self, &u.uri)?, + uri: decrypter.decrypt_field(Some(&self), &u.uri)?, match_type: u.match_type, }) }) @@ -639,7 +639,7 @@ impl Entry { .map(|he| { Ok(HistoryEntry { last_used_date: he.last_used_date.clone(), - password: decrypter.decrypt_field(&self, &he.password)?, + password: decrypter.decrypt_field(Some(&self), &he.password)?, }) }) .collect::>()?; @@ -739,7 +739,7 @@ impl Entry { folder_id: None, org_id: None, key: None, - name: decrypter.decrypt_field(&self, &self.name)?, + name: decrypter.decrypt_field(Some(&self), &self.name)?, data, fields, notes, From 04e032ead0a65d9e3bbc255f96d85beca033471d Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 21 May 2026 11:01:08 +0200 Subject: [PATCH 086/273] simplify access_token and refresh_token management --- src/bin/rbw/commands.rs | 63 +++++++++++++++-------------------------- 1 file changed, 23 insertions(+), 40 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index e3206aff..298f4240 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -37,6 +37,7 @@ impl Display for Needle { } } +// TODO: Could this be FromStr? #[allow(clippy::unnecessary_wraps)] pub fn parse_needle(arg: &str) -> Result { if let Ok(uuid) = uuid::Uuid::parse_str(arg) { @@ -681,20 +682,15 @@ pub fn code( Ok(()) } -fn find_or_create_folder( - access_token: &mut String, - refresh_token: &str, - db: &mut rbw::db::Db, - folder: &str, -) -> anyhow::Result { +fn find_or_create_folder(db: &mut rbw::db::Db, folder: &str) -> anyhow::Result { let mut dec = RemoteDecrypter {}; - let (new_access_token, folders) = rbw::actions::list_folders(&access_token, refresh_token)?; - if let Some(new_access_token) = new_access_token { - access_token.clone_from(&new_access_token); - db.access_token = Some(new_access_token); - save_db(&db)?; - } + let (new_access_token, folders) = rbw::actions::list_folders( + db.access_token.as_ref().unwrap(), + db.refresh_token.as_ref().unwrap(), + )?; + + update_token(db, new_access_token)?; let folders: Vec<(String, String)> = folders .into_iter() @@ -709,16 +705,12 @@ fn find_or_create_folder( folder_id } else { let (new_access_token, id) = rbw::actions::create_folder( - &access_token, - refresh_token, + db.access_token.as_ref().unwrap(), + db.refresh_token.as_ref().unwrap(), &crate::actions::encrypt(folder, None)?, )?; - if let Some(new_access_token) = new_access_token { - access_token.clone_from(&new_access_token); - db.access_token = Some(new_access_token); - save_db(&db)?; - } + update_token(db, new_access_token)?; id }; @@ -762,8 +754,6 @@ pub fn add( let mut db = load_db()?; // unwrap is safe here because the call to unlock above is guaranteed to // populate these or error - let mut access_token = db.access_token.as_ref().unwrap().clone(); - let refresh_token = db.refresh_token.as_ref().unwrap().clone(); let name = crate::actions::encrypt(name, None)?; @@ -796,18 +786,13 @@ pub fn add( .collect::>()?; let folder_id = match folder { - Some(folder) => Some(find_or_create_folder( - &mut access_token, - &refresh_token, - &mut db, - folder, - )?), + Some(folder) => Some(find_or_create_folder(&mut db, folder)?), None => None, }; let (new_token, ()) = rbw::actions::add( - &access_token, - &refresh_token, + &db.access_token.as_ref().unwrap(), + &db.refresh_token.as_ref().unwrap(), &name, &rbw::db::EntryData::Login { username, @@ -850,8 +835,6 @@ pub fn edit( unlock()?; let mut db = load_db()?; - let access_token = db.access_token.as_ref().unwrap(); - let refresh_token = db.refresh_token.as_ref().unwrap(); let desc = format!( "{}{}", @@ -931,8 +914,8 @@ pub fn edit( }; let (new_token, ()) = rbw::actions::edit( - access_token, - refresh_token, + db.access_token.as_ref().unwrap(), + db.refresh_token.as_ref().unwrap(), &entry.id, entry.org_id.as_deref(), &entry.name, @@ -957,8 +940,6 @@ pub fn remove( unlock()?; let mut db = load_db()?; - let access_token = db.access_token.as_ref().unwrap(); - let refresh_token = db.refresh_token.as_ref().unwrap(); let desc = format!( "{}{}", @@ -969,11 +950,13 @@ pub fn remove( let (entry, _) = find_entry(&db, name, username, folder, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; - if let (Some(access_token), ()) = rbw::actions::remove(access_token, refresh_token, &entry.id)? - { - db.access_token = Some(access_token); - save_db(&db)?; - } + let (new_access_token, ()) = rbw::actions::remove( + db.access_token.as_ref().unwrap(), + db.refresh_token.as_ref().unwrap(), + &entry.id, + )?; + + update_token(&mut db, new_access_token)?; crate::actions::sync() } From 639067d7be82ce93bd0771d97de1eb46e697e46d Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 21 May 2026 12:47:19 +0200 Subject: [PATCH 087/273] implement Encrypter and rework a bit the encryption of optional field/strings --- src/bin/rbw/commands.rs | 43 +++++++++++++++++++++++++++++++---------- src/db.rs | 35 +++++++++++++++++++++++++++++---- src/error.rs | 3 +++ 3 files changed, 67 insertions(+), 14 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 298f4240..2e72b98a 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -3,7 +3,7 @@ use std::{ }; use anyhow::Context as _; -use rbw::db::{Decrypted, Decrypter, Encrypted, EntryData}; +use rbw::db::{Decrypted, Decrypter, Encrypted, Encrypter, EntryData}; // The default number of seconds the generated TOTP // code lasts for before a new one must be generated @@ -52,6 +52,25 @@ pub fn parse_needle(arg: &str) -> Result { Ok(Needle::Name(arg.to_string())) } +/// This Encrypter implementation will send the decrypted string to the agent and wait for it to +/// encrypt it. +struct RemoteEncrypter {} + +impl rbw::db::Encrypter for RemoteEncrypter { + fn encrypt_field( + &mut self, + entry: Option<&rbw::db::Entry>, + field: &str, + ) -> rbw::error::Result { + Ok(crate::actions::encrypt( + field, + // entry.map_or(None, |e| e.key.as_deref()), + entry.map_or(None, |e| e.org_id.as_deref()), + ) + .map_err(|_e| rbw::error::Error::EncryptRemote)?) + } +} + /// This Decrypter implementation will send the encrypted string to the agent and wait for it to /// decrypt it. struct RemoteDecrypter {} @@ -683,6 +702,7 @@ pub fn code( } fn find_or_create_folder(db: &mut rbw::db::Db, folder: &str) -> anyhow::Result { + let enc: &mut dyn Encrypter<()> = &mut RemoteEncrypter {}; // fat ptr trick let mut dec = RemoteDecrypter {}; let (new_access_token, folders) = rbw::actions::list_folders( @@ -707,7 +727,7 @@ fn find_or_create_folder(db: &mut rbw::db::Db, folder: &str) -> anyhow::Result, password: Option<&str>, ) -> anyhow::Result<()> { + let enc: &mut dyn Encrypter<()> = &mut RemoteEncrypter {}; // fat ptr trick + unlock()?; let mut db = load_db()?; // unwrap is safe here because the call to unlock above is guaranteed to // populate these or error - let name = crate::actions::encrypt(name, None)?; + let name = enc.encrypt_field(None, &name)?; let username = username - .map(|username| crate::actions::encrypt(username, None)) + .map(|username| enc.encrypt_field(None, &username)) .transpose()?; let (password, notes) = match password { @@ -770,16 +792,16 @@ pub fn add( }; let password = password - .map(|password| crate::actions::encrypt(&password, None)) + .map(|password| enc.encrypt_field(None, &password)) .transpose()?; let notes = notes - .map(|notes| crate::actions::encrypt(¬es, None)) + .map(|notes| enc.encrypt_field(None, ¬es)) .transpose()?; let uris: Vec<_> = uris .iter() .map(|uri| { Ok(rbw::db::Uri { - uri: crate::actions::encrypt(&uri.0, None)?, + uri: enc.encrypt_field(None, &uri.0)?, match_type: uri.1, }) }) @@ -834,6 +856,7 @@ pub fn edit( ) -> anyhow::Result<()> { unlock()?; + let mut enc = RemoteEncrypter {}; let mut db = load_db()?; let desc = format!( @@ -857,10 +880,10 @@ pub fn edit( let (password, notes) = parse_editor(&contents); let password = password - .map(|password| crate::actions::encrypt(&password, entry.org_id.as_deref())) + .map(|password| entry.encrypt_string(&password, &mut enc)) .transpose()?; let notes = notes - .map(|notes| crate::actions::encrypt(¬es, entry.org_id.as_deref())) + .map(|notes| entry.encrypt_string(¬es, &mut enc)) .transpose()?; let mut history = entry.history.clone(); let rbw::db::EntryData::Login { @@ -901,7 +924,7 @@ pub fn edit( let (_, notes) = parse_editor(&format!("\n{contents}\n")); let notes = notes - .map(|notes| crate::actions::encrypt(¬es, entry.org_id.as_deref())) + .map(|notes| entry.encrypt_string(¬es, &mut enc)) .transpose()?; (data, entry.fields, notes, entry.history) diff --git a/src/db.rs b/src/db.rs index f18050f0..3eabbf6e 100644 --- a/src/db.rs +++ b/src/db.rs @@ -574,6 +574,36 @@ impl Entry { pub trait Decrypter { fn decrypt_field(&mut self, entry: Option<&Entry>, field: &str) -> Result; + fn decrypt_optfield( + &mut self, + entry: Option<&Entry>, + field: &Option<&str>, + ) -> Result> { + Ok(match field { + Some(field) => Some(self.decrypt_field(entry, field)?), + None => None, + }) + } +} + +pub trait Encrypter { + fn encrypt_field(&mut self, entry: Option<&Entry>, field: &str) -> Result; + fn encrypt_optfield( + &mut self, + entry: Option<&Entry>, + field: &Option<&str>, + ) -> Result> { + Ok(match field { + Some(field) => Some(self.encrypt_field(entry, field)?), + None => None, + }) + } +} + +impl Entry { + pub fn encrypt_string(&self, s: &str, encrypter: &mut impl Encrypter) -> Result { + encrypter.encrypt_field(Some(&self), &s) + } } impl Entry { @@ -586,10 +616,7 @@ impl Entry { optstring: &Option, decrypter: &mut impl Decrypter, ) -> Result> { - Ok(match optstring { - Some(s) => Some(decrypter.decrypt_field(Some(&self), s)?), - None => None, - }) + decrypter.decrypt_optfield(Some(&self), &optstring.as_deref()) } pub fn decrypt_custom_fields( diff --git a/src/error.rs b/src/error.rs index f5461c91..70a5febe 100644 --- a/src/error.rs +++ b/src/error.rs @@ -21,6 +21,9 @@ pub enum Error { #[error("failed to create sso callback server: {err}")] CreateSSOCallbackServer { err: std::io::Error }, + #[error("failed to encrypt remotely")] + EncryptRemote, + #[error("failed to decrypt")] Decrypt { source: block_padding::UnpadError }, From 143a6ab2acc7809da31c434e1681f13682b64b04 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 21 May 2026 13:48:55 +0200 Subject: [PATCH 088/273] put type parameter on Decrypter trait --- src/bin/rbw/commands.rs | 6 +++--- src/db.rs | 20 ++++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 2e72b98a..99cdcc79 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -75,10 +75,10 @@ impl rbw::db::Encrypter for RemoteEncrypter { /// decrypt it. struct RemoteDecrypter {} -impl rbw::db::Decrypter for RemoteDecrypter { +impl rbw::db::Decrypter for RemoteDecrypter { fn decrypt_field( &mut self, - entry: Option<&rbw::db::Entry>, + entry: Option<&rbw::db::Entry>, field: &str, ) -> rbw::error::Result { Ok(crate::actions::decrypt( @@ -703,7 +703,7 @@ pub fn code( fn find_or_create_folder(db: &mut rbw::db::Db, folder: &str) -> anyhow::Result { let enc: &mut dyn Encrypter<()> = &mut RemoteEncrypter {}; // fat ptr trick - let mut dec = RemoteDecrypter {}; + let dec: &mut dyn Decrypter<()> = &mut RemoteDecrypter {}; let (new_access_token, folders) = rbw::actions::list_folders( db.access_token.as_ref().unwrap(), diff --git a/src/db.rs b/src/db.rs index 3eabbf6e..c46e46bf 100644 --- a/src/db.rs +++ b/src/db.rs @@ -572,11 +572,11 @@ impl Entry { } } -pub trait Decrypter { - fn decrypt_field(&mut self, entry: Option<&Entry>, field: &str) -> Result; +pub trait Decrypter { + fn decrypt_field(&mut self, entry: Option<&Entry>, field: &str) -> Result; fn decrypt_optfield( &mut self, - entry: Option<&Entry>, + entry: Option<&Entry>, field: &Option<&str>, ) -> Result> { Ok(match field { @@ -604,24 +604,24 @@ impl Entry { pub fn encrypt_string(&self, s: &str, encrypter: &mut impl Encrypter) -> Result { encrypter.encrypt_field(Some(&self), &s) } -} -impl Entry { - pub fn decrypt_string(&self, s: &str, decrypter: &mut impl Decrypter) -> Result { + pub fn decrypt_string(&self, s: &str, decrypter: &mut impl Decrypter) -> Result { decrypter.decrypt_field(Some(&self), &s) } pub fn decrypt_optstring( &self, optstring: &Option, - decrypter: &mut impl Decrypter, + decrypter: &mut impl Decrypter, ) -> Result> { decrypter.decrypt_optfield(Some(&self), &optstring.as_deref()) } +} +impl Entry { pub fn decrypt_custom_fields( &self, - decrypter: &mut impl Decrypter, + decrypter: &mut impl Decrypter, ) -> Result> { self.fields .iter() @@ -636,7 +636,7 @@ impl Entry { .collect() } - pub fn decrypt_uris(&self, decrypter: &mut impl Decrypter) -> Result> { + pub fn decrypt_uris(&self, decrypter: &mut impl Decrypter) -> Result> { match &self.data { EntryData::Login { uris, .. } => Ok(uris .iter() @@ -651,7 +651,7 @@ impl Entry { } } - pub fn decrypt(&self, decrypter: &mut impl Decrypter) -> Result> { + pub fn decrypt(&self, decrypter: &mut impl Decrypter) -> Result> { // folder name should always be decrypted with the local key because // folders are local to a specific user's vault, not the organization let folder = self.decrypt_optstring(&self.folder, decrypter)?; From 483cdc90184e929bef7e14d15920535cbba8131d Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 21 May 2026 14:54:47 +0200 Subject: [PATCH 089/273] simplify a bit edit flow --- src/actions.rs | 30 +++++++++++++----------------- src/bin/rbw/commands.rs | 25 ++++++++++--------------- src/db.rs | 9 +++++++++ 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index 8d196d45..4a873175 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -1,4 +1,7 @@ -use crate::{db::Encrypted, prelude::*}; +use crate::{ + db::{Encrypted, Entry}, + prelude::*, +}; pub async fn register(email: &str, apikey: crate::locked::ApiKey) -> Result<()> { let (client, config) = api_client_async().await?; @@ -168,26 +171,19 @@ fn add_once( pub fn edit( access_token: &str, refresh_token: &str, - id: &str, - org_id: Option<&str>, - name: &str, - data: &crate::db::EntryData, - fields: &[crate::db::DynamicField], - notes: Option<&str>, - folder_uuid: Option<&str>, - history: &[crate::db::HistoryEntry], + entry: &Entry, ) -> Result<(Option, ())> { with_exchange_refresh_token(access_token, refresh_token, |access_token| { api_client()?.0.edit( access_token, - id, - org_id, - name, - data, - fields, - notes, - folder_uuid, - history, + &entry.id, + entry.org_id.as_deref(), + &entry.name, + &entry.data, + &entry.fields, + entry.notes.as_deref(), + entry.folder_id.as_deref(), + &entry.history, ) }) } diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 99cdcc79..12f18018 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -865,7 +865,7 @@ pub fn edit( name ); - let (entry, decrypted) = find_entry(&db, name, username, folder, ignore_case) + let (mut entry, decrypted) = find_entry(&db, name, username, folder, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; let (data, fields, notes, history) = match &decrypted.data { @@ -879,12 +879,9 @@ pub fn edit( let contents = rbw::edit::edit(&contents, HELP_PW)?; let (password, notes) = parse_editor(&contents); - let password = password - .map(|password| entry.encrypt_string(&password, &mut enc)) - .transpose()?; - let notes = notes - .map(|notes| entry.encrypt_string(¬es, &mut enc)) - .transpose()?; + let password = entry.encrypt_optstring(&password, &mut enc)?; + let notes = entry.encrypt_optstring(¬es, &mut enc)?; + let mut history = entry.history.clone(); let rbw::db::EntryData::Login { username: entry_username, @@ -936,17 +933,15 @@ pub fn edit( } }; + entry.data = data; + entry.fields = fields; + entry.notes = notes; + entry.history = history; + let (new_token, ()) = rbw::actions::edit( db.access_token.as_ref().unwrap(), db.refresh_token.as_ref().unwrap(), - &entry.id, - entry.org_id.as_deref(), - &entry.name, - &data, - &fields, - notes.as_deref(), - entry.folder_id.as_deref(), - &history, + &entry, )?; update_token(&mut db, new_token)?; diff --git a/src/db.rs b/src/db.rs index c46e46bf..9f5190be 100644 --- a/src/db.rs +++ b/src/db.rs @@ -605,6 +605,14 @@ impl Entry { encrypter.encrypt_field(Some(&self), &s) } + pub fn encrypt_optstring( + &self, + optstring: &Option, + encrypter: &mut impl Encrypter, + ) -> Result> { + encrypter.encrypt_optfield(Some(&self), &optstring.as_deref()) + } + pub fn decrypt_string(&self, s: &str, decrypter: &mut impl Decrypter) -> Result { decrypter.decrypt_field(Some(&self), &s) } @@ -997,6 +1005,7 @@ pub struct Db { pub protected_private_key: Option, pub protected_org_keys: std::collections::HashMap, + // TODO: This could be a HashMap? pub entries: Vec>, } From 3e07bc5fb35331946a6faec439d4d49b7887f4ab Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 21 May 2026 16:34:56 +0200 Subject: [PATCH 090/273] improve readability of fn edit --- src/bin/rbw/commands.rs | 109 +++++++++++++++++----------------------- 1 file changed, 46 insertions(+), 63 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 12f18018..7a589990 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -857,6 +857,8 @@ pub fn edit( unlock()?; let mut enc = RemoteEncrypter {}; + let mut dec = RemoteDecrypter {}; + let mut db = load_db()?; let desc = format!( @@ -865,78 +867,58 @@ pub fn edit( name ); - let (mut entry, decrypted) = find_entry(&db, name, username, folder, ignore_case) + let (mut entry, _decrypted) = find_entry(&db, name, username, folder, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; - let (data, fields, notes, history) = match &decrypted.data { - EntryData::Login { password, .. } => { - let password = password.as_deref().unwrap_or(""); - let notes = decrypted - .notes - .map_or("".to_string(), |n| format!("\n{n}\n")); - let contents = format!("{password}\n{notes}"); - - let contents = rbw::edit::edit(&contents, HELP_PW)?; - - let (password, notes) = parse_editor(&contents); - let password = entry.encrypt_optstring(&password, &mut enc)?; - let notes = entry.encrypt_optstring(¬es, &mut enc)?; - - let mut history = entry.history.clone(); - let rbw::db::EntryData::Login { - username: entry_username, - password: entry_password, - uris: entry_uris, - totp: entry_totp, - } = &entry.data - else { - unreachable!(); - }; - - if let Some(prev_password) = entry_password.clone() { - let new_history_entry = rbw::db::HistoryEntry { - last_used_date: format!("{}", humantime::format_rfc3339(SystemTime::now())), - password: prev_password, - }; - history.insert(0, new_history_entry); - } - - let data = rbw::db::EntryData::Login { - username: entry_username.clone(), - password, - uris: entry_uris.clone(), - totp: entry_totp.clone(), - }; - (data, entry.fields, notes, history) + let (dec_password, dec_notes, help) = match &entry.data { + EntryData::Login { password, .. } => ( + entry.decrypt_optstring(&password, &mut dec)?, + entry.decrypt_optstring(&entry.notes, &mut dec)?, + HELP_PW, + ), + EntryData::SecureNote => ( + None, + entry.decrypt_optstring(&entry.notes, &mut dec)?, + HELP_NOTES, + ), + _ => { + anyhow::bail!("modifications are only supported for login and note entries") } - EntryData::SecureNote => { - let data = rbw::db::EntryData::SecureNote {}; + }; - let editor_content = decrypted - .notes - .map_or("\n".to_string(), |notes| format!("{notes}\n")); - let contents = rbw::edit::edit(&editor_content, HELP_NOTES)?; + // TODO: This is VERY ugly + let contents = format!( + "{}{}{}", + dec_password.as_deref().unwrap_or(""), + if matches!(entry.data, EntryData::Login { .. }) { + "\n" + } else { + "" + }, + dec_notes.map_or_else(String::new, |n| format!("\n{n}\n")) + ); - // prepend blank line to be parsed as pw by `parse_editor` - let (_, notes) = parse_editor(&format!("\n{contents}\n")); + let contents = rbw::edit::edit(&contents, help)?; - let notes = notes - .map(|notes| entry.encrypt_string(¬es, &mut enc)) - .transpose()?; + let (dec_password, dec_notes) = parse_editor(&contents); - (data, entry.fields, notes, entry.history) - } - _ => { - return Err(anyhow::anyhow!( - "modifications are only supported for login and note entries" - )); + let new_enc_password = entry.encrypt_optstring(&dec_password, &mut enc)?; + + if let EntryData::Login { password, .. } = &mut entry.data { + if let Some(prev_password) = password { + entry.history.insert( + 0, + rbw::db::HistoryEntry { + last_used_date: format!("{}", humantime::format_rfc3339(SystemTime::now())), + password: prev_password.clone(), + }, + ); } - }; - entry.data = data; - entry.fields = fields; - entry.notes = notes; - entry.history = history; + password.clone_from(&&new_enc_password); + } + + entry.notes = entry.encrypt_optstring(&dec_notes, &mut enc)?; let (new_token, ()) = rbw::actions::edit( db.access_token.as_ref().unwrap(), @@ -1094,6 +1076,7 @@ fn find_entry( .map(|entry| entry.try_into().map(|decrypted| (entry.clone(), decrypted))) .collect::>()?; let (entry, _) = find_entry_raw(&ciphers, &needle, username, folder, ignore_case)?; + // TODO: Consider if full decryption is necessary let decrypted_entry = entry.decrypt(&mut RemoteDecrypter {})?; Ok((entry, decrypted_entry)) } From c66981be475cbf3f76c58cb4053d521fdcc09603 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 21 May 2026 16:58:09 +0200 Subject: [PATCH 091/273] address contents creation ugliness --- src/bin/rbw/commands.rs | 42 +++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 7a589990..d3dbe03a 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -870,34 +870,30 @@ pub fn edit( let (mut entry, _decrypted) = find_entry(&db, name, username, folder, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; - let (dec_password, dec_notes, help) = match &entry.data { - EntryData::Login { password, .. } => ( - entry.decrypt_optstring(&password, &mut dec)?, - entry.decrypt_optstring(&entry.notes, &mut dec)?, - HELP_PW, - ), - EntryData::SecureNote => ( - None, - entry.decrypt_optstring(&entry.notes, &mut dec)?, - HELP_NOTES, - ), + let (contents, help) = match &entry.data { + EntryData::Login { password, .. } => { + let dec_password = entry + .decrypt_optstring(password, &mut dec)? + .unwrap_or("".to_string()); + + let dec_notes = entry + .decrypt_optstring(&entry.notes, &mut dec)? + .map_or_else(String::new, |n| format!("\n{n}\n")); + + (format!("{dec_password}\n{dec_notes}"), HELP_PW) + } + EntryData::SecureNote => { + let dec_notes = entry + .decrypt_optstring(&entry.notes, &mut dec)? + .map_or_else(String::new, |n| format!("\n{n}\n")); + + (format!("{dec_notes}"), HELP_NOTES) + } _ => { anyhow::bail!("modifications are only supported for login and note entries") } }; - // TODO: This is VERY ugly - let contents = format!( - "{}{}{}", - dec_password.as_deref().unwrap_or(""), - if matches!(entry.data, EntryData::Login { .. }) { - "\n" - } else { - "" - }, - dec_notes.map_or_else(String::new, |n| format!("\n{n}\n")) - ); - let contents = rbw::edit::edit(&contents, help)?; let (dec_password, dec_notes) = parse_editor(&contents); From 0305ef596073242882df85d2d905bb0d9d15082f Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 21 May 2026 21:01:48 +0200 Subject: [PATCH 092/273] improve fn edit readability and optimize fn parse_editor --- src/bin/rbw/commands.rs | 38 ++++++++++++++++---------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index d3dbe03a..f81156ae 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -743,12 +743,16 @@ fn parse_editor(contents: &str) -> (Option, Option) { let password = lines.next().map(ToString::to_string); - let notes: String = lines + let mut notes: String = lines .skip_while(|line| line.is_empty()) .filter(|line| !line.starts_with('#')) - .map(|s| s.to_string()) .collect::>() .join("\n"); + + if notes.ends_with("\n") { + notes.pop(); + } + let notes = if notes.is_empty() { None } else { Some(notes) }; (password, notes) @@ -870,28 +874,18 @@ pub fn edit( let (mut entry, _decrypted) = find_entry(&db, name, username, folder, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; - let (contents, help) = match &entry.data { - EntryData::Login { password, .. } => { - let dec_password = entry - .decrypt_optstring(password, &mut dec)? - .unwrap_or("".to_string()); - - let dec_notes = entry - .decrypt_optstring(&entry.notes, &mut dec)? - .map_or_else(String::new, |n| format!("\n{n}\n")); + let dec_notes = entry + .decrypt_optstring(&entry.notes, &mut dec)? + .map_or_else(String::new, |n| format!("\n{n}\n")); - (format!("{dec_password}\n{dec_notes}"), HELP_PW) - } - EntryData::SecureNote => { - let dec_notes = entry - .decrypt_optstring(&entry.notes, &mut dec)? - .map_or_else(String::new, |n| format!("\n{n}\n")); + let (contents, help) = if let EntryData::Login { password, .. } = &entry.data { + let dec_password = entry + .decrypt_optstring(password, &mut dec)? + .unwrap_or("".to_string()); - (format!("{dec_notes}"), HELP_NOTES) - } - _ => { - anyhow::bail!("modifications are only supported for login and note entries") - } + (format!("{dec_password}\n{dec_notes}"), HELP_PW) + } else { + (dec_notes, HELP_NOTES) }; let contents = rbw::edit::edit(&contents, help)?; From e4f7d4bdc14c921a60cb8fe67f2e25728c9d3828 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 21 May 2026 21:14:49 +0200 Subject: [PATCH 093/273] cosmetic edits --- src/bin/rbw/commands.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index f81156ae..ae5dfb0c 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -860,11 +860,11 @@ pub fn edit( ) -> anyhow::Result<()> { unlock()?; + let mut db = load_db()?; + let mut enc = RemoteEncrypter {}; let mut dec = RemoteDecrypter {}; - let mut db = load_db()?; - let desc = format!( "{}{}", username.map_or_else(String::new, |s| format!("{s}@")), @@ -881,16 +881,14 @@ pub fn edit( let (contents, help) = if let EntryData::Login { password, .. } = &entry.data { let dec_password = entry .decrypt_optstring(password, &mut dec)? - .unwrap_or("".to_string()); + .unwrap_or_else(String::new); (format!("{dec_password}\n{dec_notes}"), HELP_PW) } else { (dec_notes, HELP_NOTES) }; - let contents = rbw::edit::edit(&contents, help)?; - - let (dec_password, dec_notes) = parse_editor(&contents); + let (dec_password, dec_notes) = parse_editor(&rbw::edit::edit(&contents, help)?); let new_enc_password = entry.encrypt_optstring(&dec_password, &mut enc)?; From 9a3b570ba03f12927ddbd4fbb04569bdaad64fed Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 21 May 2026 23:26:13 +0200 Subject: [PATCH 094/273] move history decryption in a separate pub fn and stop using the returned decrypted from find_entry --- src/bin/rbw/commands.rs | 6 ++++-- src/db.rs | 26 ++++++++++++++++---------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index ae5dfb0c..3679360c 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -958,6 +958,7 @@ pub fn history( unlock()?; let db = load_db()?; + let mut dec = RemoteDecrypter {}; let desc = format!( "{}{}", @@ -965,9 +966,10 @@ pub fn history( name ); - let (_, decrypted) = find_entry(&db, name, username, folder, ignore_case) + let (entry, _) = find_entry(&db, name, username, folder, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; - for history in decrypted.history { + + for history in entry.decrypt_history(&mut dec)? { println!("{}: {}", history.last_used_date, history.password); } diff --git a/src/db.rs b/src/db.rs index 9f5190be..391671ab 100644 --- a/src/db.rs +++ b/src/db.rs @@ -659,6 +659,21 @@ impl Entry { } } + pub fn decrypt_history( + &self, + decrypter: &mut impl Decrypter, + ) -> Result> { + self.history + .iter() + .map(|he| { + Ok(HistoryEntry { + last_used_date: he.last_used_date.clone(), + password: decrypter.decrypt_field(Some(&self), &he.password)?, + }) + }) + .collect::>() + } + pub fn decrypt(&self, decrypter: &mut impl Decrypter) -> Result> { // folder name should always be decrypted with the local key because // folders are local to a specific user's vault, not the organization @@ -668,16 +683,7 @@ impl Entry { let notes = self.decrypt_optstring(&self.notes, decrypter)?; - let history = self - .history - .iter() - .map(|he| { - Ok(HistoryEntry { - last_used_date: he.last_used_date.clone(), - password: decrypter.decrypt_field(Some(&self), &he.password)?, - }) - }) - .collect::>()?; + let history = self.decrypt_history(decrypter)?; let mut df = |_ft, val: &Option| self.decrypt_optstring(&val, decrypter); From 0174d17d651aa6c665d18eeebeab8de79002c48e Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 21 May 2026 23:37:03 +0200 Subject: [PATCH 095/273] make find_entry do one thing instead of two --- src/bin/rbw/commands.rs | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 3679360c..1225d481 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -520,6 +520,7 @@ pub fn get( unlock()?; let db = load_db()?; + let mut dec = RemoteDecrypter {}; let desc = format!( "{}{}", @@ -527,9 +528,11 @@ pub fn get( needle ); - let (_, decrypted) = find_entry(&db, needle, user, folder, ignore_case) + let entry = find_entry(&db, needle, user, folder, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; + let decrypted = entry.decrypt(&mut dec)?; + if list_fields { decrypted .get_fields_list() @@ -671,6 +674,7 @@ pub fn code( unlock()?; let db = load_db()?; + let mut dec = RemoteDecrypter {}; let desc = format!( "{}{}", @@ -678,10 +682,12 @@ pub fn code( needle ); - let (_, decrypted) = find_entry(&db, needle, user, folder, ignore_case) + let entry = find_entry(&db, needle, user, folder, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; - if let EntryData::Login { totp, .. } = decrypted.data { + if let EntryData::Login { totp, .. } = &entry.data { + let totp = entry.decrypt_optstring(totp, &mut dec)?; + if let Some(totp) = totp { let code = generate_totp(&totp)?; if clipboard { @@ -871,7 +877,7 @@ pub fn edit( name ); - let (mut entry, _decrypted) = find_entry(&db, name, username, folder, ignore_case) + let mut entry = find_entry(&db, name, username, folder, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; let dec_notes = entry @@ -935,7 +941,7 @@ pub fn remove( name ); - let (entry, _) = find_entry(&db, name, username, folder, ignore_case) + let entry = find_entry(&db, name, username, folder, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; let (new_access_token, ()) = rbw::actions::remove( @@ -966,7 +972,7 @@ pub fn history( name ); - let (entry, _) = find_entry(&db, name, username, folder, ignore_case) + let entry = find_entry(&db, name, username, folder, ignore_case) .with_context(|| format!("couldn't find entry for '{desc}'"))?; for history in entry.decrypt_history(&mut dec)? { @@ -1050,11 +1056,11 @@ fn find_entry( username: Option<&str>, folder: Option<&str>, ignore_case: bool, -) -> anyhow::Result<(rbw::db::Entry, rbw::db::Entry)> { +) -> anyhow::Result> { if let Needle::Uuid(uuid, s) = needle { for cipher in &db.entries { if uuid::Uuid::parse_str(&cipher.id) == Ok(uuid) { - return Ok((cipher.clone(), cipher.decrypt(&mut RemoteDecrypter {})?)); + return Ok(cipher.clone()); } } needle = Needle::Name(s); @@ -1065,10 +1071,10 @@ fn find_entry( .iter() .map(|entry| entry.try_into().map(|decrypted| (entry.clone(), decrypted))) .collect::>()?; + let (entry, _) = find_entry_raw(&ciphers, &needle, username, folder, ignore_case)?; - // TODO: Consider if full decryption is necessary - let decrypted_entry = entry.decrypt(&mut RemoteDecrypter {})?; - Ok((entry, decrypted_entry)) + + Ok(entry) } fn find_entry_raw( From 27d7ebc6663f3616661ed39cc694d72592063703 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 21 May 2026 23:52:33 +0200 Subject: [PATCH 096/273] setting folder_id and org_id on the decrypted entry --- src/db.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/db.rs b/src/db.rs index 391671ab..d024fad4 100644 --- a/src/db.rs +++ b/src/db.rs @@ -777,8 +777,8 @@ impl Entry { Ok(Entry:: { id: self.id.clone(), folder, - folder_id: None, - org_id: None, + folder_id: self.folder_id.clone(), + org_id: self.org_id.clone(), key: None, name: decrypter.decrypt_field(Some(&self), &self.name)?, data, From ba7a0a102158b0b3bf340099e9a6735a0774619e Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 22 May 2026 00:49:37 +0200 Subject: [PATCH 097/273] unify fields handling complexity in few, more compact, APIs --- src/db.rs | 391 ++++++++++++++++++------------------------------------ 1 file changed, 130 insertions(+), 261 deletions(-) diff --git a/src/db.rs b/src/db.rs index d024fad4..b17f0d77 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,6 +1,7 @@ use crate::prelude::*; use std::{ + collections::HashMap, fmt::Display, io::{Read as _, Write as _}, }; @@ -253,169 +254,20 @@ impl Entry { } } - /// Get all the custom fields defined by the user with the same name. Yes there can be more - /// than one custom field with the same name. Don't ask me why. - fn get_dynamic_fields(&self, name: &str) -> Vec> { - self.fields - .iter() - .map(|f| { - if let Some(fname) = &f.name { - if fname.to_lowercase().contains(name) { - f.value.clone() - } else { - None - } - } else { - None - } - }) - .collect() - } - /// Ugly function. Its job could be handled semi-automatically by the type system. /// Doesn't need to be "Decrypted" to work. pub fn get_fields_list(&self) -> Vec { - let mut r: Vec = vec![]; - - match &self.data { - EntryData::Login { - username, - password, - totp, - uris, - .. - } => { - if username.is_some() { - r.push(FieldType::Username.to_string()); - } - if totp.is_some() { - r.push(FieldType::Totp.to_string()); - } - if !uris.is_empty() { - r.push(FieldType::Uris.to_string()); - } - if password.is_some() { - r.push(FieldType::Password.to_string()); - } - } - EntryData::Card { - cardholder_name, - number, - brand, - exp_month, - exp_year, - code, - .. - } => { - if number.is_some() { - r.push(FieldType::CardNumber.to_string()); - } - if exp_month.is_some() { - r.push(FieldType::ExpMonth.to_string()); - } - if exp_year.is_some() { - r.push(FieldType::ExpYear.to_string()); - } - if code.is_some() { - r.push(FieldType::Cvv.to_string()); - } - if cardholder_name.is_some() { - r.push(FieldType::Cardholder.to_string()); - } - if brand.is_some() { - r.push(FieldType::Brand.to_string()); - } - } - - EntryData::Identity { - address1, - address2, - address3, - city, - state, - postal_code, - country, - phone, - email, - ssn, - license_number, - passport_number, - username, - title, - first_name, - middle_name, - last_name, - .. - } => { - if [title, first_name, middle_name, last_name] - .iter() - .any(|f| f.is_some()) - { - // the display_field combines all these fields together. - r.push("name".to_string()); - } - if email.is_some() { - r.push(FieldType::Email.to_string()); - } - if [address1, address2, address3].iter().any(|f| f.is_some()) { - // the display_field combines all these fields together. - r.push("address".to_string()); - } - if city.is_some() { - r.push(FieldType::City.to_string()); - } - if state.is_some() { - r.push(FieldType::State.to_string()); - } - if postal_code.is_some() { - r.push(FieldType::PostalCode.to_string()); - } - if country.is_some() { - r.push(FieldType::Country.to_string()); - } - if phone.is_some() { - r.push(FieldType::Phone.to_string()); - } - if ssn.is_some() { - r.push(FieldType::Ssn.to_string()); - } - if license_number.is_some() { - r.push(FieldType::License.to_string()); - } - if passport_number.is_some() { - r.push(FieldType::Passport.to_string()); - } - if username.is_some() { - r.push(FieldType::Username.to_string()); - } - } - - EntryData::SecureNote => (), // handled at the end - EntryData::SshKey { - fingerprint, - public_key, - .. - } => { - if fingerprint.is_some() { - r.push(FieldType::Fingerprint.to_string()); - } - if public_key.is_some() { - r.push(FieldType::PublicKey.to_string()); - } - } - } + let mut ret = vec![]; - if self.notes.is_some() { - r.push(FieldType::Notes.to_string()); + for (k, _) in self.static_fields() { + ret.push(k.to_string()); } - for f in &self.fields { - if let Some(name) = &f.name { - r.push(name.clone()); - } + for (k, _) in self.custom_fields() { + ret.push(k); } - r + ret } /// This function is sh*t but I need it for now @@ -430,76 +282,92 @@ impl Entry { field: &str, generate_totp: fn(&str) -> anyhow::Result, ) -> Vec { - let ftype: FieldType = field.into(); - let ret: Vec> = match &self.data { - EntryData::Login { - username, - totp, - uris, - .. - } => match &ftype { - FieldType::Notes => vec![self.notes.clone()], - FieldType::Username => vec![username.clone()], - FieldType::Totp => { - if let Some(totp) = totp { - match generate_totp(totp) { - Ok(code) => { - vec![Some(code)] - - // val_display_or_store(clipboard, &code); - } + let mut ret = vec![]; + let field: FieldType = field.into(); + + if let FieldType::Custom(field) = field { + for (k, v) in self.custom_fields() { + if k == field { + v.into_iter().for_each(|i| ret.push(i)); + } + } + } else { + for (ft, value) in self.static_fields() { + if ft == field { + let value = if ft == FieldType::Totp { + match generate_totp(&value) { + Ok(totp) => totp, Err(e) => { eprintln!("{e}"); - vec![] + String::new() } } } else { - vec![] - } - } - FieldType::Uris => { - if !uris.is_empty() { - let uri_strs: Vec<_> = uris.iter().map(|uri| uri.uri.clone()).collect(); - // val_display_or_store(clipboard, &uri_strs.join("\n")); - vec![Some(uri_strs.join("\n"))] - } else { - vec![] - } + value + }; + + ret.push(value); } - FieldType::Password => { - // self.display_short(desc, clipboard); - vec![self.get_short()] + } + } + + ret + } + + pub fn static_fields(&self) -> HashMap { + let mut map = HashMap::new(); + + let mut ins = |k, v: &Option| { + if let Some(v) = v { + map.insert(k, v.clone()); + } + }; + + match &self.data { + EntryData::Login { + username, + password, + totp, + uris, + } => { + ins(FieldType::Username, username); + ins(FieldType::Password, password); + ins(FieldType::Totp, totp); + if !uris.is_empty() { + ins( + FieldType::Uris, + &Some( + uris.iter() + .map(|u| u.uri.clone()) + .collect::>() + .join("\n"), + ), + ); } - // This should be Custom - _ => self.get_dynamic_fields(field), - }, + } EntryData::Card { cardholder_name, + number, brand, exp_month, exp_year, code, - .. - } => match &ftype { - FieldType::CardNumber => vec![self.get_short()], - FieldType::Expiration => { - if let (Some(month), Some(year)) = (exp_month, exp_year) { - vec![Some(format!("{month}/{year}"))] - //val_display_or_store(clipboard, &format!("{month}/{year}")); - } else { - vec![] - } + } => { + ins(FieldType::CardNumber, number); + ins(FieldType::Cvv, code); + ins(FieldType::Cardholder, cardholder_name); + ins(FieldType::Brand, brand); + ins(FieldType::ExpMonth, exp_month); + ins(FieldType::ExpYear, exp_year); + if let (Some(m), Some(y)) = (exp_month, exp_year) { + ins(FieldType::Expiration, &Some(format!("{m}/{y}"))); } - FieldType::ExpMonth => vec![exp_month.clone()], - FieldType::ExpYear => vec![exp_year.clone()], - FieldType::Cvv => vec![code.clone()], - FieldType::Name | FieldType::Cardholder => vec![cardholder_name.clone()], - FieldType::Brand => vec![brand.clone()], - FieldType::Notes => vec![self.notes.clone()], - // This should be Custom - _ => self.get_dynamic_fields(field), - }, + } EntryData::Identity { + title, + first_name, + middle_name, + last_name, address1, address2, address3, @@ -513,62 +381,63 @@ impl Entry { license_number, passport_number, username, - .. - } => match &ftype { - FieldType::Name => vec![self.get_short()], - FieldType::Email => vec![email.clone()], - FieldType::Address => { - let mut strs = vec![]; - - if let Some(address1) = address1 { - strs.push(address1.clone()); - } - if let Some(address2) = address2 { - strs.push(address2.clone()); - } - if let Some(address3) = address3 { - strs.push(address3.clone()); - } - - if !strs.is_empty() { - vec![Some(strs.join("\n"))] - //val_display_or_store(clipboard, &strs.join("\n")); - } else { - vec![] - } + } => { + let name: Vec = [title, first_name, middle_name, last_name] + .iter() + .copied() + .flatten() + .cloned() + .collect(); + if !name.is_empty() { + ins(FieldType::Name, &Some(name.join(" "))); } - FieldType::City => vec![city.clone()], - FieldType::State => vec![state.clone()], - FieldType::PostalCode => vec![postal_code.clone()], - FieldType::Country => vec![country.clone()], - FieldType::Phone => vec![phone.clone()], - FieldType::Ssn => vec![ssn.clone()], - FieldType::License => vec![license_number.clone()], - FieldType::Passport => vec![passport_number.clone()], - FieldType::Username => vec![username.clone()], - FieldType::Notes => vec![self.notes.clone()], - _ => self.get_dynamic_fields(field), - }, - - EntryData::SecureNote => match &ftype { - FieldType::Notes => vec![self.get_short()], - _ => self.get_dynamic_fields(field), - }, + let address: Vec = [address1, address2, address3] + .iter() + .copied() + .flatten() + .cloned() + .collect(); + if !address.is_empty() { + ins(FieldType::Address, &Some(address.join("\n"))); + } + + ins(FieldType::City, city); + ins(FieldType::State, state); + ins(FieldType::PostalCode, postal_code); + ins(FieldType::Country, country); + ins(FieldType::Phone, phone); + ins(FieldType::Email, email); + ins(FieldType::Ssn, ssn); + ins(FieldType::License, license_number); + ins(FieldType::Passport, passport_number); + ins(FieldType::Username, username); + } + EntryData::SecureNote => {} EntryData::SshKey { - fingerprint, private_key, - .. - } => match &ftype { - FieldType::Fingerprint => vec![fingerprint.clone()], - FieldType::PublicKey => vec![self.get_short()], - FieldType::PrivateKey => vec![private_key.clone()], - FieldType::Notes => vec![self.notes.clone()], - _ => self.get_dynamic_fields(field), - }, - }; + public_key, + fingerprint, + } => { + ins(FieldType::PrivateKey, private_key); + ins(FieldType::PublicKey, public_key); + ins(FieldType::Fingerprint, fingerprint); + } + } + + ins(FieldType::Notes, &self.notes); - ret.into_iter().flatten().collect() + map + } + + pub fn custom_fields(&self) -> HashMap> { + let mut map: HashMap> = HashMap::new(); + for f in &self.fields { + if let (Some(name), Some(value)) = (&f.name, &f.value) { + map.entry(name.clone()).or_default().push(value.clone()); + } + } + map } } From 1b3c8ff719d0d19915361b2a42f2a428dd8e3580 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 22 May 2026 00:52:05 +0200 Subject: [PATCH 098/273] improve naming and remove ugly comment --- src/db.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/db.rs b/src/db.rs index b17f0d77..97bbfdc5 100644 --- a/src/db.rs +++ b/src/db.rs @@ -270,7 +270,6 @@ impl Entry { ret } - /// This function is sh*t but I need it for now /// Given a textual representation of a field, like "username", "password" or "card number", /// check which type of entry EntryData is and extract the "username" or "cardnumber" field if /// available from the "static" fields, else go check for the dynamic ones. @@ -279,21 +278,21 @@ impl Entry { /// The dynamic fields are the user's added ones and labeled as "Custom field" in GUI apps. pub fn get_field( &self, - field: &str, + field_key: &str, generate_totp: fn(&str) -> anyhow::Result, ) -> Vec { let mut ret = vec![]; - let field: FieldType = field.into(); + let ftype: FieldType = field_key.into(); - if let FieldType::Custom(field) = field { + if let FieldType::Custom(field_key) = ftype { for (k, v) in self.custom_fields() { - if k == field { + if k == field_key { v.into_iter().for_each(|i| ret.push(i)); } } } else { for (ft, value) in self.static_fields() { - if ft == field { + if ft == ftype { let value = if ft == FieldType::Totp { match generate_totp(&value) { Ok(totp) => totp, From a05204289e82fe4e8f60189890e1494fcec326f0 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 22 May 2026 01:03:18 +0200 Subject: [PATCH 099/273] since the fields() functions return hashmaps, we better use them --- src/db.rs | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/src/db.rs b/src/db.rs index 97bbfdc5..6c5b6712 100644 --- a/src/db.rs +++ b/src/db.rs @@ -285,28 +285,24 @@ impl Entry { let ftype: FieldType = field_key.into(); if let FieldType::Custom(field_key) = ftype { - for (k, v) in self.custom_fields() { - if k == field_key { - v.into_iter().for_each(|i| ret.push(i)); - } + if let Some(value) = self.custom_fields().remove(&field_key) { + value.into_iter().for_each(|i| ret.push(i)); } } else { - for (ft, value) in self.static_fields() { - if ft == ftype { - let value = if ft == FieldType::Totp { - match generate_totp(&value) { - Ok(totp) => totp, - Err(e) => { - eprintln!("{e}"); - String::new() - } + if let Some(value) = self.static_fields().remove(&ftype) { + let value = if ftype == FieldType::Totp { + match generate_totp(&value) { + Ok(totp) => totp, + Err(e) => { + eprintln!("{e}"); + String::new() } - } else { - value - }; + } + } else { + value + }; - ret.push(value); - } + ret.push(value); } } From 715902a9f30e885d311171936d51a9893ca68f85 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 22 May 2026 01:03:30 +0200 Subject: [PATCH 100/273] move find_entry fns up --- src/bin/rbw/commands.rs | 172 ++++++++++++++++++++-------------------- 1 file changed, 86 insertions(+), 86 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 1225d481..204b6b3a 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -472,6 +472,92 @@ pub fn sync() -> anyhow::Result<()> { crate::actions::sync() } +fn find_entry( + db: &rbw::db::Db, + mut needle: Needle, + username: Option<&str>, + folder: Option<&str>, + ignore_case: bool, +) -> anyhow::Result> { + if let Needle::Uuid(uuid, s) = needle { + for cipher in &db.entries { + if uuid::Uuid::parse_str(&cipher.id) == Ok(uuid) { + return Ok(cipher.clone()); + } + } + needle = Needle::Name(s); + } + + let ciphers: Vec<(rbw::db::Entry, SearchEntry)> = db + .entries + .iter() + .map(|entry| entry.try_into().map(|decrypted| (entry.clone(), decrypted))) + .collect::>()?; + + let (entry, _) = find_entry_raw(&ciphers, &needle, username, folder, ignore_case)?; + + Ok(entry) +} + +fn find_entry_raw( + entries: &[(rbw::db::Entry, SearchEntry)], + needle: &Needle, + username: Option<&str>, + folder: Option<&str>, + ignore_case: bool, +) -> anyhow::Result<(rbw::db::Entry, SearchEntry)> { + let mut matches: Vec<(rbw::db::Entry, SearchEntry)> = vec![]; + + let find_matches = |strict_username, strict_folder, exact| { + entries + .iter() + .filter(|&(_, decrypted_cipher)| { + decrypted_cipher.matches( + needle, + username, + folder, + ignore_case, + strict_username, + strict_folder, + exact, + ) + }) + .cloned() + .collect() + }; + + for exact in [true, false] { + matches = find_matches(true, true, exact); + if matches.len() == 1 { + return Ok(matches[0].clone()); + } + + let strict_folder_matches = find_matches(false, true, exact); + let strict_username_matches = find_matches(true, false, exact); + if strict_folder_matches.len() == 1 && strict_username_matches.len() != 1 { + return Ok(strict_folder_matches[0].clone()); + } else if strict_folder_matches.len() != 1 && strict_username_matches.len() == 1 { + return Ok(strict_username_matches[0].clone()); + } + + matches = find_matches(false, false, exact); + if matches.len() == 1 { + return Ok(matches[0].clone()); + } + } + + if matches.is_empty() { + Err(anyhow::anyhow!("no entry found")) + } else { + let entries: Vec = matches + .iter() + .map(|(_, decrypted)| decrypted.display_name()) + .collect(); + let entries = entries.join(", "); + Err(anyhow::anyhow!("multiple entries found: {entries}")) + } +} + pub fn display_entry_field(entry: &rbw::db::Entry, desc: &str, field: &str) { let fields = entry.get_field(&field.to_lowercase(), generate_totp); if fields.is_empty() { @@ -1050,92 +1136,6 @@ fn version_or_quit() -> anyhow::Result { }) } -fn find_entry( - db: &rbw::db::Db, - mut needle: Needle, - username: Option<&str>, - folder: Option<&str>, - ignore_case: bool, -) -> anyhow::Result> { - if let Needle::Uuid(uuid, s) = needle { - for cipher in &db.entries { - if uuid::Uuid::parse_str(&cipher.id) == Ok(uuid) { - return Ok(cipher.clone()); - } - } - needle = Needle::Name(s); - } - - let ciphers: Vec<(rbw::db::Entry, SearchEntry)> = db - .entries - .iter() - .map(|entry| entry.try_into().map(|decrypted| (entry.clone(), decrypted))) - .collect::>()?; - - let (entry, _) = find_entry_raw(&ciphers, &needle, username, folder, ignore_case)?; - - Ok(entry) -} - -fn find_entry_raw( - entries: &[(rbw::db::Entry, SearchEntry)], - needle: &Needle, - username: Option<&str>, - folder: Option<&str>, - ignore_case: bool, -) -> anyhow::Result<(rbw::db::Entry, SearchEntry)> { - let mut matches: Vec<(rbw::db::Entry, SearchEntry)> = vec![]; - - let find_matches = |strict_username, strict_folder, exact| { - entries - .iter() - .filter(|&(_, decrypted_cipher)| { - decrypted_cipher.matches( - needle, - username, - folder, - ignore_case, - strict_username, - strict_folder, - exact, - ) - }) - .cloned() - .collect() - }; - - for exact in [true, false] { - matches = find_matches(true, true, exact); - if matches.len() == 1 { - return Ok(matches[0].clone()); - } - - let strict_folder_matches = find_matches(false, true, exact); - let strict_username_matches = find_matches(true, false, exact); - if strict_folder_matches.len() == 1 && strict_username_matches.len() != 1 { - return Ok(strict_folder_matches[0].clone()); - } else if strict_folder_matches.len() != 1 && strict_username_matches.len() == 1 { - return Ok(strict_username_matches[0].clone()); - } - - matches = find_matches(false, false, exact); - if matches.len() == 1 { - return Ok(matches[0].clone()); - } - } - - if matches.is_empty() { - Err(anyhow::anyhow!("no entry found")) - } else { - let entries: Vec = matches - .iter() - .map(|(_, decrypted)| decrypted.display_name()) - .collect(); - let entries = entries.join(", "); - Err(anyhow::anyhow!("multiple entries found: {entries}")) - } -} - fn with_config(f: impl FnOnce(&str, &str) -> anyhow::Result) -> anyhow::Result { let config = rbw::config::Config::load()?; let Some(email) = &config.email else { From 58fbfc0beffcf0c4e0b823bc741d5451906c0a57 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 22 May 2026 01:06:15 +0200 Subject: [PATCH 101/273] add small backwards compatibility comment --- src/bin/rbw/commands.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 204b6b3a..47b5618d 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -970,6 +970,8 @@ pub fn edit( .decrypt_optstring(&entry.notes, &mut dec)? .map_or_else(String::new, |n| format!("\n{n}\n")); + // NOTE: Editing, previously, was limited to Login and SecureNote types. Now it's not limited + // anymore. This behavior is not 100% backwards compatible, but it's hardly noticeable let (contents, help) = if let EntryData::Login { password, .. } = &entry.data { let dec_password = entry .decrypt_optstring(password, &mut dec)? From 7f1f758ffea8d7c66d944244baa4166f7e9cb781 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 22 May 2026 10:28:45 +0200 Subject: [PATCH 102/273] impl FromStr for Needle --- src/bin/rbw/commands.rs | 33 ++++++++++++++++++--------------- src/bin/rbw/main.rs | 2 +- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 47b5618d..7d916612 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1,5 +1,6 @@ use std::{ - fmt::Display, io::Write as _, os::unix::ffi::OsStrExt as _, path::PathBuf, time::SystemTime, + fmt::Display, io::Write as _, os::unix::ffi::OsStrExt as _, path::PathBuf, str::FromStr, + time::SystemTime, }; use anyhow::Context as _; @@ -37,19 +38,21 @@ impl Display for Needle { } } -// TODO: Could this be FromStr? -#[allow(clippy::unnecessary_wraps)] -pub fn parse_needle(arg: &str) -> Result { - if let Ok(uuid) = uuid::Uuid::parse_str(arg) { - return Ok(Needle::Uuid(uuid, arg.to_string())); - } - if let Ok(url) = url::Url::parse(arg) { - if url.is_special() { - return Ok(Needle::Uri(url)); +impl FromStr for Needle { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + if let Ok(uuid) = uuid::Uuid::parse_str(s) { + return Ok(Needle::Uuid(uuid, s.to_string())); + } + if let Ok(url) = url::Url::parse(s) { + if url.is_special() { + return Ok(Needle::Uri(url)); + } } - } - Ok(Needle::Name(arg.to_string())) + Ok(Needle::Name(s.to_string())) + } } /// This Encrypter implementation will send the decrypted string to the agent and wait for it to @@ -2224,7 +2227,7 @@ mod test { entries_eq( &find_entry_raw( entries, - &parse_needle(needle).unwrap(), + &needle.parse().unwrap(), username, folder, ignore_case, @@ -2244,7 +2247,7 @@ mod test { ) -> bool { let res = find_entry_raw( entries, - &parse_needle(needle).unwrap(), + &needle.parse().unwrap(), username, folder, ignore_case, @@ -2266,7 +2269,7 @@ mod test { ) -> bool { let res = find_entry_raw( entries, - &parse_needle(needle).unwrap(), + &needle.parse().unwrap(), username, folder, ignore_case, diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index d2af2b6c..991a231f 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -9,7 +9,7 @@ mod sock; #[derive(Debug, clap::Args)] struct FindArgs { - #[arg(help = "Name, URI or UUID of the entry to display", value_parser = commands::parse_needle)] + #[arg(help = "Name, URI or UUID of the entry to display")] needle: commands::Needle, #[arg(help = "Username of the entry to display")] user: Option, From b2644ee6a28b3fa31d5db10bdb3b360efa0ae95b Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 22 May 2026 10:33:12 +0200 Subject: [PATCH 103/273] move help msg --- src/bin/rbw/commands.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 7d916612..502a29cb 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -350,17 +350,6 @@ impl TryFrom<&String> for ListField { } } -const HELP_PW: &str = r" -# The first line of this file will be the password, and the remainder of the -# file (after any blank lines after the password) will be stored as a note. -# Lines with leading # will be ignored. -"; - -const HELP_NOTES: &str = r" -# The content of this file will be stored as a note. -# Lines with leading # will be ignored. -"; - pub fn config_show() -> anyhow::Result<()> { let config = rbw::config::Config::load()?; serde_json::to_writer_pretty(std::io::stdout(), &config) @@ -861,6 +850,17 @@ fn update_token(db: &mut rbw::db::Db, new_token: Option) -> anyhow::Resu Ok(()) } +const HELP_PW: &str = r" +# The first line of this file will be the password, and the remainder of the +# file (after any blank lines after the password) will be stored as a note. +# Lines with leading # will be ignored. +"; + +const HELP_NOTES: &str = r" +# The content of this file will be stored as a note. +# Lines with leading # will be ignored. +"; + pub fn add( name: &str, username: Option<&str>, From 5a4db72803920dfcb00ec71a0ad44d681c467add Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 22 May 2026 10:45:48 +0200 Subject: [PATCH 104/273] condense shared behavior in client actions --- src/bin/rbw/actions.rs | 43 ++++++++++++++++-------------------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/src/bin/rbw/actions.rs b/src/bin/rbw/actions.rs index 4c305440..1295858d 100644 --- a/src/bin/rbw/actions.rs +++ b/src/bin/rbw/actions.rs @@ -79,17 +79,12 @@ pub fn decrypt( entry_key: Option<&str>, org_id: Option<&str>, ) -> anyhow::Result { - let mut sock = connect()?; - sock.send(&rbw::protocol::Request::new( - get_environment(), - rbw::protocol::Action::Decrypt { - cipherstring: cipherstring.to_string(), - entry_key: entry_key.map(std::string::ToString::to_string), - org_id: org_id.map(std::string::ToString::to_string), - }, - ))?; + let res = complex_action(rbw::protocol::Action::Decrypt { + cipherstring: cipherstring.to_string(), + entry_key: entry_key.map(std::string::ToString::to_string), + org_id: org_id.map(std::string::ToString::to_string), + })?; - let res = sock.recv()?; match res { rbw::protocol::Response::Decrypt { plaintext } => Ok(plaintext), rbw::protocol::Response::Error { error } => { @@ -100,16 +95,11 @@ pub fn decrypt( } pub fn encrypt(plaintext: &str, org_id: Option<&str>) -> anyhow::Result { - let mut sock = connect()?; - sock.send(&rbw::protocol::Request::new( - get_environment(), - rbw::protocol::Action::Encrypt { - plaintext: plaintext.to_string(), - org_id: org_id.map(std::string::ToString::to_string), - }, - ))?; + let res = complex_action(rbw::protocol::Action::Encrypt { + plaintext: plaintext.to_string(), + org_id: org_id.map(std::string::ToString::to_string), + })?; - let res = sock.recv()?; match res { rbw::protocol::Response::Encrypt { cipherstring } => Ok(cipherstring), rbw::protocol::Response::Error { error } => { @@ -126,13 +116,8 @@ pub fn clipboard_store(text: &str) -> anyhow::Result<()> { } pub fn version() -> anyhow::Result { - let mut sock = connect()?; - sock.send(&rbw::protocol::Request::new( - get_environment(), - rbw::protocol::Action::Version, - ))?; + let res = complex_action(rbw::protocol::Action::Version)?; - let res = sock.recv()?; match res { rbw::protocol::Response::Version { version } => Ok(version), rbw::protocol::Response::Error { error } => { @@ -142,12 +127,16 @@ pub fn version() -> anyhow::Result { } } -fn simple_action(action: rbw::protocol::Action) -> anyhow::Result<()> { +fn complex_action(action: rbw::protocol::Action) -> anyhow::Result { let mut sock = connect()?; sock.send(&rbw::protocol::Request::new(get_environment(), action))?; + sock.recv() +} + +fn simple_action(action: rbw::protocol::Action) -> anyhow::Result<()> { + let res = complex_action(action)?; - let res = sock.recv()?; match res { rbw::protocol::Response::Ack => Ok(()), rbw::protocol::Response::Error { error } => Err(anyhow::anyhow!("{error}")), From 63606bb84633dcfd69277de8e4493a01b3597224 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 22 May 2026 22:41:44 +0200 Subject: [PATCH 105/273] remove full spec for Arc and Mutex --- src/bin/rbw-agent/ssh_agent.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/bin/rbw-agent/ssh_agent.rs b/src/bin/rbw-agent/ssh_agent.rs index 1fe72913..1f8874d9 100644 --- a/src/bin/rbw-agent/ssh_agent.rs +++ b/src/bin/rbw-agent/ssh_agent.rs @@ -1,4 +1,7 @@ +use std::sync::Arc; + use signature::{RandomizedSigner as _, SignatureEncoding as _, Signer as _}; +use tokio::sync::Mutex; const SSH_AGENT_RSA_SHA2_256: u32 = 2; const SSH_AGENT_RSA_SHA2_512: u32 = 4; @@ -15,11 +18,11 @@ async fn config_confirm_ssh() -> anyhow::Result { #[derive(Clone)] pub struct SshAgent { - state: std::sync::Arc>, + state: Arc>, } impl SshAgent { - pub fn new(state: std::sync::Arc>) -> Self { + pub fn new(state: Arc>) -> Self { Self { state } } From 258f563cb6e4ad3010f2b65e964a8b69e645eb45 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 22 May 2026 22:42:29 +0200 Subject: [PATCH 106/273] dedup some code and pass FindArgs directly to commands --- src/bin/rbw/commands.rs | 77 +++++++++++++++++++--------------- src/bin/rbw/main.rs | 91 ++++++++++------------------------------- 2 files changed, 66 insertions(+), 102 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 502a29cb..17f6b566 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -6,6 +6,8 @@ use std::{ use anyhow::Context as _; use rbw::db::{Decrypted, Decrypter, Encrypted, Encrypter, EntryData}; +use crate::FindArgs; + // The default number of seconds the generated TOTP // code lasts for before a new one must be generated const TOTP_DEFAULT_STEP: u64 = 30; @@ -583,16 +585,17 @@ pub fn display_entry_short(entry: &rbw::db::Entry, desc: &str) -> boo true } -#[allow(clippy::fn_params_excessive_bools)] pub fn get( - needle: Needle, - user: Option<&str>, - folder: Option<&str>, + FindArgs { + needle, + user, + folder, + ignorecase, + }: FindArgs, field: Option<&str>, full: bool, raw: bool, clipboard: bool, - ignore_case: bool, list_fields: bool, ) -> anyhow::Result<()> { unlock()?; @@ -602,11 +605,11 @@ pub fn get( let desc = format!( "{}{}", - user.map_or_else(String::new, |s| format!("{s}@")), + user.as_ref().map_or_else(String::new, |s| format!("{s}@")), needle ); - let entry = find_entry(&db, needle, user, folder, ignore_case) + let entry = find_entry(&db, needle, user.as_deref(), folder.as_deref(), ignorecase) .with_context(|| format!("couldn't find entry for '{desc}'"))?; let decrypted = entry.decrypt(&mut dec)?; @@ -743,11 +746,13 @@ pub fn list(fields: &[String], raw: bool) -> anyhow::Result<()> { } pub fn code( - needle: Needle, - user: Option<&str>, - folder: Option<&str>, + FindArgs { + needle, + user, + folder, + ignorecase, + }: FindArgs, clipboard: bool, - ignore_case: bool, ) -> anyhow::Result<()> { unlock()?; @@ -756,11 +761,11 @@ pub fn code( let desc = format!( "{}{}", - user.map_or_else(String::new, |s| format!("{s}@")), + user.as_ref().map_or_else(String::new, |s| format!("{s}@")), needle ); - let entry = find_entry(&db, needle, user, folder, ignore_case) + let entry = find_entry(&db, needle, user.as_deref(), folder.as_deref(), ignorecase) .with_context(|| format!("couldn't find entry for '{desc}'"))?; if let EntryData::Login { totp, .. } = &entry.data { @@ -948,10 +953,12 @@ pub fn generate( } pub fn edit( - name: Needle, - username: Option<&str>, - folder: Option<&str>, - ignore_case: bool, + FindArgs { + needle, + user, + folder, + ignorecase, + }: FindArgs, ) -> anyhow::Result<()> { unlock()?; @@ -962,11 +969,11 @@ pub fn edit( let desc = format!( "{}{}", - username.map_or_else(String::new, |s| format!("{s}@")), - name + user.as_ref().map_or_else(String::new, |s| format!("{s}@")), + needle ); - let mut entry = find_entry(&db, name, username, folder, ignore_case) + let mut entry = find_entry(&db, needle, user.as_deref(), folder.as_deref(), ignorecase) .with_context(|| format!("couldn't find entry for '{desc}'"))?; let dec_notes = entry @@ -1017,10 +1024,12 @@ pub fn edit( } pub fn remove( - name: Needle, - username: Option<&str>, - folder: Option<&str>, - ignore_case: bool, + FindArgs { + needle, + user, + folder, + ignorecase, + }: FindArgs, ) -> anyhow::Result<()> { unlock()?; @@ -1028,11 +1037,11 @@ pub fn remove( let desc = format!( "{}{}", - username.map_or_else(String::new, |s| format!("{s}@")), - name + user.as_ref().map_or_else(String::new, |s| format!("{s}@")), + needle ); - let entry = find_entry(&db, name, username, folder, ignore_case) + let entry = find_entry(&db, needle, user.as_deref(), folder.as_deref(), ignorecase) .with_context(|| format!("couldn't find entry for '{desc}'"))?; let (new_access_token, ()) = rbw::actions::remove( @@ -1047,10 +1056,12 @@ pub fn remove( } pub fn history( - name: Needle, - username: Option<&str>, - folder: Option<&str>, - ignore_case: bool, + FindArgs { + needle: name, + user, + folder, + ignorecase, + }: FindArgs, ) -> anyhow::Result<()> { unlock()?; @@ -1059,11 +1070,11 @@ pub fn history( let desc = format!( "{}{}", - username.map_or_else(String::new, |s| format!("{s}@")), + user.as_ref().map_or_else(String::new, |s| format!("{s}@")), name ); - let entry = find_entry(&db, name, username, folder, ignore_case) + let entry = find_entry(&db, name, user.as_deref(), folder.as_deref(), ignorecase) .with_context(|| format!("couldn't find entry for '{desc}'"))?; for history in entry.decrypt_history(&mut dec)? { diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index 991a231f..3c789d05 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -8,7 +8,7 @@ mod commands; mod sock; #[derive(Debug, clap::Args)] -struct FindArgs { +pub struct FindArgs { #[arg(help = "Name, URI or UUID of the entry to display")] needle: commands::Needle, #[arg(help = "Username of the entry to display")] @@ -299,6 +299,15 @@ impl Config { } } +fn generate_completion(generator: G) { + clap_complete::generate( + generator, + &mut Opt::command(), + "rbw", + &mut std::io::stdout(), + ); +} + fn main() { let opt = Opt::parse(); @@ -335,9 +344,7 @@ fn main() { clipboard, list_fields, } => commands::get( - find_args.needle.clone(), - find_args.user.as_deref(), - find_args.folder.as_deref(), + find_args, field.as_deref(), full, raw, @@ -345,7 +352,6 @@ fn main() { clipboard, #[cfg(not(feature = "clipboard"))] false, - find_args.ignorecase, list_fields, ), Opt::Search { @@ -359,14 +365,11 @@ fn main() { #[cfg(feature = "clipboard")] clipboard, } => commands::code( - find_args.needle, - find_args.user.as_deref(), - find_args.folder.as_deref(), + find_args, #[cfg(feature = "clipboard")] clipboard, #[cfg(not(feature = "clipboard"))] false, - find_args.ignorecase, ), Opt::Add { name, @@ -419,87 +422,37 @@ fn main() { ty, ) } - Opt::Edit { find_args } => commands::edit( - find_args.needle, - find_args.user.as_deref(), - find_args.folder.as_deref(), - find_args.ignorecase, - ), - Opt::Remove { find_args } => commands::remove( - find_args.needle, - find_args.user.as_deref(), - find_args.folder.as_deref(), - find_args.ignorecase, - ), - Opt::History { find_args } => commands::history( - find_args.needle, - find_args.user.as_deref(), - find_args.folder.as_deref(), - find_args.ignorecase, - ), + Opt::Edit { find_args } => commands::edit(find_args), + Opt::Remove { find_args } => commands::remove(find_args), + Opt::History { find_args } => commands::history(find_args), Opt::Lock => commands::lock(), Opt::Purge => commands::purge(), Opt::StopAgent => commands::stop_agent(), Opt::GenCompletions { shell } => { match shell { CompletionShell::Bash => { - clap_complete::generate( - clap_complete::Shell::Bash, - &mut Opt::command(), - "rbw", - &mut std::io::stdout(), - ); + generate_completion(clap_complete::Shell::Bash); println!("{}", include_str!("completion/rbw.bash")); } CompletionShell::Fish => { - clap_complete::generate( - clap_complete::Shell::Fish, - &mut Opt::command(), - "rbw", - &mut std::io::stdout(), - ); + generate_completion(clap_complete::Shell::Fish); println!("{}", include_str!("completion/rbw.fish")); } CompletionShell::Zsh => { - clap_complete::generate( - clap_complete::Shell::Zsh, - &mut Opt::command(), - "rbw", - &mut std::io::stdout(), - ); + generate_completion(clap_complete::Shell::Zsh); println!("{}", include_str!("completion/rbw.zsh")); } CompletionShell::Powershell => { - clap_complete::generate( - clap_complete::Shell::PowerShell, - &mut Opt::command(), - "rbw", - &mut std::io::stdout(), - ); + generate_completion(clap_complete::Shell::PowerShell); } CompletionShell::Elvish => { - clap_complete::generate( - clap_complete::Shell::Elvish, - &mut Opt::command(), - "rbw", - &mut std::io::stdout(), - ); + generate_completion(clap_complete::Shell::Elvish); } CompletionShell::Nushell => { - clap_complete::generate( - clap_complete_nushell::Nushell, - &mut Opt::command(), - "rbw", - &mut std::io::stdout(), - ); + generate_completion(clap_complete_nushell::Nushell); } CompletionShell::Fig => { - clap_complete::generate( - clap_complete_fig::Fig, - &mut Opt::command(), - "rbw", - &mut std::io::stdout(), - ); + generate_completion(clap_complete_fig::Fig); } } Ok(()) From d878e4522e17d246f0782ab22b4114ec3e01d6ba Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 22 May 2026 22:44:47 +0200 Subject: [PATCH 107/273] split gen_completions in separate fn --- src/bin/rbw/main.rs | 56 ++++++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index 3c789d05..92570b4b 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -308,6 +308,35 @@ fn generate_completion(generator: G) { ); } +fn gen_completions(shell: CompletionShell) { + match shell { + CompletionShell::Bash => { + generate_completion(clap_complete::Shell::Bash); + println!("{}", include_str!("completion/rbw.bash")); + } + CompletionShell::Fish => { + generate_completion(clap_complete::Shell::Fish); + println!("{}", include_str!("completion/rbw.fish")); + } + CompletionShell::Zsh => { + generate_completion(clap_complete::Shell::Zsh); + println!("{}", include_str!("completion/rbw.zsh")); + } + CompletionShell::Powershell => { + generate_completion(clap_complete::Shell::PowerShell); + } + CompletionShell::Elvish => { + generate_completion(clap_complete::Shell::Elvish); + } + CompletionShell::Nushell => { + generate_completion(clap_complete_nushell::Nushell); + } + CompletionShell::Fig => { + generate_completion(clap_complete_fig::Fig); + } + } +} + fn main() { let opt = Opt::parse(); @@ -429,32 +458,7 @@ fn main() { Opt::Purge => commands::purge(), Opt::StopAgent => commands::stop_agent(), Opt::GenCompletions { shell } => { - match shell { - CompletionShell::Bash => { - generate_completion(clap_complete::Shell::Bash); - println!("{}", include_str!("completion/rbw.bash")); - } - CompletionShell::Fish => { - generate_completion(clap_complete::Shell::Fish); - println!("{}", include_str!("completion/rbw.fish")); - } - CompletionShell::Zsh => { - generate_completion(clap_complete::Shell::Zsh); - println!("{}", include_str!("completion/rbw.zsh")); - } - CompletionShell::Powershell => { - generate_completion(clap_complete::Shell::PowerShell); - } - CompletionShell::Elvish => { - generate_completion(clap_complete::Shell::Elvish); - } - CompletionShell::Nushell => { - generate_completion(clap_complete_nushell::Nushell); - } - CompletionShell::Fig => { - generate_completion(clap_complete_fig::Fig); - } - } + gen_completions(shell); Ok(()) } } From f3174ab8eca1bd4f2ffdd7aa6a605182e3d58478 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 22 May 2026 23:03:59 +0200 Subject: [PATCH 108/273] split pwgen type calculation --- src/bin/rbw/main.rs | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index 92570b4b..de03563b 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -337,6 +337,21 @@ fn gen_completions(shell: CompletionShell) { } } +fn calc_pwgen_type( + no_symbols: bool, + only_numbers: bool, + nonconfusables: bool, + diceware: bool, +) -> rbw::pwgen::Type { + match (no_symbols, only_numbers, nonconfusables, diceware) { + (true, ..) => rbw::pwgen::Type::NoSymbols, + (_, true, ..) => rbw::pwgen::Type::Numbers, + (_, _, true, _) => rbw::pwgen::Type::NonConfusables, + (.., true) => rbw::pwgen::Type::Diceware, + _ => rbw::pwgen::Type::AllChars, + } +} + fn main() { let opt = Opt::parse(); @@ -427,17 +442,6 @@ fn main() { nonconfusables, diceware, } => { - let ty = if no_symbols { - rbw::pwgen::Type::NoSymbols - } else if only_numbers { - rbw::pwgen::Type::Numbers - } else if nonconfusables { - rbw::pwgen::Type::NonConfusables - } else if diceware { - rbw::pwgen::Type::Diceware - } else { - rbw::pwgen::Type::AllChars - }; commands::generate( name.as_deref(), user.as_deref(), @@ -448,7 +452,7 @@ fn main() { .collect::>(), folder.as_deref(), len, - ty, + calc_pwgen_type(no_symbols, only_numbers, nonconfusables, diceware), ) } Opt::Edit { find_args } => commands::edit(find_args), From 4a5d1c7c8062f609da4e05ab4ee25791aef36a28 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 22 May 2026 23:07:55 +0200 Subject: [PATCH 109/273] Arc and Mutex short --- src/bin/rbw-agent/actions.rs | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 06140906..64c41629 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -1,5 +1,8 @@ +use std::sync::Arc; + use anyhow::Context as _; use sha2::Digest as _; +use tokio::sync::Mutex; pub async fn register( sock: &mut crate::sock::Sock, @@ -71,7 +74,7 @@ pub async fn register( pub async fn login( sock: &mut crate::sock::Sock, - state: std::sync::Arc>, + state: Arc>, environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { let db = load_db().await.unwrap_or_else(|_| rbw::db::Db::new()); @@ -280,7 +283,7 @@ async fn two_factor( } async fn login_success( - state: std::sync::Arc>, + state: Arc>, access_token: String, refresh_token: String, kdf: rbw::api::KdfType, @@ -335,7 +338,7 @@ async fn login_success( } async fn unlock_state( - state: std::sync::Arc>, + state: Arc>, environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { if state.lock().await.needs_unlock() { @@ -414,7 +417,7 @@ async fn unlock_state( pub async fn unlock( sock: &mut crate::sock::Sock, - state: std::sync::Arc>, + state: Arc>, environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { unlock_state(state, environment).await?; @@ -425,7 +428,7 @@ pub async fn unlock( } async fn unlock_success( - state: std::sync::Arc>, + state: Arc>, keys: rbw::locked::Keys, org_keys: std::collections::HashMap, ) -> anyhow::Result<()> { @@ -437,7 +440,7 @@ async fn unlock_success( pub async fn lock( sock: &mut crate::sock::Sock, - state: std::sync::Arc>, + state: Arc>, ) -> anyhow::Result<()> { state.lock().await.clear(); @@ -448,7 +451,7 @@ pub async fn lock( pub async fn check_lock( sock: &mut crate::sock::Sock, - state: std::sync::Arc>, + state: Arc>, ) -> anyhow::Result<()> { if state.lock().await.needs_unlock() { return Err(anyhow::anyhow!("agent is locked")); @@ -461,7 +464,7 @@ pub async fn check_lock( pub async fn sync( sock: Option<&mut crate::sock::Sock>, - state: std::sync::Arc>, + state: Arc>, ) -> anyhow::Result<()> { let mut db = load_db().await?; @@ -501,7 +504,7 @@ pub async fn sync( } async fn decrypt_cipher( - state: std::sync::Arc>, + state: Arc>, environment: &rbw::protocol::Environment, cipherstring: &str, entry_key: Option<&str>, @@ -620,7 +623,7 @@ async fn decrypt_cipher( pub async fn decrypt( sock: &mut crate::sock::Sock, - state: std::sync::Arc>, + state: Arc>, environment: &rbw::protocol::Environment, cipherstring: &str, entry_key: Option<&str>, @@ -634,7 +637,7 @@ pub async fn decrypt( pub async fn encrypt( sock: &mut crate::sock::Sock, - state: std::sync::Arc>, + state: Arc>, plaintext: &str, org_id: Option<&str>, ) -> anyhow::Result<()> { @@ -656,7 +659,7 @@ pub async fn encrypt( #[cfg(feature = "clipboard")] pub async fn clipboard_store( sock: &mut crate::sock::Sock, - state: std::sync::Arc>, + state: Arc>, text: &str, ) -> anyhow::Result<()> { let mut state = state.lock().await; @@ -674,7 +677,7 @@ pub async fn clipboard_store( #[cfg(not(feature = "clipboard"))] pub async fn clipboard_store( sock: &mut crate::sock::Sock, - _state: std::sync::Arc>, + _state: Arc>, _text: &str, ) -> anyhow::Result<()> { sock.send(&rbw::protocol::Response::Error { @@ -755,7 +758,7 @@ async fn config_pinentry() -> anyhow::Result { } pub async fn subscribe_to_notifications( - state: std::sync::Arc>, + state: Arc>, ) -> anyhow::Result<()> { if state.lock().await.notifications_handler.is_connected() { return Ok(()); @@ -785,7 +788,7 @@ pub async fn subscribe_to_notifications( } pub async fn get_ssh_public_keys( - state: std::sync::Arc>, + state: Arc>, ) -> anyhow::Result> { let environment = { let state = state.lock().await; @@ -820,7 +823,7 @@ pub async fn get_ssh_public_keys( } pub async fn find_ssh_private_key( - state: std::sync::Arc>, + state: Arc>, request_public_key: ssh_agent_lib::ssh_key::PublicKey, ) -> anyhow::Result { let environment = { From 0e42cb1f38d29e78cdfbadb2475b34543eab5e68 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 22 May 2026 23:28:19 +0200 Subject: [PATCH 110/273] split some getpin calls from big loops --- src/bin/rbw-agent/actions.rs | 88 ++++++++++++++++++++++++------------ 1 file changed, 58 insertions(+), 30 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 64c41629..99612456 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -4,6 +4,40 @@ use anyhow::Context as _; use sha2::Digest as _; use tokio::sync::Mutex; +async fn get_client_id( + host: &str, + err: &Option, + environment: &rbw::protocol::Environment, +) -> anyhow::Result { + rbw::pinentry::getpin( + &config_pinentry().await?, + "API key client__id", + &format!("Log in to {host}"), + err.as_deref(), + environment, + false, + ) + .await + .context("failed to read client_id from pinentry") +} + +async fn get_client_secret( + host: &str, + err: &Option, + environment: &rbw::protocol::Environment, +) -> anyhow::Result { + rbw::pinentry::getpin( + &config_pinentry().await?, + "API key client__secret", + &format!("Log in to {host}"), + err.as_deref(), + environment, + false, + ) + .await + .context("failed to read client_secret from pinentry") +} + pub async fn register( sock: &mut crate::sock::Sock, environment: &rbw::protocol::Environment, @@ -30,26 +64,10 @@ pub async fn register( } else { None }; - let client_id = rbw::pinentry::getpin( - &config_pinentry().await?, - "API key client__id", - &format!("Log in to {host}"), - err.as_deref(), - environment, - false, - ) - .await - .context("failed to read client_id from pinentry")?; - let client_secret = rbw::pinentry::getpin( - &config_pinentry().await?, - "API key client__secret", - &format!("Log in to {host}"), - err.as_deref(), - environment, - false, - ) - .await - .context("failed to read client_secret from pinentry")?; + + let client_id = get_client_id(host, &err, environment).await?; + let client_secret = get_client_secret(host, &err, environment).await?; + let apikey = rbw::locked::ApiKey::new(client_id, client_secret); match rbw::actions::register(&email, apikey.clone()).await { Ok(()) => { @@ -72,6 +90,23 @@ pub async fn register( Ok(()) } +async fn get_password( + host: &str, + err: &Option, + environment: &rbw::protocol::Environment, +) -> anyhow::Result { + rbw::pinentry::getpin( + &config_pinentry().await?, + "Master Password", + &format!("Log in to {host}"), + err.as_deref(), + environment, + true, + ) + .await + .context("failed to read password from pinentry") +} + pub async fn login( sock: &mut crate::sock::Sock, state: Arc>, @@ -99,16 +134,9 @@ pub async fn login( } else { None }; - let password = rbw::pinentry::getpin( - &config_pinentry().await?, - "Master Password", - &format!("Log in to {host}"), - err.as_deref(), - environment, - true, - ) - .await - .context("failed to read password from pinentry")?; + + let password = get_password(host, &err, environment).await?; + match rbw::actions::login(&email, password.clone(), None, None).await { Ok(( access_token, From 2be6902a809799788d529275f3f8fae2bc4220ad Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 22 May 2026 23:33:40 +0200 Subject: [PATCH 111/273] split code getpin --- src/bin/rbw-agent/actions.rs | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 99612456..0b9f6558 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -233,6 +233,23 @@ pub async fn login( Ok(()) } +async fn get_code( + provider: rbw::api::TwoFactorProviderType, + err: &Option, + environment: &rbw::protocol::Environment, +) -> anyhow::Result { + rbw::pinentry::getpin( + &config_pinentry().await?, + provider.header(), + provider.message(), + err.as_deref(), + environment, + provider.grab(), + ) + .await + .context("failed to read code from pinentry") +} + async fn two_factor( environment: &rbw::protocol::Environment, email: &str, @@ -256,17 +273,10 @@ async fn two_factor( } else { None }; - let code = rbw::pinentry::getpin( - &config_pinentry().await?, - provider.header(), - provider.message(), - err.as_deref(), - environment, - provider.grab(), - ) - .await - .context("failed to read code from pinentry")?; + + let code = get_code(provider, &err, environment).await?; let code = std::str::from_utf8(code.password()).context("code was not valid utf8")?; + match rbw::actions::login(email, password.clone(), Some(code), Some(provider)).await { Ok(( access_token, From 44cd0e5fd79ff157e989bbcca8bdafe241ff986c Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 22 May 2026 23:40:33 +0200 Subject: [PATCH 112/273] re-use get_password --- src/bin/rbw-agent/actions.rs | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 0b9f6558..b2ef1308 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -91,14 +91,14 @@ pub async fn register( } async fn get_password( - host: &str, + desc: &str, err: &Option, environment: &rbw::protocol::Environment, ) -> anyhow::Result { rbw::pinentry::getpin( &config_pinentry().await?, "Master Password", - &format!("Log in to {host}"), + desc, err.as_deref(), environment, true, @@ -135,7 +135,7 @@ pub async fn login( None }; - let password = get_password(host, &err, environment).await?; + let password = get_password(&format!("Log in to {host}"), &err, environment).await?; match rbw::actions::login(&email, password.clone(), None, None).await { Ok(( @@ -413,16 +413,14 @@ async fn unlock_state( } else { None }; - let password = rbw::pinentry::getpin( - &config_pinentry().await?, - "Master Password", + + let password = get_password( &format!("Unlock the local database for '{}'", rbw::dirs::profile()), - err.as_deref(), + &err, environment, - true, ) - .await - .context("failed to read password from pinentry")?; + .await?; + match rbw::actions::unlock( &email, &password, @@ -610,17 +608,15 @@ async fn decrypt_cipher( } else { None }; + // TODO: Remember somewhere that only GUI pinentry work, since this is a daemon. - let password = rbw::pinentry::getpin( - &config_pinentry().await?, - "Master Password", + let password = get_password( "Accessing this entry requires the master password", - err.as_deref(), + &err, environment, - true, ) - .await - .context("failed to read password from pinentry")?; + .await?; + match rbw::actions::unlock( &email, &password, From 07a501f01b3e12686008fbb41ecbd5d414c27d0e Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 22 May 2026 23:57:06 +0200 Subject: [PATCH 113/273] reduce boilerplate on register() --- src/bin/rbw-agent/actions.rs | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index b2ef1308..6f8dfd07 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -57,27 +57,19 @@ pub async fn register( let mut err_msg = None; for i in 1_u8..=3 { - let err = if i > 1 { - // this unwrap is safe because we only ever continue the loop - // if we have set err_msg - Some(format!("{} (attempt {}/3)", err_msg.unwrap(), i)) - } else { - None - }; - + let err = err_msg + .as_deref() + .map(|msg| format!("{msg} (attempt {i}/3)")); let client_id = get_client_id(host, &err, environment).await?; let client_secret = get_client_secret(host, &err, environment).await?; let apikey = rbw::locked::ApiKey::new(client_id, client_secret); - match rbw::actions::register(&email, apikey.clone()).await { + + match rbw::actions::register(&email, apikey).await { Ok(()) => { break; } - Err(rbw::error::Error::IncorrectPassword { message }) => { - if i == 3 { - return Err(rbw::error::Error::IncorrectPassword { message }) - .context("failed to log in to bitwarden instance"); - } + Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { err_msg = Some(message); } Err(e) => return Err(e).context("failed to log in to bitwarden instance"), From ca43435f74872405386c0d8206ab7a59cf69aa7b Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 23 May 2026 00:04:54 +0200 Subject: [PATCH 114/273] simplify more repeated code --- src/bin/rbw-agent/actions.rs | 69 ++++++------------------------------ 1 file changed, 11 insertions(+), 58 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 6f8dfd07..19186a39 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -57,9 +57,7 @@ pub async fn register( let mut err_msg = None; for i in 1_u8..=3 { - let err = err_msg - .as_deref() - .map(|msg| format!("{msg} (attempt {i}/3)")); + let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); let client_id = get_client_id(host, &err, environment).await?; let client_secret = get_client_secret(host, &err, environment).await?; @@ -119,13 +117,7 @@ pub async fn login( let mut err_msg = None; 'attempts: for i in 1_u8..=3 { - let err = if i > 1 { - // this unwrap is safe because we only ever continue the loop - // if we have set err_msg - Some(format!("{} (attempt {}/3)", err_msg.unwrap(), i)) - } else { - None - }; + let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); let password = get_password(&format!("Log in to {host}"), &err, environment).await?; @@ -208,11 +200,7 @@ pub async fn login( "unsupported two factor methods: {providers:?}" )); } - Err(rbw::error::Error::IncorrectPassword { message }) => { - if i == 3 { - return Err(rbw::error::Error::IncorrectPassword { message }) - .context("failed to log in to bitwarden instance"); - } + Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { err_msg = Some(message); } Err(e) => return Err(e).context("failed to log in to bitwarden instance"), @@ -258,13 +246,7 @@ async fn two_factor( )> { let mut err_msg = None; for i in 1_u8..=3 { - let err = if i > 1 { - // this unwrap is safe because we only ever continue the loop if - // we have set err_msg - Some(format!("{} (attempt {}/3)", err_msg.unwrap(), i)) - } else { - None - }; + let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); let code = get_code(provider, &err, environment).await?; let code = std::str::from_utf8(code.password()).context("code was not valid utf8")?; @@ -289,21 +271,12 @@ async fn two_factor( protected_key, )) } - Err(rbw::error::Error::IncorrectPassword { message }) => { - if i == 3 { - return Err(rbw::error::Error::IncorrectPassword { message }) - .context("failed to log in to bitwarden instance"); - } + Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { err_msg = Some(message); } // can get this if the user passes an empty string - Err(rbw::error::Error::TwoFactorRequired { .. }) => { - let message = "TOTP code is not a number".to_string(); - if i == 3 { - return Err(rbw::error::Error::IncorrectPassword { message }) - .context("failed to log in to bitwarden instance"); - } - err_msg = Some(message); + Err(rbw::error::Error::TwoFactorRequired { .. }) if i < 3 => { + err_msg = Some("TOTP code is not a number".to_string()); } Err(e) => return Err(e).context("failed to log in to bitwarden instance"), } @@ -398,13 +371,7 @@ async fn unlock_state( let mut err_msg = None; for i in 1_u8..=3 { - let err = if i > 1 { - // this unwrap is safe because we only ever continue the loop - // if we have set err_msg - Some(format!("{} (attempt {}/3)", err_msg.unwrap(), i)) - } else { - None - }; + let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); let password = get_password( &format!("Unlock the local database for '{}'", rbw::dirs::profile()), @@ -428,11 +395,7 @@ async fn unlock_state( unlock_success(state, keys, org_keys).await?; break; } - Err(rbw::error::Error::IncorrectPassword { message }) => { - if i == 3 { - return Err(rbw::error::Error::IncorrectPassword { message }) - .context("failed to unlock database"); - } + Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { err_msg = Some(message); } Err(e) => return Err(e).context("failed to unlock database"), @@ -593,13 +556,7 @@ async fn decrypt_cipher( let mut err_msg = None; for i in 1_u8..=3 { - let err = if i > 1 { - // this unwrap is safe because we only ever continue the loop - // if we have set err_msg - Some(format!("{} (attempt {}/3)", err_msg.unwrap(), i)) - } else { - None - }; + let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); // TODO: Remember somewhere that only GUI pinentry work, since this is a daemon. let password = get_password( @@ -623,11 +580,7 @@ async fn decrypt_cipher( Ok(_) => { break; } - Err(rbw::error::Error::IncorrectPassword { message }) => { - if i == 3 { - return Err(rbw::error::Error::IncorrectPassword { message }) - .context("failed to unlock database"); - } + Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { err_msg = Some(message); } Err(e) => return Err(e).context("failed to unlock database"), From 39b1b6433bb3c8695c38e10ee2c6624459190ec2 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 23 May 2026 00:21:30 +0200 Subject: [PATCH 115/273] extract two_factor_required from login() --- src/bin/rbw-agent/actions.rs | 125 ++++++++++++++++++++--------------- 1 file changed, 71 insertions(+), 54 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 19186a39..f8307363 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -97,12 +97,64 @@ async fn get_password( .context("failed to read password from pinentry") } +async fn two_factor_required( + state: &Arc>, + email: &str, + password: rbw::locked::Password, + providers: Vec, + sso_email_2fa_session_token: Option, + environment: &rbw::protocol::Environment, + db: &mut rbw::db::Db, +) -> anyhow::Result<()> { + let supported_types = [ + rbw::api::TwoFactorProviderType::Authenticator, + rbw::api::TwoFactorProviderType::Yubikey, + rbw::api::TwoFactorProviderType::Email, + ]; + + for provider in supported_types { + if providers.contains(&provider) { + if provider == rbw::api::TwoFactorProviderType::Email { + if let Some(sso_email_2fa_session_token) = sso_email_2fa_session_token { + rbw::actions::send_two_factor_email(&email, &sso_email_2fa_session_token) + .await?; + } + } + + let (access_token, refresh_token, kdf, iterations, memory, parallelism, protected_key) = + two_factor(environment, &email, password.clone(), provider).await?; + + login_success( + state.clone(), + access_token, + refresh_token, + kdf, + iterations, + memory, + parallelism, + protected_key, + password, + db, + email, + ) + .await?; + + return Ok(()); + // break 'attempts; + } + } + + return Err(anyhow::anyhow!( + "unsupported two factor methods: {providers:?}" + )); +} + pub async fn login( sock: &mut crate::sock::Sock, state: Arc>, environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { - let db = load_db().await.unwrap_or_else(|_| rbw::db::Db::new()); + let mut db = load_db().await.unwrap_or_else(|_| rbw::db::Db::new()); if db.needs_login() { let url_str = config_base_url().await?; @@ -117,7 +169,9 @@ pub async fn login( let mut err_msg = None; 'attempts: for i in 1_u8..=3 { - let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); + let err = err_msg + .as_deref() + .map(|msg| format!("{msg} (attempt {i}/3)")); let password = get_password(&format!("Log in to {host}"), &err, environment).await?; @@ -141,8 +195,8 @@ pub async fn login( parallelism, protected_key, password, - db, - email, + &mut db, + &email, ) .await?; break 'attempts; @@ -151,54 +205,17 @@ pub async fn login( providers, sso_email_2fa_session_token, }) => { - let supported_types = vec![ - rbw::api::TwoFactorProviderType::Authenticator, - rbw::api::TwoFactorProviderType::Yubikey, - rbw::api::TwoFactorProviderType::Email, - ]; - - for provider in supported_types { - if providers.contains(&provider) { - if provider == rbw::api::TwoFactorProviderType::Email { - if let Some(sso_email_2fa_session_token) = - sso_email_2fa_session_token - { - rbw::actions::send_two_factor_email( - &email, - &sso_email_2fa_session_token, - ) - .await?; - } - } - let ( - access_token, - refresh_token, - kdf, - iterations, - memory, - parallelism, - protected_key, - ) = two_factor(environment, &email, password.clone(), provider).await?; - login_success( - state.clone(), - access_token, - refresh_token, - kdf, - iterations, - memory, - parallelism, - protected_key, - password, - db, - email, - ) - .await?; - break 'attempts; - } - } - return Err(anyhow::anyhow!( - "unsupported two factor methods: {providers:?}" - )); + two_factor_required( + &state, + &email, + password, + providers, + sso_email_2fa_session_token, + environment, + &mut db, + ) + .await?; + break 'attempts; } Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { err_msg = Some(message); @@ -295,8 +312,8 @@ async fn login_success( parallelism: Option, protected_key: String, password: rbw::locked::Password, - mut db: rbw::db::Db, - email: String, + db: &mut rbw::db::Db, + email: &str, ) -> anyhow::Result<()> { db.access_token = Some(access_token.clone()); db.refresh_token = Some(refresh_token.clone()); From d4a9366f59c36658225a7a9d5cf03fb7d0abefeb Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 23 May 2026 00:24:04 +0200 Subject: [PATCH 116/273] improve two_factor_required readability --- src/bin/rbw-agent/actions.rs | 56 ++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 31 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index f8307363..68f2dbdb 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -112,41 +112,35 @@ async fn two_factor_required( rbw::api::TwoFactorProviderType::Email, ]; - for provider in supported_types { - if providers.contains(&provider) { - if provider == rbw::api::TwoFactorProviderType::Email { - if let Some(sso_email_2fa_session_token) = sso_email_2fa_session_token { - rbw::actions::send_two_factor_email(&email, &sso_email_2fa_session_token) - .await?; - } - } - - let (access_token, refresh_token, kdf, iterations, memory, parallelism, protected_key) = - two_factor(environment, &email, password.clone(), provider).await?; - - login_success( - state.clone(), - access_token, - refresh_token, - kdf, - iterations, - memory, - parallelism, - protected_key, - password, - db, - email, - ) - .await?; + let Some(provider) = supported_types.into_iter().find(|p| providers.contains(p)) else { + return Err(anyhow::anyhow!( + "unsupported two factor methods: {providers:?}" + )); + }; - return Ok(()); - // break 'attempts; + if provider == rbw::api::TwoFactorProviderType::Email { + if let Some(token) = sso_email_2fa_session_token { + rbw::actions::send_two_factor_email(email, &token).await?; } } - return Err(anyhow::anyhow!( - "unsupported two factor methods: {providers:?}" - )); + let (access_token, refresh_token, kdf, iterations, memory, parallelism, protected_key) = + two_factor(environment, &email, password.clone(), provider).await?; + + login_success( + state.clone(), + access_token, + refresh_token, + kdf, + iterations, + memory, + parallelism, + protected_key, + password, + db, + email, + ) + .await } pub async fn login( From 80cdcf4354e1a2819719c524a8d21cbd109e6301 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 23 May 2026 00:27:19 +0200 Subject: [PATCH 117/273] unneeded label --- src/bin/rbw-agent/actions.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 68f2dbdb..8a3b7381 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -162,7 +162,7 @@ pub async fn login( let email = config_email().await?; let mut err_msg = None; - 'attempts: for i in 1_u8..=3 { + for i in 1_u8..=3 { let err = err_msg .as_deref() .map(|msg| format!("{msg} (attempt {i}/3)")); @@ -193,7 +193,8 @@ pub async fn login( &email, ) .await?; - break 'attempts; + + break; } Err(rbw::error::Error::TwoFactorRequired { providers, @@ -209,7 +210,8 @@ pub async fn login( &mut db, ) .await?; - break 'attempts; + + break; } Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { err_msg = Some(message); From 1fdc1f2d43c8f18addaaca07fd5874faac6f5f61 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 23 May 2026 00:41:52 +0200 Subject: [PATCH 118/273] create LoginCredentials for repetitive parameters --- src/actions.rs | 24 +++++----- src/bin/rbw-agent/actions.rs | 90 +++++++----------------------------- 2 files changed, 29 insertions(+), 85 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index 4a873175..3d2c00bd 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -13,20 +13,22 @@ pub async fn register(email: &str, apikey: crate::locked::ApiKey) -> Result<()> Ok(()) } +pub struct LoginCredentials { + pub access_token: String, + pub refresh_token: String, + pub kdf: crate::api::KdfType, + pub iterations: u32, + pub memory: Option, + pub parallelism: Option, + pub protected_key: String, +} + pub async fn login( email: &str, password: crate::locked::Password, two_factor_token: Option<&str>, two_factor_provider: Option, -) -> Result<( - String, - String, - crate::api::KdfType, - u32, - Option, - Option, - String, -)> { +) -> Result { let (client, config) = api_client_async().await?; let (kdf, iterations, memory, parallelism) = client.prelogin(email).await?; @@ -43,7 +45,7 @@ pub async fn login( ) .await?; - Ok(( + Ok(LoginCredentials { access_token, refresh_token, kdf, @@ -51,7 +53,7 @@ pub async fn login( memory, parallelism, protected_key, - )) + }) } pub async fn send_two_factor_email(email: &str, sso_email_2fa_session_token: &str) -> Result<()> { diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 8a3b7381..1b9d354f 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use anyhow::Context as _; +use rbw::actions::LoginCredentials; use sha2::Digest as _; use tokio::sync::Mutex; @@ -124,23 +125,9 @@ async fn two_factor_required( } } - let (access_token, refresh_token, kdf, iterations, memory, parallelism, protected_key) = - two_factor(environment, &email, password.clone(), provider).await?; + let creds = two_factor(environment, &email, password.clone(), provider).await?; - login_success( - state.clone(), - access_token, - refresh_token, - kdf, - iterations, - memory, - parallelism, - protected_key, - password, - db, - email, - ) - .await + login_success(state.clone(), creds, password, db, email).await } pub async fn login( @@ -170,29 +157,8 @@ pub async fn login( let password = get_password(&format!("Log in to {host}"), &err, environment).await?; match rbw::actions::login(&email, password.clone(), None, None).await { - Ok(( - access_token, - refresh_token, - kdf, - iterations, - memory, - parallelism, - protected_key, - )) => { - login_success( - state.clone(), - access_token, - refresh_token, - kdf, - iterations, - memory, - parallelism, - protected_key, - password, - &mut db, - &email, - ) - .await?; + Ok(creds) => { + login_success(state.clone(), creds, password, &mut db, &email).await?; break; } @@ -248,15 +214,7 @@ async fn two_factor( email: &str, password: rbw::locked::Password, provider: rbw::api::TwoFactorProviderType, -) -> anyhow::Result<( - String, - String, - rbw::api::KdfType, - u32, - Option, - Option, - String, -)> { +) -> anyhow::Result { let mut err_msg = None; for i in 1_u8..=3 { let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); @@ -265,25 +223,7 @@ async fn two_factor( let code = std::str::from_utf8(code.password()).context("code was not valid utf8")?; match rbw::actions::login(email, password.clone(), Some(code), Some(provider)).await { - Ok(( - access_token, - refresh_token, - kdf, - iterations, - memory, - parallelism, - protected_key, - )) => { - return Ok(( - access_token, - refresh_token, - kdf, - iterations, - memory, - parallelism, - protected_key, - )) - } + Ok(creds) => return Ok(creds), Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { err_msg = Some(message); } @@ -300,13 +240,15 @@ async fn two_factor( async fn login_success( state: Arc>, - access_token: String, - refresh_token: String, - kdf: rbw::api::KdfType, - iterations: u32, - memory: Option, - parallelism: Option, - protected_key: String, + LoginCredentials { + access_token, + refresh_token, + kdf, + iterations, + memory, + parallelism, + protected_key, + }: LoginCredentials, password: rbw::locked::Password, db: &mut rbw::db::Db, email: &str, From 34be9b7aac5dd5f8e6f9ba12aab0fcd9e80e9a0f Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 23 May 2026 00:57:27 +0200 Subject: [PATCH 119/273] improve sync() readability and add some TODOs --- src/actions.rs | 1 + src/bin/rbw-agent/actions.rs | 21 +++++++++++---------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index 3d2c00bd..02f3b2b6 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -114,6 +114,7 @@ pub fn unlock( Ok((key, org_keys)) } +// TODO: This return type could be a struct, like SyncCredentials? pub async fn sync( access_token: &str, refresh_token: &str, diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 1b9d354f..36828356 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -414,21 +414,22 @@ pub async fn sync( ) -> anyhow::Result<()> { let mut db = load_db().await?; - let access_token = if let Some(access_token) = &db.access_token { - access_token.clone() - } else { - return Err(anyhow::anyhow!("failed to find access token in db")); - }; - let refresh_token = if let Some(refresh_token) = &db.refresh_token { - refresh_token.clone() - } else { - return Err(anyhow::anyhow!("failed to find refresh token in db")); - }; + let access_token = &db + .access_token + .as_deref() + .ok_or(anyhow::anyhow!("failed to find access token in db"))?; + + let refresh_token = &db + .access_token + .as_deref() + .ok_or(anyhow::anyhow!("failed to find refresh token in db"))?; + let (access_token, (protected_key, protected_private_key, protected_org_keys, entries)) = rbw::actions::sync(&access_token, &refresh_token) .await .context("failed to sync database from server")?; state.lock().await.set_master_password_reprompt(&entries); + // TODO: This is update_token() behavior. think about integrating it into db if let Some(access_token) = access_token { db.access_token = Some(access_token); } From 8ed5e7b657610bcb6e9022e9c9102e2a7cb46257 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 23 May 2026 01:01:14 +0200 Subject: [PATCH 120/273] improve sync() readability --- src/bin/rbw-agent/actions.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 36828356..fa298170 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -414,15 +414,13 @@ pub async fn sync( ) -> anyhow::Result<()> { let mut db = load_db().await?; - let access_token = &db - .access_token - .as_deref() - .ok_or(anyhow::anyhow!("failed to find access token in db"))?; - - let refresh_token = &db - .access_token - .as_deref() - .ok_or(anyhow::anyhow!("failed to find refresh token in db"))?; + let Some(access_token) = &db.access_token else { + anyhow::bail!("failed to find access token in db"); + }; + + let Some(refresh_token) = &db.refresh_token else { + anyhow::bail!("failed to find refresh token in db"); + }; let (access_token, (protected_key, protected_private_key, protected_org_keys, entries)) = rbw::actions::sync(&access_token, &refresh_token) From c1a2c214fc61ea34fe2d8e7ad758271259d77651 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 23 May 2026 01:11:14 +0200 Subject: [PATCH 121/273] reduce find_ssh_private_key() indentation --- src/bin/rbw-agent/actions.rs | 76 +++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 35 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index fa298170..f3736220 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -771,46 +771,52 @@ pub async fn find_ssh_private_key( let db = load_db().await?; for entry in db.entries { - if let rbw::db::EntryData::SshKey { + let rbw::db::EntryData::SshKey { private_key, public_key, .. } = &entry.data - { - let Some(public_key_enc) = public_key else { - continue; - }; - let public_key_plaintext = decrypt_cipher( - state.clone(), - &environment, - public_key_enc, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - .await?; - let public_key_bytes = - ssh_agent_lib::ssh_key::PublicKey::from_openssh(&public_key_plaintext) - .map_err(anyhow::Error::new)? - .to_bytes(); - - if public_key_bytes == request_bytes { - let private_key_enc = private_key - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Matching entry has no private key"))?; - - let private_key_plaintext = decrypt_cipher( - state.clone(), - &environment, - private_key_enc, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - .await?; - - return ssh_agent_lib::ssh_key::PrivateKey::from_openssh(private_key_plaintext) - .map_err(anyhow::Error::new); - } + else { + continue; + }; + + let Some(public_key_enc) = public_key else { + continue; + }; + + let public_key_plaintext = decrypt_cipher( + state.clone(), + &environment, + public_key_enc, + entry.key.as_deref(), + entry.org_id.as_deref(), + ) + .await?; + + let public_key_bytes = + ssh_agent_lib::ssh_key::PublicKey::from_openssh(&public_key_plaintext) + .map_err(anyhow::Error::new)? + .to_bytes(); + + if public_key_bytes != request_bytes { + continue; } + + let private_key_enc = private_key + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Matching entry has no private key"))?; + + let private_key_plaintext = decrypt_cipher( + state.clone(), + &environment, + private_key_enc, + entry.key.as_deref(), + entry.org_id.as_deref(), + ) + .await?; + + return ssh_agent_lib::ssh_key::PrivateKey::from_openssh(private_key_plaintext) + .map_err(anyhow::Error::new); } Err(anyhow::anyhow!("No matching private key found")) From 061b7005d1d8cda2764ed271a3fdcea053371202 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 23 May 2026 14:21:35 +0200 Subject: [PATCH 122/273] simplify config_email --- src/bin/rbw-agent/actions.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index f3736220..ab329988 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -652,10 +652,9 @@ async fn respond_encrypt(sock: &mut crate::sock::Sock, cipherstring: String) -> async fn config_email() -> anyhow::Result { let config = rbw::config::Config::load_async().await?; - config.email.map_or_else( - || Err(anyhow::anyhow!("failed to find email address in config")), - Ok, - ) + config + .email + .ok_or(anyhow::anyhow!("failed to find email address in config")) } async fn load_db() -> anyhow::Result { From baaf9477dd7cefbc668eb4961fd6b2e56804be6a Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 23 May 2026 14:36:26 +0200 Subject: [PATCH 123/273] extract decrypt entry key to specific fn --- src/bin/rbw-agent/actions.rs | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index ab329988..34aebda6 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -448,6 +448,22 @@ pub async fn sync( Ok(()) } +fn decrypt_entry_key( + entry_key: Option<&str>, + keys: &rbw::locked::Keys, +) -> anyhow::Result> { + entry_key + .map(|ek| { + let cs = rbw::cipherstring::CipherString::new(ek) + .context("failed to parse individual item encryption key")?; + Ok(rbw::locked::Keys::new( + cs.decrypt_locked_symmetric(keys) + .context("failed to decrypt individual item encryption key")?, + )) + }) + .transpose() +} + async fn decrypt_cipher( state: Arc>, environment: &rbw::protocol::Environment, @@ -465,17 +481,8 @@ async fn decrypt_cipher( "failed to find decryption keys in in-memory state" )); }; - let entry_key = if let Some(entry_key) = entry_key { - let key_cipherstring = rbw::cipherstring::CipherString::new(entry_key) - .context("failed to parse individual item encryption key")?; - Some(rbw::locked::Keys::new( - key_cipherstring - .decrypt_locked_symmetric(keys) - .context("failed to decrypt individual item encryption key")?, - )) - } else { - None - }; + + let entry_key = decrypt_entry_key(entry_key, &keys)?; let mut sha256 = sha2::Sha256::new(); sha256.update(cipherstring); From 3600dae71b3289feb8b61bf246db995bb4ade83d Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 23 May 2026 14:45:09 +0200 Subject: [PATCH 124/273] extract password reprompt behavior from decrypt_cipher --- src/bin/rbw-agent/actions.rs | 50 +++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 34aebda6..bbfe791a 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -464,29 +464,15 @@ fn decrypt_entry_key( .transpose() } -async fn decrypt_cipher( - state: Arc>, +async fn maybe_reprompt_password( + state: &crate::state::State, environment: &rbw::protocol::Environment, cipherstring: &str, - entry_key: Option<&str>, - org_id: Option<&str>, -) -> anyhow::Result { - let mut state = state.lock().await; - if !state.master_password_reprompt_initialized() { - let db = load_db().await?; - state.set_master_password_reprompt(&db.entries); - } - let Some(keys) = state.key(org_id) else { - return Err(anyhow::anyhow!( - "failed to find decryption keys in in-memory state" - )); - }; - - let entry_key = decrypt_entry_key(entry_key, &keys)?; - +) -> anyhow::Result<()> { let mut sha256 = sha2::Sha256::new(); sha256.update(cipherstring); let master_password_reprompt: [u8; 32] = sha256.finalize().into(); + if state .master_password_reprompt .contains(&master_password_reprompt) @@ -549,8 +535,36 @@ async fn decrypt_cipher( } } + Ok(()) +} + +async fn decrypt_cipher( + state: Arc>, + environment: &rbw::protocol::Environment, + cipherstring: &str, + entry_key: Option<&str>, + org_id: Option<&str>, +) -> anyhow::Result { + let mut state = state.lock().await; + + if !state.master_password_reprompt_initialized() { + let db = load_db().await?; + state.set_master_password_reprompt(&db.entries); + } + + let Some(keys) = state.key(org_id) else { + return Err(anyhow::anyhow!( + "failed to find decryption keys in in-memory state" + )); + }; + + let entry_key = decrypt_entry_key(entry_key, &keys)?; + + maybe_reprompt_password(&state, environment, cipherstring).await?; + let cipherstring = rbw::cipherstring::CipherString::new(cipherstring) .context("failed to parse encrypted secret")?; + let plaintext = String::from_utf8( cipherstring .decrypt_symmetric(keys, entry_key.as_ref()) From 7709ff567700eab3698ca69a9dcc4144e684e056 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 23 May 2026 15:06:00 +0200 Subject: [PATCH 125/273] remove superfluous map_err and add some spacing --- src/bin/rbw-agent/actions.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index bbfe791a..95fe573d 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -748,6 +748,7 @@ pub async fn get_ssh_public_keys( state.set_timeout(); state.last_environment().clone() }; + unlock_state(state.clone(), &environment).await?; let db = load_db().await?; @@ -784,6 +785,7 @@ pub async fn find_ssh_private_key( state.set_timeout(); state.last_environment().clone() }; + unlock_state(state.clone(), &environment).await?; let request_bytes = request_public_key.to_bytes(); @@ -814,9 +816,7 @@ pub async fn find_ssh_private_key( .await?; let public_key_bytes = - ssh_agent_lib::ssh_key::PublicKey::from_openssh(&public_key_plaintext) - .map_err(anyhow::Error::new)? - .to_bytes(); + ssh_agent_lib::ssh_key::PublicKey::from_openssh(&public_key_plaintext)?.to_bytes(); if public_key_bytes != request_bytes { continue; From 72f07d91ab466a8f380db8ef5b5f9c5bd04cca1e Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 24 May 2026 10:50:59 +0200 Subject: [PATCH 126/273] split getpint fn even further --- src/bin/rbw-agent/actions.rs | 46 +++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 95fe573d..f043188c 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -5,16 +5,33 @@ use rbw::actions::LoginCredentials; use sha2::Digest as _; use tokio::sync::Mutex; +async fn getpin( + desc: &str, + prompt: &str, + err: &Option, + environment: &rbw::protocol::Environment, + grab: bool, +) -> anyhow::Result { + Ok(rbw::pinentry::getpin( + &config_pinentry().await?, + prompt, + desc, + err.as_deref(), + environment, + grab, + ) + .await?) +} + async fn get_client_id( host: &str, err: &Option, environment: &rbw::protocol::Environment, ) -> anyhow::Result { - rbw::pinentry::getpin( - &config_pinentry().await?, + getpin( "API key client__id", &format!("Log in to {host}"), - err.as_deref(), + err, environment, false, ) @@ -27,11 +44,10 @@ async fn get_client_secret( err: &Option, environment: &rbw::protocol::Environment, ) -> anyhow::Result { - rbw::pinentry::getpin( - &config_pinentry().await?, + getpin( "API key client__secret", &format!("Log in to {host}"), - err.as_deref(), + err, environment, false, ) @@ -86,16 +102,9 @@ async fn get_password( err: &Option, environment: &rbw::protocol::Environment, ) -> anyhow::Result { - rbw::pinentry::getpin( - &config_pinentry().await?, - "Master Password", - desc, - err.as_deref(), - environment, - true, - ) - .await - .context("failed to read password from pinentry") + getpin("Master Password", desc, err, environment, true) + .await + .context("failed to read password from pinentry") } async fn two_factor_required( @@ -197,11 +206,10 @@ async fn get_code( err: &Option, environment: &rbw::protocol::Environment, ) -> anyhow::Result { - rbw::pinentry::getpin( - &config_pinentry().await?, + getpin( provider.header(), provider.message(), - err.as_deref(), + err, environment, provider.grab(), ) From fa88965ff038795dabc80efe21f00df399d149a8 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 24 May 2026 10:51:21 +0200 Subject: [PATCH 127/273] create fn apply_login_credentials for shared behavior --- src/bin/rbw-agent/actions.rs | 31 ++++++++++--------------------- src/db.rs | 12 +++++++++++- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index f043188c..b8ea3cba 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -248,29 +248,17 @@ async fn two_factor( async fn login_success( state: Arc>, - LoginCredentials { - access_token, - refresh_token, - kdf, - iterations, - memory, - parallelism, - protected_key, - }: LoginCredentials, + creds: LoginCredentials, password: rbw::locked::Password, db: &mut rbw::db::Db, email: &str, ) -> anyhow::Result<()> { - db.access_token = Some(access_token.clone()); - db.refresh_token = Some(refresh_token.clone()); - db.kdf = Some(kdf); - db.iterations = Some(iterations); - db.memory = memory; - db.parallelism = parallelism; - db.protected_key = Some(protected_key.clone()); + db.apply_login_credentials(&creds); + save_db(&db).await?; sync(None, state.clone()).await?; + let db = load_db().await?; let Some(protected_private_key) = db.protected_private_key else { @@ -279,14 +267,15 @@ async fn login_success( )); }; + // TODO: Maybe use logincredentials for unlock too? let res = rbw::actions::unlock( &email, &password, - kdf, - iterations, - memory, - parallelism, - &protected_key, + creds.kdf, + creds.iterations, + creds.memory, + creds.parallelism, + &creds.protected_key, &protected_private_key, &db.protected_org_keys, ); diff --git a/src/db.rs b/src/db.rs index 6c5b6712..93f21205 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,4 +1,4 @@ -use crate::prelude::*; +use crate::{actions::LoginCredentials, prelude::*}; use std::{ collections::HashMap, @@ -921,6 +921,16 @@ impl Db { Ok(slf) } + pub fn apply_login_credentials(&mut self, creds: &LoginCredentials) { + self.access_token = Some(creds.access_token.clone()); + self.refresh_token = Some(creds.refresh_token.clone()); + self.kdf = Some(creds.kdf); + self.iterations = Some(creds.iterations); + self.memory = creds.memory; + self.parallelism = creds.parallelism; + self.protected_key = Some(creds.protected_key.clone()); + } + // XXX need to make this atomic pub fn save(&self, server: &str, email: &str) -> Result<()> { let file = crate::dirs::db_file(server, email); From 799e81dc0636fdc8d81a1343a87ce1124cefa683 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 24 May 2026 11:03:28 +0200 Subject: [PATCH 128/273] LoginCredentials -> SessionParameters --- src/actions.rs | 6 +++--- src/bin/rbw-agent/actions.rs | 8 ++++---- src/db.rs | 18 +++++++++--------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index 02f3b2b6..d9f22d15 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -13,7 +13,7 @@ pub async fn register(email: &str, apikey: crate::locked::ApiKey) -> Result<()> Ok(()) } -pub struct LoginCredentials { +pub struct SessionParameters { pub access_token: String, pub refresh_token: String, pub kdf: crate::api::KdfType, @@ -28,7 +28,7 @@ pub async fn login( password: crate::locked::Password, two_factor_token: Option<&str>, two_factor_provider: Option, -) -> Result { +) -> Result { let (client, config) = api_client_async().await?; let (kdf, iterations, memory, parallelism) = client.prelogin(email).await?; @@ -45,7 +45,7 @@ pub async fn login( ) .await?; - Ok(LoginCredentials { + Ok(SessionParameters { access_token, refresh_token, kdf, diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index b8ea3cba..a1ce4f56 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::Context as _; -use rbw::actions::LoginCredentials; +use rbw::actions::SessionParameters; use sha2::Digest as _; use tokio::sync::Mutex; @@ -222,7 +222,7 @@ async fn two_factor( email: &str, password: rbw::locked::Password, provider: rbw::api::TwoFactorProviderType, -) -> anyhow::Result { +) -> anyhow::Result { let mut err_msg = None; for i in 1_u8..=3 { let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); @@ -248,12 +248,12 @@ async fn two_factor( async fn login_success( state: Arc>, - creds: LoginCredentials, + creds: SessionParameters, password: rbw::locked::Password, db: &mut rbw::db::Db, email: &str, ) -> anyhow::Result<()> { - db.apply_login_credentials(&creds); + db.apply_session_parameters(&creds); save_db(&db).await?; diff --git a/src/db.rs b/src/db.rs index 93f21205..16c6d438 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,4 +1,4 @@ -use crate::{actions::LoginCredentials, prelude::*}; +use crate::{actions::SessionParameters, prelude::*}; use std::{ collections::HashMap, @@ -921,14 +921,14 @@ impl Db { Ok(slf) } - pub fn apply_login_credentials(&mut self, creds: &LoginCredentials) { - self.access_token = Some(creds.access_token.clone()); - self.refresh_token = Some(creds.refresh_token.clone()); - self.kdf = Some(creds.kdf); - self.iterations = Some(creds.iterations); - self.memory = creds.memory; - self.parallelism = creds.parallelism; - self.protected_key = Some(creds.protected_key.clone()); + pub fn apply_session_parameters(&mut self, params: &SessionParameters) { + self.access_token = Some(params.access_token.clone()); + self.refresh_token = Some(params.refresh_token.clone()); + self.kdf = Some(params.kdf); + self.iterations = Some(params.iterations); + self.memory = params.memory; + self.parallelism = params.parallelism; + self.protected_key = Some(params.protected_key.clone()); } // XXX need to make this atomic From 231541823e989fa38ae15c545e95ea8647393cf1 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 24 May 2026 11:36:22 +0200 Subject: [PATCH 129/273] group crypto parameters into a separate struct --- src/actions.rs | 29 +++++++++---------- src/api.rs | 16 +++++------ src/bin/rbw-agent/actions.rs | 39 ++++---------------------- src/db.rs | 54 ++++++++++++++++++++++++++++++++---- src/identity.rs | 17 +++++------- 5 files changed, 83 insertions(+), 72 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index d9f22d15..594dd34f 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -13,13 +13,18 @@ pub async fn register(email: &str, apikey: crate::locked::ApiKey) -> Result<()> Ok(()) } -pub struct SessionParameters { - pub access_token: String, - pub refresh_token: String, +#[derive(Clone)] +pub struct CryptoParameters { pub kdf: crate::api::KdfType, pub iterations: u32, pub memory: Option, pub parallelism: Option, +} + +pub struct SessionParameters { + pub access_token: String, + pub refresh_token: String, + pub crypto_params: CryptoParameters, pub protected_key: String, } @@ -30,10 +35,9 @@ pub async fn login( two_factor_provider: Option, ) -> Result { let (client, config) = api_client_async().await?; - let (kdf, iterations, memory, parallelism) = client.prelogin(email).await?; + let crypto_params = client.prelogin(email).await?; - let identity = - crate::identity::Identity::new(email, &password, kdf, iterations, memory, parallelism)?; + let identity = crate::identity::Identity::new(email, &password, &crypto_params)?; let (access_token, refresh_token, protected_key) = client .login( email, @@ -48,10 +52,7 @@ pub async fn login( Ok(SessionParameters { access_token, refresh_token, - kdf, - iterations, - memory, - parallelism, + crypto_params, protected_key, }) } @@ -70,10 +71,7 @@ pub async fn send_two_factor_email(email: &str, sso_email_2fa_session_token: &st pub fn unlock( email: &str, password: &crate::locked::Password, - kdf: crate::api::KdfType, - iterations: u32, - memory: Option, - parallelism: Option, + crypto_params: &CryptoParameters, protected_key: &str, protected_private_key: &str, protected_org_keys: &std::collections::HashMap, @@ -81,8 +79,7 @@ pub fn unlock( crate::locked::Keys, std::collections::HashMap, )> { - let identity = - crate::identity::Identity::new(email, password, kdf, iterations, memory, parallelism)?; + let identity = crate::identity::Identity::new(email, password, crypto_params)?; let protected_key = crate::cipherstring::CipherString::new(protected_key)?; let key = match protected_key.decrypt_locked_symmetric(&identity.keys) { diff --git a/src/api.rs b/src/api.rs index 935e3040..c1b6c193 100644 --- a/src/api.rs +++ b/src/api.rs @@ -10,7 +10,7 @@ use std::{ sync::Arc, }; -use crate::{db::Encrypted, prelude::*}; +use crate::{actions::CryptoParameters, db::Encrypted, prelude::*}; use rand::distr::SampleString as _; use serde::{Deserialize, Serialize}; @@ -1053,19 +1053,19 @@ impl Client { } } - pub async fn prelogin(&self, email: &str) -> Result<(KdfType, u32, Option, Option)> { + pub async fn prelogin(&self, email: &str) -> Result { let res: PreloginRes = ClientRequest::Prelogin(email) .req(self) .await? .json_with_path() .await?; - Ok(( - res.kdf, - res.kdf_iterations, - res.kdf_memory, - res.kdf_parallelism, - )) + Ok(CryptoParameters { + kdf: res.kdf, + iterations: res.kdf_iterations, + memory: res.kdf_memory, + parallelism: res.kdf_parallelism, + }) } pub async fn register( diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index a1ce4f56..25987810 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -267,14 +267,10 @@ async fn login_success( )); }; - // TODO: Maybe use logincredentials for unlock too? let res = rbw::actions::unlock( &email, &password, - creds.kdf, - creds.iterations, - creds.memory, - creds.parallelism, + &creds.crypto_params, &creds.protected_key, &protected_private_key, &db.protected_org_keys, @@ -299,16 +295,7 @@ async fn unlock_state( if state.lock().await.needs_unlock() { let db = load_db().await?; - let Some(kdf) = db.kdf else { - return Err(anyhow::anyhow!("failed to find kdf type in db")); - }; - - let Some(iterations) = db.iterations else { - return Err(anyhow::anyhow!("failed to find number of iterations in db")); - }; - - let memory = db.memory; - let parallelism = db.parallelism; + let crypto_params = db.get_crypto_parameters()?; let Some(protected_key) = db.protected_key else { return Err(anyhow::anyhow!("failed to find protected key in db")); @@ -335,10 +322,7 @@ async fn unlock_state( match rbw::actions::unlock( &email, &password, - kdf, - iterations, - memory, - parallelism, + &crypto_params, &protected_key, &protected_private_key, &db.protected_org_keys, @@ -476,20 +460,12 @@ async fn maybe_reprompt_password( { let db = load_db().await?; - let Some(kdf) = db.kdf else { - return Err(anyhow::anyhow!("failed to find kdf type in db")); - }; - - let Some(iterations) = db.iterations else { - return Err(anyhow::anyhow!("failed to find number of iterations in db")); - }; - - let memory = db.memory; - let parallelism = db.parallelism; + let crypto_params = db.get_crypto_parameters()?; let Some(protected_key) = db.protected_key else { return Err(anyhow::anyhow!("failed to find protected key in db")); }; + let Some(protected_private_key) = db.protected_private_key else { return Err(anyhow::anyhow!( "failed to find protected private key in db" @@ -513,10 +489,7 @@ async fn maybe_reprompt_password( match rbw::actions::unlock( &email, &password, - kdf, - iterations, - memory, - parallelism, + &crypto_params, &protected_key, &protected_private_key, &db.protected_org_keys, diff --git a/src/db.rs b/src/db.rs index 16c6d438..42d1a7e9 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,4 +1,7 @@ -use crate::{actions::SessionParameters, prelude::*}; +use crate::{ + actions::{CryptoParameters, SessionParameters}, + prelude::*, +}; use std::{ collections::HashMap, @@ -864,6 +867,7 @@ impl<'de> serde::Deserialize<'de> for Uri { #[derive(serde::Serialize, serde::Deserialize, Default, Debug)] pub struct Db { + // TODO: Flatten SessionParameters into these fields pub access_token: Option, pub refresh_token: Option, @@ -924,13 +928,53 @@ impl Db { pub fn apply_session_parameters(&mut self, params: &SessionParameters) { self.access_token = Some(params.access_token.clone()); self.refresh_token = Some(params.refresh_token.clone()); - self.kdf = Some(params.kdf); - self.iterations = Some(params.iterations); - self.memory = params.memory; - self.parallelism = params.parallelism; + self.kdf = Some(params.crypto_params.kdf); + self.iterations = Some(params.crypto_params.iterations); + self.memory = params.crypto_params.memory; + self.parallelism = params.crypto_params.parallelism; self.protected_key = Some(params.protected_key.clone()); } + // TODO: Return references if possible + pub fn get_crypto_parameters(&self) -> anyhow::Result { + let Some(kdf) = self.kdf else { + return Err(anyhow::anyhow!("failed to find kdf type in db")); + }; + + let Some(iterations) = self.iterations else { + return Err(anyhow::anyhow!("failed to find number of iterations in db")); + }; + + Ok(CryptoParameters { + kdf, + iterations, + memory: self.memory, + parallelism: self.parallelism, + }) + } + + // TODO: Return references if possible + pub fn get_session_parameters(&self) -> anyhow::Result { + let Some(access_token) = self.access_token.clone() else { + return Err(anyhow::anyhow!("failed to find access_token in db")); + }; + + let Some(refresh_token) = self.refresh_token.clone() else { + return Err(anyhow::anyhow!("failed to find refresh_token in db")); + }; + + let Some(protected_key) = self.protected_key.clone() else { + return Err(anyhow::anyhow!("failed to find protected key in db")); + }; + + Ok(SessionParameters { + access_token, + refresh_token, + crypto_params: self.get_crypto_parameters()?, + protected_key, + }) + } + // XXX need to make this atomic pub fn save(&self, server: &str, email: &str) -> Result<()> { let file = crate::dirs::db_file(server, email); diff --git a/src/identity.rs b/src/identity.rs index a26596c2..f931160f 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -1,4 +1,4 @@ -use crate::prelude::*; +use crate::{actions::CryptoParameters, prelude::*}; use sha1::Digest as _; @@ -12,22 +12,19 @@ impl Identity { pub fn new( email: &str, password: &crate::locked::Password, - kdf: crate::api::KdfType, - iterations: u32, - memory: Option, - parallelism: Option, + crypto_params: &CryptoParameters, ) -> Result { let email = email.trim().to_lowercase(); - let iterations = - std::num::NonZeroU32::new(iterations).ok_or(Error::Pbkdf2ZeroIterations)?; + let iterations = std::num::NonZeroU32::new(crypto_params.iterations) + .ok_or(Error::Pbkdf2ZeroIterations)?; let mut keys = crate::locked::Vec::new(); keys.extend(std::iter::repeat_n(0, 64)); let enc_key = &mut keys.data_mut()[0..32]; - match kdf { + match crypto_params.kdf { crate::api::KdfType::Pbkdf2 => { pbkdf2::pbkdf2::>( password.password(), @@ -47,9 +44,9 @@ impl Identity { argon2::Algorithm::Argon2id, argon2::Version::V0x13, argon2::Params::new( - memory.unwrap() * 1024, + crypto_params.memory.unwrap() * 1024, iterations.get(), - parallelism.unwrap(), + crypto_params.parallelism.unwrap(), Some(32), ) .unwrap(), From 82a4a2c8f30bd672026110c6c0082abc122f8e26 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 24 May 2026 12:00:58 +0200 Subject: [PATCH 130/273] group db's crypto parameters --- src/actions.rs | 2 +- src/db.rs | 32 ++++++++------------------------ 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index 594dd34f..0558fdfe 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -13,7 +13,7 @@ pub async fn register(email: &str, apikey: crate::locked::ApiKey) -> Result<()> Ok(()) } -#[derive(Clone)] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct CryptoParameters { pub kdf: crate::api::KdfType, pub iterations: u32, diff --git a/src/db.rs b/src/db.rs index 42d1a7e9..d7f4fc1b 100644 --- a/src/db.rs +++ b/src/db.rs @@ -871,10 +871,9 @@ pub struct Db { pub access_token: Option, pub refresh_token: Option, - pub kdf: Option, - pub iterations: Option, - pub memory: Option, - pub parallelism: Option, + #[serde(flatten)] + pub crypto_params: Option, + pub protected_key: Option, pub protected_private_key: Option, pub protected_org_keys: std::collections::HashMap, @@ -928,29 +927,15 @@ impl Db { pub fn apply_session_parameters(&mut self, params: &SessionParameters) { self.access_token = Some(params.access_token.clone()); self.refresh_token = Some(params.refresh_token.clone()); - self.kdf = Some(params.crypto_params.kdf); - self.iterations = Some(params.crypto_params.iterations); - self.memory = params.crypto_params.memory; - self.parallelism = params.crypto_params.parallelism; + self.crypto_params = Some(params.crypto_params.clone()); self.protected_key = Some(params.protected_key.clone()); } // TODO: Return references if possible pub fn get_crypto_parameters(&self) -> anyhow::Result { - let Some(kdf) = self.kdf else { - return Err(anyhow::anyhow!("failed to find kdf type in db")); - }; - - let Some(iterations) = self.iterations else { - return Err(anyhow::anyhow!("failed to find number of iterations in db")); - }; - - Ok(CryptoParameters { - kdf, - iterations, - memory: self.memory, - parallelism: self.parallelism, - }) + self.crypto_params + .clone() + .ok_or(anyhow::anyhow!("failed to find crypto parameters in db")) } // TODO: Return references if possible @@ -1045,8 +1030,7 @@ impl Db { pub fn needs_login(&self) -> bool { self.access_token.is_none() || self.refresh_token.is_none() - || self.iterations.is_none() - || self.kdf.is_none() + || self.crypto_params.is_none() || self.protected_key.is_none() } } From 1f503370b743ca1401425b14278538db5e8d6461 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 24 May 2026 12:42:32 +0200 Subject: [PATCH 131/273] split get host code into separate fn --- src/bin/rbw-agent/actions.rs | 128 ++++++++++++++++++----------------- 1 file changed, 67 insertions(+), 61 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 25987810..ceffa63b 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -55,40 +55,48 @@ async fn get_client_secret( .context("failed to read client_secret from pinentry") } +async fn get_host() -> anyhow::Result { + let url_str = config_base_url().await?; + let url = reqwest::Url::parse(&url_str).context("failed to parse base url")?; + let Some(host) = url.host_str() else { + return Err(anyhow::anyhow!( + "couldn't find host in rbw base url {url_str}" + )); + }; + + Ok(host.to_string()) +} + pub async fn register( sock: &mut crate::sock::Sock, environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { let db = load_db().await.unwrap_or_else(|_| rbw::db::Db::new()); - if db.needs_login() { - let url_str = config_base_url().await?; - let url = reqwest::Url::parse(&url_str).context("failed to parse base url")?; - let Some(host) = url.host_str() else { - return Err(anyhow::anyhow!( - "couldn't find host in rbw base url {url_str}" - )); - }; + if !db.needs_login() { + return respond_ack(sock).await; + } - let email = config_email().await?; + let host = get_host().await?; - let mut err_msg = None; - for i in 1_u8..=3 { - let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); - let client_id = get_client_id(host, &err, environment).await?; - let client_secret = get_client_secret(host, &err, environment).await?; + let email = config_email().await?; - let apikey = rbw::locked::ApiKey::new(client_id, client_secret); + let mut err_msg = None; + for i in 1_u8..=3 { + let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); + let client_id = get_client_id(&host, &err, environment).await?; + let client_secret = get_client_secret(&host, &err, environment).await?; - match rbw::actions::register(&email, apikey).await { - Ok(()) => { - break; - } - Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { - err_msg = Some(message); - } - Err(e) => return Err(e).context("failed to log in to bitwarden instance"), + let apikey = rbw::locked::ApiKey::new(client_id, client_secret); + + match rbw::actions::register(&email, apikey).await { + Ok(()) => { + break; + } + Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { + err_msg = Some(message); } + Err(e) => return Err(e).context("failed to log in to bitwarden instance"), } } @@ -146,53 +154,49 @@ pub async fn login( ) -> anyhow::Result<()> { let mut db = load_db().await.unwrap_or_else(|_| rbw::db::Db::new()); - if db.needs_login() { - let url_str = config_base_url().await?; - let url = reqwest::Url::parse(&url_str).context("failed to parse base url")?; - let Some(host) = url.host_str() else { - return Err(anyhow::anyhow!( - "couldn't find host in rbw base url {url_str}" - )); - }; + if !db.needs_login() { + return respond_ack(sock).await; + } - let email = config_email().await?; + let host = get_host().await?; - let mut err_msg = None; - for i in 1_u8..=3 { - let err = err_msg - .as_deref() - .map(|msg| format!("{msg} (attempt {i}/3)")); + let email = config_email().await?; + + let mut err_msg = None; + for i in 1_u8..=3 { + let err = err_msg + .as_deref() + .map(|msg| format!("{msg} (attempt {i}/3)")); - let password = get_password(&format!("Log in to {host}"), &err, environment).await?; + let password = get_password(&format!("Log in to {host}"), &err, environment).await?; - match rbw::actions::login(&email, password.clone(), None, None).await { - Ok(creds) => { - login_success(state.clone(), creds, password, &mut db, &email).await?; + match rbw::actions::login(&email, password.clone(), None, None).await { + Ok(creds) => { + login_success(state.clone(), creds, password, &mut db, &email).await?; - break; - } - Err(rbw::error::Error::TwoFactorRequired { + break; + } + Err(rbw::error::Error::TwoFactorRequired { + providers, + sso_email_2fa_session_token, + }) => { + two_factor_required( + &state, + &email, + password, providers, sso_email_2fa_session_token, - }) => { - two_factor_required( - &state, - &email, - password, - providers, - sso_email_2fa_session_token, - environment, - &mut db, - ) - .await?; + environment, + &mut db, + ) + .await?; - break; - } - Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { - err_msg = Some(message); - } - Err(e) => return Err(e).context("failed to log in to bitwarden instance"), + break; } + Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { + err_msg = Some(message); + } + Err(e) => return Err(e).context("failed to log in to bitwarden instance"), } } @@ -300,6 +304,7 @@ async fn unlock_state( let Some(protected_key) = db.protected_key else { return Err(anyhow::anyhow!("failed to find protected key in db")); }; + let Some(protected_private_key) = db.protected_private_key else { return Err(anyhow::anyhow!( "failed to find protected private key in db" @@ -599,6 +604,7 @@ pub async fn clipboard_store( } #[cfg(not(feature = "clipboard"))] + pub async fn clipboard_store( sock: &mut crate::sock::Sock, _state: Arc>, From c6c1776d81231ef63f170e1d5db1fd860eb6449a Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 24 May 2026 13:51:55 +0200 Subject: [PATCH 132/273] add a note regarding output compatibility --- src/db.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/db.rs b/src/db.rs index d7f4fc1b..c98c9953 100644 --- a/src/db.rs +++ b/src/db.rs @@ -932,6 +932,7 @@ impl Db { } // TODO: Return references if possible + // NOTE: Previous error string were different. Not 100% compatible output. pub fn get_crypto_parameters(&self) -> anyhow::Result { self.crypto_params .clone() From 7cde5ddbbd5d06debea11dc9025ad48d72fa5898 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 25 May 2026 11:22:16 +0200 Subject: [PATCH 133/273] add update_access_token method in db impl --- src/bin/rbw-agent/actions.rs | 8 ++++---- src/bin/rbw/commands.rs | 4 ++-- src/db.rs | 9 +++++++++ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index ceffa63b..bb58fdae 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -413,14 +413,14 @@ pub async fn sync( .await .context("failed to sync database from server")?; state.lock().await.set_master_password_reprompt(&entries); - // TODO: This is update_token() behavior. think about integrating it into db - if let Some(access_token) = access_token { - db.access_token = Some(access_token); - } + + db.update_access_token(access_token); + db.protected_key = Some(protected_key); db.protected_private_key = Some(protected_private_key); db.protected_org_keys = protected_org_keys; db.entries = entries; + save_db(&db).await?; if let Err(e) = subscribe_to_notifications(state.clone()).await { diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 17f6b566..9d187818 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -848,10 +848,10 @@ fn parse_editor(contents: &str) -> (Option, Option) { } fn update_token(db: &mut rbw::db::Db, new_token: Option) -> anyhow::Result<()> { - if let Some(token) = new_token { - db.access_token = Some(token); + if db.update_access_token(new_token) { save_db(db)?; } + Ok(()) } diff --git a/src/db.rs b/src/db.rs index c98c9953..ae14c69e 100644 --- a/src/db.rs +++ b/src/db.rs @@ -924,6 +924,15 @@ impl Db { Ok(slf) } + pub fn update_access_token(&mut self, access_token: Option) -> bool { + if let Some(access_token) = access_token { + self.access_token = Some(access_token); + true + } else { + false + } + } + pub fn apply_session_parameters(&mut self, params: &SessionParameters) { self.access_token = Some(params.access_token.clone()); self.refresh_token = Some(params.refresh_token.clone()); From 1a364376cc87932d23ddf94451dea5fb3c198f31 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 25 May 2026 11:34:21 +0200 Subject: [PATCH 134/273] remove superfluous pub and clone --- src/bin/rbw-agent/agent.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index 5a71fd66..ead784d4 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -21,7 +21,7 @@ impl Agent { } pub async fn run(self, listener: tokio::net::UnixListener) -> anyhow::Result<()> { - pub enum Event { + enum Event { Request(std::io::Result), Timeout(()), Sync(()), @@ -135,9 +135,6 @@ async fn handle_request( entry_key, org_id, } => { - let cipherstring = cipherstring.clone(); - let entry_key = entry_key.clone(); - let org_id = org_id.clone(); crate::actions::decrypt( sock, state.clone(), From 0953db8f32fe939c069c7a49ca8c9ffacc727f95 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 25 May 2026 11:58:34 +0200 Subject: [PATCH 135/273] "use" common prefixes and "expect" instead of simply unwrapping --- src/bin/rbw-agent/agent.rs | 27 ++++++++++++++++----------- src/bin/rbw-agent/notifications.rs | 26 +++++++++++++++----------- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index ead784d4..f345b81c 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -1,17 +1,23 @@ +use std::sync::Arc; + use anyhow::Context as _; use futures_util::StreamExt as _; +use tokio::{ + net::{UnixListener, UnixStream}, + sync::{mpsc::UnboundedReceiver, Mutex}, +}; pub struct Agent { - timer_r: tokio::sync::mpsc::UnboundedReceiver<()>, - sync_timer_r: tokio::sync::mpsc::UnboundedReceiver<()>, - state: std::sync::Arc>, + timer_r: UnboundedReceiver<()>, + sync_timer_r: UnboundedReceiver<()>, + state: Arc>, } impl Agent { pub fn new( - timer_r: tokio::sync::mpsc::UnboundedReceiver<()>, - sync_timer_r: tokio::sync::mpsc::UnboundedReceiver<()>, - state: std::sync::Arc>, + timer_r: UnboundedReceiver<()>, + sync_timer_r: UnboundedReceiver<()>, + state: Arc>, ) -> Self { Self { timer_r, @@ -20,9 +26,9 @@ impl Agent { } } - pub async fn run(self, listener: tokio::net::UnixListener) -> anyhow::Result<()> { + pub async fn run(self, listener: UnixListener) -> anyhow::Result<()> { enum Event { - Request(std::io::Result), + Request(std::io::Result), Timeout(()), Sync(()), } @@ -63,12 +69,11 @@ impl Agent { tokio::spawn(async move { let res = handle_request(&mut sock, state.clone()).await; if let Err(e) = res { - // unwrap is the only option here sock.send(&rbw::protocol::Response::Error { error: format!("{e:#}"), }) .await - .unwrap(); + .expect("failed to send error response to client"); } }); } @@ -94,7 +99,7 @@ impl Agent { async fn handle_request( sock: &mut crate::sock::Sock, - state: std::sync::Arc>, + state: Arc>, ) -> anyhow::Result<()> { let req = sock.recv().await?; let req = match req { diff --git a/src/bin/rbw-agent/notifications.rs b/src/bin/rbw-agent/notifications.rs index c54f4a83..cc171cdb 100644 --- a/src/bin/rbw-agent/notifications.rs +++ b/src/bin/rbw-agent/notifications.rs @@ -1,4 +1,11 @@ +use std::sync::Arc; + use futures_util::{SinkExt as _, StreamExt as _}; +use tokio::{ + net::TcpStream, sync::{ + RwLock, mpsc::{UnboundedReceiver, UnboundedSender} + }, task::JoinHandle +}; #[derive(Clone, Copy, Debug)] pub enum Message { @@ -10,14 +17,13 @@ pub struct Handler { write: Option< futures::stream::SplitSink< tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, + tokio_tungstenite::MaybeTlsStream, >, tokio_tungstenite::tungstenite::Message, >, >, - read_handle: Option>, - sending_channels: - std::sync::Arc>>>, + read_handle: Option>, + sending_channels: Arc>>>, } impl Handler { @@ -25,7 +31,7 @@ impl Handler { Self { write: None, read_handle: None, - sending_channels: std::sync::Arc::new(tokio::sync::RwLock::new(Vec::new())), + sending_channels: Arc::new(RwLock::new(Vec::new())), } } @@ -62,7 +68,7 @@ impl Handler { Ok(()) } - pub async fn get_channel(&self) -> tokio::sync::mpsc::UnboundedReceiver { + pub async fn get_channel(&self) -> UnboundedReceiver { let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); self.sending_channels.write().await.push(tx); rx @@ -71,18 +77,16 @@ impl Handler { async fn subscribe_to_notifications( url: String, - sending_channels: std::sync::Arc< - tokio::sync::RwLock>>, - >, + sending_channels: Arc>>>, ) -> Result< ( futures_util::stream::SplitSink< tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, + tokio_tungstenite::MaybeTlsStream, >, tokio_tungstenite::tungstenite::Message, >, - tokio::task::JoinHandle<()>, + JoinHandle<()>, ), Box, > { From e2eb213ec35e1a58b2b76d381ca83051cf65b4cd Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 25 May 2026 12:49:30 +0200 Subject: [PATCH 136/273] rename notifications::Handler to notifications::NotificationsHandler --- src/bin/rbw-agent/main.rs | 2 +- src/bin/rbw-agent/notifications.rs | 21 ++++++++++----------- src/bin/rbw-agent/state.rs | 2 +- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index bada8561..b1dd418c 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -25,7 +25,7 @@ async fn tokio_main(startup_ack: Option) -> anyhow::R if sync_timeout_duration > std::time::Duration::ZERO { sync_timeout.set(sync_timeout_duration); } - let notifications_handler = crate::notifications::Handler::new(); + let notifications_handler = crate::notifications::NotificationsHandler::new(); let state = std::sync::Arc::new(tokio::sync::Mutex::new(crate::state::State { priv_key: None, org_keys: None, diff --git a/src/bin/rbw-agent/notifications.rs b/src/bin/rbw-agent/notifications.rs index cc171cdb..f0ec0fef 100644 --- a/src/bin/rbw-agent/notifications.rs +++ b/src/bin/rbw-agent/notifications.rs @@ -2,9 +2,12 @@ use std::sync::Arc; use futures_util::{SinkExt as _, StreamExt as _}; use tokio::{ - net::TcpStream, sync::{ - RwLock, mpsc::{UnboundedReceiver, UnboundedSender} - }, task::JoinHandle + net::TcpStream, + sync::{ + mpsc::{UnboundedReceiver, UnboundedSender}, + RwLock, + }, + task::JoinHandle, }; #[derive(Clone, Copy, Debug)] @@ -13,12 +16,10 @@ pub enum Message { Logout, } -pub struct Handler { +pub struct NotificationsHandler { write: Option< futures::stream::SplitSink< - tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, + tokio_tungstenite::WebSocketStream>, tokio_tungstenite::tungstenite::Message, >, >, @@ -26,7 +27,7 @@ pub struct Handler { sending_channels: Arc>>>, } -impl Handler { +impl NotificationsHandler { pub fn new() -> Self { Self { write: None, @@ -81,9 +82,7 @@ async fn subscribe_to_notifications( ) -> Result< ( futures_util::stream::SplitSink< - tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, + tokio_tungstenite::WebSocketStream>, tokio_tungstenite::tungstenite::Message, >, JoinHandle<()>, diff --git a/src/bin/rbw-agent/state.rs b/src/bin/rbw-agent/state.rs index 3e9d5400..036511df 100644 --- a/src/bin/rbw-agent/state.rs +++ b/src/bin/rbw-agent/state.rs @@ -7,7 +7,7 @@ pub struct State { pub timeout_duration: std::time::Duration, pub sync_timeout: crate::timeout::Timeout, pub sync_timeout_duration: std::time::Duration, - pub notifications_handler: crate::notifications::Handler, + pub notifications_handler: crate::notifications::NotificationsHandler, pub master_password_reprompt: std::collections::HashSet<[u8; 32]>, pub master_password_reprompt_initialized: bool, From 25196c4b9f348a761499d323112d5be586a3eec5 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 25 May 2026 14:19:57 +0200 Subject: [PATCH 137/273] remove tokio_stream::wrappers prefix --- src/bin/rbw-agent/agent.rs | 9 +++++---- src/bin/rbw-agent/timeout.rs | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index f345b81c..03f4316a 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -6,6 +6,7 @@ use tokio::{ net::{UnixListener, UnixStream}, sync::{mpsc::UnboundedReceiver, Mutex}, }; +use tokio_stream::wrappers::{UnboundedReceiverStream, UnixListenerStream}; pub struct Agent { timer_r: UnboundedReceiver<()>, @@ -40,7 +41,7 @@ impl Agent { .notifications_handler .get_channel() .await; - let notifications = tokio_stream::wrappers::UnboundedReceiverStream::new(notifications) + let notifications = UnboundedReceiverStream::new(notifications) .map(|message| match message { crate::notifications::Message::Logout => Event::Timeout(()), crate::notifications::Message::Sync => Event::Sync(()), @@ -48,13 +49,13 @@ impl Agent { .boxed(); let mut stream = futures_util::stream::select_all([ - tokio_stream::wrappers::UnixListenerStream::new(listener) + UnixListenerStream::new(listener) .map(Event::Request) .boxed(), - tokio_stream::wrappers::UnboundedReceiverStream::new(self.timer_r) + UnboundedReceiverStream::new(self.timer_r) .map(Event::Timeout) .boxed(), - tokio_stream::wrappers::UnboundedReceiverStream::new(self.sync_timer_r) + UnboundedReceiverStream::new(self.sync_timer_r) .map(Event::Sync) .boxed(), notifications, diff --git a/src/bin/rbw-agent/timeout.rs b/src/bin/rbw-agent/timeout.rs index b9e5f764..5bccd11c 100644 --- a/src/bin/rbw-agent/timeout.rs +++ b/src/bin/rbw-agent/timeout.rs @@ -1,4 +1,5 @@ use futures_util::StreamExt as _; +use tokio_stream::wrappers::UnboundedReceiverStream; #[derive(Debug, Hash, Eq, PartialEq, Copy, Clone)] enum Streams { @@ -28,7 +29,7 @@ impl Timeout { let mut stream = tokio_stream::StreamMap::new(); stream.insert( Streams::Requests, - tokio_stream::wrappers::UnboundedReceiverStream::new(req_r) + UnboundedReceiverStream::new(req_r) .map(Event::Request) .boxed(), ); From 7e8fc314dbe17256beceaabc2ed45abd506fe343 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 25 May 2026 14:34:45 +0200 Subject: [PATCH 138/273] remove tokio::sync::mpsc prefix --- src/bin/rbw-agent/notifications.rs | 4 ++-- src/bin/rbw-agent/timeout.rs | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/bin/rbw-agent/notifications.rs b/src/bin/rbw-agent/notifications.rs index f0ec0fef..ad2ce30a 100644 --- a/src/bin/rbw-agent/notifications.rs +++ b/src/bin/rbw-agent/notifications.rs @@ -4,7 +4,7 @@ use futures_util::{SinkExt as _, StreamExt as _}; use tokio::{ net::TcpStream, sync::{ - mpsc::{UnboundedReceiver, UnboundedSender}, + mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}, RwLock, }, task::JoinHandle, @@ -70,7 +70,7 @@ impl NotificationsHandler { } pub async fn get_channel(&self) -> UnboundedReceiver { - let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let (tx, rx) = unbounded_channel(); self.sending_channels.write().await.push(tx); rx } diff --git a/src/bin/rbw-agent/timeout.rs b/src/bin/rbw-agent/timeout.rs index 5bccd11c..50218ff5 100644 --- a/src/bin/rbw-agent/timeout.rs +++ b/src/bin/rbw-agent/timeout.rs @@ -1,4 +1,5 @@ use futures_util::StreamExt as _; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; use tokio_stream::wrappers::UnboundedReceiverStream; #[derive(Debug, Hash, Eq, PartialEq, Copy, Clone)] @@ -14,13 +15,13 @@ enum Action { } pub struct Timeout { - req_w: tokio::sync::mpsc::UnboundedSender, + req_w: UnboundedSender, } impl Timeout { - pub fn new() -> (Self, tokio::sync::mpsc::UnboundedReceiver<()>) { - let (req_w, req_r) = tokio::sync::mpsc::unbounded_channel(); - let (timer_w, timer_r) = tokio::sync::mpsc::unbounded_channel(); + pub fn new() -> (Self, UnboundedReceiver<()>) { + let (req_w, req_r) = unbounded_channel(); + let (timer_w, timer_r) = unbounded_channel(); tokio::spawn(async move { enum Event { Request(Action), From 26d7c0b1775323cccddac141e0e8e6902b3c9ae7 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 25 May 2026 15:39:14 +0200 Subject: [PATCH 139/273] remove unwrap()s in dirs.rs --- src/bin/rbw-agent/daemon.rs | 8 ++-- src/bin/rbw-agent/sock.rs | 2 +- src/bin/rbw-agent/ssh_agent.rs | 2 +- src/bin/rbw/actions.rs | 6 ++- src/bin/rbw/sock.rs | 3 +- src/config.rs | 8 ++-- src/db.rs | 10 ++--- src/dirs.rs | 72 ++++++++++++++++++---------------- src/error.rs | 3 ++ 9 files changed, 63 insertions(+), 51 deletions(-) diff --git a/src/bin/rbw-agent/daemon.rs b/src/bin/rbw-agent/daemon.rs index 0a612e29..c439c4f3 100644 --- a/src/bin/rbw-agent/daemon.rs +++ b/src/bin/rbw-agent/daemon.rs @@ -21,7 +21,7 @@ pub fn daemonize(no_daemonize: bool) -> anyhow::Result> { .create(true) .truncate(false) .mode(0o666) - .open(rbw::dirs::pid_file()) + .open(rbw::dirs::pid_file()?) .context("failed to open pid file")?; rustix::fs::flock( &pidfile, @@ -39,15 +39,15 @@ pub fn daemonize(no_daemonize: bool) -> anyhow::Result> { let stdout = std::fs::OpenOptions::new() .append(true) .create(true) - .open(rbw::dirs::agent_stdout_file())?; + .open(rbw::dirs::agent_stdout_file()?)?; let stderr = std::fs::OpenOptions::new() .append(true) .create(true) - .open(rbw::dirs::agent_stderr_file())?; + .open(rbw::dirs::agent_stderr_file()?)?; let (r, w) = rustix::pipe::pipe()?; let daemonize = daemonize::Daemonize::new() - .pid_file(rbw::dirs::pid_file()) + .pid_file(rbw::dirs::pid_file()?) .stdout(stdout) .stderr(stderr); let res = match daemonize.execute() { diff --git a/src/bin/rbw-agent/sock.rs b/src/bin/rbw-agent/sock.rs index 2320435d..2b3aa535 100644 --- a/src/bin/rbw-agent/sock.rs +++ b/src/bin/rbw-agent/sock.rs @@ -42,7 +42,7 @@ impl Sock { } pub fn listen() -> anyhow::Result { - let path = rbw::dirs::socket_file(); + let path = rbw::dirs::socket_file()?; // if the socket already doesn't exist, that's fine let _ = std::fs::remove_file(&path); let sock = tokio::net::UnixListener::bind(&path).context("failed to listen on socket")?; diff --git a/src/bin/rbw-agent/ssh_agent.rs b/src/bin/rbw-agent/ssh_agent.rs index 1f8874d9..44986a14 100644 --- a/src/bin/rbw-agent/ssh_agent.rs +++ b/src/bin/rbw-agent/ssh_agent.rs @@ -27,7 +27,7 @@ impl SshAgent { } pub async fn run(self) -> anyhow::Result<()> { - let socket = rbw::dirs::ssh_agent_socket_file(); + let socket = rbw::dirs::ssh_agent_socket_file()?; let _ = std::fs::remove_file(&socket); // Ignore error if it doesn't exist diff --git a/src/bin/rbw/actions.rs b/src/bin/rbw/actions.rs index 1295858d..f916176d 100644 --- a/src/bin/rbw/actions.rs +++ b/src/bin/rbw/actions.rs @@ -52,7 +52,7 @@ pub fn lock() -> anyhow::Result<()> { pub fn quit() -> anyhow::Result<()> { match crate::sock::Sock::connect() { Ok(mut sock) => { - let pidfile = rbw::dirs::pid_file(); + let pidfile = rbw::dirs::pid_file()?; let mut pid = String::new(); std::fs::File::open(pidfile)?.read_to_string(&mut pid)?; let Some(pid) = rustix::process::Pid::from_raw(pid.trim_end().parse()?) else { @@ -151,7 +151,9 @@ fn connect() -> anyhow::Result { "failed to connect to rbw-agent \ (this often means that the agent failed to start; \ check {} for agent logs)", - log.display() + log.map_or("".to_string(), |p| p + .display() + .to_string()) ) }) } diff --git a/src/bin/rbw/sock.rs b/src/bin/rbw/sock.rs index 250edc1c..7d13a0b4 100644 --- a/src/bin/rbw/sock.rs +++ b/src/bin/rbw/sock.rs @@ -9,7 +9,8 @@ impl Sock { // specific kinds of std::io::Results differently pub fn connect() -> std::io::Result { Ok(Self(std::os::unix::net::UnixStream::connect( - rbw::dirs::socket_file(), + rbw::dirs::socket_file() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?, )?)) } diff --git a/src/config.rs b/src/config.rs index 1ec45441..54cc7bce 100644 --- a/src/config.rs +++ b/src/config.rs @@ -66,7 +66,7 @@ impl Config { } pub fn load() -> Result { - let file = crate::dirs::config_file(); + let file = crate::dirs::config_file()?; let mut fh = std::fs::File::open(&file).map_err(|source| Error::LoadConfig { source, file: file.clone(), @@ -87,7 +87,7 @@ impl Config { } pub async fn load_async() -> Result { - let file = crate::dirs::config_file(); + let file = crate::dirs::config_file()?; let mut fh = tokio::fs::File::open(&file) .await @@ -112,7 +112,7 @@ impl Config { } pub fn save(&self) -> Result<()> { - let file = crate::dirs::config_file(); + let file = crate::dirs::config_file()?; // unwrap is safe here because Self::filename is explicitly // constructed as a filename in a directory std::fs::create_dir_all(file.parent().unwrap()).map_err(|source| Error::SaveConfig { @@ -217,7 +217,7 @@ impl Config { } pub async fn device_id(config: &Config) -> Result { - let file = crate::dirs::device_id_file(); + let file = crate::dirs::device_id_file()?; if let Ok(mut fh) = tokio::fs::File::open(&file).await { let mut s = String::new(); fh.read_to_string(&mut s) diff --git a/src/db.rs b/src/db.rs index ae14c69e..5f5a0fdf 100644 --- a/src/db.rs +++ b/src/db.rs @@ -888,7 +888,7 @@ impl Db { } pub fn load(server: &str, email: &str) -> Result { - let file = crate::dirs::db_file(server, email); + let file = crate::dirs::db_file(server, email)?; let mut fh = std::fs::File::open(&file).map_err(|source| Error::LoadDb { source, file: file.clone(), @@ -905,7 +905,7 @@ impl Db { } pub async fn load_async(server: &str, email: &str) -> Result { - let file = crate::dirs::db_file(server, email); + let file = crate::dirs::db_file(server, email)?; let mut fh = tokio::fs::File::open(&file) .await .map_err(|source| Error::LoadDbAsync { @@ -972,7 +972,7 @@ impl Db { // XXX need to make this atomic pub fn save(&self, server: &str, email: &str) -> Result<()> { - let file = crate::dirs::db_file(server, email); + let file = crate::dirs::db_file(server, email)?; // unwrap is safe here because Self::filename is explicitly // constructed as a filename in a directory std::fs::create_dir_all(file.parent().unwrap()).map_err(|source| Error::SaveDb { @@ -997,7 +997,7 @@ impl Db { // XXX need to make this atomic pub async fn save_async(&self, server: &str, email: &str) -> Result<()> { - let file = crate::dirs::db_file(server, email); + let file = crate::dirs::db_file(server, email)?; // unwrap is safe here because Self::filename is explicitly // constructed as a filename in a directory tokio::fs::create_dir_all(file.parent().unwrap()) @@ -1026,7 +1026,7 @@ impl Db { } pub fn remove(server: &str, email: &str) -> Result<()> { - let file = crate::dirs::db_file(server, email); + let file = crate::dirs::db_file(server, email)?; let res = std::fs::remove_file(&file); if let Err(e) = &res { if e.kind() == std::io::ErrorKind::NotFound { diff --git a/src/dirs.rs b/src/dirs.rs index 2c64757e..f404528a 100644 --- a/src/dirs.rs +++ b/src/dirs.rs @@ -1,11 +1,16 @@ +use directories::ProjectDirs; + use crate::prelude::*; -use std::os::unix::fs::{DirBuilderExt as _, PermissionsExt as _}; +use std::{ + os::unix::fs::{DirBuilderExt as _, PermissionsExt as _}, + path::PathBuf, +}; pub fn make_all() -> Result<()> { - create_dir_all_with_permissions(&cache_dir(), 0o700)?; - create_dir_all_with_permissions(&runtime_dir(), 0o700)?; - create_dir_all_with_permissions(&data_dir(), 0o700)?; + create_dir_all_with_permissions(&cache_dir()?, 0o700)?; + create_dir_all_with_permissions(&runtime_dir()?, 0o700)?; + create_dir_all_with_permissions(&data_dir()?, 0o700)?; Ok(()) } @@ -32,59 +37,60 @@ fn create_dir_all_with_permissions(path: &std::path::Path, mode: u32) -> Result< Ok(()) } -pub fn config_file() -> std::path::PathBuf { - config_dir().join("config.json") +pub fn config_file() -> Result { + Ok(config_dir()?.join("config.json")) } const INVALID_PATH: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS.add(b'/').add(b'%').add(b':'); -pub fn db_file(server: &str, email: &str) -> std::path::PathBuf { + +pub fn db_file(server: &str, email: &str) -> Result { let server = percent_encoding::percent_encode(server.as_bytes(), INVALID_PATH).to_string(); - cache_dir().join(format!("{server}:{email}.json")) + Ok(cache_dir()?.join(format!("{server}:{email}.json"))) +} + +pub fn pid_file() -> Result { + Ok(runtime_dir()?.join("pidfile")) } -pub fn pid_file() -> std::path::PathBuf { - runtime_dir().join("pidfile") +pub fn agent_stdout_file() -> Result { + Ok(data_dir()?.join("agent.out")) } -pub fn agent_stdout_file() -> std::path::PathBuf { - data_dir().join("agent.out") +pub fn agent_stderr_file() -> Result { + Ok(data_dir()?.join("agent.err")) } -pub fn agent_stderr_file() -> std::path::PathBuf { - data_dir().join("agent.err") +pub fn device_id_file() -> Result { + Ok(data_dir()?.join("device_id")) } -pub fn device_id_file() -> std::path::PathBuf { - data_dir().join("device_id") +pub fn socket_file() -> Result { + Ok(runtime_dir()?.join("socket")) } -pub fn socket_file() -> std::path::PathBuf { - runtime_dir().join("socket") +pub fn ssh_agent_socket_file() -> Result { + Ok(runtime_dir()?.join("ssh-agent-socket")) } -pub fn ssh_agent_socket_file() -> std::path::PathBuf { - runtime_dir().join("ssh-agent-socket") +fn project_dirs() -> Result { + ProjectDirs::from("", "", &profile()).ok_or(crate::error::Error::FailedToFindDataDirectory) } -fn config_dir() -> std::path::PathBuf { - let project_dirs = directories::ProjectDirs::from("", "", &profile()).unwrap(); - project_dirs.config_dir().to_path_buf() +fn config_dir() -> Result { + Ok(project_dirs()?.config_dir().to_path_buf()) } -fn cache_dir() -> std::path::PathBuf { - let project_dirs = directories::ProjectDirs::from("", "", &profile()).unwrap(); - project_dirs.cache_dir().to_path_buf() +fn cache_dir() -> Result { + Ok(project_dirs()?.cache_dir().to_path_buf()) } -fn data_dir() -> std::path::PathBuf { - let project_dirs = directories::ProjectDirs::from("", "", &profile()).unwrap(); - project_dirs.data_dir().to_path_buf() +fn data_dir() -> Result { + Ok(project_dirs()?.data_dir().to_path_buf()) } -fn runtime_dir() -> std::path::PathBuf { - let project_dirs = directories::ProjectDirs::from("", "", &profile()).unwrap(); - project_dirs.runtime_dir().map_or_else( +fn runtime_dir() -> Result { + Ok(project_dirs()?.runtime_dir().map_or_else( || { format!( "{}/{}-{}", @@ -95,7 +101,7 @@ fn runtime_dir() -> std::path::PathBuf { .into() }, std::path::Path::to_path_buf, - ) + )) } pub fn profile() -> String { diff --git a/src/error.rs b/src/error.rs index 70a5febe..1c4f4f04 100644 --- a/src/error.rs +++ b/src/error.rs @@ -30,6 +30,9 @@ pub enum Error { #[error("failed to decrypt remotely")] DecryptRemote, + #[error("failed to find data directory")] + FailedToFindDataDirectory, + #[error("failed to find free port in {range}")] FailedToFindFreePort { range: String }, From cfeafd6c4d8e648eb44d5332d363e893a8228b9b Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 25 May 2026 15:49:06 +0200 Subject: [PATCH 140/273] remove unwrap()s in edit.rs --- src/edit.rs | 16 ++++++++++------ src/error.rs | 9 +++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/edit.rs b/src/edit.rs index d78c14b4..83cd85f1 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -17,11 +17,13 @@ pub fn edit(contents: &str, help: &str) -> Result { std::env::var_os(var).unwrap_or_else(|| "/usr/bin/vim".into()) }); - let dir = tempfile::tempdir().unwrap(); + let dir = tempfile::tempdir()?; let file = dir.path().join("rbw"); - let mut fh = std::fs::File::create(&file).unwrap(); - fh.write_all(contents.as_bytes()).unwrap(); - fh.write_all(help.as_bytes()).unwrap(); + let mut fh = std::fs::File::create(&file)?; + + fh.write_all(contents.as_bytes())?; + fh.write_all(help.as_bytes())?; + drop(fh); let (cmd, args) = if contains_shell_metacharacters(&editor) { @@ -76,9 +78,11 @@ pub fn edit(contents: &str, help: &str) -> Result { } } - let mut fh = std::fs::File::open(&file).unwrap(); + let mut fh = std::fs::File::open(&file)?; let mut contents = String::new(); - fh.read_to_string(&mut contents).unwrap(); + + fh.read_to_string(&mut contents)?; + drop(fh); Ok(contents) diff --git a/src/error.rs b/src/error.rs index 1c4f4f04..8d5628a6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -247,6 +247,9 @@ pub enum Error { #[error("unimplemented cipherstring type: {ty}")] UnimplementedCipherStringType { ty: String }, + #[error("I/O Error: {source}")] + GenericIo { source: std::io::Error }, + #[error("error writing to pinentry stdin")] WriteStdin { source: tokio::io::Error }, @@ -254,4 +257,10 @@ pub enum Error { InvalidKdfType { ty: String }, } +impl From for Error { + fn from(value: std::io::Error) -> Self { + Self::GenericIo { source: value } + } +} + pub type Result = std::result::Result; From 98905d36698cf1c0edfcf1c8f16ec1f5178ce03c Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 25 May 2026 18:09:30 +0200 Subject: [PATCH 141/273] split editor logic into two separate fns --- src/edit.rs | 79 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 50 insertions(+), 29 deletions(-) diff --git a/src/edit.rs b/src/edit.rs index 83cd85f1..4930e0ed 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -1,39 +1,35 @@ use crate::prelude::*; -use std::io::{IsTerminal as _, Read as _, Write as _}; - -pub fn edit(contents: &str, help: &str) -> Result { - if !std::io::stdin().is_terminal() { - // directly read from piped content - return match std::io::read_to_string(std::io::stdin()) { - Err(e) => Err(Error::FailedToReadFromStdin { err: e }), - Ok(res) => Ok(res), - }; - } +use std::{ + ffi::{OsStr, OsString}, + io::{IsTerminal as _, Read as _, Write as _}, + path::PathBuf, +}; +fn get_editor() -> (&'static str, OsString) { let mut var = "VISUAL"; let editor = std::env::var_os(var).unwrap_or_else(|| { var = "EDITOR"; std::env::var_os(var).unwrap_or_else(|| "/usr/bin/vim".into()) }); - let dir = tempfile::tempdir()?; - let file = dir.path().join("rbw"); - let mut fh = std::fs::File::create(&file)?; - - fh.write_all(contents.as_bytes())?; - fh.write_all(help.as_bytes())?; + (var, editor) +} - drop(fh); +fn get_editor_cmd_args( + editor: &OsString, + file: &PathBuf, + var: &str, +) -> Result<(PathBuf, Vec)> { + if contains_shell_metacharacters(&editor) { + let mut cmdline = OsString::new(); + cmdline.extend([editor.as_ref(), OsStr::new(" "), file.as_os_str()]); - let (cmd, args) = if contains_shell_metacharacters(&editor) { - let mut cmdline = std::ffi::OsString::new(); - cmdline.extend([editor.as_ref(), std::ffi::OsStr::new(" "), file.as_os_str()]); + let editor_args = vec![OsString::from("-c"), cmdline]; - let editor_args = vec![std::ffi::OsString::from("-c"), cmdline]; - (std::path::Path::new("/bin/sh"), editor_args) + Ok((PathBuf::from("/bin/sh"), editor_args)) } else { - let editor = std::path::Path::new(&editor); + let editor = PathBuf::from(editor); let mut editor_args = vec![]; #[allow(clippy::single_match_else)] // more to come @@ -41,8 +37,8 @@ pub fn edit(contents: &str, help: &str) -> Result { Some(editor) => match editor.to_str() { Some("vim" | "nvim") => { // disable swap files and viminfo for password entry - editor_args.push(std::ffi::OsString::from("-ni")); - editor_args.push(std::ffi::OsString::from("NONE")); + editor_args.push(OsString::from("-ni")); + editor_args.push(OsString::from("NONE")); } _ => { // other editor support welcomed @@ -55,11 +51,36 @@ pub fn edit(contents: &str, help: &str) -> Result { }) } } + editor_args.push(file.clone().into_os_string()); - (editor, editor_args) - }; - let res = std::process::Command::new(cmd).args(&args).status(); + Ok((editor, editor_args)) + } +} + +pub fn edit(contents: &str, help: &str) -> Result { + if !std::io::stdin().is_terminal() { + // directly read from piped content + return match std::io::read_to_string(std::io::stdin()) { + Err(e) => Err(Error::FailedToReadFromStdin { err: e }), + Ok(res) => Ok(res), + }; + } + + let dir = tempfile::tempdir()?; + let file = dir.path().join("rbw"); + let mut fh = std::fs::File::create(&file)?; + + fh.write_all(contents.as_bytes())?; + fh.write_all(help.as_bytes())?; + + drop(fh); + + let (var, editor) = get_editor(); + + let (cmd, args) = get_editor_cmd_args(&editor, &file, var)?; + + let res = std::process::Command::new(&cmd).args(&args).status(); match res { Ok(res) => { if !res.success() { @@ -88,7 +109,7 @@ pub fn edit(contents: &str, help: &str) -> Result { Ok(contents) } -fn contains_shell_metacharacters(cmd: &std::ffi::OsStr) -> bool { +fn contains_shell_metacharacters(cmd: &OsStr) -> bool { cmd.to_str() .is_some_and(|s| s.contains(&[' ', '$', '\'', '"'][..])) } From 301416380c27589ab5a336a1292d7ffa02e8329a Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 25 May 2026 21:32:32 +0200 Subject: [PATCH 142/273] further split editor logic --- src/edit.rs | 110 +++++++++++++++++++++++++--------------------------- 1 file changed, 53 insertions(+), 57 deletions(-) diff --git a/src/edit.rs b/src/edit.rs index 4930e0ed..c9191c14 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -3,58 +3,66 @@ use crate::prelude::*; use std::{ ffi::{OsStr, OsString}, io::{IsTerminal as _, Read as _, Write as _}, - path::PathBuf, + path::{Path, PathBuf}, }; -fn get_editor() -> (&'static str, OsString) { - let mut var = "VISUAL"; - let editor = std::env::var_os(var).unwrap_or_else(|| { - var = "EDITOR"; - std::env::var_os(var).unwrap_or_else(|| "/usr/bin/vim".into()) - }); - - (var, editor) +fn contains_shell_metacharacters(cmd: &OsStr) -> bool { + cmd.to_str() + .is_some_and(|s| s.contains(&[' ', '$', '\'', '"'])) } -fn get_editor_cmd_args( - editor: &OsString, - file: &PathBuf, - var: &str, -) -> Result<(PathBuf, Vec)> { - if contains_shell_metacharacters(&editor) { - let mut cmdline = OsString::new(); - cmdline.extend([editor.as_ref(), OsStr::new(" "), file.as_os_str()]); +fn get_editor_metachars(editor: &OsStr, file: &Path) -> (PathBuf, Vec) { + let mut cmdline = OsString::new(); + cmdline.extend([editor.as_ref(), OsStr::new(" "), file.as_os_str()]); - let editor_args = vec![OsString::from("-c"), cmdline]; + let args = vec![OsString::from("-c"), cmdline]; - Ok((PathBuf::from("/bin/sh"), editor_args)) - } else { - let editor = PathBuf::from(editor); - let mut editor_args = vec![]; - - #[allow(clippy::single_match_else)] // more to come - match editor.file_name() { - Some(editor) => match editor.to_str() { - Some("vim" | "nvim") => { - // disable swap files and viminfo for password entry - editor_args.push(OsString::from("-ni")); - editor_args.push(OsString::from("NONE")); - } - _ => { - // other editor support welcomed - } - }, - None => { - return Err(Error::InvalidEditor { - var: var.to_string(), - editor: editor.as_os_str().to_os_string(), - }) + (PathBuf::from("/bin/sh"), args) +} + +fn get_editor_cmd_args(editor: &OsStr, file: &Path) -> Option<(PathBuf, Vec)> { + let editor = PathBuf::from(&editor); + let mut args = vec![]; + + #[allow(clippy::single_match_else)] // more to come + match editor.file_name() { + Some(editor) => match editor.to_str() { + Some("vim" | "nvim") => { + // disable swap files and viminfo for password entry + args.push(OsString::from("-ni")); + args.push(OsString::from("NONE")); } + _ => { + // other editor support welcomed + } + }, + None => { + return None; } + } - editor_args.push(file.clone().into_os_string()); + args.push(file.as_os_str().to_os_string()); + + Some((editor, args)) +} - Ok((editor, editor_args)) +fn get_editor(file: &Path) -> Result<(PathBuf, Vec)> { + let mut var = "VISUAL"; + + let editor = std::env::var_os(var).unwrap_or_else(|| { + var = "EDITOR"; + std::env::var_os(var).unwrap_or_else(|| "/usr/bin/vim".into()) + }); + + if contains_shell_metacharacters(&editor) { + Ok(get_editor_metachars(&editor, file)) + } else { + Ok( + get_editor_cmd_args(&editor, file).ok_or(Error::InvalidEditor { + var: var.to_string(), + editor, + })?, + ) } } @@ -76,27 +84,20 @@ pub fn edit(contents: &str, help: &str) -> Result { drop(fh); - let (var, editor) = get_editor(); - - let (cmd, args) = get_editor_cmd_args(&editor, &file, var)?; + let (cmd, args) = get_editor(&file)?; let res = std::process::Command::new(&cmd).args(&args).status(); match res { Ok(res) => { if !res.success() { return Err(Error::FailedToRunEditor { - editor: cmd.to_owned(), + editor: cmd, args, res, }); } } - Err(err) => { - return Err(Error::FailedToFindEditor { - editor: cmd.to_owned(), - err, - }) - } + Err(err) => return Err(Error::FailedToFindEditor { editor: cmd, err }), } let mut fh = std::fs::File::open(&file)?; @@ -108,8 +109,3 @@ pub fn edit(contents: &str, help: &str) -> Result { Ok(contents) } - -fn contains_shell_metacharacters(cmd: &OsStr) -> bool { - cmd.to_str() - .is_some_and(|s| s.contains(&[' ', '$', '\'', '"'][..])) -} From 5bd238b7680ce20c4fa69a62f36b62d02ea35b11 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 25 May 2026 21:42:23 +0200 Subject: [PATCH 143/273] simplify get_editor_cmd_args fn --- src/edit.rs | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/src/edit.rs b/src/edit.rs index c9191c14..14a564e8 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -22,28 +22,17 @@ fn get_editor_metachars(editor: &OsStr, file: &Path) -> (PathBuf, Vec) fn get_editor_cmd_args(editor: &OsStr, file: &Path) -> Option<(PathBuf, Vec)> { let editor = PathBuf::from(&editor); - let mut args = vec![]; #[allow(clippy::single_match_else)] // more to come match editor.file_name() { - Some(editor) => match editor.to_str() { - Some("vim" | "nvim") => { - // disable swap files and viminfo for password entry - args.push(OsString::from("-ni")); - args.push(OsString::from("NONE")); - } - _ => { - // other editor support welcomed - } + Some(editor_file_name) => match editor_file_name.to_str() { + // disable swap files and viminfo for password entry + Some("vim" | "nvim") => Some((editor, vec!["-ni".into(), "NONE".into(), file.into()])), + // other editor support welcomed + _ => Some((editor, vec![file.into()])), }, - None => { - return None; - } + None => None, } - - args.push(file.as_os_str().to_os_string()); - - Some((editor, args)) } fn get_editor(file: &Path) -> Result<(PathBuf, Vec)> { From eac15333dc1f43cc52b37be7433fe81df7395231 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 25 May 2026 21:50:58 +0200 Subject: [PATCH 144/273] simplify get_editor_cmd_args even further --- src/edit.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/edit.rs b/src/edit.rs index 14a564e8..77606054 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -24,14 +24,11 @@ fn get_editor_cmd_args(editor: &OsStr, file: &Path) -> Option<(PathBuf, Vec match editor_file_name.to_str() { - // disable swap files and viminfo for password entry - Some("vim" | "nvim") => Some((editor, vec!["-ni".into(), "NONE".into(), file.into()])), - // other editor support welcomed - _ => Some((editor, vec![file.into()])), - }, - None => None, + match editor.file_name()?.to_str() { + // disable swap files and viminfo for password entry + Some("vim" | "nvim") => Some((editor, vec!["-ni".into(), "NONE".into(), file.into()])), + // other editor support welcomed + _ => Some((editor, vec![file.into()])), } } From c6817376e2a36bc7359add3d7b422f192a516ed2 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 25 May 2026 21:59:05 +0200 Subject: [PATCH 145/273] simplify get_editor_metachars --- src/edit.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/edit.rs b/src/edit.rs index 77606054..b26cef79 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -12,12 +12,15 @@ fn contains_shell_metacharacters(cmd: &OsStr) -> bool { } fn get_editor_metachars(editor: &OsStr, file: &Path) -> (PathBuf, Vec) { - let mut cmdline = OsString::new(); - cmdline.extend([editor.as_ref(), OsStr::new(" "), file.as_os_str()]); - - let args = vec![OsString::from("-c"), cmdline]; - - (PathBuf::from("/bin/sh"), args) + ( + PathBuf::from("/bin/sh"), + vec![ + "-c".into(), + [editor.as_ref(), OsStr::new(" "), file.as_os_str()] + .into_iter() + .collect::(), + ], + ) } fn get_editor_cmd_args(editor: &OsStr, file: &Path) -> Option<(PathBuf, Vec)> { From 5688ac190567788fb81df3a1236b5b54eaddf71c Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 25 May 2026 22:06:29 +0200 Subject: [PATCH 146/273] passing editor as reference --- src/edit.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/edit.rs b/src/edit.rs index b26cef79..3738c1bc 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -16,22 +16,22 @@ fn get_editor_metachars(editor: &OsStr, file: &Path) -> (PathBuf, Vec) PathBuf::from("/bin/sh"), vec![ "-c".into(), - [editor.as_ref(), OsStr::new(" "), file.as_os_str()] + [editor, OsStr::new(" "), file.as_os_str()] .into_iter() .collect::(), ], ) } -fn get_editor_cmd_args(editor: &OsStr, file: &Path) -> Option<(PathBuf, Vec)> { - let editor = PathBuf::from(&editor); - - #[allow(clippy::single_match_else)] // more to come +fn get_editor_cmd_args(editor: &Path, file: &Path) -> Option<(PathBuf, Vec)> { match editor.file_name()?.to_str() { // disable swap files and viminfo for password entry - Some("vim" | "nvim") => Some((editor, vec!["-ni".into(), "NONE".into(), file.into()])), + Some("vim" | "nvim") => Some(( + editor.to_owned(), + vec!["-ni".into(), "NONE".into(), file.into()], + )), // other editor support welcomed - _ => Some((editor, vec![file.into()])), + _ => Some((editor.to_owned(), vec![file.into()])), } } @@ -47,7 +47,7 @@ fn get_editor(file: &Path) -> Result<(PathBuf, Vec)> { Ok(get_editor_metachars(&editor, file)) } else { Ok( - get_editor_cmd_args(&editor, file).ok_or(Error::InvalidEditor { + get_editor_cmd_args(Path::new(&editor), file).ok_or(Error::InvalidEditor { var: var.to_string(), editor, })?, From 9bcd8636451c14f3f7fa9a9473a57c540ab5dad9 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 25 May 2026 22:15:32 +0200 Subject: [PATCH 147/273] simplify edit fn --- src/edit.rs | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/edit.rs b/src/edit.rs index 3738c1bc..469a0eb5 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -55,6 +55,15 @@ fn get_editor(file: &Path) -> Result<(PathBuf, Vec)> { } } +/// Small helper to avoid heap allocation of std::fs::write(.., [str1, str2].join("")) +fn write_strs(path: &Path, pieces: &[&str]) -> Result<()> { + let mut f = std::fs::File::create(path)?; + for piece in pieces { + f.write_all(piece.as_bytes())?; + } + Ok(()) +} + pub fn edit(contents: &str, help: &str) -> Result { if !std::io::stdin().is_terminal() { // directly read from piped content @@ -66,12 +75,8 @@ pub fn edit(contents: &str, help: &str) -> Result { let dir = tempfile::tempdir()?; let file = dir.path().join("rbw"); - let mut fh = std::fs::File::create(&file)?; - - fh.write_all(contents.as_bytes())?; - fh.write_all(help.as_bytes())?; - drop(fh); + write_strs(&file, &[contents, help])?; let (cmd, args) = get_editor(&file)?; @@ -89,12 +94,6 @@ pub fn edit(contents: &str, help: &str) -> Result { Err(err) => return Err(Error::FailedToFindEditor { editor: cmd, err }), } - let mut fh = std::fs::File::open(&file)?; - let mut contents = String::new(); - - fh.read_to_string(&mut contents)?; - - drop(fh); - - Ok(contents) + // TODO: This should be zeroized as it contains sensible stuff + Ok(std::fs::read_to_string(&file)?) } From 3278594e30fafd3fce1100be597edfc4b2703175 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 25 May 2026 22:18:42 +0200 Subject: [PATCH 148/273] replace match with map_err --- src/edit.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/edit.rs b/src/edit.rs index 469a0eb5..2c629bb9 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -67,10 +67,9 @@ fn write_strs(path: &Path, pieces: &[&str]) -> Result<()> { pub fn edit(contents: &str, help: &str) -> Result { if !std::io::stdin().is_terminal() { // directly read from piped content - return match std::io::read_to_string(std::io::stdin()) { - Err(e) => Err(Error::FailedToReadFromStdin { err: e }), - Ok(res) => Ok(res), - }; + // TODO: This should be zeroized as it contains sensible stuff + return std::io::read_to_string(std::io::stdin()) + .map_err(|err| Error::FailedToReadFromStdin { err }); } let dir = tempfile::tempdir()?; From f6fddd1a23e40b85c6f81b151844acad06d6b317 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 25 May 2026 22:25:06 +0200 Subject: [PATCH 149/273] improve some naming, comment and remove a match in favor of map_err --- src/edit.rs | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/src/edit.rs b/src/edit.rs index 2c629bb9..b5b7fe81 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -2,8 +2,9 @@ use crate::prelude::*; use std::{ ffi::{OsStr, OsString}, - io::{IsTerminal as _, Read as _, Write as _}, + io::{IsTerminal as _, Write as _}, path::{Path, PathBuf}, + process::Command, }; fn contains_shell_metacharacters(cmd: &OsStr) -> bool { @@ -35,7 +36,7 @@ fn get_editor_cmd_args(editor: &Path, file: &Path) -> Option<(PathBuf, Vec Result<(PathBuf, Vec)> { +fn get_editor_cmdline(file: &Path) -> Result<(PathBuf, Vec)> { let mut var = "VISUAL"; let editor = std::env::var_os(var).unwrap_or_else(|| { @@ -67,7 +68,7 @@ fn write_strs(path: &Path, pieces: &[&str]) -> Result<()> { pub fn edit(contents: &str, help: &str) -> Result { if !std::io::stdin().is_terminal() { // directly read from piped content - // TODO: This should be zeroized as it contains sensible stuff + // TODO: This should be zeroized / locked as it contains sensible stuff return std::io::read_to_string(std::io::stdin()) .map_err(|err| Error::FailedToReadFromStdin { err }); } @@ -77,22 +78,24 @@ pub fn edit(contents: &str, help: &str) -> Result { write_strs(&file, &[contents, help])?; - let (cmd, args) = get_editor(&file)?; + let (cmd, args) = get_editor_cmdline(&file)?; - let res = std::process::Command::new(&cmd).args(&args).status(); - match res { - Ok(res) => { - if !res.success() { - return Err(Error::FailedToRunEditor { - editor: cmd, - args, - res, - }); - } - } - Err(err) => return Err(Error::FailedToFindEditor { editor: cmd, err }), + let res = Command::new(&cmd) + .args(&args) + .status() + .map_err(|err| Error::FailedToFindEditor { + editor: cmd.clone(), + err, + })?; + + if !res.success() { + return Err(Error::FailedToRunEditor { + editor: cmd, + args, + res, + }); } - // TODO: This should be zeroized as it contains sensible stuff + // TODO: This should be zeroized / locked as it contains sensible stuff Ok(std::fs::read_to_string(&file)?) } From a517e932f82e5381fa63994fa8c4d11d9d6a8ffe Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 25 May 2026 22:47:28 +0200 Subject: [PATCH 150/273] simplify main --- src/bin/rbw-agent/main.rs | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index b1dd418c..68f5f15d 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -10,7 +10,7 @@ mod ssh_agent; mod state; mod timeout; -async fn tokio_main(startup_ack: Option) -> anyhow::Result<()> { +async fn async_main(startup_ack: Option) -> anyhow::Result<()> { let listener = crate::sock::listen()?; if let Some(startup_ack) = startup_ack { @@ -54,7 +54,7 @@ async fn tokio_main(startup_ack: Option) -> anyhow::R Ok(()) } -fn real_main() -> anyhow::Result<()> { +fn main() -> anyhow::Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); let no_daemonize = std::env::args() @@ -69,31 +69,9 @@ fn real_main() -> anyhow::Result<()> { log::warn!("{e}"); } - let (w, r) = std::sync::mpsc::channel(); // can't use tokio::main because we need to daemonize before starting the // tokio runloop, or else things break - // unwrap is fine here because there's no good reason that this should - // ever fail - tokio::runtime::Runtime::new().unwrap().block_on(async { - if let Err(e) = tokio_main(startup_ack).await { - // this unwrap is fine because it's the only real option here - w.send(e).unwrap(); - } - }); - - if let Ok(e) = r.recv() { - return Err(e); - } + tokio::runtime::Runtime::new()?.block_on(async { async_main(startup_ack).await })?; Ok(()) } - -fn main() { - let res = real_main(); - - if let Err(e) = res { - // XXX log file? - eprintln!("{e:#}"); - std::process::exit(1); - } -} From 3532e4c4150bf10f2c407bedb6e216f1bd04e5a8 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 25 May 2026 23:10:40 +0200 Subject: [PATCH 151/273] bugfix Display trait println instead writeln --- src/db.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/db.rs b/src/db.rs index 5f5a0fdf..d6593805 100644 --- a/src/db.rs +++ b/src/db.rs @@ -773,9 +773,9 @@ impl Display for Entry { if !matches!(self.data, EntryData::SecureNote) { if let Some(notes) = &self.notes { if d { - println!(); + writeln!(f, "")?; } - println!("{notes}"); + writeln!(f, "{notes}")?; } } From c9dbf10f8da56048c27170ecdee74863b03117f1 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 25 May 2026 23:29:50 +0200 Subject: [PATCH 152/273] resolve almost every clippy warning --- Cargo.toml | 22 -------------------- src/actions.rs | 12 +---------- src/api.rs | 38 ++++++++++++---------------------- src/bin/rbw-agent/actions.rs | 10 ++++----- src/bin/rbw-agent/agent.rs | 2 +- src/bin/rbw-agent/ssh_agent.rs | 2 +- src/bin/rbw/commands.rs | 32 +++++++++++++--------------- src/bin/rbw/sock.rs | 3 +-- src/db.rs | 18 ++++++++-------- src/edit.rs | 2 +- 10 files changed, 46 insertions(+), 95 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4c43ce7c..99855fad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,28 +91,6 @@ arboard = { version = "3.6.1", default-features = false, features = [ default = ["clipboard"] clipboard = ["arboard"] -[lints.clippy] -cargo = { level = "warn", priority = -1 } -pedantic = { level = "warn", priority = -1 } -nursery = { level = "warn", priority = -1 } -as_conversions = "warn" -get_unwrap = "warn" -cognitive_complexity = "allow" -missing_const_for_fn = "allow" -similar_names = "allow" -struct_excessive_bools = "allow" -fn_params_excessive_bools = "allow" -too_many_arguments = "allow" -too_many_lines = "allow" -type_complexity = "allow" -multiple_crate_versions = "allow" -large_enum_variant = "allow" -must_use_candidate = "allow" -missing_errors_doc = "allow" -missing_panics_doc = "allow" -significant_drop_tightening = "allow" -struct_field_names = "allow" - [package.metadata.deb] depends = "pinentry" license-file = ["LICENSE"] diff --git a/src/actions.rs b/src/actions.rs index 0558fdfe..8842852c 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -174,17 +174,7 @@ pub fn edit( entry: &Entry, ) -> Result<(Option, ())> { with_exchange_refresh_token(access_token, refresh_token, |access_token| { - api_client()?.0.edit( - access_token, - &entry.id, - entry.org_id.as_deref(), - &entry.name, - &entry.data, - &entry.fields, - entry.notes.as_deref(), - entry.folder_id.as_deref(), - &entry.history, - ) + api_client()?.0.edit(access_token, entry) }) } diff --git a/src/api.rs b/src/api.rs index c1b6c193..82c96f70 100644 --- a/src/api.rs +++ b/src/api.rs @@ -936,10 +936,7 @@ impl<'a> ClientRequest<'a> { ]), }; - Ok(rb - .send() - .await - .map_err(|source| Error::Reqwest { source })?) + rb.send().await.map_err(|source| Error::Reqwest { source }) } } @@ -984,7 +981,7 @@ impl<'a> ClientBlockingRequest<'a> { ]), }; - Ok(rb.send().map_err(|source| Error::Reqwest { source })?) + rb.send().map_err(|source| Error::Reqwest { source }) } } @@ -1316,25 +1313,15 @@ impl Client { } } - pub fn edit( - &self, - access_token: &str, - id: &str, - org_id: Option<&str>, - name: &str, - data: &crate::db::EntryData, - fields: &[crate::db::DynamicField], - notes: Option<&str>, - folder_uuid: Option<&str>, - history: &[crate::db::HistoryEntry], - ) -> Result<()> { + pub fn edit(&self, access_token: &str, entry: &crate::db::Entry) -> Result<()> { let req = CiphersPutReq { - folder_id: folder_uuid.map(ToString::to_string), - organization_id: org_id.map(ToString::to_string), - name: name.to_string(), - notes: notes.map(ToString::to_string), - data: EntryDataWire(data), - fields: fields + folder_id: entry.folder_id.clone(), + organization_id: entry.org_id.clone(), + name: entry.name.clone(), + notes: entry.notes.clone(), + data: EntryDataWire(&entry.data), + fields: entry + .fields .iter() .map(|field| CipherField { ty: field.ty, @@ -1343,7 +1330,8 @@ impl Client { linked_id: field.linked_id, }) .collect(), - password_history: history + password_history: entry + .history .iter() .map(|entry| CiphersPutReqHistory { last_used_date: entry.last_used_date.clone(), @@ -1352,7 +1340,7 @@ impl Client { .collect(), }; - let res = ClientBlockingRequest::Edit(access_token, id, req).req(self)?; + let res = ClientBlockingRequest::Edit(access_token, &entry.id, req).req(self)?; match res.status() { reqwest::StatusCode::OK => Ok(()), diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index bb58fdae..80517efd 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -142,7 +142,7 @@ async fn two_factor_required( } } - let creds = two_factor(environment, &email, password.clone(), provider).await?; + let creds = two_factor(environment, email, password.clone(), provider).await?; login_success(state.clone(), creds, password, db, email).await } @@ -259,7 +259,7 @@ async fn login_success( ) -> anyhow::Result<()> { db.apply_session_parameters(&creds); - save_db(&db).await?; + save_db(db).await?; sync(None, state.clone()).await?; @@ -272,7 +272,7 @@ async fn login_success( }; let res = rbw::actions::unlock( - &email, + email, &password, &creds.crypto_params, &creds.protected_key, @@ -409,7 +409,7 @@ pub async fn sync( }; let (access_token, (protected_key, protected_private_key, protected_org_keys, entries)) = - rbw::actions::sync(&access_token, &refresh_token) + rbw::actions::sync(access_token, refresh_token) .await .context("failed to sync database from server")?; state.lock().await.set_master_password_reprompt(&entries); @@ -533,7 +533,7 @@ async fn decrypt_cipher( )); }; - let entry_key = decrypt_entry_key(entry_key, &keys)?; + let entry_key = decrypt_entry_key(entry_key, keys)?; maybe_reprompt_password(&state, environment, cipherstring).await?; diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index 03f4316a..aaa980eb 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -145,7 +145,7 @@ async fn handle_request( sock, state.clone(), &environment, - &cipherstring, + cipherstring, entry_key.as_deref(), org_id.as_deref(), ) diff --git a/src/bin/rbw-agent/ssh_agent.rs b/src/bin/rbw-agent/ssh_agent.rs index 44986a14..00420979 100644 --- a/src/bin/rbw-agent/ssh_agent.rs +++ b/src/bin/rbw-agent/ssh_agent.rs @@ -13,7 +13,7 @@ async fn config_pinentry() -> anyhow::Result { async fn config_confirm_ssh() -> anyhow::Result { let config = rbw::config::Config::load_async().await?; - Ok(config.confirm_ssh.is_some_and(|o| o == true)) + Ok(config.confirm_ssh.is_some_and(|o| o)) } #[derive(Clone)] diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 9d187818..cd5ac005 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -67,12 +67,8 @@ impl rbw::db::Encrypter for RemoteEncrypter { entry: Option<&rbw::db::Entry>, field: &str, ) -> rbw::error::Result { - Ok(crate::actions::encrypt( - field, - // entry.map_or(None, |e| e.key.as_deref()), - entry.map_or(None, |e| e.org_id.as_deref()), - ) - .map_err(|_e| rbw::error::Error::EncryptRemote)?) + crate::actions::encrypt(field, entry.and_then(|e| e.org_id.as_deref())) + .map_err(|_e| rbw::error::Error::EncryptRemote) } } @@ -86,12 +82,12 @@ impl rbw::db::Decrypter for RemoteDecrypter { entry: Option<&rbw::db::Entry>, field: &str, ) -> rbw::error::Result { - Ok(crate::actions::decrypt( + crate::actions::decrypt( field, - entry.map_or(None, |e| e.key.as_deref()), - entry.map_or(None, |e| e.org_id.as_deref()), + entry.and_then(|e| e.key.as_deref()), + entry.and_then(|e| e.org_id.as_deref()), ) - .map_err(|_e| rbw::error::Error::DecryptRemote)?) + .map_err(|_e| rbw::error::Error::DecryptRemote) } } @@ -307,7 +303,7 @@ fn matches_url( given_url.to_string().trim_end_matches('/') == url.trim_end_matches('/') } rbw::api::UriMatchType::RegularExpression => { - regex::Regex::new(url).map_or(false, |rx| rx.is_match(given_url.as_ref())) + regex::Regex::new(url).is_ok_and(|rx| rx.is_match(given_url.as_ref())) } rbw::api::UriMatchType::Never => false, } @@ -734,8 +730,8 @@ pub fn search( .map(|entry: &SearchEntry| entry.search_match(term, folder)) .unwrap_or(true) }) - .map(|entry| entry.map(Into::into)) .collect::>()?; + entries.sort_unstable_by(|a, b| a.name.cmp(&b.name)); print_entry_list(&entries, &fields, raw) @@ -816,7 +812,7 @@ fn find_or_create_folder(db: &mut rbw::db::Db, folder: &str) -> anyhow::Result std::io::Result { Ok(Self(std::os::unix::net::UnixStream::connect( - rbw::dirs::socket_file() - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?, + rbw::dirs::socket_file().map_err(std::io::Error::other)?, )?)) } diff --git a/src/db.rs b/src/db.rs index d6593805..3152c6df 100644 --- a/src/db.rs +++ b/src/db.rs @@ -469,7 +469,7 @@ pub trait Encrypter { impl Entry { pub fn encrypt_string(&self, s: &str, encrypter: &mut impl Encrypter) -> Result { - encrypter.encrypt_field(Some(&self), &s) + encrypter.encrypt_field(Some(self), s) } pub fn encrypt_optstring( @@ -477,11 +477,11 @@ impl Entry { optstring: &Option, encrypter: &mut impl Encrypter, ) -> Result> { - encrypter.encrypt_optfield(Some(&self), &optstring.as_deref()) + encrypter.encrypt_optfield(Some(self), &optstring.as_deref()) } pub fn decrypt_string(&self, s: &str, decrypter: &mut impl Decrypter) -> Result { - decrypter.decrypt_field(Some(&self), &s) + decrypter.decrypt_field(Some(self), s) } pub fn decrypt_optstring( @@ -489,7 +489,7 @@ impl Entry { optstring: &Option, decrypter: &mut impl Decrypter, ) -> Result> { - decrypter.decrypt_optfield(Some(&self), &optstring.as_deref()) + decrypter.decrypt_optfield(Some(self), &optstring.as_deref()) } } @@ -517,7 +517,7 @@ impl Entry { .iter() .map(|u| -> Result { Ok(Uri { - uri: decrypter.decrypt_field(Some(&self), &u.uri)?, + uri: decrypter.decrypt_field(Some(self), &u.uri)?, match_type: u.match_type, }) }) @@ -535,7 +535,7 @@ impl Entry { .map(|he| { Ok(HistoryEntry { last_used_date: he.last_used_date.clone(), - password: decrypter.decrypt_field(Some(&self), &he.password)?, + password: decrypter.decrypt_field(Some(self), &he.password)?, }) }) .collect::>() @@ -552,7 +552,7 @@ impl Entry { let history = self.decrypt_history(decrypter)?; - let mut df = |_ft, val: &Option| self.decrypt_optstring(&val, decrypter); + let mut df = |_ft, val: &Option| self.decrypt_optstring(val, decrypter); let data = match &self.data { EntryData::Login { @@ -647,7 +647,7 @@ impl Entry { folder_id: self.folder_id.clone(), org_id: self.org_id.clone(), key: None, - name: decrypter.decrypt_field(Some(&self), &self.name)?, + name: decrypter.decrypt_field(Some(self), &self.name)?, data, fields, notes, @@ -773,7 +773,7 @@ impl Display for Entry { if !matches!(self.data, EntryData::SecureNote) { if let Some(notes) = &self.notes { if d { - writeln!(f, "")?; + writeln!(f)?; } writeln!(f, "{notes}")?; } diff --git a/src/edit.rs b/src/edit.rs index b5b7fe81..1393134c 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -9,7 +9,7 @@ use std::{ fn contains_shell_metacharacters(cmd: &OsStr) -> bool { cmd.to_str() - .is_some_and(|s| s.contains(&[' ', '$', '\'', '"'])) + .is_some_and(|s| s.contains([' ', '$', '\'', '"'])) } fn get_editor_metachars(editor: &OsStr, file: &Path) -> (PathBuf, Vec) { From 946091082e444cc3b98307ef098d53bd3b45f9d3 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 26 May 2026 10:50:31 +0200 Subject: [PATCH 153/273] to_entry consumes the self instead of continuously clone its attributes --- src/api.rs | 114 ++++++++++++++++++++++++++--------------------------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/src/api.rs b/src/api.rs index 82c96f70..ec60d7e1 100644 --- a/src/api.rs +++ b/src/api.rs @@ -425,45 +425,46 @@ struct SyncResCipher { } impl SyncResCipher { - fn to_entry(&self, folders: &[SyncResFolder]) -> Option> { + fn to_entry(self, folders: &[SyncResFolder]) -> Option> { if self.deleted_date.is_some() { return None; } let history = self .password_history - .as_ref() + //.as_ref() .map_or_else(Vec::new, |history| { history - .iter() + .into_iter() .filter_map(|entry| { // Gets rid of entries with a non-existent // password - entry.password.clone().map(|p| crate::db::HistoryEntry { - last_used_date: entry.last_used_date.clone(), + entry.password.map(|p| crate::db::HistoryEntry { + last_used_date: entry.last_used_date, password: p, }) }) .collect() }); - let (folder, folder_id) = self.folder_id.as_ref().map_or((None, None), |folder_id| { + let (folder, folder_id) = self.folder_id.map_or((None, None), |folder_id| { let mut folder_name = None; for folder in folders { - if &folder.id == folder_id { + if folder.id == folder_id { folder_name = Some(folder.name.clone()); } } (folder_name, Some(folder_id)) }); - let data = if let Some(login) = &self.login { + + let data = if let Some(login) = self.login { crate::db::EntryData::Login { - username: login.username.clone(), - password: login.password.clone(), - totp: login.totp.clone(), - uris: login.uris.as_ref().map_or_else(Vec::new, |uris| { - uris.iter() + username: login.username, + password: login.password, + totp: login.totp, + uris: login.uris.map_or_else(Vec::new, |uris| { + uris.into_iter() .filter_map(|uri| { - uri.uri.clone().map(|s| crate::db::Uri { + uri.uri.map(|s| crate::db::Uri { uri: s, match_type: uri.match_type, }) @@ -471,68 +472,68 @@ impl SyncResCipher { .collect() }), } - } else if let Some(card) = &self.card { + } else if let Some(card) = self.card { crate::db::EntryData::Card { - cardholder_name: card.cardholder_name.clone(), - number: card.number.clone(), - brand: card.brand.clone(), - exp_month: card.exp_month.clone(), - exp_year: card.exp_year.clone(), - code: card.code.clone(), + cardholder_name: card.cardholder_name, + number: card.number, + brand: card.brand, + exp_month: card.exp_month, + exp_year: card.exp_year, + code: card.code, } - } else if let Some(identity) = &self.identity { + } else if let Some(identity) = self.identity { crate::db::EntryData::Identity { - title: identity.title.clone(), - first_name: identity.first_name.clone(), - middle_name: identity.middle_name.clone(), - last_name: identity.last_name.clone(), - address1: identity.address1.clone(), - address2: identity.address2.clone(), - address3: identity.address3.clone(), - city: identity.city.clone(), - state: identity.state.clone(), - postal_code: identity.postal_code.clone(), - country: identity.country.clone(), - phone: identity.phone.clone(), - email: identity.email.clone(), - ssn: identity.ssn.clone(), - license_number: identity.license_number.clone(), - passport_number: identity.passport_number.clone(), - username: identity.username.clone(), + title: identity.title, + first_name: identity.first_name, + middle_name: identity.middle_name, + last_name: identity.last_name, + address1: identity.address1, + address2: identity.address2, + address3: identity.address3, + city: identity.city, + state: identity.state, + postal_code: identity.postal_code, + country: identity.country, + phone: identity.phone, + email: identity.email, + ssn: identity.ssn, + license_number: identity.license_number, + passport_number: identity.passport_number, + username: identity.username, } - } else if let Some(_secure_note) = &self.secure_note { + } else if let Some(_secure_note) = self.secure_note { crate::db::EntryData::SecureNote - } else if let Some(ssh_key) = &self.ssh_key { + } else if let Some(ssh_key) = self.ssh_key { crate::db::EntryData::SshKey { - private_key: ssh_key.private_key.clone(), - public_key: ssh_key.public_key.clone(), - fingerprint: ssh_key.fingerprint.clone(), + private_key: ssh_key.private_key, + public_key: ssh_key.public_key, + fingerprint: ssh_key.fingerprint, } } else { return None; }; - let fields = self.fields.as_ref().map_or_else(Vec::new, |fields| { + let fields = self.fields.map_or_else(Vec::new, |fields| { fields - .iter() + .into_iter() .map(|field| crate::db::DynamicField { ty: field.ty, - name: field.name.clone(), - value: field.value.clone(), + name: field.name, + value: field.value, linked_id: field.linked_id, }) .collect() }); Some(crate::db::Entry:: { - id: self.id.clone(), - org_id: self.organization_id.clone(), + id: self.id, + org_id: self.organization_id, folder, - folder_id: folder_id.map(ToString::to_string), - name: self.name.clone(), + folder_id: folder_id, + name: self.name, data, fields, - notes: self.notes.clone(), + notes: self.notes, history, - key: self.key.clone(), + key: self.key, master_password_reprompt: self.reprompt, _state: std::marker::PhantomData, }) @@ -1261,11 +1262,10 @@ impl Client { match res.status() { reqwest::StatusCode::OK => { let sync_res: SyncRes = res.json_with_path().await?; - let folders = sync_res.folders.clone(); let ciphers = sync_res .ciphers - .iter() - .filter_map(|cipher| cipher.to_entry(&folders)) + .into_iter() + .filter_map(|cipher| cipher.to_entry(&sync_res.folders)) .collect(); let org_keys = sync_res .profile From 5db5e673ab24da16d89b38fa1726abd32fb31819 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 26 May 2026 11:07:26 +0200 Subject: [PATCH 154/273] group all status checking code under two methods --- src/api.rs | 138 ++++++++++++++++++++++++++--------------------------- 1 file changed, 67 insertions(+), 71 deletions(-) diff --git a/src/api.rs b/src/api.rs index ec60d7e1..730ea78a 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1249,6 +1249,26 @@ impl Client { Ok((sso_code, sso_code_verifier, callback_url)) } + fn async_check_status(res: reqwest::Response) -> Result { + match res.status() { + reqwest::StatusCode::OK => Ok(res), + reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), + _ => Err(Error::RequestFailed { + status: res.status().as_u16(), + }), + } + } + + fn check_status(res: reqwest::blocking::Response) -> Result { + match res.status() { + reqwest::StatusCode::OK => Ok(res), + reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), + _ => Err(Error::RequestFailed { + status: res.status().as_u16(), + }), + } + } + pub async fn sync( &self, access_token: &str, @@ -1259,32 +1279,29 @@ impl Client { Vec>, )> { let res = ClientRequest::Sync(access_token).req(self).await?; - match res.status() { - reqwest::StatusCode::OK => { - let sync_res: SyncRes = res.json_with_path().await?; - let ciphers = sync_res - .ciphers - .into_iter() - .filter_map(|cipher| cipher.to_entry(&sync_res.folders)) - .collect(); - let org_keys = sync_res - .profile - .organizations - .iter() - .map(|org| (org.id.clone(), org.key.clone())) - .collect(); - Ok(( - sync_res.profile.key, - sync_res.profile.private_key, - org_keys, - ciphers, - )) - } - reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), - _ => Err(Error::RequestFailed { - status: res.status().as_u16(), - }), - } + let res = Self::async_check_status(res)?; + + let sync_res: SyncRes = res.json_with_path().await?; + + let ciphers = sync_res + .ciphers + .into_iter() + .filter_map(|cipher| cipher.to_entry(&sync_res.folders)) + .collect(); + + let org_keys = sync_res + .profile + .organizations + .iter() + .map(|org| (org.id.clone(), org.key.clone())) + .collect(); + + Ok(( + sync_res.profile.key, + sync_res.profile.private_key, + org_keys, + ciphers, + )) } pub fn add( @@ -1304,13 +1321,9 @@ impl Client { let res = ClientBlockingRequest::Add(access_token, req).req(self)?; - match res.status() { - reqwest::StatusCode::OK => Ok(()), - reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), - _ => Err(Error::RequestFailed { - status: res.status().as_u16(), - }), - } + Self::check_status(res)?; + + Ok(()) } pub fn edit(&self, access_token: &str, entry: &crate::db::Entry) -> Result<()> { @@ -1342,56 +1355,39 @@ impl Client { let res = ClientBlockingRequest::Edit(access_token, &entry.id, req).req(self)?; - match res.status() { - reqwest::StatusCode::OK => Ok(()), - reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), - _ => Err(Error::RequestFailed { - status: res.status().as_u16(), - }), - } + Self::check_status(res)?; + + Ok(()) } pub fn remove(&self, access_token: &str, id: &str) -> Result<()> { let res = ClientBlockingRequest::Remove(access_token, id).req(self)?; - match res.status() { - reqwest::StatusCode::OK => Ok(()), - reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), - _ => Err(Error::RequestFailed { - status: res.status().as_u16(), - }), - } + + Self::check_status(res)?; + + Ok(()) } pub fn folders(&self, access_token: &str) -> Result> { let res = ClientBlockingRequest::Folders(access_token).req(self)?; - match res.status() { - reqwest::StatusCode::OK => { - let folders_res: FoldersRes = res.json_with_path()?; - Ok(folders_res - .data - .iter() - .map(|folder| (folder.id.clone(), folder.name.clone())) - .collect()) - } - reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), - _ => Err(Error::RequestFailed { - status: res.status().as_u16(), - }), - } + let res = Self::check_status(res)?; + + let folders_res: FoldersRes = res.json_with_path()?; + + Ok(folders_res + .data + .iter() + .map(|folder| (folder.id.clone(), folder.name.clone())) + .collect()) } pub fn create_folder(&self, access_token: &str, name: &str) -> Result { let res = ClientBlockingRequest::CreateFolder(access_token, name).req(self)?; - match res.status() { - reqwest::StatusCode::OK => { - let folders_res: FoldersResData = res.json_with_path()?; - Ok(folders_res.id) - } - reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), - _ => Err(Error::RequestFailed { - status: res.status().as_u16(), - }), - } + let res = Self::check_status(res)?; + + let folders_res: FoldersResData = res.json_with_path()?; + + Ok(folders_res.id) } pub fn exchange_refresh_token(&self, refresh_token: &str) -> Result { From 167bf58d6e15fc01c41648983da9d68a684122e4 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 26 May 2026 11:14:10 +0200 Subject: [PATCH 155/273] group status checking code even further --- src/api.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/api.rs b/src/api.rs index 730ea78a..51d88518 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1249,24 +1249,22 @@ impl Client { Ok((sso_code, sso_code_verifier, callback_url)) } - fn async_check_status(res: reqwest::Response) -> Result { - match res.status() { + fn match_status(status: reqwest::StatusCode, res: T) -> Result { + match status { reqwest::StatusCode::OK => Ok(res), reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), _ => Err(Error::RequestFailed { - status: res.status().as_u16(), + status: status.as_u16(), }), } } + fn async_check_status(res: reqwest::Response) -> Result { + Self::match_status(res.status(), res) + } + fn check_status(res: reqwest::blocking::Response) -> Result { - match res.status() { - reqwest::StatusCode::OK => Ok(res), - reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), - _ => Err(Error::RequestFailed { - status: res.status().as_u16(), - }), - } + Self::match_status(res.status(), res) } pub async fn sync( From e8ee4caa665124c4b28164682349f4444162da50 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 26 May 2026 11:16:06 +0200 Subject: [PATCH 156/273] improve status matching readability --- src/api.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/api.rs b/src/api.rs index 51d88518..db895f2c 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1249,9 +1249,9 @@ impl Client { Ok((sso_code, sso_code_verifier, callback_url)) } - fn match_status(status: reqwest::StatusCode, res: T) -> Result { + fn match_status(status: reqwest::StatusCode) -> Result<()> { match status { - reqwest::StatusCode::OK => Ok(res), + reqwest::StatusCode::OK => Ok(()), reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), _ => Err(Error::RequestFailed { status: status.as_u16(), @@ -1260,11 +1260,13 @@ impl Client { } fn async_check_status(res: reqwest::Response) -> Result { - Self::match_status(res.status(), res) + Self::match_status(res.status())?; + Ok(res) } fn check_status(res: reqwest::blocking::Response) -> Result { - Self::match_status(res.status(), res) + Self::match_status(res.status())?; + Ok(res) } pub async fn sync( From 87910f8491838cf749aaee374b15f654843a9b5c Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 26 May 2026 11:19:37 +0200 Subject: [PATCH 157/273] remove superfluous response checking code --- src/api.rs | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/api.rs b/src/api.rs index db895f2c..48d8b89b 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1249,7 +1249,7 @@ impl Client { Ok((sso_code, sso_code_verifier, callback_url)) } - fn match_status(status: reqwest::StatusCode) -> Result<()> { + fn ok_status(status: reqwest::StatusCode) -> Result<()> { match status { reqwest::StatusCode::OK => Ok(()), reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), @@ -1259,16 +1259,6 @@ impl Client { } } - fn async_check_status(res: reqwest::Response) -> Result { - Self::match_status(res.status())?; - Ok(res) - } - - fn check_status(res: reqwest::blocking::Response) -> Result { - Self::match_status(res.status())?; - Ok(res) - } - pub async fn sync( &self, access_token: &str, @@ -1279,7 +1269,8 @@ impl Client { Vec>, )> { let res = ClientRequest::Sync(access_token).req(self).await?; - let res = Self::async_check_status(res)?; + + Self::ok_status(res.status())?; let sync_res: SyncRes = res.json_with_path().await?; @@ -1321,7 +1312,7 @@ impl Client { let res = ClientBlockingRequest::Add(access_token, req).req(self)?; - Self::check_status(res)?; + Self::ok_status(res.status())?; Ok(()) } @@ -1355,7 +1346,7 @@ impl Client { let res = ClientBlockingRequest::Edit(access_token, &entry.id, req).req(self)?; - Self::check_status(res)?; + Self::ok_status(res.status())?; Ok(()) } @@ -1363,14 +1354,15 @@ impl Client { pub fn remove(&self, access_token: &str, id: &str) -> Result<()> { let res = ClientBlockingRequest::Remove(access_token, id).req(self)?; - Self::check_status(res)?; + Self::ok_status(res.status())?; Ok(()) } pub fn folders(&self, access_token: &str) -> Result> { let res = ClientBlockingRequest::Folders(access_token).req(self)?; - let res = Self::check_status(res)?; + + Self::ok_status(res.status())?; let folders_res: FoldersRes = res.json_with_path()?; @@ -1383,7 +1375,8 @@ impl Client { pub fn create_folder(&self, access_token: &str, name: &str) -> Result { let res = ClientBlockingRequest::CreateFolder(access_token, name).req(self)?; - let res = Self::check_status(res)?; + + Self::ok_status(res.status())?; let folders_res: FoldersResData = res.json_with_path()?; From 6e559d1fa13f721b959f54cd4a1abe700b260506 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 26 May 2026 14:13:21 +0200 Subject: [PATCH 158/273] use error_for_status instead of custom method and create a From for Error NOTE: this is not 100% backwards compatible behavior as previously only a 200 response would have been considered Ok, now the evaluation is demanded to reqwest (200-299). --- src/api.rs | 51 +++++++++++++++++++++------------------------------ src/error.rs | 14 ++++++++++++++ src/json.rs | 7 ++----- 3 files changed, 37 insertions(+), 35 deletions(-) diff --git a/src/api.rs b/src/api.rs index 48d8b89b..911340c4 100644 --- a/src/api.rs +++ b/src/api.rs @@ -937,7 +937,7 @@ impl<'a> ClientRequest<'a> { ]), }; - rb.send().await.map_err(|source| Error::Reqwest { source }) + Ok(rb.send().await?) } } @@ -982,7 +982,7 @@ impl<'a> ClientBlockingRequest<'a> { ]), }; - rb.send().map_err(|source| Error::Reqwest { source }) + Ok(rb.send()?) } } @@ -1249,16 +1249,6 @@ impl Client { Ok((sso_code, sso_code_verifier, callback_url)) } - fn ok_status(status: reqwest::StatusCode) -> Result<()> { - match status { - reqwest::StatusCode::OK => Ok(()), - reqwest::StatusCode::UNAUTHORIZED => Err(Error::RequestUnauthorized), - _ => Err(Error::RequestFailed { - status: status.as_u16(), - }), - } - } - pub async fn sync( &self, access_token: &str, @@ -1268,9 +1258,10 @@ impl Client { HashMap, Vec>, )> { - let res = ClientRequest::Sync(access_token).req(self).await?; - - Self::ok_status(res.status())?; + let res = ClientRequest::Sync(access_token) + .req(self) + .await? + .error_for_status()?; let sync_res: SyncRes = res.json_with_path().await?; @@ -1310,9 +1301,9 @@ impl Client { data: EntryDataWire(data), }; - let res = ClientBlockingRequest::Add(access_token, req).req(self)?; - - Self::ok_status(res.status())?; + ClientBlockingRequest::Add(access_token, req) + .req(self)? + .error_for_status()?; Ok(()) } @@ -1344,25 +1335,25 @@ impl Client { .collect(), }; - let res = ClientBlockingRequest::Edit(access_token, &entry.id, req).req(self)?; - - Self::ok_status(res.status())?; + ClientBlockingRequest::Edit(access_token, &entry.id, req) + .req(self)? + .error_for_status()?; Ok(()) } pub fn remove(&self, access_token: &str, id: &str) -> Result<()> { - let res = ClientBlockingRequest::Remove(access_token, id).req(self)?; - - Self::ok_status(res.status())?; + ClientBlockingRequest::Remove(access_token, id) + .req(self)? + .error_for_status()?; Ok(()) } pub fn folders(&self, access_token: &str) -> Result> { - let res = ClientBlockingRequest::Folders(access_token).req(self)?; - - Self::ok_status(res.status())?; + let res = ClientBlockingRequest::Folders(access_token) + .req(self)? + .error_for_status()?; let folders_res: FoldersRes = res.json_with_path()?; @@ -1374,9 +1365,9 @@ impl Client { } pub fn create_folder(&self, access_token: &str, name: &str) -> Result { - let res = ClientBlockingRequest::CreateFolder(access_token, name).req(self)?; - - Self::ok_status(res.status())?; + let res = ClientBlockingRequest::CreateFolder(access_token, name) + .req(self)? + .error_for_status()?; let folders_res: FoldersResData = res.json_with_path()?; diff --git a/src/error.rs b/src/error.rs index 8d5628a6..c70e3e9e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -263,4 +263,18 @@ impl From for Error { } } +impl From for Error { + fn from(err: reqwest::Error) -> Self { + match err.status() { + Some(status) => match status { + reqwest::StatusCode::UNAUTHORIZED => Self::RequestUnauthorized, + _ => Self::RequestFailed { + status: status.as_u16(), + }, + }, + None => Self::Reqwest { source: err }, + } + } +} + pub type Result = std::result::Result; diff --git a/src/json.rs b/src/json.rs index 98d3f203..453d40d4 100644 --- a/src/json.rs +++ b/src/json.rs @@ -13,7 +13,7 @@ impl DeserializeJsonWithPath for String { impl DeserializeJsonWithPath for reqwest::blocking::Response { fn json_with_path(self) -> Result { - let bytes = self.bytes().map_err(|source| Error::Reqwest { source })?; + let bytes = self.bytes()?; let jd = &mut serde_json::Deserializer::from_slice(&bytes); serde_path_to_error::deserialize(jd).map_err(|source| Error::Json { source }) } @@ -26,10 +26,7 @@ pub trait DeserializeJsonWithPathAsync { impl DeserializeJsonWithPathAsync for reqwest::Response { async fn json_with_path(self) -> Result { - let bytes = self - .bytes() - .await - .map_err(|source| Error::Reqwest { source })?; + let bytes = self.bytes().await?; let jd = &mut serde_json::Deserializer::from_slice(&bytes); serde_path_to_error::deserialize(jd).map_err(|source| Error::Json { source }) } From f99b7584618a22c1d51a6eaa99769fe85c8b2091 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 26 May 2026 15:32:59 +0200 Subject: [PATCH 159/273] extract connect error res checking into a separate fn --- src/api.rs | 93 +++++++++++++++++++++++++++--------------------------- 1 file changed, 46 insertions(+), 47 deletions(-) diff --git a/src/api.rs b/src/api.rs index 911340c4..04bf679e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1066,6 +1066,37 @@ impl Client { }) } + async fn check_connect_token_res(res: reqwest::Response) -> Result { + match res.status() { + reqwest::StatusCode::OK => Ok(res), + status => match res.text().await { + Ok(body) => match body.clone().json_with_path::() { + Ok(err) => match err.try_into() { + Ok(e) => Err(e), + Err(err) => { + log::warn!("unexpected error received during login: {err:?}"); + Err(Error::RequestFailed { + status: status.as_u16(), + }) + } + }, + Err(e) => { + log::warn!("{e}: {body}"); + Err(Error::RequestFailed { + status: status.as_u16(), + }) + } + }, + Err(e) => { + log::warn!("failed to read response body: {e}"); + Err(Error::RequestFailed { + status: status.as_u16(), + }) + } + }, + } + } + pub async fn register( &self, email: &str, @@ -1088,28 +1119,12 @@ impl Client { two_factor_token: None, two_factor_provider: None, }; + let res = ClientRequest::ConnectToken(connect_req).req(self).await?; - if res.status() == reqwest::StatusCode::OK { - Ok(()) - } else { - let code = res.status().as_u16(); - match res.text().await { - Ok(body) => match body.clone().json_with_path::() { - Ok(err) => Err(err.try_into().unwrap_or_else(|err| { - log::warn!("unexpected error received during login: {err:?}"); - Error::RequestFailed { status: code } - })), - Err(e) => { - log::warn!("{e}: {body}"); - Err(Error::RequestFailed { status: code }) - } - }, - Err(e) => { - log::warn!("failed to read response body: {e}"); - Err(Error::RequestFailed { status: code }) - } - } - } + + Self::check_connect_token_res(res).await?; + + Ok(()) } pub async fn login( @@ -1159,32 +1174,16 @@ impl Client { }; let res = ClientRequest::Login(connect_req, email).req(self).await?; - if res.status() == reqwest::StatusCode::OK { - let connect_res: ConnectTokenRes = res.json_with_path().await?; - Ok(( - connect_res.access_token, - connect_res.refresh_token, - connect_res.key, - )) - } else { - let code = res.status().as_u16(); - match res.text().await { - Ok(body) => match body.clone().json_with_path::() { - Ok(err) => Err(err.try_into().unwrap_or_else(|err| { - log::warn!("unexpected error received during login: {err:?}"); - Error::RequestFailed { status: code } - })), - Err(e) => { - log::warn!("{e}: {body}"); - Err(Error::RequestFailed { status: code }) - } - }, - Err(e) => { - log::warn!("failed to read response body: {e}"); - Err(Error::RequestFailed { status: code }) - } - } - } + + let res = Self::check_connect_token_res(res).await?; + + let connect_res: ConnectTokenRes = res.json_with_path().await?; + + Ok(( + connect_res.access_token, + connect_res.refresh_token, + connect_res.key, + )) } pub async fn send_email_login( From e1f5d4d86056c147efd8db4666d7ace37cc71b40 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 26 May 2026 15:56:41 +0200 Subject: [PATCH 160/273] make ConnectTokenReq have references instead of owned strings --- src/api.rs | 78 +++++++++++++++++++++++++++--------------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/src/api.rs b/src/api.rs index 04bf679e..187f39ca 100644 --- a/src/api.rs +++ b/src/api.rs @@ -255,41 +255,41 @@ struct PreloginRes { } #[derive(Serialize, Debug)] -struct ConnectTokenReq { - grant_type: String, - scope: String, - client_id: String, +struct ConnectTokenReq<'a> { + grant_type: &'a str, + scope: &'a str, + client_id: &'a str, #[serde(rename = "deviceType")] device_type: u32, #[serde(rename = "deviceIdentifier")] - device_identifier: String, + device_identifier: &'a str, #[serde(rename = "deviceName")] - device_name: String, + device_name: &'a str, #[serde(rename = "devicePushToken")] - device_push_token: String, + device_push_token: &'a str, #[serde(rename = "twoFactorToken")] - two_factor_token: Option, + two_factor_token: Option<&'a str>, #[serde(rename = "twoFactorProvider")] two_factor_provider: Option, #[serde(flatten)] - auth: ConnectTokenAuth, + auth: ConnectTokenAuth<'a>, } #[derive(Serialize, Debug)] #[serde(untagged)] -enum ConnectTokenAuth { +enum ConnectTokenAuth<'a> { Password { - username: String, - password: String, + username: &'a str, + password: &'a str, }, AuthCode { - code: String, - code_verifier: String, - redirect_uri: String, + code: &'a str, + code_verifier: &'a str, + redirect_uri: &'a str, }, ClientCredentials { - username: String, - client_secret: String, + username: &'a str, + client_secret: &'a str, }, } @@ -891,8 +891,8 @@ const DEVICE_TYPE: u8 = 8; enum ClientRequest<'a> { Prelogin(&'a str), - ConnectToken(ConnectTokenReq), - Login(ConnectTokenReq, &'a str), + ConnectToken(ConnectTokenReq<'a>), + Login(ConnectTokenReq<'a>, &'a str), SendEmailLogin(&'a str, &'a str, &'a str), Sync(&'a str), ExchangeRefreshToken(&'a str), @@ -1105,17 +1105,17 @@ impl Client { ) -> Result<()> { let connect_req = ConnectTokenReq { auth: ConnectTokenAuth::ClientCredentials { - username: email.to_string(), - client_secret: String::from_utf8(apikey.client_secret().to_vec()).unwrap(), + username: &email, + client_secret: &String::from_utf8(apikey.client_secret().to_vec()).unwrap(), }, - grant_type: "client_credentials".to_string(), - scope: "api".to_string(), + grant_type: "client_credentials", + scope: "api", // XXX unwraps here are not necessarily safe - client_id: String::from_utf8(apikey.client_id().to_vec()).unwrap(), + client_id: &String::from_utf8(apikey.client_id().to_vec()).unwrap(), device_type: u32::from(DEVICE_TYPE), - device_identifier: device_id.to_string(), - device_name: "rbw".to_string(), - device_push_token: String::new(), + device_identifier: device_id, + device_name: "rbw", + device_push_token: "", two_factor_token: None, two_factor_provider: None, }; @@ -1142,9 +1142,9 @@ impl Client { self.obtain_sso_code(sso_id).await?; ( ConnectTokenAuth::AuthCode { - code: sso_code, - code_verifier: sso_code_verifier, - redirect_uri: callback_url, + code: &sso_code.clone(), + code_verifier: &sso_code_verifier.clone(), + redirect_uri: &callback_url.clone(), }, "authorization_code", "api offline_access", @@ -1152,8 +1152,8 @@ impl Client { } None => ( ConnectTokenAuth::Password { - username: email.to_string(), - password: crate::base64::encode(password_hash.hash()), + username: email, + password: &crate::base64::encode(password_hash.hash()), }, "password", "api offline_access", @@ -1162,14 +1162,14 @@ impl Client { let connect_req = ConnectTokenReq { auth, - grant_type: grant_type.to_string(), - scope: scope.to_string(), - client_id: "cli".to_string(), + grant_type: grant_type, + scope: scope, + client_id: "cli", device_type: u32::from(DEVICE_TYPE), - device_identifier: device_id.to_string(), - device_name: "rbw".to_string(), - device_push_token: String::new(), - two_factor_token: two_factor_token.map(ToString::to_string), + device_identifier: device_id, + device_name: "rbw", + device_push_token: "", + two_factor_token: two_factor_token, two_factor_provider: two_factor_provider.map(|ty| ty as u32), }; From f17d7189dbff05d1f9dfaf4d0215690925d34de0 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 26 May 2026 16:52:14 +0200 Subject: [PATCH 161/273] merge Visitors into one impl --- src/api.rs | 73 +++++++++++++++++++----------------------------------- 1 file changed, 25 insertions(+), 48 deletions(-) diff --git a/src/api.rs b/src/api.rs index 187f39ca..0598b3fe 100644 --- a/src/api.rs +++ b/src/api.rs @@ -48,6 +48,29 @@ impl Display for UriMatchType { } } +struct IntegerStringVisitor(std::marker::PhantomData); + +impl serde::de::Visitor<'_> for IntegerStringVisitor +where + T: TryFrom + FromStr, + >::Error: Display, + ::Err: Display, +{ + type Value = T; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("integer or string") + } + + fn visit_u64(self, v: u64) -> std::result::Result { + T::try_from(v).map_err(serde::de::Error::custom) + } + + fn visit_str(self, v: &str) -> std::result::Result { + v.parse().map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum TwoFactorProviderType { Authenticator = 0, @@ -91,30 +114,7 @@ impl<'de> Deserialize<'de> for TwoFactorProviderType { where D: serde::Deserializer<'de>, { - struct TwoFactorProviderTypeVisitor; - impl serde::de::Visitor<'_> for TwoFactorProviderTypeVisitor { - type Value = TwoFactorProviderType; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("two factor provider id") - } - - fn visit_str(self, value: &str) -> std::result::Result - where - E: serde::de::Error, - { - value.parse().map_err(serde::de::Error::custom) - } - - fn visit_u64(self, value: u64) -> std::result::Result - where - E: serde::de::Error, - { - std::convert::TryFrom::try_from(value).map_err(serde::de::Error::custom) - } - } - - deserializer.deserialize_any(TwoFactorProviderTypeVisitor) + deserializer.deserialize_any(IntegerStringVisitor(std::marker::PhantomData)) } } @@ -167,30 +167,7 @@ impl<'de> Deserialize<'de> for KdfType { where D: serde::Deserializer<'de>, { - struct KdfTypeVisitor; - impl serde::de::Visitor<'_> for KdfTypeVisitor { - type Value = KdfType; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("kdf id") - } - - fn visit_str(self, value: &str) -> std::result::Result - where - E: serde::de::Error, - { - value.parse().map_err(serde::de::Error::custom) - } - - fn visit_u64(self, value: u64) -> std::result::Result - where - E: serde::de::Error, - { - std::convert::TryFrom::try_from(value).map_err(serde::de::Error::custom) - } - } - - deserializer.deserialize_any(KdfTypeVisitor) + deserializer.deserialize_any(IntegerStringVisitor(std::marker::PhantomData)) } } From 85789a28e1b9e97cca0fd45fca74be3c54c56ed0 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 26 May 2026 22:46:57 +0200 Subject: [PATCH 162/273] move some structs around --- src/api.rs | 54 +++++++++++++++++++++++++++--------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/api.rs b/src/api.rs index 0598b3fe..ffea00a9 100644 --- a/src/api.rs +++ b/src/api.rs @@ -231,6 +231,24 @@ struct PreloginRes { kdf_parallelism: Option, } +#[derive(Serialize, Debug)] +#[serde(untagged)] +enum ConnectTokenAuth<'a> { + Password { + username: &'a str, + password: &'a str, + }, + AuthCode { + code: &'a str, + code_verifier: &'a str, + redirect_uri: &'a str, + }, + ClientCredentials { + username: &'a str, + client_secret: &'a str, + }, +} + #[derive(Serialize, Debug)] struct ConnectTokenReq<'a> { grant_type: &'a str, @@ -252,24 +270,6 @@ struct ConnectTokenReq<'a> { auth: ConnectTokenAuth<'a>, } -#[derive(Serialize, Debug)] -#[serde(untagged)] -enum ConnectTokenAuth<'a> { - Password { - username: &'a str, - password: &'a str, - }, - AuthCode { - code: &'a str, - code_verifier: &'a str, - redirect_uri: &'a str, - }, - ClientCredentials { - username: &'a str, - client_secret: &'a str, - }, -} - #[derive(Deserialize, Debug)] struct ConnectTokenRes { access_token: String, @@ -392,7 +392,7 @@ struct SyncResCipher { #[serde(rename = "PasswordHistory", alias = "passwordHistory")] password_history: Option>, #[serde(rename = "Fields", alias = "fields")] - fields: Option>, + fields: Option>, #[serde(rename = "DeletedDate", alias = "deletedDate")] deleted_date: Option, #[serde(rename = "Key", alias = "key")] @@ -627,6 +627,11 @@ struct CipherSshKey { fingerprint: Option, } +// this is just a name and some notes, both of which are already on the cipher +// object +#[derive(Serialize, Deserialize, Debug, Clone)] +struct CipherSecureNote {} + #[derive( serde_repr::Serialize_repr, serde_repr::Deserialize_repr, Debug, Clone, Copy, PartialEq, Eq, )] @@ -673,7 +678,7 @@ pub enum LinkedIdType { } #[derive(Serialize, Deserialize, Debug, Clone)] -struct CipherField { +struct CipherDynamicField { #[serde(rename = "Type", alias = "type")] ty: Option, #[serde(rename = "Name", alias = "name")] @@ -684,11 +689,6 @@ struct CipherField { linked_id: Option, } -// this is just a name and some notes, both of which are already on the cipher -// object -#[derive(Serialize, Deserialize, Debug, Clone)] -struct CipherSecureNote {} - #[derive(Serialize, Deserialize, Debug, Clone)] struct SyncResPasswordHistory { #[serde(rename = "LastUsedDate", alias = "lastUsedDate")] @@ -717,7 +717,7 @@ struct CiphersPutReq<'a> { notes: Option, #[serde(flatten)] data: EntryDataWire<'a>, - fields: Vec, + fields: Vec, #[serde(rename = "passwordHistory")] password_history: Vec, } @@ -1294,7 +1294,7 @@ impl Client { fields: entry .fields .iter() - .map(|field| CipherField { + .map(|field| CipherDynamicField { ty: field.ty, name: field.name.clone(), value: field.value.clone(), From 1870f9b9b48892f705935bc83c5bad8801cb6573 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 26 May 2026 23:09:11 +0200 Subject: [PATCH 163/273] using From and TryFrom traits for EntryData <-> CipherLogin conversion I am aware that this is ugly, as there is one more .clone() and one .unwrap(), but this is temporary --- src/api.rs | 107 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 63 insertions(+), 44 deletions(-) diff --git a/src/api.rs b/src/api.rs index ffea00a9..6cc6452d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -434,21 +434,7 @@ impl SyncResCipher { }); let data = if let Some(login) = self.login { - crate::db::EntryData::Login { - username: login.username, - password: login.password, - totp: login.totp, - uris: login.uris.map_or_else(Vec::new, |uris| { - uris.into_iter() - .filter_map(|uri| { - uri.uri.map(|s| crate::db::Uri { - uri: s, - match_type: uri.match_type, - }) - }) - .collect() - }), - } + login.into() } else if let Some(card) = self.card { crate::db::EntryData::Card { cardholder_name: card.cardholder_name, @@ -543,6 +529,14 @@ struct SyncResFolder { name: String, } +#[derive(Serialize, Deserialize, Debug, Clone)] +struct CipherLoginUri { + #[serde(rename = "Uri", alias = "uri")] + uri: Option, + #[serde(rename = "Match", alias = "match")] + match_type: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone)] struct CipherLogin { #[serde(rename = "Username", alias = "username")] @@ -555,12 +549,58 @@ struct CipherLogin { uris: Option>, } -#[derive(Serialize, Deserialize, Debug, Clone)] -struct CipherLoginUri { - #[serde(rename = "Uri", alias = "uri")] - uri: Option, - #[serde(rename = "Match", alias = "match")] - match_type: Option, +impl From for crate::db::EntryData { + fn from(value: CipherLogin) -> Self { + Self::Login { + username: value.username, + password: value.password, + totp: value.totp, + uris: value.uris.map_or_else(Vec::new, |uris| { + uris.into_iter() + .filter_map(|uri| { + uri.uri.map(|s| crate::db::Uri { + uri: s, + match_type: uri.match_type, + }) + }) + .collect() + }), + } + } +} + +impl TryFrom for CipherLogin { + type Error = (); + + fn try_from(value: crate::db::EntryData) -> std::result::Result { + let crate::db::EntryData::Login { + username, + password, + totp, + uris, + } = value + else { + return Err(()); + }; + + Ok(CipherLogin { + username, + password, + totp, + uris: if uris.is_empty() { + None + } else { + Some( + uris.iter() + .map(|s| CipherLoginUri { + uri: Some(s.uri.clone()), + match_type: s.match_type, + }) + .collect(), + ) + }, + }) + } } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -741,32 +781,11 @@ impl Serialize for EntryDataWire<'_> { use serde::ser::SerializeMap; let mut map = serializer.serialize_map(None)?; match self.0.clone() { - crate::db::EntryData::Login { - username, - password, - totp, - uris, - } => { + crate::db::EntryData::Login { .. } => { map.serialize_entry("type", &1u32)?; map.serialize_entry( "login", - &CipherLogin { - username, - password, - totp, - uris: if uris.is_empty() { - None - } else { - Some( - uris.iter() - .map(|s| CipherLoginUri { - uri: Some(s.uri.clone()), - match_type: s.match_type, - }) - .collect(), - ) - }, - }, + &TryInto::::try_into(self.0.clone()).unwrap(), )?; } crate::db::EntryData::Card { From 7eff480d79ea7b55109adb29f4790a79aa87a91c Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 26 May 2026 23:30:07 +0200 Subject: [PATCH 164/273] implement ugly TryFrom and From for other EntryData variants --- src/api.rs | 266 +++++++++++++++++++++++++++++++++++------------------ 1 file changed, 177 insertions(+), 89 deletions(-) diff --git a/src/api.rs b/src/api.rs index 6cc6452d..08bf5f91 100644 --- a/src/api.rs +++ b/src/api.rs @@ -436,45 +436,17 @@ impl SyncResCipher { let data = if let Some(login) = self.login { login.into() } else if let Some(card) = self.card { - crate::db::EntryData::Card { - cardholder_name: card.cardholder_name, - number: card.number, - brand: card.brand, - exp_month: card.exp_month, - exp_year: card.exp_year, - code: card.code, - } + card.into() } else if let Some(identity) = self.identity { - crate::db::EntryData::Identity { - title: identity.title, - first_name: identity.first_name, - middle_name: identity.middle_name, - last_name: identity.last_name, - address1: identity.address1, - address2: identity.address2, - address3: identity.address3, - city: identity.city, - state: identity.state, - postal_code: identity.postal_code, - country: identity.country, - phone: identity.phone, - email: identity.email, - ssn: identity.ssn, - license_number: identity.license_number, - passport_number: identity.passport_number, - username: identity.username, - } - } else if let Some(_secure_note) = self.secure_note { - crate::db::EntryData::SecureNote + identity.into() + } else if let Some(secure_note) = self.secure_note { + secure_note.into() } else if let Some(ssh_key) = self.ssh_key { - crate::db::EntryData::SshKey { - private_key: ssh_key.private_key, - public_key: ssh_key.public_key, - fingerprint: ssh_key.fingerprint, - } + ssh_key.into() } else { return None; }; + let fields = self.fields.map_or_else(Vec::new, |fields| { fields .into_iter() @@ -619,6 +591,46 @@ struct CipherCard { code: Option, } +impl From for crate::db::EntryData { + fn from(value: CipherCard) -> Self { + Self::Card { + cardholder_name: value.cardholder_name, + number: value.number, + brand: value.brand, + exp_month: value.exp_month, + exp_year: value.exp_year, + code: value.code, + } + } +} + +impl TryFrom for CipherCard { + type Error = (); + + fn try_from(value: crate::db::EntryData) -> std::result::Result { + let crate::db::EntryData::Card { + cardholder_name, + number, + brand, + exp_month, + exp_year, + code, + } = value + else { + return Err(()); + }; + + Ok(Self { + cardholder_name, + number, + brand, + exp_month, + exp_year, + code, + }) + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] struct CipherIdentity { #[serde(rename = "Title", alias = "title")] @@ -657,6 +669,79 @@ struct CipherIdentity { username: Option, } +impl From for crate::db::EntryData { + fn from(value: CipherIdentity) -> Self { + Self::Identity { + title: value.title, + first_name: value.first_name, + middle_name: value.middle_name, + last_name: value.last_name, + address1: value.address1, + address2: value.address2, + address3: value.address3, + city: value.city, + state: value.state, + postal_code: value.postal_code, + country: value.country, + phone: value.phone, + email: value.email, + ssn: value.ssn, + license_number: value.license_number, + passport_number: value.passport_number, + username: value.username, + } + } +} + +impl TryFrom for CipherIdentity { + type Error = (); + + fn try_from(value: crate::db::EntryData) -> std::result::Result { + let crate::db::EntryData::Identity { + title, + first_name, + middle_name, + last_name, + address1, + address2, + address3, + city, + state, + postal_code, + country, + phone, + email, + ssn, + license_number, + passport_number, + username, + } = value + else { + return Err(()); + }; + + Ok(Self { + title, + first_name, + middle_name, + last_name, + address1, + address2, + address3, + city, + state, + postal_code, + country, + phone, + email, + ssn, + license_number, + passport_number, + username, + }) + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] struct CipherSshKey { #[serde(rename = "PrivateKey", alias = "privateKey")] @@ -667,11 +752,60 @@ struct CipherSshKey { fingerprint: Option, } +impl From for crate::db::EntryData { + fn from(value: CipherSshKey) -> Self { + Self::SshKey { + private_key: value.private_key, + public_key: value.public_key, + fingerprint: value.fingerprint, + } + } +} + +impl TryFrom for CipherSshKey { + type Error = (); + + fn try_from(value: crate::db::EntryData) -> std::result::Result { + let crate::db::EntryData::SshKey { + private_key, + public_key, + fingerprint, + } = value + else { + return Err(()); + }; + + Ok(Self { + private_key, + public_key, + fingerprint, + }) + } +} + // this is just a name and some notes, both of which are already on the cipher // object #[derive(Serialize, Deserialize, Debug, Clone)] struct CipherSecureNote {} +impl From for crate::db::EntryData { + fn from(_value: CipherSecureNote) -> Self { + Self::SecureNote + } +} + +impl TryFrom for CipherSecureNote { + type Error = (); + + fn try_from(value: crate::db::EntryData) -> std::result::Result { + let crate::db::EntryData::SecureNote = value else { + return Err(()); + }; + + Ok(Self {}) + } +} + #[derive( serde_repr::Serialize_repr, serde_repr::Deserialize_repr, Debug, Clone, Copy, PartialEq, Eq, )] @@ -788,75 +922,29 @@ impl Serialize for EntryDataWire<'_> { &TryInto::::try_into(self.0.clone()).unwrap(), )?; } - crate::db::EntryData::Card { - cardholder_name, - number, - brand, - exp_month, - exp_year, - code, - } => { + crate::db::EntryData::Card { .. } => { map.serialize_entry("type", &3u32)?; map.serialize_entry( "card", - &CipherCard { - cardholder_name, - number, - brand, - exp_month, - exp_year, - code, - }, + &TryInto::::try_into(self.0.clone()).unwrap(), )?; } - crate::db::EntryData::Identity { - title, - first_name, - middle_name, - last_name, - address1, - address2, - address3, - city, - state, - postal_code, - country, - phone, - email, - ssn, - license_number, - passport_number, - username, - } => { + crate::db::EntryData::Identity { .. } => { map.serialize_entry("type", &4u32)?; map.serialize_entry( "identity", - &CipherIdentity { - title, - first_name, - middle_name, - last_name, - address1, - address2, - address3, - city, - state, - postal_code, - country, - phone, - email, - ssn, - license_number, - passport_number, - username, - }, + &TryInto::::try_into(self.0.clone()).unwrap(), )?; } crate::db::EntryData::SecureNote => { map.serialize_entry("type", &2u32)?; - map.serialize_entry("secureNote", &CipherSecureNote {})?; + map.serialize_entry( + "secureNote", + &TryInto::::try_into(self.0.clone()).unwrap(), + )?; } crate::db::EntryData::SshKey { .. } => { + // TODO: Not entirely true now return Err(serde::ser::Error::custom("SshKey not supported")); } } From 342a352156458d92bd9bc73be6e991e29661091c Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 26 May 2026 23:32:09 +0200 Subject: [PATCH 165/273] remove crate::db prefix for EntryData --- src/api.rs | 56 +++++++++++++++++++++++++++--------------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/api.rs b/src/api.rs index 08bf5f91..45f4f633 100644 --- a/src/api.rs +++ b/src/api.rs @@ -10,7 +10,7 @@ use std::{ sync::Arc, }; -use crate::{actions::CryptoParameters, db::Encrypted, prelude::*}; +use crate::{actions::CryptoParameters, db::{Encrypted, EntryData}, prelude::*}; use rand::distr::SampleString as _; use serde::{Deserialize, Serialize}; @@ -521,7 +521,7 @@ struct CipherLogin { uris: Option>, } -impl From for crate::db::EntryData { +impl From for EntryData { fn from(value: CipherLogin) -> Self { Self::Login { username: value.username, @@ -541,11 +541,11 @@ impl From for crate::db::EntryData { } } -impl TryFrom for CipherLogin { +impl TryFrom for CipherLogin { type Error = (); - fn try_from(value: crate::db::EntryData) -> std::result::Result { - let crate::db::EntryData::Login { + fn try_from(value: EntryData) -> std::result::Result { + let EntryData::Login { username, password, totp, @@ -591,7 +591,7 @@ struct CipherCard { code: Option, } -impl From for crate::db::EntryData { +impl From for EntryData { fn from(value: CipherCard) -> Self { Self::Card { cardholder_name: value.cardholder_name, @@ -604,11 +604,11 @@ impl From for crate::db::EntryData { } } -impl TryFrom for CipherCard { +impl TryFrom for CipherCard { type Error = (); - fn try_from(value: crate::db::EntryData) -> std::result::Result { - let crate::db::EntryData::Card { + fn try_from(value: EntryData) -> std::result::Result { + let EntryData::Card { cardholder_name, number, brand, @@ -669,7 +669,7 @@ struct CipherIdentity { username: Option, } -impl From for crate::db::EntryData { +impl From for EntryData { fn from(value: CipherIdentity) -> Self { Self::Identity { title: value.title, @@ -693,11 +693,11 @@ impl From for crate::db::EntryData { } } -impl TryFrom for CipherIdentity { +impl TryFrom for CipherIdentity { type Error = (); - fn try_from(value: crate::db::EntryData) -> std::result::Result { - let crate::db::EntryData::Identity { + fn try_from(value: EntryData) -> std::result::Result { + let EntryData::Identity { title, first_name, middle_name, @@ -752,7 +752,7 @@ struct CipherSshKey { fingerprint: Option, } -impl From for crate::db::EntryData { +impl From for EntryData { fn from(value: CipherSshKey) -> Self { Self::SshKey { private_key: value.private_key, @@ -762,11 +762,11 @@ impl From for crate::db::EntryData { } } -impl TryFrom for CipherSshKey { +impl TryFrom for CipherSshKey { type Error = (); - fn try_from(value: crate::db::EntryData) -> std::result::Result { - let crate::db::EntryData::SshKey { + fn try_from(value: EntryData) -> std::result::Result { + let EntryData::SshKey { private_key, public_key, fingerprint, @@ -788,17 +788,17 @@ impl TryFrom for CipherSshKey { #[derive(Serialize, Deserialize, Debug, Clone)] struct CipherSecureNote {} -impl From for crate::db::EntryData { +impl From for EntryData { fn from(_value: CipherSecureNote) -> Self { Self::SecureNote } } -impl TryFrom for CipherSecureNote { +impl TryFrom for CipherSecureNote { type Error = (); - fn try_from(value: crate::db::EntryData) -> std::result::Result { - let crate::db::EntryData::SecureNote = value else { + fn try_from(value: EntryData) -> std::result::Result { + let EntryData::SecureNote = value else { return Err(()); }; @@ -905,7 +905,7 @@ struct CiphersPutReqHistory { } #[derive(Debug)] -struct EntryDataWire<'a>(&'a crate::db::EntryData); +struct EntryDataWire<'a>(&'a EntryData); impl Serialize for EntryDataWire<'_> { fn serialize( @@ -915,35 +915,35 @@ impl Serialize for EntryDataWire<'_> { use serde::ser::SerializeMap; let mut map = serializer.serialize_map(None)?; match self.0.clone() { - crate::db::EntryData::Login { .. } => { + EntryData::Login { .. } => { map.serialize_entry("type", &1u32)?; map.serialize_entry( "login", &TryInto::::try_into(self.0.clone()).unwrap(), )?; } - crate::db::EntryData::Card { .. } => { + EntryData::Card { .. } => { map.serialize_entry("type", &3u32)?; map.serialize_entry( "card", &TryInto::::try_into(self.0.clone()).unwrap(), )?; } - crate::db::EntryData::Identity { .. } => { + EntryData::Identity { .. } => { map.serialize_entry("type", &4u32)?; map.serialize_entry( "identity", &TryInto::::try_into(self.0.clone()).unwrap(), )?; } - crate::db::EntryData::SecureNote => { + EntryData::SecureNote => { map.serialize_entry("type", &2u32)?; map.serialize_entry( "secureNote", &TryInto::::try_into(self.0.clone()).unwrap(), )?; } - crate::db::EntryData::SshKey { .. } => { + EntryData::SshKey { .. } => { // TODO: Not entirely true now return Err(serde::ser::Error::custom("SshKey not supported")); } @@ -1373,7 +1373,7 @@ impl Client { &self, access_token: &str, name: &str, - data: &crate::db::EntryData, + data: &EntryData, notes: Option<&str>, folder_id: Option<&str>, ) -> Result<()> { From cd184249b962d6d01f07d5a9340d44b1fbd3ca52 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 26 May 2026 23:39:42 +0200 Subject: [PATCH 166/273] make entrydatawire less ugly but still ugly --- src/api.rs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/api.rs b/src/api.rs index 45f4f633..6d913407 100644 --- a/src/api.rs +++ b/src/api.rs @@ -10,7 +10,11 @@ use std::{ sync::Arc, }; -use crate::{actions::CryptoParameters, db::{Encrypted, EntryData}, prelude::*}; +use crate::{ + actions::CryptoParameters, + db::{Encrypted, EntryData}, + prelude::*, +}; use rand::distr::SampleString as _; use serde::{Deserialize, Serialize}; @@ -914,33 +918,29 @@ impl Serialize for EntryDataWire<'_> { ) -> std::result::Result { use serde::ser::SerializeMap; let mut map = serializer.serialize_map(None)?; - match self.0.clone() { + let data = self.0.clone(); + + match self.0 { EntryData::Login { .. } => { map.serialize_entry("type", &1u32)?; - map.serialize_entry( - "login", - &TryInto::::try_into(self.0.clone()).unwrap(), - )?; + map.serialize_entry("login", &TryInto::::try_into(data).unwrap())?; } EntryData::Card { .. } => { map.serialize_entry("type", &3u32)?; - map.serialize_entry( - "card", - &TryInto::::try_into(self.0.clone()).unwrap(), - )?; + map.serialize_entry("card", &TryInto::::try_into(data).unwrap())?; } EntryData::Identity { .. } => { map.serialize_entry("type", &4u32)?; map.serialize_entry( "identity", - &TryInto::::try_into(self.0.clone()).unwrap(), + &TryInto::::try_into(data).unwrap(), )?; } EntryData::SecureNote => { map.serialize_entry("type", &2u32)?; map.serialize_entry( "secureNote", - &TryInto::::try_into(self.0.clone()).unwrap(), + &TryInto::::try_into(data).unwrap(), )?; } EntryData::SshKey { .. } => { From 9601087a859cc5a1a6e9a51004a115306257623e Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 26 May 2026 23:47:45 +0200 Subject: [PATCH 167/273] impl From for dynamic fields --- src/api.rs | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/api.rs b/src/api.rs index 6d913407..d7bda37e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -451,17 +451,10 @@ impl SyncResCipher { return None; }; - let fields = self.fields.map_or_else(Vec::new, |fields| { - fields - .into_iter() - .map(|field| crate::db::DynamicField { - ty: field.ty, - name: field.name, - value: field.value, - linked_id: field.linked_id, - }) - .collect() + let fields: Vec = self.fields.map_or_else(Vec::new, |fields| { + fields.into_iter().map(|field| field.into()).collect() }); + Some(crate::db::Entry:: { id: self.id, org_id: self.organization_id, @@ -867,6 +860,28 @@ struct CipherDynamicField { linked_id: Option, } +impl From for crate::db::DynamicField { + fn from(value: CipherDynamicField) -> Self { + Self { + ty: value.ty, + name: value.name, + value: value.value, + linked_id: value.linked_id, + } + } +} + +impl From for CipherDynamicField { + fn from(value: crate::db::DynamicField) -> Self { + Self { + ty: value.ty, + name: value.name, + value: value.value, + linked_id: value.linked_id, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] struct SyncResPasswordHistory { #[serde(rename = "LastUsedDate", alias = "lastUsedDate")] From 46987f3671337bc6e01a819403743870dc128b16 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 26 May 2026 23:55:18 +0200 Subject: [PATCH 168/273] move structs around --- src/api.rs | 286 ++++++++++++++++++++++++++--------------------------- 1 file changed, 143 insertions(+), 143 deletions(-) diff --git a/src/api.rs b/src/api.rs index d7bda37e..a1fa53f6 100644 --- a/src/api.rs +++ b/src/api.rs @@ -361,143 +361,6 @@ struct ConnectRefreshTokenRes { access_token: String, } -#[derive(Deserialize, Debug)] -struct SyncRes { - #[serde(rename = "Ciphers", alias = "ciphers")] - ciphers: Vec, - #[serde(rename = "Profile", alias = "profile")] - profile: SyncResProfile, - #[serde(rename = "Folders", alias = "folders")] - folders: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -struct SyncResCipher { - #[serde(rename = "Id", alias = "id")] - id: String, - #[serde(rename = "FolderId", alias = "folderId")] - folder_id: Option, - #[serde(rename = "OrganizationId", alias = "organizationId")] - organization_id: Option, - #[serde(rename = "Name", alias = "name")] - name: String, - #[serde(rename = "Login", alias = "login")] - login: Option, - #[serde(rename = "Card", alias = "card")] - card: Option, - #[serde(rename = "Identity", alias = "identity")] - identity: Option, - #[serde(rename = "SecureNote", alias = "secureNote")] - secure_note: Option, - #[serde(rename = "SshKey", alias = "sshKey")] - ssh_key: Option, - #[serde(rename = "Notes", alias = "notes")] - notes: Option, - #[serde(rename = "PasswordHistory", alias = "passwordHistory")] - password_history: Option>, - #[serde(rename = "Fields", alias = "fields")] - fields: Option>, - #[serde(rename = "DeletedDate", alias = "deletedDate")] - deleted_date: Option, - #[serde(rename = "Key", alias = "key")] - key: Option, - #[serde(rename = "Reprompt", alias = "reprompt")] - reprompt: CipherRepromptType, -} - -impl SyncResCipher { - fn to_entry(self, folders: &[SyncResFolder]) -> Option> { - if self.deleted_date.is_some() { - return None; - } - let history = self - .password_history - //.as_ref() - .map_or_else(Vec::new, |history| { - history - .into_iter() - .filter_map(|entry| { - // Gets rid of entries with a non-existent - // password - entry.password.map(|p| crate::db::HistoryEntry { - last_used_date: entry.last_used_date, - password: p, - }) - }) - .collect() - }); - - let (folder, folder_id) = self.folder_id.map_or((None, None), |folder_id| { - let mut folder_name = None; - for folder in folders { - if folder.id == folder_id { - folder_name = Some(folder.name.clone()); - } - } - (folder_name, Some(folder_id)) - }); - - let data = if let Some(login) = self.login { - login.into() - } else if let Some(card) = self.card { - card.into() - } else if let Some(identity) = self.identity { - identity.into() - } else if let Some(secure_note) = self.secure_note { - secure_note.into() - } else if let Some(ssh_key) = self.ssh_key { - ssh_key.into() - } else { - return None; - }; - - let fields: Vec = self.fields.map_or_else(Vec::new, |fields| { - fields.into_iter().map(|field| field.into()).collect() - }); - - Some(crate::db::Entry:: { - id: self.id, - org_id: self.organization_id, - folder, - folder_id: folder_id, - name: self.name, - data, - fields, - notes: self.notes, - history, - key: self.key, - master_password_reprompt: self.reprompt, - _state: std::marker::PhantomData, - }) - } -} - -#[derive(Deserialize, Debug)] -struct SyncResProfile { - #[serde(rename = "Key", alias = "key")] - key: String, - #[serde(rename = "PrivateKey", alias = "privateKey")] - private_key: String, - #[serde(rename = "Organizations", alias = "organizations")] - organizations: Vec, -} - -#[derive(Deserialize, Debug)] -struct SyncResProfileOrganization { - #[serde(rename = "Id", alias = "id")] - id: String, - #[serde(rename = "Key", alias = "key")] - key: String, -} - -#[derive(Deserialize, Debug, Clone)] -struct SyncResFolder { - #[serde(rename = "Id", alias = "id")] - id: String, - #[serde(rename = "Name", alias = "name")] - name: String, -} - #[derive(Serialize, Deserialize, Debug, Clone)] struct CipherLoginUri { #[serde(rename = "Uri", alias = "uri")] @@ -890,6 +753,143 @@ struct SyncResPasswordHistory { password: Option, } +#[derive(Serialize, Deserialize, Debug, Clone)] +struct SyncResCipher { + #[serde(rename = "Id", alias = "id")] + id: String, + #[serde(rename = "FolderId", alias = "folderId")] + folder_id: Option, + #[serde(rename = "OrganizationId", alias = "organizationId")] + organization_id: Option, + #[serde(rename = "Name", alias = "name")] + name: String, + #[serde(rename = "Login", alias = "login")] + login: Option, + #[serde(rename = "Card", alias = "card")] + card: Option, + #[serde(rename = "Identity", alias = "identity")] + identity: Option, + #[serde(rename = "SecureNote", alias = "secureNote")] + secure_note: Option, + #[serde(rename = "SshKey", alias = "sshKey")] + ssh_key: Option, + #[serde(rename = "Notes", alias = "notes")] + notes: Option, + #[serde(rename = "PasswordHistory", alias = "passwordHistory")] + password_history: Option>, + #[serde(rename = "Fields", alias = "fields")] + fields: Option>, + #[serde(rename = "DeletedDate", alias = "deletedDate")] + deleted_date: Option, + #[serde(rename = "Key", alias = "key")] + key: Option, + #[serde(rename = "Reprompt", alias = "reprompt")] + reprompt: CipherRepromptType, +} + +impl SyncResCipher { + fn to_entry(self, folders: &[SyncResFolder]) -> Option> { + if self.deleted_date.is_some() { + return None; + } + let history = self + .password_history + //.as_ref() + .map_or_else(Vec::new, |history| { + history + .into_iter() + .filter_map(|entry| { + // Gets rid of entries with a non-existent + // password + entry.password.map(|p| crate::db::HistoryEntry { + last_used_date: entry.last_used_date, + password: p, + }) + }) + .collect() + }); + + let (folder, folder_id) = self.folder_id.map_or((None, None), |folder_id| { + let mut folder_name = None; + for folder in folders { + if folder.id == folder_id { + folder_name = Some(folder.name.clone()); + } + } + (folder_name, Some(folder_id)) + }); + + let data = if let Some(login) = self.login { + login.into() + } else if let Some(card) = self.card { + card.into() + } else if let Some(identity) = self.identity { + identity.into() + } else if let Some(secure_note) = self.secure_note { + secure_note.into() + } else if let Some(ssh_key) = self.ssh_key { + ssh_key.into() + } else { + return None; + }; + + let fields: Vec = self.fields.map_or_else(Vec::new, |fields| { + fields.into_iter().map(|field| field.into()).collect() + }); + + Some(crate::db::Entry:: { + id: self.id, + org_id: self.organization_id, + folder, + folder_id: folder_id, + name: self.name, + data, + fields, + notes: self.notes, + history, + key: self.key, + master_password_reprompt: self.reprompt, + _state: std::marker::PhantomData, + }) + } +} + +#[derive(Deserialize, Debug)] +struct SyncResProfile { + #[serde(rename = "Key", alias = "key")] + key: String, + #[serde(rename = "PrivateKey", alias = "privateKey")] + private_key: String, + #[serde(rename = "Organizations", alias = "organizations")] + organizations: Vec, +} + +#[derive(Deserialize, Debug)] +struct SyncResProfileOrganization { + #[serde(rename = "Id", alias = "id")] + id: String, + #[serde(rename = "Key", alias = "key")] + key: String, +} + +#[derive(Deserialize, Debug, Clone)] +struct SyncResFolder { + #[serde(rename = "Id", alias = "id")] + id: String, + #[serde(rename = "Name", alias = "name")] + name: String, +} + +#[derive(Deserialize, Debug)] +struct SyncRes { + #[serde(rename = "Ciphers", alias = "ciphers")] + ciphers: Vec, + #[serde(rename = "Profile", alias = "profile")] + profile: SyncResProfile, + #[serde(rename = "Folders", alias = "folders")] + folders: Vec, +} + #[derive(Serialize, Debug)] struct CiphersPostReq<'a> { #[serde(rename = "folderId")] @@ -967,12 +967,6 @@ impl Serialize for EntryDataWire<'_> { } } -#[derive(Deserialize, Debug)] -struct FoldersRes { - #[serde(rename = "Data", alias = "data")] - data: Vec, -} - #[derive(Deserialize, Debug)] struct FoldersResData { #[serde(rename = "Id", alias = "id")] @@ -981,6 +975,12 @@ struct FoldersResData { name: String, } +#[derive(Deserialize, Debug)] +struct FoldersRes { + #[serde(rename = "Data", alias = "data")] + data: Vec, +} + // Used for the Bitwarden-Client-Name header. Accepted values: // https://github.com/bitwarden/server/blob/main/src/Core/Enums/BitwardenClient.cs const BITWARDEN_CLIENT: &str = "cli"; From 3d214353969f8db627d76a4806e04c309ed1a781 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 27 May 2026 00:02:09 +0200 Subject: [PATCH 169/273] to_entry -> into_entry --- src/api.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api.rs b/src/api.rs index a1fa53f6..8cfd2980 100644 --- a/src/api.rs +++ b/src/api.rs @@ -788,7 +788,7 @@ struct SyncResCipher { } impl SyncResCipher { - fn to_entry(self, folders: &[SyncResFolder]) -> Option> { + fn into_entry(self, folders: &[SyncResFolder]) -> Option> { if self.deleted_date.is_some() { return None; } @@ -1366,7 +1366,7 @@ impl Client { let ciphers = sync_res .ciphers .into_iter() - .filter_map(|cipher| cipher.to_entry(&sync_res.folders)) + .filter_map(|cipher| cipher.into_entry(&sync_res.folders)) .collect(); let org_keys = sync_res From a8816c86fd598ef6d720d5f4de97aa8dfbfd7285 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 27 May 2026 00:05:24 +0200 Subject: [PATCH 170/273] avoid allocation for client_secret and client_id --- src/api.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api.rs b/src/api.rs index 8cfd2980..19e053df 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1205,12 +1205,12 @@ impl Client { let connect_req = ConnectTokenReq { auth: ConnectTokenAuth::ClientCredentials { username: &email, - client_secret: &String::from_utf8(apikey.client_secret().to_vec()).unwrap(), + client_secret: str::from_utf8(apikey.client_secret()).unwrap(), }, grant_type: "client_credentials", scope: "api", // XXX unwraps here are not necessarily safe - client_id: &String::from_utf8(apikey.client_id().to_vec()).unwrap(), + client_id: str::from_utf8(apikey.client_id()).unwrap(), device_type: u32::from(DEVICE_TYPE), device_identifier: device_id, device_name: "rbw", From 12e943ee91dae6c6d71991ee60d19f1a6efb0aef Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 27 May 2026 00:13:43 +0200 Subject: [PATCH 171/273] using From impl for DynamicField --- src/api.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/api.rs b/src/api.rs index 19e053df..3a9f5a8b 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1416,12 +1416,7 @@ impl Client { fields: entry .fields .iter() - .map(|field| CipherDynamicField { - ty: field.ty, - name: field.name.clone(), - value: field.value.clone(), - linked_id: field.linked_id, - }) + .map(|field| field.clone().into()) .collect(), password_history: entry .history From 2a04a546d0d01f6d042ad76c405d308eca11772a Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 27 May 2026 00:17:41 +0200 Subject: [PATCH 172/273] use references in CiphersPostReq and partially in CipherPutReq --- src/api.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/api.rs b/src/api.rs index 3a9f5a8b..011f1c80 100644 --- a/src/api.rs +++ b/src/api.rs @@ -893,9 +893,9 @@ struct SyncRes { #[derive(Serialize, Debug)] struct CiphersPostReq<'a> { #[serde(rename = "folderId")] - folder_id: Option, - name: String, - notes: Option, + folder_id: Option<&'a str>, + name: &'a str, + notes: Option<&'a str>, #[serde(flatten)] data: EntryDataWire<'a>, // use lifetime parameter on the struct instead } @@ -903,11 +903,11 @@ struct CiphersPostReq<'a> { #[derive(Serialize, Debug)] struct CiphersPutReq<'a> { #[serde(rename = "folderId")] - folder_id: Option, + folder_id: Option<&'a str>, #[serde(rename = "organizationId")] - organization_id: Option, - name: String, - notes: Option, + organization_id: Option<&'a str>, + name: &'a str, + notes: Option<&'a str>, #[serde(flatten)] data: EntryDataWire<'a>, fields: Vec, @@ -1393,9 +1393,9 @@ impl Client { folder_id: Option<&str>, ) -> Result<()> { let req = CiphersPostReq { - folder_id: folder_id.map(ToString::to_string), - name: name.to_string(), - notes: notes.map(ToString::to_string), + folder_id: folder_id, + name: name, + notes: notes, data: EntryDataWire(data), }; @@ -1408,10 +1408,10 @@ impl Client { pub fn edit(&self, access_token: &str, entry: &crate::db::Entry) -> Result<()> { let req = CiphersPutReq { - folder_id: entry.folder_id.clone(), - organization_id: entry.org_id.clone(), - name: entry.name.clone(), - notes: entry.notes.clone(), + folder_id: entry.folder_id.as_deref(), + organization_id: entry.org_id.as_deref(), + name: &entry.name, + notes: entry.notes.as_deref(), data: EntryDataWire(&entry.data), fields: entry .fields From 6ce2ad8ea9036abbd9b5966b61e2466eed4021aa Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 27 May 2026 00:23:17 +0200 Subject: [PATCH 173/273] use full references in CiphersPutReq --- src/api.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/api.rs b/src/api.rs index 011f1c80..5b8be496 100644 --- a/src/api.rs +++ b/src/api.rs @@ -910,9 +910,9 @@ struct CiphersPutReq<'a> { notes: Option<&'a str>, #[serde(flatten)] data: EntryDataWire<'a>, - fields: Vec, + fields: &'a [CipherDynamicField], #[serde(rename = "passwordHistory")] - password_history: Vec, + password_history: &'a [CiphersPutReqHistory], } #[derive(Serialize, Debug)] @@ -1413,19 +1413,19 @@ impl Client { name: &entry.name, notes: entry.notes.as_deref(), data: EntryDataWire(&entry.data), - fields: entry + fields: &entry .fields .iter() .map(|field| field.clone().into()) - .collect(), - password_history: entry + .collect::>(), + password_history: &entry .history .iter() .map(|entry| CiphersPutReqHistory { last_used_date: entry.last_used_date.clone(), password: entry.password.clone(), }) - .collect(), + .collect::>(), }; ClientBlockingRequest::Edit(access_token, &entry.id, req) From abfd29552582d5fe175788110dc9d7c26a348108 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 27 May 2026 00:37:48 +0200 Subject: [PATCH 174/273] impl some From for HistoryEntry and SyncResPasswordHistory --- src/api.rs | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/api.rs b/src/api.rs index 5b8be496..27a5e11a 100644 --- a/src/api.rs +++ b/src/api.rs @@ -753,6 +753,28 @@ struct SyncResPasswordHistory { password: Option, } +impl From for SyncResPasswordHistory { + fn from(value: crate::db::HistoryEntry) -> Self { + Self { + last_used_date: value.last_used_date, + password: Some(value.password), + } + } +} + +impl From for Option { + fn from(value: SyncResPasswordHistory) -> Self { + let Some(password) = value.password else { + return None; + }; + + Some(crate::db::HistoryEntry { + last_used_date: value.last_used_date, + password, + }) + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] struct SyncResCipher { #[serde(rename = "Id", alias = "id")] @@ -792,20 +814,14 @@ impl SyncResCipher { if self.deleted_date.is_some() { return None; } + let history = self .password_history //.as_ref() .map_or_else(Vec::new, |history| { history .into_iter() - .filter_map(|entry| { - // Gets rid of entries with a non-existent - // password - entry.password.map(|p| crate::db::HistoryEntry { - last_used_date: entry.last_used_date, - password: p, - }) - }) + .filter_map(Into::>::into) .collect() }); From f084a7e56e70925da728ec031f2c6dc319fdd09d Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 27 May 2026 00:38:32 +0200 Subject: [PATCH 175/273] use Into::into directly --- src/api.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api.rs b/src/api.rs index 27a5e11a..c9afd271 100644 --- a/src/api.rs +++ b/src/api.rs @@ -850,7 +850,7 @@ impl SyncResCipher { }; let fields: Vec = self.fields.map_or_else(Vec::new, |fields| { - fields.into_iter().map(|field| field.into()).collect() + fields.into_iter().map(Into::into).collect() }); Some(crate::db::Entry:: { From bc0eca9e4eb88828f5b11265b1503490b8e79170 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 27 May 2026 00:39:36 +0200 Subject: [PATCH 176/273] rename to SyncResHistoryEntry for consistency with db --- src/api.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/api.rs b/src/api.rs index c9afd271..20a43660 100644 --- a/src/api.rs +++ b/src/api.rs @@ -746,14 +746,14 @@ impl From for CipherDynamicField { } #[derive(Serialize, Deserialize, Debug, Clone)] -struct SyncResPasswordHistory { +struct SyncResHistoryEntry { #[serde(rename = "LastUsedDate", alias = "lastUsedDate")] last_used_date: String, #[serde(rename = "Password", alias = "password")] password: Option, } -impl From for SyncResPasswordHistory { +impl From for SyncResHistoryEntry { fn from(value: crate::db::HistoryEntry) -> Self { Self { last_used_date: value.last_used_date, @@ -762,8 +762,8 @@ impl From for SyncResPasswordHistory { } } -impl From for Option { - fn from(value: SyncResPasswordHistory) -> Self { +impl From for Option { + fn from(value: SyncResHistoryEntry) -> Self { let Some(password) = value.password else { return None; }; @@ -798,7 +798,7 @@ struct SyncResCipher { #[serde(rename = "Notes", alias = "notes")] notes: Option, #[serde(rename = "PasswordHistory", alias = "passwordHistory")] - password_history: Option>, + password_history: Option>, #[serde(rename = "Fields", alias = "fields")] fields: Option>, #[serde(rename = "DeletedDate", alias = "deletedDate")] From e8920b72501f51932df6f41d2689b8cca1e353f6 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 27 May 2026 00:57:30 +0200 Subject: [PATCH 177/273] delete CiphersPutReqWhatever duplicate struct --- src/api.rs | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/src/api.rs b/src/api.rs index 20a43660..64b3904d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -746,14 +746,14 @@ impl From for CipherDynamicField { } #[derive(Serialize, Deserialize, Debug, Clone)] -struct SyncResHistoryEntry { +struct CipherHistoryEntry { #[serde(rename = "LastUsedDate", alias = "lastUsedDate")] last_used_date: String, #[serde(rename = "Password", alias = "password")] password: Option, } -impl From for SyncResHistoryEntry { +impl From for CipherHistoryEntry { fn from(value: crate::db::HistoryEntry) -> Self { Self { last_used_date: value.last_used_date, @@ -762,8 +762,8 @@ impl From for SyncResHistoryEntry { } } -impl From for Option { - fn from(value: SyncResHistoryEntry) -> Self { +impl From for Option { + fn from(value: CipherHistoryEntry) -> Self { let Some(password) = value.password else { return None; }; @@ -798,7 +798,7 @@ struct SyncResCipher { #[serde(rename = "Notes", alias = "notes")] notes: Option, #[serde(rename = "PasswordHistory", alias = "passwordHistory")] - password_history: Option>, + password_history: Option>, #[serde(rename = "Fields", alias = "fields")] fields: Option>, #[serde(rename = "DeletedDate", alias = "deletedDate")] @@ -928,15 +928,7 @@ struct CiphersPutReq<'a> { data: EntryDataWire<'a>, fields: &'a [CipherDynamicField], #[serde(rename = "passwordHistory")] - password_history: &'a [CiphersPutReqHistory], -} - -#[derive(Serialize, Debug)] -struct CiphersPutReqHistory { - #[serde(rename = "LastUsedDate")] - last_used_date: String, - #[serde(rename = "Password")] - password: String, + password_history: &'a [CipherHistoryEntry], } #[derive(Debug)] @@ -1437,10 +1429,7 @@ impl Client { password_history: &entry .history .iter() - .map(|entry| CiphersPutReqHistory { - last_used_date: entry.last_used_date.clone(), - password: entry.password.clone(), - }) + .map(|entry| entry.clone().into()) .collect::>(), }; From 7cfdf3d031597f7169d0a41f9e61c610d7e270a4 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 27 May 2026 01:24:03 +0200 Subject: [PATCH 178/273] split struct Client and other fns / logic into separate file --- src/actions.rs | 8 +- src/api/client.rs | 633 +++++++++++++++++++++++++++++++++++++ src/{api.rs => api/mod.rs} | 627 +----------------------------------- 3 files changed, 639 insertions(+), 629 deletions(-) create mode 100644 src/api/client.rs rename src/{api.rs => api/mod.rs} (56%) diff --git a/src/actions.rs b/src/actions.rs index 8842852c..077b4d15 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -270,9 +270,9 @@ async fn exchange_refresh_token_async(refresh_token: &str) -> Result { client.exchange_refresh_token_async(refresh_token).await } -fn api_client() -> Result<(crate::api::Client, crate::config::Config)> { +fn api_client() -> Result<(crate::api::client::Client, crate::config::Config)> { let config = crate::config::Config::load()?; - let client = crate::api::Client::new( + let client = crate::api::client::Client::new( &config.base_url(), &config.identity_url(), &config.ui_url(), @@ -281,9 +281,9 @@ fn api_client() -> Result<(crate::api::Client, crate::config::Config)> { Ok((client, config)) } -async fn api_client_async() -> Result<(crate::api::Client, crate::config::Config)> { +async fn api_client_async() -> Result<(crate::api::client::Client, crate::config::Config)> { let config = crate::config::Config::load_async().await?; - let client = crate::api::Client::new( + let client = crate::api::client::Client::new( &config.base_url(), &config.identity_url(), &config.ui_url(), diff --git a/src/api/client.rs b/src/api/client.rs new file mode 100644 index 00000000..f49e5a36 --- /dev/null +++ b/src/api/client.rs @@ -0,0 +1,633 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::Arc, +}; + +use rand::distr::SampleString as _; +use sha2::Digest as _; +use tokio::sync::mpsc::{channel, Sender}; + +use crate::{ + actions::CryptoParameters, + api::{ + CiphersPostReq, CiphersPutReq, ConnectErrorRes, ConnectRefreshTokenRes, ConnectTokenAuth, + ConnectTokenReq, ConnectTokenRes, EntryDataWire, FoldersRes, FoldersResData, PreloginRes, + SyncRes, TwoFactorProviderType, + }, + db::{Encrypted, Entry, EntryData}, + error::{Error, Result}, + json::{DeserializeJsonWithPath as _, DeserializeJsonWithPathAsync as _}, +}; + +// Used for the Bitwarden-Client-Name header. Accepted values: +// https://github.com/bitwarden/server/blob/main/src/Core/Enums/BitwardenClient.cs +const BITWARDEN_CLIENT: &str = "cli"; + +// DeviceType.LinuxDesktop, as per Bitwarden API device types. +const DEVICE_TYPE: u8 = 8; + +enum ClientRequest<'a> { + Prelogin(&'a str), + ConnectToken(ConnectTokenReq<'a>), + Login(ConnectTokenReq<'a>, &'a str), + SendEmailLogin(&'a str, &'a str, &'a str), + Sync(&'a str), + ExchangeRefreshToken(&'a str), +} + +impl<'a> ClientRequest<'a> { + async fn req(self, client: &Client) -> Result { + let http_client = client.reqwest_client().await?; + + let rb = match self { + Self::Prelogin(email) => http_client + .post(client.identity_url("/accounts/prelogin")) + .json(&serde_json::json!({"email": email})), + Self::ConnectToken(r) => http_client + .post(client.identity_url("/connect/token")) + .form(&r), + Self::Login(r, email) => http_client + .post(client.identity_url("/connect/token")) + .form(&r) + .header("auth-email", crate::base64::encode_url_safe_no_pad(email)), + Self::SendEmailLogin(email, device_identifier, sso_email_2fa_session_token) => { + http_client + .post(client.api_url("/two-factor/send-email-login")) + .json(&serde_json::json!({ + "email": email, + "DeviceIdentifier": device_identifier, + "SsoEmail2faSessionToken": sso_email_2fa_session_token + })) + .header("auth-email", crate::base64::encode_url_safe_no_pad(email)) + } + Self::Sync(access_token) => http_client + .get(client.api_url("/sync")) + .header("Authorization", format!("Bearer {access_token}")) + // This is necessary for vaultwarden to include the ssh keys in the response + .header("Bitwarden-Client-Version", "2024.12.0"), + Self::ExchangeRefreshToken(refresh_token) => http_client + .post(client.identity_url("/connect/token")) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", "cli"), + ("refresh_token", refresh_token), + ]), + }; + + Ok(rb.send().await?) + } +} + +enum ClientBlockingRequest<'a> { + Add(&'a str, CiphersPostReq<'a>), + Edit(&'a str, &'a str, CiphersPutReq<'a>), + Remove(&'a str, &'a str), + Folders(&'a str), + CreateFolder(&'a str, &'a str), + ExchangeRefreshToken(&'a str), +} + +impl<'a> ClientBlockingRequest<'a> { + fn req(self, client: &Client) -> Result { + let http_client = reqwest::blocking::Client::new(); + + let rb = match self { + Self::Add(access_token, r) => http_client + .post(client.api_url("/ciphers")) + .header("Authorization", format!("Bearer {access_token}")) + .json(&r), + Self::Edit(access_token, id, r) => http_client + .put(client.api_url(&format!("/ciphers/{id}"))) + .header("Authorization", format!("Bearer {access_token}")) + .json(&r), + Self::Remove(access_token, id) => http_client + .delete(client.api_url(&format!("/ciphers/{id}"))) + .header("Authorization", format!("Bearer {access_token}")), + Self::Folders(access_token) => http_client + .get(client.api_url("/folders")) + .header("Authorization", format!("Bearer {access_token}")), + Self::CreateFolder(access_token, name) => http_client + .post(client.api_url("/folders")) + .header("Authorization", format!("Bearer {access_token}")) + .json(&serde_json::json!({"name": name})), + Self::ExchangeRefreshToken(refresh_token) => http_client + .post(client.identity_url("/connect/token")) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", "cli"), + ("refresh_token", refresh_token), + ]), + }; + + Ok(rb.send()?) + } +} + +async fn find_free_port(bottom: u16, top: u16) -> Result { + for port in bottom..top { + if tokio::net::TcpListener::bind(("127.0.0.1", port)) + .await + .is_ok() + { + return Ok(port); + } + } + + Err(Error::FailedToFindFreePort { + range: format!("({bottom}..{top})"), + }) +} + +#[derive(Clone)] +struct SSOHandlerState { + state: String, + sender: Sender>, +} + +async fn start_sso_callback_server( + listener: tokio::net::TcpListener, + state: &str, +) -> Result { + let (shut_tx, mut shut_rx) = channel(1); + let (tx, mut rx) = channel(1); + + let sso_handler_state = Arc::new(SSOHandlerState { + state: state.to_string(), + sender: shut_tx, + }); + + let app = axum::Router::new() + .route("/", axum::routing::get(handle_sso_callback)) + .with_state(sso_handler_state); + + axum::serve(listener, app) + .with_graceful_shutdown( + async move { tx.send(shut_rx.recv().await.unwrap()).await.unwrap() }, + ) + .await + .map_err(|e| Error::FailedToProcessSSOCallback { msg: e.to_string() })?; + + rx.recv().await.unwrap() +} + +async fn handle_sso_callback( + axum::extract::State(state): axum::extract::State>, + axum::extract::Query(params): axum::extract::Query>, +) -> axum::http::Response { + match sso_query_code(¶ms, state.state.as_str()) { + Ok(sso_code) => { + state.sender.send(Ok(sso_code)).await.unwrap(); + + axum::http::Response::builder() + .status(axum::http::StatusCode::OK) + .body( + "Success | rbw \ +

Successfully authenticated with rbw

\ +

You may now close this tab and return to the terminal.

\ + " + .to_string(), + ) + .unwrap() + } + Err(e) => { + state.sender.send(Err(e)).await.unwrap(); + + axum::http::Response::builder() + .status(axum::http::StatusCode::BAD_REQUEST) + .body( + "Failed | rbw \ +

Something went wrong logging into the rbw

\ +

You may now close this tab and return to the terminal.

\ + " + .to_string(), + ) + .unwrap() + } + } +} + +fn sso_query_code(params: &HashMap, state: &str) -> Result { + let sso_code = params + .get("code") + .ok_or(Error::FailedToProcessSSOCallback { + msg: "Could not obtain code from the URL".to_string(), + })?; + + let received_state = params + .get("state") + .ok_or(Error::FailedToProcessSSOCallback { + msg: "Could not obtain state from the URL".to_string(), + })?; + + if received_state.split("_identifier=").next().unwrap() != state { + return Err(Error::FailedToProcessSSOCallback { + msg: format!( + "SSO callback states do not match, sent: {state}, received: {received_state}" + ), + }); + } + + Ok(sso_code.clone()) +} +#[derive(Debug)] +pub struct Client { + base_url: String, + identity_url: String, + ui_url: String, + client_cert_path: Option, +} + +impl Client { + pub fn new( + base_url: &str, + identity_url: &str, + ui_url: &str, + client_cert_path: Option<&Path>, + ) -> Self { + Self { + base_url: base_url.to_string(), + identity_url: identity_url.to_string(), + ui_url: ui_url.to_string(), + client_cert_path: client_cert_path.map(Path::to_path_buf), + } + } + + pub(super) async fn reqwest_client(&self) -> Result { + let mut default_headers = axum::http::HeaderMap::new(); + default_headers.insert( + "Bitwarden-Client-Name", + axum::http::HeaderValue::from_static(BITWARDEN_CLIENT), + ); + default_headers.insert( + "Bitwarden-Client-Version", + axum::http::HeaderValue::from_static(env!("CARGO_PKG_VERSION")), + ); + default_headers.append( + "Device-Type", + // unwrap is safe here because DEVICE_TYPE is a number and digits + // are valid ASCII + axum::http::HeaderValue::from_str(&DEVICE_TYPE.to_string()).unwrap(), + ); + let user_agent = format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); + if let Some(client_cert_path) = self.client_cert_path.as_ref() { + let buf = + tokio::fs::read(client_cert_path) + .await + .map_err(|e| Error::LoadClientCert { + source: e, + file: client_cert_path.clone(), + })?; + let pem = reqwest::Identity::from_pem(&buf) + .map_err(|e| Error::CreateReqwestClient { source: e })?; + Ok(reqwest::Client::builder() + .user_agent(user_agent) + .identity(pem) + .default_headers(default_headers) + .build() + .map_err(|e| Error::CreateReqwestClient { source: e })?) + } else { + Ok(reqwest::Client::builder() + .user_agent(user_agent) + .default_headers(default_headers) + .build() + .map_err(|e| Error::CreateReqwestClient { source: e })?) + } + } + + pub async fn prelogin(&self, email: &str) -> Result { + let res: PreloginRes = ClientRequest::Prelogin(email) + .req(self) + .await? + .json_with_path() + .await?; + + Ok(CryptoParameters { + kdf: res.kdf, + iterations: res.kdf_iterations, + memory: res.kdf_memory, + parallelism: res.kdf_parallelism, + }) + } + + async fn check_connect_token_res(res: reqwest::Response) -> Result { + match res.status() { + reqwest::StatusCode::OK => Ok(res), + status => match res.text().await { + Ok(body) => match body.clone().json_with_path::() { + Ok(err) => match err.try_into() { + Ok(e) => Err(e), + Err(err) => { + log::warn!("unexpected error received during login: {err:?}"); + Err(Error::RequestFailed { + status: status.as_u16(), + }) + } + }, + Err(e) => { + log::warn!("{e}: {body}"); + Err(Error::RequestFailed { + status: status.as_u16(), + }) + } + }, + Err(e) => { + log::warn!("failed to read response body: {e}"); + Err(Error::RequestFailed { + status: status.as_u16(), + }) + } + }, + } + } + + pub async fn register( + &self, + email: &str, + device_id: &str, + apikey: &crate::locked::ApiKey, + ) -> Result<()> { + let connect_req = ConnectTokenReq { + auth: ConnectTokenAuth::ClientCredentials { + username: &email, + client_secret: str::from_utf8(apikey.client_secret()).unwrap(), + }, + grant_type: "client_credentials", + scope: "api", + // XXX unwraps here are not necessarily safe + client_id: str::from_utf8(apikey.client_id()).unwrap(), + device_type: u32::from(DEVICE_TYPE), + device_identifier: device_id, + device_name: "rbw", + device_push_token: "", + two_factor_token: None, + two_factor_provider: None, + }; + + let res = ClientRequest::ConnectToken(connect_req).req(self).await?; + + Self::check_connect_token_res(res).await?; + + Ok(()) + } + + pub async fn login( + &self, + email: &str, + sso_id: Option<&str>, + device_id: &str, + password_hash: &crate::locked::PasswordHash, + two_factor_token: Option<&str>, + two_factor_provider: Option, + ) -> Result<(String, String, String)> { + let (auth, grant_type, scope) = match sso_id { + Some(sso_id) => { + let (sso_code, sso_code_verifier, callback_url) = + self.obtain_sso_code(sso_id).await?; + ( + ConnectTokenAuth::AuthCode { + code: &sso_code.clone(), + code_verifier: &sso_code_verifier.clone(), + redirect_uri: &callback_url.clone(), + }, + "authorization_code", + "api offline_access", + ) + } + None => ( + ConnectTokenAuth::Password { + username: email, + password: &crate::base64::encode(password_hash.hash()), + }, + "password", + "api offline_access", + ), + }; + + let connect_req = ConnectTokenReq { + auth, + grant_type: grant_type, + scope: scope, + client_id: "cli", + device_type: u32::from(DEVICE_TYPE), + device_identifier: device_id, + device_name: "rbw", + device_push_token: "", + two_factor_token: two_factor_token, + two_factor_provider: two_factor_provider.map(|ty| ty as u32), + }; + + let res = ClientRequest::Login(connect_req, email).req(self).await?; + + let res = Self::check_connect_token_res(res).await?; + + let connect_res: ConnectTokenRes = res.json_with_path().await?; + + Ok(( + connect_res.access_token, + connect_res.refresh_token, + connect_res.key, + )) + } + + pub async fn send_email_login( + &self, + email: &str, + device_id: &str, + sso_email_2fa_session_token: &str, + ) -> Result<()> { + let res = ClientRequest::SendEmailLogin(email, device_id, sso_email_2fa_session_token) + .req(self) + .await?; + + if res.status() == reqwest::StatusCode::OK { + Ok(()) + } else { + let code = res.status().as_u16(); + log::warn!("{code}: {:?}", res.text().await); + Err(Error::RequestFailed { status: code }) + } + } + + async fn obtain_sso_code(&self, sso_id: &str) -> Result<(String, String, String)> { + let state = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 64); + let sso_code_verifier = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 64); + + let mut hasher = sha2::Sha256::new(); + hasher.update(&sso_code_verifier); + let code_challenge = crate::base64::encode_url_safe_no_pad(hasher.finalize()); + + let port = find_free_port(8065, 8070).await?; + + let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)) + .await + .map_err(|e| Error::CreateSSOCallbackServer { err: e })?; + + let callback_server = start_sso_callback_server(listener, state.as_str()); + + let callback_url = "http://localhost:".to_string() + port.to_string().as_str(); + + open::that( + self.ui_url.clone() + + "/#/sso?clientId=" + + "cli" + + "&redirectUri=" + + urlencoding::encode(callback_url.as_str()) + .into_owned() + .as_str() + + "&state=" + + state.as_str() + + "&codeChallenge=" + + code_challenge.as_str() + + "&identifier=" + + sso_id, + ) + .map_err(|e| Error::FailedToOpenWebBrowser { err: e })?; + // TODO: probably it'd be better to display the URL in the console if the automatic + // open operation fails, instead of failing the whole process? E.g. docker container + // case + + let sso_code = callback_server.await?; + + Ok((sso_code, sso_code_verifier, callback_url)) + } + + pub async fn sync( + &self, + access_token: &str, + ) -> Result<( + String, + String, + HashMap, + Vec>, + )> { + let res = ClientRequest::Sync(access_token) + .req(self) + .await? + .error_for_status()?; + + let sync_res: SyncRes = res.json_with_path().await?; + + let ciphers = sync_res + .ciphers + .into_iter() + .filter_map(|cipher| cipher.into_entry(&sync_res.folders)) + .collect(); + + let org_keys = sync_res + .profile + .organizations + .iter() + .map(|org| (org.id.clone(), org.key.clone())) + .collect(); + + Ok(( + sync_res.profile.key, + sync_res.profile.private_key, + org_keys, + ciphers, + )) + } + + pub fn add( + &self, + access_token: &str, + name: &str, + data: &EntryData, + notes: Option<&str>, + folder_id: Option<&str>, + ) -> Result<()> { + let req = CiphersPostReq { + folder_id: folder_id, + name: name, + notes: notes, + data: EntryDataWire(data), + }; + + ClientBlockingRequest::Add(access_token, req) + .req(self)? + .error_for_status()?; + + Ok(()) + } + + pub fn edit(&self, access_token: &str, entry: &Entry) -> Result<()> { + let req = CiphersPutReq { + folder_id: entry.folder_id.as_deref(), + organization_id: entry.org_id.as_deref(), + name: &entry.name, + notes: entry.notes.as_deref(), + data: EntryDataWire(&entry.data), + fields: &entry + .fields + .iter() + .map(|field| field.clone().into()) + .collect::>(), + password_history: &entry + .history + .iter() + .map(|entry| entry.clone().into()) + .collect::>(), + }; + + ClientBlockingRequest::Edit(access_token, &entry.id, req) + .req(self)? + .error_for_status()?; + + Ok(()) + } + + pub fn remove(&self, access_token: &str, id: &str) -> Result<()> { + ClientBlockingRequest::Remove(access_token, id) + .req(self)? + .error_for_status()?; + + Ok(()) + } + + pub fn folders(&self, access_token: &str) -> Result> { + let res = ClientBlockingRequest::Folders(access_token) + .req(self)? + .error_for_status()?; + + let folders_res: FoldersRes = res.json_with_path()?; + + Ok(folders_res + .data + .iter() + .map(|folder| (folder.id.clone(), folder.name.clone())) + .collect()) + } + + pub fn create_folder(&self, access_token: &str, name: &str) -> Result { + let res = ClientBlockingRequest::CreateFolder(access_token, name) + .req(self)? + .error_for_status()?; + + let folders_res: FoldersResData = res.json_with_path()?; + + Ok(folders_res.id) + } + + pub fn exchange_refresh_token(&self, refresh_token: &str) -> Result { + let res = ClientBlockingRequest::ExchangeRefreshToken(refresh_token).req(self)?; + let connect_res: ConnectRefreshTokenRes = res.json_with_path()?; + Ok(connect_res.access_token) + } + + pub async fn exchange_refresh_token_async(&self, refresh_token: &str) -> Result { + let res = ClientRequest::ExchangeRefreshToken(refresh_token) + .req(self) + .await?; + let connect_res: ConnectRefreshTokenRes = res.json_with_path().await?; + Ok(connect_res.access_token) + } + + pub(super) fn api_url(&self, path: &str) -> String { + format!("{}{}", self.base_url, path) + } + + pub(super) fn identity_url(&self, path: &str) -> String { + format!("{}{}", self.identity_url, path) + } +} diff --git a/src/api.rs b/src/api/mod.rs similarity index 56% rename from src/api.rs rename to src/api/mod.rs index 64b3904d..75cf25b9 100644 --- a/src/api.rs +++ b/src/api/mod.rs @@ -2,26 +2,16 @@ // here, unfortunately #![allow(clippy::as_conversions)] -use std::{ - collections::HashMap, - fmt::Display, - path::{Path, PathBuf}, - str::FromStr, - sync::Arc, -}; +use std::{fmt::Display, str::FromStr}; use crate::{ - actions::CryptoParameters, db::{Encrypted, EntryData}, prelude::*, }; -use rand::distr::SampleString as _; use serde::{Deserialize, Serialize}; -use sha2::Digest as _; -use tokio::sync::mpsc; -use crate::json::{DeserializeJsonWithPath as _, DeserializeJsonWithPathAsync as _}; +pub mod client; #[derive( serde_repr::Serialize_repr, serde_repr::Deserialize_repr, Debug, Copy, Clone, PartialEq, Eq, @@ -988,616 +978,3 @@ struct FoldersRes { #[serde(rename = "Data", alias = "data")] data: Vec, } - -// Used for the Bitwarden-Client-Name header. Accepted values: -// https://github.com/bitwarden/server/blob/main/src/Core/Enums/BitwardenClient.cs -const BITWARDEN_CLIENT: &str = "cli"; - -// DeviceType.LinuxDesktop, as per Bitwarden API device types. -const DEVICE_TYPE: u8 = 8; - -enum ClientRequest<'a> { - Prelogin(&'a str), - ConnectToken(ConnectTokenReq<'a>), - Login(ConnectTokenReq<'a>, &'a str), - SendEmailLogin(&'a str, &'a str, &'a str), - Sync(&'a str), - ExchangeRefreshToken(&'a str), -} - -impl<'a> ClientRequest<'a> { - async fn req(self, client: &Client) -> Result { - let http_client = client.reqwest_client().await?; - - let rb = match self { - Self::Prelogin(email) => http_client - .post(client.identity_url("/accounts/prelogin")) - .json(&serde_json::json!({"email": email})), - Self::ConnectToken(r) => http_client - .post(client.identity_url("/connect/token")) - .form(&r), - Self::Login(r, email) => http_client - .post(client.identity_url("/connect/token")) - .form(&r) - .header("auth-email", crate::base64::encode_url_safe_no_pad(email)), - Self::SendEmailLogin(email, device_identifier, sso_email_2fa_session_token) => { - http_client - .post(client.api_url("/two-factor/send-email-login")) - .json(&serde_json::json!({ - "email": email, - "DeviceIdentifier": device_identifier, - "SsoEmail2faSessionToken": sso_email_2fa_session_token - })) - .header("auth-email", crate::base64::encode_url_safe_no_pad(email)) - } - Self::Sync(access_token) => http_client - .get(client.api_url("/sync")) - .header("Authorization", format!("Bearer {access_token}")) - // This is necessary for vaultwarden to include the ssh keys in the response - .header("Bitwarden-Client-Version", "2024.12.0"), - Self::ExchangeRefreshToken(refresh_token) => http_client - .post(client.identity_url("/connect/token")) - .form(&[ - ("grant_type", "refresh_token"), - ("client_id", "cli"), - ("refresh_token", refresh_token), - ]), - }; - - Ok(rb.send().await?) - } -} - -enum ClientBlockingRequest<'a> { - Add(&'a str, CiphersPostReq<'a>), - Edit(&'a str, &'a str, CiphersPutReq<'a>), - Remove(&'a str, &'a str), - Folders(&'a str), - CreateFolder(&'a str, &'a str), - ExchangeRefreshToken(&'a str), -} - -impl<'a> ClientBlockingRequest<'a> { - fn req(self, client: &Client) -> Result { - let http_client = reqwest::blocking::Client::new(); - - let rb = match self { - Self::Add(access_token, r) => http_client - .post(client.api_url("/ciphers")) - .header("Authorization", format!("Bearer {access_token}")) - .json(&r), - Self::Edit(access_token, id, r) => http_client - .put(client.api_url(&format!("/ciphers/{id}"))) - .header("Authorization", format!("Bearer {access_token}")) - .json(&r), - Self::Remove(access_token, id) => http_client - .delete(client.api_url(&format!("/ciphers/{id}"))) - .header("Authorization", format!("Bearer {access_token}")), - Self::Folders(access_token) => http_client - .get(client.api_url("/folders")) - .header("Authorization", format!("Bearer {access_token}")), - Self::CreateFolder(access_token, name) => http_client - .post(client.api_url("/folders")) - .header("Authorization", format!("Bearer {access_token}")) - .json(&serde_json::json!({"name": name})), - Self::ExchangeRefreshToken(refresh_token) => http_client - .post(client.identity_url("/connect/token")) - .form(&[ - ("grant_type", "refresh_token"), - ("client_id", "cli"), - ("refresh_token", refresh_token), - ]), - }; - - Ok(rb.send()?) - } -} - -#[derive(Debug)] -pub struct Client { - base_url: String, - identity_url: String, - ui_url: String, - client_cert_path: Option, -} - -impl Client { - pub fn new( - base_url: &str, - identity_url: &str, - ui_url: &str, - client_cert_path: Option<&Path>, - ) -> Self { - Self { - base_url: base_url.to_string(), - identity_url: identity_url.to_string(), - ui_url: ui_url.to_string(), - client_cert_path: client_cert_path.map(Path::to_path_buf), - } - } - - async fn reqwest_client(&self) -> Result { - let mut default_headers = axum::http::HeaderMap::new(); - default_headers.insert( - "Bitwarden-Client-Name", - axum::http::HeaderValue::from_static(BITWARDEN_CLIENT), - ); - default_headers.insert( - "Bitwarden-Client-Version", - axum::http::HeaderValue::from_static(env!("CARGO_PKG_VERSION")), - ); - default_headers.append( - "Device-Type", - // unwrap is safe here because DEVICE_TYPE is a number and digits - // are valid ASCII - axum::http::HeaderValue::from_str(&DEVICE_TYPE.to_string()).unwrap(), - ); - let user_agent = format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); - if let Some(client_cert_path) = self.client_cert_path.as_ref() { - let buf = - tokio::fs::read(client_cert_path) - .await - .map_err(|e| Error::LoadClientCert { - source: e, - file: client_cert_path.clone(), - })?; - let pem = reqwest::Identity::from_pem(&buf) - .map_err(|e| Error::CreateReqwestClient { source: e })?; - Ok(reqwest::Client::builder() - .user_agent(user_agent) - .identity(pem) - .default_headers(default_headers) - .build() - .map_err(|e| Error::CreateReqwestClient { source: e })?) - } else { - Ok(reqwest::Client::builder() - .user_agent(user_agent) - .default_headers(default_headers) - .build() - .map_err(|e| Error::CreateReqwestClient { source: e })?) - } - } - - pub async fn prelogin(&self, email: &str) -> Result { - let res: PreloginRes = ClientRequest::Prelogin(email) - .req(self) - .await? - .json_with_path() - .await?; - - Ok(CryptoParameters { - kdf: res.kdf, - iterations: res.kdf_iterations, - memory: res.kdf_memory, - parallelism: res.kdf_parallelism, - }) - } - - async fn check_connect_token_res(res: reqwest::Response) -> Result { - match res.status() { - reqwest::StatusCode::OK => Ok(res), - status => match res.text().await { - Ok(body) => match body.clone().json_with_path::() { - Ok(err) => match err.try_into() { - Ok(e) => Err(e), - Err(err) => { - log::warn!("unexpected error received during login: {err:?}"); - Err(Error::RequestFailed { - status: status.as_u16(), - }) - } - }, - Err(e) => { - log::warn!("{e}: {body}"); - Err(Error::RequestFailed { - status: status.as_u16(), - }) - } - }, - Err(e) => { - log::warn!("failed to read response body: {e}"); - Err(Error::RequestFailed { - status: status.as_u16(), - }) - } - }, - } - } - - pub async fn register( - &self, - email: &str, - device_id: &str, - apikey: &crate::locked::ApiKey, - ) -> Result<()> { - let connect_req = ConnectTokenReq { - auth: ConnectTokenAuth::ClientCredentials { - username: &email, - client_secret: str::from_utf8(apikey.client_secret()).unwrap(), - }, - grant_type: "client_credentials", - scope: "api", - // XXX unwraps here are not necessarily safe - client_id: str::from_utf8(apikey.client_id()).unwrap(), - device_type: u32::from(DEVICE_TYPE), - device_identifier: device_id, - device_name: "rbw", - device_push_token: "", - two_factor_token: None, - two_factor_provider: None, - }; - - let res = ClientRequest::ConnectToken(connect_req).req(self).await?; - - Self::check_connect_token_res(res).await?; - - Ok(()) - } - - pub async fn login( - &self, - email: &str, - sso_id: Option<&str>, - device_id: &str, - password_hash: &crate::locked::PasswordHash, - two_factor_token: Option<&str>, - two_factor_provider: Option, - ) -> Result<(String, String, String)> { - let (auth, grant_type, scope) = match sso_id { - Some(sso_id) => { - let (sso_code, sso_code_verifier, callback_url) = - self.obtain_sso_code(sso_id).await?; - ( - ConnectTokenAuth::AuthCode { - code: &sso_code.clone(), - code_verifier: &sso_code_verifier.clone(), - redirect_uri: &callback_url.clone(), - }, - "authorization_code", - "api offline_access", - ) - } - None => ( - ConnectTokenAuth::Password { - username: email, - password: &crate::base64::encode(password_hash.hash()), - }, - "password", - "api offline_access", - ), - }; - - let connect_req = ConnectTokenReq { - auth, - grant_type: grant_type, - scope: scope, - client_id: "cli", - device_type: u32::from(DEVICE_TYPE), - device_identifier: device_id, - device_name: "rbw", - device_push_token: "", - two_factor_token: two_factor_token, - two_factor_provider: two_factor_provider.map(|ty| ty as u32), - }; - - let res = ClientRequest::Login(connect_req, email).req(self).await?; - - let res = Self::check_connect_token_res(res).await?; - - let connect_res: ConnectTokenRes = res.json_with_path().await?; - - Ok(( - connect_res.access_token, - connect_res.refresh_token, - connect_res.key, - )) - } - - pub async fn send_email_login( - &self, - email: &str, - device_id: &str, - sso_email_2fa_session_token: &str, - ) -> Result<()> { - let res = ClientRequest::SendEmailLogin(email, device_id, sso_email_2fa_session_token) - .req(self) - .await?; - - if res.status() == reqwest::StatusCode::OK { - Ok(()) - } else { - let code = res.status().as_u16(); - log::warn!("{code}: {:?}", res.text().await); - Err(Error::RequestFailed { status: code }) - } - } - - async fn obtain_sso_code(&self, sso_id: &str) -> Result<(String, String, String)> { - let state = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 64); - let sso_code_verifier = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 64); - - let mut hasher = sha2::Sha256::new(); - hasher.update(&sso_code_verifier); - let code_challenge = crate::base64::encode_url_safe_no_pad(hasher.finalize()); - - let port = find_free_port(8065, 8070).await?; - - let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)) - .await - .map_err(|e| Error::CreateSSOCallbackServer { err: e })?; - - let callback_server = start_sso_callback_server(listener, state.as_str()); - - let callback_url = "http://localhost:".to_string() + port.to_string().as_str(); - - open::that( - self.ui_url.clone() - + "/#/sso?clientId=" - + "cli" - + "&redirectUri=" - + urlencoding::encode(callback_url.as_str()) - .into_owned() - .as_str() - + "&state=" - + state.as_str() - + "&codeChallenge=" - + code_challenge.as_str() - + "&identifier=" - + sso_id, - ) - .map_err(|e| Error::FailedToOpenWebBrowser { err: e })?; - // TODO: probably it'd be better to display the URL in the console if the automatic - // open operation fails, instead of failing the whole process? E.g. docker container - // case - - let sso_code = callback_server.await?; - - Ok((sso_code, sso_code_verifier, callback_url)) - } - - pub async fn sync( - &self, - access_token: &str, - ) -> Result<( - String, - String, - HashMap, - Vec>, - )> { - let res = ClientRequest::Sync(access_token) - .req(self) - .await? - .error_for_status()?; - - let sync_res: SyncRes = res.json_with_path().await?; - - let ciphers = sync_res - .ciphers - .into_iter() - .filter_map(|cipher| cipher.into_entry(&sync_res.folders)) - .collect(); - - let org_keys = sync_res - .profile - .organizations - .iter() - .map(|org| (org.id.clone(), org.key.clone())) - .collect(); - - Ok(( - sync_res.profile.key, - sync_res.profile.private_key, - org_keys, - ciphers, - )) - } - - pub fn add( - &self, - access_token: &str, - name: &str, - data: &EntryData, - notes: Option<&str>, - folder_id: Option<&str>, - ) -> Result<()> { - let req = CiphersPostReq { - folder_id: folder_id, - name: name, - notes: notes, - data: EntryDataWire(data), - }; - - ClientBlockingRequest::Add(access_token, req) - .req(self)? - .error_for_status()?; - - Ok(()) - } - - pub fn edit(&self, access_token: &str, entry: &crate::db::Entry) -> Result<()> { - let req = CiphersPutReq { - folder_id: entry.folder_id.as_deref(), - organization_id: entry.org_id.as_deref(), - name: &entry.name, - notes: entry.notes.as_deref(), - data: EntryDataWire(&entry.data), - fields: &entry - .fields - .iter() - .map(|field| field.clone().into()) - .collect::>(), - password_history: &entry - .history - .iter() - .map(|entry| entry.clone().into()) - .collect::>(), - }; - - ClientBlockingRequest::Edit(access_token, &entry.id, req) - .req(self)? - .error_for_status()?; - - Ok(()) - } - - pub fn remove(&self, access_token: &str, id: &str) -> Result<()> { - ClientBlockingRequest::Remove(access_token, id) - .req(self)? - .error_for_status()?; - - Ok(()) - } - - pub fn folders(&self, access_token: &str) -> Result> { - let res = ClientBlockingRequest::Folders(access_token) - .req(self)? - .error_for_status()?; - - let folders_res: FoldersRes = res.json_with_path()?; - - Ok(folders_res - .data - .iter() - .map(|folder| (folder.id.clone(), folder.name.clone())) - .collect()) - } - - pub fn create_folder(&self, access_token: &str, name: &str) -> Result { - let res = ClientBlockingRequest::CreateFolder(access_token, name) - .req(self)? - .error_for_status()?; - - let folders_res: FoldersResData = res.json_with_path()?; - - Ok(folders_res.id) - } - - pub fn exchange_refresh_token(&self, refresh_token: &str) -> Result { - let res = ClientBlockingRequest::ExchangeRefreshToken(refresh_token).req(self)?; - let connect_res: ConnectRefreshTokenRes = res.json_with_path()?; - Ok(connect_res.access_token) - } - - pub async fn exchange_refresh_token_async(&self, refresh_token: &str) -> Result { - let res = ClientRequest::ExchangeRefreshToken(refresh_token) - .req(self) - .await?; - let connect_res: ConnectRefreshTokenRes = res.json_with_path().await?; - Ok(connect_res.access_token) - } - - fn api_url(&self, path: &str) -> String { - format!("{}{}", self.base_url, path) - } - - fn identity_url(&self, path: &str) -> String { - format!("{}{}", self.identity_url, path) - } -} - -async fn find_free_port(bottom: u16, top: u16) -> Result { - for port in bottom..top { - if tokio::net::TcpListener::bind(("127.0.0.1", port)) - .await - .is_ok() - { - return Ok(port); - } - } - - Err(Error::FailedToFindFreePort { - range: format!("({bottom}..{top})"), - }) -} - -#[derive(Clone)] -struct SSOHandlerState { - state: String, - sender: mpsc::Sender>, -} - -async fn start_sso_callback_server( - listener: tokio::net::TcpListener, - state: &str, -) -> Result { - let (shut_tx, mut shut_rx) = mpsc::channel(1); - let (tx, mut rx) = mpsc::channel(1); - - let sso_handler_state = Arc::new(SSOHandlerState { - state: state.to_string(), - sender: shut_tx, - }); - - let app = axum::Router::new() - .route("/", axum::routing::get(handle_sso_callback)) - .with_state(sso_handler_state); - - axum::serve(listener, app) - .with_graceful_shutdown( - async move { tx.send(shut_rx.recv().await.unwrap()).await.unwrap() }, - ) - .await - .map_err(|e| Error::FailedToProcessSSOCallback { msg: e.to_string() })?; - - rx.recv().await.unwrap() -} - -async fn handle_sso_callback( - axum::extract::State(state): axum::extract::State>, - axum::extract::Query(params): axum::extract::Query>, -) -> axum::http::Response { - match sso_query_code(¶ms, state.state.as_str()) { - Ok(sso_code) => { - state.sender.send(Ok(sso_code)).await.unwrap(); - - axum::http::Response::builder() - .status(axum::http::StatusCode::OK) - .body( - "Success | rbw \ -

Successfully authenticated with rbw

\ -

You may now close this tab and return to the terminal.

\ - " - .to_string(), - ) - .unwrap() - } - Err(e) => { - state.sender.send(Err(e)).await.unwrap(); - - axum::http::Response::builder() - .status(axum::http::StatusCode::BAD_REQUEST) - .body( - "Failed | rbw \ -

Something went wrong logging into the rbw

\ -

You may now close this tab and return to the terminal.

\ - " - .to_string(), - ) - .unwrap() - } - } -} - -fn sso_query_code(params: &HashMap, state: &str) -> Result { - let sso_code = params - .get("code") - .ok_or(Error::FailedToProcessSSOCallback { - msg: "Could not obtain code from the URL".to_string(), - })?; - - let received_state = params - .get("state") - .ok_or(Error::FailedToProcessSSOCallback { - msg: "Could not obtain state from the URL".to_string(), - })?; - - if received_state.split("_identifier=").next().unwrap() != state { - return Err(Error::FailedToProcessSSOCallback { - msg: format!( - "SSO callback states do not match, sent: {state}, received: {received_state}" - ), - }); - } - - Ok(sso_code.clone()) -} From 627a292835d43deb9dfe826f234b844c655071de Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 27 May 2026 01:34:41 +0200 Subject: [PATCH 179/273] improve readability of password history conversion --- src/api/mod.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 75cf25b9..a7c22178 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -805,15 +805,9 @@ impl SyncResCipher { return None; } - let history = self + let history: Vec = self .password_history - //.as_ref() - .map_or_else(Vec::new, |history| { - history - .into_iter() - .filter_map(Into::>::into) - .collect() - }); + .map_or(vec![], |e| e.into_iter().filter_map(Into::into).collect()); let (folder, folder_id) = self.folder_id.map_or((None, None), |folder_id| { let mut folder_name = None; From c9c1106d421baf54c6611fc4a86b0b78a8b82f35 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 27 May 2026 19:56:58 +0200 Subject: [PATCH 180/273] group config's urls resolving logic into one fn and add a line in client.rs --- src/api/client.rs | 1 + src/config.rs | 90 ++++++++++++++++++++++------------------------- 2 files changed, 44 insertions(+), 47 deletions(-) diff --git a/src/api/client.rs b/src/api/client.rs index f49e5a36..1fc62732 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -230,6 +230,7 @@ fn sso_query_code(params: &HashMap, state: &str) -> Result String { - self.base_url.clone().map_or_else( - || "https://api.bitwarden.com".to_string(), - |url| { - let clean_url = url.trim_end_matches('/'); - if clean_url == "https://api.bitwarden.eu" { - "https://api.bitwarden.eu".to_string() - } else { - format!("{clean_url}/api") - } - }, + self.resolve_url( + &self.base_url, + "https://api.bitwarden.com", + "https://api.bitwarden.eu", + Some("/api"), ) } pub fn identity_url(&self) -> String { - self.identity_url.clone().unwrap_or_else(|| { - self.base_url.clone().map_or_else( - || "https://identity.bitwarden.com".to_string(), - |url| { - let clean_url = url.trim_end_matches('/'); - if clean_url == "https://api.bitwarden.eu" { - "https://identity.bitwarden.eu".to_string() - } else { - format!("{clean_url}/identity") - } - }, - ) - }) + self.resolve_url( + &self.identity_url, + "https://identity.bitwarden.com", + "https://identity.bitwarden.eu", + Some("/identity"), + ) } pub fn ui_url(&self) -> String { - self.ui_url.clone().unwrap_or_else(|| { - self.base_url.clone().map_or_else( - || "https://vault.bitwarden.com".to_string(), - |url| { - let clean_url = url.trim_end_matches('/'); - if clean_url == "https://api.bitwarden.eu" { - "https://vault.bitwarden.eu".to_string() - } else { - clean_url.to_string() - } - }, - ) - }) + self.resolve_url( + &self.ui_url, + "https://vault.bitwarden.com", + "https://vault.bitwarden.eu", + None, + ) } pub fn notifications_url(&self) -> String { - self.notifications_url.clone().unwrap_or_else(|| { - self.base_url.clone().map_or_else( - || "https://notifications.bitwarden.com".to_string(), - |url| { - let clean_url = url.trim_end_matches('/'); - if clean_url == "https://api.bitwarden.eu" { - "https://notifications.bitwarden.eu".to_string() - } else { - format!("{clean_url}/notifications") + self.resolve_url( + &self.notifications_url, + "https://notifications.bitwarden.com", + "https://notifications.bitwarden.eu", + Some("/notifications"), + ) + } + + fn resolve_url( + &self, + explicit: &Option, + default: &str, + eu_url: &str, + suffix: Option<&str>, + ) -> String { + explicit.clone().unwrap_or_else(|| { + self.base_url.as_ref().map_or(default.to_string(), |u| { + let u = u.trim_end_matches('/').to_string(); + if u == "https://api.bitwarden.eu" { + eu_url.to_string() + } else { + match suffix { + Some(s) => u + s, + None => u, } - }, - ) + } + }) }) } From 25816d69339a39e4f19d513edde68078a5cfb55c Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 27 May 2026 20:00:02 +0200 Subject: [PATCH 181/273] move two constants near their users --- src/bin/rbw/commands.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index cd5ac005..24e4f304 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -8,20 +8,6 @@ use rbw::db::{Decrypted, Decrypter, Encrypted, Encrypter, EntryData}; use crate::FindArgs; -// The default number of seconds the generated TOTP -// code lasts for before a new one must be generated -const TOTP_DEFAULT_STEP: u64 = 30; - -const MISSING_CONFIG_HELP: &str = - "Before using rbw, you must configure the email address you would like to \ - use to log in to the server by running:\n\n \ - rbw config set email \n\n\ - Additionally, if you are using a self-hosted installation, you should \ - run:\n\n \ - rbw config set base_url \n\n\ - and, if your server has a non-default identity url:\n\n \ - rbw config set identity_url \n"; - #[derive(Debug, Clone)] pub enum Needle { Name(String), @@ -1123,6 +1109,16 @@ fn run_agent() -> anyhow::Result<()> { Ok(()) } +const MISSING_CONFIG_HELP: &str = + "Before using rbw, you must configure the email address you would like to \ + use to log in to the server by running:\n\n \ + rbw config set email \n\n\ + Additionally, if you are using a self-hosted installation, you should \ + run:\n\n \ + rbw config set base_url \n\n\ + and, if your server has a non-default identity url:\n\n \ + rbw config set identity_url \n"; + fn check_config() -> anyhow::Result<()> { rbw::config::Config::validate().map_err(|e| { log::error!("{MISSING_CONFIG_HELP}"); @@ -1189,6 +1185,10 @@ fn decode_totp_secret(secret: &str) -> anyhow::Result> { Err(anyhow::anyhow!("totp secret was not valid base32")) } +// The default number of seconds the generated TOTP +// code lasts for before a new one must be generated +const TOTP_DEFAULT_STEP: u64 = 30; + fn generate_totp(secret: &str) -> anyhow::Result { // Small hack that is not RFC compliant but helps with some services. // Most authenticators have this built-in, included official Bitwarden clients. From 8c5c7d80703d86f2d62bb8f798b52cbba5046184 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 27 May 2026 20:01:26 +0200 Subject: [PATCH 182/273] move encrypter/decrypter definitions up --- src/bin/rbw/commands.rs | 68 ++++++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 24e4f304..f49c2246 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -8,6 +8,40 @@ use rbw::db::{Decrypted, Decrypter, Encrypted, Encrypter, EntryData}; use crate::FindArgs; +/// This Encrypter implementation will send the decrypted string to the agent and wait for it to +/// encrypt it. +struct RemoteEncrypter {} + +impl rbw::db::Encrypter for RemoteEncrypter { + fn encrypt_field( + &mut self, + entry: Option<&rbw::db::Entry>, + field: &str, + ) -> rbw::error::Result { + crate::actions::encrypt(field, entry.and_then(|e| e.org_id.as_deref())) + .map_err(|_e| rbw::error::Error::EncryptRemote) + } +} + +/// This Decrypter implementation will send the encrypted string to the agent and wait for it to +/// decrypt it. +struct RemoteDecrypter {} + +impl rbw::db::Decrypter for RemoteDecrypter { + fn decrypt_field( + &mut self, + entry: Option<&rbw::db::Entry>, + field: &str, + ) -> rbw::error::Result { + crate::actions::decrypt( + field, + entry.and_then(|e| e.key.as_deref()), + entry.and_then(|e| e.org_id.as_deref()), + ) + .map_err(|_e| rbw::error::Error::DecryptRemote) + } +} + #[derive(Debug, Clone)] pub enum Needle { Name(String), @@ -43,40 +77,6 @@ impl FromStr for Needle { } } -/// This Encrypter implementation will send the decrypted string to the agent and wait for it to -/// encrypt it. -struct RemoteEncrypter {} - -impl rbw::db::Encrypter for RemoteEncrypter { - fn encrypt_field( - &mut self, - entry: Option<&rbw::db::Entry>, - field: &str, - ) -> rbw::error::Result { - crate::actions::encrypt(field, entry.and_then(|e| e.org_id.as_deref())) - .map_err(|_e| rbw::error::Error::EncryptRemote) - } -} - -/// This Decrypter implementation will send the encrypted string to the agent and wait for it to -/// decrypt it. -struct RemoteDecrypter {} - -impl rbw::db::Decrypter for RemoteDecrypter { - fn decrypt_field( - &mut self, - entry: Option<&rbw::db::Entry>, - field: &str, - ) -> rbw::error::Result { - crate::actions::decrypt( - field, - entry.and_then(|e| e.key.as_deref()), - entry.and_then(|e| e.org_id.as_deref()), - ) - .map_err(|_e| rbw::error::Error::DecryptRemote) - } -} - #[derive(Debug, Clone, serde::Serialize)] #[cfg_attr(test, derive(Eq, PartialEq))] struct SearchEntry { From cf77f246793039695f2c5168a8f4cda9670adf37 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 28 May 2026 10:12:08 +0200 Subject: [PATCH 183/273] fix broken protocol version calculation --- src/protocol.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/protocol.rs b/src/protocol.rs index e3b72c0d..8a9a43ef 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1,20 +1,18 @@ use std::os::unix::ffi::{OsStrExt as _, OsStringExt as _}; pub const VERSION: u32 = { - const fn unwrap(res: &Result) -> u32 { - match res { - Ok(t) => *t, + const fn parse_component(s: &str) -> u32 { + match u32::from_str_radix(s, 10) { + Ok(n) => n, Err(_) => panic!("failed to parse cargo version"), } } - let major = env!("CARGO_PKG_VERSION_MAJOR"); - let minor = env!("CARGO_PKG_VERSION_MINOR"); - let patch = env!("CARGO_PKG_VERSION_PATCH"); + let major = parse_component(env!("CARGO_PKG_VERSION_MAJOR")); + let minor = parse_component(env!("CARGO_PKG_VERSION_MINOR")); + let patch = parse_component(env!("CARGO_PKG_VERSION_PATCH")); - unwrap(&u32::from_str_radix(major, 10)) * 1_000_000 - + unwrap(&u32::from_str_radix(minor, 10)) * 1_000_000 - + unwrap(&u32::from_str_radix(patch, 10)) * 1_000_000 + major * 1_000_000 + minor * 1_000 + patch }; #[derive(serde::Serialize, serde::Deserialize, Debug)] From 7ab7b6d49c3ee8eea40bc169111593951dc45c3f Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 28 May 2026 10:30:44 +0200 Subject: [PATCH 184/273] remove duplicate errors since tokio::io::Error == std::io::Error --- src/config.rs | 4 ++-- src/db.rs | 10 +++++----- src/error.rs | 18 ------------------ 3 files changed, 7 insertions(+), 25 deletions(-) diff --git a/src/config.rs b/src/config.rs index 7a96bb62..2377327c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -91,14 +91,14 @@ impl Config { let mut fh = tokio::fs::File::open(&file) .await - .map_err(|source| Error::LoadConfigAsync { + .map_err(|source| Error::LoadConfig { source, file: file.clone(), })?; let mut json = String::new(); fh.read_to_string(&mut json) .await - .map_err(|source| Error::LoadConfigAsync { + .map_err(|source| Error::LoadConfig { source, file: file.clone(), })?; diff --git a/src/db.rs b/src/db.rs index 3152c6df..01158fba 100644 --- a/src/db.rs +++ b/src/db.rs @@ -908,14 +908,14 @@ impl Db { let file = crate::dirs::db_file(server, email)?; let mut fh = tokio::fs::File::open(&file) .await - .map_err(|source| Error::LoadDbAsync { + .map_err(|source| Error::LoadDb { source, file: file.clone(), })?; let mut json = String::new(); fh.read_to_string(&mut json) .await - .map_err(|source| Error::LoadDbAsync { + .map_err(|source| Error::LoadDb { source, file: file.clone(), })?; @@ -1002,13 +1002,13 @@ impl Db { // constructed as a filename in a directory tokio::fs::create_dir_all(file.parent().unwrap()) .await - .map_err(|source| Error::SaveDbAsync { + .map_err(|source| Error::SaveDb { source, file: file.clone(), })?; let mut fh = tokio::fs::File::create(&file) .await - .map_err(|source| Error::SaveDbAsync { + .map_err(|source| Error::SaveDb { source, file: file.clone(), })?; @@ -1021,7 +1021,7 @@ impl Db { .as_bytes(), ) .await - .map_err(|source| Error::SaveDbAsync { source, file })?; + .map_err(|source| Error::SaveDb { source, file })?; Ok(()) } diff --git a/src/error.rs b/src/error.rs index c70e3e9e..64468f9b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -109,12 +109,6 @@ pub enum Error { file: std::path::PathBuf, }, - #[error("failed to load config from {}", .file.display())] - LoadConfigAsync { - source: tokio::io::Error, - file: std::path::PathBuf, - }, - #[error("failed to load config from {}", .file.display())] LoadConfigJson { source: serde_json::Error, @@ -127,12 +121,6 @@ pub enum Error { file: std::path::PathBuf, }, - #[error("failed to load db from {}", .file.display())] - LoadDbAsync { - source: tokio::io::Error, - file: std::path::PathBuf, - }, - #[error("failed to load db from {}", .file.display())] LoadDbJson { source: serde_json::Error, @@ -220,12 +208,6 @@ pub enum Error { file: std::path::PathBuf, }, - #[error("failed to save db to {}", .file.display())] - SaveDbAsync { - source: tokio::io::Error, - file: std::path::PathBuf, - }, - #[error("failed to save db to {}", .file.display())] SaveDbJson { source: serde_json::Error, From 3be2a951dab524e09f87747858261b9e2fc2f5ff Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 28 May 2026 15:55:57 +0200 Subject: [PATCH 185/273] load config only at startup and setup for state interior mutability pattern this is mostly a test to see whether the interior mutability can be used within the State object --- src/bin/rbw-agent/actions.rs | 193 +++++++++++++++++++-------------- src/bin/rbw-agent/agent.rs | 2 +- src/bin/rbw-agent/main.rs | 5 +- src/bin/rbw-agent/ssh_agent.rs | 35 +++--- src/bin/rbw-agent/state.rs | 35 ++++++ src/bin/rbw/commands.rs | 1 + 6 files changed, 166 insertions(+), 105 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 80517efd..88541e2e 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -6,29 +6,24 @@ use sha2::Digest as _; use tokio::sync::Mutex; async fn getpin( + pinentry: &str, desc: &str, prompt: &str, err: &Option, environment: &rbw::protocol::Environment, grab: bool, ) -> anyhow::Result { - Ok(rbw::pinentry::getpin( - &config_pinentry().await?, - prompt, - desc, - err.as_deref(), - environment, - grab, - ) - .await?) + Ok(rbw::pinentry::getpin(pinentry, prompt, desc, err.as_deref(), environment, grab).await?) } async fn get_client_id( + pinentry: &str, host: &str, err: &Option, environment: &rbw::protocol::Environment, ) -> anyhow::Result { getpin( + pinentry, "API key client__id", &format!("Log in to {host}"), err, @@ -40,11 +35,13 @@ async fn get_client_id( } async fn get_client_secret( + pinentry: &str, host: &str, err: &Option, environment: &rbw::protocol::Environment, ) -> anyhow::Result { getpin( + pinentry, "API key client__secret", &format!("Log in to {host}"), err, @@ -55,8 +52,8 @@ async fn get_client_secret( .context("failed to read client_secret from pinentry") } -async fn get_host() -> anyhow::Result { - let url_str = config_base_url().await?; +fn get_host(state: &crate::state::State) -> anyhow::Result { + let url_str = state.base_url(); let url = reqwest::Url::parse(&url_str).context("failed to parse base url")?; let Some(host) = url.host_str() else { return Err(anyhow::anyhow!( @@ -69,23 +66,38 @@ async fn get_host() -> anyhow::Result { pub async fn register( sock: &mut crate::sock::Sock, + state: Arc>, environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { - let db = load_db().await.unwrap_or_else(|_| rbw::db::Db::new()); + let db = { + let guard = state.lock().await; + load_db(&guard).await.unwrap_or_else(|_| rbw::db::Db::new()) + }; if !db.needs_login() { return respond_ack(sock).await; } - let host = get_host().await?; + let host = { + let guard = state.lock().await; + get_host(&guard)? + }; + + let email = { + let guard = state.lock().await; + guard.email()?.to_string() + }; - let email = config_email().await?; + let pinentry = { + let guard = state.lock().await; + guard.pinentry().to_string() + }; let mut err_msg = None; for i in 1_u8..=3 { let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); - let client_id = get_client_id(&host, &err, environment).await?; - let client_secret = get_client_secret(&host, &err, environment).await?; + let client_id = get_client_id(&pinentry, &host, &err, environment).await?; + let client_secret = get_client_secret(&pinentry, &host, &err, environment).await?; let apikey = rbw::locked::ApiKey::new(client_id, client_secret); @@ -106,17 +118,19 @@ pub async fn register( } async fn get_password( + pinentry: &str, desc: &str, err: &Option, environment: &rbw::protocol::Environment, ) -> anyhow::Result { - getpin("Master Password", desc, err, environment, true) + getpin(pinentry, "Master Password", desc, err, environment, true) .await .context("failed to read password from pinentry") } async fn two_factor_required( state: &Arc>, + pinentry: &str, email: &str, password: rbw::locked::Password, providers: Vec, @@ -142,7 +156,7 @@ async fn two_factor_required( } } - let creds = two_factor(environment, email, password.clone(), provider).await?; + let creds = two_factor(pinentry, environment, email, password.clone(), provider).await?; login_success(state.clone(), creds, password, db, email).await } @@ -152,23 +166,37 @@ pub async fn login( state: Arc>, environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { - let mut db = load_db().await.unwrap_or_else(|_| rbw::db::Db::new()); + let mut db = { + let guard = state.lock().await; + load_db(&guard).await.unwrap_or_else(|_| rbw::db::Db::new()) + }; if !db.needs_login() { return respond_ack(sock).await; } - let host = get_host().await?; + let host = { + let guard = state.lock().await; + get_host(&guard)? + }; - let email = config_email().await?; + let email = { + let guard = state.lock().await; + guard.email()?.to_string() + }; + let pinentry = { + let guard = state.lock().await; + guard.pinentry().to_string() + }; let mut err_msg = None; for i in 1_u8..=3 { let err = err_msg .as_deref() .map(|msg| format!("{msg} (attempt {i}/3)")); - let password = get_password(&format!("Log in to {host}"), &err, environment).await?; + let password = + get_password(&pinentry, &format!("Log in to {host}"), &err, environment).await?; match rbw::actions::login(&email, password.clone(), None, None).await { Ok(creds) => { @@ -182,6 +210,7 @@ pub async fn login( }) => { two_factor_required( &state, + &pinentry, &email, password, providers, @@ -206,11 +235,13 @@ pub async fn login( } async fn get_code( + pinentry: &str, provider: rbw::api::TwoFactorProviderType, err: &Option, environment: &rbw::protocol::Environment, ) -> anyhow::Result { getpin( + pinentry, provider.header(), provider.message(), err, @@ -222,6 +253,7 @@ async fn get_code( } async fn two_factor( + pinentry: &str, environment: &rbw::protocol::Environment, email: &str, password: rbw::locked::Password, @@ -231,7 +263,7 @@ async fn two_factor( for i in 1_u8..=3 { let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); - let code = get_code(provider, &err, environment).await?; + let code = get_code(pinentry, provider, &err, environment).await?; let code = std::str::from_utf8(code.password()).context("code was not valid utf8")?; match rbw::actions::login(email, password.clone(), Some(code), Some(provider)).await { @@ -259,11 +291,17 @@ async fn login_success( ) -> anyhow::Result<()> { db.apply_session_parameters(&creds); - save_db(db).await?; + { + let guard = state.lock().await; + save_db(&guard, db).await?; + } sync(None, state.clone()).await?; - let db = load_db().await?; + let db = { + let guard = state.lock().await; + load_db(&guard).await? + }; let Some(protected_private_key) = db.protected_private_key else { return Err(anyhow::anyhow!( @@ -297,7 +335,12 @@ async fn unlock_state( environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { if state.lock().await.needs_unlock() { - let db = load_db().await?; + let (db, email) = { + let guard = state.lock().await; + let db = load_db(&guard).await?; + let email = guard.email()?.to_string(); + (db, email) + }; let crypto_params = db.get_crypto_parameters()?; @@ -311,13 +354,13 @@ async fn unlock_state( )); }; - let email = config_email().await?; - + let pinentry = state.lock().await.pinentry().to_string(); let mut err_msg = None; for i in 1_u8..=3 { let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); let password = get_password( + &pinentry, &format!("Unlock the local database for '{}'", rbw::dirs::profile()), &err, environment, @@ -398,7 +441,10 @@ pub async fn sync( sock: Option<&mut crate::sock::Sock>, state: Arc>, ) -> anyhow::Result<()> { - let mut db = load_db().await?; + let mut db = { + let guard = state.lock().await; + load_db(&guard).await? + }; let Some(access_token) = &db.access_token else { anyhow::bail!("failed to find access token in db"); @@ -421,7 +467,10 @@ pub async fn sync( db.protected_org_keys = protected_org_keys; db.entries = entries; - save_db(&db).await?; + { + let guard = state.lock().await; + save_db(&guard, &db).await?; + } if let Err(e) = subscribe_to_notifications(state.clone()).await { eprintln!("failed to subscribe to notifications: {e}"); @@ -463,7 +512,7 @@ async fn maybe_reprompt_password( .master_password_reprompt .contains(&master_password_reprompt) { - let db = load_db().await?; + let db = load_db(state).await?; let crypto_params = db.get_crypto_parameters()?; @@ -477,14 +526,16 @@ async fn maybe_reprompt_password( )); }; - let email = config_email().await?; + let email = state.email()?; + let pinentry = state.pinentry().to_string(); let mut err_msg = None; for i in 1_u8..=3 { let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); // TODO: Remember somewhere that only GUI pinentry work, since this is a daemon. let password = get_password( + &pinentry, "Accessing this entry requires the master password", &err, environment, @@ -523,7 +574,7 @@ async fn decrypt_cipher( let mut state = state.lock().await; if !state.master_password_reprompt_initialized() { - let db = load_db().await?; + let db = load_db(&state).await?; state.set_master_password_reprompt(&db.entries); } @@ -647,43 +698,18 @@ async fn respond_encrypt(sock: &mut crate::sock::Sock, cipherstring: String) -> Ok(()) } -async fn config_email() -> anyhow::Result { - let config = rbw::config::Config::load_async().await?; - config - .email - .ok_or(anyhow::anyhow!("failed to find email address in config")) -} - -async fn load_db() -> anyhow::Result { - let config = rbw::config::Config::load_async().await?; - if let Some(email) = &config.email { - rbw::db::Db::load_async(&config.server_name(), email) - .await - .map_err(anyhow::Error::new) - } else { - Err(anyhow::anyhow!("failed to find email address in config")) - } -} - -async fn save_db(db: &rbw::db::Db) -> anyhow::Result<()> { - let config = rbw::config::Config::load_async().await?; - if let Some(email) = &config.email { - db.save_async(&config.server_name(), email) - .await - .map_err(anyhow::Error::new) - } else { - Err(anyhow::anyhow!("failed to find email address in config")) - } -} - -async fn config_base_url() -> anyhow::Result { - let config = rbw::config::Config::load_async().await?; - Ok(config.base_url()) +async fn load_db(state: &crate::state::State) -> anyhow::Result { + let email = state.email()?; + rbw::db::Db::load_async(&state.server_name(), email) + .await + .map_err(anyhow::Error::new) } -async fn config_pinentry() -> anyhow::Result { - let config = rbw::config::Config::load_async().await?; - Ok(config.pinentry) +async fn save_db(state: &crate::state::State, db: &rbw::db::Db) -> anyhow::Result<()> { + let email = state.email()?; + db.save_async(&state.server_name(), email) + .await + .map_err(anyhow::Error::new) } pub async fn subscribe_to_notifications( @@ -693,19 +719,18 @@ pub async fn subscribe_to_notifications( return Ok(()); } - let config = rbw::config::Config::load_async() - .await - .context("Config is missing")?; - let email = config.email.clone().context("Config is missing email")?; - let db = rbw::db::Db::load_async(config.server_name().as_str(), &email).await?; + let (email, server_name, notifications_url) = { + let guard = state.lock().await; + let email = guard.email()?.to_string(); + let server_name = guard.server_name(); + let notifications_url = guard.notifications_url(); + (email, server_name, notifications_url) + }; + let db = rbw::db::Db::load_async(&server_name, &email).await?; let access_token = db.access_token.context("Error getting access token")?; - let websocket_url = format!( - "{}/hub?access_token={}", - config.notifications_url(), - access_token - ) - .replace("https://", "wss://"); + let websocket_url = format!("{}/hub?access_token={}", notifications_url, access_token) + .replace("https://", "wss://"); let mut state = state.lock().await; state @@ -727,7 +752,10 @@ pub async fn get_ssh_public_keys( unlock_state(state.clone(), &environment).await?; - let db = load_db().await?; + let db = { + let guard = state.lock().await; + load_db(&guard).await? + }; let mut pubkeys = Vec::new(); for entry in db.entries { @@ -766,7 +794,10 @@ pub async fn find_ssh_private_key( let request_bytes = request_public_key.to_bytes(); - let db = load_db().await?; + let db = { + let guard = state.lock().await; + load_db(&guard).await? + }; for entry in db.entries { let rbw::db::EntryData::SshKey { diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index aaa980eb..793bf07c 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -113,7 +113,7 @@ async fn handle_request( let (action, environment) = req.into_parts(); let set_timeout = match &action { rbw::protocol::Action::Register => { - crate::actions::register(sock, &environment).await?; + crate::actions::register(sock, state.clone(), &environment).await?; true } rbw::protocol::Action::Login => { diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index 68f5f15d..ec840e0b 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use anyhow::Context as _; mod actions; @@ -26,7 +28,7 @@ async fn async_main(startup_ack: Option) -> anyhow::R sync_timeout.set(sync_timeout_duration); } let notifications_handler = crate::notifications::NotificationsHandler::new(); - let state = std::sync::Arc::new(tokio::sync::Mutex::new(crate::state::State { + let state = Arc::new(tokio::sync::Mutex::new(crate::state::State { priv_key: None, org_keys: None, timeout, @@ -37,6 +39,7 @@ async fn async_main(startup_ack: Option) -> anyhow::R master_password_reprompt: std::collections::HashSet::new(), master_password_reprompt_initialized: false, last_environment: rbw::protocol::Environment::default(), + inner: Arc::new(crate::state::InnerState { config }), #[cfg(feature = "clipboard")] clipboard: arboard::Clipboard::new() .inspect_err(|e| { diff --git a/src/bin/rbw-agent/ssh_agent.rs b/src/bin/rbw-agent/ssh_agent.rs index 00420979..885ffed7 100644 --- a/src/bin/rbw-agent/ssh_agent.rs +++ b/src/bin/rbw-agent/ssh_agent.rs @@ -6,16 +6,6 @@ use tokio::sync::Mutex; const SSH_AGENT_RSA_SHA2_256: u32 = 2; const SSH_AGENT_RSA_SHA2_512: u32 = 4; -async fn config_pinentry() -> anyhow::Result { - let config = rbw::config::Config::load_async().await?; - Ok(config.pinentry) -} - -async fn config_confirm_ssh() -> anyhow::Result { - let config = rbw::config::Config::load_async().await?; - Ok(config.confirm_ssh.is_some_and(|o| o)) -} - #[derive(Clone)] pub struct SshAgent { state: Arc>, @@ -68,19 +58,20 @@ impl ssh_agent_lib::agent::Session for SshAgent { .await .map_err(|e| ssh_agent_lib::error::AgentError::Other(e.into()))?; - if config_confirm_ssh().await.map_err(|_| { - ssh_agent_lib::error::AgentError::Other("Unable to load configuration".into()) - })? { - let confirmed = rbw::pinentry::confirm( - &config_pinentry() - .await - .map_err(|_| ssh_agent_lib::error::AgentError::Failure)?, - "Allow SSH key use?", - &self.state.lock().await.last_environment, - true, + let (confirm_ssh, pinentry, last_environment) = { + let guard = self.state.lock().await; + ( + guard.confirm_ssh(), + guard.pinentry().to_string(), + guard.last_environment().clone(), ) - .await - .map_err(|_| ssh_agent_lib::error::AgentError::Failure)?; + }; + + if confirm_ssh { + let confirmed = + rbw::pinentry::confirm(&pinentry, "Allow SSH key use?", &last_environment, true) + .await + .map_err(|_| ssh_agent_lib::error::AgentError::Failure)?; if !confirmed { return Err(ssh_agent_lib::error::AgentError::Other( diff --git a/src/bin/rbw-agent/state.rs b/src/bin/rbw-agent/state.rs index 036511df..9039794f 100644 --- a/src/bin/rbw-agent/state.rs +++ b/src/bin/rbw-agent/state.rs @@ -1,5 +1,11 @@ +use std::sync::Arc; + use sha2::Digest as _; +pub struct InnerState { + pub config: rbw::config::Config, +} + pub struct State { pub priv_key: Option, pub org_keys: Option>, @@ -22,6 +28,7 @@ pub struct State { // we should not use this for any requests on the main agent, those // should all send their own environment over. pub last_environment: rbw::protocol::Environment, + pub inner: Arc, #[cfg(feature = "clipboard")] pub clipboard: Option, @@ -136,4 +143,32 @@ impl State { pub fn set_last_environment(&mut self, environment: rbw::protocol::Environment) { self.last_environment = environment; } + + pub fn email(&self) -> anyhow::Result<&str> { + self.inner + .config + .email + .as_deref() + .ok_or_else(|| anyhow::anyhow!("failed to find email address in config")) + } + + pub fn base_url(&self) -> String { + self.inner.config.base_url() + } + + pub fn pinentry(&self) -> &str { + &self.inner.config.pinentry + } + + pub fn notifications_url(&self) -> String { + self.inner.config.notifications_url() + } + + pub fn server_name(&self) -> String { + self.inner.config.server_name() + } + + pub fn confirm_ssh(&self) -> bool { + self.inner.config.confirm_ssh.is_some_and(|o| o) + } } diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index f49c2246..fe1114bf 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -343,6 +343,7 @@ pub fn config_show() -> anyhow::Result<()> { Ok(()) } +// TODO: Make this a Config method pub fn config_set(key: &str, value: &str) -> anyhow::Result<()> { let mut config = rbw::config::Config::load().unwrap_or_else(|_| rbw::config::Config::new()); match key { From 4f184aa8a6ec3e98b1f71c565733332a970fbcb0 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 28 May 2026 16:21:58 +0200 Subject: [PATCH 186/273] move last_environment inside the inner state --- src/bin/rbw-agent/actions.rs | 6 ++++-- src/bin/rbw-agent/agent.rs | 6 ++++-- src/bin/rbw-agent/main.rs | 7 +++++-- src/bin/rbw-agent/ssh_agent.rs | 9 +++++---- src/bin/rbw-agent/state.rs | 13 ++++++++----- 5 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 88541e2e..09e256f3 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -746,8 +746,9 @@ pub async fn get_ssh_public_keys( ) -> anyhow::Result> { let environment = { let state = state.lock().await; + let le = state.last_environment().await; state.set_timeout(); - state.last_environment().clone() + le.clone() }; unlock_state(state.clone(), &environment).await?; @@ -786,8 +787,9 @@ pub async fn find_ssh_private_key( ) -> anyhow::Result { let environment = { let state = state.lock().await; + let le = state.last_environment().await; state.set_timeout(); - state.last_environment().clone() + le.clone() }; unlock_state(state.clone(), &environment).await?; diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index 793bf07c..e3dae96d 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -167,8 +167,10 @@ async fn handle_request( } }; - let mut state = state.lock().await; - state.set_last_environment(environment); + let state = state.lock().await; + + state.set_last_environment(environment).await; + if set_timeout { state.set_timeout(); } diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index ec840e0b..2aa861e0 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use anyhow::Context as _; +use tokio::sync::RwLock; mod actions; mod agent; @@ -38,8 +39,10 @@ async fn async_main(startup_ack: Option) -> anyhow::R notifications_handler, master_password_reprompt: std::collections::HashSet::new(), master_password_reprompt_initialized: false, - last_environment: rbw::protocol::Environment::default(), - inner: Arc::new(crate::state::InnerState { config }), + inner: Arc::new(crate::state::InnerState { + config, + last_environment: RwLock::new(rbw::protocol::Environment::default()), + }), #[cfg(feature = "clipboard")] clipboard: arboard::Clipboard::new() .inspect_err(|e| { diff --git a/src/bin/rbw-agent/ssh_agent.rs b/src/bin/rbw-agent/ssh_agent.rs index 885ffed7..52931ca2 100644 --- a/src/bin/rbw-agent/ssh_agent.rs +++ b/src/bin/rbw-agent/ssh_agent.rs @@ -59,11 +59,12 @@ impl ssh_agent_lib::agent::Session for SshAgent { .map_err(|e| ssh_agent_lib::error::AgentError::Other(e.into()))?; let (confirm_ssh, pinentry, last_environment) = { - let guard = self.state.lock().await; + let state = self.state.lock().await; + let le = state.last_environment().await; ( - guard.confirm_ssh(), - guard.pinentry().to_string(), - guard.last_environment().clone(), + state.confirm_ssh(), + state.pinentry().to_string(), + le.clone(), ) }; diff --git a/src/bin/rbw-agent/state.rs b/src/bin/rbw-agent/state.rs index 9039794f..bfec88df 100644 --- a/src/bin/rbw-agent/state.rs +++ b/src/bin/rbw-agent/state.rs @@ -1,9 +1,11 @@ use std::sync::Arc; use sha2::Digest as _; +use tokio::sync::RwLock; pub struct InnerState { pub config: rbw::config::Config, + pub last_environment: RwLock, } pub struct State { @@ -27,7 +29,6 @@ pub struct State { // // we should not use this for any requests on the main agent, those // should all send their own environment over. - pub last_environment: rbw::protocol::Environment, pub inner: Arc, #[cfg(feature = "clipboard")] @@ -136,12 +137,14 @@ impl State { self.master_password_reprompt_initialized } - pub fn last_environment(&self) -> &rbw::protocol::Environment { - &self.last_environment + pub async fn last_environment( + &self, + ) -> tokio::sync::RwLockReadGuard<'_, rbw::protocol::Environment> { + self.inner.last_environment.read().await } - pub fn set_last_environment(&mut self, environment: rbw::protocol::Environment) { - self.last_environment = environment; + pub async fn set_last_environment(&self, environment: rbw::protocol::Environment) { + *self.inner.last_environment.write().await = environment; } pub fn email(&self) -> anyhow::Result<&str> { From c9d032bdf2a0bd9dc9a44daf5736a7082325398a Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 28 May 2026 17:14:48 +0200 Subject: [PATCH 187/273] move keys inside the inner state --- src/bin/rbw-agent/actions.rs | 28 +++++++++++------------ src/bin/rbw-agent/agent.rs | 2 +- src/bin/rbw-agent/main.rs | 4 ++-- src/bin/rbw-agent/state.rs | 44 ++++++++++++++++++++++++++---------- 4 files changed, 49 insertions(+), 29 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 09e256f3..ff0a5751 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -320,9 +320,9 @@ 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); + let state = state.lock().await; + state.set_priv_key(keys).await; + state.set_org_keys(org_keys).await; } Err(e) => return Err(e).context("failed to unlock database"), } @@ -334,7 +334,7 @@ async fn unlock_state( state: Arc>, environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { - if state.lock().await.needs_unlock() { + if state.lock().await.needs_unlock().await { let (db, email) = { let guard = state.lock().await; let db = load_db(&guard).await?; @@ -407,9 +407,9 @@ async fn unlock_success( keys: rbw::locked::Keys, org_keys: std::collections::HashMap, ) -> anyhow::Result<()> { - let mut state = state.lock().await; - state.priv_key = Some(keys); - state.org_keys = Some(org_keys); + let state = state.lock().await; + state.set_priv_key(keys).await; + state.set_org_keys(org_keys).await; Ok(()) } @@ -417,7 +417,7 @@ pub async fn lock( sock: &mut crate::sock::Sock, state: Arc>, ) -> anyhow::Result<()> { - state.lock().await.clear(); + state.lock().await.clear().await; respond_ack(sock).await?; @@ -428,7 +428,7 @@ pub async fn check_lock( sock: &mut crate::sock::Sock, state: Arc>, ) -> anyhow::Result<()> { - if state.lock().await.needs_unlock() { + if state.lock().await.needs_unlock().await { return Err(anyhow::anyhow!("agent is locked")); } @@ -578,13 +578,13 @@ async fn decrypt_cipher( state.set_master_password_reprompt(&db.entries); } - let Some(keys) = state.key(org_id) else { + let Some(keys) = state.key(org_id).await else { return Err(anyhow::anyhow!( "failed to find decryption keys in in-memory state" )); }; - let entry_key = decrypt_entry_key(entry_key, keys)?; + let entry_key = decrypt_entry_key(entry_key, keys.as_ref())?; maybe_reprompt_password(&state, environment, cipherstring).await?; @@ -593,7 +593,7 @@ async fn decrypt_cipher( let plaintext = String::from_utf8( cipherstring - .decrypt_symmetric(keys, entry_key.as_ref()) + .decrypt_symmetric(keys.as_ref(), entry_key.as_ref()) .context("failed to decrypt encrypted secret")?, ) .context("failed to parse decrypted secret")?; @@ -622,13 +622,13 @@ pub async fn encrypt( org_id: Option<&str>, ) -> anyhow::Result<()> { let state = state.lock().await; - let Some(keys) = state.key(org_id) else { + let Some(keys) = state.key(org_id).await else { return Err(anyhow::anyhow!( "failed to find encryption keys in in-memory state" )); }; let cipherstring = - rbw::cipherstring::CipherString::encrypt_symmetric(keys, plaintext.as_bytes()) + rbw::cipherstring::CipherString::encrypt_symmetric(keys.as_ref(), plaintext.as_bytes()) .context("failed to encrypt plaintext secret")?; respond_encrypt(sock, cipherstring.to_string()).await?; diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index e3dae96d..8142a033 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -79,7 +79,7 @@ impl Agent { }); } Event::Timeout(()) => { - self.state.lock().await.clear(); + self.state.lock().await.clear().await; } Event::Sync(()) => { let state = self.state.clone(); diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index 2aa861e0..af0fb545 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -30,8 +30,6 @@ async fn async_main(startup_ack: Option) -> anyhow::R } let notifications_handler = crate::notifications::NotificationsHandler::new(); let state = Arc::new(tokio::sync::Mutex::new(crate::state::State { - priv_key: None, - org_keys: None, timeout, timeout_duration, sync_timeout, @@ -40,6 +38,8 @@ async fn async_main(startup_ack: Option) -> anyhow::R master_password_reprompt: std::collections::HashSet::new(), master_password_reprompt_initialized: false, inner: Arc::new(crate::state::InnerState { + priv_key: RwLock::new(None), + org_keys: RwLock::new(None), config, last_environment: RwLock::new(rbw::protocol::Environment::default()), }), diff --git a/src/bin/rbw-agent/state.rs b/src/bin/rbw-agent/state.rs index bfec88df..ce92d34a 100644 --- a/src/bin/rbw-agent/state.rs +++ b/src/bin/rbw-agent/state.rs @@ -1,16 +1,16 @@ -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; use sha2::Digest as _; use tokio::sync::RwLock; pub struct InnerState { + pub priv_key: RwLock>>, + pub org_keys: RwLock>>>, pub config: rbw::config::Config, pub last_environment: RwLock, } pub struct State { - pub priv_key: Option, - pub org_keys: Option>, pub timeout: crate::timeout::Timeout, pub timeout_duration: std::time::Duration, pub sync_timeout: crate::timeout::Timeout, @@ -36,23 +36,43 @@ pub struct State { } impl State { - pub fn key(&self, org_id: Option<&str>) -> Option<&rbw::locked::Keys> { - org_id.map_or(self.priv_key.as_ref(), |id| { - self.org_keys.as_ref().and_then(|h| h.get(id)) - }) + pub async fn key(&self, org_id: Option<&str>) -> Option> { + match org_id { + Some(id) => self + .inner + .org_keys + .read() + .await + .as_ref() + .and_then(|h| h.get(id).cloned()), + None => self.inner.priv_key.read().await.clone(), + } + } + + pub async fn set_priv_key(&self, priv_key: rbw::locked::Keys) { + *self.inner.priv_key.write().await = Some(Arc::new(priv_key)); + } + + pub async fn set_org_keys(&self, org_keys: HashMap) { + let org_keys: HashMap> = org_keys + .into_iter() + .map(|(k, v)| (k, Arc::new(v))) + .collect(); + + *self.inner.org_keys.write().await = Some(org_keys); } - pub fn needs_unlock(&self) -> bool { - self.priv_key.is_none() || self.org_keys.is_none() + pub async fn needs_unlock(&self) -> bool { + self.inner.priv_key.read().await.is_none() || self.inner.org_keys.read().await.is_none() } pub fn set_timeout(&self) { self.timeout.set(self.timeout_duration); } - pub fn clear(&mut self) { - self.priv_key = None; - self.org_keys = None; + pub async fn clear(&mut self) { + *self.inner.priv_key.write().await = None; + *self.inner.org_keys.write().await = None; self.timeout.clear(); } From 57332863d1c24df77218cc400fb0fef1de6846bb Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 28 May 2026 17:24:19 +0200 Subject: [PATCH 188/273] move notifications_handler inside the inner state --- src/bin/rbw-agent/actions.rs | 16 +++++++++++----- src/bin/rbw-agent/agent.rs | 3 ++- src/bin/rbw-agent/main.rs | 2 +- src/bin/rbw-agent/state.rs | 14 ++++++++++++-- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index ff0a5751..d366a5d9 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -715,7 +715,13 @@ async fn save_db(state: &crate::state::State, db: &rbw::db::Db) -> anyhow::Resul pub async fn subscribe_to_notifications( state: Arc>, ) -> anyhow::Result<()> { - if state.lock().await.notifications_handler.is_connected() { + if state + .lock() + .await + .notifications_handler() + .await + .is_connected() + { return Ok(()); } @@ -732,10 +738,10 @@ pub async fn subscribe_to_notifications( let websocket_url = format!("{}/hub?access_token={}", notifications_url, access_token) .replace("https://", "wss://"); - let mut state = state.lock().await; - state - .notifications_handler - .connect(websocket_url) + let state = state.lock().await; + let mut nh = state.notifications_handler_mut().await; + + nh.connect(websocket_url) .await .err() .map_or_else(|| Ok(()), |err| Err(anyhow::anyhow!(err.to_string()))) diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index 8142a033..0e6c229b 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -38,7 +38,8 @@ impl Agent { .state .lock() .await - .notifications_handler + .notifications_handler() + .await .get_channel() .await; let notifications = UnboundedReceiverStream::new(notifications) diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index af0fb545..bdc5580a 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -34,12 +34,12 @@ async fn async_main(startup_ack: Option) -> anyhow::R timeout_duration, sync_timeout, sync_timeout_duration, - notifications_handler, master_password_reprompt: std::collections::HashSet::new(), master_password_reprompt_initialized: false, inner: Arc::new(crate::state::InnerState { priv_key: RwLock::new(None), org_keys: RwLock::new(None), + notifications_handler: RwLock::new(notifications_handler), config, last_environment: RwLock::new(rbw::protocol::Environment::default()), }), diff --git a/src/bin/rbw-agent/state.rs b/src/bin/rbw-agent/state.rs index ce92d34a..f76f24f6 100644 --- a/src/bin/rbw-agent/state.rs +++ b/src/bin/rbw-agent/state.rs @@ -1,11 +1,14 @@ use std::{collections::HashMap, sync::Arc}; use sha2::Digest as _; -use tokio::sync::RwLock; +use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; + +use crate::notifications::NotificationsHandler; pub struct InnerState { pub priv_key: RwLock>>, pub org_keys: RwLock>>>, + pub notifications_handler: RwLock, pub config: rbw::config::Config, pub last_environment: RwLock, } @@ -15,7 +18,6 @@ pub struct State { pub timeout_duration: std::time::Duration, pub sync_timeout: crate::timeout::Timeout, pub sync_timeout_duration: std::time::Duration, - pub notifications_handler: crate::notifications::NotificationsHandler, pub master_password_reprompt: std::collections::HashSet<[u8; 32]>, pub master_password_reprompt_initialized: bool, @@ -70,6 +72,14 @@ impl State { self.timeout.set(self.timeout_duration); } + pub async fn notifications_handler(&self) -> RwLockReadGuard<'_, NotificationsHandler> { + self.inner.notifications_handler.read().await + } + + pub async fn notifications_handler_mut(&self) -> RwLockWriteGuard<'_, NotificationsHandler> { + self.inner.notifications_handler.write().await + } + pub async fn clear(&mut self) { *self.inner.priv_key.write().await = None; *self.inner.org_keys.write().await = None; From ed64120d82fed87d1e695a531662b4469038dcc9 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 28 May 2026 17:51:50 +0200 Subject: [PATCH 189/273] move timeouts inside the inner state --- src/bin/rbw-agent/main.rs | 8 ++++---- src/bin/rbw-agent/state.rs | 16 +++++++++------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index bdc5580a..4a497787 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -30,16 +30,16 @@ async fn async_main(startup_ack: Option) -> anyhow::R } let notifications_handler = crate::notifications::NotificationsHandler::new(); let state = Arc::new(tokio::sync::Mutex::new(crate::state::State { - timeout, - timeout_duration, - sync_timeout, - sync_timeout_duration, master_password_reprompt: std::collections::HashSet::new(), master_password_reprompt_initialized: false, inner: Arc::new(crate::state::InnerState { priv_key: RwLock::new(None), org_keys: RwLock::new(None), notifications_handler: RwLock::new(notifications_handler), + timeout, + timeout_duration, + sync_timeout, + sync_timeout_duration, config, last_environment: RwLock::new(rbw::protocol::Environment::default()), }), diff --git a/src/bin/rbw-agent/state.rs b/src/bin/rbw-agent/state.rs index f76f24f6..4d7213f7 100644 --- a/src/bin/rbw-agent/state.rs +++ b/src/bin/rbw-agent/state.rs @@ -9,15 +9,15 @@ pub struct InnerState { pub priv_key: RwLock>>, pub org_keys: RwLock>>>, pub notifications_handler: RwLock, + pub timeout: crate::timeout::Timeout, + pub timeout_duration: std::time::Duration, + pub sync_timeout: crate::timeout::Timeout, + pub sync_timeout_duration: std::time::Duration, pub config: rbw::config::Config, pub last_environment: RwLock, } pub struct State { - pub timeout: crate::timeout::Timeout, - pub timeout_duration: std::time::Duration, - pub sync_timeout: crate::timeout::Timeout, - pub sync_timeout_duration: std::time::Duration, pub master_password_reprompt: std::collections::HashSet<[u8; 32]>, pub master_password_reprompt_initialized: bool, @@ -69,7 +69,7 @@ impl State { } pub fn set_timeout(&self) { - self.timeout.set(self.timeout_duration); + self.inner.timeout.set(self.inner.timeout_duration); } pub async fn notifications_handler(&self) -> RwLockReadGuard<'_, NotificationsHandler> { @@ -83,11 +83,13 @@ impl State { pub async fn clear(&mut self) { *self.inner.priv_key.write().await = None; *self.inner.org_keys.write().await = None; - self.timeout.clear(); + self.inner.timeout.clear(); } pub fn set_sync_timeout(&self) { - self.sync_timeout.set(self.sync_timeout_duration); + self.inner + .sync_timeout + .set(self.inner.sync_timeout_duration); } // the way we structure the client/agent split in rbw makes the master From 4ea4bef9ee7a764b8f36b4b13485e0d67b321a66 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 28 May 2026 18:32:49 +0200 Subject: [PATCH 190/273] move master_password_reprompt into the inner state --- src/bin/rbw-agent/actions.rs | 13 ++++++-- src/bin/rbw-agent/main.rs | 6 ++-- src/bin/rbw-agent/state.rs | 62 +++++++++++++++++++++--------------- 3 files changed, 49 insertions(+), 32 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index d366a5d9..6773f752 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -458,7 +458,11 @@ pub async fn sync( rbw::actions::sync(access_token, refresh_token) .await .context("failed to sync database from server")?; - state.lock().await.set_master_password_reprompt(&entries); + state + .lock() + .await + .set_master_password_reprompt(&entries) + .await; db.update_access_token(access_token); @@ -509,7 +513,10 @@ async fn maybe_reprompt_password( let master_password_reprompt: [u8; 32] = sha256.finalize().into(); if state + .inner .master_password_reprompt + .read() + .await .contains(&master_password_reprompt) { let db = load_db(state).await?; @@ -571,11 +578,11 @@ async fn decrypt_cipher( entry_key: Option<&str>, org_id: Option<&str>, ) -> anyhow::Result { - let mut state = state.lock().await; + let state = state.lock().await; if !state.master_password_reprompt_initialized() { let db = load_db(&state).await?; - state.set_master_password_reprompt(&db.entries); + state.set_master_password_reprompt(&db.entries).await; } let Some(keys) = state.key(org_id).await else { diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index 4a497787..f54bea12 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::sync::{atomic::AtomicBool, Arc}; use anyhow::Context as _; use tokio::sync::RwLock; @@ -30,8 +30,6 @@ async fn async_main(startup_ack: Option) -> anyhow::R } let notifications_handler = crate::notifications::NotificationsHandler::new(); let state = Arc::new(tokio::sync::Mutex::new(crate::state::State { - master_password_reprompt: std::collections::HashSet::new(), - master_password_reprompt_initialized: false, inner: Arc::new(crate::state::InnerState { priv_key: RwLock::new(None), org_keys: RwLock::new(None), @@ -40,6 +38,8 @@ async fn async_main(startup_ack: Option) -> anyhow::R timeout_duration, sync_timeout, sync_timeout_duration, + master_password_reprompt: RwLock::new(std::collections::HashSet::new()), + master_password_reprompt_initialized: AtomicBool::new(false), config, last_environment: RwLock::new(rbw::protocol::Environment::default()), }), diff --git a/src/bin/rbw-agent/state.rs b/src/bin/rbw-agent/state.rs index 4d7213f7..dd38ec0b 100644 --- a/src/bin/rbw-agent/state.rs +++ b/src/bin/rbw-agent/state.rs @@ -1,4 +1,7 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::HashMap, + sync::{atomic::AtomicBool, Arc}, +}; use sha2::Digest as _; use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; @@ -13,14 +16,13 @@ pub struct InnerState { pub timeout_duration: std::time::Duration, pub sync_timeout: crate::timeout::Timeout, pub sync_timeout_duration: std::time::Duration, + pub master_password_reprompt: RwLock>, + pub master_password_reprompt_initialized: AtomicBool, pub config: rbw::config::Config, pub last_environment: RwLock, } pub struct State { - pub master_password_reprompt: std::collections::HashSet<[u8; 32]>, - pub master_password_reprompt_initialized: bool, - // 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 // can't properly initialize the pinentry process. we work around this by @@ -113,19 +115,23 @@ impl State { // if the agent gets a request for any of those cipherstrings that it saw // marked as master password reprompt during the most recent sync, it // forces a reprompt. - pub fn set_master_password_reprompt(&mut self, entries: &[rbw::db::Entry]) { - self.master_password_reprompt.clear(); - - let mut hasher = sha2::Sha256::new(); - let mut insert = |s: Option<&str>| { - if let Some(s) = s { - if !s.is_empty() { - hasher.update(s); - self.master_password_reprompt - .insert(hasher.finalize_reset().into()); - } + + async fn add_mpr(&self, s: Option<&str>) { + if let Some(s) = s { + if !s.is_empty() { + let mut hasher = sha2::Sha256::new(); + hasher.update(s); + self.inner + .master_password_reprompt + .write() + .await + .insert(hasher.finalize().into()); } - }; + } + } + + pub async fn set_master_password_reprompt(&self, entries: &[rbw::db::Entry]) { + self.inner.master_password_reprompt.write().await.clear(); for entry in entries { if !entry.master_password_reprompt() { @@ -134,39 +140,43 @@ impl State { match &entry.data { rbw::db::EntryData::Login { password, totp, .. } => { - insert(password.as_deref()); - insert(totp.as_deref()); + self.add_mpr(password.as_deref()).await; + self.add_mpr(totp.as_deref()).await; } rbw::db::EntryData::Card { number, code, .. } => { - insert(number.as_deref()); - insert(code.as_deref()); + self.add_mpr(number.as_deref()).await; + self.add_mpr(code.as_deref()).await; } rbw::db::EntryData::Identity { ssn, passport_number, .. } => { - insert(ssn.as_deref()); - insert(passport_number.as_deref()); + self.add_mpr(ssn.as_deref()).await; + self.add_mpr(passport_number.as_deref()).await; } rbw::db::EntryData::SecureNote => {} rbw::db::EntryData::SshKey { private_key, .. } => { - insert(private_key.as_deref()); + self.add_mpr(private_key.as_deref()).await; } } for field in &entry.fields { if field.ty == Some(rbw::api::FieldType::Hidden) { - insert(field.value.as_deref()); + self.add_mpr(field.value.as_deref()).await; } } } - self.master_password_reprompt_initialized = true; + self.inner + .master_password_reprompt_initialized + .store(true, std::sync::atomic::Ordering::Relaxed); } pub fn master_password_reprompt_initialized(&self) -> bool { - self.master_password_reprompt_initialized + self.inner + .master_password_reprompt_initialized + .load(std::sync::atomic::Ordering::Relaxed) } pub async fn last_environment( From 001f1f582b6edd684af5571e2cf4967458d43c05 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 28 May 2026 18:53:55 +0200 Subject: [PATCH 191/273] move clipboard inside inner state --- src/bin/rbw-agent/actions.rs | 4 ++-- src/bin/rbw-agent/main.rs | 19 ++++++++++++------- src/bin/rbw-agent/state.rs | 18 +++++++++++++----- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 6773f752..b52b6e71 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -649,8 +649,8 @@ pub async fn clipboard_store( state: Arc>, text: &str, ) -> anyhow::Result<()> { - let mut state = state.lock().await; - if let Some(clipboard) = &mut state.clipboard { + let state = state.lock().await; + if let Some(clipboard) = &mut (*state.clipboard_mut().await) { clipboard .set_text(text) .map_err(|e| anyhow::anyhow!("couldn't store value to clipboard: {e}"))?; diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index f54bea12..d03c09a0 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -1,6 +1,8 @@ use std::sync::{atomic::AtomicBool, Arc}; use anyhow::Context as _; +#[cfg(feature = "clipboard")] +use tokio::sync::Mutex; use tokio::sync::RwLock; mod actions; @@ -29,7 +31,7 @@ async fn async_main(startup_ack: Option) -> anyhow::R sync_timeout.set(sync_timeout_duration); } let notifications_handler = crate::notifications::NotificationsHandler::new(); - let state = Arc::new(tokio::sync::Mutex::new(crate::state::State { + let state = Arc::new(Mutex::new(crate::state::State { inner: Arc::new(crate::state::InnerState { priv_key: RwLock::new(None), org_keys: RwLock::new(None), @@ -42,13 +44,16 @@ async fn async_main(startup_ack: Option) -> anyhow::R master_password_reprompt_initialized: AtomicBool::new(false), config, last_environment: RwLock::new(rbw::protocol::Environment::default()), + + #[cfg(feature = "clipboard")] + clipboard: Mutex::new( + arboard::Clipboard::new() + .inspect_err(|e| { + log::warn!("couldn't create clipboard context: {e}"); + }) + .ok(), + ), }), - #[cfg(feature = "clipboard")] - clipboard: arboard::Clipboard::new() - .inspect_err(|e| { - log::warn!("couldn't create clipboard context: {e}"); - }) - .ok(), })); let agent = crate::agent::Agent::new(timer_r, sync_timer_r, state.clone()); diff --git a/src/bin/rbw-agent/state.rs b/src/bin/rbw-agent/state.rs index dd38ec0b..910225cf 100644 --- a/src/bin/rbw-agent/state.rs +++ b/src/bin/rbw-agent/state.rs @@ -4,6 +4,8 @@ use std::{ }; use sha2::Digest as _; +#[cfg(feature = "clipboard")] +use tokio::sync::Mutex; use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; use crate::notifications::NotificationsHandler; @@ -19,10 +21,7 @@ pub struct InnerState { pub master_password_reprompt: RwLock>, pub master_password_reprompt_initialized: AtomicBool, pub config: rbw::config::Config, - pub last_environment: RwLock, -} -pub struct State { // 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 // can't properly initialize the pinentry process. we work around this by @@ -33,10 +32,14 @@ pub struct State { // // we should not use this for any requests on the main agent, those // should all send their own environment over. - pub inner: Arc, + pub last_environment: RwLock, #[cfg(feature = "clipboard")] - pub clipboard: Option, + pub clipboard: Mutex>, +} + +pub struct State { + pub inner: Arc, } impl State { @@ -213,6 +216,11 @@ impl State { self.inner.config.server_name() } + #[cfg(feature = "clipboard")] + pub async fn clipboard_mut(&self) -> tokio::sync::MutexGuard<'_, Option> { + self.inner.clipboard.lock().await + } + pub fn confirm_ssh(&self) -> bool { self.inner.config.confirm_ssh.is_some_and(|o| o) } From ca07cb239714fa5f3dee3396a9f21f02937a3c0c Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 28 May 2026 19:35:30 +0200 Subject: [PATCH 192/273] remove Arc> from every State --- src/bin/rbw-agent/actions.rs | 169 ++++++++++----------------------- src/bin/rbw-agent/agent.rs | 33 +++---- src/bin/rbw-agent/main.rs | 38 +------- src/bin/rbw-agent/ssh_agent.rs | 14 +-- src/bin/rbw-agent/state.rs | 45 ++++++++- 5 files changed, 115 insertions(+), 184 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index b52b6e71..9b267b55 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -1,9 +1,6 @@ -use std::sync::Arc; - use anyhow::Context as _; use rbw::actions::SessionParameters; use sha2::Digest as _; -use tokio::sync::Mutex; async fn getpin( pinentry: &str, @@ -66,32 +63,20 @@ fn get_host(state: &crate::state::State) -> anyhow::Result { pub async fn register( sock: &mut crate::sock::Sock, - state: Arc>, + state: crate::state::State, environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { - let db = { - let guard = state.lock().await; - load_db(&guard).await.unwrap_or_else(|_| rbw::db::Db::new()) - }; + let db = load_db(&state).await.unwrap_or_else(|_| rbw::db::Db::new()); if !db.needs_login() { return respond_ack(sock).await; } - let host = { - let guard = state.lock().await; - get_host(&guard)? - }; + let host = get_host(&state)?; - let email = { - let guard = state.lock().await; - guard.email()?.to_string() - }; + let email = state.email()?.to_string(); - let pinentry = { - let guard = state.lock().await; - guard.pinentry().to_string() - }; + let pinentry = state.pinentry().to_string(); let mut err_msg = None; for i in 1_u8..=3 { @@ -129,7 +114,7 @@ async fn get_password( } async fn two_factor_required( - state: &Arc>, + state: &crate::state::State, pinentry: &str, email: &str, password: rbw::locked::Password, @@ -163,32 +148,21 @@ async fn two_factor_required( pub async fn login( sock: &mut crate::sock::Sock, - state: Arc>, + state: crate::state::State, environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { - let mut db = { - let guard = state.lock().await; - load_db(&guard).await.unwrap_or_else(|_| rbw::db::Db::new()) - }; + let mut db = load_db(&state).await.unwrap_or_else(|_| rbw::db::Db::new()); if !db.needs_login() { return respond_ack(sock).await; } - let host = { - let guard = state.lock().await; - get_host(&guard)? - }; + let host = get_host(&state)?; - let email = { - let guard = state.lock().await; - guard.email()?.to_string() - }; + let email = state.email()?.to_string(); + + let pinentry = state.pinentry().to_string(); - let pinentry = { - let guard = state.lock().await; - guard.pinentry().to_string() - }; let mut err_msg = None; for i in 1_u8..=3 { let err = err_msg @@ -283,7 +257,7 @@ async fn two_factor( } async fn login_success( - state: Arc>, + state: crate::state::State, creds: SessionParameters, password: rbw::locked::Password, db: &mut rbw::db::Db, @@ -291,17 +265,11 @@ async fn login_success( ) -> anyhow::Result<()> { db.apply_session_parameters(&creds); - { - let guard = state.lock().await; - save_db(&guard, db).await?; - } + save_db(&state, db).await?; sync(None, state.clone()).await?; - let db = { - let guard = state.lock().await; - load_db(&guard).await? - }; + let db = load_db(&state).await?; let Some(protected_private_key) = db.protected_private_key else { return Err(anyhow::anyhow!( @@ -320,7 +288,6 @@ async fn login_success( match res { Ok((keys, org_keys)) => { - let state = state.lock().await; state.set_priv_key(keys).await; state.set_org_keys(org_keys).await; } @@ -331,14 +298,13 @@ async fn login_success( } async fn unlock_state( - state: Arc>, + state: &crate::state::State, environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { - if state.lock().await.needs_unlock().await { + if state.needs_unlock().await { let (db, email) = { - let guard = state.lock().await; - let db = load_db(&guard).await?; - let email = guard.email()?.to_string(); + let db = load_db(&state).await?; + let email = state.email()?.to_string(); (db, email) }; @@ -354,7 +320,7 @@ async fn unlock_state( )); }; - let pinentry = state.lock().await.pinentry().to_string(); + let pinentry = state.pinentry().to_string(); let mut err_msg = None; for i in 1_u8..=3 { let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); @@ -392,7 +358,7 @@ async fn unlock_state( pub async fn unlock( sock: &mut crate::sock::Sock, - state: Arc>, + state: &crate::state::State, environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { unlock_state(state, environment).await?; @@ -403,21 +369,18 @@ pub async fn unlock( } async fn unlock_success( - state: Arc>, + state: &crate::state::State, keys: rbw::locked::Keys, org_keys: std::collections::HashMap, ) -> anyhow::Result<()> { - let state = state.lock().await; state.set_priv_key(keys).await; state.set_org_keys(org_keys).await; + Ok(()) } -pub async fn lock( - sock: &mut crate::sock::Sock, - state: Arc>, -) -> anyhow::Result<()> { - state.lock().await.clear().await; +pub async fn lock(sock: &mut crate::sock::Sock, state: crate::state::State) -> anyhow::Result<()> { + state.clear().await; respond_ack(sock).await?; @@ -426,9 +389,9 @@ pub async fn lock( pub async fn check_lock( sock: &mut crate::sock::Sock, - state: Arc>, + state: crate::state::State, ) -> anyhow::Result<()> { - if state.lock().await.needs_unlock().await { + if state.needs_unlock().await { return Err(anyhow::anyhow!("agent is locked")); } @@ -439,12 +402,9 @@ pub async fn check_lock( pub async fn sync( sock: Option<&mut crate::sock::Sock>, - state: Arc>, + state: crate::state::State, ) -> anyhow::Result<()> { - let mut db = { - let guard = state.lock().await; - load_db(&guard).await? - }; + let mut db = load_db(&state).await?; let Some(access_token) = &db.access_token else { anyhow::bail!("failed to find access token in db"); @@ -458,11 +418,8 @@ pub async fn sync( rbw::actions::sync(access_token, refresh_token) .await .context("failed to sync database from server")?; - state - .lock() - .await - .set_master_password_reprompt(&entries) - .await; + + state.set_master_password_reprompt(&entries).await; db.update_access_token(access_token); @@ -471,10 +428,7 @@ pub async fn sync( db.protected_org_keys = protected_org_keys; db.entries = entries; - { - let guard = state.lock().await; - save_db(&guard, &db).await?; - } + save_db(&state, &db).await?; if let Err(e) = subscribe_to_notifications(state.clone()).await { eprintln!("failed to subscribe to notifications: {e}"); @@ -572,14 +526,12 @@ async fn maybe_reprompt_password( } async fn decrypt_cipher( - state: Arc>, + state: crate::state::State, environment: &rbw::protocol::Environment, cipherstring: &str, entry_key: Option<&str>, org_id: Option<&str>, ) -> anyhow::Result { - let state = state.lock().await; - if !state.master_password_reprompt_initialized() { let db = load_db(&state).await?; state.set_master_password_reprompt(&db.entries).await; @@ -610,7 +562,7 @@ async fn decrypt_cipher( pub async fn decrypt( sock: &mut crate::sock::Sock, - state: Arc>, + state: crate::state::State, environment: &rbw::protocol::Environment, cipherstring: &str, entry_key: Option<&str>, @@ -624,16 +576,16 @@ pub async fn decrypt( pub async fn encrypt( sock: &mut crate::sock::Sock, - state: Arc>, + state: crate::state::State, plaintext: &str, org_id: Option<&str>, ) -> anyhow::Result<()> { - let state = state.lock().await; let Some(keys) = state.key(org_id).await else { return Err(anyhow::anyhow!( "failed to find encryption keys in in-memory state" )); }; + let cipherstring = rbw::cipherstring::CipherString::encrypt_symmetric(keys.as_ref(), plaintext.as_bytes()) .context("failed to encrypt plaintext secret")?; @@ -646,10 +598,9 @@ pub async fn encrypt( #[cfg(feature = "clipboard")] pub async fn clipboard_store( sock: &mut crate::sock::Sock, - state: Arc>, + state: crate::state::State, text: &str, ) -> anyhow::Result<()> { - let state = state.lock().await; if let Some(clipboard) = &mut (*state.clipboard_mut().await) { clipboard .set_text(text) @@ -665,7 +616,7 @@ pub async fn clipboard_store( pub async fn clipboard_store( sock: &mut crate::sock::Sock, - _state: Arc>, + _state: crate::state::State, _text: &str, ) -> anyhow::Result<()> { sock.send(&rbw::protocol::Response::Error { @@ -719,33 +670,24 @@ async fn save_db(state: &crate::state::State, db: &rbw::db::Db) -> anyhow::Resul .map_err(anyhow::Error::new) } -pub async fn subscribe_to_notifications( - state: Arc>, -) -> anyhow::Result<()> { - if state - .lock() - .await - .notifications_handler() - .await - .is_connected() - { +pub async fn subscribe_to_notifications(state: crate::state::State) -> anyhow::Result<()> { + if state.notifications_handler().await.is_connected() { return Ok(()); } let (email, server_name, notifications_url) = { - let guard = state.lock().await; - let email = guard.email()?.to_string(); - let server_name = guard.server_name(); - let notifications_url = guard.notifications_url(); + let email = state.email()?.to_string(); + let server_name = state.server_name(); + let notifications_url = state.notifications_url(); (email, server_name, notifications_url) }; + let db = rbw::db::Db::load_async(&server_name, &email).await?; let access_token = db.access_token.context("Error getting access token")?; let websocket_url = format!("{}/hub?access_token={}", notifications_url, access_token) .replace("https://", "wss://"); - let state = state.lock().await; let mut nh = state.notifications_handler_mut().await; nh.connect(websocket_url) @@ -754,22 +696,17 @@ pub async fn subscribe_to_notifications( .map_or_else(|| Ok(()), |err| Err(anyhow::anyhow!(err.to_string()))) } -pub async fn get_ssh_public_keys( - state: Arc>, -) -> anyhow::Result> { +pub async fn get_ssh_public_keys(state: crate::state::State) -> anyhow::Result> { let environment = { - let state = state.lock().await; let le = state.last_environment().await; state.set_timeout(); le.clone() }; - unlock_state(state.clone(), &environment).await?; + unlock_state(&state, &environment).await?; + + let db = load_db(&state).await?; - let db = { - let guard = state.lock().await; - load_db(&guard).await? - }; let mut pubkeys = Vec::new(); for entry in db.entries { @@ -795,24 +732,20 @@ pub async fn get_ssh_public_keys( } pub async fn find_ssh_private_key( - state: Arc>, + state: crate::state::State, request_public_key: ssh_agent_lib::ssh_key::PublicKey, ) -> anyhow::Result { let environment = { - let state = state.lock().await; let le = state.last_environment().await; state.set_timeout(); le.clone() }; - unlock_state(state.clone(), &environment).await?; + unlock_state(&state, &environment).await?; let request_bytes = request_public_key.to_bytes(); - let db = { - let guard = state.lock().await; - load_db(&guard).await? - }; + let db = load_db(&state).await?; for entry in db.entries { let rbw::db::EntryData::SshKey { diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index 0e6c229b..adf8ca47 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -1,24 +1,22 @@ -use std::sync::Arc; - use anyhow::Context as _; use futures_util::StreamExt as _; use tokio::{ net::{UnixListener, UnixStream}, - sync::{mpsc::UnboundedReceiver, Mutex}, + sync::mpsc::UnboundedReceiver, }; use tokio_stream::wrappers::{UnboundedReceiverStream, UnixListenerStream}; pub struct Agent { timer_r: UnboundedReceiver<()>, sync_timer_r: UnboundedReceiver<()>, - state: Arc>, + state: crate::state::State, } impl Agent { pub fn new( timer_r: UnboundedReceiver<()>, sync_timer_r: UnboundedReceiver<()>, - state: Arc>, + state: crate::state::State, ) -> Self { Self { timer_r, @@ -34,14 +32,7 @@ impl Agent { Sync(()), } - let notifications = self - .state - .lock() - .await - .notifications_handler() - .await - .get_channel() - .await; + let notifications = self.state.notifications_handler().await.get_channel().await; let notifications = UnboundedReceiverStream::new(notifications) .map(|message| match message { crate::notifications::Message::Logout => Event::Timeout(()), @@ -61,13 +52,16 @@ impl Agent { .boxed(), notifications, ]); + while let Some(event) = stream.next().await { match event { Event::Request(res) => { let mut sock = crate::sock::Sock::new( res.context("failed to accept incoming connection")?, ); + let state = self.state.clone(); + tokio::spawn(async move { let res = handle_request(&mut sock, state.clone()).await; if let Err(e) = res { @@ -80,28 +74,29 @@ impl Agent { }); } Event::Timeout(()) => { - self.state.lock().await.clear().await; + self.state.clear().await; } Event::Sync(()) => { let state = self.state.clone(); tokio::spawn(async move { // this could fail if we aren't logged in, but we // don't care about that - if let Err(e) = crate::actions::sync(None, state.clone()).await { + if let Err(e) = crate::actions::sync(None, state).await { eprintln!("failed to sync: {e:#}"); } }); - self.state.lock().await.set_sync_timeout(); + self.state.set_sync_timeout(); } } } + Ok(()) } } async fn handle_request( sock: &mut crate::sock::Sock, - state: Arc>, + state: crate::state::State, ) -> anyhow::Result<()> { let req = sock.recv().await?; let req = match req { @@ -122,7 +117,7 @@ async fn handle_request( true } rbw::protocol::Action::Unlock => { - crate::actions::unlock(sock, state.clone(), &environment).await?; + crate::actions::unlock(sock, &state, &environment).await?; true } rbw::protocol::Action::CheckLock => { @@ -168,8 +163,6 @@ async fn handle_request( } }; - let state = state.lock().await; - state.set_last_environment(environment).await; if set_timeout { diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index d03c09a0..ba3d788d 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -1,9 +1,4 @@ -use std::sync::{atomic::AtomicBool, Arc}; - use anyhow::Context as _; -#[cfg(feature = "clipboard")] -use tokio::sync::Mutex; -use tokio::sync::RwLock; mod actions; mod agent; @@ -23,42 +18,13 @@ async fn async_main(startup_ack: Option) -> anyhow::R } let config = rbw::config::Config::load()?; - let timeout_duration = std::time::Duration::from_secs(config.lock_timeout); - let sync_timeout_duration = std::time::Duration::from_secs(config.sync_interval); let (timeout, timer_r) = crate::timeout::Timeout::new(); let (sync_timeout, sync_timer_r) = crate::timeout::Timeout::new(); - if sync_timeout_duration > std::time::Duration::ZERO { - sync_timeout.set(sync_timeout_duration); - } - let notifications_handler = crate::notifications::NotificationsHandler::new(); - let state = Arc::new(Mutex::new(crate::state::State { - inner: Arc::new(crate::state::InnerState { - priv_key: RwLock::new(None), - org_keys: RwLock::new(None), - notifications_handler: RwLock::new(notifications_handler), - timeout, - timeout_duration, - sync_timeout, - sync_timeout_duration, - master_password_reprompt: RwLock::new(std::collections::HashSet::new()), - master_password_reprompt_initialized: AtomicBool::new(false), - config, - last_environment: RwLock::new(rbw::protocol::Environment::default()), - - #[cfg(feature = "clipboard")] - clipboard: Mutex::new( - arboard::Clipboard::new() - .inspect_err(|e| { - log::warn!("couldn't create clipboard context: {e}"); - }) - .ok(), - ), - }), - })); + let state = crate::state::State::new(config, timeout, sync_timeout); let agent = crate::agent::Agent::new(timer_r, sync_timer_r, state.clone()); - let ssh_agent = crate::ssh_agent::SshAgent::new(state.clone()); + let ssh_agent = crate::ssh_agent::SshAgent::new(state); tokio::try_join!(agent.run(listener), ssh_agent.run())?; diff --git a/src/bin/rbw-agent/ssh_agent.rs b/src/bin/rbw-agent/ssh_agent.rs index 52931ca2..0d3c0397 100644 --- a/src/bin/rbw-agent/ssh_agent.rs +++ b/src/bin/rbw-agent/ssh_agent.rs @@ -1,18 +1,15 @@ -use std::sync::Arc; - use signature::{RandomizedSigner as _, SignatureEncoding as _, Signer as _}; -use tokio::sync::Mutex; const SSH_AGENT_RSA_SHA2_256: u32 = 2; const SSH_AGENT_RSA_SHA2_512: u32 = 4; #[derive(Clone)] pub struct SshAgent { - state: Arc>, + state: crate::state::State, } impl SshAgent { - pub fn new(state: Arc>) -> Self { + pub fn new(state: crate::state::State) -> Self { Self { state } } @@ -59,11 +56,10 @@ impl ssh_agent_lib::agent::Session for SshAgent { .map_err(|e| ssh_agent_lib::error::AgentError::Other(e.into()))?; let (confirm_ssh, pinentry, last_environment) = { - let state = self.state.lock().await; - let le = state.last_environment().await; + let le = self.state.last_environment().await; ( - state.confirm_ssh(), - state.pinentry().to_string(), + self.state.confirm_ssh(), + self.state.pinentry().to_string(), le.clone(), ) }; diff --git a/src/bin/rbw-agent/state.rs b/src/bin/rbw-agent/state.rs index 910225cf..d71ee00f 100644 --- a/src/bin/rbw-agent/state.rs +++ b/src/bin/rbw-agent/state.rs @@ -38,11 +38,54 @@ pub struct InnerState { pub clipboard: Mutex>, } +#[derive(Clone)] pub struct State { pub inner: Arc, } impl State { + pub fn new( + config: rbw::config::Config, + timeout: crate::timeout::Timeout, + sync_timeout: crate::timeout::Timeout, + ) -> Self { + let notifications_handler = crate::notifications::NotificationsHandler::new(); + + let timeout_duration = std::time::Duration::from_secs(config.lock_timeout); + + let sync_timeout_duration = std::time::Duration::from_secs(config.sync_interval); + + if sync_timeout_duration > std::time::Duration::ZERO { + sync_timeout.set(sync_timeout_duration); + } + + let state = crate::state::State { + inner: Arc::new(crate::state::InnerState { + priv_key: RwLock::new(None), + org_keys: RwLock::new(None), + notifications_handler: RwLock::new(notifications_handler), + timeout, + timeout_duration, + sync_timeout, + sync_timeout_duration, + master_password_reprompt: RwLock::new(std::collections::HashSet::new()), + master_password_reprompt_initialized: AtomicBool::new(false), + config, + last_environment: RwLock::new(rbw::protocol::Environment::default()), + + #[cfg(feature = "clipboard")] + clipboard: Mutex::new( + arboard::Clipboard::new() + .inspect_err(|e| { + log::warn!("couldn't create clipboard context: {e}"); + }) + .ok(), + ), + }), + }; + + state + } pub async fn key(&self, org_id: Option<&str>) -> Option> { match org_id { Some(id) => self @@ -85,7 +128,7 @@ impl State { self.inner.notifications_handler.write().await } - pub async fn clear(&mut self) { + pub async fn clear(&self) { *self.inner.priv_key.write().await = None; *self.inner.org_keys.write().await = None; self.inner.timeout.clear(); From ea2905f298e6d7352d3ae822d06038a34c160d6a Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Thu, 28 May 2026 22:02:00 +0200 Subject: [PATCH 193/273] make set keys operations atomic --- src/bin/rbw-agent/actions.rs | 6 ++---- src/bin/rbw-agent/state.rs | 30 +++++++++++++++++++++--------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 9b267b55..303298b5 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -288,8 +288,7 @@ async fn login_success( match res { Ok((keys, org_keys)) => { - state.set_priv_key(keys).await; - state.set_org_keys(org_keys).await; + state.set_keys(keys, org_keys).await; } Err(e) => return Err(e).context("failed to unlock database"), } @@ -373,8 +372,7 @@ async fn unlock_success( keys: rbw::locked::Keys, org_keys: std::collections::HashMap, ) -> anyhow::Result<()> { - state.set_priv_key(keys).await; - state.set_org_keys(org_keys).await; + state.set_keys(keys, org_keys).await; Ok(()) } diff --git a/src/bin/rbw-agent/state.rs b/src/bin/rbw-agent/state.rs index d71ee00f..6d6bb8c5 100644 --- a/src/bin/rbw-agent/state.rs +++ b/src/bin/rbw-agent/state.rs @@ -11,8 +11,8 @@ use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; use crate::notifications::NotificationsHandler; pub struct InnerState { - pub priv_key: RwLock>>, - pub org_keys: RwLock>>>, + priv_key: RwLock>>, + org_keys: RwLock>>>, pub notifications_handler: RwLock, pub timeout: crate::timeout::Timeout, pub timeout_duration: std::time::Duration, @@ -86,6 +86,7 @@ impl State { state } + pub async fn key(&self, org_id: Option<&str>) -> Option> { match org_id { Some(id) => self @@ -99,17 +100,22 @@ impl State { } } - pub async fn set_priv_key(&self, priv_key: rbw::locked::Keys) { - *self.inner.priv_key.write().await = Some(Arc::new(priv_key)); - } + pub async fn set_keys( + &self, + priv_key: rbw::locked::Keys, + org_keys: HashMap, + ) { + let mut priv_key_guard = self.inner.priv_key.write().await; + let mut org_keys_guard = self.inner.org_keys.write().await; + + *priv_key_guard = Some(Arc::new(priv_key)); - pub async fn set_org_keys(&self, org_keys: HashMap) { let org_keys: HashMap> = org_keys .into_iter() .map(|(k, v)| (k, Arc::new(v))) .collect(); - *self.inner.org_keys.write().await = Some(org_keys); + *org_keys_guard = Some(org_keys); } pub async fn needs_unlock(&self) -> bool { @@ -129,8 +135,14 @@ impl State { } pub async fn clear(&self) { - *self.inner.priv_key.write().await = None; - *self.inner.org_keys.write().await = None; + { + let mut priv_key_guard = self.inner.priv_key.write().await; + let mut org_keys_guard = self.inner.org_keys.write().await; + + *priv_key_guard = None; + *org_keys_guard = None; + } + self.inner.timeout.clear(); } From 84bdf1efb3f065d0f70907bad889fef9208e5dfa Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 29 May 2026 11:39:02 +0200 Subject: [PATCH 194/273] make nearly all members of State private --- src/bin/rbw-agent/state.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/bin/rbw-agent/state.rs b/src/bin/rbw-agent/state.rs index 6d6bb8c5..8a830c18 100644 --- a/src/bin/rbw-agent/state.rs +++ b/src/bin/rbw-agent/state.rs @@ -13,14 +13,14 @@ use crate::notifications::NotificationsHandler; pub struct InnerState { priv_key: RwLock>>, org_keys: RwLock>>>, - pub notifications_handler: RwLock, - pub timeout: crate::timeout::Timeout, - pub timeout_duration: std::time::Duration, - pub sync_timeout: crate::timeout::Timeout, - pub sync_timeout_duration: std::time::Duration, + notifications_handler: RwLock, + timeout: crate::timeout::Timeout, + timeout_duration: std::time::Duration, + sync_timeout: crate::timeout::Timeout, + sync_timeout_duration: std::time::Duration, pub master_password_reprompt: RwLock>, - pub master_password_reprompt_initialized: AtomicBool, - pub config: rbw::config::Config, + master_password_reprompt_initialized: AtomicBool, + config: rbw::config::Config, // 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 From 969aa11b2c5ba681596a22d94c3448fb2680998a Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 29 May 2026 22:53:23 +0200 Subject: [PATCH 195/273] remove timeouts, streams in favor of tokio::select! and other stuff This changes a bit the behavior of the application in different cases but it's more solid. - Pass state by reference when possible. - Remove Timeout and timeout.rs in favor of way simpler deadline calculation method with Instant. - Remove streams. - Run Sync blocking all the agent operations (except handle_request's started Sync for now). - Use tokio::select in main too, instead of try_join. - Handle sigint / sigterm with awareness. --- src/bin/rbw-agent/actions.rs | 8 +- src/bin/rbw-agent/agent.rs | 129 ++++++++++++++++++--------------- src/bin/rbw-agent/main.rs | 22 ++++-- src/bin/rbw-agent/ssh_agent.rs | 3 +- src/bin/rbw-agent/state.rs | 50 ++++++------- src/bin/rbw-agent/timeout.rs | 66 ----------------- 6 files changed, 115 insertions(+), 163 deletions(-) delete mode 100644 src/bin/rbw-agent/timeout.rs diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 303298b5..7c5e0d19 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -267,7 +267,7 @@ async fn login_success( save_db(&state, db).await?; - sync(None, state.clone()).await?; + sync(None, &state).await?; let db = load_db(&state).await?; @@ -400,7 +400,7 @@ pub async fn check_lock( pub async fn sync( sock: Option<&mut crate::sock::Sock>, - state: crate::state::State, + state: &crate::state::State, ) -> anyhow::Result<()> { let mut db = load_db(&state).await?; @@ -697,7 +697,7 @@ pub async fn subscribe_to_notifications(state: crate::state::State) -> anyhow::R pub async fn get_ssh_public_keys(state: crate::state::State) -> anyhow::Result> { let environment = { let le = state.last_environment().await; - state.set_timeout(); + state.set_timeout().await; le.clone() }; @@ -735,7 +735,7 @@ pub async fn find_ssh_private_key( ) -> anyhow::Result { let environment = { let le = state.last_environment().await; - state.set_timeout(); + state.set_timeout().await; le.clone() }; diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index adf8ca47..9c4f668a 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -1,67 +1,72 @@ use anyhow::Context as _; -use futures_util::StreamExt as _; use tokio::{ - net::{UnixListener, UnixStream}, - sync::mpsc::UnboundedReceiver, + net::UnixListener, + time::{sleep_until, Instant}, }; -use tokio_stream::wrappers::{UnboundedReceiverStream, UnixListenerStream}; pub struct Agent { - timer_r: UnboundedReceiver<()>, - sync_timer_r: UnboundedReceiver<()>, state: crate::state::State, } impl Agent { - pub fn new( - timer_r: UnboundedReceiver<()>, - sync_timer_r: UnboundedReceiver<()>, - state: crate::state::State, - ) -> Self { - Self { - timer_r, - sync_timer_r, - state, + pub fn new(state: crate::state::State) -> Self { + Self { state } + } + + async fn sleep_until_deadline(deadline: Option) { + match deadline { + Some(d) => sleep_until(d).await, + None => std::future::pending().await, } } pub async fn run(self, listener: UnixListener) -> anyhow::Result<()> { - enum Event { - Request(std::io::Result), - Timeout(()), - Sync(()), - } + let mut nchannel = self.state.notifications_handler().await.get_channel().await; + + loop { + let lock_deadline = *self.state.inner.lock_deadline.lock().await; + let sync_deadline = *self.state.inner.sync_deadline.lock().await; + + tokio::select! { + message = nchannel.recv() => { + match message { + Some(crate::notifications::Message::Logout) => { + log::debug!("Received Logout Message via notification channel"); + self.state.clear().await; + }, + Some(crate::notifications::Message::Sync) => { + log::debug!("Received Sync Message via notification channel"); + self.state.set_sync_timeout().await; + + if let Err(e) = crate::actions::sync(None, &self.state).await { + eprintln!("failed to sync: {e:#}"); + } + }, + None => { + log::debug!("Notification channel dropped. Recreating it..."); + nchannel = self + .state + .notifications_handler() + .await + .get_channel() + .await; + }, + } + + }, + // TODO: The client does like a hundred connections to do basic things. Maybe it + // makes sense to create more comprehensive opcodes. + res = listener.accept() => { + + log::debug!("Received a connection."); + + let res = res.context("failed to accept incoming connection")?; - let notifications = self.state.notifications_handler().await.get_channel().await; - let notifications = UnboundedReceiverStream::new(notifications) - .map(|message| match message { - crate::notifications::Message::Logout => Event::Timeout(()), - crate::notifications::Message::Sync => Event::Sync(()), - }) - .boxed(); - - let mut stream = futures_util::stream::select_all([ - UnixListenerStream::new(listener) - .map(Event::Request) - .boxed(), - UnboundedReceiverStream::new(self.timer_r) - .map(Event::Timeout) - .boxed(), - UnboundedReceiverStream::new(self.sync_timer_r) - .map(Event::Sync) - .boxed(), - notifications, - ]); - - while let Some(event) = stream.next().await { - match event { - Event::Request(res) => { - let mut sock = crate::sock::Sock::new( - res.context("failed to accept incoming connection")?, - ); + let mut sock = crate::sock::Sock::new(res.0); let state = self.state.clone(); + // TODO: Check if does it make sense to handle this in another task tokio::spawn(async move { let res = handle_request(&mut sock, state.clone()).await; if let Err(e) = res { @@ -72,25 +77,26 @@ impl Agent { .expect("failed to send error response to client"); } }); - } - Event::Timeout(()) => { + }, + _ = Self::sleep_until_deadline(lock_deadline) => { self.state.clear().await; - } - Event::Sync(()) => { - let state = self.state.clone(); - tokio::spawn(async move { + }, + _ = Self::sleep_until_deadline(sync_deadline) => { + //let state = self.state.clone(); + + self.state.set_sync_timeout().await; + + //tokio::spawn(async move { // this could fail if we aren't logged in, but we // don't care about that - if let Err(e) = crate::actions::sync(None, state).await { + if let Err(e) = crate::actions::sync(None, &self.state).await { eprintln!("failed to sync: {e:#}"); } - }); - self.state.set_sync_timeout(); + //}); + } } } - - Ok(()) } } @@ -129,9 +135,11 @@ async fn handle_request( false } rbw::protocol::Action::Sync => { - crate::actions::sync(Some(sock), state.clone()).await?; + crate::actions::sync(Some(sock), &state).await?; false } + // TODO: This alone does not do much, as it's a simple oracle open for everybody, to + // decrypt stuff. rbw::protocol::Action::Decrypt { cipherstring, entry_key, @@ -156,6 +164,7 @@ async fn handle_request( crate::actions::clipboard_store(sock, state.clone(), text).await?; true } + // TODO: It's better to handle the closing more gracefully rbw::protocol::Action::Quit => std::process::exit(0), rbw::protocol::Action::Version => { crate::actions::version(sock).await?; @@ -166,7 +175,7 @@ async fn handle_request( state.set_last_environment(environment).await; if set_timeout { - state.set_timeout(); + state.set_timeout().await; } Ok(()) diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index ba3d788d..d7676059 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -1,4 +1,5 @@ use anyhow::Context as _; +use tokio::signal::unix::{signal, SignalKind}; mod actions; mod agent; @@ -8,7 +9,6 @@ mod notifications; mod sock; mod ssh_agent; mod state; -mod timeout; async fn async_main(startup_ack: Option) -> anyhow::Result<()> { let listener = crate::sock::listen()?; @@ -18,15 +18,25 @@ async fn async_main(startup_ack: Option) -> anyhow::R } let config = rbw::config::Config::load()?; - let (timeout, timer_r) = crate::timeout::Timeout::new(); - let (sync_timeout, sync_timer_r) = crate::timeout::Timeout::new(); - let state = crate::state::State::new(config, timeout, sync_timeout); - let agent = crate::agent::Agent::new(timer_r, sync_timer_r, state.clone()); + let state = crate::state::State::new(config); + let agent = crate::agent::Agent::new(state.clone()); let ssh_agent = crate::ssh_agent::SshAgent::new(state); - tokio::try_join!(agent.run(listener), ssh_agent.run())?; + let mut sigterm = signal(SignalKind::terminate())?; + let mut sigint = signal(SignalKind::interrupt())?; + + tokio::select!( + _ = agent.run(listener) => {}, + _ = ssh_agent.run() => {}, + _ = sigint.recv() => { + log::warn!("SIGINT received. Closing the application."); + }, + _ = sigterm.recv() => { + log::warn!("SIGTERM received. Closing the application."); + } + ); Ok(()) } diff --git a/src/bin/rbw-agent/ssh_agent.rs b/src/bin/rbw-agent/ssh_agent.rs index 0d3c0397..99074674 100644 --- a/src/bin/rbw-agent/ssh_agent.rs +++ b/src/bin/rbw-agent/ssh_agent.rs @@ -1,4 +1,5 @@ use signature::{RandomizedSigner as _, SignatureEncoding as _, Signer as _}; +use tokio::net::UnixListener; const SSH_AGENT_RSA_SHA2_256: u32 = 2; const SSH_AGENT_RSA_SHA2_512: u32 = 4; @@ -18,7 +19,7 @@ impl SshAgent { let _ = std::fs::remove_file(&socket); // Ignore error if it doesn't exist - let listener = tokio::net::UnixListener::bind(socket)?; + let listener = UnixListener::bind(socket)?; ssh_agent_lib::agent::listen(listener, self).await?; Ok(()) diff --git a/src/bin/rbw-agent/state.rs b/src/bin/rbw-agent/state.rs index 8a830c18..1a67e788 100644 --- a/src/bin/rbw-agent/state.rs +++ b/src/bin/rbw-agent/state.rs @@ -1,12 +1,15 @@ use std::{ collections::HashMap, sync::{atomic::AtomicBool, Arc}, + time::Duration, }; use sha2::Digest as _; -#[cfg(feature = "clipboard")] -use tokio::sync::Mutex; -use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; + +use tokio::{ + sync::{Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard}, + time::Instant, +}; use crate::notifications::NotificationsHandler; @@ -14,10 +17,8 @@ pub struct InnerState { priv_key: RwLock>>, org_keys: RwLock>>>, notifications_handler: RwLock, - timeout: crate::timeout::Timeout, - timeout_duration: std::time::Duration, - sync_timeout: crate::timeout::Timeout, - sync_timeout_duration: std::time::Duration, + pub lock_deadline: Mutex>, + pub sync_deadline: Mutex>, pub master_password_reprompt: RwLock>, master_password_reprompt_initialized: AtomicBool, config: rbw::config::Config, @@ -44,19 +45,15 @@ pub struct State { } impl State { - pub fn new( - config: rbw::config::Config, - timeout: crate::timeout::Timeout, - sync_timeout: crate::timeout::Timeout, - ) -> Self { + pub fn new(config: rbw::config::Config) -> Self { let notifications_handler = crate::notifications::NotificationsHandler::new(); - let timeout_duration = std::time::Duration::from_secs(config.lock_timeout); - + // TODO: ugly + let mut sync_deadline: Option = None; let sync_timeout_duration = std::time::Duration::from_secs(config.sync_interval); if sync_timeout_duration > std::time::Duration::ZERO { - sync_timeout.set(sync_timeout_duration); + sync_deadline = Some(Instant::now() + sync_timeout_duration); } let state = crate::state::State { @@ -64,10 +61,8 @@ impl State { priv_key: RwLock::new(None), org_keys: RwLock::new(None), notifications_handler: RwLock::new(notifications_handler), - timeout, - timeout_duration, - sync_timeout, - sync_timeout_duration, + lock_deadline: Mutex::new(None), + sync_deadline: Mutex::new(sync_deadline), master_password_reprompt: RwLock::new(std::collections::HashSet::new()), master_password_reprompt_initialized: AtomicBool::new(false), config, @@ -122,8 +117,9 @@ impl State { self.inner.priv_key.read().await.is_none() || self.inner.org_keys.read().await.is_none() } - pub fn set_timeout(&self) { - self.inner.timeout.set(self.inner.timeout_duration); + pub async fn set_timeout(&self) { + *self.inner.lock_deadline.lock().await = + Some(Instant::now() + Duration::from_secs(self.inner.config.lock_timeout)); } pub async fn notifications_handler(&self) -> RwLockReadGuard<'_, NotificationsHandler> { @@ -143,13 +139,15 @@ impl State { *org_keys_guard = None; } - self.inner.timeout.clear(); + *self.inner.lock_deadline.lock().await = None; } - pub fn set_sync_timeout(&self) { - self.inner - .sync_timeout - .set(self.inner.sync_timeout_duration); + pub async fn set_sync_timeout(&self) { + *self.inner.sync_deadline.lock().await = + Some(Instant::now() + Duration::from_secs(self.inner.config.sync_interval)); + // self.inner + // .sync_timeout + // .set(self.inner.sync_timeout_duration); } // the way we structure the client/agent split in rbw makes the master diff --git a/src/bin/rbw-agent/timeout.rs b/src/bin/rbw-agent/timeout.rs deleted file mode 100644 index 50218ff5..00000000 --- a/src/bin/rbw-agent/timeout.rs +++ /dev/null @@ -1,66 +0,0 @@ -use futures_util::StreamExt as _; -use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; -use tokio_stream::wrappers::UnboundedReceiverStream; - -#[derive(Debug, Hash, Eq, PartialEq, Copy, Clone)] -enum Streams { - Requests, - Timer, -} - -#[derive(Debug)] -enum Action { - Set(std::time::Duration), - Clear, -} - -pub struct Timeout { - req_w: UnboundedSender, -} - -impl Timeout { - pub fn new() -> (Self, UnboundedReceiver<()>) { - let (req_w, req_r) = unbounded_channel(); - let (timer_w, timer_r) = unbounded_channel(); - tokio::spawn(async move { - enum Event { - Request(Action), - Timer, - } - let mut stream = tokio_stream::StreamMap::new(); - stream.insert( - Streams::Requests, - UnboundedReceiverStream::new(req_r) - .map(Event::Request) - .boxed(), - ); - while let Some(event) = stream.next().await { - match event { - (_, Event::Request(Action::Set(dur))) => { - stream.insert( - Streams::Timer, - futures_util::stream::once(tokio::time::sleep(dur)) - .map(|()| Event::Timer) - .boxed(), - ); - } - (_, Event::Request(Action::Clear)) => { - stream.remove(&Streams::Timer); - } - (_, Event::Timer) => { - timer_w.send(()).unwrap(); - } - } - } - }); - (Self { req_w }, timer_r) - } - - pub fn set(&self, dur: std::time::Duration) { - self.req_w.send(Action::Set(dur)).unwrap(); - } - - pub fn clear(&self) { - self.req_w.send(Action::Clear).unwrap(); - } -} From 772a7b511347e75bcb94db90d85599a5eddb99f0 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 29 May 2026 23:20:07 +0200 Subject: [PATCH 196/273] Revert "group config's urls resolving logic into one fn and add a line in client.rs" This reverts commit c9c1106d421baf54c6611fc4a86b0b78a8b82f35. --- src/api/client.rs | 1 - src/config.rs | 90 +++++++++++++++++++++++++---------------------- 2 files changed, 47 insertions(+), 44 deletions(-) diff --git a/src/api/client.rs b/src/api/client.rs index 1fc62732..f49e5a36 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -230,7 +230,6 @@ fn sso_query_code(params: &HashMap, state: &str) -> Result String { - self.resolve_url( - &self.base_url, - "https://api.bitwarden.com", - "https://api.bitwarden.eu", - Some("/api"), + self.base_url.clone().map_or_else( + || "https://api.bitwarden.com".to_string(), + |url| { + let clean_url = url.trim_end_matches('/'); + if clean_url == "https://api.bitwarden.eu" { + "https://api.bitwarden.eu".to_string() + } else { + format!("{clean_url}/api") + } + }, ) } pub fn identity_url(&self) -> String { - self.resolve_url( - &self.identity_url, - "https://identity.bitwarden.com", - "https://identity.bitwarden.eu", - Some("/identity"), - ) + self.identity_url.clone().unwrap_or_else(|| { + self.base_url.clone().map_or_else( + || "https://identity.bitwarden.com".to_string(), + |url| { + let clean_url = url.trim_end_matches('/'); + if clean_url == "https://api.bitwarden.eu" { + "https://identity.bitwarden.eu".to_string() + } else { + format!("{clean_url}/identity") + } + }, + ) + }) } pub fn ui_url(&self) -> String { - self.resolve_url( - &self.ui_url, - "https://vault.bitwarden.com", - "https://vault.bitwarden.eu", - None, - ) + self.ui_url.clone().unwrap_or_else(|| { + self.base_url.clone().map_or_else( + || "https://vault.bitwarden.com".to_string(), + |url| { + let clean_url = url.trim_end_matches('/'); + if clean_url == "https://api.bitwarden.eu" { + "https://vault.bitwarden.eu".to_string() + } else { + clean_url.to_string() + } + }, + ) + }) } pub fn notifications_url(&self) -> String { - self.resolve_url( - &self.notifications_url, - "https://notifications.bitwarden.com", - "https://notifications.bitwarden.eu", - Some("/notifications"), - ) - } - - fn resolve_url( - &self, - explicit: &Option, - default: &str, - eu_url: &str, - suffix: Option<&str>, - ) -> String { - explicit.clone().unwrap_or_else(|| { - self.base_url.as_ref().map_or(default.to_string(), |u| { - let u = u.trim_end_matches('/').to_string(); - if u == "https://api.bitwarden.eu" { - eu_url.to_string() - } else { - match suffix { - Some(s) => u + s, - None => u, + self.notifications_url.clone().unwrap_or_else(|| { + self.base_url.clone().map_or_else( + || "https://notifications.bitwarden.com".to_string(), + |url| { + let clean_url = url.trim_end_matches('/'); + if clean_url == "https://api.bitwarden.eu" { + "https://notifications.bitwarden.eu".to_string() + } else { + format!("{clean_url}/notifications") } - } - }) + }, + ) }) } From c7e7bd6d7fd5e43c43abd772d2136042372fe8e9 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 29 May 2026 23:36:23 +0200 Subject: [PATCH 197/273] group url resolving into one fn --- src/config.rs | 75 +++++++++++++++++++++++---------------------------- 1 file changed, 33 insertions(+), 42 deletions(-) diff --git a/src/config.rs b/src/config.rs index 57e02a70..3d262bc2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -60,6 +60,8 @@ pub fn default_confirm_ssh() -> Option { None } +const RBW_EU_URL: &str = "https://api.bitwarden.eu"; + impl Config { pub fn new() -> Self { Self::default() @@ -88,13 +90,12 @@ impl Config { pub async fn load_async() -> Result { let file = crate::dirs::config_file()?; - let mut fh = - tokio::fs::File::open(&file) - .await - .map_err(|source| Error::LoadConfig { - source, - file: file.clone(), - })?; + let mut fh = tokio::fs::File::open(&file) + .await + .map_err(|source| Error::LoadConfig { + source, + file: file.clone(), + })?; let mut json = String::new(); fh.read_to_string(&mut json) .await @@ -143,64 +144,54 @@ impl Config { Ok(()) } - pub fn base_url(&self) -> String { + fn resolve_url(&self, default: String, eu_default: String, suffix: &str) -> String { self.base_url.clone().map_or_else( - || "https://api.bitwarden.com".to_string(), + || default, |url| { let clean_url = url.trim_end_matches('/'); - if clean_url == "https://api.bitwarden.eu" { - "https://api.bitwarden.eu".to_string() + if clean_url == RBW_EU_URL { + eu_default } else { - format!("{clean_url}/api") + format!("{clean_url}{suffix}") } }, ) } + pub fn base_url(&self) -> String { + self.resolve_url( + "https://api.bitwarden.com".to_string(), + "https://api.bitwarden.eu".to_string(), + "/api", + ) + } + pub fn identity_url(&self) -> String { self.identity_url.clone().unwrap_or_else(|| { - self.base_url.clone().map_or_else( - || "https://identity.bitwarden.com".to_string(), - |url| { - let clean_url = url.trim_end_matches('/'); - if clean_url == "https://api.bitwarden.eu" { - "https://identity.bitwarden.eu".to_string() - } else { - format!("{clean_url}/identity") - } - }, + self.resolve_url( + "https://identity.bitwarden.com".to_string(), + "https://identity.bitwarden.eu".to_string(), + "/identity", ) }) } pub fn ui_url(&self) -> String { self.ui_url.clone().unwrap_or_else(|| { - self.base_url.clone().map_or_else( - || "https://vault.bitwarden.com".to_string(), - |url| { - let clean_url = url.trim_end_matches('/'); - if clean_url == "https://api.bitwarden.eu" { - "https://vault.bitwarden.eu".to_string() - } else { - clean_url.to_string() - } - }, + self.resolve_url( + "https://vault.bitwarden.com".to_string(), + "https://vault.bitwarden.eu".to_string(), + "", ) }) } pub fn notifications_url(&self) -> String { self.notifications_url.clone().unwrap_or_else(|| { - self.base_url.clone().map_or_else( - || "https://notifications.bitwarden.com".to_string(), - |url| { - let clean_url = url.trim_end_matches('/'); - if clean_url == "https://api.bitwarden.eu" { - "https://notifications.bitwarden.eu".to_string() - } else { - format!("{clean_url}/notifications") - } - }, + self.resolve_url( + "https://notifications.bitwarden.com".to_string(), + "https://notifications.bitwarden.eu".to_string(), + "/notifications", ) }) } From c5e7155ab26ccd870e70ebbf8d327ee62839e3ff Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 29 May 2026 23:41:27 +0200 Subject: [PATCH 198/273] improve url resolve readability --- src/config.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/config.rs b/src/config.rs index 3d262bc2..70886c12 100644 --- a/src/config.rs +++ b/src/config.rs @@ -60,7 +60,7 @@ pub fn default_confirm_ssh() -> Option { None } -const RBW_EU_URL: &str = "https://api.bitwarden.eu"; +const BW_EU_URL: &str = "https://api.bitwarden.eu"; impl Config { pub fn new() -> Self { @@ -145,17 +145,17 @@ impl Config { } fn resolve_url(&self, default: String, eu_default: String, suffix: &str) -> String { - self.base_url.clone().map_or_else( - || default, - |url| { - let clean_url = url.trim_end_matches('/'); - if clean_url == RBW_EU_URL { + match &self.base_url { + Some(url) => { + let url = url.trim_end_matches('/'); + if url == BW_EU_URL { eu_default } else { - format!("{clean_url}{suffix}") + format!("{url}{suffix}") } - }, - ) + } + None => default, + } } pub fn base_url(&self) -> String { From fe19c0d8cf91b7c670523dbffd1ea9b238922900 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 29 May 2026 23:44:12 +0200 Subject: [PATCH 199/273] add small TODO regardint notifications reading stuff --- src/bin/rbw-agent/agent.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index 9c4f668a..c8d36fac 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -21,6 +21,7 @@ impl Agent { } pub async fn run(self, listener: UnixListener) -> anyhow::Result<()> { + // TODO: Notification stuff is only created after first Sync is issued. let mut nchannel = self.state.notifications_handler().await.get_channel().await; loop { From fa162063250fbe4f8b109001b3e18599b904d40c Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 30 May 2026 11:39:49 +0200 Subject: [PATCH 200/273] Vec -> LockedVec --- src/cipherstring.rs | 8 ++++---- src/identity.rs | 4 ++-- src/locked.rs | 26 +++++++++++++------------- src/pinentry.rs | 2 +- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/cipherstring.rs b/src/cipherstring.rs index 179b4431..5f5c9b35 100644 --- a/src/cipherstring.rs +++ b/src/cipherstring.rs @@ -136,14 +136,14 @@ impl CipherString { pub fn decrypt_locked_symmetric( &self, keys: &crate::locked::Keys, - ) -> Result { + ) -> Result { if let Self::Symmetric { iv, ciphertext, mac, } = self { - let mut res = crate::locked::Vec::new(); + let mut res = crate::locked::LockedVec::new(); res.extend(ciphertext.iter().copied()); let cipher = decrypt_common_symmetric(keys, iv, ciphertext, mac.as_deref())?; cipher @@ -160,7 +160,7 @@ impl CipherString { pub fn decrypt_locked_asymmetric( &self, private_key: &crate::locked::PrivateKey, - ) -> Result { + ) -> Result { if let Self::Asymmetric { ciphertext } = self { let privkey_data = private_key.private_key(); let privkey_data = pkcs7_unpad(privkey_data).ok_or(Error::Padding)?; @@ -173,7 +173,7 @@ impl CipherString { // XXX it'd be great if the rsa crate would let us decrypt // into a preallocated buffer directly to avoid the // intermediate vec that needs to be manually zeroized, etc - let mut res = crate::locked::Vec::new(); + let mut res = crate::locked::LockedVec::new(); res.extend(bytes.iter().copied()); bytes.zeroize(); diff --git a/src/identity.rs b/src/identity.rs index f931160f..781977c0 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -19,7 +19,7 @@ impl Identity { let iterations = std::num::NonZeroU32::new(crypto_params.iterations) .ok_or(Error::Pbkdf2ZeroIterations)?; - let mut keys = crate::locked::Vec::new(); + let mut keys = crate::locked::LockedVec::new(); keys.extend(std::iter::repeat_n(0, 64)); let enc_key = &mut keys.data_mut()[0..32]; @@ -61,7 +61,7 @@ impl Identity { } } - let mut hash = crate::locked::Vec::new(); + let mut hash = crate::locked::LockedVec::new(); hash.extend(std::iter::repeat_n(0, 32)); pbkdf2::pbkdf2::>( enc_key, diff --git a/src/locked.rs b/src/locked.rs index 3dd9bf8e..1ed30c2f 100644 --- a/src/locked.rs +++ b/src/locked.rs @@ -4,12 +4,12 @@ const LEN: usize = 4096; static REGION_LOCK_WORKS: std::sync::OnceLock = std::sync::OnceLock::new(); -pub struct Vec { +pub struct LockedVec { data: Box>, _lock: Option, } -impl Default for Vec { +impl Default for LockedVec { fn default() -> Self { let data = Box::new(arrayvec::ArrayVec::<_, LEN>::new()); let lock = match REGION_LOCK_WORKS.get() { @@ -32,7 +32,7 @@ impl Default for Vec { } } -impl Vec { +impl LockedVec { pub fn new() -> Self { Self::default() } @@ -59,14 +59,14 @@ impl Vec { } } -impl Drop for Vec { +impl Drop for LockedVec { fn drop(&mut self) { self.zero(); self.data.as_mut().zeroize(); } } -impl Clone for Vec { +impl Clone for LockedVec { fn clone(&self) -> Self { let mut new_vec = Self::new(); new_vec.extend(self.data().iter().copied()); @@ -76,11 +76,11 @@ impl Clone for Vec { #[derive(Clone)] pub struct Password { - password: Vec, + password: LockedVec, } impl Password { - pub fn new(password: Vec) -> Self { + pub fn new(password: LockedVec) -> Self { Self { password } } @@ -91,11 +91,11 @@ impl Password { #[derive(Clone)] pub struct Keys { - keys: Vec, + keys: LockedVec, } impl Keys { - pub fn new(keys: Vec) -> Self { + pub fn new(keys: LockedVec) -> Self { Self { keys } } @@ -110,11 +110,11 @@ impl Keys { #[derive(Clone)] pub struct PasswordHash { - hash: Vec, + hash: LockedVec, } impl PasswordHash { - pub fn new(hash: Vec) -> Self { + pub fn new(hash: LockedVec) -> Self { Self { hash } } @@ -125,11 +125,11 @@ impl PasswordHash { #[derive(Clone)] pub struct PrivateKey { - private_key: Vec, + private_key: LockedVec, } impl PrivateKey { - pub fn new(private_key: Vec) -> Self { + pub fn new(private_key: LockedVec) -> Self { Self { private_key } } diff --git a/src/pinentry.rs b/src/pinentry.rs index 2a6a8242..8211ca0a 100644 --- a/src/pinentry.rs +++ b/src/pinentry.rs @@ -85,7 +85,7 @@ pub async fn getpin( ncommands += 1; drop(stdin); - let mut buf = crate::locked::Vec::new(); + let mut buf = crate::locked::LockedVec::new(); buf.zero(); // unwrap is safe because we specified stdout as piped in the command opts // above From abcf27e3234a65646838c0cfcf711ebabdb4455f Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 30 May 2026 12:41:21 +0200 Subject: [PATCH 201/273] use impl Zeroize from ArrayVec instead of the Deref u8 one The previous code was using zeroize() on the Deref impl of ArrayVec, targeting the u8 array. Now we explicitly use the zeroize impl of ArrayVec. Also remove the final zeroize() on Drop as it is unneeded. --- Cargo.lock | 3 +++ Cargo.toml | 2 +- src/locked.rs | 3 +-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9290ea18..92a2638b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,6 +113,9 @@ name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "zeroize", +] [[package]] name = "async-trait" diff --git a/Cargo.toml b/Cargo.toml index 99855fad..c98ad390 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ include = ["src/**/*", "bin/**/*", "LICENSE", "README.md", "CHANGELOG.md"] aes = "0.8.4" anyhow = "1.0.100" argon2 = "0.5.3" -arrayvec = "0.7.6" +arrayvec = { version = "0.7.6", features = [ "zeroize" ] } axum = "0.8.8" base32 = "0.5.1" base64 = "0.22.1" diff --git a/src/locked.rs b/src/locked.rs index 1ed30c2f..f68401bf 100644 --- a/src/locked.rs +++ b/src/locked.rs @@ -46,7 +46,7 @@ impl LockedVec { } pub fn zero(&mut self) { - self.truncate(0); + self.data.zeroize(); self.data.extend(std::iter::repeat_n(0, LEN)); } @@ -62,7 +62,6 @@ impl LockedVec { impl Drop for LockedVec { fn drop(&mut self) { self.zero(); - self.data.as_mut().zeroize(); } } From d1adbe3fad6a1895edbd4dfd6db06f319ffdfe16 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 30 May 2026 15:30:07 +0200 Subject: [PATCH 202/273] remove arrayvec dependency in favor of internal impl Implement a data structure that is similar to ArrayVec and change some function naming. Use zeroize directly on the array and its length. --- Cargo.lock | 10 ---------- Cargo.toml | 1 - src/locked.rs | 50 ++++++++++++++++++++++++++++++++++++------------- src/pinentry.rs | 3 ++- 4 files changed, 39 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 92a2638b..4486bbd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -108,15 +108,6 @@ dependencies = [ "password-hash", ] -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -dependencies = [ - "zeroize", -] - [[package]] name = "async-trait" version = "0.1.89" @@ -1847,7 +1838,6 @@ dependencies = [ "anyhow", "arboard", "argon2", - "arrayvec", "axum", "base32", "base64", diff --git a/Cargo.toml b/Cargo.toml index c98ad390..3569c6de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,6 @@ include = ["src/**/*", "bin/**/*", "LICENSE", "README.md", "CHANGELOG.md"] aes = "0.8.4" anyhow = "1.0.100" argon2 = "0.5.3" -arrayvec = { version = "0.7.6", features = [ "zeroize" ] } axum = "0.8.8" base32 = "0.5.1" base64 = "0.22.1" diff --git a/src/locked.rs b/src/locked.rs index f68401bf..e10d543c 100644 --- a/src/locked.rs +++ b/src/locked.rs @@ -1,21 +1,22 @@ -use zeroize::Zeroize as _; +use zeroize::Zeroize; const LEN: usize = 4096; static REGION_LOCK_WORKS: std::sync::OnceLock = std::sync::OnceLock::new(); pub struct LockedVec { - data: Box>, + data: Box<([u8; LEN], usize)>, _lock: Option, } +// TODO: Think about making the memory lock a hard requirement instead impl Default for LockedVec { fn default() -> Self { - let data = Box::new(arrayvec::ArrayVec::<_, LEN>::new()); + let data = Box::new(([0u8; LEN], 0)); let lock = match REGION_LOCK_WORKS.get() { - Some(true) => Some(region::lock(data.as_ptr(), data.capacity()).unwrap()), + Some(true) => Some(region::lock(data.0.as_ptr(), LEN).unwrap()), Some(false) => None, - None => match region::lock(data.as_ptr(), data.capacity()) { + None => match region::lock(data.0.as_ptr(), LEN) { Ok(lock) => { let _ = REGION_LOCK_WORKS.set(true); Some(lock) @@ -37,31 +38,54 @@ impl LockedVec { Self::default() } + pub fn capacity(&self) -> usize { + LEN + } + + pub fn len(&self) -> usize { + self.data.1 + } + pub fn data(&self) -> &[u8] { - self.data.as_slice() + &self.data.0[0..self.len()] } pub fn data_mut(&mut self) -> &mut [u8] { - self.data.as_mut_slice() + let len = self.len(); + &mut self.data.0[0..len] + } + + pub fn push(&mut self, el: u8) { + let len = self.len(); + + if len == self.capacity() { + panic!("Array capacity exceeded"); + } + + self.data.0[len] = el; + self.data.1 += 1; } - pub fn zero(&mut self) { - self.data.zeroize(); - self.data.extend(std::iter::repeat_n(0, LEN)); + pub fn alloc_all(&mut self) { + self.truncate(0); + self.extend(std::iter::repeat_n(0, self.capacity())); } pub fn extend(&mut self, it: impl Iterator) { - self.data.extend(it); + for el in it { + self.push(el); + } } pub fn truncate(&mut self, len: usize) { - self.data.truncate(len); + self.data.1 = usize::min(len, self.len()); + self.data.0[self.data.1..].zeroize(); } } impl Drop for LockedVec { fn drop(&mut self) { - self.zero(); + self.data.zeroize() } } diff --git a/src/pinentry.rs b/src/pinentry.rs index 8211ca0a..fff7da5f 100644 --- a/src/pinentry.rs +++ b/src/pinentry.rs @@ -86,7 +86,8 @@ pub async fn getpin( drop(stdin); let mut buf = crate::locked::LockedVec::new(); - buf.zero(); + buf.alloc_all(); + // unwrap is safe because we specified stdout as piped in the command opts // above let len = read_password(ncommands, buf.data_mut(), child.stdout.as_mut().unwrap()).await?; From 64d950189b766ad36b78b552578163170b368ce4 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 30 May 2026 15:53:48 +0200 Subject: [PATCH 203/273] remove tokio-stream --- Cargo.lock | 12 ------------ Cargo.toml | 1 - 2 files changed, 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4486bbd3..4577e35a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1883,7 +1883,6 @@ dependencies = [ "textwrap", "thiserror 2.0.17", "tokio", - "tokio-stream", "tokio-tungstenite", "totp-rs", "url", @@ -2638,17 +2637,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-stream" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - [[package]] name = "tokio-tungstenite" version = "0.28.0" diff --git a/Cargo.toml b/Cargo.toml index 3569c6de..bcc69fb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,6 @@ tempfile = "3.24.0" terminal_size = "0.4.3" textwrap = "0.16.2" thiserror = "2.0.17" -tokio-stream = { version = "0.1.17", features = ["net"] } tokio-tungstenite = { version = "0.28", features = [ "rustls-tls-native-roots", "url", From fb2d2d3ec1f26fe009f3947c950ecbf2d9e55a01 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 30 May 2026 18:52:23 +0200 Subject: [PATCH 204/273] refactor notifications to use broadcast instead of complicated sender Vec This commit drastically improves the handling of broadcast information using broadcast channel from tokio instead of the complicated Vec of UnboundSenders. It behaves slightly differently as connect() now fails if send fails, instead of panicking the whole agent. It also features a Disconnected Message, in case the websocket gets disconnected. Also used some log::debug and warn here and there. --- src/bin/rbw-agent/actions.rs | 4 +- src/bin/rbw-agent/agent.rs | 28 ++-- src/bin/rbw-agent/notifications.rs | 202 +++++++++++++---------------- 3 files changed, 108 insertions(+), 126 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 7c5e0d19..87b22297 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -428,7 +428,7 @@ pub async fn sync( save_db(&state, &db).await?; - if let Err(e) = subscribe_to_notifications(state.clone()).await { + if let Err(e) = subscribe_to_notifications(&state).await { eprintln!("failed to subscribe to notifications: {e}"); } @@ -668,7 +668,7 @@ async fn save_db(state: &crate::state::State, db: &rbw::db::Db) -> anyhow::Resul .map_err(anyhow::Error::new) } -pub async fn subscribe_to_notifications(state: crate::state::State) -> anyhow::Result<()> { +pub async fn subscribe_to_notifications(state: &crate::state::State) -> anyhow::Result<()> { if state.notifications_handler().await.is_connected() { return Ok(()); } diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index c8d36fac..cdedfc0a 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -21,8 +21,16 @@ impl Agent { } pub async fn run(self, listener: UnixListener) -> anyhow::Result<()> { - // TODO: Notification stuff is only created after first Sync is issued. - let mut nchannel = self.state.notifications_handler().await.get_channel().await; + let mut nchannel = self.state.notifications_handler().await.get_channel(); + + match crate::actions::subscribe_to_notifications(&self.state).await { + Ok(_) => { + log::debug!("Successfully subscribed to notifications"); + } + Err(e) => { + log::warn!("Failed to subscribe to notifications: {e}"); + } + }; loop { let lock_deadline = *self.state.inner.lock_deadline.lock().await; @@ -30,12 +38,12 @@ impl Agent { tokio::select! { message = nchannel.recv() => { - match message { - Some(crate::notifications::Message::Logout) => { + match message? { + crate::notifications::Message::Logout => { log::debug!("Received Logout Message via notification channel"); self.state.clear().await; }, - Some(crate::notifications::Message::Sync) => { + crate::notifications::Message::Sync => { log::debug!("Received Sync Message via notification channel"); self.state.set_sync_timeout().await; @@ -43,14 +51,8 @@ impl Agent { eprintln!("failed to sync: {e:#}"); } }, - None => { - log::debug!("Notification channel dropped. Recreating it..."); - nchannel = self - .state - .notifications_handler() - .await - .get_channel() - .await; + crate::notifications::Message::Disconnected => { + log::warn!("Notifications websocket disconnected"); }, } diff --git a/src/bin/rbw-agent/notifications.rs b/src/bin/rbw-agent/notifications.rs index ad2ce30a..dbf743bd 100644 --- a/src/bin/rbw-agent/notifications.rs +++ b/src/bin/rbw-agent/notifications.rs @@ -2,128 +2,20 @@ use std::sync::Arc; use futures_util::{SinkExt as _, StreamExt as _}; use tokio::{ - net::TcpStream, sync::{ - mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}, - RwLock, + broadcast::{Receiver, Sender}, + oneshot, }, task::JoinHandle, }; #[derive(Clone, Copy, Debug)] pub enum Message { + Disconnected, Sync, Logout, } -pub struct NotificationsHandler { - write: Option< - futures::stream::SplitSink< - tokio_tungstenite::WebSocketStream>, - tokio_tungstenite::tungstenite::Message, - >, - >, - read_handle: Option>, - sending_channels: Arc>>>, -} - -impl NotificationsHandler { - pub fn new() -> Self { - Self { - write: None, - read_handle: None, - sending_channels: Arc::new(RwLock::new(Vec::new())), - } - } - - pub async fn connect(&mut self, url: String) -> Result<(), Box> { - if self.is_connected() { - self.disconnect().await?; - } - - let (write, read_handle) = - subscribe_to_notifications(url, self.sending_channels.clone()).await?; - - self.write = Some(write); - self.read_handle = Some(read_handle); - Ok(()) - } - - pub fn is_connected(&self) -> bool { - self.write.is_some() - && self.read_handle.is_some() - && !self.read_handle.as_ref().unwrap().is_finished() - } - - pub async fn disconnect(&mut self) -> Result<(), Box> { - self.sending_channels.write().await.clear(); - if let Some(mut write) = self.write.take() { - write - .send(tokio_tungstenite::tungstenite::Message::Close(None)) - .await?; - write.close().await?; - self.read_handle.take().unwrap().await?; - } - self.write = None; - self.read_handle = None; - Ok(()) - } - - pub async fn get_channel(&self) -> UnboundedReceiver { - let (tx, rx) = unbounded_channel(); - self.sending_channels.write().await.push(tx); - rx - } -} - -async fn subscribe_to_notifications( - url: String, - sending_channels: Arc>>>, -) -> Result< - ( - futures_util::stream::SplitSink< - tokio_tungstenite::WebSocketStream>, - tokio_tungstenite::tungstenite::Message, - >, - JoinHandle<()>, - ), - Box, -> { - let url = url::Url::parse(url.as_str())?; - let (ws_stream, _response) = tokio_tungstenite::connect_async(url).await?; - let (mut write, read) = ws_stream.split(); - - write - .send(tokio_tungstenite::tungstenite::Message::Text( - "{\"protocol\":\"messagepack\",\"version\":1}\x1e".into(), - )) - .await - .unwrap(); - - let read_future = async move { - let sending_channels = &sending_channels; - read.for_each(|message| async move { - match message { - Ok(message) => { - if let Some(message) = parse_message(message) { - let sending_channels = sending_channels.read().await; - let sending_channels = sending_channels.as_slice(); - for channel in sending_channels { - channel.send(message).unwrap(); - } - } - } - Err(e) => { - eprintln!("websocket error: {e:?}"); - } - } - }) - .await; - }; - - Ok((write, tokio::spawn(read_future))) -} - fn parse_message(message: tokio_tungstenite::tungstenite::Message) -> Option { let tokio_tungstenite::tungstenite::Message::Binary(data) = message else { return None; @@ -159,3 +51,91 @@ fn parse_message(message: tokio_tungstenite::tungstenite::Message) -> Option>, + read_handle: Option>, + broadcast: Arc>, +} + +impl NotificationsHandler { + pub fn new() -> Self { + let (tx, _) = tokio::sync::broadcast::channel(32); + + Self { + disconnect_tx: None, + read_handle: None, + broadcast: Arc::new(tx), + } + } + + pub async fn connect(&mut self, url: String) -> Result<(), Box> { + if self.is_connected() { + self.disconnect().await?; + } + + let url = url::Url::parse(url.as_str())?; + let (mut ws_stream, _response) = tokio_tungstenite::connect_async(url).await?; + + ws_stream + .send(tokio_tungstenite::tungstenite::Message::Text( + "{\"protocol\":\"messagepack\",\"version\":1}\x1e".into(), + )) + .await?; + + let (disconnect_tx, mut disconnect_rx) = tokio::sync::oneshot::channel::<()>(); + let broadcast = self.broadcast.clone(); + + let read_task = tokio::spawn(async move { + loop { + tokio::select! { + _ = &mut disconnect_rx => break, + msg = ws_stream.next() => { + match msg { + Some(Ok(msg)) => { + if let Some(parsed) = parse_message(msg) { + let _ = broadcast.send(parsed); + } + }, + Some(Err(e)) => { + eprintln!("websocket error: {e:?}"); + break; + }, + None => break, + } + } + } + } + + let _ = ws_stream.close(None).await; + let _ = broadcast.send(Message::Disconnected); + }); + + self.disconnect_tx = Some(disconnect_tx); + self.read_handle = Some(read_task); + + Ok(()) + } + + pub fn is_connected(&self) -> bool { + self.disconnect_tx.is_some() + && self.read_handle.is_some() + && !self.read_handle.as_ref().unwrap().is_finished() + } + + pub async fn disconnect(&mut self) -> Result<(), Box> { + if let Some(disconnect_tx) = self.disconnect_tx.take() { + let _ = disconnect_tx.send(()); + self.read_handle.take().unwrap().await?; + } + + self.disconnect_tx = None; + self.read_handle = None; + + Ok(()) + } + + pub fn get_channel(&self) -> Receiver { + self.broadcast.subscribe() + } +} From f2022883fb6eabb2fc2667839251c13ff0997554 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 30 May 2026 18:57:35 +0200 Subject: [PATCH 205/273] split subscribe to websocket logic into a separate fn --- src/bin/rbw-agent/notifications.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/bin/rbw-agent/notifications.rs b/src/bin/rbw-agent/notifications.rs index dbf743bd..d9be487b 100644 --- a/src/bin/rbw-agent/notifications.rs +++ b/src/bin/rbw-agent/notifications.rs @@ -69,11 +69,10 @@ impl NotificationsHandler { } } - pub async fn connect(&mut self, url: String) -> Result<(), Box> { - if self.is_connected() { - self.disconnect().await?; - } - + async fn subscribe_ws( + &mut self, + url: String, + ) -> Result<(oneshot::Sender<()>, JoinHandle<()>), Box> { let url = url::Url::parse(url.as_str())?; let (mut ws_stream, _response) = tokio_tungstenite::connect_async(url).await?; @@ -84,8 +83,8 @@ impl NotificationsHandler { .await?; let (disconnect_tx, mut disconnect_rx) = tokio::sync::oneshot::channel::<()>(); - let broadcast = self.broadcast.clone(); + let broadcast = self.broadcast.clone(); let read_task = tokio::spawn(async move { loop { tokio::select! { @@ -111,6 +110,16 @@ impl NotificationsHandler { let _ = broadcast.send(Message::Disconnected); }); + Ok((disconnect_tx, read_task)) + } + + pub async fn connect(&mut self, url: String) -> Result<(), Box> { + if self.is_connected() { + self.disconnect().await?; + } + + let (disconnect_tx, read_task) = self.subscribe_ws(url).await?; + self.disconnect_tx = Some(disconnect_tx); self.read_handle = Some(read_task); From 6c16ed5d743cb0ea849c0dbe4b81ef7993737dd2 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 30 May 2026 19:36:58 +0200 Subject: [PATCH 206/273] split tokio::select content into multiple fn and add some spacing --- src/bin/rbw-agent/agent.rs | 94 ++++++++++++++++++++------------------ 1 file changed, 50 insertions(+), 44 deletions(-) diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index cdedfc0a..e712b4c9 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -1,6 +1,6 @@ use anyhow::Context as _; use tokio::{ - net::UnixListener, + net::{UnixListener, UnixStream}, time::{sleep_until, Instant}, }; @@ -20,6 +20,44 @@ impl Agent { } } + async fn on_notification(&self, message: crate::notifications::Message) { + match message { + crate::notifications::Message::Logout => { + log::debug!("Received Logout Message via notification channel"); + self.state.clear().await; + } + crate::notifications::Message::Sync => { + log::debug!("Received Sync Message via notification channel"); + self.state.set_sync_timeout().await; + + if let Err(e) = crate::actions::sync(None, &self.state).await { + eprintln!("failed to sync: {e:#}"); + } + } + crate::notifications::Message::Disconnected => { + log::warn!("Notifications websocket disconnected"); + } + } + } + + async fn on_connection(&self, stream: UnixStream) { + let mut sock = crate::sock::Sock::new(stream); + + let state = self.state.clone(); + + // TODO: Check if does it make sense to handle this in another task + tokio::spawn(async move { + let res = handle_request(&mut sock, state.clone()).await; + if let Err(e) = res { + sock.send(&rbw::protocol::Response::Error { + error: format!("{e:#}"), + }) + .await + .expect("failed to send error response to client"); + } + }); + } + pub async fn run(self, listener: UnixListener) -> anyhow::Result<()> { let mut nchannel = self.state.notifications_handler().await.get_channel(); @@ -38,48 +76,17 @@ impl Agent { tokio::select! { message = nchannel.recv() => { - match message? { - crate::notifications::Message::Logout => { - log::debug!("Received Logout Message via notification channel"); - self.state.clear().await; - }, - crate::notifications::Message::Sync => { - log::debug!("Received Sync Message via notification channel"); - self.state.set_sync_timeout().await; - - if let Err(e) = crate::actions::sync(None, &self.state).await { - eprintln!("failed to sync: {e:#}"); - } - }, - crate::notifications::Message::Disconnected => { - log::warn!("Notifications websocket disconnected"); - }, - } - + let message = message?; + self.on_notification(message).await; }, // TODO: The client does like a hundred connections to do basic things. Maybe it // makes sense to create more comprehensive opcodes. res = listener.accept() => { - log::debug!("Received a connection."); let res = res.context("failed to accept incoming connection")?; - let mut sock = crate::sock::Sock::new(res.0); - - let state = self.state.clone(); - - // TODO: Check if does it make sense to handle this in another task - tokio::spawn(async move { - let res = handle_request(&mut sock, state.clone()).await; - if let Err(e) = res { - sock.send(&rbw::protocol::Response::Error { - error: format!("{e:#}"), - }) - .await - .expect("failed to send error response to client"); - } - }); + self.on_connection(res.0).await; }, _ = Self::sleep_until_deadline(lock_deadline) => { self.state.clear().await; @@ -89,13 +96,11 @@ impl Agent { self.state.set_sync_timeout().await; - //tokio::spawn(async move { - // this could fail if we aren't logged in, but we - // don't care about that - if let Err(e) = crate::actions::sync(None, &self.state).await { - eprintln!("failed to sync: {e:#}"); - } - //}); + // this could fail if we aren't logged in, but we + // don't care about that + if let Err(e) = crate::actions::sync(None, &self.state).await { + eprintln!("failed to sync: {e:#}"); + } } } @@ -107,15 +112,16 @@ async fn handle_request( sock: &mut crate::sock::Sock, state: crate::state::State, ) -> anyhow::Result<()> { - let req = sock.recv().await?; - let req = match req { + let req = match sock.recv().await? { Ok(msg) => msg, Err(error) => { sock.send(&rbw::protocol::Response::Error { error }).await?; return Ok(()); } }; + let (action, environment) = req.into_parts(); + let set_timeout = match &action { rbw::protocol::Action::Register => { crate::actions::register(sock, state.clone(), &environment).await?; From 0952aa6c21d567d2392bf5b655aa6fd00e86d8e7 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 30 May 2026 20:29:22 +0200 Subject: [PATCH 207/273] move up the getting of parameters and simplify finding the correct k --- src/bin/rbw-agent/notifications.rs | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/bin/rbw-agent/notifications.rs b/src/bin/rbw-agent/notifications.rs index d9be487b..732a65da 100644 --- a/src/bin/rbw-agent/notifications.rs +++ b/src/bin/rbw-agent/notifications.rs @@ -28,28 +28,25 @@ fn parse_message(message: tokio_tungstenite::tungstenite::Message) -> Option Some(Message::Logout), - _ => Some(Message::Sync), - }; - } - } + let (_, ty) = map.iter().find(|(k, _)| k.as_str() == Some("Type"))?; - None + match ty.as_i64()? { + 11 => Some(Message::Logout), + _ => Some(Message::Sync), + } } pub struct NotificationsHandler { From 2995a95691367565e730df9b89027529555365c4 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 30 May 2026 20:39:41 +0200 Subject: [PATCH 208/273] improve readability of ssh agent --- src/bin/rbw-agent/actions.rs | 8 ++++---- src/bin/rbw-agent/ssh_agent.rs | 21 ++++++++------------- src/bin/rbw-agent/state.rs | 2 +- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 87b22297..1cf137a0 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -76,7 +76,7 @@ pub async fn register( let email = state.email()?.to_string(); - let pinentry = state.pinentry().to_string(); + let pinentry = state.config_pinentry().to_string(); let mut err_msg = None; for i in 1_u8..=3 { @@ -161,7 +161,7 @@ pub async fn login( let email = state.email()?.to_string(); - let pinentry = state.pinentry().to_string(); + let pinentry = state.config_pinentry().to_string(); let mut err_msg = None; for i in 1_u8..=3 { @@ -319,7 +319,7 @@ async fn unlock_state( )); }; - let pinentry = state.pinentry().to_string(); + let pinentry = state.config_pinentry().to_string(); let mut err_msg = None; for i in 1_u8..=3 { let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); @@ -487,7 +487,7 @@ async fn maybe_reprompt_password( let email = state.email()?; - let pinentry = state.pinentry().to_string(); + let pinentry = state.config_pinentry().to_string(); let mut err_msg = None; for i in 1_u8..=3 { let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); diff --git a/src/bin/rbw-agent/ssh_agent.rs b/src/bin/rbw-agent/ssh_agent.rs index 99074674..029e3ee8 100644 --- a/src/bin/rbw-agent/ssh_agent.rs +++ b/src/bin/rbw-agent/ssh_agent.rs @@ -56,20 +56,15 @@ impl ssh_agent_lib::agent::Session for SshAgent { .await .map_err(|e| ssh_agent_lib::error::AgentError::Other(e.into()))?; - let (confirm_ssh, pinentry, last_environment) = { - let le = self.state.last_environment().await; - ( - self.state.confirm_ssh(), - self.state.pinentry().to_string(), - le.clone(), + if self.state.confirm_ssh() { + let confirmed = rbw::pinentry::confirm( + &self.state.config_pinentry(), + "Allow SSH key use?", + &self.state.last_environment().await.clone(), + true, ) - }; - - if confirm_ssh { - let confirmed = - rbw::pinentry::confirm(&pinentry, "Allow SSH key use?", &last_environment, true) - .await - .map_err(|_| ssh_agent_lib::error::AgentError::Failure)?; + .await + .map_err(|_| ssh_agent_lib::error::AgentError::Failure)?; if !confirmed { return Err(ssh_agent_lib::error::AgentError::Other( diff --git a/src/bin/rbw-agent/state.rs b/src/bin/rbw-agent/state.rs index 1a67e788..7faaaa17 100644 --- a/src/bin/rbw-agent/state.rs +++ b/src/bin/rbw-agent/state.rs @@ -257,7 +257,7 @@ impl State { self.inner.config.base_url() } - pub fn pinentry(&self) -> &str { + pub fn config_pinentry(&self) -> &str { &self.inner.config.pinentry } From bfe131d6fbc92ce20095ccde840ddc2b42f733e2 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 30 May 2026 21:24:36 +0200 Subject: [PATCH 209/273] slowly porting every logic into Agent "object" --- src/bin/rbw-agent/{ => agent}/actions.rs | 523 ++++++++++--------- src/bin/rbw-agent/{agent.rs => agent/mod.rs} | 169 +++--- src/bin/rbw-agent/{ => agent}/ssh_agent.rs | 10 +- src/bin/rbw-agent/{ => agent}/state.rs | 4 +- src/bin/rbw-agent/main.rs | 7 +- 5 files changed, 359 insertions(+), 354 deletions(-) rename src/bin/rbw-agent/{ => agent}/actions.rs (64%) rename src/bin/rbw-agent/{agent.rs => agent/mod.rs} (53%) rename src/bin/rbw-agent/{ => agent}/ssh_agent.rs (94%) rename src/bin/rbw-agent/{ => agent}/state.rs (99%) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs similarity index 64% rename from src/bin/rbw-agent/actions.rs rename to src/bin/rbw-agent/agent/actions.rs index 1cf137a0..42f70356 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -2,6 +2,8 @@ use anyhow::Context as _; use rbw::actions::SessionParameters; use sha2::Digest as _; +use crate::agent::Agent; + async fn getpin( pinentry: &str, desc: &str, @@ -49,181 +51,294 @@ async fn get_client_secret( .context("failed to read client_secret from pinentry") } -fn get_host(state: &crate::state::State) -> anyhow::Result { - let url_str = state.base_url(); - let url = reqwest::Url::parse(&url_str).context("failed to parse base url")?; - let Some(host) = url.host_str() else { - return Err(anyhow::anyhow!( - "couldn't find host in rbw base url {url_str}" - )); - }; - - Ok(host.to_string()) +async fn get_password( + pinentry: &str, + desc: &str, + err: &Option, + environment: &rbw::protocol::Environment, +) -> anyhow::Result { + getpin(pinentry, "Master Password", desc, err, environment, true) + .await + .context("failed to read password from pinentry") } -pub async fn register( - sock: &mut crate::sock::Sock, - state: crate::state::State, +async fn get_code( + pinentry: &str, + provider: rbw::api::TwoFactorProviderType, + err: &Option, environment: &rbw::protocol::Environment, -) -> anyhow::Result<()> { - let db = load_db(&state).await.unwrap_or_else(|_| rbw::db::Db::new()); +) -> anyhow::Result { + getpin( + pinentry, + provider.header(), + provider.message(), + err, + environment, + provider.grab(), + ) + .await + .context("failed to read code from pinentry") +} + +impl Agent { + fn get_host(&self) -> anyhow::Result { + let url_str = self.state.base_url(); + let url = reqwest::Url::parse(&url_str).context("failed to parse base url")?; + let Some(host) = url.host_str() else { + return Err(anyhow::anyhow!( + "couldn't find host in rbw base url {url_str}" + )); + }; - if !db.needs_login() { - return respond_ack(sock).await; + Ok(host.to_string()) } - let host = get_host(&state)?; + pub async fn register( + &self, + sock: &mut crate::sock::Sock, + environment: &rbw::protocol::Environment, + ) -> anyhow::Result<()> { + let db = load_db(&self.state) + .await + .unwrap_or_else(|_| rbw::db::Db::new()); - let email = state.email()?.to_string(); + if !db.needs_login() { + return respond_ack(sock).await; + } - let pinentry = state.config_pinentry().to_string(); + let host = self.get_host()?; - let mut err_msg = None; - for i in 1_u8..=3 { - let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); - let client_id = get_client_id(&pinentry, &host, &err, environment).await?; - let client_secret = get_client_secret(&pinentry, &host, &err, environment).await?; + let email = self.state.email()?.to_string(); + + let pinentry = self.state.config_pinentry().to_string(); - let apikey = rbw::locked::ApiKey::new(client_id, client_secret); + let mut err_msg = None; + for i in 1_u8..=3 { + let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); + let client_id = get_client_id(&pinentry, &host, &err, environment).await?; + let client_secret = get_client_secret(&pinentry, &host, &err, environment).await?; - match rbw::actions::register(&email, apikey).await { - Ok(()) => { - break; + let apikey = rbw::locked::ApiKey::new(client_id, client_secret); + + match rbw::actions::register(&email, apikey).await { + Ok(()) => { + break; + } + Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { + err_msg = Some(message); + } + Err(e) => return Err(e).context("failed to log in to bitwarden instance"), } - Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { - err_msg = Some(message); + } + + respond_ack(sock).await?; + + Ok(()) + } + + async fn two_factor_required( + &self, + pinentry: &str, + email: &str, + password: rbw::locked::Password, + providers: Vec, + sso_email_2fa_session_token: Option, + environment: &rbw::protocol::Environment, + db: &mut rbw::db::Db, + ) -> anyhow::Result<()> { + let supported_types = [ + rbw::api::TwoFactorProviderType::Authenticator, + rbw::api::TwoFactorProviderType::Yubikey, + rbw::api::TwoFactorProviderType::Email, + ]; + + let Some(provider) = supported_types.into_iter().find(|p| providers.contains(p)) else { + return Err(anyhow::anyhow!( + "unsupported two factor methods: {providers:?}" + )); + }; + + if provider == rbw::api::TwoFactorProviderType::Email { + if let Some(token) = sso_email_2fa_session_token { + rbw::actions::send_two_factor_email(email, &token).await?; } - Err(e) => return Err(e).context("failed to log in to bitwarden instance"), } + + let creds = two_factor(pinentry, environment, email, password.clone(), provider).await?; + + self.login_success(creds, password, db, email).await } - respond_ack(sock).await?; + pub async fn login( + &self, + sock: &mut crate::sock::Sock, + environment: &rbw::protocol::Environment, + ) -> anyhow::Result<()> { + let mut db = load_db(&self.state) + .await + .unwrap_or_else(|_| rbw::db::Db::new()); - Ok(()) -} + if !db.needs_login() { + return respond_ack(sock).await; + } -async fn get_password( - pinentry: &str, - desc: &str, - err: &Option, - environment: &rbw::protocol::Environment, -) -> anyhow::Result { - getpin(pinentry, "Master Password", desc, err, environment, true) - .await - .context("failed to read password from pinentry") -} + let host = self.get_host()?; -async fn two_factor_required( - state: &crate::state::State, - pinentry: &str, - email: &str, - password: rbw::locked::Password, - providers: Vec, - sso_email_2fa_session_token: Option, - environment: &rbw::protocol::Environment, - db: &mut rbw::db::Db, -) -> anyhow::Result<()> { - let supported_types = [ - rbw::api::TwoFactorProviderType::Authenticator, - rbw::api::TwoFactorProviderType::Yubikey, - rbw::api::TwoFactorProviderType::Email, - ]; + let email = self.state.email()?.to_string(); - let Some(provider) = supported_types.into_iter().find(|p| providers.contains(p)) else { - return Err(anyhow::anyhow!( - "unsupported two factor methods: {providers:?}" - )); - }; + let pinentry = self.state.config_pinentry().to_string(); + + let mut err_msg = None; + for i in 1_u8..=3 { + let err = err_msg + .as_deref() + .map(|msg| format!("{msg} (attempt {i}/3)")); - if provider == rbw::api::TwoFactorProviderType::Email { - if let Some(token) = sso_email_2fa_session_token { - rbw::actions::send_two_factor_email(email, &token).await?; + let password = + get_password(&pinentry, &format!("Log in to {host}"), &err, environment).await?; + + match rbw::actions::login(&email, password.clone(), None, None).await { + Ok(creds) => { + self.login_success(creds, password, &mut db, &email).await?; + + break; + } + Err(rbw::error::Error::TwoFactorRequired { + providers, + sso_email_2fa_session_token, + }) => { + self.two_factor_required( + &pinentry, + &email, + password, + providers, + sso_email_2fa_session_token, + environment, + &mut db, + ) + .await?; + + break; + } + Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { + err_msg = Some(message); + } + Err(e) => return Err(e).context("failed to log in to bitwarden instance"), + } } + + respond_ack(sock).await?; + + Ok(()) } - let creds = two_factor(pinentry, environment, email, password.clone(), provider).await?; + pub async fn unlock( + &self, + sock: &mut crate::sock::Sock, + environment: &rbw::protocol::Environment, + ) -> anyhow::Result<()> { + unlock_state(&self.state, environment).await?; - login_success(state.clone(), creds, password, db, email).await -} + respond_ack(sock).await?; -pub async fn login( - sock: &mut crate::sock::Sock, - state: crate::state::State, - environment: &rbw::protocol::Environment, -) -> anyhow::Result<()> { - let mut db = load_db(&state).await.unwrap_or_else(|_| rbw::db::Db::new()); + Ok(()) + } + + pub async fn lock(&self, sock: &mut crate::sock::Sock) -> anyhow::Result<()> { + self.state.clear().await; + + respond_ack(sock).await?; - if !db.needs_login() { - return respond_ack(sock).await; + Ok(()) } - let host = get_host(&state)?; + pub async fn check_lock(&self, sock: &mut crate::sock::Sock) -> anyhow::Result<()> { + if self.state.needs_unlock().await { + return Err(anyhow::anyhow!("agent is locked")); + } - let email = state.email()?.to_string(); + respond_ack(sock).await?; - let pinentry = state.config_pinentry().to_string(); + Ok(()) + } - let mut err_msg = None; - for i in 1_u8..=3 { - let err = err_msg - .as_deref() - .map(|msg| format!("{msg} (attempt {i}/3)")); + pub async fn sync(&self, sock: Option<&mut crate::sock::Sock>) -> anyhow::Result<()> { + let mut db = load_db(&self.state).await?; - let password = - get_password(&pinentry, &format!("Log in to {host}"), &err, environment).await?; + let Some(access_token) = &db.access_token else { + anyhow::bail!("failed to find access token in db"); + }; - match rbw::actions::login(&email, password.clone(), None, None).await { - Ok(creds) => { - login_success(state.clone(), creds, password, &mut db, &email).await?; + let Some(refresh_token) = &db.refresh_token else { + anyhow::bail!("failed to find refresh token in db"); + }; - break; - } - Err(rbw::error::Error::TwoFactorRequired { - providers, - sso_email_2fa_session_token, - }) => { - two_factor_required( - &state, - &pinentry, - &email, - password, - providers, - sso_email_2fa_session_token, - environment, - &mut db, - ) - .await?; + let (access_token, (protected_key, protected_private_key, protected_org_keys, entries)) = + rbw::actions::sync(access_token, refresh_token) + .await + .context("failed to sync database from server")?; - break; - } - Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { - err_msg = Some(message); - } - Err(e) => return Err(e).context("failed to log in to bitwarden instance"), + self.state.set_master_password_reprompt(&entries).await; + + db.update_access_token(access_token); + + db.protected_key = Some(protected_key); + db.protected_private_key = Some(protected_private_key); + db.protected_org_keys = protected_org_keys; + db.entries = entries; + + save_db(&self.state, &db).await?; + + if let Err(e) = subscribe_to_notifications(&self.state).await { + eprintln!("failed to subscribe to notifications: {e}"); } + + if let Some(sock) = sock { + respond_ack(sock).await?; + } + + Ok(()) } - respond_ack(sock).await?; + async fn login_success( + &self, + creds: SessionParameters, + password: rbw::locked::Password, + db: &mut rbw::db::Db, + email: &str, + ) -> anyhow::Result<()> { + db.apply_session_parameters(&creds); - Ok(()) -} + save_db(&self.state, db).await?; -async fn get_code( - pinentry: &str, - provider: rbw::api::TwoFactorProviderType, - err: &Option, - environment: &rbw::protocol::Environment, -) -> anyhow::Result { - getpin( - pinentry, - provider.header(), - provider.message(), - err, - environment, - provider.grab(), - ) - .await - .context("failed to read code from pinentry") + self.sync(None).await?; + + let db = load_db(&self.state).await?; + + let Some(protected_private_key) = db.protected_private_key else { + return Err(anyhow::anyhow!( + "failed to find protected private key in db" + )); + }; + + let res = rbw::actions::unlock( + email, + &password, + &creds.crypto_params, + &creds.protected_key, + &protected_private_key, + &db.protected_org_keys, + ); + + match res { + Ok((keys, org_keys)) => { + self.state.set_keys(keys, org_keys).await; + } + Err(e) => return Err(e).context("failed to unlock database"), + } + + Ok(()) + } } async fn two_factor( @@ -256,48 +371,8 @@ async fn two_factor( unreachable!() } -async fn login_success( - state: crate::state::State, - creds: SessionParameters, - password: rbw::locked::Password, - db: &mut rbw::db::Db, - email: &str, -) -> anyhow::Result<()> { - db.apply_session_parameters(&creds); - - save_db(&state, db).await?; - - sync(None, &state).await?; - - let db = load_db(&state).await?; - - let Some(protected_private_key) = db.protected_private_key else { - return Err(anyhow::anyhow!( - "failed to find protected private key in db" - )); - }; - - let res = rbw::actions::unlock( - email, - &password, - &creds.crypto_params, - &creds.protected_key, - &protected_private_key, - &db.protected_org_keys, - ); - - match res { - Ok((keys, org_keys)) => { - state.set_keys(keys, org_keys).await; - } - Err(e) => return Err(e).context("failed to unlock database"), - } - - Ok(()) -} - async fn unlock_state( - state: &crate::state::State, + state: &crate::agent::state::State, environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { if state.needs_unlock().await { @@ -355,20 +430,8 @@ async fn unlock_state( Ok(()) } -pub async fn unlock( - sock: &mut crate::sock::Sock, - state: &crate::state::State, - environment: &rbw::protocol::Environment, -) -> anyhow::Result<()> { - unlock_state(state, environment).await?; - - respond_ack(sock).await?; - - Ok(()) -} - async fn unlock_success( - state: &crate::state::State, + state: &crate::agent::state::State, keys: rbw::locked::Keys, org_keys: std::collections::HashMap, ) -> anyhow::Result<()> { @@ -377,68 +440,6 @@ async fn unlock_success( Ok(()) } -pub async fn lock(sock: &mut crate::sock::Sock, state: crate::state::State) -> anyhow::Result<()> { - state.clear().await; - - respond_ack(sock).await?; - - Ok(()) -} - -pub async fn check_lock( - sock: &mut crate::sock::Sock, - state: crate::state::State, -) -> anyhow::Result<()> { - if state.needs_unlock().await { - return Err(anyhow::anyhow!("agent is locked")); - } - - respond_ack(sock).await?; - - Ok(()) -} - -pub async fn sync( - sock: Option<&mut crate::sock::Sock>, - state: &crate::state::State, -) -> anyhow::Result<()> { - let mut db = load_db(&state).await?; - - let Some(access_token) = &db.access_token else { - anyhow::bail!("failed to find access token in db"); - }; - - let Some(refresh_token) = &db.refresh_token else { - anyhow::bail!("failed to find refresh token in db"); - }; - - let (access_token, (protected_key, protected_private_key, protected_org_keys, entries)) = - rbw::actions::sync(access_token, refresh_token) - .await - .context("failed to sync database from server")?; - - state.set_master_password_reprompt(&entries).await; - - db.update_access_token(access_token); - - db.protected_key = Some(protected_key); - db.protected_private_key = Some(protected_private_key); - db.protected_org_keys = protected_org_keys; - db.entries = entries; - - save_db(&state, &db).await?; - - if let Err(e) = subscribe_to_notifications(&state).await { - eprintln!("failed to subscribe to notifications: {e}"); - } - - if let Some(sock) = sock { - respond_ack(sock).await?; - } - - Ok(()) -} - fn decrypt_entry_key( entry_key: Option<&str>, keys: &rbw::locked::Keys, @@ -456,7 +457,7 @@ fn decrypt_entry_key( } async fn maybe_reprompt_password( - state: &crate::state::State, + state: &crate::agent::state::State, environment: &rbw::protocol::Environment, cipherstring: &str, ) -> anyhow::Result<()> { @@ -524,7 +525,7 @@ async fn maybe_reprompt_password( } async fn decrypt_cipher( - state: crate::state::State, + state: crate::agent::state::State, environment: &rbw::protocol::Environment, cipherstring: &str, entry_key: Option<&str>, @@ -560,7 +561,7 @@ async fn decrypt_cipher( pub async fn decrypt( sock: &mut crate::sock::Sock, - state: crate::state::State, + state: crate::agent::state::State, environment: &rbw::protocol::Environment, cipherstring: &str, entry_key: Option<&str>, @@ -574,7 +575,7 @@ pub async fn decrypt( pub async fn encrypt( sock: &mut crate::sock::Sock, - state: crate::state::State, + state: crate::agent::state::State, plaintext: &str, org_id: Option<&str>, ) -> anyhow::Result<()> { @@ -596,7 +597,7 @@ pub async fn encrypt( #[cfg(feature = "clipboard")] pub async fn clipboard_store( sock: &mut crate::sock::Sock, - state: crate::state::State, + state: crate::agent::state::State, text: &str, ) -> anyhow::Result<()> { if let Some(clipboard) = &mut (*state.clipboard_mut().await) { @@ -614,7 +615,7 @@ pub async fn clipboard_store( pub async fn clipboard_store( sock: &mut crate::sock::Sock, - _state: crate::state::State, + _state: crate::agent::state::State, _text: &str, ) -> anyhow::Result<()> { sock.send(&rbw::protocol::Response::Error { @@ -654,21 +655,21 @@ async fn respond_encrypt(sock: &mut crate::sock::Sock, cipherstring: String) -> Ok(()) } -async fn load_db(state: &crate::state::State) -> anyhow::Result { +async fn load_db(state: &crate::agent::state::State) -> anyhow::Result { let email = state.email()?; rbw::db::Db::load_async(&state.server_name(), email) .await .map_err(anyhow::Error::new) } -async fn save_db(state: &crate::state::State, db: &rbw::db::Db) -> anyhow::Result<()> { +async fn save_db(state: &crate::agent::state::State, db: &rbw::db::Db) -> anyhow::Result<()> { let email = state.email()?; db.save_async(&state.server_name(), email) .await .map_err(anyhow::Error::new) } -pub async fn subscribe_to_notifications(state: &crate::state::State) -> anyhow::Result<()> { +pub async fn subscribe_to_notifications(state: &crate::agent::state::State) -> anyhow::Result<()> { if state.notifications_handler().await.is_connected() { return Ok(()); } @@ -694,7 +695,7 @@ pub async fn subscribe_to_notifications(state: &crate::state::State) -> anyhow:: .map_or_else(|| Ok(()), |err| Err(anyhow::anyhow!(err.to_string()))) } -pub async fn get_ssh_public_keys(state: crate::state::State) -> anyhow::Result> { +pub async fn get_ssh_public_keys(state: crate::agent::state::State) -> anyhow::Result> { let environment = { let le = state.last_environment().await; state.set_timeout().await; @@ -730,7 +731,7 @@ pub async fn get_ssh_public_keys(state: crate::state::State) -> anyhow::Result anyhow::Result { let environment = { diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent/mod.rs similarity index 53% rename from src/bin/rbw-agent/agent.rs rename to src/bin/rbw-agent/agent/mod.rs index e712b4c9..50cc51bb 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent/mod.rs @@ -4,12 +4,20 @@ use tokio::{ time::{sleep_until, Instant}, }; +use crate::agent::state::State; + +mod actions; +pub mod ssh_agent; + +pub(crate) mod state; + +#[derive(Clone)] pub struct Agent { - state: crate::state::State, + state: State, } impl Agent { - pub fn new(state: crate::state::State) -> Self { + pub fn new(state: State) -> Self { Self { state } } @@ -30,7 +38,7 @@ impl Agent { log::debug!("Received Sync Message via notification channel"); self.state.set_sync_timeout().await; - if let Err(e) = crate::actions::sync(None, &self.state).await { + if let Err(e) = self.sync(None).await { eprintln!("failed to sync: {e:#}"); } } @@ -43,11 +51,11 @@ impl Agent { async fn on_connection(&self, stream: UnixStream) { let mut sock = crate::sock::Sock::new(stream); - let state = self.state.clone(); + let self_ref = self.clone(); // TODO: Check if does it make sense to handle this in another task tokio::spawn(async move { - let res = handle_request(&mut sock, state.clone()).await; + let res = self_ref.handle_request(&mut sock).await; if let Err(e) = res { sock.send(&rbw::protocol::Response::Error { error: format!("{e:#}"), @@ -61,7 +69,7 @@ impl Agent { pub async fn run(self, listener: UnixListener) -> anyhow::Result<()> { let mut nchannel = self.state.notifications_handler().await.get_channel(); - match crate::actions::subscribe_to_notifications(&self.state).await { + match actions::subscribe_to_notifications(&self.state).await { Ok(_) => { log::debug!("Successfully subscribed to notifications"); } @@ -98,7 +106,7 @@ impl Agent { // this could fail if we aren't logged in, but we // don't care about that - if let Err(e) = crate::actions::sync(None, &self.state).await { + if let Err(e) = self.sync(None).await { eprintln!("failed to sync: {e:#}"); } @@ -106,86 +114,83 @@ impl Agent { } } } -} -async fn handle_request( - sock: &mut crate::sock::Sock, - state: crate::state::State, -) -> anyhow::Result<()> { - let req = match sock.recv().await? { - Ok(msg) => msg, - Err(error) => { - sock.send(&rbw::protocol::Response::Error { error }).await?; - return Ok(()); - } - }; + async fn handle_request(&self, sock: &mut crate::sock::Sock) -> anyhow::Result<()> { + let req = match sock.recv().await? { + Ok(msg) => msg, + Err(error) => { + sock.send(&rbw::protocol::Response::Error { error }).await?; + return Ok(()); + } + }; - let (action, environment) = req.into_parts(); + let (action, environment) = req.into_parts(); - let set_timeout = match &action { - rbw::protocol::Action::Register => { - crate::actions::register(sock, state.clone(), &environment).await?; - true - } - rbw::protocol::Action::Login => { - crate::actions::login(sock, state.clone(), &environment).await?; - true - } - rbw::protocol::Action::Unlock => { - crate::actions::unlock(sock, &state, &environment).await?; - true - } - rbw::protocol::Action::CheckLock => { - crate::actions::check_lock(sock, state.clone()).await?; - false - } - rbw::protocol::Action::Lock => { - crate::actions::lock(sock, state.clone()).await?; - false - } - rbw::protocol::Action::Sync => { - crate::actions::sync(Some(sock), &state).await?; - false - } - // TODO: This alone does not do much, as it's a simple oracle open for everybody, to - // decrypt stuff. - rbw::protocol::Action::Decrypt { - cipherstring, - entry_key, - org_id, - } => { - crate::actions::decrypt( - sock, - state.clone(), - &environment, + let set_timeout = match &action { + rbw::protocol::Action::Register => { + self.register(sock, &environment).await?; + true + } + rbw::protocol::Action::Login => { + self.login(sock, &environment).await?; + true + } + rbw::protocol::Action::Unlock => { + self.unlock(sock, &environment).await?; + true + } + rbw::protocol::Action::CheckLock => { + self.check_lock(sock).await?; + false + } + rbw::protocol::Action::Lock => { + self.lock(sock).await?; + false + } + rbw::protocol::Action::Sync => { + self.sync(Some(sock)).await?; + false + } + // TODO: This alone does not do much, as it's a simple oracle open for everybody, to + // decrypt stuff. + rbw::protocol::Action::Decrypt { cipherstring, - entry_key.as_deref(), - org_id.as_deref(), - ) - .await?; - true - } - rbw::protocol::Action::Encrypt { plaintext, org_id } => { - crate::actions::encrypt(sock, state.clone(), plaintext, org_id.as_deref()).await?; - true - } - rbw::protocol::Action::ClipboardStore { text } => { - crate::actions::clipboard_store(sock, state.clone(), text).await?; - true - } - // TODO: It's better to handle the closing more gracefully - rbw::protocol::Action::Quit => std::process::exit(0), - rbw::protocol::Action::Version => { - crate::actions::version(sock).await?; - false - } - }; + entry_key, + org_id, + } => { + actions::decrypt( + sock, + self.state.clone(), + &environment, + cipherstring, + entry_key.as_deref(), + org_id.as_deref(), + ) + .await?; + true + } + rbw::protocol::Action::Encrypt { plaintext, org_id } => { + actions::encrypt(sock, self.state.clone(), plaintext, org_id.as_deref()).await?; + true + } + rbw::protocol::Action::ClipboardStore { text } => { + actions::clipboard_store(sock, self.state.clone(), text).await?; + true + } + // TODO: It's better to handle the closing more gracefully + rbw::protocol::Action::Quit => std::process::exit(0), + rbw::protocol::Action::Version => { + actions::version(sock).await?; + false + } + }; - state.set_last_environment(environment).await; + self.state.set_last_environment(environment).await; - if set_timeout { - state.set_timeout().await; - } + if set_timeout { + self.state.set_timeout().await; + } - Ok(()) + Ok(()) + } } diff --git a/src/bin/rbw-agent/ssh_agent.rs b/src/bin/rbw-agent/agent/ssh_agent.rs similarity index 94% rename from src/bin/rbw-agent/ssh_agent.rs rename to src/bin/rbw-agent/agent/ssh_agent.rs index 029e3ee8..4a4b2996 100644 --- a/src/bin/rbw-agent/ssh_agent.rs +++ b/src/bin/rbw-agent/agent/ssh_agent.rs @@ -1,16 +1,18 @@ use signature::{RandomizedSigner as _, SignatureEncoding as _, Signer as _}; use tokio::net::UnixListener; +use crate::agent::actions; + const SSH_AGENT_RSA_SHA2_256: u32 = 2; const SSH_AGENT_RSA_SHA2_512: u32 = 4; #[derive(Clone)] pub struct SshAgent { - state: crate::state::State, + state: crate::agent::state::State, } impl SshAgent { - pub fn new(state: crate::state::State) -> Self { + pub fn new(state: crate::agent::state::State) -> Self { Self { state } } @@ -31,7 +33,7 @@ impl ssh_agent_lib::agent::Session for SshAgent { async fn request_identities( &mut self, ) -> Result, ssh_agent_lib::error::AgentError> { - crate::actions::get_ssh_public_keys(self.state.clone()) + actions::get_ssh_public_keys(self.state.clone()) .await .map_err(|e| ssh_agent_lib::error::AgentError::Other(e.into()))? .into_iter() @@ -52,7 +54,7 @@ impl ssh_agent_lib::agent::Session for SshAgent { ) -> Result { let pubkey = ssh_agent_lib::ssh_key::PublicKey::new(request.pubkey, ""); - let private_key = crate::actions::find_ssh_private_key(self.state.clone(), pubkey) + let private_key = actions::find_ssh_private_key(self.state.clone(), pubkey) .await .map_err(|e| ssh_agent_lib::error::AgentError::Other(e.into()))?; diff --git a/src/bin/rbw-agent/state.rs b/src/bin/rbw-agent/agent/state.rs similarity index 99% rename from src/bin/rbw-agent/state.rs rename to src/bin/rbw-agent/agent/state.rs index 7faaaa17..34dcefc8 100644 --- a/src/bin/rbw-agent/state.rs +++ b/src/bin/rbw-agent/agent/state.rs @@ -56,8 +56,8 @@ impl State { sync_deadline = Some(Instant::now() + sync_timeout_duration); } - let state = crate::state::State { - inner: Arc::new(crate::state::InnerState { + let state = Self { + inner: Arc::new(InnerState { priv_key: RwLock::new(None), org_keys: RwLock::new(None), notifications_handler: RwLock::new(notifications_handler), diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index d7676059..1081c333 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -1,14 +1,11 @@ use anyhow::Context as _; use tokio::signal::unix::{signal, SignalKind}; -mod actions; mod agent; mod daemon; mod debugger; mod notifications; mod sock; -mod ssh_agent; -mod state; async fn async_main(startup_ack: Option) -> anyhow::Result<()> { let listener = crate::sock::listen()?; @@ -19,10 +16,10 @@ async fn async_main(startup_ack: Option) -> anyhow::R let config = rbw::config::Config::load()?; - let state = crate::state::State::new(config); + let state = crate::agent::state::State::new(config); let agent = crate::agent::Agent::new(state.clone()); - let ssh_agent = crate::ssh_agent::SshAgent::new(state); + let ssh_agent = crate::agent::ssh_agent::SshAgent::new(state); let mut sigterm = signal(SignalKind::terminate())?; let mut sigint = signal(SignalKind::interrupt())?; From 4e4380558f86c9e75a10cab9999b7089cd60719a Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 30 May 2026 21:28:54 +0200 Subject: [PATCH 210/273] port encrypt and decrypt into Agent --- src/bin/rbw-agent/agent/actions.rs | 159 +++++++++++++++-------------- src/bin/rbw-agent/agent/mod.rs | 7 +- 2 files changed, 86 insertions(+), 80 deletions(-) diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index 42f70356..5dffec4f 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -339,6 +339,89 @@ impl Agent { Ok(()) } + + pub async fn decrypt( + &self, + sock: &mut crate::sock::Sock, + environment: &rbw::protocol::Environment, + cipherstring: &str, + entry_key: Option<&str>, + org_id: Option<&str>, + ) -> anyhow::Result<()> { + let plaintext = decrypt_cipher( + self.state.clone(), + environment, + cipherstring, + entry_key, + org_id, + ) + .await?; + respond_decrypt(sock, plaintext).await?; + + Ok(()) + } + + pub async fn encrypt( + &self, + sock: &mut crate::sock::Sock, + plaintext: &str, + org_id: Option<&str>, + ) -> anyhow::Result<()> { + let Some(keys) = self.state.key(org_id).await else { + return Err(anyhow::anyhow!( + "failed to find encryption keys in in-memory state" + )); + }; + + let cipherstring = + rbw::cipherstring::CipherString::encrypt_symmetric(keys.as_ref(), plaintext.as_bytes()) + .context("failed to encrypt plaintext secret")?; + + respond_encrypt(sock, cipherstring.to_string()).await?; + + Ok(()) + } + + #[cfg(feature = "clipboard")] + pub async fn clipboard_store( + &self, + sock: &mut crate::sock::Sock, + text: &str, + ) -> anyhow::Result<()> { + if let Some(clipboard) = &mut (*self.state.clipboard_mut().await) { + clipboard + .set_text(text) + .map_err(|e| anyhow::anyhow!("couldn't store value to clipboard: {e}"))?; + } + + respond_ack(sock).await?; + + Ok(()) + } + + #[cfg(not(feature = "clipboard"))] + + pub async fn clipboard_store( + &self, + sock: &mut crate::sock::Sock, + _text: &str, + ) -> anyhow::Result<()> { + sock.send(&rbw::protocol::Response::Error { + error: "clipboard not supported".to_string(), + }) + .await?; + + Ok(()) + } +} + +pub async fn version(sock: &mut crate::sock::Sock) -> anyhow::Result<()> { + sock.send(&rbw::protocol::Response::Version { + version: rbw::protocol::VERSION, + }) + .await?; + + Ok(()) } async fn two_factor( @@ -559,82 +642,6 @@ async fn decrypt_cipher( Ok(plaintext) } -pub async fn decrypt( - sock: &mut crate::sock::Sock, - state: crate::agent::state::State, - environment: &rbw::protocol::Environment, - cipherstring: &str, - entry_key: Option<&str>, - org_id: Option<&str>, -) -> anyhow::Result<()> { - let plaintext = decrypt_cipher(state, environment, cipherstring, entry_key, org_id).await?; - respond_decrypt(sock, plaintext).await?; - - Ok(()) -} - -pub async fn encrypt( - sock: &mut crate::sock::Sock, - state: crate::agent::state::State, - plaintext: &str, - org_id: Option<&str>, -) -> anyhow::Result<()> { - let Some(keys) = state.key(org_id).await else { - return Err(anyhow::anyhow!( - "failed to find encryption keys in in-memory state" - )); - }; - - let cipherstring = - rbw::cipherstring::CipherString::encrypt_symmetric(keys.as_ref(), plaintext.as_bytes()) - .context("failed to encrypt plaintext secret")?; - - respond_encrypt(sock, cipherstring.to_string()).await?; - - Ok(()) -} - -#[cfg(feature = "clipboard")] -pub async fn clipboard_store( - sock: &mut crate::sock::Sock, - state: crate::agent::state::State, - text: &str, -) -> anyhow::Result<()> { - if let Some(clipboard) = &mut (*state.clipboard_mut().await) { - clipboard - .set_text(text) - .map_err(|e| anyhow::anyhow!("couldn't store value to clipboard: {e}"))?; - } - - respond_ack(sock).await?; - - Ok(()) -} - -#[cfg(not(feature = "clipboard"))] - -pub async fn clipboard_store( - sock: &mut crate::sock::Sock, - _state: crate::agent::state::State, - _text: &str, -) -> anyhow::Result<()> { - sock.send(&rbw::protocol::Response::Error { - error: "clipboard not supported".to_string(), - }) - .await?; - - Ok(()) -} - -pub async fn version(sock: &mut crate::sock::Sock) -> anyhow::Result<()> { - sock.send(&rbw::protocol::Response::Version { - version: rbw::protocol::VERSION, - }) - .await?; - - Ok(()) -} - async fn respond_ack(sock: &mut crate::sock::Sock) -> anyhow::Result<()> { sock.send(&rbw::protocol::Response::Ack).await?; diff --git a/src/bin/rbw-agent/agent/mod.rs b/src/bin/rbw-agent/agent/mod.rs index 50cc51bb..b2a890aa 100644 --- a/src/bin/rbw-agent/agent/mod.rs +++ b/src/bin/rbw-agent/agent/mod.rs @@ -158,9 +158,8 @@ impl Agent { entry_key, org_id, } => { - actions::decrypt( + self.decrypt( sock, - self.state.clone(), &environment, cipherstring, entry_key.as_deref(), @@ -170,11 +169,11 @@ impl Agent { true } rbw::protocol::Action::Encrypt { plaintext, org_id } => { - actions::encrypt(sock, self.state.clone(), plaintext, org_id.as_deref()).await?; + self.encrypt(sock, plaintext, org_id.as_deref()).await?; true } rbw::protocol::Action::ClipboardStore { text } => { - actions::clipboard_store(sock, self.state.clone(), text).await?; + self.clipboard_store(sock, text).await?; true } // TODO: It's better to handle the closing more gracefully From d8b397ec5238782e5b41e1edfb960665889f7e01 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 30 May 2026 21:30:02 +0200 Subject: [PATCH 211/273] remove version action --- src/bin/rbw-agent/agent/actions.rs | 9 --------- src/bin/rbw-agent/agent/mod.rs | 5 ++++- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index 5dffec4f..060ef4bc 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -415,15 +415,6 @@ impl Agent { } } -pub async fn version(sock: &mut crate::sock::Sock) -> anyhow::Result<()> { - sock.send(&rbw::protocol::Response::Version { - version: rbw::protocol::VERSION, - }) - .await?; - - Ok(()) -} - async fn two_factor( pinentry: &str, environment: &rbw::protocol::Environment, diff --git a/src/bin/rbw-agent/agent/mod.rs b/src/bin/rbw-agent/agent/mod.rs index b2a890aa..8eab38ae 100644 --- a/src/bin/rbw-agent/agent/mod.rs +++ b/src/bin/rbw-agent/agent/mod.rs @@ -179,7 +179,10 @@ impl Agent { // TODO: It's better to handle the closing more gracefully rbw::protocol::Action::Quit => std::process::exit(0), rbw::protocol::Action::Version => { - actions::version(sock).await?; + sock.send(&rbw::protocol::Response::Version { + version: rbw::protocol::VERSION, + }) + .await?; false } }; From e3fb2c595987225ebc733006590308c0901f0a64 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 30 May 2026 21:56:42 +0200 Subject: [PATCH 212/273] port a lot of stuff into Agent --- src/bin/rbw-agent/agent/actions.rs | 762 +++++++++++++-------------- src/bin/rbw-agent/agent/mod.rs | 2 +- src/bin/rbw-agent/agent/ssh_agent.rs | 21 +- src/bin/rbw-agent/main.rs | 2 +- 4 files changed, 386 insertions(+), 401 deletions(-) diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index 060ef4bc..fd31bcc8 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -15,72 +15,43 @@ async fn getpin( Ok(rbw::pinentry::getpin(pinentry, prompt, desc, err.as_deref(), environment, grab).await?) } -async fn get_client_id( - pinentry: &str, - host: &str, - err: &Option, - environment: &rbw::protocol::Environment, -) -> anyhow::Result { - getpin( - pinentry, - "API key client__id", - &format!("Log in to {host}"), - err, - environment, - false, - ) - .await - .context("failed to read client_id from pinentry") -} - -async fn get_client_secret( - pinentry: &str, - host: &str, - err: &Option, - environment: &rbw::protocol::Environment, -) -> anyhow::Result { - getpin( - pinentry, - "API key client__secret", - &format!("Log in to {host}"), - err, - environment, - false, - ) - .await - .context("failed to read client_secret from pinentry") -} - -async fn get_password( - pinentry: &str, - desc: &str, - err: &Option, - environment: &rbw::protocol::Environment, -) -> anyhow::Result { - getpin(pinentry, "Master Password", desc, err, environment, true) +impl Agent { + async fn get_client_id( + &self, + host: &str, + err: &Option, + environment: &rbw::protocol::Environment, + ) -> anyhow::Result { + getpin( + self.state.config_pinentry(), + "API key client__id", + &format!("Log in to {host}"), + err, + environment, + false, + ) .await - .context("failed to read password from pinentry") -} + .context("failed to read client_id from pinentry") + } -async fn get_code( - pinentry: &str, - provider: rbw::api::TwoFactorProviderType, - err: &Option, - environment: &rbw::protocol::Environment, -) -> anyhow::Result { - getpin( - pinentry, - provider.header(), - provider.message(), - err, - environment, - provider.grab(), - ) - .await - .context("failed to read code from pinentry") -} + async fn get_client_secret( + &self, + host: &str, + err: &Option, + environment: &rbw::protocol::Environment, + ) -> anyhow::Result { + getpin( + self.state.config_pinentry(), + "API key client__secret", + &format!("Log in to {host}"), + err, + environment, + false, + ) + .await + .context("failed to read client_secret from pinentry") + } -impl Agent { fn get_host(&self) -> anyhow::Result { let url_str = self.state.base_url(); let url = reqwest::Url::parse(&url_str).context("failed to parse base url")?; @@ -110,13 +81,11 @@ impl Agent { let email = self.state.email()?.to_string(); - let pinentry = self.state.config_pinentry().to_string(); - let mut err_msg = None; for i in 1_u8..=3 { let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); - let client_id = get_client_id(&pinentry, &host, &err, environment).await?; - let client_secret = get_client_secret(&pinentry, &host, &err, environment).await?; + let client_id = self.get_client_id(&host, &err, environment).await?; + let client_secret = self.get_client_secret(&host, &err, environment).await?; let apikey = rbw::locked::ApiKey::new(client_id, client_secret); @@ -136,9 +105,56 @@ impl Agent { Ok(()) } + async fn get_code( + &self, + provider: rbw::api::TwoFactorProviderType, + err: &Option, + environment: &rbw::protocol::Environment, + ) -> anyhow::Result { + getpin( + self.state.config_pinentry(), + provider.header(), + provider.message(), + err, + environment, + provider.grab(), + ) + .await + .context("failed to read code from pinentry") + } + + async fn two_factor( + &self, + environment: &rbw::protocol::Environment, + email: &str, + password: rbw::locked::Password, + provider: rbw::api::TwoFactorProviderType, + ) -> anyhow::Result { + let mut err_msg = None; + for i in 1_u8..=3 { + let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); + + let code = self.get_code(provider, &err, environment).await?; + let code = std::str::from_utf8(code.password()).context("code was not valid utf8")?; + + match rbw::actions::login(email, password.clone(), Some(code), Some(provider)).await { + Ok(creds) => return Ok(creds), + Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { + err_msg = Some(message); + } + // can get this if the user passes an empty string + Err(rbw::error::Error::TwoFactorRequired { .. }) if i < 3 => { + err_msg = Some("TOTP code is not a number".to_string()); + } + Err(e) => return Err(e).context("failed to log in to bitwarden instance"), + } + } + + unreachable!() + } + async fn two_factor_required( &self, - pinentry: &str, email: &str, password: rbw::locked::Password, providers: Vec, @@ -164,11 +180,31 @@ impl Agent { } } - let creds = two_factor(pinentry, environment, email, password.clone(), provider).await?; + let creds = self + .two_factor(environment, email, password.clone(), provider) + .await?; self.login_success(creds, password, db, email).await } + async fn get_password( + &self, + desc: &str, + err: &Option, + environment: &rbw::protocol::Environment, + ) -> anyhow::Result { + getpin( + self.state.config_pinentry(), + "Master Password", + desc, + err, + environment, + true, + ) + .await + .context("failed to read password from pinentry") + } + pub async fn login( &self, sock: &mut crate::sock::Sock, @@ -186,16 +222,15 @@ impl Agent { let email = self.state.email()?.to_string(); - let pinentry = self.state.config_pinentry().to_string(); - let mut err_msg = None; for i in 1_u8..=3 { let err = err_msg .as_deref() .map(|msg| format!("{msg} (attempt {i}/3)")); - let password = - get_password(&pinentry, &format!("Log in to {host}"), &err, environment).await?; + let password = self + .get_password(&format!("Log in to {host}"), &err, environment) + .await?; match rbw::actions::login(&email, password.clone(), None, None).await { Ok(creds) => { @@ -208,7 +243,6 @@ impl Agent { sso_email_2fa_session_token, }) => { self.two_factor_required( - &pinentry, &email, password, providers, @@ -237,7 +271,7 @@ impl Agent { sock: &mut crate::sock::Sock, environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { - unlock_state(&self.state, environment).await?; + self.unlock_state(environment).await?; respond_ack(sock).await?; @@ -289,7 +323,7 @@ impl Agent { save_db(&self.state, &db).await?; - if let Err(e) = subscribe_to_notifications(&self.state).await { + if let Err(e) = self.subscribe_to_notifications().await { eprintln!("failed to subscribe to notifications: {e}"); } @@ -348,14 +382,9 @@ impl Agent { entry_key: Option<&str>, org_id: Option<&str>, ) -> anyhow::Result<()> { - let plaintext = decrypt_cipher( - self.state.clone(), - environment, - cipherstring, - entry_key, - org_id, - ) - .await?; + let plaintext = self + .decrypt_cipher(environment, cipherstring, entry_key, org_id) + .await?; respond_decrypt(sock, plaintext).await?; Ok(()) @@ -413,224 +442,306 @@ impl Agent { Ok(()) } -} -async fn two_factor( - pinentry: &str, - environment: &rbw::protocol::Environment, - email: &str, - password: rbw::locked::Password, - provider: rbw::api::TwoFactorProviderType, -) -> anyhow::Result { - let mut err_msg = None; - for i in 1_u8..=3 { - let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); - - let code = get_code(pinentry, provider, &err, environment).await?; - let code = std::str::from_utf8(code.password()).context("code was not valid utf8")?; - - match rbw::actions::login(email, password.clone(), Some(code), Some(provider)).await { - Ok(creds) => return Ok(creds), - Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { - err_msg = Some(message); - } - // can get this if the user passes an empty string - Err(rbw::error::Error::TwoFactorRequired { .. }) if i < 3 => { - err_msg = Some("TOTP code is not a number".to_string()); + async fn unlock_state(&self, environment: &rbw::protocol::Environment) -> anyhow::Result<()> { + if self.state.needs_unlock().await { + let (db, email) = { + let db = load_db(&self.state).await?; + let email = self.state.email()?.to_string(); + (db, email) + }; + + let crypto_params = db.get_crypto_parameters()?; + + let Some(protected_key) = db.protected_key else { + return Err(anyhow::anyhow!("failed to find protected key in db")); + }; + + let Some(protected_private_key) = db.protected_private_key else { + return Err(anyhow::anyhow!( + "failed to find protected private key in db" + )); + }; + + let mut err_msg = None; + for i in 1_u8..=3 { + let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); + + let password = self + .get_password( + &format!("Unlock the local database for '{}'", rbw::dirs::profile()), + &err, + environment, + ) + .await?; + + match rbw::actions::unlock( + &email, + &password, + &crypto_params, + &protected_key, + &protected_private_key, + &db.protected_org_keys, + ) { + Ok((keys, org_keys)) => { + self.state.set_keys(keys, org_keys).await; + break; + } + Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { + err_msg = Some(message); + } + Err(e) => return Err(e).context("failed to unlock database"), + } } - Err(e) => return Err(e).context("failed to log in to bitwarden instance"), } - } - unreachable!() -} + Ok(()) + } -async fn unlock_state( - state: &crate::agent::state::State, - environment: &rbw::protocol::Environment, -) -> anyhow::Result<()> { - if state.needs_unlock().await { - let (db, email) = { - let db = load_db(&state).await?; - let email = state.email()?.to_string(); - (db, email) - }; + async fn maybe_reprompt_password( + &self, + environment: &rbw::protocol::Environment, + cipherstring: &str, + ) -> anyhow::Result<()> { + let mut sha256 = sha2::Sha256::new(); + sha256.update(cipherstring); + let master_password_reprompt: [u8; 32] = sha256.finalize().into(); + + if self + .state + .inner + .master_password_reprompt + .read() + .await + .contains(&master_password_reprompt) + { + let db = load_db(&self.state).await?; - let crypto_params = db.get_crypto_parameters()?; + let crypto_params = db.get_crypto_parameters()?; - let Some(protected_key) = db.protected_key else { - return Err(anyhow::anyhow!("failed to find protected key in db")); - }; + let Some(protected_key) = db.protected_key else { + return Err(anyhow::anyhow!("failed to find protected key in db")); + }; - let Some(protected_private_key) = db.protected_private_key else { - return Err(anyhow::anyhow!( - "failed to find protected private key in db" - )); - }; + let Some(protected_private_key) = db.protected_private_key else { + return Err(anyhow::anyhow!( + "failed to find protected private key in db" + )); + }; - let pinentry = state.config_pinentry().to_string(); - let mut err_msg = None; - for i in 1_u8..=3 { - let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); + let mut err_msg = None; + for i in 1_u8..=3 { + let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); - let password = get_password( - &pinentry, - &format!("Unlock the local database for '{}'", rbw::dirs::profile()), - &err, - environment, - ) - .await?; + // TODO: Remember somewhere that only GUI pinentry work, since this is a daemon. + let password = self + .get_password( + "Accessing this entry requires the master password", + &err, + environment, + ) + .await?; - match rbw::actions::unlock( - &email, - &password, - &crypto_params, - &protected_key, - &protected_private_key, - &db.protected_org_keys, - ) { - Ok((keys, org_keys)) => { - unlock_success(state, keys, org_keys).await?; - break; + match rbw::actions::unlock( + &self.state.email()?, + &password, + &crypto_params, + &protected_key, + &protected_private_key, + &db.protected_org_keys, + ) { + Ok(_) => { + break; + } + Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { + err_msg = Some(message); + } + Err(e) => return Err(e).context("failed to unlock database"), } - Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { - err_msg = Some(message); - } - Err(e) => return Err(e).context("failed to unlock database"), } } + + Ok(()) } - Ok(()) -} + async fn decrypt_cipher( + &self, + environment: &rbw::protocol::Environment, + cipherstring: &str, + entry_key: Option<&str>, + org_id: Option<&str>, + ) -> anyhow::Result { + if !self.state.master_password_reprompt_initialized() { + let db = load_db(&self.state).await?; + self.state.set_master_password_reprompt(&db.entries).await; + } -async fn unlock_success( - state: &crate::agent::state::State, - keys: rbw::locked::Keys, - org_keys: std::collections::HashMap, -) -> anyhow::Result<()> { - state.set_keys(keys, org_keys).await; + let Some(keys) = self.state.key(org_id).await else { + return Err(anyhow::anyhow!( + "failed to find decryption keys in in-memory state" + )); + }; - Ok(()) -} + let entry_key = decrypt_entry_key(entry_key, keys.as_ref())?; -fn decrypt_entry_key( - entry_key: Option<&str>, - keys: &rbw::locked::Keys, -) -> anyhow::Result> { - entry_key - .map(|ek| { - let cs = rbw::cipherstring::CipherString::new(ek) - .context("failed to parse individual item encryption key")?; - Ok(rbw::locked::Keys::new( - cs.decrypt_locked_symmetric(keys) - .context("failed to decrypt individual item encryption key")?, - )) - }) - .transpose() -} + self.maybe_reprompt_password(environment, cipherstring) + .await?; -async fn maybe_reprompt_password( - state: &crate::agent::state::State, - environment: &rbw::protocol::Environment, - cipherstring: &str, -) -> anyhow::Result<()> { - let mut sha256 = sha2::Sha256::new(); - sha256.update(cipherstring); - let master_password_reprompt: [u8; 32] = sha256.finalize().into(); - - if state - .inner - .master_password_reprompt - .read() - .await - .contains(&master_password_reprompt) - { - let db = load_db(state).await?; + let cipherstring = rbw::cipherstring::CipherString::new(cipherstring) + .context("failed to parse encrypted secret")?; - let crypto_params = db.get_crypto_parameters()?; + let plaintext = String::from_utf8( + cipherstring + .decrypt_symmetric(keys.as_ref(), entry_key.as_ref()) + .context("failed to decrypt encrypted secret")?, + ) + .context("failed to parse decrypted secret")?; - let Some(protected_key) = db.protected_key else { - return Err(anyhow::anyhow!("failed to find protected key in db")); - }; + Ok(plaintext) + } - let Some(protected_private_key) = db.protected_private_key else { - return Err(anyhow::anyhow!( - "failed to find protected private key in db" - )); + pub async fn get_ssh_public_keys(&self) -> anyhow::Result> { + let environment = { + let le = self.state.last_environment().await; + self.state.set_timeout().await; + le.clone() }; - let email = state.email()?; + self.unlock_state(&environment).await?; - let pinentry = state.config_pinentry().to_string(); - let mut err_msg = None; - for i in 1_u8..=3 { - let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); + let db = load_db(&self.state).await?; - // TODO: Remember somewhere that only GUI pinentry work, since this is a daemon. - let password = get_password( - &pinentry, - "Accessing this entry requires the master password", - &err, - environment, - ) - .await?; + let mut pubkeys = Vec::new(); + + for entry in db.entries { + if let rbw::db::EntryData::SshKey { + public_key: Some(encrypted), + .. + } = &entry.data + { + let plaintext = self + .decrypt_cipher( + &environment, + encrypted, + entry.key.as_deref(), + entry.org_id.as_deref(), + ) + .await?; - match rbw::actions::unlock( - &email, - &password, - &crypto_params, - &protected_key, - &protected_private_key, - &db.protected_org_keys, - ) { - Ok(_) => { - break; - } - Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { - err_msg = Some(message); - } - Err(e) => return Err(e).context("failed to unlock database"), + pubkeys.push(plaintext); } } + + Ok(pubkeys) } - Ok(()) -} + pub async fn find_ssh_private_key( + &self, + request_public_key: ssh_agent_lib::ssh_key::PublicKey, + ) -> anyhow::Result { + let environment = { + let le = self.state.last_environment().await; + self.state.set_timeout().await; + le.clone() + }; -async fn decrypt_cipher( - state: crate::agent::state::State, - environment: &rbw::protocol::Environment, - cipherstring: &str, - entry_key: Option<&str>, - org_id: Option<&str>, -) -> anyhow::Result { - if !state.master_password_reprompt_initialized() { - let db = load_db(&state).await?; - state.set_master_password_reprompt(&db.entries).await; + self.unlock_state(&environment).await?; + + let request_bytes = request_public_key.to_bytes(); + + let db = load_db(&self.state).await?; + + for entry in db.entries { + let rbw::db::EntryData::SshKey { + private_key, + public_key, + .. + } = &entry.data + else { + continue; + }; + + let Some(public_key_enc) = public_key else { + continue; + }; + + let public_key_plaintext = self + .decrypt_cipher( + &environment, + public_key_enc, + entry.key.as_deref(), + entry.org_id.as_deref(), + ) + .await?; + + let public_key_bytes = + ssh_agent_lib::ssh_key::PublicKey::from_openssh(&public_key_plaintext)?.to_bytes(); + + if public_key_bytes != request_bytes { + continue; + } + + let private_key_enc = private_key + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Matching entry has no private key"))?; + + let private_key_plaintext = self + .decrypt_cipher( + &environment, + private_key_enc, + entry.key.as_deref(), + entry.org_id.as_deref(), + ) + .await?; + + return ssh_agent_lib::ssh_key::PrivateKey::from_openssh(private_key_plaintext) + .map_err(anyhow::Error::new); + } + + Err(anyhow::anyhow!("No matching private key found")) } - let Some(keys) = state.key(org_id).await else { - return Err(anyhow::anyhow!( - "failed to find decryption keys in in-memory state" - )); - }; + pub async fn subscribe_to_notifications(&self) -> anyhow::Result<()> { + if self.state.notifications_handler().await.is_connected() { + return Ok(()); + } - let entry_key = decrypt_entry_key(entry_key, keys.as_ref())?; + let (email, server_name, notifications_url) = { + let email = self.state.email()?.to_string(); + let server_name = self.state.server_name(); + let notifications_url = self.state.notifications_url(); + (email, server_name, notifications_url) + }; - maybe_reprompt_password(&state, environment, cipherstring).await?; + let db = rbw::db::Db::load_async(&server_name, &email).await?; + let access_token = db.access_token.context("Error getting access token")?; - let cipherstring = rbw::cipherstring::CipherString::new(cipherstring) - .context("failed to parse encrypted secret")?; + let websocket_url = format!("{}/hub?access_token={}", notifications_url, access_token) + .replace("https://", "wss://"); - let plaintext = String::from_utf8( - cipherstring - .decrypt_symmetric(keys.as_ref(), entry_key.as_ref()) - .context("failed to decrypt encrypted secret")?, - ) - .context("failed to parse decrypted secret")?; + let mut nh = self.state.notifications_handler_mut().await; - Ok(plaintext) + nh.connect(websocket_url) + .await + .err() + .map_or_else(|| Ok(()), |err| Err(anyhow::anyhow!(err.to_string()))) + } +} + +fn decrypt_entry_key( + entry_key: Option<&str>, + keys: &rbw::locked::Keys, +) -> anyhow::Result> { + entry_key + .map(|ek| { + let cs = rbw::cipherstring::CipherString::new(ek) + .context("failed to parse individual item encryption key")?; + Ok(rbw::locked::Keys::new( + cs.decrypt_locked_symmetric(keys) + .context("failed to decrypt individual item encryption key")?, + )) + }) + .transpose() } async fn respond_ack(sock: &mut crate::sock::Sock) -> anyhow::Result<()> { @@ -666,130 +777,3 @@ async fn save_db(state: &crate::agent::state::State, db: &rbw::db::Db) -> anyhow .await .map_err(anyhow::Error::new) } - -pub async fn subscribe_to_notifications(state: &crate::agent::state::State) -> anyhow::Result<()> { - if state.notifications_handler().await.is_connected() { - return Ok(()); - } - - let (email, server_name, notifications_url) = { - let email = state.email()?.to_string(); - let server_name = state.server_name(); - let notifications_url = state.notifications_url(); - (email, server_name, notifications_url) - }; - - let db = rbw::db::Db::load_async(&server_name, &email).await?; - let access_token = db.access_token.context("Error getting access token")?; - - let websocket_url = format!("{}/hub?access_token={}", notifications_url, access_token) - .replace("https://", "wss://"); - - let mut nh = state.notifications_handler_mut().await; - - nh.connect(websocket_url) - .await - .err() - .map_or_else(|| Ok(()), |err| Err(anyhow::anyhow!(err.to_string()))) -} - -pub async fn get_ssh_public_keys(state: crate::agent::state::State) -> anyhow::Result> { - let environment = { - let le = state.last_environment().await; - state.set_timeout().await; - le.clone() - }; - - unlock_state(&state, &environment).await?; - - let db = load_db(&state).await?; - - let mut pubkeys = Vec::new(); - - for entry in db.entries { - if let rbw::db::EntryData::SshKey { - public_key: Some(encrypted), - .. - } = &entry.data - { - let plaintext = decrypt_cipher( - state.clone(), - &environment, - encrypted, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - .await?; - - pubkeys.push(plaintext); - } - } - - Ok(pubkeys) -} - -pub async fn find_ssh_private_key( - state: crate::agent::state::State, - request_public_key: ssh_agent_lib::ssh_key::PublicKey, -) -> anyhow::Result { - let environment = { - let le = state.last_environment().await; - state.set_timeout().await; - le.clone() - }; - - unlock_state(&state, &environment).await?; - - let request_bytes = request_public_key.to_bytes(); - - let db = load_db(&state).await?; - - for entry in db.entries { - let rbw::db::EntryData::SshKey { - private_key, - public_key, - .. - } = &entry.data - else { - continue; - }; - - let Some(public_key_enc) = public_key else { - continue; - }; - - let public_key_plaintext = decrypt_cipher( - state.clone(), - &environment, - public_key_enc, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - .await?; - - let public_key_bytes = - ssh_agent_lib::ssh_key::PublicKey::from_openssh(&public_key_plaintext)?.to_bytes(); - - if public_key_bytes != request_bytes { - continue; - } - - let private_key_enc = private_key - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Matching entry has no private key"))?; - - let private_key_plaintext = decrypt_cipher( - state.clone(), - &environment, - private_key_enc, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - .await?; - - return ssh_agent_lib::ssh_key::PrivateKey::from_openssh(private_key_plaintext) - .map_err(anyhow::Error::new); - } - - Err(anyhow::anyhow!("No matching private key found")) -} diff --git a/src/bin/rbw-agent/agent/mod.rs b/src/bin/rbw-agent/agent/mod.rs index 8eab38ae..d8f96082 100644 --- a/src/bin/rbw-agent/agent/mod.rs +++ b/src/bin/rbw-agent/agent/mod.rs @@ -69,7 +69,7 @@ impl Agent { pub async fn run(self, listener: UnixListener) -> anyhow::Result<()> { let mut nchannel = self.state.notifications_handler().await.get_channel(); - match actions::subscribe_to_notifications(&self.state).await { + match self.subscribe_to_notifications().await { Ok(_) => { log::debug!("Successfully subscribed to notifications"); } diff --git a/src/bin/rbw-agent/agent/ssh_agent.rs b/src/bin/rbw-agent/agent/ssh_agent.rs index 4a4b2996..cb296851 100644 --- a/src/bin/rbw-agent/agent/ssh_agent.rs +++ b/src/bin/rbw-agent/agent/ssh_agent.rs @@ -1,19 +1,17 @@ use signature::{RandomizedSigner as _, SignatureEncoding as _, Signer as _}; use tokio::net::UnixListener; -use crate::agent::actions; - const SSH_AGENT_RSA_SHA2_256: u32 = 2; const SSH_AGENT_RSA_SHA2_512: u32 = 4; #[derive(Clone)] pub struct SshAgent { - state: crate::agent::state::State, + agent: crate::agent::Agent, } impl SshAgent { - pub fn new(state: crate::agent::state::State) -> Self { - Self { state } + pub fn new(agent: crate::agent::Agent) -> Self { + Self { agent } } pub async fn run(self) -> anyhow::Result<()> { @@ -33,7 +31,8 @@ impl ssh_agent_lib::agent::Session for SshAgent { async fn request_identities( &mut self, ) -> Result, ssh_agent_lib::error::AgentError> { - actions::get_ssh_public_keys(self.state.clone()) + self.agent + .get_ssh_public_keys() .await .map_err(|e| ssh_agent_lib::error::AgentError::Other(e.into()))? .into_iter() @@ -54,15 +53,17 @@ impl ssh_agent_lib::agent::Session for SshAgent { ) -> Result { let pubkey = ssh_agent_lib::ssh_key::PublicKey::new(request.pubkey, ""); - let private_key = actions::find_ssh_private_key(self.state.clone(), pubkey) + let private_key = self + .agent + .find_ssh_private_key(pubkey) .await .map_err(|e| ssh_agent_lib::error::AgentError::Other(e.into()))?; - if self.state.confirm_ssh() { + if self.agent.state.confirm_ssh() { let confirmed = rbw::pinentry::confirm( - &self.state.config_pinentry(), + &self.agent.state.config_pinentry(), "Allow SSH key use?", - &self.state.last_environment().await.clone(), + &self.agent.state.last_environment().await.clone(), true, ) .await diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index 1081c333..6600d1cb 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -19,7 +19,7 @@ async fn async_main(startup_ack: Option) -> anyhow::R let state = crate::agent::state::State::new(config); let agent = crate::agent::Agent::new(state.clone()); - let ssh_agent = crate::agent::ssh_agent::SshAgent::new(state); + let ssh_agent = crate::agent::ssh_agent::SshAgent::new(agent.clone()); let mut sigterm = signal(SignalKind::terminate())?; let mut sigint = signal(SignalKind::interrupt())?; From 7e8108ff2d9917be55e7805629a5a6064ae6d81e Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 30 May 2026 21:57:56 +0200 Subject: [PATCH 213/273] put getpin inside Agent --- src/bin/rbw-agent/agent/actions.rs | 42 ++++++++++++++++-------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index fd31bcc8..12592d83 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -4,26 +4,33 @@ use sha2::Digest as _; use crate::agent::Agent; -async fn getpin( - pinentry: &str, - desc: &str, - prompt: &str, - err: &Option, - environment: &rbw::protocol::Environment, - grab: bool, -) -> anyhow::Result { - Ok(rbw::pinentry::getpin(pinentry, prompt, desc, err.as_deref(), environment, grab).await?) -} - impl Agent { + async fn getpin( + &self, + desc: &str, + prompt: &str, + err: &Option, + environment: &rbw::protocol::Environment, + grab: bool, + ) -> anyhow::Result { + Ok(rbw::pinentry::getpin( + self.state.config_pinentry(), + prompt, + desc, + err.as_deref(), + environment, + grab, + ) + .await?) + } + async fn get_client_id( &self, host: &str, err: &Option, environment: &rbw::protocol::Environment, ) -> anyhow::Result { - getpin( - self.state.config_pinentry(), + self.getpin( "API key client__id", &format!("Log in to {host}"), err, @@ -40,8 +47,7 @@ impl Agent { err: &Option, environment: &rbw::protocol::Environment, ) -> anyhow::Result { - getpin( - self.state.config_pinentry(), + self.getpin( "API key client__secret", &format!("Log in to {host}"), err, @@ -111,8 +117,7 @@ impl Agent { err: &Option, environment: &rbw::protocol::Environment, ) -> anyhow::Result { - getpin( - self.state.config_pinentry(), + self.getpin( provider.header(), provider.message(), err, @@ -193,8 +198,7 @@ impl Agent { err: &Option, environment: &rbw::protocol::Environment, ) -> anyhow::Result { - getpin( - self.state.config_pinentry(), + self.getpin( "Master Password", desc, err, From 5ee9cb2374dce789127fdd1dd59104a4395c66a6 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 31 May 2026 12:39:16 +0200 Subject: [PATCH 214/273] first version of modernized pinentry this commit changes a bit of stuff in LockedVec, making it more flexible to use with different environments, as it now impl Deref and DerefMut and as_str(). On top of that Pinentry is now a "object" with methods and it's waaay more clear to read and understand. Only uses LockedVec memory to save ANY output. This commit is partial and doesn't cover pinentry error detection as before, but it's a work in progress --- src/cipherstring.rs | 2 +- src/error.rs | 11 +++ src/identity.rs | 13 +--- src/locked.rs | 47 ++++++++---- src/pinentry.rs | 176 +++++++++++++++++++++++++++++++++----------- 5 files changed, 182 insertions(+), 67 deletions(-) diff --git a/src/cipherstring.rs b/src/cipherstring.rs index 5f5c9b35..4a02d2cb 100644 --- a/src/cipherstring.rs +++ b/src/cipherstring.rs @@ -147,7 +147,7 @@ impl CipherString { res.extend(ciphertext.iter().copied()); let cipher = decrypt_common_symmetric(keys, iv, ciphertext, mac.as_deref())?; cipher - .decrypt_padded_mut::(res.data_mut()) + .decrypt_padded_mut::(&mut res) .map_err(|source| Error::Decrypt { source })?; Ok(res) } else { diff --git a/src/error.rs b/src/error.rs index 64468f9b..36efab35 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,5 @@ +use std::str::Utf8Error; + #[derive(thiserror::Error, Debug)] pub enum Error { #[error("email address not set")] @@ -237,6 +239,15 @@ pub enum Error { #[error("invalid kdf type: {ty}")] InvalidKdfType { ty: String }, + + #[error("Utf8 conversion error: {source}")] + Utf8Error { source: Utf8Error } +} + +impl From for Error { + fn from(value: Utf8Error) -> Self { + Self::Utf8Error { source: value } + } } impl From for Error { diff --git a/src/identity.rs b/src/identity.rs index 781977c0..8cdd7ea5 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -22,7 +22,7 @@ impl Identity { let mut keys = crate::locked::LockedVec::new(); keys.extend(std::iter::repeat_n(0, 64)); - let enc_key = &mut keys.data_mut()[0..32]; + let enc_key = &mut keys[0..32]; match crypto_params.kdf { crate::api::KdfType::Pbkdf2 => { @@ -63,18 +63,13 @@ impl Identity { let mut hash = crate::locked::LockedVec::new(); hash.extend(std::iter::repeat_n(0, 32)); - pbkdf2::pbkdf2::>( - enc_key, - password.password(), - 1, - hash.data_mut(), - ) - .map_err(|_| Error::Pbkdf2)?; + pbkdf2::pbkdf2::>(enc_key, password.password(), 1, &mut hash) + .map_err(|_| Error::Pbkdf2)?; let hkdf = hkdf::Hkdf::::from_prk(enc_key).map_err(|_| Error::HkdfExpand)?; hkdf.expand(b"enc", enc_key) .map_err(|_| Error::HkdfExpand)?; - let mac_key = &mut keys.data_mut()[32..64]; + let mac_key = &mut keys[32..64]; hkdf.expand(b"mac", mac_key) .map_err(|_| Error::HkdfExpand)?; diff --git a/src/locked.rs b/src/locked.rs index e10d543c..862feb6d 100644 --- a/src/locked.rs +++ b/src/locked.rs @@ -1,3 +1,5 @@ +use std::{ops::{Deref, DerefMut}, str::Utf8Error}; + use zeroize::Zeroize; const LEN: usize = 4096; @@ -33,26 +35,41 @@ impl Default for LockedVec { } } +impl Deref for LockedVec { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + &self.data.0[0..self.data.1] + } +} + +impl DerefMut for LockedVec { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.data.0[0..self.data.1] + } +} + impl LockedVec { pub fn new() -> Self { Self::default() } - pub fn capacity(&self) -> usize { - LEN + pub fn from_slice(slice: &[u8]) -> Self { + let mut v = Self::new(); + v.extend(slice.iter().copied()); + v } - pub fn len(&self) -> usize { - self.data.1 + pub fn as_str(&self) -> Result<&str, Utf8Error> { + str::from_utf8(self) } - pub fn data(&self) -> &[u8] { - &self.data.0[0..self.len()] + pub fn capacity(&self) -> usize { + LEN } - pub fn data_mut(&mut self) -> &mut [u8] { - let len = self.len(); - &mut self.data.0[0..len] + pub fn len(&self) -> usize { + self.data.1 } pub fn push(&mut self, el: u8) { @@ -92,7 +109,7 @@ impl Drop for LockedVec { impl Clone for LockedVec { fn clone(&self) -> Self { let mut new_vec = Self::new(); - new_vec.extend(self.data().iter().copied()); + new_vec.extend(self.iter().copied()); new_vec } } @@ -108,7 +125,7 @@ impl Password { } pub fn password(&self) -> &[u8] { - self.password.data() + &self.password } } @@ -123,11 +140,11 @@ impl Keys { } pub fn enc_key(&self) -> &[u8] { - &self.keys.data()[0..32] + &self.keys[0..32] } pub fn mac_key(&self) -> &[u8] { - &self.keys.data()[32..64] + &self.keys[32..64] } } @@ -142,7 +159,7 @@ impl PasswordHash { } pub fn hash(&self) -> &[u8] { - self.hash.data() + &self.hash } } @@ -157,7 +174,7 @@ impl PrivateKey { } pub fn private_key(&self) -> &[u8] { - self.private_key.data() + &self.private_key } } diff --git a/src/pinentry.rs b/src/pinentry.rs index fff7da5f..bc980d27 100644 --- a/src/pinentry.rs +++ b/src/pinentry.rs @@ -1,8 +1,130 @@ -use crate::prelude::*; +use std::{convert::TryFrom as _, ffi::OsString, process::Stdio}; -use std::convert::TryFrom as _; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt as _}, + process::{Child, ChildStdout, Command}, +}; -use tokio::{io::AsyncWriteExt as _, process::Child}; +use crate::{ + error::{Error, Result}, + locked::LockedVec, +}; + +struct Pinentry { + child: Child, + stdout: ChildStdout, +} + +impl Pinentry { + async fn read_line(&mut self) -> Result { + let mut v = LockedVec::new(); + while let Ok(b) = self.stdout.read_u8().await { + if b == b'\n' { + break; + } + v.push(b); + } + + Ok(v) + } + + async fn spawn( + binary: &str, + environment: &crate::protocol::Environment, + grab: bool, + ) -> Result { + let mut cmd = Command::new(binary); + + cmd.stdin(Stdio::piped()).stdout(Stdio::piped()); + + let mut args = vec!["--timeout".into(), "0".into()]; + + if let Some(tty) = environment.tty() { + args.extend(["--ttyname".into(), tty.into()]); + } + + let env_vars = environment.env_vars(); + + // Not all pinentry appear to respect the --display flag, so we also keep the environment + // variable. + if let Some(display) = env_vars.get(OsString::from("DISPLAY").as_os_str()) { + args.extend(["--display".into(), display.clone()]); + } + + if !grab { + args.push("--no-global-grab".into()); + } + + cmd.args(args); + + for env_var in &*crate::protocol::ENVIRONMENT_VARIABLES_OS { + if let Some(val) = env_vars.get(env_var) { + cmd.env(env_var, val); + } else { + cmd.env_remove(env_var); + } + } + + cmd.envs(env_vars); + + let mut child = cmd.spawn().map_err(|source| Error::Spawn { source })?; + // unwrap is safe because we specified stdin as piped in the command opts + // above + + let Some(stdout) = child.stdout.take() else { + return Err(Error::PinentryReadOutput { + source: std::io::Error::other("stdout unavailable"), + }); + }; + + let mut p = Self { child, stdout }; + let line = p.read_line().await?; + + match &line.as_str()?[0..2] { + "OK" => Ok(p), + _ => Err(Error::PinentryErrorMessage { + error: line.as_str()?.to_string(), + }), + } + } + + async fn command(&mut self, command: &str) -> Result { + let Some(stdin) = &mut self.child.stdin else { + return Err(Error::WriteStdin { + source: std::io::Error::other("stdin unavailable"), + }); + }; + + stdin + .write_all(&format!("{command}\n").as_bytes()) + .await + .map_err(|source| Error::WriteStdin { source })?; + + let line = self.read_line().await?; + + match &line.as_str()?[0..2] { + "OK" => Ok(line), + "D " => match self.read_line().await?.as_str()? { + "OK" => Ok(line), + line => Err(Error::PinentryErrorMessage { + error: line.to_string(), + }), + }, + _ => Err(Error::PinentryErrorMessage { + error: line.as_str()?.to_string(), + }), + } + } + + async fn wait(&mut self) -> Result<()> { + self.child + .wait() + .await + .map_err(|source| Error::PinentryWait { source })?; + + Ok(()) + } +} fn spawn_pinentry( pinentry: &str, @@ -52,51 +174,21 @@ pub async fn getpin( environment: &crate::protocol::Environment, grab: bool, ) -> Result { - let mut child = spawn_pinentry(pinentry, environment, grab)?; - let mut stdin = child.stdin.take().unwrap(); + let mut pinentry = Pinentry::spawn(pinentry, environment, grab).await?; + + pinentry.command("SETTITLE rbw").await?; + pinentry.command(&format!("SETPROMPT {prompt}")).await?; + pinentry.command(&format!("SETDESC {desc}")).await?; - let mut ncommands = 1; - stdin - .write_all(b"SETTITLE rbw\n") - .await - .map_err(|source| Error::WriteStdin { source })?; - ncommands += 1; - stdin - .write_all(format!("SETPROMPT {prompt}\n").as_bytes()) - .await - .map_err(|source| Error::WriteStdin { source })?; - ncommands += 1; - stdin - .write_all(format!("SETDESC {desc}\n").as_bytes()) - .await - .map_err(|source| Error::WriteStdin { source })?; - ncommands += 1; if let Some(err) = err { - stdin - .write_all(format!("SETERROR {err}\n").as_bytes()) - .await - .map_err(|source| Error::WriteStdin { source })?; - ncommands += 1; + pinentry.command(&format!("SETERROR {err}")).await?; } - stdin - .write_all(b"GETPIN\n") - .await - .map_err(|source| Error::WriteStdin { source })?; - ncommands += 1; - drop(stdin); - let mut buf = crate::locked::LockedVec::new(); - buf.alloc_all(); + let buf = pinentry.command("GETPIN").await?; - // unwrap is safe because we specified stdout as piped in the command opts - // above - let len = read_password(ncommands, buf.data_mut(), child.stdout.as_mut().unwrap()).await?; - buf.truncate(len); + let buf = LockedVec::from_slice(&buf[2..]); - child - .wait() - .await - .map_err(|source| Error::PinentryWait { source })?; + pinentry.wait().await?; Ok(crate::locked::Password::new(buf)) } From e5adcce86d6dcead64176c24fe409228fe247ba5 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 31 May 2026 15:07:52 +0200 Subject: [PATCH 215/273] implement missing Pinentry features and remove old code --- src/pinentry.rs | 262 +++++++++++------------------------------------- 1 file changed, 59 insertions(+), 203 deletions(-) diff --git a/src/pinentry.rs b/src/pinentry.rs index bc980d27..68a5c866 100644 --- a/src/pinentry.rs +++ b/src/pinentry.rs @@ -18,10 +18,15 @@ struct Pinentry { impl Pinentry { async fn read_line(&mut self) -> Result { let mut v = LockedVec::new(); - while let Ok(b) = self.stdout.read_u8().await { + + loop { + let b = self.stdout.read_u8().await?; + if b == b'\n' { break; } + + // NOTE: This panics if the line is > 4096 bytes v.push(b); } @@ -80,11 +85,12 @@ impl Pinentry { let mut p = Self { child, stdout }; let line = p.read_line().await?; - match &line.as_str()?[0..2] { - "OK" => Ok(p), - _ => Err(Error::PinentryErrorMessage { + if line.as_str()?.starts_with("OK") { + Ok(p) + } else { + Err(Error::PinentryErrorMessage { error: line.as_str()?.to_string(), - }), + }) } } @@ -100,19 +106,48 @@ impl Pinentry { .await .map_err(|source| Error::WriteStdin { source })?; - let line = self.read_line().await?; - - match &line.as_str()?[0..2] { - "OK" => Ok(line), - "D " => match self.read_line().await?.as_str()? { - "OK" => Ok(line), - line => Err(Error::PinentryErrorMessage { - error: line.to_string(), - }), - }, - _ => Err(Error::PinentryErrorMessage { - error: line.as_str()?.to_string(), - }), + loop { + let mut line = self.read_line().await?; + + let line_str = line.as_str()?; + + if line_str.starts_with("OK") { + return Ok(line); + } else if line_str.starts_with("ERR ") { + let err = &line_str[4..]; + let mut split = err.splitn(2, ' '); + let code = split.next(); + match code { + Some("83886179") => { + return Err(Error::PinentryCancelled); + } + _ => { + return Err(Error::PinentryErrorMessage { + error: err.to_string(), + }); + } + } + } else if line_str.starts_with("S ") { + continue; + } else if line_str.starts_with("D ") { + match self.read_line().await?.as_str()? { + "OK" => { + let len = line.len(); + let len = percent_decode(&mut line[..len]); + + return Ok(LockedVec::from_slice(&line[2..len])); + } + line => { + return Err(Error::PinentryErrorMessage { + error: line.to_string(), + }); + } + } + } else { + return Err(Error::PinentryErrorMessage { + error: line.as_str()?.to_string(), + }); + } } } @@ -126,46 +161,6 @@ impl Pinentry { } } -fn spawn_pinentry( - pinentry: &str, - environment: &crate::protocol::Environment, - grab: bool, -) -> Result { - let mut opts = tokio::process::Command::new(pinentry); - opts.stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()); - let mut args = vec!["--timeout".into(), "0".into()]; - if let Some(tty) = environment.tty() { - args.extend(["--ttyname".into(), tty.into()]); - } - - let env_vars = environment.env_vars(); - // Not all pinentry appear to respect the --display flag, so we also keep the environment - // variable. - if let Some(display) = env_vars.get(std::ffi::OsString::from("DISPLAY").as_os_str()) { - args.extend(["--display".into(), display.clone()]); - } - if !grab { - args.push("--no-global-grab".into()); - } - opts.args(args); - - for env_var in &*crate::protocol::ENVIRONMENT_VARIABLES_OS { - if let Some(val) = env_vars.get(env_var) { - opts.env(env_var, val); - } else { - opts.env_remove(env_var); - } - } - opts.envs(env_vars); - - let child = opts.spawn().map_err(|source| Error::Spawn { source })?; - // unwrap is safe because we specified stdin as piped in the command opts - // above - - Ok(child) -} - pub async fn getpin( pinentry: &str, prompt: &str, @@ -186,8 +181,6 @@ pub async fn getpin( let buf = pinentry.command("GETPIN").await?; - let buf = LockedVec::from_slice(&buf[2..]); - pinentry.wait().await?; Ok(crate::locked::Password::new(buf)) @@ -199,114 +192,16 @@ pub async fn confirm( environment: &crate::protocol::Environment, grab: bool, ) -> Result { - let mut child = spawn_pinentry(pinentry, environment, grab)?; - let mut stdin = child.stdin.take().unwrap(); - - let mut ncommands = 1; - stdin - .write_all(b"SETTITLE rbw\n") - .await - .map_err(|source| Error::WriteStdin { source })?; - ncommands += 1; - stdin - .write_all(format!("SETDESC {desc}\n").as_bytes()) - .await - .map_err(|source| Error::WriteStdin { source })?; - ncommands += 1; - stdin - .write_all(b"CONFIRM\n") - .await - .map_err(|source| Error::WriteStdin { source })?; - ncommands += 1; - drop(stdin); - - let mut buf = [0u8; 64]; - read_password(ncommands, &mut buf, child.stdout.as_mut().unwrap()).await?; - - child - .wait() - .await - .map_err(|source| Error::PinentryWait { source })?; + let mut pinentry = Pinentry::spawn(pinentry, environment, grab).await?; - Ok(true) -} + pinentry.command("SETTITLE rbw").await?; + pinentry.command(&format!("SETDESC {desc}")).await?; -async fn read_password(mut ncommands: u8, data: &mut [u8], mut r: R) -> Result -where - R: tokio::io::AsyncRead + tokio::io::AsyncReadExt + Unpin + Send, -{ - let mut len = 0; - loop { - let nl = data.iter().take(len).position(|c| *c == b'\n'); - if let Some(nl) = nl { - if data.starts_with(b"OK") { - if ncommands == 1 { - len = 0; - break; - } - data.copy_within((nl + 1).., 0); - len -= nl + 1; - ncommands -= 1; - } else if data.starts_with(b"D ") { - data.copy_within(2..nl, 0); - len = nl - 2; - break; - } else if data.starts_with(b"S ") { - data.copy_within((nl + 1).., 0); - len -= nl + 1; - } else if data.starts_with(b"ERR ") { - let line: Vec = data.iter().take(nl).copied().collect(); - let line = String::from_utf8(line).unwrap(); - let mut split = line.splitn(3, ' '); - let _ = split.next(); // ERR - let code = split.next(); - match code { - Some("83886179") => { - return Err(Error::PinentryCancelled); - } - Some(code) => { - if let Some(error) = split.next() { - return Err(Error::PinentryErrorMessage { - error: error.to_string(), - }); - } - return Err(Error::PinentryErrorMessage { - error: format!("unknown error ({code})"), - }); - } - None => { - return Err(Error::PinentryErrorMessage { - error: "unknown error".to_string(), - }); - } - } - } else { - return Err(Error::FailedToParsePinentry { - out: String::from_utf8_lossy(data) - .trim_end_matches('\0') - .to_string(), - }); - } - } else { - let bytes = r - .read(&mut data[len..]) - .await - .map_err(|source| Error::PinentryReadOutput { source })?; - if bytes == 0 { - return Err(Error::PinentryReadOutput { - source: std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "unexpected EOF", - ), - }); - } - len += bytes; - } - } + pinentry.command("CONFIRM").await?; - len = percent_decode(&mut data[..len]); + pinentry.wait().await?; - Ok(len) + Ok(true) } // not using the percent-encoding crate because it doesn't provide a way to do @@ -339,42 +234,3 @@ fn percent_decode(buf: &mut [u8]) -> usize { write_idx } - -#[test] -fn test_read_password() { - let good_inputs = &[ - (0, &b"D super secret password\n"[..]), - (4, &b"OK\nOK\nOK\nD super secret password\nOK\n"[..]), - (12, &b"OK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nD super secret password\nOK\n"[..]), - (24, &b"OK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nOK\nD super secret password\nOK\n"[..]), - ]; - for (ncommands, input) in good_inputs { - let mut buf = [0; 64]; - tokio::runtime::Runtime::new().unwrap().block_on(async { - let len = read_password(*ncommands, &mut buf, &input[..]) - .await - .unwrap(); - assert_eq!(&buf[0..len], b"super secret password"); - }); - } - - let match_inputs = &[ - (&b"OK\nOK\nOK\nOK\n"[..], &b""[..]), - (&b"D foo%25bar\n"[..], &b"foo%bar"[..]), - (&b"D foo%0abar\n"[..], &b"foo\nbar"[..]), - (&b"D foo%0Abar\n"[..], &b"foo\nbar"[..]), - (&b"D foo%0Gbar\n"[..], &b"foo%0Gbar"[..]), - (&b"D foo%0\n"[..], &b"foo%0"[..]), - (&b"D foo%\n"[..], &b"foo%"[..]), - (&b"D %25foo\n"[..], &b"%foo"[..]), - (&b"D %25\n"[..], &b"%"[..]), - ]; - - for (input, output) in match_inputs { - let mut buf = [0; 64]; - tokio::runtime::Runtime::new().unwrap().block_on(async { - let len = read_password(4, &mut buf, &input[..]).await.unwrap(); - assert_eq!(&buf[0..len], &output[..]); - }); - } -} From 7a61d8092bd96b0201db7b97bcbf0abba9063db4 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 31 May 2026 15:25:31 +0200 Subject: [PATCH 216/273] simplify percent_decode and write tests tests are written with AI, but they look good :D --- src/pinentry.rs | 146 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 131 insertions(+), 15 deletions(-) diff --git a/src/pinentry.rs b/src/pinentry.rs index 68a5c866..484785c9 100644 --- a/src/pinentry.rs +++ b/src/pinentry.rs @@ -204,33 +204,149 @@ pub async fn confirm( Ok(true) } +fn hex_digit(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'A'..=b'F' => Some(b - b'A' + 10), + b'a'..=b'f' => Some(b - b'a' + 10), + _ => None, + } +} + // not using the percent-encoding crate because it doesn't provide a way to do // this in-place, and we want the password to always live within the locked // vec. should really move something like this into the percent-encoding crate // at some point. fn percent_decode(buf: &mut [u8]) -> usize { - let mut read_idx = 0; - let mut write_idx = 0; + let mut ri = 0; + let mut wi = 0; let len = buf.len(); - while read_idx < len { - let mut c = buf[read_idx]; + while ri < len { + let mut c = buf[ri]; - if c == b'%' && read_idx + 2 < len { - if let Some(h) = char::from(buf[read_idx + 1]).to_digit(16) { - if let Some(l) = char::from(buf[read_idx + 2]).to_digit(16) { - // h and l were parsed from a single hex digit, so they - // must be in the range 0-15, so these unwraps are safe - c = u8::try_from(h).unwrap() * 0x10 + u8::try_from(l).unwrap(); - read_idx += 2; + if c == b'%' && ri + 2 < len { + if let Some(h) = hex_digit(buf[ri + 1]) { + if let Some(l) = hex_digit(buf[ri + 2]) { + c = h * 0x10 + l; + ri += 2; } } } - buf[write_idx] = c; - read_idx += 1; - write_idx += 1; + buf[wi] = c; + + ri += 1; + wi += 1; } - write_idx + wi +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_digit_valid() { + assert_eq!(hex_digit(b'0'), Some(0)); + assert_eq!(hex_digit(b'9'), Some(9)); + assert_eq!(hex_digit(b'A'), Some(10)); + assert_eq!(hex_digit(b'F'), Some(15)); + assert_eq!(hex_digit(b'a'), Some(10)); + assert_eq!(hex_digit(b'f'), Some(15)); + } + + #[test] + fn hex_digit_invalid() { + assert_eq!(hex_digit(b'g'), None); + assert_eq!(hex_digit(b'G'), None); + assert_eq!(hex_digit(b'/'), None); + assert_eq!(hex_digit(b':'), None); + assert_eq!(hex_digit(b' '), None); + assert_eq!(hex_digit(b'%'), None); + } + + #[test] + fn percent_decode_empty() { + let mut buf = []; + assert_eq!(percent_decode(&mut buf), 0); + } + + #[test] + fn percent_decode_no_encoding() { + let mut buf = *b"hello"; + assert_eq!(percent_decode(&mut buf), 5); + assert_eq!(&buf[..5], b"hello"); + } + + #[test] + fn percent_decode_simple() { + let mut buf = *b"%20"; + assert_eq!(percent_decode(&mut buf), 1); + assert_eq!(&buf[..1], b" "); + } + + #[test] + fn percent_decode_uppercase() { + let mut buf = *b"%4A"; + assert_eq!(percent_decode(&mut buf), 1); + assert_eq!(&buf[..1], b"J"); + } + + #[test] + fn percent_decode_lowercase() { + let mut buf = *b"%4a"; + assert_eq!(percent_decode(&mut buf), 1); + assert_eq!(&buf[..1], b"J"); + } + + #[test] + fn percent_decode_mixed() { + let mut buf = *b"a%20b"; + assert_eq!(percent_decode(&mut buf), 3); + assert_eq!(&buf[..3], b"a b"); + } + + #[test] + fn percent_decode_multiple() { + let mut buf = *b"%20%21"; + assert_eq!(percent_decode(&mut buf), 2); + assert_eq!(&buf[..2], b" !"); + } + + #[test] + fn percent_decode_truncated_percent() { + let mut buf = *b"%"; + assert_eq!(percent_decode(&mut buf), 1); + assert_eq!(&buf[..1], b"%"); + } + + #[test] + fn percent_decode_truncated_pair() { + let mut buf = *b"%2"; + assert_eq!(percent_decode(&mut buf), 2); + assert_eq!(&buf[..2], b"%2"); + } + + #[test] + fn percent_decode_invalid_hex() { + let mut buf = *b"%ZZ"; + assert_eq!(percent_decode(&mut buf), 3); + assert_eq!(&buf[..3], b"%ZZ"); + } + + #[test] + fn percent_decode_invalid_second_digit() { + let mut buf = *b"%0G"; + assert_eq!(percent_decode(&mut buf), 3); + assert_eq!(&buf[..3], b"%0G"); + } + + #[test] + fn percent_decode_invalid_first_digit() { + let mut buf = *b"%G0"; + assert_eq!(percent_decode(&mut buf), 3); + assert_eq!(&buf[..3], b"%G0"); + } } From f249eaae9f1df3ab94939182375ceeede7bd11fd Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 31 May 2026 15:26:08 +0200 Subject: [PATCH 217/273] remove warnings --- src/pinentry.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pinentry.rs b/src/pinentry.rs index 484785c9..6626e1bf 100644 --- a/src/pinentry.rs +++ b/src/pinentry.rs @@ -1,4 +1,4 @@ -use std::{convert::TryFrom as _, ffi::OsString, process::Stdio}; +use std::{ffi::OsString, process::Stdio}; use tokio::{ io::{AsyncReadExt, AsyncWriteExt as _}, From 19889bc31acfe381425c40bba6fc4dd01582a32e Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 31 May 2026 15:49:08 +0200 Subject: [PATCH 218/273] extract args calculation from spawn and return &OsStr HashMap from environment --- src/pinentry.rs | 41 ++++++++++++++++++++++++----------------- src/protocol.rs | 18 ++++++++---------- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/src/pinentry.rs b/src/pinentry.rs index 6626e1bf..f29b3ea2 100644 --- a/src/pinentry.rs +++ b/src/pinentry.rs @@ -1,4 +1,7 @@ -use std::{ffi::OsString, process::Stdio}; +use std::{ + ffi::{OsStr, OsString}, + process::Stdio, +}; use tokio::{ io::{AsyncReadExt, AsyncWriteExt as _}, @@ -33,16 +36,8 @@ impl Pinentry { Ok(v) } - async fn spawn( - binary: &str, - environment: &crate::protocol::Environment, - grab: bool, - ) -> Result { - let mut cmd = Command::new(binary); - - cmd.stdin(Stdio::piped()).stdout(Stdio::piped()); - - let mut args = vec!["--timeout".into(), "0".into()]; + fn calc_args(environment: &crate::protocol::Environment, grab: bool) -> Vec { + let mut args: Vec = vec!["--timeout".into(), "0".into()]; if let Some(tty) = environment.tty() { args.extend(["--ttyname".into(), tty.into()]); @@ -52,18 +47,32 @@ impl Pinentry { // Not all pinentry appear to respect the --display flag, so we also keep the environment // variable. - if let Some(display) = env_vars.get(OsString::from("DISPLAY").as_os_str()) { - args.extend(["--display".into(), display.clone()]); + if let Some(display) = env_vars.get(OsStr::new("DISPLAY")) { + args.extend(["--display".into(), display.into()]); } if !grab { args.push("--no-global-grab".into()); } - cmd.args(args); + args + } + + async fn spawn( + binary: &str, + environment: &crate::protocol::Environment, + grab: bool, + ) -> Result { + let mut cmd = Command::new(binary); + + cmd.stdin(Stdio::piped()).stdout(Stdio::piped()); + + let env_vars = environment.env_vars(); + + cmd.args(Self::calc_args(environment, grab)); for env_var in &*crate::protocol::ENVIRONMENT_VARIABLES_OS { - if let Some(val) = env_vars.get(env_var) { + if let Some(val) = env_vars.get(env_var.as_os_str()) { cmd.env(env_var, val); } else { cmd.env_remove(env_var); @@ -73,8 +82,6 @@ impl Pinentry { cmd.envs(env_vars); let mut child = cmd.spawn().map_err(|source| Error::Spawn { source })?; - // unwrap is safe because we specified stdin as piped in the command opts - // above let Some(stdout) = child.stdout.take() else { return Err(Error::PinentryReadOutput { diff --git a/src/protocol.rs b/src/protocol.rs index 8a9a43ef..23577977 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1,4 +1,7 @@ -use std::os::unix::ffi::{OsStrExt as _, OsStringExt as _}; +use std::{ + ffi::{OsStr, OsString}, + os::unix::ffi::{OsStrExt as _, OsStringExt as _}, +}; pub const VERSION: u32 = { const fn parse_component(s: &str) -> u32 { @@ -72,12 +75,7 @@ pub const ENVIRONMENT_VARIABLES: &[&str] = &[ ]; pub static ENVIRONMENT_VARIABLES_OS: std::sync::LazyLock> = - std::sync::LazyLock::new(|| { - ENVIRONMENT_VARIABLES - .iter() - .map(std::ffi::OsString::from) - .collect() - }); + std::sync::LazyLock::new(|| ENVIRONMENT_VARIABLES.iter().map(OsString::from).collect()); #[derive(Hash, PartialEq, Eq, Debug, Clone)] struct SerializableOsString(std::ffi::OsString); @@ -144,11 +142,11 @@ impl Environment { self.tty.as_ref().map(|tty| tty.0.as_os_str()) } - pub fn env_vars(&self) -> std::collections::HashMap { + pub fn env_vars<'a>(&'a self) -> std::collections::HashMap<&'a OsStr, &'a OsStr> { self.env_vars .iter() - .map(|(var, val)| (var.0.clone(), val.0.clone())) - .filter(|(var, _)| (*ENVIRONMENT_VARIABLES_OS).contains(var)) + .map(|(var, val)| (var.0.as_os_str(), val.0.as_os_str())) + .filter(|(var, _)| (ENVIRONMENT_VARIABLES_OS).contains(&var.to_os_string())) .collect() } } From 71bb132d0979f46c7e7effb5bb68c279120fee2f Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 31 May 2026 16:56:15 +0200 Subject: [PATCH 219/273] improve Pinentry struct to ease future test development --- shell.nix | 1 + src/pinentry.rs | 128 ++++++++++++++++++++++++++---------------------- 2 files changed, 71 insertions(+), 58 deletions(-) diff --git a/shell.nix b/shell.nix index 3f1ad2bc..89f4dffa 100644 --- a/shell.nix +++ b/shell.nix @@ -10,5 +10,6 @@ pkgs.mkShell { rust-analyzer rustfmt clippy + pinentry-all ]; } diff --git a/src/pinentry.rs b/src/pinentry.rs index f29b3ea2..825ba4b3 100644 --- a/src/pinentry.rs +++ b/src/pinentry.rs @@ -4,8 +4,8 @@ use std::{ }; use tokio::{ - io::{AsyncReadExt, AsyncWriteExt as _}, - process::{Child, ChildStdout, Command}, + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt as _}, + process::{Child, ChildStdin, ChildStdout, Command}, }; use crate::{ @@ -13,51 +13,13 @@ use crate::{ locked::LockedVec, }; -struct Pinentry { - child: Child, - stdout: ChildStdout, +struct Pinentry { + child: Option, + reader: R, + writer: W, } -impl Pinentry { - async fn read_line(&mut self) -> Result { - let mut v = LockedVec::new(); - - loop { - let b = self.stdout.read_u8().await?; - - if b == b'\n' { - break; - } - - // NOTE: This panics if the line is > 4096 bytes - v.push(b); - } - - Ok(v) - } - - fn calc_args(environment: &crate::protocol::Environment, grab: bool) -> Vec { - let mut args: Vec = vec!["--timeout".into(), "0".into()]; - - if let Some(tty) = environment.tty() { - args.extend(["--ttyname".into(), tty.into()]); - } - - let env_vars = environment.env_vars(); - - // Not all pinentry appear to respect the --display flag, so we also keep the environment - // variable. - if let Some(display) = env_vars.get(OsStr::new("DISPLAY")) { - args.extend(["--display".into(), display.into()]); - } - - if !grab { - args.push("--no-global-grab".into()); - } - - args - } - +impl Pinentry { async fn spawn( binary: &str, environment: &crate::protocol::Environment, @@ -83,13 +45,24 @@ impl Pinentry { let mut child = cmd.spawn().map_err(|source| Error::Spawn { source })?; + let Some(stdin) = child.stdin.take() else { + return Err(Error::PinentryReadOutput { + source: std::io::Error::other("stdin unavailable"), + }); + }; + let Some(stdout) = child.stdout.take() else { return Err(Error::PinentryReadOutput { source: std::io::Error::other("stdout unavailable"), }); }; - let mut p = Self { child, stdout }; + let mut p = Self { + child: Some(child), + reader: stdout, + writer: stdin, + }; + let line = p.read_line().await?; if line.as_str()?.starts_with("OK") { @@ -100,15 +73,50 @@ impl Pinentry { }) } } +} - async fn command(&mut self, command: &str) -> Result { - let Some(stdin) = &mut self.child.stdin else { - return Err(Error::WriteStdin { - source: std::io::Error::other("stdin unavailable"), - }); - }; +impl Pinentry { + async fn read_line(&mut self) -> Result { + let mut v = LockedVec::new(); + + loop { + let b = self.reader.read_u8().await?; + + if b == b'\n' { + break; + } - stdin + // NOTE: This panics if the line is > 4096 bytes + v.push(b); + } + + Ok(v) + } + + fn calc_args(environment: &crate::protocol::Environment, grab: bool) -> Vec { + let mut args: Vec = vec!["--timeout".into(), "0".into()]; + + if let Some(tty) = environment.tty() { + args.extend(["--ttyname".into(), tty.into()]); + } + + let env_vars = environment.env_vars(); + + // Not all pinentry appear to respect the --display flag, so we also keep the environment + // variable. + if let Some(display) = env_vars.get(OsStr::new("DISPLAY")) { + args.extend(["--display".into(), display.into()]); + } + + if !grab { + args.push("--no-global-grab".into()); + } + + args + } + + async fn command(&mut self, command: &str) -> Result { + self.writer .write_all(&format!("{command}\n").as_bytes()) .await .map_err(|source| Error::WriteStdin { source })?; @@ -158,11 +166,15 @@ impl Pinentry { } } - async fn wait(&mut self) -> Result<()> { - self.child - .wait() - .await - .map_err(|source| Error::PinentryWait { source })?; + async fn wait(mut self) -> Result<()> { + if let Some(mut child) = self.child.take() { + drop(self); + + child + .wait() + .await + .map_err(|source| Error::PinentryWait { source })?; + } Ok(()) } From ed073c65487c772a46c0cf8948a24382732c29c0 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 31 May 2026 16:59:20 +0200 Subject: [PATCH 220/273] change error type returned on unavailable stdin --- src/pinentry.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pinentry.rs b/src/pinentry.rs index 825ba4b3..42d145e5 100644 --- a/src/pinentry.rs +++ b/src/pinentry.rs @@ -46,7 +46,7 @@ impl Pinentry { let mut child = cmd.spawn().map_err(|source| Error::Spawn { source })?; let Some(stdin) = child.stdin.take() else { - return Err(Error::PinentryReadOutput { + return Err(Error::WriteStdin { source: std::io::Error::other("stdin unavailable"), }); }; From 04fffe26a7df361c3d3aa1779bb333549b1c82d1 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 31 May 2026 17:20:40 +0200 Subject: [PATCH 221/273] extract read_line and make a small test --- src/pinentry.rs | 48 ++++++++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/src/pinentry.rs b/src/pinentry.rs index 42d145e5..2b99dd29 100644 --- a/src/pinentry.rs +++ b/src/pinentry.rs @@ -19,6 +19,23 @@ struct Pinentry { writer: W, } +async fn secure_read_line(reader: &mut R) -> Result { + let mut v = LockedVec::new(); + + loop { + let b = reader.read_u8().await?; + + if b == b'\n' { + break; + } + + // NOTE: This panics if the line is > 4096 bytes + v.push(b); + } + + Ok(v) +} + impl Pinentry { async fn spawn( binary: &str, @@ -63,7 +80,7 @@ impl Pinentry { writer: stdin, }; - let line = p.read_line().await?; + let line = secure_read_line(&mut p.reader).await?; if line.as_str()?.starts_with("OK") { Ok(p) @@ -76,23 +93,6 @@ impl Pinentry { } impl Pinentry { - async fn read_line(&mut self) -> Result { - let mut v = LockedVec::new(); - - loop { - let b = self.reader.read_u8().await?; - - if b == b'\n' { - break; - } - - // NOTE: This panics if the line is > 4096 bytes - v.push(b); - } - - Ok(v) - } - fn calc_args(environment: &crate::protocol::Environment, grab: bool) -> Vec { let mut args: Vec = vec!["--timeout".into(), "0".into()]; @@ -122,7 +122,7 @@ impl Pinentry { .map_err(|source| Error::WriteStdin { source })?; loop { - let mut line = self.read_line().await?; + let mut line = secure_read_line(&mut self.reader).await?; let line_str = line.as_str()?; @@ -145,7 +145,7 @@ impl Pinentry { } else if line_str.starts_with("S ") { continue; } else if line_str.starts_with("D ") { - match self.read_line().await?.as_str()? { + match secure_read_line(&mut self.reader).await?.as_str()? { "OK" => { let len = line.len(); let len = percent_decode(&mut line[..len]); @@ -264,6 +264,8 @@ fn percent_decode(buf: &mut [u8]) -> usize { #[cfg(test)] mod tests { + use std::io::Cursor; + use super::*; #[test] @@ -368,4 +370,10 @@ mod tests { assert_eq!(percent_decode(&mut buf), 3); assert_eq!(&buf[..3], b"%G0"); } + + #[tokio::test] + async fn test_secure_read_line() { + let x = secure_read_line(&mut Cursor::new("ciao\n")).await.unwrap(); + assert_eq!(x.as_str().unwrap(), "ciao"); + } } From 493312530b7e2cfdf1addf12a4a8e5ebbab17f96 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 31 May 2026 18:39:34 +0200 Subject: [PATCH 222/273] make client partially async and remove duplicated logic for db --- src/bin/rbw/commands.rs | 95 ++++++++++++++++++++++------------------- src/bin/rbw/main.rs | 83 +++++++++++++++++++---------------- src/db.rs | 43 ------------------- 3 files changed, 99 insertions(+), 122 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index fe1114bf..b2e6f47b 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -568,7 +568,7 @@ pub fn display_entry_short(entry: &rbw::db::Entry, desc: &str) -> boo true } -pub fn get( +pub async fn get( FindArgs { needle, user, @@ -583,7 +583,7 @@ pub fn get( ) -> anyhow::Result<()> { unlock()?; - let db = load_db()?; + let db = load_db().await?; let mut dec = RemoteDecrypter {}; let desc = format!( @@ -688,7 +688,7 @@ fn print_entry_list( Ok(()) } -pub fn search( +pub async fn search( term: &str, fields: &[String], folder: Option<&str>, @@ -705,7 +705,7 @@ pub fn search( unlock()?; - let db = load_db()?; + let db = load_db().await?; let mut entries: Vec = db .entries @@ -724,11 +724,11 @@ pub fn search( print_entry_list(&entries, &fields, raw) } -pub fn list(fields: &[String], raw: bool) -> anyhow::Result<()> { - search("", fields, None, raw) +pub async fn list(fields: &[String], raw: bool) -> anyhow::Result<()> { + search("", fields, None, raw).await } -pub fn code( +pub async fn code( FindArgs { needle, user, @@ -739,7 +739,7 @@ pub fn code( ) -> anyhow::Result<()> { unlock()?; - let db = load_db()?; + let db = load_db().await?; let mut dec = RemoteDecrypter {}; let desc = format!( @@ -773,7 +773,7 @@ pub fn code( Ok(()) } -fn find_or_create_folder(db: &mut rbw::db::Db, folder: &str) -> anyhow::Result { +async fn find_or_create_folder(db: &mut rbw::db::Db, folder: &str) -> anyhow::Result { let enc: &mut dyn Encrypter<()> = &mut RemoteEncrypter {}; // fat ptr trick let dec: &mut dyn Decrypter<()> = &mut RemoteDecrypter {}; @@ -782,7 +782,7 @@ fn find_or_create_folder(db: &mut rbw::db::Db, folder: &str) -> anyhow::Result = folders .into_iter() @@ -802,7 +802,7 @@ fn find_or_create_folder(db: &mut rbw::db::Db, folder: &str) -> anyhow::Result (Option, Option) { (password, notes) } -fn update_token(db: &mut rbw::db::Db, new_token: Option) -> anyhow::Result<()> { +async fn update_token(db: &mut rbw::db::Db, new_token: Option) -> anyhow::Result<()> { if db.update_access_token(new_token) { - save_db(db)?; + save_db(db).await?; } Ok(()) @@ -849,7 +849,7 @@ const HELP_NOTES: &str = r" # Lines with leading # will be ignored. "; -pub fn add( +pub async fn add( name: &str, username: Option<&str>, uris: &[(String, Option)], @@ -860,7 +860,7 @@ pub fn add( unlock()?; - let mut db = load_db()?; + let mut db = load_db().await?; // unwrap is safe here because the call to unlock above is guaranteed to // populate these or error @@ -895,7 +895,7 @@ pub fn add( .collect::>()?; let folder_id = match folder { - Some(folder) => Some(find_or_create_folder(&mut db, folder)?), + Some(folder) => Some(find_or_create_folder(&mut db, folder).await?), None => None, }; @@ -913,12 +913,12 @@ pub fn add( folder_id.as_deref(), )?; - update_token(&mut db, new_token)?; + update_token(&mut db, new_token).await?; crate::actions::sync() } -pub fn generate( +pub async fn generate( name: Option<&str>, username: Option<&str>, uris: &[(String, Option)], @@ -930,12 +930,12 @@ pub fn generate( println!("{password}"); match name { - Some(name) => add(name, username, uris, folder, Some(&password)), + Some(name) => add(name, username, uris, folder, Some(&password)).await, None => Ok(()), } } -pub fn edit( +pub async fn edit( FindArgs { needle, user, @@ -945,7 +945,7 @@ pub fn edit( ) -> anyhow::Result<()> { unlock()?; - let mut db = load_db()?; + let mut db = load_db().await?; let mut enc = RemoteEncrypter {}; let mut dec = RemoteDecrypter {}; @@ -1001,12 +1001,12 @@ pub fn edit( &entry, )?; - update_token(&mut db, new_token)?; + update_token(&mut db, new_token).await?; crate::actions::sync() } -pub fn remove( +pub async fn remove( FindArgs { needle, user, @@ -1016,7 +1016,7 @@ pub fn remove( ) -> anyhow::Result<()> { unlock()?; - let mut db = load_db()?; + let mut db = load_db().await?; let desc = format!( "{}{}", @@ -1033,12 +1033,12 @@ pub fn remove( &entry.id, )?; - update_token(&mut db, new_access_token)?; + update_token(&mut db, new_access_token).await?; crate::actions::sync() } -pub fn history( +pub async fn history( FindArgs { needle: name, user, @@ -1048,7 +1048,7 @@ pub fn history( ) -> anyhow::Result<()> { unlock()?; - let db = load_db()?; + let db = load_db().await?; let mut dec = RemoteDecrypter {}; let desc = format!( @@ -1072,10 +1072,10 @@ pub fn lock() -> anyhow::Result<()> { crate::actions::lock() } -pub fn purge() -> anyhow::Result<()> { +pub async fn purge() -> anyhow::Result<()> { stop_agent()?; - remove_db() + remove_db().await } pub fn stop_agent() -> anyhow::Result<()> { @@ -1145,29 +1145,38 @@ fn version_or_quit() -> anyhow::Result { }) } -fn with_config(f: impl FnOnce(&str, &str) -> anyhow::Result) -> anyhow::Result { - let config = rbw::config::Config::load()?; +async fn load_db() -> anyhow::Result { + let config = rbw::config::Config::load_async().await?; + let Some(email) = &config.email else { anyhow::bail!("failed to find email address in config"); }; - f(&config.server_name(), email) + rbw::db::Db::load_async(&config.server_name(), email) + .await + .map_err(anyhow::Error::new) } -fn load_db() -> anyhow::Result { - with_config(|server_name, email| { - rbw::db::Db::load(server_name, email).map_err(anyhow::Error::new) - }) -} +async fn save_db(db: &rbw::db::Db) -> anyhow::Result<()> { + let config = rbw::config::Config::load_async().await?; + + let Some(email) = &config.email else { + anyhow::bail!("failed to find email address in config"); + }; -fn save_db(db: &rbw::db::Db) -> anyhow::Result<()> { - with_config(|server_name, email| db.save(server_name, email).map_err(anyhow::Error::new)) + db.save_async(&config.server_name(), email) + .await + .map_err(anyhow::Error::new) } -fn remove_db() -> anyhow::Result<()> { - with_config(|server_name, email| { - rbw::db::Db::remove(server_name, email).map_err(anyhow::Error::new) - }) +async fn remove_db() -> anyhow::Result<()> { + let config = rbw::config::Config::load_async().await?; + + let Some(email) = &config.email else { + anyhow::bail!("failed to find email address in config"); + }; + + rbw::db::Db::remove(&config.server_name(), email).map_err(anyhow::Error::new) } fn decode_totp_secret(secret: &str) -> anyhow::Result> { diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index de03563b..a800b292 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -352,7 +352,8 @@ fn calc_pwgen_type( } } -fn main() { +#[tokio::main] +async fn main() { let opt = Opt::parse(); env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) @@ -378,7 +379,7 @@ fn main() { Opt::Unlock => commands::unlock(), Opt::Unlocked => commands::unlocked(), Opt::Sync => commands::sync(), - Opt::List { fields, raw } => commands::list(&fields, raw), + Opt::List { fields, raw } => commands::list(&fields, raw).await, Opt::Get { find_args, field, @@ -387,50 +388,59 @@ fn main() { #[cfg(feature = "clipboard")] clipboard, list_fields, - } => commands::get( - find_args, - field.as_deref(), - full, - raw, - #[cfg(feature = "clipboard")] - clipboard, - #[cfg(not(feature = "clipboard"))] - false, - list_fields, - ), + } => { + commands::get( + find_args, + field.as_deref(), + full, + raw, + #[cfg(feature = "clipboard")] + clipboard, + #[cfg(not(feature = "clipboard"))] + false, + list_fields, + ) + .await + } Opt::Search { term, fields, folder, raw, - } => commands::search(&term, &fields, folder.as_deref(), raw), + } => commands::search(&term, &fields, folder.as_deref(), raw).await, Opt::Code { find_args, #[cfg(feature = "clipboard")] clipboard, - } => commands::code( - find_args, - #[cfg(feature = "clipboard")] - clipboard, - #[cfg(not(feature = "clipboard"))] - false, - ), + } => { + commands::code( + find_args, + #[cfg(feature = "clipboard")] + clipboard, + #[cfg(not(feature = "clipboard"))] + false, + ) + .await + } Opt::Add { name, user, uri, folder, - } => commands::add( - &name, - user.as_deref(), - &uri.iter() - // XXX not sure what the ui for specifying the match type - // should be - .map(|uri| (uri.clone(), None)) - .collect::>(), - folder.as_deref(), - None, - ), + } => { + commands::add( + &name, + user.as_deref(), + &uri.iter() + // XXX not sure what the ui for specifying the match type + // should be + .map(|uri| (uri.clone(), None)) + .collect::>(), + folder.as_deref(), + None, + ) + .await + } Opt::Generate { len, name, @@ -454,12 +464,13 @@ fn main() { len, calc_pwgen_type(no_symbols, only_numbers, nonconfusables, diceware), ) + .await } - Opt::Edit { find_args } => commands::edit(find_args), - Opt::Remove { find_args } => commands::remove(find_args), - Opt::History { find_args } => commands::history(find_args), + Opt::Edit { find_args } => commands::edit(find_args).await, + Opt::Remove { find_args } => commands::remove(find_args).await, + Opt::History { find_args } => commands::history(find_args).await, Opt::Lock => commands::lock(), - Opt::Purge => commands::purge(), + Opt::Purge => commands::purge().await, Opt::StopAgent => commands::stop_agent(), Opt::GenCompletions { shell } => { gen_completions(shell); diff --git a/src/db.rs b/src/db.rs index 01158fba..61decbc9 100644 --- a/src/db.rs +++ b/src/db.rs @@ -6,7 +6,6 @@ use crate::{ use std::{ collections::HashMap, fmt::Display, - io::{Read as _, Write as _}, }; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; @@ -887,23 +886,6 @@ impl Db { Self::default() } - pub fn load(server: &str, email: &str) -> Result { - let file = crate::dirs::db_file(server, email)?; - let mut fh = std::fs::File::open(&file).map_err(|source| Error::LoadDb { - source, - file: file.clone(), - })?; - let mut json = String::new(); - fh.read_to_string(&mut json) - .map_err(|source| Error::LoadDb { - source, - file: file.clone(), - })?; - let slf: Self = - serde_json::from_str(&json).map_err(|source| Error::LoadDbJson { source, file })?; - Ok(slf) - } - pub async fn load_async(server: &str, email: &str) -> Result { let file = crate::dirs::db_file(server, email)?; let mut fh = tokio::fs::File::open(&file) @@ -970,31 +952,6 @@ impl Db { }) } - // XXX need to make this atomic - pub fn save(&self, server: &str, email: &str) -> Result<()> { - let file = crate::dirs::db_file(server, email)?; - // unwrap is safe here because Self::filename is explicitly - // constructed as a filename in a directory - std::fs::create_dir_all(file.parent().unwrap()).map_err(|source| Error::SaveDb { - source, - file: file.clone(), - })?; - let mut fh = std::fs::File::create(&file).map_err(|source| Error::SaveDb { - source, - file: file.clone(), - })?; - fh.write_all( - serde_json::to_string(self) - .map_err(|source| Error::SaveDbJson { - source, - file: file.clone(), - })? - .as_bytes(), - ) - .map_err(|source| Error::SaveDb { source, file })?; - Ok(()) - } - // XXX need to make this atomic pub async fn save_async(&self, server: &str, email: &str) -> Result<()> { let file = crate::dirs::db_file(server, email)?; From f17c555f1a68712d39b8506ca1db39d0b3f29460 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 31 May 2026 19:03:30 +0200 Subject: [PATCH 223/273] make client async --- src/actions.rs | 132 +++++++++++++++++++++------------------- src/api/client.rs | 81 ++++++++++-------------- src/bin/rbw/commands.rs | 15 +++-- 3 files changed, 112 insertions(+), 116 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index 077b4d15..7df6675b 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -143,7 +143,7 @@ async fn sync_once( client.sync(access_token).await } -pub fn add( +pub async fn add( access_token: &str, refresh_token: &str, name: &str, @@ -151,91 +151,113 @@ pub fn add( notes: Option<&str>, folder_id: Option<&str>, ) -> Result<(Option, ())> { - with_exchange_refresh_token(access_token, refresh_token, |access_token| { - add_once(access_token, name, data, notes, folder_id) + with_exchange_refresh_token_async(access_token, refresh_token, |access_token| { + let access_token = access_token.to_string(); + let name = name.to_string(); + let data = data.clone(); + let notes = notes.map(|s| s.to_string()); + let folder_id = folder_id.map(|f| f.to_string()); + + Box::pin(async move { + add_once( + &access_token, + &name, + &data, + notes.as_deref(), + folder_id.as_deref(), + ) + .await + }) }) + .await } -fn add_once( +async fn add_once( access_token: &str, name: &str, data: &crate::db::EntryData, notes: Option<&str>, folder_id: Option<&str>, ) -> Result<()> { - let (client, _) = api_client()?; - client.add(access_token, name, data, notes, folder_id)?; + let (client, _) = api_client_async().await?; + client + .add(access_token, name, data, notes, folder_id) + .await?; Ok(()) } -pub fn edit( +pub async fn edit( access_token: &str, refresh_token: &str, entry: &Entry, ) -> Result<(Option, ())> { - with_exchange_refresh_token(access_token, refresh_token, |access_token| { - api_client()?.0.edit(access_token, entry) + with_exchange_refresh_token_async(access_token, refresh_token, |access_token| { + let access_token = access_token.to_string(); + // TODO: Super ugly clone + let entry = entry.clone(); + Box::pin(async move { + api_client_async() + .await? + .0 + .edit(&access_token, &entry) + .await + }) }) + .await } -pub fn remove(access_token: &str, refresh_token: &str, id: &str) -> Result<(Option, ())> { - with_exchange_refresh_token(access_token, refresh_token, |access_token| { - remove_once(access_token, id) +pub async fn remove( + access_token: &str, + refresh_token: &str, + id: &str, +) -> Result<(Option, ())> { + with_exchange_refresh_token_async(access_token, refresh_token, |access_token| { + let access_token = access_token.to_string(); + let id = id.to_string(); + Box::pin(async move { remove_once(&access_token, &id).await }) }) + .await } -fn remove_once(access_token: &str, id: &str) -> Result<()> { - let (client, _) = api_client()?; - client.remove(access_token, id)?; +async fn remove_once(access_token: &str, id: &str) -> Result<()> { + let (client, _) = api_client_async().await?; + client.remove(access_token, id).await?; Ok(()) } -pub fn list_folders( +pub async fn list_folders( access_token: &str, refresh_token: &str, ) -> Result<(Option, Vec<(String, String)>)> { - with_exchange_refresh_token(access_token, refresh_token, |access_token| { - list_folders_once(access_token) + with_exchange_refresh_token_async(access_token, refresh_token, |access_token| { + let access_token = access_token.to_string(); + Box::pin(async move { list_folders_once(&access_token).await }) }) + .await } -fn list_folders_once(access_token: &str) -> Result> { - let (client, _) = api_client()?; - client.folders(access_token) +async fn list_folders_once(access_token: &str) -> Result> { + let (client, _) = api_client_async().await?; + client.folders(access_token).await } -pub fn create_folder( +pub async fn create_folder( access_token: &str, refresh_token: &str, name: &str, ) -> Result<(Option, String)> { - with_exchange_refresh_token(access_token, refresh_token, |access_token| { - create_folder_once(access_token, name) - }) -} + with_exchange_refresh_token_async(access_token, refresh_token, |access_token| { + let access_token = access_token.to_string(); + let name = name.to_string(); -fn create_folder_once(access_token: &str, name: &str) -> Result { - let (client, _) = api_client()?; - client.create_folder(access_token, name) + Box::pin(async move { create_folder_once(&access_token, &name).await }) + }) + .await } -fn with_exchange_refresh_token( - access_token: &str, - refresh_token: &str, - f: F, -) -> Result<(Option, T)> -where - F: Fn(&str) -> Result, -{ - match f(access_token) { - Ok(t) => Ok((None, t)), - Err(Error::RequestUnauthorized) => { - let access_token = exchange_refresh_token(refresh_token)?; - let t = f(&access_token)?; - Ok((Some(access_token), t)) - } - Err(e) => Err(e), - } +async fn create_folder_once(access_token: &str, name: &str) -> Result { + let (client, _) = api_client_async().await?; + client.create_folder(access_token, name).await } async fn with_exchange_refresh_token_async( @@ -260,27 +282,11 @@ where } } -fn exchange_refresh_token(refresh_token: &str) -> Result { - let (client, _) = api_client()?; - client.exchange_refresh_token(refresh_token) -} - async fn exchange_refresh_token_async(refresh_token: &str) -> Result { - let (client, _) = api_client()?; + let (client, _) = api_client_async().await?; client.exchange_refresh_token_async(refresh_token).await } -fn api_client() -> Result<(crate::api::client::Client, crate::config::Config)> { - let config = crate::config::Config::load()?; - let client = crate::api::client::Client::new( - &config.base_url(), - &config.identity_url(), - &config.ui_url(), - config.client_cert_path(), - ); - Ok((client, config)) -} - async fn api_client_async() -> Result<(crate::api::client::Client, crate::config::Config)> { let config = crate::config::Config::load_async().await?; let client = crate::api::client::Client::new( diff --git a/src/api/client.rs b/src/api/client.rs index f49e5a36..57c5dfef 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -34,6 +34,11 @@ enum ClientRequest<'a> { SendEmailLogin(&'a str, &'a str, &'a str), Sync(&'a str), ExchangeRefreshToken(&'a str), + Add(&'a str, CiphersPostReq<'a>), + Edit(&'a str, &'a str, CiphersPutReq<'a>), + Remove(&'a str, &'a str), + Folders(&'a str), + CreateFolder(&'a str, &'a str), } impl<'a> ClientRequest<'a> { @@ -73,26 +78,6 @@ impl<'a> ClientRequest<'a> { ("client_id", "cli"), ("refresh_token", refresh_token), ]), - }; - - Ok(rb.send().await?) - } -} - -enum ClientBlockingRequest<'a> { - Add(&'a str, CiphersPostReq<'a>), - Edit(&'a str, &'a str, CiphersPutReq<'a>), - Remove(&'a str, &'a str), - Folders(&'a str), - CreateFolder(&'a str, &'a str), - ExchangeRefreshToken(&'a str), -} - -impl<'a> ClientBlockingRequest<'a> { - fn req(self, client: &Client) -> Result { - let http_client = reqwest::blocking::Client::new(); - - let rb = match self { Self::Add(access_token, r) => http_client .post(client.api_url("/ciphers")) .header("Authorization", format!("Bearer {access_token}")) @@ -111,16 +96,9 @@ impl<'a> ClientBlockingRequest<'a> { .post(client.api_url("/folders")) .header("Authorization", format!("Bearer {access_token}")) .json(&serde_json::json!({"name": name})), - Self::ExchangeRefreshToken(refresh_token) => http_client - .post(client.identity_url("/connect/token")) - .form(&[ - ("grant_type", "refresh_token"), - ("client_id", "cli"), - ("refresh_token", refresh_token), - ]), }; - Ok(rb.send()?) + Ok(rb.send().await?) } } @@ -529,7 +507,7 @@ impl Client { )) } - pub fn add( + pub async fn add( &self, access_token: &str, name: &str, @@ -544,14 +522,15 @@ impl Client { data: EntryDataWire(data), }; - ClientBlockingRequest::Add(access_token, req) - .req(self)? + ClientRequest::Add(access_token, req) + .req(self) + .await? .error_for_status()?; Ok(()) } - pub fn edit(&self, access_token: &str, entry: &Entry) -> Result<()> { + pub async fn edit(&self, access_token: &str, entry: &Entry) -> Result<()> { let req = CiphersPutReq { folder_id: entry.folder_id.as_deref(), organization_id: entry.org_id.as_deref(), @@ -570,27 +549,30 @@ impl Client { .collect::>(), }; - ClientBlockingRequest::Edit(access_token, &entry.id, req) - .req(self)? + ClientRequest::Edit(access_token, &entry.id, req) + .req(self) + .await? .error_for_status()?; Ok(()) } - pub fn remove(&self, access_token: &str, id: &str) -> Result<()> { - ClientBlockingRequest::Remove(access_token, id) - .req(self)? + pub async fn remove(&self, access_token: &str, id: &str) -> Result<()> { + ClientRequest::Remove(access_token, id) + .req(self) + .await? .error_for_status()?; Ok(()) } - pub fn folders(&self, access_token: &str) -> Result> { - let res = ClientBlockingRequest::Folders(access_token) - .req(self)? + pub async fn folders(&self, access_token: &str) -> Result> { + let res = ClientRequest::Folders(access_token) + .req(self) + .await? .error_for_status()?; - let folders_res: FoldersRes = res.json_with_path()?; + let folders_res: FoldersRes = res.json_with_path().await?; Ok(folders_res .data @@ -599,19 +581,22 @@ impl Client { .collect()) } - pub fn create_folder(&self, access_token: &str, name: &str) -> Result { - let res = ClientBlockingRequest::CreateFolder(access_token, name) - .req(self)? + pub async fn create_folder(&self, access_token: &str, name: &str) -> Result { + let res = ClientRequest::CreateFolder(access_token, name) + .req(self) + .await? .error_for_status()?; - let folders_res: FoldersResData = res.json_with_path()?; + let folders_res: FoldersResData = res.json_with_path().await?; Ok(folders_res.id) } - pub fn exchange_refresh_token(&self, refresh_token: &str) -> Result { - let res = ClientBlockingRequest::ExchangeRefreshToken(refresh_token).req(self)?; - let connect_res: ConnectRefreshTokenRes = res.json_with_path()?; + pub async fn exchange_refresh_token(&self, refresh_token: &str) -> Result { + let res = ClientRequest::ExchangeRefreshToken(refresh_token) + .req(self) + .await?; + let connect_res: ConnectRefreshTokenRes = res.json_with_path().await?; Ok(connect_res.access_token) } diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index b2e6f47b..d7d6fa68 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -780,7 +780,8 @@ async fn find_or_create_folder(db: &mut rbw::db::Db, folder: &str) -> anyhow::Re let (new_access_token, folders) = rbw::actions::list_folders( db.access_token.as_ref().unwrap(), db.refresh_token.as_ref().unwrap(), - )?; + ) + .await?; update_token(db, new_access_token).await?; @@ -800,7 +801,8 @@ async fn find_or_create_folder(db: &mut rbw::db::Db, folder: &str) -> anyhow::Re db.access_token.as_ref().unwrap(), db.refresh_token.as_ref().unwrap(), &enc.encrypt_field(None, folder)?, - )?; + ) + .await?; update_token(db, new_access_token).await?; @@ -911,7 +913,8 @@ pub async fn add( }, notes.as_deref(), folder_id.as_deref(), - )?; + ) + .await?; update_token(&mut db, new_token).await?; @@ -999,7 +1002,8 @@ pub async fn edit( db.access_token.as_ref().unwrap(), db.refresh_token.as_ref().unwrap(), &entry, - )?; + ) + .await?; update_token(&mut db, new_token).await?; @@ -1031,7 +1035,8 @@ pub async fn remove( db.access_token.as_ref().unwrap(), db.refresh_token.as_ref().unwrap(), &entry.id, - )?; + ) + .await?; update_token(&mut db, new_access_token).await?; From ab6fa479157270d32896fea1eac5292d317d88a2 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 31 May 2026 19:16:13 +0200 Subject: [PATCH 224/273] avoid heap allocation when refreshing token asyncrhonously --- src/actions.rs | 74 ++++++++++++++++---------------------------------- 1 file changed, 23 insertions(+), 51 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index 7df6675b..11b7de6b 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -124,9 +124,8 @@ pub async fn sync( Vec>, ), )> { - with_exchange_refresh_token_async(access_token, refresh_token, |access_token| { - let access_token = access_token.to_string(); - Box::pin(async move { sync_once(&access_token).await }) + with_exchange_refresh_token_async(access_token, refresh_token, |token| async move { + sync_once(&token).await }) .await } @@ -151,23 +150,8 @@ pub async fn add( notes: Option<&str>, folder_id: Option<&str>, ) -> Result<(Option, ())> { - with_exchange_refresh_token_async(access_token, refresh_token, |access_token| { - let access_token = access_token.to_string(); - let name = name.to_string(); - let data = data.clone(); - let notes = notes.map(|s| s.to_string()); - let folder_id = folder_id.map(|f| f.to_string()); - - Box::pin(async move { - add_once( - &access_token, - &name, - &data, - notes.as_deref(), - folder_id.as_deref(), - ) - .await - }) + with_exchange_refresh_token_async(access_token, refresh_token, |token| async move { + add_once(&token, name, data, notes, folder_id).await }) .await } @@ -186,22 +170,18 @@ async fn add_once( Ok(()) } +async fn edit_once(access_token: &str, entry: &crate::db::Entry) -> Result<()> { + let (client, _) = api_client_async().await?; + client.edit(access_token, entry).await +} + pub async fn edit( access_token: &str, refresh_token: &str, entry: &Entry, ) -> Result<(Option, ())> { - with_exchange_refresh_token_async(access_token, refresh_token, |access_token| { - let access_token = access_token.to_string(); - // TODO: Super ugly clone - let entry = entry.clone(); - Box::pin(async move { - api_client_async() - .await? - .0 - .edit(&access_token, &entry) - .await - }) + with_exchange_refresh_token_async(access_token, refresh_token, |token| async move { + edit_once(&token, entry).await }) .await } @@ -211,10 +191,8 @@ pub async fn remove( refresh_token: &str, id: &str, ) -> Result<(Option, ())> { - with_exchange_refresh_token_async(access_token, refresh_token, |access_token| { - let access_token = access_token.to_string(); - let id = id.to_string(); - Box::pin(async move { remove_once(&access_token, &id).await }) + with_exchange_refresh_token_async(access_token, refresh_token, |token| async move { + remove_once(&token, id).await }) .await } @@ -229,9 +207,8 @@ pub async fn list_folders( access_token: &str, refresh_token: &str, ) -> Result<(Option, Vec<(String, String)>)> { - with_exchange_refresh_token_async(access_token, refresh_token, |access_token| { - let access_token = access_token.to_string(); - Box::pin(async move { list_folders_once(&access_token).await }) + with_exchange_refresh_token_async(access_token, refresh_token, |token| async move { + list_folders_once(&token).await }) .await } @@ -246,11 +223,8 @@ pub async fn create_folder( refresh_token: &str, name: &str, ) -> Result<(Option, String)> { - with_exchange_refresh_token_async(access_token, refresh_token, |access_token| { - let access_token = access_token.to_string(); - let name = name.to_string(); - - Box::pin(async move { create_folder_once(&access_token, &name).await }) + with_exchange_refresh_token_async(access_token, refresh_token, |token| async move { + create_folder_once(&token, name).await }) .await } @@ -260,22 +234,20 @@ async fn create_folder_once(access_token: &str, name: &str) -> Result { client.create_folder(access_token, name).await } -async fn with_exchange_refresh_token_async( +async fn with_exchange_refresh_token_async( access_token: &str, refresh_token: &str, - f: F, + mut f: F, ) -> Result<(Option, T)> where - F: Fn(&str) -> std::pin::Pin> + Send>> - + Send - + Sync, - T: Send, + F: FnMut(String) -> Fut, + Fut: std::future::Future>, { - match f(access_token).await { + match f(access_token.to_string()).await { Ok(t) => Ok((None, t)), Err(Error::RequestUnauthorized) => { let access_token = exchange_refresh_token_async(refresh_token).await?; - let t = f(&access_token).await?; + let t = f(access_token.clone()).await?; Ok((Some(access_token), t)) } Err(e) => Err(e), From f356295b6a3fec5cfeea7de63733ba11fd37c803 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 1 Jun 2026 15:02:20 +0200 Subject: [PATCH 225/273] add print debug for ssh key signature requests --- src/bin/rbw-agent/agent/ssh_agent.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/bin/rbw-agent/agent/ssh_agent.rs b/src/bin/rbw-agent/agent/ssh_agent.rs index cb296851..7718425e 100644 --- a/src/bin/rbw-agent/agent/ssh_agent.rs +++ b/src/bin/rbw-agent/agent/ssh_agent.rs @@ -53,6 +53,13 @@ impl ssh_agent_lib::agent::Session for SshAgent { ) -> Result { let pubkey = ssh_agent_lib::ssh_key::PublicKey::new(request.pubkey, ""); + log::debug!( + "Received SSH signature request for {}", + pubkey + .to_openssh() + .map_err(|e| ssh_agent_lib::error::AgentError::other(e))? + ); + let private_key = self .agent .find_ssh_private_key(pubkey) From 364fcac1ef1f4ae159058eab8cc623962b5e4d6a Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 1 Jun 2026 15:02:59 +0200 Subject: [PATCH 226/273] format and remove extra destructuring --- src/bin/rbw-agent/agent/actions.rs | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index 12592d83..602a8ac3 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -198,15 +198,9 @@ impl Agent { err: &Option, environment: &rbw::protocol::Environment, ) -> anyhow::Result { - self.getpin( - "Master Password", - desc, - err, - environment, - true, - ) - .await - .context("failed to read password from pinentry") + self.getpin("Master Password", desc, err, environment, true) + .await + .context("failed to read password from pinentry") } pub async fn login( @@ -449,11 +443,8 @@ impl Agent { async fn unlock_state(&self, environment: &rbw::protocol::Environment) -> anyhow::Result<()> { if self.state.needs_unlock().await { - let (db, email) = { - let db = load_db(&self.state).await?; - let email = self.state.email()?.to_string(); - (db, email) - }; + let db = load_db(&self.state).await?; + let email = self.state.email()?.to_string(); let crypto_params = db.get_crypto_parameters()?; From fbd091d056bfb40722407bd639609be20e9630bf Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 1 Jun 2026 15:40:55 +0200 Subject: [PATCH 227/273] move decrypt_cipher up --- src/bin/rbw-agent/agent/actions.rs | 72 +++++++++++++++--------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index 602a8ac3..f37d0351 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -372,6 +372,42 @@ impl Agent { Ok(()) } + async fn decrypt_cipher( + &self, + environment: &rbw::protocol::Environment, + cipherstring: &str, + entry_key: Option<&str>, + org_id: Option<&str>, + ) -> anyhow::Result { + if !self.state.master_password_reprompt_initialized() { + let db = load_db(&self.state).await?; + self.state.set_master_password_reprompt(&db.entries).await; + } + + let Some(keys) = self.state.key(org_id).await else { + return Err(anyhow::anyhow!( + "failed to find decryption keys in in-memory state" + )); + }; + + let entry_key = decrypt_entry_key(entry_key, keys.as_ref())?; + + self.maybe_reprompt_password(environment, cipherstring) + .await?; + + let cipherstring = rbw::cipherstring::CipherString::new(cipherstring) + .context("failed to parse encrypted secret")?; + + let plaintext = String::from_utf8( + cipherstring + .decrypt_symmetric(keys.as_ref(), entry_key.as_ref()) + .context("failed to decrypt encrypted secret")?, + ) + .context("failed to parse decrypted secret")?; + + Ok(plaintext) + } + pub async fn decrypt( &self, sock: &mut crate::sock::Sock, @@ -559,42 +595,6 @@ impl Agent { Ok(()) } - async fn decrypt_cipher( - &self, - environment: &rbw::protocol::Environment, - cipherstring: &str, - entry_key: Option<&str>, - org_id: Option<&str>, - ) -> anyhow::Result { - if !self.state.master_password_reprompt_initialized() { - let db = load_db(&self.state).await?; - self.state.set_master_password_reprompt(&db.entries).await; - } - - let Some(keys) = self.state.key(org_id).await else { - return Err(anyhow::anyhow!( - "failed to find decryption keys in in-memory state" - )); - }; - - let entry_key = decrypt_entry_key(entry_key, keys.as_ref())?; - - self.maybe_reprompt_password(environment, cipherstring) - .await?; - - let cipherstring = rbw::cipherstring::CipherString::new(cipherstring) - .context("failed to parse encrypted secret")?; - - let plaintext = String::from_utf8( - cipherstring - .decrypt_symmetric(keys.as_ref(), entry_key.as_ref()) - .context("failed to decrypt encrypted secret")?, - ) - .context("failed to parse decrypted secret")?; - - Ok(plaintext) - } - pub async fn get_ssh_public_keys(&self) -> anyhow::Result> { let environment = { let le = self.state.last_environment().await; From 056c3b3acb84bdfd718f272297174502b2fbf78b Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 1 Jun 2026 16:15:25 +0200 Subject: [PATCH 228/273] divide getting priv ssh key fn in two pieces and remove respond_encrypt and respond_decrypt fns --- src/bin/rbw-agent/agent/actions.rs | 84 ++++++++++++------------------ 1 file changed, 32 insertions(+), 52 deletions(-) diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index f37d0351..c594c272 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -419,7 +419,9 @@ impl Agent { let plaintext = self .decrypt_cipher(environment, cipherstring, entry_key, org_id) .await?; - respond_decrypt(sock, plaintext).await?; + + sock.send(&rbw::protocol::Response::Decrypt { plaintext }) + .await?; Ok(()) } @@ -440,7 +442,10 @@ impl Agent { rbw::cipherstring::CipherString::encrypt_symmetric(keys.as_ref(), plaintext.as_bytes()) .context("failed to encrypt plaintext secret")?; - respond_encrypt(sock, cipherstring.to_string()).await?; + sock.send(&rbw::protocol::Response::Encrypt { + cipherstring: cipherstring.to_string(), + }) + .await?; Ok(()) } @@ -646,50 +651,39 @@ impl Agent { let db = load_db(&self.state).await?; - for entry in db.entries { - let rbw::db::EntryData::SshKey { - private_key, - public_key, - .. - } = &entry.data - else { - continue; - }; - - let Some(public_key_enc) = public_key else { - continue; - }; - - let public_key_plaintext = self - .decrypt_cipher( - &environment, - public_key_enc, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) + // Collect all ssh keys that are Some() + let keys: Vec<(&String, &String, &Option, &Option)> = db + .entries + .iter() + .filter_map(|e| match &e.data { + rbw::db::EntryData::SshKey { + private_key, + public_key, + .. + } => match (public_key, private_key) { + (Some(public), Some(private)) => Some((public, private, &e.key, &e.org_id)), + _ => None, + }, + _ => None, + }) + .collect(); + + for (public, private, key, org_id) in keys { + let pub_plain = self + .decrypt_cipher(&environment, public, key.as_deref(), org_id.as_deref()) .await?; - let public_key_bytes = - ssh_agent_lib::ssh_key::PublicKey::from_openssh(&public_key_plaintext)?.to_bytes(); + let pub_bytes = ssh_agent_lib::ssh_key::PublicKey::from_openssh(&pub_plain)?.to_bytes(); - if public_key_bytes != request_bytes { + if pub_bytes != request_bytes { continue; } - let private_key_enc = private_key - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Matching entry has no private key"))?; - - let private_key_plaintext = self - .decrypt_cipher( - &environment, - private_key_enc, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) + let priv_plain = self + .decrypt_cipher(&environment, private, key.as_deref(), org_id.as_deref()) .await?; - return ssh_agent_lib::ssh_key::PrivateKey::from_openssh(private_key_plaintext) + return ssh_agent_lib::ssh_key::PrivateKey::from_openssh(priv_plain) .map_err(anyhow::Error::new); } @@ -745,20 +739,6 @@ async fn respond_ack(sock: &mut crate::sock::Sock) -> anyhow::Result<()> { Ok(()) } -async fn respond_decrypt(sock: &mut crate::sock::Sock, plaintext: String) -> anyhow::Result<()> { - sock.send(&rbw::protocol::Response::Decrypt { plaintext }) - .await?; - - Ok(()) -} - -async fn respond_encrypt(sock: &mut crate::sock::Sock, cipherstring: String) -> anyhow::Result<()> { - sock.send(&rbw::protocol::Response::Encrypt { cipherstring }) - .await?; - - Ok(()) -} - async fn load_db(state: &crate::agent::state::State) -> anyhow::Result { let email = state.email()?; rbw::db::Db::load_async(&state.server_name(), email) From 41336ce8a6539ddeb398441e65cfc53eca54ee5a Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 1 Jun 2026 18:03:51 +0200 Subject: [PATCH 229/273] cache db in State and save 90% of disk reads --- src/bin/rbw-agent/agent/actions.rs | 158 +++++++++++------------------ src/bin/rbw-agent/agent/state.rs | 19 +++- src/bin/rbw-agent/main.rs | 2 +- src/db.rs | 24 ++++- 4 files changed, 99 insertions(+), 104 deletions(-) diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index c594c272..d3a4f392 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -1,5 +1,5 @@ use anyhow::Context as _; -use rbw::actions::SessionParameters; +use rbw::{actions::SessionParameters, db::Db}; use sha2::Digest as _; use crate::agent::Agent; @@ -75,11 +75,7 @@ impl Agent { sock: &mut crate::sock::Sock, environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { - let db = load_db(&self.state) - .await - .unwrap_or_else(|_| rbw::db::Db::new()); - - if !db.needs_login() { + if !self.state.inner.db.read().await.needs_login() { return respond_ack(sock).await; } @@ -131,10 +127,11 @@ impl Agent { async fn two_factor( &self, environment: &rbw::protocol::Environment, - email: &str, password: rbw::locked::Password, provider: rbw::api::TwoFactorProviderType, ) -> anyhow::Result { + let email = self.state.email()?; + let mut err_msg = None; for i in 1_u8..=3 { let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); @@ -160,12 +157,10 @@ impl Agent { async fn two_factor_required( &self, - email: &str, password: rbw::locked::Password, providers: Vec, sso_email_2fa_session_token: Option, environment: &rbw::protocol::Environment, - db: &mut rbw::db::Db, ) -> anyhow::Result<()> { let supported_types = [ rbw::api::TwoFactorProviderType::Authenticator, @@ -179,6 +174,8 @@ impl Agent { )); }; + let email = self.state.email()?; + if provider == rbw::api::TwoFactorProviderType::Email { if let Some(token) = sso_email_2fa_session_token { rbw::actions::send_two_factor_email(email, &token).await?; @@ -186,10 +183,10 @@ impl Agent { } let creds = self - .two_factor(environment, email, password.clone(), provider) + .two_factor(environment, password.clone(), provider) .await?; - self.login_success(creds, password, db, email).await + self.login_success(creds, password).await } async fn get_password( @@ -208,11 +205,7 @@ impl Agent { sock: &mut crate::sock::Sock, environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { - let mut db = load_db(&self.state) - .await - .unwrap_or_else(|_| rbw::db::Db::new()); - - if !db.needs_login() { + if !self.state.inner.db.read().await.needs_login() { return respond_ack(sock).await; } @@ -232,7 +225,7 @@ impl Agent { match rbw::actions::login(&email, password.clone(), None, None).await { Ok(creds) => { - self.login_success(creds, password, &mut db, &email).await?; + self.login_success(creds, password).await?; break; } @@ -241,12 +234,10 @@ impl Agent { sso_email_2fa_session_token, }) => { self.two_factor_required( - &email, password, providers, sso_email_2fa_session_token, environment, - &mut db, ) .await?; @@ -295,7 +286,8 @@ impl Agent { } pub async fn sync(&self, sock: Option<&mut crate::sock::Sock>) -> anyhow::Result<()> { - let mut db = load_db(&self.state).await?; + // Sync is the only one that reads an updated copy of the db from disk + let db = Db::load_async(&self.state.server_name(), &self.state.email()?).await?; let Some(access_token) = &db.access_token else { anyhow::bail!("failed to find access token in db"); @@ -312,6 +304,10 @@ impl Agent { self.state.set_master_password_reprompt(&entries).await; + // And then update the local cached copy of the db + + let mut db = self.state.inner.db.write().await; + db.update_access_token(access_token); db.protected_key = Some(protected_key); @@ -319,7 +315,8 @@ impl Agent { db.protected_org_keys = protected_org_keys; db.entries = entries; - save_db(&self.state, &db).await?; + db.save_async(&self.state.server_name(), self.state.email()?) + .await?; if let Err(e) = self.subscribe_to_notifications().await { eprintln!("failed to subscribe to notifications: {e}"); @@ -336,30 +333,31 @@ impl Agent { &self, creds: SessionParameters, password: rbw::locked::Password, - db: &mut rbw::db::Db, - email: &str, ) -> anyhow::Result<()> { - db.apply_session_parameters(&creds); + { + let mut db = self.state.inner.db.write().await; + + db.apply_session_parameters(&creds); - save_db(&self.state, db).await?; + db.save_async(&self.state.server_name(), self.state.email()?) + .await?; + } self.sync(None).await?; - let db = load_db(&self.state).await?; + let db = self.state.inner.db.read().await; - let Some(protected_private_key) = db.protected_private_key else { - return Err(anyhow::anyhow!( - "failed to find protected private key in db" - )); - }; + let (_, protected_private_key, protected_org_keys) = db + .some_protected_keys() + .ok_or(anyhow::anyhow!("Cannot access protected keys in Db"))?; let res = rbw::actions::unlock( - email, + &self.state.email()?, &password, &creds.crypto_params, &creds.protected_key, &protected_private_key, - &db.protected_org_keys, + &protected_org_keys, ); match res { @@ -379,10 +377,7 @@ impl Agent { entry_key: Option<&str>, org_id: Option<&str>, ) -> anyhow::Result { - if !self.state.master_password_reprompt_initialized() { - let db = load_db(&self.state).await?; - self.state.set_master_password_reprompt(&db.entries).await; - } + self.state.initialize_mpr().await; let Some(keys) = self.state.key(org_id).await else { return Err(anyhow::anyhow!( @@ -484,21 +479,6 @@ impl Agent { async fn unlock_state(&self, environment: &rbw::protocol::Environment) -> anyhow::Result<()> { if self.state.needs_unlock().await { - let db = load_db(&self.state).await?; - let email = self.state.email()?.to_string(); - - let crypto_params = db.get_crypto_parameters()?; - - let Some(protected_key) = db.protected_key else { - return Err(anyhow::anyhow!("failed to find protected key in db")); - }; - - let Some(protected_private_key) = db.protected_private_key else { - return Err(anyhow::anyhow!( - "failed to find protected private key in db" - )); - }; - let mut err_msg = None; for i in 1_u8..=3 { let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); @@ -511,13 +491,19 @@ impl Agent { ) .await?; + let db = self.state.inner.db.read().await; + + let (protected_key, protected_private_key, protected_org_keys) = db + .some_protected_keys() + .ok_or(anyhow::anyhow!("Cannot get protected keys from Db"))?; + match rbw::actions::unlock( - &email, + &self.state.email()?, &password, - &crypto_params, + &db.get_crypto_parameters()?, &protected_key, &protected_private_key, - &db.protected_org_keys, + &protected_org_keys, ) { Ok((keys, org_keys)) => { self.state.set_keys(keys, org_keys).await; @@ -551,20 +537,6 @@ impl Agent { .await .contains(&master_password_reprompt) { - let db = load_db(&self.state).await?; - - let crypto_params = db.get_crypto_parameters()?; - - let Some(protected_key) = db.protected_key else { - return Err(anyhow::anyhow!("failed to find protected key in db")); - }; - - let Some(protected_private_key) = db.protected_private_key else { - return Err(anyhow::anyhow!( - "failed to find protected private key in db" - )); - }; - let mut err_msg = None; for i in 1_u8..=3 { let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); @@ -578,13 +550,19 @@ impl Agent { ) .await?; + let db = self.state.inner.db.read().await; + + let (protected_key, protected_private_key, protected_org_keys) = db + .some_protected_keys() + .ok_or(anyhow::anyhow!("Cannot get protected keys from Db"))?; + match rbw::actions::unlock( &self.state.email()?, &password, - &crypto_params, + &db.get_crypto_parameters()?, &protected_key, &protected_private_key, - &db.protected_org_keys, + &protected_org_keys, ) { Ok(_) => { break; @@ -609,11 +587,11 @@ impl Agent { self.unlock_state(&environment).await?; - let db = load_db(&self.state).await?; - let mut pubkeys = Vec::new(); - for entry in db.entries { + let db = self.state.inner.db.read().await; + + for entry in &db.entries { if let rbw::db::EntryData::SshKey { public_key: Some(encrypted), .. @@ -649,7 +627,7 @@ impl Agent { let request_bytes = request_public_key.to_bytes(); - let db = load_db(&self.state).await?; + let db = self.state.inner.db.read().await; // Collect all ssh keys that are Some() let keys: Vec<(&String, &String, &Option, &Option)> = db @@ -695,19 +673,19 @@ impl Agent { return Ok(()); } - let (email, server_name, notifications_url) = { - let email = self.state.email()?.to_string(); - let server_name = self.state.server_name(); - let notifications_url = self.state.notifications_url(); - (email, server_name, notifications_url) - }; + let notifications_url = self.state.notifications_url(); - let db = rbw::db::Db::load_async(&server_name, &email).await?; - let access_token = db.access_token.context("Error getting access token")?; + let db = self.state.inner.db.read().await; + + let Some(access_token) = &db.access_token else { + anyhow::bail!("Error getting access token"); + }; let websocket_url = format!("{}/hub?access_token={}", notifications_url, access_token) .replace("https://", "wss://"); + drop(db); + let mut nh = self.state.notifications_handler_mut().await; nh.connect(websocket_url) @@ -738,17 +716,3 @@ async fn respond_ack(sock: &mut crate::sock::Sock) -> anyhow::Result<()> { Ok(()) } - -async fn load_db(state: &crate::agent::state::State) -> anyhow::Result { - let email = state.email()?; - rbw::db::Db::load_async(&state.server_name(), email) - .await - .map_err(anyhow::Error::new) -} - -async fn save_db(state: &crate::agent::state::State, db: &rbw::db::Db) -> anyhow::Result<()> { - let email = state.email()?; - db.save_async(&state.server_name(), email) - .await - .map_err(anyhow::Error::new) -} diff --git a/src/bin/rbw-agent/agent/state.rs b/src/bin/rbw-agent/agent/state.rs index 34dcefc8..f47f098d 100644 --- a/src/bin/rbw-agent/agent/state.rs +++ b/src/bin/rbw-agent/agent/state.rs @@ -4,6 +4,7 @@ use std::{ time::Duration, }; +use rbw::db::Db; use sha2::Digest as _; use tokio::{ @@ -22,6 +23,7 @@ pub struct InnerState { pub master_password_reprompt: RwLock>, master_password_reprompt_initialized: AtomicBool, config: rbw::config::Config, + pub db: RwLock, // 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 @@ -45,7 +47,7 @@ pub struct State { } impl State { - pub fn new(config: rbw::config::Config) -> Self { + pub async fn new(config: rbw::config::Config) -> Self { let notifications_handler = crate::notifications::NotificationsHandler::new(); // TODO: ugly @@ -56,6 +58,13 @@ impl State { sync_deadline = Some(Instant::now() + sync_timeout_duration); } + let db = match &config.email { + Some(email) => Db::load_async(&config.server_name(), email) + .await + .unwrap_or_else(|_| Db::new()), + None => Db::new(), + }; + let state = Self { inner: Arc::new(InnerState { priv_key: RwLock::new(None), @@ -66,6 +75,7 @@ impl State { master_password_reprompt: RwLock::new(std::collections::HashSet::new()), master_password_reprompt_initialized: AtomicBool::new(false), config, + db: RwLock::new(db), last_environment: RwLock::new(rbw::protocol::Environment::default()), #[cfg(feature = "clipboard")] @@ -186,6 +196,13 @@ impl State { } } + pub async fn initialize_mpr(&self) { + if !self.master_password_reprompt_initialized() { + self.set_master_password_reprompt(&self.inner.db.read().await.entries) + .await; + } + } + pub async fn set_master_password_reprompt(&self, entries: &[rbw::db::Entry]) { self.inner.master_password_reprompt.write().await.clear(); diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index 6600d1cb..262757b2 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -16,7 +16,7 @@ async fn async_main(startup_ack: Option) -> anyhow::R let config = rbw::config::Config::load()?; - let state = crate::agent::state::State::new(config); + let state = crate::agent::state::State::new(config).await; let agent = crate::agent::Agent::new(state.clone()); let ssh_agent = crate::agent::ssh_agent::SshAgent::new(agent.clone()); diff --git a/src/db.rs b/src/db.rs index 61decbc9..959a23b9 100644 --- a/src/db.rs +++ b/src/db.rs @@ -3,10 +3,7 @@ use crate::{ prelude::*, }; -use std::{ - collections::HashMap, - fmt::Display, -}; +use std::{collections::HashMap, fmt::Display}; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; @@ -875,7 +872,7 @@ pub struct Db { pub protected_key: Option, pub protected_private_key: Option, - pub protected_org_keys: std::collections::HashMap, + pub protected_org_keys: HashMap, // TODO: This could be a HashMap? pub entries: Vec>, @@ -1000,4 +997,21 @@ impl Db { || self.crypto_params.is_none() || self.protected_key.is_none() } + + pub fn protected_keys(&self) -> (&Option, &Option, &HashMap) { + ( + &self.protected_key, + &self.protected_private_key, + &self.protected_org_keys, + ) + } + + pub fn some_protected_keys(&self) -> Option<(&String, &String, &HashMap)> { + let keys = self.protected_keys(); + + match keys { + (Some(key), Some(priv_key), org_keys) => Some((key, priv_key, org_keys)), + _ => None, + } + } } From 2c1afaf8e53fe0f8c09ac313a413682eed402877 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 1 Jun 2026 19:34:03 +0200 Subject: [PATCH 230/273] extract unlocking of the state into a single fn --- src/bin/rbw-agent/agent/actions.rs | 100 ++++++++++++----------------- 1 file changed, 40 insertions(+), 60 deletions(-) diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index d3a4f392..07e97315 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -345,27 +345,9 @@ impl Agent { self.sync(None).await?; - let db = self.state.inner.db.read().await; - - let (_, protected_private_key, protected_org_keys) = db - .some_protected_keys() - .ok_or(anyhow::anyhow!("Cannot access protected keys in Db"))?; - - let res = rbw::actions::unlock( - &self.state.email()?, - &password, - &creds.crypto_params, - &creds.protected_key, - &protected_private_key, - &protected_org_keys, - ); - - match res { - Ok((keys, org_keys)) => { - self.state.set_keys(keys, org_keys).await; - } - Err(e) => return Err(e).context("failed to unlock database"), - } + self.try_unlock(&password) + .await + .context("failed to unlock database")?; Ok(()) } @@ -477,6 +459,27 @@ impl Agent { Ok(()) } + async fn try_unlock(&self, password: &rbw::locked::Password) -> anyhow::Result<()> { + let db = self.state.inner.db.read().await; + + let (protected_key, protected_private_key, protected_org_keys) = + db.some_protected_keys() + .ok_or(anyhow::anyhow!("Cannot get protected keys from Db"))?; + + let (keys, org_keys) = rbw::actions::unlock( + &self.state.email()?, + password, + &db.get_crypto_parameters()?, + &protected_key, + &protected_private_key, + &protected_org_keys, + )?; + + self.state.set_keys(keys, org_keys).await; + + Ok(()) + } + async fn unlock_state(&self, environment: &rbw::protocol::Environment) -> anyhow::Result<()> { if self.state.needs_unlock().await { let mut err_msg = None; @@ -491,28 +494,16 @@ impl Agent { ) .await?; - let db = self.state.inner.db.read().await; - - let (protected_key, protected_private_key, protected_org_keys) = db - .some_protected_keys() - .ok_or(anyhow::anyhow!("Cannot get protected keys from Db"))?; - - match rbw::actions::unlock( - &self.state.email()?, - &password, - &db.get_crypto_parameters()?, - &protected_key, - &protected_private_key, - &protected_org_keys, - ) { - Ok((keys, org_keys)) => { - self.state.set_keys(keys, org_keys).await; + match self.try_unlock(&password).await { + Ok(()) => { break; } - Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { - err_msg = Some(message); - } - Err(e) => return Err(e).context("failed to unlock database"), + Err(e) => match e.downcast_ref::() { + Some(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { + err_msg = Some(message.clone()) + } + _ => return Err(e).context("failed to unlock database"), + }, } } } @@ -550,27 +541,16 @@ impl Agent { ) .await?; - let db = self.state.inner.db.read().await; - - let (protected_key, protected_private_key, protected_org_keys) = db - .some_protected_keys() - .ok_or(anyhow::anyhow!("Cannot get protected keys from Db"))?; - - match rbw::actions::unlock( - &self.state.email()?, - &password, - &db.get_crypto_parameters()?, - &protected_key, - &protected_private_key, - &protected_org_keys, - ) { - Ok(_) => { + match self.try_unlock(&password).await { + Ok(()) => { break; } - Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { - err_msg = Some(message); - } - Err(e) => return Err(e).context("failed to unlock database"), + Err(e) => match e.downcast_ref::() { + Some(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { + err_msg = Some(message.clone()) + } + _ => return Err(e).context("failed to unlock database"), + }, } } } From 1648efea65485413377d971b17c2bc8203350d00 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Mon, 1 Jun 2026 21:41:25 +0200 Subject: [PATCH 231/273] merge get_client_id and get_client_secret --- src/bin/rbw-agent/agent/actions.rs | 55 ++++++++++++++---------------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index 07e97315..24aba209 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -24,38 +24,35 @@ impl Agent { .await?) } - async fn get_client_id( + async fn get_client_id_secret( &self, host: &str, err: &Option, environment: &rbw::protocol::Environment, - ) -> anyhow::Result { - self.getpin( - "API key client__id", - &format!("Log in to {host}"), - err, - environment, - false, - ) - .await - .context("failed to read client_id from pinentry") - } + ) -> anyhow::Result<(rbw::locked::Password, rbw::locked::Password)> { + let id = self + .getpin( + "API key client__id", + &format!("Log in to {host}"), + err, + environment, + false, + ) + .await + .context("failed to read client_id from pinentry")?; + + let secret = self + .getpin( + "API key client__secret", + &format!("Log in to {host}"), + err, + environment, + false, + ) + .await + .context("failed to read client_secret from pinentry")?; - async fn get_client_secret( - &self, - host: &str, - err: &Option, - environment: &rbw::protocol::Environment, - ) -> anyhow::Result { - self.getpin( - "API key client__secret", - &format!("Log in to {host}"), - err, - environment, - false, - ) - .await - .context("failed to read client_secret from pinentry") + Ok((id, secret)) } fn get_host(&self) -> anyhow::Result { @@ -86,8 +83,8 @@ impl Agent { let mut err_msg = None; for i in 1_u8..=3 { let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); - let client_id = self.get_client_id(&host, &err, environment).await?; - let client_secret = self.get_client_secret(&host, &err, environment).await?; + let (client_id, client_secret) = + self.get_client_id_secret(&host, &err, environment).await?; let apikey = rbw::locked::ApiKey::new(client_id, client_secret); From fce6316d708c240262842ea8ea6ce8587e5a1c30 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 2 Jun 2026 14:37:54 +0200 Subject: [PATCH 232/273] add trace of common actions --- src/bin/rbw-agent/agent/mod.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/bin/rbw-agent/agent/mod.rs b/src/bin/rbw-agent/agent/mod.rs index d8f96082..fc5fbdec 100644 --- a/src/bin/rbw-agent/agent/mod.rs +++ b/src/bin/rbw-agent/agent/mod.rs @@ -126,6 +126,12 @@ impl Agent { let (action, environment) = req.into_parts(); + if !matches!(action, rbw::protocol::Action::Decrypt { .. }) + && !matches!(action, rbw::protocol::Action::Encrypt { .. }) + { + log::trace!("Start of action: {:?}", &action); + } + let set_timeout = match &action { rbw::protocol::Action::Register => { self.register(sock, &environment).await?; @@ -187,6 +193,12 @@ impl Agent { } }; + if !matches!(action, rbw::protocol::Action::Decrypt { .. }) + && !matches!(action, rbw::protocol::Action::Encrypt { .. }) + { + log::trace!("End of action: {:?}", &action); + } + self.state.set_last_environment(environment).await; if set_timeout { From d240dfcbc0698a6046f570d980d658e6dce286e1 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 2 Jun 2026 14:41:21 +0200 Subject: [PATCH 233/273] Remove useless print debug --- src/bin/rbw-agent/agent/mod.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/bin/rbw-agent/agent/mod.rs b/src/bin/rbw-agent/agent/mod.rs index fc5fbdec..294d7b6b 100644 --- a/src/bin/rbw-agent/agent/mod.rs +++ b/src/bin/rbw-agent/agent/mod.rs @@ -90,8 +90,6 @@ impl Agent { // TODO: The client does like a hundred connections to do basic things. Maybe it // makes sense to create more comprehensive opcodes. res = listener.accept() => { - log::debug!("Received a connection."); - let res = res.context("failed to accept incoming connection")?; self.on_connection(res.0).await; From 49ab7c6d4c46c7c725fc5a38dab097d9d08f6fc9 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 2 Jun 2026 14:47:27 +0200 Subject: [PATCH 234/273] add a bunch of trace! in sync --- src/bin/rbw-agent/agent/actions.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index 24aba209..7611957a 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -285,6 +285,7 @@ impl Agent { pub async fn sync(&self, sock: Option<&mut crate::sock::Sock>) -> anyhow::Result<()> { // Sync is the only one that reads an updated copy of the db from disk let db = Db::load_async(&self.state.server_name(), &self.state.email()?).await?; + log::trace!("Read fresh db from disk"); let Some(access_token) = &db.access_token else { anyhow::bail!("failed to find access token in db"); @@ -294,17 +295,25 @@ impl Agent { anyhow::bail!("failed to find refresh token in db"); }; + log::trace!("Obtained access and refresh tokens"); + let (access_token, (protected_key, protected_private_key, protected_org_keys, entries)) = rbw::actions::sync(access_token, refresh_token) .await .context("failed to sync database from server")?; + log::trace!("Sync operation finished"); + self.state.set_master_password_reprompt(&entries).await; + log::trace!("Set master password reprompt"); + // And then update the local cached copy of the db let mut db = self.state.inner.db.write().await; + log::trace!("Opened cached db for write operation"); + db.update_access_token(access_token); db.protected_key = Some(protected_key); @@ -315,6 +324,8 @@ impl Agent { db.save_async(&self.state.server_name(), self.state.email()?) .await?; + log::trace!("Updated disk db"); + if let Err(e) = self.subscribe_to_notifications().await { eprintln!("failed to subscribe to notifications: {e}"); } From 5a61d3b8c393731161de371915beca3b813f449f Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 2 Jun 2026 15:08:45 +0200 Subject: [PATCH 235/273] shorten types --- src/bin/rbw-agent/agent/state.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/bin/rbw-agent/agent/state.rs b/src/bin/rbw-agent/agent/state.rs index f47f098d..32254f13 100644 --- a/src/bin/rbw-agent/agent/state.rs +++ b/src/bin/rbw-agent/agent/state.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, sync::{atomic::AtomicBool, Arc}, time::Duration, }; @@ -16,11 +16,11 @@ use crate::notifications::NotificationsHandler; pub struct InnerState { priv_key: RwLock>>, - org_keys: RwLock>>>, + org_keys: RwLock>>>, notifications_handler: RwLock, pub lock_deadline: Mutex>, pub sync_deadline: Mutex>, - pub master_password_reprompt: RwLock>, + pub master_password_reprompt: RwLock>, master_password_reprompt_initialized: AtomicBool, config: rbw::config::Config, pub db: RwLock, From c30243fad602a949bd48d8e16749a783c743a65f Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 2 Jun 2026 15:09:05 +0200 Subject: [PATCH 236/273] add some trace in maybe reprompt --- src/bin/rbw-agent/agent/actions.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index 7611957a..67539711 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -536,6 +536,14 @@ impl Agent { .await .contains(&master_password_reprompt) { + log::trace!( + "Requesting password reprompt for item {:#?}", + master_password_reprompt + .iter() + .map(|b| format!("{:02x}", b)) + .collect::() + ); + let mut err_msg = None; for i in 1_u8..=3 { let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); @@ -551,13 +559,18 @@ impl Agent { match self.try_unlock(&password).await { Ok(()) => { + log::trace!("Password correct, reprompt successful"); break; } Err(e) => match e.downcast_ref::() { Some(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { + log::trace!("mpr incorrect password"); err_msg = Some(message.clone()) } - _ => return Err(e).context("failed to unlock database"), + _ => { + log::trace!("mpr other error"); + return Err(e).context("failed to unlock database"); + } }, } } From a8e068df04654a0201232a0f07e430073f2edf51 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 2 Jun 2026 15:46:57 +0200 Subject: [PATCH 237/273] add conversions EntryData -> CipherData and CipherData -> EntryData --- src/api/client.rs | 10 ++-- src/api/mod.rs | 117 +++++++++++++++++++++++++++++++++++----------- src/error.rs | 8 +++- 3 files changed, 103 insertions(+), 32 deletions(-) diff --git a/src/api/client.rs b/src/api/client.rs index 57c5dfef..9ddef434 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -486,11 +486,15 @@ impl Client { let sync_res: SyncRes = res.json_with_path().await?; - let ciphers = sync_res + let ciphers: Vec> = sync_res .ciphers .into_iter() - .filter_map(|cipher| cipher.into_entry(&sync_res.folders)) - .collect(); + .filter_map(|cipher| match cipher.into_entry(&sync_res.folders) { + Ok(e) => Some(Ok(e)), + Err(Error::DeletedEntry) => None, // If deleted entry, simply skip it + Err(e) => Some(Err(e)), + }) + .collect::>>()?; let org_keys = sync_res .profile diff --git a/src/api/mod.rs b/src/api/mod.rs index a7c22178..4b4a2f9b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -656,6 +656,81 @@ impl TryFrom for CipherSecureNote { } } +#[derive(Serialize, Deserialize, Debug, Clone)] +struct CipherData { + #[serde(rename = "Login", alias = "login")] + login: Option, + #[serde(rename = "Card", alias = "card")] + card: Option, + #[serde(rename = "Identity", alias = "identity")] + identity: Option, + #[serde(rename = "SecureNote", alias = "secureNote")] + secure_note: Option, + #[serde(rename = "SshKey", alias = "sshKey")] + ssh_key: Option, +} + +impl From for CipherData { + fn from(value: EntryData) -> Self { + match value { + EntryData::Login { .. } => Self { + login: Some(value.try_into().unwrap()), + card: None, + identity: None, + secure_note: None, + ssh_key: None, + }, + EntryData::Card { .. } => Self { + login: None, + card: Some(value.try_into().unwrap()), + identity: None, + secure_note: None, + ssh_key: None, + }, + EntryData::Identity { .. } => Self { + login: None, + card: None, + identity: Some(value.try_into().unwrap()), + secure_note: None, + ssh_key: None, + }, + EntryData::SecureNote => Self { + login: None, + card: None, + identity: None, + secure_note: Some(value.try_into().unwrap()), + ssh_key: None, + }, + EntryData::SshKey { .. } => Self { + login: None, + card: None, + identity: None, + secure_note: None, + ssh_key: Some(value.try_into().unwrap()), + }, + } + } +} + +impl TryFrom for EntryData { + type Error = Error; + fn try_from(value: CipherData) -> std::result::Result { + if let Some(login) = value.login { + Ok(login.into()) + } else if let Some(card) = value.card { + Ok(card.into()) + } else if let Some(identity) = value.identity { + Ok(identity.into()) + } else if let Some(secure_note) = value.secure_note { + Ok(secure_note.into()) + } else if let Some(ssh_key) = value.ssh_key { + Ok(ssh_key.into()) + } else { + Err(Error::EmptyCipherData) + } + } +} + #[derive( serde_repr::Serialize_repr, serde_repr::Deserialize_repr, Debug, Clone, Copy, PartialEq, Eq, )] @@ -775,16 +850,8 @@ struct SyncResCipher { organization_id: Option, #[serde(rename = "Name", alias = "name")] name: String, - #[serde(rename = "Login", alias = "login")] - login: Option, - #[serde(rename = "Card", alias = "card")] - card: Option, - #[serde(rename = "Identity", alias = "identity")] - identity: Option, - #[serde(rename = "SecureNote", alias = "secureNote")] - secure_note: Option, - #[serde(rename = "SshKey", alias = "sshKey")] - ssh_key: Option, + #[serde(flatten)] + data: CipherData, #[serde(rename = "Notes", alias = "notes")] notes: Option, #[serde(rename = "PasswordHistory", alias = "passwordHistory")] @@ -800,9 +867,9 @@ struct SyncResCipher { } impl SyncResCipher { - fn into_entry(self, folders: &[SyncResFolder]) -> Option> { + fn into_entry(self, folders: &[SyncResFolder]) -> Result> { if self.deleted_date.is_some() { - return None; + return Err(Error::DeletedEntry); } let history: Vec = self @@ -819,31 +886,17 @@ impl SyncResCipher { (folder_name, Some(folder_id)) }); - let data = if let Some(login) = self.login { - login.into() - } else if let Some(card) = self.card { - card.into() - } else if let Some(identity) = self.identity { - identity.into() - } else if let Some(secure_note) = self.secure_note { - secure_note.into() - } else if let Some(ssh_key) = self.ssh_key { - ssh_key.into() - } else { - return None; - }; - let fields: Vec = self.fields.map_or_else(Vec::new, |fields| { fields.into_iter().map(Into::into).collect() }); - Some(crate::db::Entry:: { + Ok(crate::db::Entry:: { id: self.id, org_id: self.organization_id, folder, folder_id: folder_id, name: self.name, - data, + data: self.data.try_into()?, fields, notes: self.notes, history, @@ -902,6 +955,8 @@ struct CiphersPostReq<'a> { #[derive(Serialize, Debug)] struct CiphersPutReq<'a> { + // #[serde(rename = "type")] + // ty: u32, // XXX what are the valid types? #[serde(rename = "folderId")] folder_id: Option<&'a str>, #[serde(rename = "organizationId")] @@ -910,6 +965,12 @@ struct CiphersPutReq<'a> { notes: Option<&'a str>, #[serde(flatten)] data: EntryDataWire<'a>, + // login: Option, + // card: Option, + // identity: Option, + // fields: Vec, + // #[serde(rename = "secureNote")] + // secure_note: Option, fields: &'a [CipherDynamicField], #[serde(rename = "passwordHistory")] password_history: &'a [CipherHistoryEntry], diff --git a/src/error.rs b/src/error.rs index 36efab35..e4dcbc6a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -241,7 +241,13 @@ pub enum Error { InvalidKdfType { ty: String }, #[error("Utf8 conversion error: {source}")] - Utf8Error { source: Utf8Error } + Utf8Error { source: Utf8Error }, + + #[error("the remote has sent an empty cipher data")] + EmptyCipherData, + + #[error("the entry has been deleted")] + DeletedEntry } impl From for Error { From df8bab5389a8cdb43d37f3d03d3af60a207ed748 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 2 Jun 2026 15:55:04 +0200 Subject: [PATCH 238/273] make two_factor_required to one thing: to get session params --- src/bin/rbw-agent/agent/actions.rs | 61 ++++++++++++------------------ 1 file changed, 25 insertions(+), 36 deletions(-) diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index 67539711..55f0ba5d 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -154,11 +154,11 @@ impl Agent { async fn two_factor_required( &self, - password: rbw::locked::Password, + password: &rbw::locked::Password, providers: Vec, sso_email_2fa_session_token: Option, environment: &rbw::protocol::Environment, - ) -> anyhow::Result<()> { + ) -> anyhow::Result { let supported_types = [ rbw::api::TwoFactorProviderType::Authenticator, rbw::api::TwoFactorProviderType::Yubikey, @@ -183,7 +183,7 @@ impl Agent { .two_factor(environment, password.clone(), provider) .await?; - self.login_success(creds, password).await + Ok(creds) } async fn get_password( @@ -220,31 +220,43 @@ impl Agent { .get_password(&format!("Log in to {host}"), &err, environment) .await?; - match rbw::actions::login(&email, password.clone(), None, None).await { - Ok(creds) => { - self.login_success(creds, password).await?; - - break; - } + let creds = match rbw::actions::login(&email, password.clone(), None, None).await { + Ok(creds) => creds, Err(rbw::error::Error::TwoFactorRequired { providers, sso_email_2fa_session_token, }) => { self.two_factor_required( - password, + &password, providers, sso_email_2fa_session_token, environment, ) - .await?; - - break; + .await? } Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { err_msg = Some(message); + continue; } Err(e) => return Err(e).context("failed to log in to bitwarden instance"), + }; + + { + let mut db = self.state.inner.db.write().await; + + db.apply_session_parameters(&creds); + + db.save_async(&self.state.server_name(), self.state.email()?) + .await?; } + + self.sync(None).await?; + + self.try_unlock(&password) + .await + .context("failed to unlock database")?; + + break; } respond_ack(sock).await?; @@ -337,29 +349,6 @@ impl Agent { Ok(()) } - async fn login_success( - &self, - creds: SessionParameters, - password: rbw::locked::Password, - ) -> anyhow::Result<()> { - { - let mut db = self.state.inner.db.write().await; - - db.apply_session_parameters(&creds); - - db.save_async(&self.state.server_name(), self.state.email()?) - .await?; - } - - self.sync(None).await?; - - self.try_unlock(&password) - .await - .context("failed to unlock database")?; - - Ok(()) - } - async fn decrypt_cipher( &self, environment: &rbw::protocol::Environment, From 2c2333bbf620f6cc64cf19b9e623dcb3695b4bc6 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 2 Jun 2026 16:13:34 +0200 Subject: [PATCH 239/273] remove EntryDataWire struct in favor of CipherData --- src/api/client.rs | 18 ++++++++++-- src/api/mod.rs | 70 ++++++++--------------------------------------- 2 files changed, 26 insertions(+), 62 deletions(-) diff --git a/src/api/client.rs b/src/api/client.rs index 9ddef434..e82c6bd7 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -12,7 +12,7 @@ use crate::{ actions::CryptoParameters, api::{ CiphersPostReq, CiphersPutReq, ConnectErrorRes, ConnectRefreshTokenRes, ConnectTokenAuth, - ConnectTokenReq, ConnectTokenRes, EntryDataWire, FoldersRes, FoldersResData, PreloginRes, + ConnectTokenReq, ConnectTokenRes, FoldersRes, FoldersResData, PreloginRes, SyncRes, TwoFactorProviderType, }, db::{Encrypted, Entry, EntryData}, @@ -511,6 +511,16 @@ impl Client { )) } + fn entry_data_type(data: &EntryData) -> u32 { + match data { + EntryData::Login { .. } => 1, + EntryData::Card { .. } => 3, + EntryData::Identity { .. } => 4, + EntryData::SecureNote => 2, + EntryData::SshKey { .. } => unreachable!(), // TODO: Fix me + } + } + pub async fn add( &self, access_token: &str, @@ -520,10 +530,11 @@ impl Client { folder_id: Option<&str>, ) -> Result<()> { let req = CiphersPostReq { + ty: Self::entry_data_type(data), folder_id: folder_id, name: name, notes: notes, - data: EntryDataWire(data), + data: data.clone().into(), }; ClientRequest::Add(access_token, req) @@ -536,11 +547,12 @@ impl Client { pub async fn edit(&self, access_token: &str, entry: &Entry) -> Result<()> { let req = CiphersPutReq { + ty: Self::entry_data_type(&entry.data), folder_id: entry.folder_id.as_deref(), organization_id: entry.org_id.as_deref(), name: &entry.name, notes: entry.notes.as_deref(), - data: EntryDataWire(&entry.data), + data: entry.data.clone().into(), fields: &entry .fields .iter() diff --git a/src/api/mod.rs b/src/api/mod.rs index 4b4a2f9b..436365ed 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -658,15 +658,15 @@ impl TryFrom for CipherSecureNote { #[derive(Serialize, Deserialize, Debug, Clone)] struct CipherData { - #[serde(rename = "Login", alias = "login")] + #[serde(alias = "login")] login: Option, - #[serde(rename = "Card", alias = "card")] + #[serde(alias = "card")] card: Option, - #[serde(rename = "Identity", alias = "identity")] + #[serde(alias = "identity")] identity: Option, - #[serde(rename = "SecureNote", alias = "secureNote")] + #[serde(rename = "secureNote", alias = "secureNote")] secure_note: Option, - #[serde(rename = "SshKey", alias = "sshKey")] + #[serde(alias = "sshKey")] ssh_key: Option, } @@ -945,18 +945,20 @@ struct SyncRes { #[derive(Serialize, Debug)] struct CiphersPostReq<'a> { + #[serde(rename = "type")] + ty: u32, // XXX what are the valid types? #[serde(rename = "folderId")] folder_id: Option<&'a str>, name: &'a str, notes: Option<&'a str>, #[serde(flatten)] - data: EntryDataWire<'a>, // use lifetime parameter on the struct instead + data: CipherData, } #[derive(Serialize, Debug)] struct CiphersPutReq<'a> { - // #[serde(rename = "type")] - // ty: u32, // XXX what are the valid types? + #[serde(rename = "type")] + ty: u32, // XXX what are the valid types? #[serde(rename = "folderId")] folder_id: Option<&'a str>, #[serde(rename = "organizationId")] @@ -964,62 +966,12 @@ struct CiphersPutReq<'a> { name: &'a str, notes: Option<&'a str>, #[serde(flatten)] - data: EntryDataWire<'a>, - // login: Option, - // card: Option, - // identity: Option, - // fields: Vec, - // #[serde(rename = "secureNote")] - // secure_note: Option, + data: CipherData, fields: &'a [CipherDynamicField], #[serde(rename = "passwordHistory")] password_history: &'a [CipherHistoryEntry], } -#[derive(Debug)] -struct EntryDataWire<'a>(&'a EntryData); - -impl Serialize for EntryDataWire<'_> { - fn serialize( - &self, - serializer: S, - ) -> std::result::Result { - use serde::ser::SerializeMap; - let mut map = serializer.serialize_map(None)?; - let data = self.0.clone(); - - match self.0 { - EntryData::Login { .. } => { - map.serialize_entry("type", &1u32)?; - map.serialize_entry("login", &TryInto::::try_into(data).unwrap())?; - } - EntryData::Card { .. } => { - map.serialize_entry("type", &3u32)?; - map.serialize_entry("card", &TryInto::::try_into(data).unwrap())?; - } - EntryData::Identity { .. } => { - map.serialize_entry("type", &4u32)?; - map.serialize_entry( - "identity", - &TryInto::::try_into(data).unwrap(), - )?; - } - EntryData::SecureNote => { - map.serialize_entry("type", &2u32)?; - map.serialize_entry( - "secureNote", - &TryInto::::try_into(data).unwrap(), - )?; - } - EntryData::SshKey { .. } => { - // TODO: Not entirely true now - return Err(serde::ser::Error::custom("SshKey not supported")); - } - } - map.end() - } -} - #[derive(Deserialize, Debug)] struct FoldersResData { #[serde(rename = "Id", alias = "id")] From 9245bbbe15f538039ae4f51de29607768c8d467e Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Tue, 2 Jun 2026 18:30:05 +0200 Subject: [PATCH 240/273] go back using owned structures for Post and Put request. adjust alias/rename stuff a bit --- src/api/client.rs | 47 ++++-------------- src/api/mod.rs | 123 +++++++++++++++++++++++++++++++++++----------- 2 files changed, 105 insertions(+), 65 deletions(-) diff --git a/src/api/client.rs b/src/api/client.rs index e82c6bd7..c8f0aadc 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -11,9 +11,9 @@ use tokio::sync::mpsc::{channel, Sender}; use crate::{ actions::CryptoParameters, api::{ - CiphersPostReq, CiphersPutReq, ConnectErrorRes, ConnectRefreshTokenRes, ConnectTokenAuth, - ConnectTokenReq, ConnectTokenRes, FoldersRes, FoldersResData, PreloginRes, - SyncRes, TwoFactorProviderType, + entry_data_type, CiphersPostReq, CiphersPutReq, ConnectErrorRes, ConnectRefreshTokenRes, + ConnectTokenAuth, ConnectTokenReq, ConnectTokenRes, FoldersRes, FoldersResData, + PreloginRes, SyncRes, TwoFactorProviderType, }, db::{Encrypted, Entry, EntryData}, error::{Error, Result}, @@ -34,8 +34,8 @@ enum ClientRequest<'a> { SendEmailLogin(&'a str, &'a str, &'a str), Sync(&'a str), ExchangeRefreshToken(&'a str), - Add(&'a str, CiphersPostReq<'a>), - Edit(&'a str, &'a str, CiphersPutReq<'a>), + Add(&'a str, CiphersPostReq), + Edit(&'a str, &'a str, CiphersPutReq), Remove(&'a str, &'a str), Folders(&'a str), CreateFolder(&'a str, &'a str), @@ -511,16 +511,6 @@ impl Client { )) } - fn entry_data_type(data: &EntryData) -> u32 { - match data { - EntryData::Login { .. } => 1, - EntryData::Card { .. } => 3, - EntryData::Identity { .. } => 4, - EntryData::SecureNote => 2, - EntryData::SshKey { .. } => unreachable!(), // TODO: Fix me - } - } - pub async fn add( &self, access_token: &str, @@ -530,10 +520,10 @@ impl Client { folder_id: Option<&str>, ) -> Result<()> { let req = CiphersPostReq { - ty: Self::entry_data_type(data), - folder_id: folder_id, - name: name, - notes: notes, + ty: entry_data_type(data), + folder_id: folder_id.map(|f| f.to_string()), + name: name.to_string(), + notes: notes.map(|n| n.to_string()), data: data.clone().into(), }; @@ -546,24 +536,7 @@ impl Client { } pub async fn edit(&self, access_token: &str, entry: &Entry) -> Result<()> { - let req = CiphersPutReq { - ty: Self::entry_data_type(&entry.data), - folder_id: entry.folder_id.as_deref(), - organization_id: entry.org_id.as_deref(), - name: &entry.name, - notes: entry.notes.as_deref(), - data: entry.data.clone().into(), - fields: &entry - .fields - .iter() - .map(|field| field.clone().into()) - .collect::>(), - password_history: &entry - .history - .iter() - .map(|entry| entry.clone().into()) - .collect::>(), - }; + let req: CiphersPutReq = entry.clone().into(); ClientRequest::Edit(access_token, &entry.id, req) .req(self) diff --git a/src/api/mod.rs b/src/api/mod.rs index 436365ed..cd80bf6f 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -5,7 +5,7 @@ use std::{fmt::Display, str::FromStr}; use crate::{ - db::{Encrypted, EntryData}, + db::{Encrypted, Entry, EntryData}, prelude::*, }; @@ -658,15 +658,15 @@ impl TryFrom for CipherSecureNote { #[derive(Serialize, Deserialize, Debug, Clone)] struct CipherData { - #[serde(alias = "login")] + #[serde(alias = "Login")] login: Option, - #[serde(alias = "card")] + #[serde(alias = "Card")] card: Option, - #[serde(alias = "identity")] + #[serde(alias = "Identity")] identity: Option, - #[serde(rename = "secureNote", alias = "secureNote")] + #[serde(rename = "secureNote")] secure_note: Option, - #[serde(alias = "sshKey")] + #[serde(alias = "SshKey", alias = "sshKey")] ssh_key: Option, } @@ -840,32 +840,58 @@ impl From for Option { } } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Deserialize, Debug, Clone)] struct SyncResCipher { - #[serde(rename = "Id", alias = "id")] + #[serde(alias = "Id")] id: String, - #[serde(rename = "FolderId", alias = "folderId")] + #[serde(alias = "FolderId", alias = "folderId")] folder_id: Option, - #[serde(rename = "OrganizationId", alias = "organizationId")] + #[serde(alias = "OrganizationId", alias = "organizationId")] organization_id: Option, - #[serde(rename = "Name", alias = "name")] + #[serde(alias = "Name")] name: String, #[serde(flatten)] data: CipherData, - #[serde(rename = "Notes", alias = "notes")] + #[serde(alias = "Notes")] notes: Option, - #[serde(rename = "PasswordHistory", alias = "passwordHistory")] + #[serde(alias = "PasswordHistory", alias = "passwordHistory")] password_history: Option>, - #[serde(rename = "Fields", alias = "fields")] + #[serde(alias = "Fields")] fields: Option>, - #[serde(rename = "DeletedDate", alias = "deletedDate")] + #[serde(alias = "DeletedDate", alias = "deletedDate")] deleted_date: Option, - #[serde(rename = "Key", alias = "key")] + #[serde(alias = "Key")] key: Option, - #[serde(rename = "Reprompt", alias = "reprompt")] + #[serde(alias = "Reprompt")] reprompt: CipherRepromptType, } +// impl From> for Cipher { +// fn from(value: Entry) -> Self { +// Self { +// id: value.id, +// folder_id: value.folder_id, +// organization_id: value.org_id, +// name: value.name, +// data: value.data.into(), +// notes: value.notes, +// password_history: if value.history.is_empty() { +// None +// } else { +// Some(value.history.into_iter().map(|he| he.into()).collect()) +// }, +// fields: if value.fields.is_empty() { +// None +// } else { +// Some(value.fields.into_iter().map(|f| f.into()).collect()) +// }, +// deleted_date: None, +// key: value.key, +// reprompt: value.master_password_reprompt, +// } +// } +// } + impl SyncResCipher { fn into_entry(self, folders: &[SyncResFolder]) -> Result> { if self.deleted_date.is_some() { @@ -943,33 +969,74 @@ struct SyncRes { folders: Vec, } +fn entry_data_type(data: &EntryData) -> u32 { + match data { + EntryData::Login { .. } => 1, + EntryData::Card { .. } => 3, + EntryData::Identity { .. } => 4, + EntryData::SecureNote => 2, + EntryData::SshKey { .. } => unreachable!(), // TODO: Fix me + } +} + +fn _cipher_data_type(data: &CipherData) -> u32 { + if data.login.is_some() { + 1 + } else if data.card.is_some() { + 3 + } else if data.identity.is_some() { + 4 + } else if data.secure_note.is_some() { + 2 + } else if data.ssh_key.is_some() { + unreachable!() + } else { + unreachable!() + } +} + #[derive(Serialize, Debug)] -struct CiphersPostReq<'a> { +struct CiphersPostReq { #[serde(rename = "type")] ty: u32, // XXX what are the valid types? #[serde(rename = "folderId")] - folder_id: Option<&'a str>, - name: &'a str, - notes: Option<&'a str>, + folder_id: Option, + name: String, + notes: Option, #[serde(flatten)] data: CipherData, } #[derive(Serialize, Debug)] -struct CiphersPutReq<'a> { +struct CiphersPutReq { #[serde(rename = "type")] ty: u32, // XXX what are the valid types? #[serde(rename = "folderId")] - folder_id: Option<&'a str>, + folder_id: Option, #[serde(rename = "organizationId")] - organization_id: Option<&'a str>, - name: &'a str, - notes: Option<&'a str>, + organization_id: Option, + name: String, + notes: Option, #[serde(flatten)] data: CipherData, - fields: &'a [CipherDynamicField], + fields: Vec, #[serde(rename = "passwordHistory")] - password_history: &'a [CipherHistoryEntry], + password_history: Vec, +} + +impl From> for CiphersPutReq { + fn from(value: Entry) -> Self { + Self { + ty: entry_data_type(&value.data), + folder_id: value.folder_id, + organization_id: value.org_id, + name: value.name, + notes: value.notes, + data: value.data.into(), + fields: value.fields.into_iter().map(|f| f.into()).collect(), + password_history: value.history.into_iter().map(|he| he.into()).collect(), + } + } } #[derive(Deserialize, Debug)] From 9b88458190f813b5d72b9af39caf45579c5c010a Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 3 Jun 2026 16:15:05 +0200 Subject: [PATCH 241/273] use let else instead of if let --- src/cipherstring.rs | 92 ++++++++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 48 deletions(-) diff --git a/src/cipherstring.rs b/src/cipherstring.rs index 4a02d2cb..0faa6376 100644 --- a/src/cipherstring.rs +++ b/src/cipherstring.rs @@ -111,78 +111,74 @@ impl CipherString { keys: &crate::locked::Keys, entry_key: Option<&crate::locked::Keys>, ) -> Result> { - if let Self::Symmetric { + let Self::Symmetric { iv, ciphertext, mac, } = self - { - let cipher = decrypt_common_symmetric( - entry_key.unwrap_or(keys), - iv, - ciphertext, - mac.as_deref(), - )?; - cipher - .decrypt_padded_vec_mut::(ciphertext) - .map_err(|source| Error::Decrypt { source }) - } else { - Err(Error::InvalidCipherString { + else { + return Err(Error::InvalidCipherString { reason: "found an asymmetric cipherstring, expecting symmetric".to_string(), - }) - } + }); + }; + + let cipher = + decrypt_common_symmetric(entry_key.unwrap_or(keys), iv, ciphertext, mac.as_deref())?; + cipher + .decrypt_padded_vec_mut::(ciphertext) + .map_err(|source| Error::Decrypt { source }) } pub fn decrypt_locked_symmetric( &self, keys: &crate::locked::Keys, ) -> Result { - if let Self::Symmetric { + let Self::Symmetric { iv, ciphertext, mac, } = self - { - let mut res = crate::locked::LockedVec::new(); - res.extend(ciphertext.iter().copied()); - let cipher = decrypt_common_symmetric(keys, iv, ciphertext, mac.as_deref())?; - cipher - .decrypt_padded_mut::(&mut res) - .map_err(|source| Error::Decrypt { source })?; - Ok(res) - } else { - Err(Error::InvalidCipherString { + else { + return Err(Error::InvalidCipherString { reason: "found an asymmetric cipherstring, expecting symmetric".to_string(), - }) - } + }); + }; + + let mut res = crate::locked::LockedVec::new(); + res.extend(ciphertext.iter().copied()); + let cipher = decrypt_common_symmetric(keys, iv, ciphertext, mac.as_deref())?; + cipher + .decrypt_padded_mut::(&mut res) + .map_err(|source| Error::Decrypt { source })?; + Ok(res) } pub fn decrypt_locked_asymmetric( &self, private_key: &crate::locked::PrivateKey, ) -> Result { - if let Self::Asymmetric { ciphertext } = self { - let privkey_data = private_key.private_key(); - let privkey_data = pkcs7_unpad(privkey_data).ok_or(Error::Padding)?; - let pkey = rsa::RsaPrivateKey::from_pkcs8_der(privkey_data) - .map_err(|source| Error::RsaPkcs8 { source })?; - let mut bytes = pkey - .decrypt(rsa::Oaep::new::(), ciphertext) - .map_err(|source| Error::Rsa { source })?; + let Self::Asymmetric { ciphertext } = self else { + return Err(Error::InvalidCipherString { + reason: "found a symmetric cipherstring, expecting asymmetric".to_string(), + }); + }; - // XXX it'd be great if the rsa crate would let us decrypt - // into a preallocated buffer directly to avoid the - // intermediate vec that needs to be manually zeroized, etc - let mut res = crate::locked::LockedVec::new(); - res.extend(bytes.iter().copied()); - bytes.zeroize(); + let privkey_data = private_key.private_key(); + let privkey_data = pkcs7_unpad(privkey_data).ok_or(Error::Padding)?; + let pkey = rsa::RsaPrivateKey::from_pkcs8_der(privkey_data) + .map_err(|source| Error::RsaPkcs8 { source })?; + let mut bytes = pkey + .decrypt(rsa::Oaep::new::(), ciphertext) + .map_err(|source| Error::Rsa { source })?; - Ok(res) - } else { - Err(Error::InvalidCipherString { - reason: "found a symmetric cipherstring, expecting asymmetric".to_string(), - }) - } + // XXX it'd be great if the rsa crate would let us decrypt + // into a preallocated buffer directly to avoid the + // intermediate vec that needs to be manually zeroized, etc + let mut res = crate::locked::LockedVec::new(); + res.extend(bytes.iter().copied()); + bytes.zeroize(); + + Ok(res) } } From e02fc9e023a15d080ef088b774c504e649dd1d91 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 5 Jun 2026 16:34:41 +0200 Subject: [PATCH 242/273] add three log::trace for deadlines and end of the loop --- src/bin/rbw-agent/agent/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/bin/rbw-agent/agent/mod.rs b/src/bin/rbw-agent/agent/mod.rs index 294d7b6b..fedfb947 100644 --- a/src/bin/rbw-agent/agent/mod.rs +++ b/src/bin/rbw-agent/agent/mod.rs @@ -95,11 +95,13 @@ impl Agent { self.on_connection(res.0).await; }, _ = Self::sleep_until_deadline(lock_deadline) => { + log::trace!("Lock deadline reached. Locking the db"); self.state.clear().await; }, _ = Self::sleep_until_deadline(sync_deadline) => { //let state = self.state.clone(); + log::trace!("Sync deadline reached. Syncing the db"); self.state.set_sync_timeout().await; // this could fail if we aren't logged in, but we @@ -110,6 +112,8 @@ impl Agent { } } + + log::trace!("End of run loop"); } } From c2ab72196e7fe3030676e9a74b50a3633f13cc47 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 5 Jun 2026 16:35:01 +0200 Subject: [PATCH 243/273] add print debug for identities request --- src/bin/rbw-agent/agent/ssh_agent.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/bin/rbw-agent/agent/ssh_agent.rs b/src/bin/rbw-agent/agent/ssh_agent.rs index 7718425e..1d217959 100644 --- a/src/bin/rbw-agent/agent/ssh_agent.rs +++ b/src/bin/rbw-agent/agent/ssh_agent.rs @@ -31,6 +31,8 @@ impl ssh_agent_lib::agent::Session for SshAgent { async fn request_identities( &mut self, ) -> Result, ssh_agent_lib::error::AgentError> { + log::debug!("Received SSH identities request"); + self.agent .get_ssh_public_keys() .await From 2b81cdf97b2446e7d5a7cbc6671fe68b48962a8a Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 5 Jun 2026 16:35:36 +0200 Subject: [PATCH 244/273] split get_ssh_public_keys to avoid mutex/rwlock contention --- src/bin/rbw-agent/agent/actions.rs | 57 +++++++++++++++++------------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index 55f0ba5d..4d38c8e2 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -1,5 +1,8 @@ use anyhow::Context as _; -use rbw::{actions::SessionParameters, db::Db}; +use rbw::{ + actions::SessionParameters, + db::{Db, EntryData}, +}; use sha2::Digest as _; use crate::agent::Agent; @@ -569,35 +572,41 @@ impl Agent { } pub async fn get_ssh_public_keys(&self) -> anyhow::Result> { - let environment = { - let le = self.state.last_environment().await; - self.state.set_timeout().await; - le.clone() - }; + let environment = { self.state.last_environment().await.clone() }; - self.unlock_state(&environment).await?; + log::trace!("Resetting lock timeout due to get_ssh_public_keys"); + self.state.set_timeout().await; - let mut pubkeys = Vec::new(); + log::trace!("Trying to unlock state"); + self.unlock_state(&environment).await?; let db = self.state.inner.db.read().await; - for entry in &db.entries { - if let rbw::db::EntryData::SshKey { - public_key: Some(encrypted), - .. - } = &entry.data - { - let plaintext = self - .decrypt_cipher( - &environment, - encrypted, - entry.key.as_deref(), - entry.org_id.as_deref(), - ) - .await?; + let enc_pubkeys: Vec<(String, Option, Option)> = db + .entries + .iter() + .filter_map(|e| { + if let EntryData::SshKey { + public_key: Some(pubkey), + .. + } = &e.data + { + Some((pubkey.clone(), e.key.clone(), e.org_id.clone())) + } else { + None + } + }) + .collect(); - pubkeys.push(plaintext); - } + drop(db); + + let mut pubkeys = vec![]; + + for (e, entry_key, org_id) in enc_pubkeys { + let pubkey = self + .decrypt_cipher(&environment, &e, entry_key.as_deref(), org_id.as_deref()) + .await?; + pubkeys.push(pubkey); } Ok(pubkeys) From 4843435f5defcd94baea1375f734cb39c67d330e Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Fri, 5 Jun 2026 17:16:48 +0200 Subject: [PATCH 245/273] improve readability of handle_request method and lock timeout operations. remove end of loop trace print --- src/bin/rbw-agent/agent/actions.rs | 4 ++-- src/bin/rbw-agent/agent/mod.rs | 27 +++++++++++---------------- src/bin/rbw-agent/agent/state.rs | 2 +- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index 4d38c8e2..d8b00a75 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -575,7 +575,7 @@ impl Agent { let environment = { self.state.last_environment().await.clone() }; log::trace!("Resetting lock timeout due to get_ssh_public_keys"); - self.state.set_timeout().await; + self.state.reset_lock_timeout().await; log::trace!("Trying to unlock state"); self.unlock_state(&environment).await?; @@ -618,7 +618,7 @@ impl Agent { ) -> anyhow::Result { let environment = { let le = self.state.last_environment().await; - self.state.set_timeout().await; + self.state.reset_lock_timeout().await; le.clone() }; diff --git a/src/bin/rbw-agent/agent/mod.rs b/src/bin/rbw-agent/agent/mod.rs index fedfb947..b706b6d7 100644 --- a/src/bin/rbw-agent/agent/mod.rs +++ b/src/bin/rbw-agent/agent/mod.rs @@ -112,8 +112,6 @@ impl Agent { } } - - log::trace!("End of run loop"); } } @@ -134,30 +132,24 @@ impl Agent { log::trace!("Start of action: {:?}", &action); } - let set_timeout = match &action { + match &action { rbw::protocol::Action::Register => { self.register(sock, &environment).await?; - true } rbw::protocol::Action::Login => { self.login(sock, &environment).await?; - true } rbw::protocol::Action::Unlock => { self.unlock(sock, &environment).await?; - true } rbw::protocol::Action::CheckLock => { self.check_lock(sock).await?; - false } rbw::protocol::Action::Lock => { self.lock(sock).await?; - false } rbw::protocol::Action::Sync => { self.sync(Some(sock)).await?; - false } // TODO: This alone does not do much, as it's a simple oracle open for everybody, to // decrypt stuff. @@ -174,15 +166,12 @@ impl Agent { org_id.as_deref(), ) .await?; - true } rbw::protocol::Action::Encrypt { plaintext, org_id } => { self.encrypt(sock, plaintext, org_id.as_deref()).await?; - true } rbw::protocol::Action::ClipboardStore { text } => { self.clipboard_store(sock, text).await?; - true } // TODO: It's better to handle the closing more gracefully rbw::protocol::Action::Quit => std::process::exit(0), @@ -191,9 +180,8 @@ impl Agent { version: rbw::protocol::VERSION, }) .await?; - false } - }; + } if !matches!(action, rbw::protocol::Action::Decrypt { .. }) && !matches!(action, rbw::protocol::Action::Encrypt { .. }) @@ -203,8 +191,15 @@ impl Agent { self.state.set_last_environment(environment).await; - if set_timeout { - self.state.set_timeout().await; + // Reset lock timeout on these request types + match &action { + rbw::protocol::Action::Register + | rbw::protocol::Action::Login + | rbw::protocol::Action::Unlock + | rbw::protocol::Action::Decrypt { .. } + | rbw::protocol::Action::Encrypt { .. } + | rbw::protocol::Action::ClipboardStore { .. } => self.state.reset_lock_timeout().await, + _ => {} } Ok(()) diff --git a/src/bin/rbw-agent/agent/state.rs b/src/bin/rbw-agent/agent/state.rs index 32254f13..d5ace69e 100644 --- a/src/bin/rbw-agent/agent/state.rs +++ b/src/bin/rbw-agent/agent/state.rs @@ -127,7 +127,7 @@ impl State { self.inner.priv_key.read().await.is_none() || self.inner.org_keys.read().await.is_none() } - pub async fn set_timeout(&self) { + pub async fn reset_lock_timeout(&self) { *self.inner.lock_deadline.lock().await = Some(Instant::now() + Duration::from_secs(self.inner.config.lock_timeout)); } From 5860d38b55a55d6ab8f6aaede665141dbe045ac2 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 6 Jun 2026 01:12:21 +0200 Subject: [PATCH 246/273] working on README --- README.md | 46 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a2367444..638e2d94 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,49 @@ similar to the way that `ssh-agent` or `gpg-agent` work. This allows the client to be used in a much simpler way, with the background agent taking care of maintaining the necessary state. +## Fork + +Since the original developer of this project has not been active in the last +months, I took the project and heavily refactored it. + +There were all the signs of a project that grew over time without chance to +receive some maintenance. + +Around 20%-30% of program's logic was duplicated. There was no clear separation +of concerns, etc. Now it's not perfect of course but I will work towards making +it easily auditable and maintainable. + +I don't blame the author as this is all free time and unpaid labor, and on top +of that, he actually provided the community with a great tool. + +NOTE: This fork has not 100% error messages backwards compatibility. Some of +them have changed. + +I couldn't do anything about it, as it was way easier to do things this way. +However it should not impact any actual tool built on rbw, but beware, the bug +is behind the corner! + +Oh and there also is my confirm ssh feature baked in the code, which is totally +optional. + ## Maintenance -I consider `rbw` to be essentially feature-complete for me at this point. While -I still use it on a daily basis, and will continue to fix regressions as they -occur, I am unlikely to spend time implementing new features on my own. If you -would like to see new functionality in `rbw`, I am more than happy to review -and merge pull requests implementing those features. +I DO NOT consider rbw to be essentially feature-complete, BUT in this first +phase I will accept PRs that fix bugs or enhance code readability. + +The first big elephant in the room to address is the fact that the client +should read no DB and therefore all search/get/etc. operations must be +performed by the daemon, while the protocol must provide such "opcodes" to do +it. + +## Before continuing + +The rest of this README is untouched from the original's, so if you install rbw +from repositories, you will not install this version but the older one. Same +for the listed tools. + +I am working to get this new version in the repositories, but it's not +immediate. ## Installation From 46ebe7b4bc7ce2dbbdfb3def77fba05e6cd20847 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 6 Jun 2026 15:13:32 +0200 Subject: [PATCH 247/273] remove async version of config loading --- src/actions.rs | 2 +- src/bin/rbw/commands.rs | 12 ++++++------ src/bin/rbw/main.rs | 2 +- src/config.rs | 24 ------------------------ 4 files changed, 8 insertions(+), 32 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index 11b7de6b..c9242e14 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -260,7 +260,7 @@ async fn exchange_refresh_token_async(refresh_token: &str) -> Result { } async fn api_client_async() -> Result<(crate::api::client::Client, crate::config::Config)> { - let config = crate::config::Config::load_async().await?; + let config = crate::config::Config::load()?; let client = crate::api::client::Client::new( &config.base_url(), &config.identity_url(), diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index d7d6fa68..d62b486a 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1077,10 +1077,10 @@ pub fn lock() -> anyhow::Result<()> { crate::actions::lock() } -pub async fn purge() -> anyhow::Result<()> { +pub fn purge() -> anyhow::Result<()> { stop_agent()?; - remove_db().await + remove_db() } pub fn stop_agent() -> anyhow::Result<()> { @@ -1151,7 +1151,7 @@ fn version_or_quit() -> anyhow::Result { } async fn load_db() -> anyhow::Result { - let config = rbw::config::Config::load_async().await?; + let config = rbw::config::Config::load()?; let Some(email) = &config.email else { anyhow::bail!("failed to find email address in config"); @@ -1163,7 +1163,7 @@ async fn load_db() -> anyhow::Result { } async fn save_db(db: &rbw::db::Db) -> anyhow::Result<()> { - let config = rbw::config::Config::load_async().await?; + let config = rbw::config::Config::load()?; let Some(email) = &config.email else { anyhow::bail!("failed to find email address in config"); @@ -1174,8 +1174,8 @@ async fn save_db(db: &rbw::db::Db) -> anyhow::Result<()> { .map_err(anyhow::Error::new) } -async fn remove_db() -> anyhow::Result<()> { - let config = rbw::config::Config::load_async().await?; +fn remove_db() -> anyhow::Result<()> { + let config = rbw::config::Config::load()?; let Some(email) = &config.email else { anyhow::bail!("failed to find email address in config"); diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index a800b292..213702af 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -470,7 +470,7 @@ async fn main() { Opt::Remove { find_args } => commands::remove(find_args).await, Opt::History { find_args } => commands::history(find_args).await, Opt::Lock => commands::lock(), - Opt::Purge => commands::purge().await, + Opt::Purge => commands::purge(), Opt::StopAgent => commands::stop_agent(), Opt::GenCompletions { shell } => { gen_completions(shell); diff --git a/src/config.rs b/src/config.rs index 70886c12..800b9102 100644 --- a/src/config.rs +++ b/src/config.rs @@ -88,30 +88,6 @@ impl Config { Ok(slf) } - pub async fn load_async() -> Result { - let file = crate::dirs::config_file()?; - let mut fh = tokio::fs::File::open(&file) - .await - .map_err(|source| Error::LoadConfig { - source, - file: file.clone(), - })?; - let mut json = String::new(); - fh.read_to_string(&mut json) - .await - .map_err(|source| Error::LoadConfig { - source, - file: file.clone(), - })?; - let mut slf: Self = - serde_json::from_str(&json).map_err(|source| Error::LoadConfigJson { source, file })?; - if slf.lock_timeout == 0 { - log::warn!("lock_timeout must be greater than 0"); - slf.lock_timeout = default_lock_timeout(); - } - Ok(slf) - } - pub fn save(&self) -> Result<()> { let file = crate::dirs::config_file()?; // unwrap is safe here because Self::filename is explicitly From 87c3ae81192033b45cc5c63c3c7fbde3936e68bd Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sat, 6 Jun 2026 15:35:30 +0200 Subject: [PATCH 248/273] move State into Agent and add a note for bug --- src/bin/rbw-agent/agent/actions.rs | 73 ++++--- src/bin/rbw-agent/agent/mod.rs | 305 +++++++++++++++++++++++++-- src/bin/rbw-agent/agent/ssh_agent.rs | 6 +- src/bin/rbw-agent/agent/state.rs | 297 -------------------------- src/bin/rbw-agent/main.rs | 3 +- 5 files changed, 331 insertions(+), 353 deletions(-) delete mode 100644 src/bin/rbw-agent/agent/state.rs diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index d8b00a75..dbf91f92 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -17,7 +17,7 @@ impl Agent { grab: bool, ) -> anyhow::Result { Ok(rbw::pinentry::getpin( - self.state.config_pinentry(), + self.config_pinentry(), prompt, desc, err.as_deref(), @@ -59,7 +59,7 @@ impl Agent { } fn get_host(&self) -> anyhow::Result { - let url_str = self.state.base_url(); + let url_str = self.base_url(); let url = reqwest::Url::parse(&url_str).context("failed to parse base url")?; let Some(host) = url.host_str() else { return Err(anyhow::anyhow!( @@ -75,13 +75,13 @@ impl Agent { sock: &mut crate::sock::Sock, environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { - if !self.state.inner.db.read().await.needs_login() { + if !self.inner.db.read().await.needs_login() { return respond_ack(sock).await; } let host = self.get_host()?; - let email = self.state.email()?.to_string(); + let email = self.email()?.to_string(); let mut err_msg = None; for i in 1_u8..=3 { @@ -130,7 +130,7 @@ impl Agent { password: rbw::locked::Password, provider: rbw::api::TwoFactorProviderType, ) -> anyhow::Result { - let email = self.state.email()?; + let email = self.email()?; let mut err_msg = None; for i in 1_u8..=3 { @@ -174,7 +174,7 @@ impl Agent { )); }; - let email = self.state.email()?; + let email = self.email()?; if provider == rbw::api::TwoFactorProviderType::Email { if let Some(token) = sso_email_2fa_session_token { @@ -205,13 +205,13 @@ impl Agent { sock: &mut crate::sock::Sock, environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { - if !self.state.inner.db.read().await.needs_login() { + if !self.inner.db.read().await.needs_login() { return respond_ack(sock).await; } let host = self.get_host()?; - let email = self.state.email()?.to_string(); + let email = self.email()?.to_string(); let mut err_msg = None; for i in 1_u8..=3 { @@ -245,12 +245,11 @@ impl Agent { }; { - let mut db = self.state.inner.db.write().await; + let mut db = self.inner.db.write().await; db.apply_session_parameters(&creds); - db.save_async(&self.state.server_name(), self.state.email()?) - .await?; + db.save_async(&self.server_name(), self.email()?).await?; } self.sync(None).await?; @@ -280,7 +279,7 @@ impl Agent { } pub async fn lock(&self, sock: &mut crate::sock::Sock) -> anyhow::Result<()> { - self.state.clear().await; + self.clear().await; respond_ack(sock).await?; @@ -288,7 +287,7 @@ impl Agent { } pub async fn check_lock(&self, sock: &mut crate::sock::Sock) -> anyhow::Result<()> { - if self.state.needs_unlock().await { + if self.needs_unlock().await { return Err(anyhow::anyhow!("agent is locked")); } @@ -299,7 +298,7 @@ impl Agent { pub async fn sync(&self, sock: Option<&mut crate::sock::Sock>) -> anyhow::Result<()> { // Sync is the only one that reads an updated copy of the db from disk - let db = Db::load_async(&self.state.server_name(), &self.state.email()?).await?; + let db = Db::load_async(&self.server_name(), &self.email()?).await?; log::trace!("Read fresh db from disk"); let Some(access_token) = &db.access_token else { @@ -319,13 +318,13 @@ impl Agent { log::trace!("Sync operation finished"); - self.state.set_master_password_reprompt(&entries).await; + self.set_master_password_reprompt(&entries).await; log::trace!("Set master password reprompt"); // And then update the local cached copy of the db - let mut db = self.state.inner.db.write().await; + let mut db = self.inner.db.write().await; log::trace!("Opened cached db for write operation"); @@ -336,8 +335,7 @@ impl Agent { db.protected_org_keys = protected_org_keys; db.entries = entries; - db.save_async(&self.state.server_name(), self.state.email()?) - .await?; + db.save_async(&self.server_name(), self.email()?).await?; log::trace!("Updated disk db"); @@ -359,9 +357,9 @@ impl Agent { entry_key: Option<&str>, org_id: Option<&str>, ) -> anyhow::Result { - self.state.initialize_mpr().await; + self.initialize_mpr().await; - let Some(keys) = self.state.key(org_id).await else { + let Some(keys) = self.key(org_id).await else { return Err(anyhow::anyhow!( "failed to find decryption keys in in-memory state" )); @@ -409,7 +407,7 @@ impl Agent { plaintext: &str, org_id: Option<&str>, ) -> anyhow::Result<()> { - let Some(keys) = self.state.key(org_id).await else { + let Some(keys) = self.key(org_id).await else { return Err(anyhow::anyhow!( "failed to find encryption keys in in-memory state" )); @@ -433,7 +431,7 @@ impl Agent { sock: &mut crate::sock::Sock, text: &str, ) -> anyhow::Result<()> { - if let Some(clipboard) = &mut (*self.state.clipboard_mut().await) { + if let Some(clipboard) = &mut (*self.clipboard_mut().await) { clipboard .set_text(text) .map_err(|e| anyhow::anyhow!("couldn't store value to clipboard: {e}"))?; @@ -460,14 +458,14 @@ impl Agent { } async fn try_unlock(&self, password: &rbw::locked::Password) -> anyhow::Result<()> { - let db = self.state.inner.db.read().await; + let db = self.inner.db.read().await; let (protected_key, protected_private_key, protected_org_keys) = db.some_protected_keys() .ok_or(anyhow::anyhow!("Cannot get protected keys from Db"))?; let (keys, org_keys) = rbw::actions::unlock( - &self.state.email()?, + &self.email()?, password, &db.get_crypto_parameters()?, &protected_key, @@ -475,13 +473,13 @@ impl Agent { &protected_org_keys, )?; - self.state.set_keys(keys, org_keys).await; + self.set_keys(keys, org_keys).await; Ok(()) } async fn unlock_state(&self, environment: &rbw::protocol::Environment) -> anyhow::Result<()> { - if self.state.needs_unlock().await { + if self.needs_unlock().await { let mut err_msg = None; for i in 1_u8..=3 { let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); @@ -521,7 +519,6 @@ impl Agent { let master_password_reprompt: [u8; 32] = sha256.finalize().into(); if self - .state .inner .master_password_reprompt .read() @@ -572,15 +569,17 @@ impl Agent { } pub async fn get_ssh_public_keys(&self) -> anyhow::Result> { - let environment = { self.state.last_environment().await.clone() }; + let environment = { self.last_environment().await.clone() }; + // BUG: this reset_lock_timeout call doesn't make agent::run() restart the loop, so re-lock + // never happens. log::trace!("Resetting lock timeout due to get_ssh_public_keys"); - self.state.reset_lock_timeout().await; + self.reset_lock_timeout().await; log::trace!("Trying to unlock state"); self.unlock_state(&environment).await?; - let db = self.state.inner.db.read().await; + let db = self.inner.db.read().await; let enc_pubkeys: Vec<(String, Option, Option)> = db .entries @@ -617,8 +616,8 @@ impl Agent { request_public_key: ssh_agent_lib::ssh_key::PublicKey, ) -> anyhow::Result { let environment = { - let le = self.state.last_environment().await; - self.state.reset_lock_timeout().await; + let le = self.last_environment().await; + self.reset_lock_timeout().await; le.clone() }; @@ -626,7 +625,7 @@ impl Agent { let request_bytes = request_public_key.to_bytes(); - let db = self.state.inner.db.read().await; + let db = self.inner.db.read().await; // Collect all ssh keys that are Some() let keys: Vec<(&String, &String, &Option, &Option)> = db @@ -668,13 +667,13 @@ impl Agent { } pub async fn subscribe_to_notifications(&self) -> anyhow::Result<()> { - if self.state.notifications_handler().await.is_connected() { + if self.notifications_handler().await.is_connected() { return Ok(()); } - let notifications_url = self.state.notifications_url(); + let notifications_url = self.notifications_url(); - let db = self.state.inner.db.read().await; + let db = self.inner.db.read().await; let Some(access_token) = &db.access_token else { anyhow::bail!("Error getting access token"); @@ -685,7 +684,7 @@ impl Agent { drop(db); - let mut nh = self.state.notifications_handler_mut().await; + let mut nh = self.notifications_handler_mut().await; nh.connect(websocket_url) .await diff --git a/src/bin/rbw-agent/agent/mod.rs b/src/bin/rbw-agent/agent/mod.rs index b706b6d7..a1b03fa6 100644 --- a/src/bin/rbw-agent/agent/mod.rs +++ b/src/bin/rbw-agent/agent/mod.rs @@ -1,26 +1,303 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::{atomic::AtomicBool, Arc}, + time::Duration, +}; + use anyhow::Context as _; +use rbw::db::Db; +use sha2::Digest as _; use tokio::{ net::{UnixListener, UnixStream}, + sync::{Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard}, time::{sleep_until, Instant}, }; -use crate::agent::state::State; +use crate::notifications::NotificationsHandler; mod actions; pub mod ssh_agent; -pub(crate) mod state; +struct InnerAgent { + priv_key: RwLock>>, + org_keys: RwLock>>>, + notifications_handler: RwLock, + pub lock_deadline: Mutex>, + pub sync_deadline: Mutex>, + pub master_password_reprompt: RwLock>, + master_password_reprompt_initialized: AtomicBool, + config: rbw::config::Config, + pub db: RwLock, + + // 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 + // can't properly initialize the pinentry process. we work around this by + // just reusing the last environment we saw being sent to the main agent + // (there should be at least one in most cases because you need to start + // the rbw agent in order to make it start serving on the ssh agent + // socket, and that initial request should come with an environment). + // + // we should not use this for any requests on the main agent, those + // should all send their own environment over. + pub last_environment: RwLock, + + #[cfg(feature = "clipboard")] + pub clipboard: Mutex>, +} #[derive(Clone)] pub struct Agent { - state: State, + inner: Arc, } impl Agent { - pub fn new(state: State) -> Self { - Self { state } + pub async fn new(config: rbw::config::Config) -> Self { + let notifications_handler = crate::notifications::NotificationsHandler::new(); + + // TODO: ugly + let mut sync_deadline: Option = None; + let sync_timeout_duration = std::time::Duration::from_secs(config.sync_interval); + + if sync_timeout_duration > std::time::Duration::ZERO { + sync_deadline = Some(Instant::now() + sync_timeout_duration); + } + + let db = match &config.email { + Some(email) => Db::load_async(&config.server_name(), email) + .await + .unwrap_or_else(|_| Db::new()), + None => Db::new(), + }; + + let state = Self { + inner: Arc::new(InnerAgent { + priv_key: RwLock::new(None), + org_keys: RwLock::new(None), + notifications_handler: RwLock::new(notifications_handler), + lock_deadline: Mutex::new(None), + sync_deadline: Mutex::new(sync_deadline), + master_password_reprompt: RwLock::new(std::collections::HashSet::new()), + master_password_reprompt_initialized: AtomicBool::new(false), + config, + db: RwLock::new(db), + last_environment: RwLock::new(rbw::protocol::Environment::default()), + + #[cfg(feature = "clipboard")] + clipboard: Mutex::new( + arboard::Clipboard::new() + .inspect_err(|e| { + log::warn!("couldn't create clipboard context: {e}"); + }) + .ok(), + ), + }), + }; + + state + } + + pub async fn key(&self, org_id: Option<&str>) -> Option> { + match org_id { + Some(id) => self + .inner + .org_keys + .read() + .await + .as_ref() + .and_then(|h| h.get(id).cloned()), + None => self.inner.priv_key.read().await.clone(), + } + } + + pub async fn set_keys( + &self, + priv_key: rbw::locked::Keys, + org_keys: HashMap, + ) { + let mut priv_key_guard = self.inner.priv_key.write().await; + let mut org_keys_guard = self.inner.org_keys.write().await; + + *priv_key_guard = Some(Arc::new(priv_key)); + + let org_keys: HashMap> = org_keys + .into_iter() + .map(|(k, v)| (k, Arc::new(v))) + .collect(); + + *org_keys_guard = Some(org_keys); + } + + pub async fn needs_unlock(&self) -> bool { + self.inner.priv_key.read().await.is_none() || self.inner.org_keys.read().await.is_none() + } + + pub async fn reset_lock_timeout(&self) { + *self.inner.lock_deadline.lock().await = + Some(Instant::now() + Duration::from_secs(self.inner.config.lock_timeout)); + } + + pub async fn notifications_handler(&self) -> RwLockReadGuard<'_, NotificationsHandler> { + self.inner.notifications_handler.read().await + } + + pub async fn notifications_handler_mut(&self) -> RwLockWriteGuard<'_, NotificationsHandler> { + self.inner.notifications_handler.write().await + } + + pub async fn clear(&self) { + { + let mut priv_key_guard = self.inner.priv_key.write().await; + let mut org_keys_guard = self.inner.org_keys.write().await; + + *priv_key_guard = None; + *org_keys_guard = None; + } + + *self.inner.lock_deadline.lock().await = None; + } + + pub async fn set_sync_timeout(&self) { + *self.inner.sync_deadline.lock().await = + Some(Instant::now() + Duration::from_secs(self.inner.config.sync_interval)); + // self.inner + // .sync_timeout + // .set(self.inner.sync_timeout_duration); + } + + // the way we structure the client/agent split in rbw makes the master + // password reprompt feature a bit complicated to implement - it would be + // a lot easier to just have the client do the prompting, but that would + // leave it open to someone reading the cipherstring from the local + // database and passing it to the agent directly, bypassing the client. + // the agent is the thing that holds the unlocked secrets, so it also + // needs to be the thing guarding access to master password reprompt + // entries. we only pass individual cipherstrings to the agent though, so + // the agent needs to be able to recognize the cipherstrings that need + // reprompting, without the additional context of the entry they came + // from. in addition, because the reprompt state is stored in the sync db + // in plaintext, we can't just read it from the db directly, because + // someone could just edit the file on disk before making the request. + // + // therefore, the solution we choose here is to keep an in-memory set of + // cipherstrings that we know correspond to entries with master password + // reprompt enabled. this set is only updated when the agent itself does + // a sync, so it can't be bypassed by editing the on-disk file directly. + // if the agent gets a request for any of those cipherstrings that it saw + // marked as master password reprompt during the most recent sync, it + // forces a reprompt. + + async fn add_mpr(&self, s: Option<&str>) { + if let Some(s) = s { + if !s.is_empty() { + let mut hasher = sha2::Sha256::new(); + hasher.update(s); + self.inner + .master_password_reprompt + .write() + .await + .insert(hasher.finalize().into()); + } + } } + pub async fn initialize_mpr(&self) { + if !self.master_password_reprompt_initialized() { + self.set_master_password_reprompt(&self.inner.db.read().await.entries) + .await; + } + } + + pub async fn set_master_password_reprompt(&self, entries: &[rbw::db::Entry]) { + self.inner.master_password_reprompt.write().await.clear(); + + for entry in entries { + if !entry.master_password_reprompt() { + continue; + } + + match &entry.data { + rbw::db::EntryData::Login { password, totp, .. } => { + self.add_mpr(password.as_deref()).await; + self.add_mpr(totp.as_deref()).await; + } + rbw::db::EntryData::Card { number, code, .. } => { + self.add_mpr(number.as_deref()).await; + self.add_mpr(code.as_deref()).await; + } + rbw::db::EntryData::Identity { + ssn, + passport_number, + .. + } => { + self.add_mpr(ssn.as_deref()).await; + self.add_mpr(passport_number.as_deref()).await; + } + rbw::db::EntryData::SecureNote => {} + rbw::db::EntryData::SshKey { private_key, .. } => { + self.add_mpr(private_key.as_deref()).await; + } + } + + for field in &entry.fields { + if field.ty == Some(rbw::api::FieldType::Hidden) { + self.add_mpr(field.value.as_deref()).await; + } + } + } + + self.inner + .master_password_reprompt_initialized + .store(true, std::sync::atomic::Ordering::Relaxed); + } + + pub fn master_password_reprompt_initialized(&self) -> bool { + self.inner + .master_password_reprompt_initialized + .load(std::sync::atomic::Ordering::Relaxed) + } + + pub async fn last_environment( + &self, + ) -> tokio::sync::RwLockReadGuard<'_, rbw::protocol::Environment> { + self.inner.last_environment.read().await + } + + pub async fn set_last_environment(&self, environment: rbw::protocol::Environment) { + *self.inner.last_environment.write().await = environment; + } + + pub fn email(&self) -> anyhow::Result<&str> { + self.inner + .config + .email + .as_deref() + .ok_or_else(|| anyhow::anyhow!("failed to find email address in config")) + } + + pub fn base_url(&self) -> String { + self.inner.config.base_url() + } + + pub fn config_pinentry(&self) -> &str { + &self.inner.config.pinentry + } + + pub fn notifications_url(&self) -> String { + self.inner.config.notifications_url() + } + + pub fn server_name(&self) -> String { + self.inner.config.server_name() + } + + #[cfg(feature = "clipboard")] + pub async fn clipboard_mut(&self) -> tokio::sync::MutexGuard<'_, Option> { + self.inner.clipboard.lock().await + } + + pub fn confirm_ssh(&self) -> bool { + self.inner.config.confirm_ssh.is_some_and(|o| o) + } async fn sleep_until_deadline(deadline: Option) { match deadline { Some(d) => sleep_until(d).await, @@ -32,11 +309,11 @@ impl Agent { match message { crate::notifications::Message::Logout => { log::debug!("Received Logout Message via notification channel"); - self.state.clear().await; + self.clear().await; } crate::notifications::Message::Sync => { log::debug!("Received Sync Message via notification channel"); - self.state.set_sync_timeout().await; + self.set_sync_timeout().await; if let Err(e) = self.sync(None).await { eprintln!("failed to sync: {e:#}"); @@ -67,7 +344,7 @@ impl Agent { } pub async fn run(self, listener: UnixListener) -> anyhow::Result<()> { - let mut nchannel = self.state.notifications_handler().await.get_channel(); + let mut nchannel = self.notifications_handler().await.get_channel(); match self.subscribe_to_notifications().await { Ok(_) => { @@ -79,8 +356,8 @@ impl Agent { }; loop { - let lock_deadline = *self.state.inner.lock_deadline.lock().await; - let sync_deadline = *self.state.inner.sync_deadline.lock().await; + let lock_deadline = *self.inner.lock_deadline.lock().await; + let sync_deadline = *self.inner.sync_deadline.lock().await; tokio::select! { message = nchannel.recv() => { @@ -96,13 +373,13 @@ impl Agent { }, _ = Self::sleep_until_deadline(lock_deadline) => { log::trace!("Lock deadline reached. Locking the db"); - self.state.clear().await; + self.clear().await; }, _ = Self::sleep_until_deadline(sync_deadline) => { //let state = self.state.clone(); log::trace!("Sync deadline reached. Syncing the db"); - self.state.set_sync_timeout().await; + self.set_sync_timeout().await; // this could fail if we aren't logged in, but we // don't care about that @@ -189,7 +466,7 @@ impl Agent { log::trace!("End of action: {:?}", &action); } - self.state.set_last_environment(environment).await; + self.set_last_environment(environment).await; // Reset lock timeout on these request types match &action { @@ -198,7 +475,7 @@ impl Agent { | rbw::protocol::Action::Unlock | rbw::protocol::Action::Decrypt { .. } | rbw::protocol::Action::Encrypt { .. } - | rbw::protocol::Action::ClipboardStore { .. } => self.state.reset_lock_timeout().await, + | rbw::protocol::Action::ClipboardStore { .. } => self.reset_lock_timeout().await, _ => {} } diff --git a/src/bin/rbw-agent/agent/ssh_agent.rs b/src/bin/rbw-agent/agent/ssh_agent.rs index 1d217959..6fadc9e3 100644 --- a/src/bin/rbw-agent/agent/ssh_agent.rs +++ b/src/bin/rbw-agent/agent/ssh_agent.rs @@ -68,11 +68,11 @@ impl ssh_agent_lib::agent::Session for SshAgent { .await .map_err(|e| ssh_agent_lib::error::AgentError::Other(e.into()))?; - if self.agent.state.confirm_ssh() { + if self.agent.confirm_ssh() { let confirmed = rbw::pinentry::confirm( - &self.agent.state.config_pinentry(), + &self.agent.config_pinentry(), "Allow SSH key use?", - &self.agent.state.last_environment().await.clone(), + &self.agent.last_environment().await.clone(), true, ) .await diff --git a/src/bin/rbw-agent/agent/state.rs b/src/bin/rbw-agent/agent/state.rs deleted file mode 100644 index d5ace69e..00000000 --- a/src/bin/rbw-agent/agent/state.rs +++ /dev/null @@ -1,297 +0,0 @@ -use std::{ - collections::{HashMap, HashSet}, - sync::{atomic::AtomicBool, Arc}, - time::Duration, -}; - -use rbw::db::Db; -use sha2::Digest as _; - -use tokio::{ - sync::{Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard}, - time::Instant, -}; - -use crate::notifications::NotificationsHandler; - -pub struct InnerState { - priv_key: RwLock>>, - org_keys: RwLock>>>, - notifications_handler: RwLock, - pub lock_deadline: Mutex>, - pub sync_deadline: Mutex>, - pub master_password_reprompt: RwLock>, - master_password_reprompt_initialized: AtomicBool, - config: rbw::config::Config, - pub db: RwLock, - - // 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 - // can't properly initialize the pinentry process. we work around this by - // just reusing the last environment we saw being sent to the main agent - // (there should be at least one in most cases because you need to start - // the rbw agent in order to make it start serving on the ssh agent - // socket, and that initial request should come with an environment). - // - // we should not use this for any requests on the main agent, those - // should all send their own environment over. - pub last_environment: RwLock, - - #[cfg(feature = "clipboard")] - pub clipboard: Mutex>, -} - -#[derive(Clone)] -pub struct State { - pub inner: Arc, -} - -impl State { - pub async fn new(config: rbw::config::Config) -> Self { - let notifications_handler = crate::notifications::NotificationsHandler::new(); - - // TODO: ugly - let mut sync_deadline: Option = None; - let sync_timeout_duration = std::time::Duration::from_secs(config.sync_interval); - - if sync_timeout_duration > std::time::Duration::ZERO { - sync_deadline = Some(Instant::now() + sync_timeout_duration); - } - - let db = match &config.email { - Some(email) => Db::load_async(&config.server_name(), email) - .await - .unwrap_or_else(|_| Db::new()), - None => Db::new(), - }; - - let state = Self { - inner: Arc::new(InnerState { - priv_key: RwLock::new(None), - org_keys: RwLock::new(None), - notifications_handler: RwLock::new(notifications_handler), - lock_deadline: Mutex::new(None), - sync_deadline: Mutex::new(sync_deadline), - master_password_reprompt: RwLock::new(std::collections::HashSet::new()), - master_password_reprompt_initialized: AtomicBool::new(false), - config, - db: RwLock::new(db), - last_environment: RwLock::new(rbw::protocol::Environment::default()), - - #[cfg(feature = "clipboard")] - clipboard: Mutex::new( - arboard::Clipboard::new() - .inspect_err(|e| { - log::warn!("couldn't create clipboard context: {e}"); - }) - .ok(), - ), - }), - }; - - state - } - - pub async fn key(&self, org_id: Option<&str>) -> Option> { - match org_id { - Some(id) => self - .inner - .org_keys - .read() - .await - .as_ref() - .and_then(|h| h.get(id).cloned()), - None => self.inner.priv_key.read().await.clone(), - } - } - - pub async fn set_keys( - &self, - priv_key: rbw::locked::Keys, - org_keys: HashMap, - ) { - let mut priv_key_guard = self.inner.priv_key.write().await; - let mut org_keys_guard = self.inner.org_keys.write().await; - - *priv_key_guard = Some(Arc::new(priv_key)); - - let org_keys: HashMap> = org_keys - .into_iter() - .map(|(k, v)| (k, Arc::new(v))) - .collect(); - - *org_keys_guard = Some(org_keys); - } - - pub async fn needs_unlock(&self) -> bool { - self.inner.priv_key.read().await.is_none() || self.inner.org_keys.read().await.is_none() - } - - pub async fn reset_lock_timeout(&self) { - *self.inner.lock_deadline.lock().await = - Some(Instant::now() + Duration::from_secs(self.inner.config.lock_timeout)); - } - - pub async fn notifications_handler(&self) -> RwLockReadGuard<'_, NotificationsHandler> { - self.inner.notifications_handler.read().await - } - - pub async fn notifications_handler_mut(&self) -> RwLockWriteGuard<'_, NotificationsHandler> { - self.inner.notifications_handler.write().await - } - - pub async fn clear(&self) { - { - let mut priv_key_guard = self.inner.priv_key.write().await; - let mut org_keys_guard = self.inner.org_keys.write().await; - - *priv_key_guard = None; - *org_keys_guard = None; - } - - *self.inner.lock_deadline.lock().await = None; - } - - pub async fn set_sync_timeout(&self) { - *self.inner.sync_deadline.lock().await = - Some(Instant::now() + Duration::from_secs(self.inner.config.sync_interval)); - // self.inner - // .sync_timeout - // .set(self.inner.sync_timeout_duration); - } - - // the way we structure the client/agent split in rbw makes the master - // password reprompt feature a bit complicated to implement - it would be - // a lot easier to just have the client do the prompting, but that would - // leave it open to someone reading the cipherstring from the local - // database and passing it to the agent directly, bypassing the client. - // the agent is the thing that holds the unlocked secrets, so it also - // needs to be the thing guarding access to master password reprompt - // entries. we only pass individual cipherstrings to the agent though, so - // the agent needs to be able to recognize the cipherstrings that need - // reprompting, without the additional context of the entry they came - // from. in addition, because the reprompt state is stored in the sync db - // in plaintext, we can't just read it from the db directly, because - // someone could just edit the file on disk before making the request. - // - // therefore, the solution we choose here is to keep an in-memory set of - // cipherstrings that we know correspond to entries with master password - // reprompt enabled. this set is only updated when the agent itself does - // a sync, so it can't be bypassed by editing the on-disk file directly. - // if the agent gets a request for any of those cipherstrings that it saw - // marked as master password reprompt during the most recent sync, it - // forces a reprompt. - - async fn add_mpr(&self, s: Option<&str>) { - if let Some(s) = s { - if !s.is_empty() { - let mut hasher = sha2::Sha256::new(); - hasher.update(s); - self.inner - .master_password_reprompt - .write() - .await - .insert(hasher.finalize().into()); - } - } - } - - pub async fn initialize_mpr(&self) { - if !self.master_password_reprompt_initialized() { - self.set_master_password_reprompt(&self.inner.db.read().await.entries) - .await; - } - } - - pub async fn set_master_password_reprompt(&self, entries: &[rbw::db::Entry]) { - self.inner.master_password_reprompt.write().await.clear(); - - for entry in entries { - if !entry.master_password_reprompt() { - continue; - } - - match &entry.data { - rbw::db::EntryData::Login { password, totp, .. } => { - self.add_mpr(password.as_deref()).await; - self.add_mpr(totp.as_deref()).await; - } - rbw::db::EntryData::Card { number, code, .. } => { - self.add_mpr(number.as_deref()).await; - self.add_mpr(code.as_deref()).await; - } - rbw::db::EntryData::Identity { - ssn, - passport_number, - .. - } => { - self.add_mpr(ssn.as_deref()).await; - self.add_mpr(passport_number.as_deref()).await; - } - rbw::db::EntryData::SecureNote => {} - rbw::db::EntryData::SshKey { private_key, .. } => { - self.add_mpr(private_key.as_deref()).await; - } - } - - for field in &entry.fields { - if field.ty == Some(rbw::api::FieldType::Hidden) { - self.add_mpr(field.value.as_deref()).await; - } - } - } - - self.inner - .master_password_reprompt_initialized - .store(true, std::sync::atomic::Ordering::Relaxed); - } - - pub fn master_password_reprompt_initialized(&self) -> bool { - self.inner - .master_password_reprompt_initialized - .load(std::sync::atomic::Ordering::Relaxed) - } - - pub async fn last_environment( - &self, - ) -> tokio::sync::RwLockReadGuard<'_, rbw::protocol::Environment> { - self.inner.last_environment.read().await - } - - pub async fn set_last_environment(&self, environment: rbw::protocol::Environment) { - *self.inner.last_environment.write().await = environment; - } - - pub fn email(&self) -> anyhow::Result<&str> { - self.inner - .config - .email - .as_deref() - .ok_or_else(|| anyhow::anyhow!("failed to find email address in config")) - } - - pub fn base_url(&self) -> String { - self.inner.config.base_url() - } - - pub fn config_pinentry(&self) -> &str { - &self.inner.config.pinentry - } - - pub fn notifications_url(&self) -> String { - self.inner.config.notifications_url() - } - - pub fn server_name(&self) -> String { - self.inner.config.server_name() - } - - #[cfg(feature = "clipboard")] - pub async fn clipboard_mut(&self) -> tokio::sync::MutexGuard<'_, Option> { - self.inner.clipboard.lock().await - } - - pub fn confirm_ssh(&self) -> bool { - self.inner.config.confirm_ssh.is_some_and(|o| o) - } -} diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index 262757b2..35e704a8 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -16,8 +16,7 @@ async fn async_main(startup_ack: Option) -> anyhow::R let config = rbw::config::Config::load()?; - let state = crate::agent::state::State::new(config).await; - let agent = crate::agent::Agent::new(state.clone()); + let agent = crate::agent::Agent::new(config).await; let ssh_agent = crate::agent::ssh_agent::SshAgent::new(agent.clone()); From 8fea98c3c832d2f014fd9f8b3c5b5e4b37cc36c3 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 7 Jun 2026 15:38:02 +0200 Subject: [PATCH 249/273] line --- src/api/client.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/client.rs b/src/api/client.rs index c8f0aadc..34b9df63 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -208,6 +208,7 @@ fn sso_query_code(params: &HashMap, state: &str) -> Result Date: Sun, 7 Jun 2026 16:27:03 +0200 Subject: [PATCH 250/273] add a bunch of log::trace and release db guard to fix notifications bug --- src/bin/rbw-agent/agent/actions.rs | 31 +++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index dbf91f92..9701daf4 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -177,11 +177,15 @@ impl Agent { let email = self.email()?; if provider == rbw::api::TwoFactorProviderType::Email { + log::trace!("Two factor provider is email"); if let Some(token) = sso_email_2fa_session_token { + log::trace!("Sending 2FA email"); rbw::actions::send_two_factor_email(email, &token).await?; } } + log::trace!("Performing 2FA login"); + let creds = self .two_factor(environment, password.clone(), provider) .await?; @@ -229,6 +233,8 @@ impl Agent { providers, sso_email_2fa_session_token, }) => { + log::trace!("Login requires 2FA, performing it."); + self.two_factor_required( &password, providers, @@ -244,6 +250,7 @@ impl Agent { Err(e) => return Err(e).context("failed to log in to bitwarden instance"), }; + log::debug!("Login successful. Applying session parameters.."); { let mut db = self.inner.db.write().await; @@ -252,12 +259,17 @@ impl Agent { db.save_async(&self.server_name(), self.email()?).await?; } + log::trace!("Session parameters set. Syncing.."); self.sync(None).await?; + log::trace!("Sync performed. Trying to unlock with the current password.."); + self.try_unlock(&password) .await .context("failed to unlock database")?; + log::trace!("Login and unlock successful!"); + break; } @@ -323,19 +335,20 @@ impl Agent { log::trace!("Set master password reprompt"); // And then update the local cached copy of the db + { + let mut db = self.inner.db.write().await; - let mut db = self.inner.db.write().await; - - log::trace!("Opened cached db for write operation"); + log::trace!("Opened cached db for write operation"); - db.update_access_token(access_token); + db.update_access_token(access_token); - db.protected_key = Some(protected_key); - db.protected_private_key = Some(protected_private_key); - db.protected_org_keys = protected_org_keys; - db.entries = entries; + db.protected_key = Some(protected_key); + db.protected_private_key = Some(protected_private_key); + db.protected_org_keys = protected_org_keys; + db.entries = entries; - db.save_async(&self.server_name(), self.email()?).await?; + db.save_async(&self.server_name(), self.email()?).await?; + } log::trace!("Updated disk db"); From 7267c1800eef2ccd5733a0bbc2f8d19d7fa5b01c Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 7 Jun 2026 18:11:19 +0200 Subject: [PATCH 251/273] fix missing re-evaluation of deadlines --- src/bin/rbw-agent/agent/actions.rs | 2 -- src/bin/rbw-agent/agent/mod.rs | 8 +++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index 9701daf4..7bb6dc9d 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -584,8 +584,6 @@ impl Agent { pub async fn get_ssh_public_keys(&self) -> anyhow::Result> { let environment = { self.last_environment().await.clone() }; - // BUG: this reset_lock_timeout call doesn't make agent::run() restart the loop, so re-lock - // never happens. log::trace!("Resetting lock timeout due to get_ssh_public_keys"); self.reset_lock_timeout().await; diff --git a/src/bin/rbw-agent/agent/mod.rs b/src/bin/rbw-agent/agent/mod.rs index a1b03fa6..249c980b 100644 --- a/src/bin/rbw-agent/agent/mod.rs +++ b/src/bin/rbw-agent/agent/mod.rs @@ -9,7 +9,7 @@ use rbw::db::Db; use sha2::Digest as _; use tokio::{ net::{UnixListener, UnixStream}, - sync::{Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard}, + sync::{Mutex, Notify, RwLock, RwLockReadGuard, RwLockWriteGuard}, time::{sleep_until, Instant}, }; @@ -24,6 +24,7 @@ struct InnerAgent { notifications_handler: RwLock, pub lock_deadline: Mutex>, pub sync_deadline: Mutex>, + pub run_notify: Notify, pub master_password_reprompt: RwLock>, master_password_reprompt_initialized: AtomicBool, config: rbw::config::Config, @@ -76,6 +77,7 @@ impl Agent { notifications_handler: RwLock::new(notifications_handler), lock_deadline: Mutex::new(None), sync_deadline: Mutex::new(sync_deadline), + run_notify: Notify::new(), master_password_reprompt: RwLock::new(std::collections::HashSet::new()), master_password_reprompt_initialized: AtomicBool::new(false), config, @@ -134,6 +136,7 @@ impl Agent { pub async fn reset_lock_timeout(&self) { *self.inner.lock_deadline.lock().await = Some(Instant::now() + Duration::from_secs(self.inner.config.lock_timeout)); + self.inner.run_notify.notify_one(); } pub async fn notifications_handler(&self) -> RwLockReadGuard<'_, NotificationsHandler> { @@ -371,6 +374,9 @@ impl Agent { self.on_connection(res.0).await; }, + _ = self.inner.run_notify.notified() => { + log::trace!("Waking run loop to re-evaluate deadlines"); + }, _ = Self::sleep_until_deadline(lock_deadline) => { log::trace!("Lock deadline reached. Locking the db"); self.clear().await; From 8d5f4cd87f13d2f17c2851f19b698c343b7c96a7 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 7 Jun 2026 18:29:22 +0200 Subject: [PATCH 252/273] update CHANGELOG.md written with AI, verified and fixed by OI (Organic Intelligence) --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 729f5b58..ffb2fe22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## [Unreleased] + +## Added + +* Added `confirm_ssh` configuration option. When set to `true`, the agent will + ask for pinentry confirmation before every SSH signature request. +* Added Nix flake support (`flake.nix`, `flake.lock`, `shell.nix`). + +## Changed + +* The `rbw` client and agent are now fully async internally. +* The local database is now cached in the agent's state, reducing disk reads. +* Notifications were refactored to use `tokio::sync::broadcast` instead of a + manual vector of senders. +* The `Field` enum was replaced by `rbw::db::FieldType`, which now supports a + `Custom(String)` variant. Unknown field names given to `rbw get --field` are + now treated as custom field lookups instead of returning an error. +* Removed unused dependencies: `arrayvec`, `is-terminal`, `tokio-stream`. +* `.rustfmt.toml`: removed `max_width = 78`, added `newline_style = "Unix"`. +* Removed the extensive clippy lint configuration from `Cargo.toml`. +* `dirs.rs` helpers now return `Result` instead of potentially panicking. +* `timeout.rs` was removed in favor of a simple Instant based deadline + mechanism. + +## Fixed + +* Fixed broken protocol version calculation where minor and patch components + were incorrectly scaled by 1_000_000. +* Various error types and messages have changed; backwards compatibility of + error text is not guaranteed. + ## [1.15.0] - 2025-12-31 ## Added From 61d3e1e05dc710c1d2c917a1e9c0fb6f3e797c48 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 7 Jun 2026 18:39:49 +0200 Subject: [PATCH 253/273] avoid some clones in error conversion --- src/api/mod.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index cd80bf6f..2b895d5c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -292,16 +292,16 @@ impl TryFrom for Error { match value.error.as_str() { "invalid_grant" => match error_desc { Some("invalid_username_or_password") => { - if let Some(error_model) = value.error_model.as_ref() { - let message = error_model.message.as_str().to_string(); + if let Some(error_model) = value.error_model { + let message = error_model.message; return Ok(Error::IncorrectPassword { message }); } } Some("Two factor required.") => { - if let Some(providers) = value.two_factor_providers.as_ref() { + if let Some(providers) = value.two_factor_providers { return Ok(Error::TwoFactorRequired { - providers: providers.clone(), - sso_email_2fa_session_token: value.sso_email_2fa_session_token.clone(), + providers: providers, + sso_email_2fa_session_token: value.sso_email_2fa_session_token, }); } } From d99273186c7a437b420ed7f1f9310211151b2389 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 7 Jun 2026 18:41:35 +0200 Subject: [PATCH 254/273] error_model -> model --- src/api/mod.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 2b895d5c..31f211f5 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -292,9 +292,10 @@ impl TryFrom for Error { match value.error.as_str() { "invalid_grant" => match error_desc { Some("invalid_username_or_password") => { - if let Some(error_model) = value.error_model { - let message = error_model.message; - return Ok(Error::IncorrectPassword { message }); + if let Some(model) = value.error_model { + return Ok(Error::IncorrectPassword { + message: model.message, + }); } } Some("Two factor required.") => { @@ -317,8 +318,8 @@ impl TryFrom for Error { // bitwarden_rs returns an empty error and error_description for // this case, for some reason if error_desc.is_none() || error_desc == Some("") { - if let Some(error_model) = value.error_model.as_ref() { - let message = error_model.message.clone(); + if let Some(model) = value.error_model.as_ref() { + let message = model.message.clone(); match message.as_str() { "Username or password is incorrect. Try again" | "TOTP code is not a number" => { From c03b221d06e5dabd2dc449129f875675679162ee Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 7 Jun 2026 18:57:29 +0200 Subject: [PATCH 255/273] add specific error for unavailability of crypto parameters in db --- src/db.rs | 4 ++-- src/error.rs | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/db.rs b/src/db.rs index 959a23b9..eb28259c 100644 --- a/src/db.rs +++ b/src/db.rs @@ -921,10 +921,10 @@ impl Db { // TODO: Return references if possible // NOTE: Previous error string were different. Not 100% compatible output. - pub fn get_crypto_parameters(&self) -> anyhow::Result { + pub fn get_crypto_parameters(&self) -> Result { self.crypto_params .clone() - .ok_or(anyhow::anyhow!("failed to find crypto parameters in db")) + .ok_or(Error::UnavailableDbCryptoParameters) } // TODO: Return references if possible diff --git a/src/error.rs b/src/error.rs index e4dcbc6a..19381c88 100644 --- a/src/error.rs +++ b/src/error.rs @@ -216,6 +216,9 @@ pub enum Error { file: std::path::PathBuf, }, + #[error("failed to find crypto parameters in db")] + UnavailableDbCryptoParameters, + #[error("error spawning pinentry")] Spawn { source: tokio::io::Error }, @@ -247,7 +250,7 @@ pub enum Error { EmptyCipherData, #[error("the entry has been deleted")] - DeletedEntry + DeletedEntry, } impl From for Error { From 67d8f503d5ca266f351c0bc213b177990b833cf9 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 7 Jun 2026 19:06:03 +0200 Subject: [PATCH 256/273] add specific error for unavailability of session parameters --- src/db.rs | 8 ++++---- src/error.rs | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/db.rs b/src/db.rs index eb28259c..150272c4 100644 --- a/src/db.rs +++ b/src/db.rs @@ -928,17 +928,17 @@ impl Db { } // TODO: Return references if possible - pub fn get_session_parameters(&self) -> anyhow::Result { + pub fn get_session_parameters(&self) -> Result { let Some(access_token) = self.access_token.clone() else { - return Err(anyhow::anyhow!("failed to find access_token in db")); + return Err(Error::UnavailableDbSessionParameters("access_token")); }; let Some(refresh_token) = self.refresh_token.clone() else { - return Err(anyhow::anyhow!("failed to find refresh_token in db")); + return Err(Error::UnavailableDbSessionParameters("refresh_token")); }; let Some(protected_key) = self.protected_key.clone() else { - return Err(anyhow::anyhow!("failed to find protected key in db")); + return Err(Error::UnavailableDbSessionParameters("protected key")); }; Ok(SessionParameters { diff --git a/src/error.rs b/src/error.rs index 19381c88..1d0bdb6d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -219,6 +219,9 @@ pub enum Error { #[error("failed to find crypto parameters in db")] UnavailableDbCryptoParameters, + #[error("failed to find {0} in db")] + UnavailableDbSessionParameters(&'static str), + #[error("error spawning pinentry")] Spawn { source: tokio::io::Error }, From 2e534c472b0e774553bf35d9a6846a0b297d071e Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 7 Jun 2026 19:09:26 +0200 Subject: [PATCH 257/273] re-use error for missing config email --- src/bin/rbw-agent/agent/mod.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/bin/rbw-agent/agent/mod.rs b/src/bin/rbw-agent/agent/mod.rs index 249c980b..7481882a 100644 --- a/src/bin/rbw-agent/agent/mod.rs +++ b/src/bin/rbw-agent/agent/mod.rs @@ -5,7 +5,10 @@ use std::{ }; use anyhow::Context as _; -use rbw::db::Db; +use rbw::{ + db::Db, + error::{Error, Result}, +}; use sha2::Digest as _; use tokio::{ net::{UnixListener, UnixStream}, @@ -269,12 +272,12 @@ impl Agent { *self.inner.last_environment.write().await = environment; } - pub fn email(&self) -> anyhow::Result<&str> { + pub fn email(&self) -> Result<&str> { self.inner .config .email .as_deref() - .ok_or_else(|| anyhow::anyhow!("failed to find email address in config")) + .ok_or_else(|| Error::ConfigMissingEmail) } pub fn base_url(&self) -> String { From 2f390cbed5850b34287ca589075e230ad2472134 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 7 Jun 2026 19:16:00 +0200 Subject: [PATCH 258/273] add specific error for unavailability of protected keys in db and avoid downcasting errors --- src/bin/rbw-agent/agent/actions.rs | 17 +++++++++-------- src/error.rs | 3 +++ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index 7bb6dc9d..a9a099e6 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -2,6 +2,7 @@ use anyhow::Context as _; use rbw::{ actions::SessionParameters, db::{Db, EntryData}, + error::{Error, Result}, }; use sha2::Digest as _; @@ -470,12 +471,12 @@ impl Agent { Ok(()) } - async fn try_unlock(&self, password: &rbw::locked::Password) -> anyhow::Result<()> { + async fn try_unlock(&self, password: &rbw::locked::Password) -> Result<()> { let db = self.inner.db.read().await; let (protected_key, protected_private_key, protected_org_keys) = db.some_protected_keys() - .ok_or(anyhow::anyhow!("Cannot get protected keys from Db"))?; + .ok_or(Error::UnavailableDbProtectedKeys)?; let (keys, org_keys) = rbw::actions::unlock( &self.email()?, @@ -509,9 +510,9 @@ impl Agent { Ok(()) => { break; } - Err(e) => match e.downcast_ref::() { - Some(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { - err_msg = Some(message.clone()) + Err(e) => match e { + rbw::error::Error::IncorrectPassword { message } if i < 3 => { + err_msg = Some(message) } _ => return Err(e).context("failed to unlock database"), }, @@ -564,10 +565,10 @@ impl Agent { log::trace!("Password correct, reprompt successful"); break; } - Err(e) => match e.downcast_ref::() { - Some(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { + Err(e) => match e { + rbw::error::Error::IncorrectPassword { message } if i < 3 => { log::trace!("mpr incorrect password"); - err_msg = Some(message.clone()) + err_msg = Some(message) } _ => { log::trace!("mpr other error"); diff --git a/src/error.rs b/src/error.rs index 1d0bdb6d..b87aa730 100644 --- a/src/error.rs +++ b/src/error.rs @@ -222,6 +222,9 @@ pub enum Error { #[error("failed to find {0} in db")] UnavailableDbSessionParameters(&'static str), + #[error("failed to find protected keys in db")] + UnavailableDbProtectedKeys, + #[error("error spawning pinentry")] Spawn { source: tokio::io::Error }, From c258925d2caaa0d412281841bba73289579da0d4 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 7 Jun 2026 22:08:41 +0200 Subject: [PATCH 259/273] remove duplicated retry logic by implementing with_retry --- src/actions.rs | 2 +- src/bin/rbw-agent/agent/actions.rs | 220 ++++++++++++++--------------- 2 files changed, 107 insertions(+), 115 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index c9242e14..ca36775d 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -30,7 +30,7 @@ pub struct SessionParameters { pub async fn login( email: &str, - password: crate::locked::Password, + password: &crate::locked::Password, two_factor_token: Option<&str>, two_factor_provider: Option, ) -> Result { diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index a9a099e6..4223d309 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -1,3 +1,5 @@ +use std::future::Future; + use anyhow::Context as _; use rbw::{ actions::SessionParameters, @@ -8,6 +10,44 @@ use sha2::Digest as _; use crate::agent::Agent; +async fn with_retry(c: C) -> anyhow::Result +where + C: Fn(Option) -> Fut, + Fut: Future>, +{ + let mut err_msg = None; + + for i in 1..=3 { + let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); + + match c(err).await { + Ok(r) => { + return Ok(r); + } + Err(e) => { + if let Some(e) = e.downcast_ref::() { + match e { + rbw::error::Error::IncorrectPassword { message } if i < 3 => { + err_msg = Some(message.clone()); + continue; + } + // TODO: Move this back where it was if possible + rbw::error::Error::TwoFactorRequired { .. } if i < 3 => { + err_msg = Some("TOTP code is not a number".to_string()); + continue; + } + _ => {} + } + } + + return Err(e); + } + } + } + + unreachable!() +} + impl Agent { async fn getpin( &self, @@ -80,28 +120,20 @@ impl Agent { return respond_ack(sock).await; } - let host = self.get_host()?; + let host = &self.get_host()?; - let email = self.email()?.to_string(); + let email = &self.email()?.to_string(); - let mut err_msg = None; - for i in 1_u8..=3 { - let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); + with_retry(|e| async move { let (client_id, client_secret) = - self.get_client_id_secret(&host, &err, environment).await?; + self.get_client_id_secret(&host, &e, environment).await?; let apikey = rbw::locked::ApiKey::new(client_id, client_secret); - match rbw::actions::register(&email, apikey).await { - Ok(()) => { - break; - } - Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { - err_msg = Some(message); - } - Err(e) => return Err(e).context("failed to log in to bitwarden instance"), - } - } + Ok(rbw::actions::register(&email, apikey).await?) + }) + .await + .context("failed to log in to bitwarden instance")?; respond_ack(sock).await?; @@ -128,32 +160,19 @@ impl Agent { async fn two_factor( &self, environment: &rbw::protocol::Environment, - password: rbw::locked::Password, + password: &rbw::locked::Password, provider: rbw::api::TwoFactorProviderType, ) -> anyhow::Result { let email = self.email()?; - let mut err_msg = None; - for i in 1_u8..=3 { - let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); - + with_retry(|err| async move { let code = self.get_code(provider, &err, environment).await?; let code = std::str::from_utf8(code.password()).context("code was not valid utf8")?; - match rbw::actions::login(email, password.clone(), Some(code), Some(provider)).await { - Ok(creds) => return Ok(creds), - Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { - err_msg = Some(message); - } - // can get this if the user passes an empty string - Err(rbw::error::Error::TwoFactorRequired { .. }) if i < 3 => { - err_msg = Some("TOTP code is not a number".to_string()); - } - Err(e) => return Err(e).context("failed to log in to bitwarden instance"), - } - } - - unreachable!() + Ok(rbw::actions::login(email, password, Some(code), Some(provider)).await?) + }) + .await + .context("failed to log in to bitwarden instance") } async fn two_factor_required( @@ -187,9 +206,7 @@ impl Agent { log::trace!("Performing 2FA login"); - let creds = self - .two_factor(environment, password.clone(), provider) - .await?; + let creds = self.two_factor(environment, password, provider).await?; Ok(creds) } @@ -214,65 +231,66 @@ impl Agent { return respond_ack(sock).await; } - let host = self.get_host()?; - - let email = self.email()?.to_string(); + let host = &self.get_host()?; - let mut err_msg = None; - for i in 1_u8..=3 { - let err = err_msg - .as_deref() - .map(|msg| format!("{msg} (attempt {i}/3)")); + let email = &self.email()?.to_string(); + let (creds, password) = with_retry(|err| async move { let password = self .get_password(&format!("Log in to {host}"), &err, environment) .await?; - let creds = match rbw::actions::login(&email, password.clone(), None, None).await { - Ok(creds) => creds, - Err(rbw::error::Error::TwoFactorRequired { + let r = match rbw::actions::login(&email, &password, None, None).await { + Err(Error::TwoFactorRequired { providers, sso_email_2fa_session_token, }) => { log::trace!("Login requires 2FA, performing it."); - self.two_factor_required( - &password, - providers, - sso_email_2fa_session_token, - environment, - ) - .await? + let ret = match self + .two_factor_required( + &password, + providers, + sso_email_2fa_session_token, + environment, + ) + .await + { + Ok(creds) => Ok((creds, password)), + Err(e) => Err(anyhow::anyhow!("2FA verification failed: {e}")), + }?; + + Ok(ret) } - Err(rbw::error::Error::IncorrectPassword { message }) if i < 3 => { - err_msg = Some(message); - continue; - } - Err(e) => return Err(e).context("failed to log in to bitwarden instance"), + Ok(creds) => Ok((creds, password)), + Err(e) => Err(e), }; - log::debug!("Login successful. Applying session parameters.."); - { - let mut db = self.inner.db.write().await; + Ok(r?) + }) + .await + .context("failed to log in to bitwarden instance")?; - db.apply_session_parameters(&creds); + log::debug!("Login successful. Applying session parameters.."); - db.save_async(&self.server_name(), self.email()?).await?; - } + { + let mut db = self.inner.db.write().await; - log::trace!("Session parameters set. Syncing.."); - self.sync(None).await?; + db.apply_session_parameters(&creds); - log::trace!("Sync performed. Trying to unlock with the current password.."); + db.save_async(&self.server_name(), self.email()?).await?; + } - self.try_unlock(&password) - .await - .context("failed to unlock database")?; + log::trace!("Session parameters set. Syncing.."); + self.sync(None).await?; - log::trace!("Login and unlock successful!"); + log::trace!("Sync performed. Trying to unlock with the current password.."); - break; - } + self.try_unlock(&password) + .await + .context("failed to unlock database")?; + + log::trace!("Login and unlock successful!"); respond_ack(sock).await?; @@ -494,10 +512,7 @@ impl Agent { async fn unlock_state(&self, environment: &rbw::protocol::Environment) -> anyhow::Result<()> { if self.needs_unlock().await { - let mut err_msg = None; - for i in 1_u8..=3 { - let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); - + with_retry(|err| async move { let password = self .get_password( &format!("Unlock the local database for '{}'", rbw::dirs::profile()), @@ -506,18 +521,10 @@ impl Agent { ) .await?; - match self.try_unlock(&password).await { - Ok(()) => { - break; - } - Err(e) => match e { - rbw::error::Error::IncorrectPassword { message } if i < 3 => { - err_msg = Some(message) - } - _ => return Err(e).context("failed to unlock database"), - }, - } - } + Ok(self.try_unlock(&password).await?) + }) + .await + .context("failed to unlock database")?; } Ok(()) @@ -547,11 +554,7 @@ impl Agent { .collect::() ); - let mut err_msg = None; - for i in 1_u8..=3 { - let err = err_msg.map(|msg| format!("{msg} (attempt {i}/3)")); - - // TODO: Remember somewhere that only GUI pinentry work, since this is a daemon. + with_retry(|err| async move { let password = self .get_password( "Accessing this entry requires the master password", @@ -560,23 +563,12 @@ impl Agent { ) .await?; - match self.try_unlock(&password).await { - Ok(()) => { - log::trace!("Password correct, reprompt successful"); - break; - } - Err(e) => match e { - rbw::error::Error::IncorrectPassword { message } if i < 3 => { - log::trace!("mpr incorrect password"); - err_msg = Some(message) - } - _ => { - log::trace!("mpr other error"); - return Err(e).context("failed to unlock database"); - } - }, - } - } + Ok(self.try_unlock(&password).await?) + }) + .await + .context("failed to unlock database")?; + + log::trace!("Password correct, reprompt successful"); } Ok(()) From daad9fdc84a830bcd40b5e99f21903ce95694ebf Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 7 Jun 2026 22:15:51 +0200 Subject: [PATCH 260/273] remove superfluous error context --- src/bin/rbw-agent/agent/actions.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index 4223d309..2472a31b 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -172,7 +172,6 @@ impl Agent { Ok(rbw::actions::login(email, password, Some(code), Some(provider)).await?) }) .await - .context("failed to log in to bitwarden instance") } async fn two_factor_required( From 48f4a782b4bdb49751937892a41a661f8925fd00 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 7 Jun 2026 22:42:59 +0200 Subject: [PATCH 261/273] remove superfluous deserializer impl --- src/json.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/json.rs b/src/json.rs index 453d40d4..f1ac55d4 100644 --- a/src/json.rs +++ b/src/json.rs @@ -11,14 +11,6 @@ impl DeserializeJsonWithPath for String { } } -impl DeserializeJsonWithPath for reqwest::blocking::Response { - fn json_with_path(self) -> Result { - let bytes = self.bytes()?; - let jd = &mut serde_json::Deserializer::from_slice(&bytes); - serde_path_to_error::deserialize(jd).map_err(|source| Error::Json { source }) - } -} - pub trait DeserializeJsonWithPathAsync { #[allow(async_fn_in_trait)] async fn json_with_path(self) -> Result; From df2c73145c51a20478e5525b12b855d9b5ce28bf Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Sun, 7 Jun 2026 22:50:44 +0200 Subject: [PATCH 262/273] add a comment for a security weakness --- src/bin/rbw-agent/agent/actions.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index 2472a31b..f3fac6a2 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -404,6 +404,7 @@ impl Agent { let cipherstring = rbw::cipherstring::CipherString::new(cipherstring) .context("failed to parse encrypted secret")?; + // BUG: This is sensible memory and should be handled more carefully (locked) let plaintext = String::from_utf8( cipherstring .decrypt_symmetric(keys.as_ref(), entry_key.as_ref()) From febc2d0ebaaf064c5c34e95752be6bc39c3bcfde Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 10 Jun 2026 17:24:58 +0200 Subject: [PATCH 263/273] trigger workflow --- .github/workflows/tests.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index df0a1ff9..3a95d8ed 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,4 +1,5 @@ name: tests + on: push: branches: [main] From 80d73a811025fc24206683c6a4d8af3d98433e43 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 10 Jun 2026 17:47:25 +0200 Subject: [PATCH 264/273] fix clippy warnings --- src/actions.rs | 2 +- src/api/client.rs | 12 ++++++------ src/api/mod.rs | 10 +++------- src/bin/rbw-agent/agent/actions.rs | 16 ++++++++-------- src/bin/rbw-agent/agent/ssh_agent.rs | 4 ++-- src/bin/rbw/commands.rs | 1 + src/db.rs | 1 + src/locked.rs | 9 ++++++--- src/pinentry.rs | 5 ++--- src/protocol.rs | 2 +- 10 files changed, 31 insertions(+), 31 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index ca36775d..f53d1762 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -37,7 +37,7 @@ pub async fn login( let (client, config) = api_client_async().await?; let crypto_params = client.prelogin(email).await?; - let identity = crate::identity::Identity::new(email, &password, &crypto_params)?; + let identity = crate::identity::Identity::new(email, password, &crypto_params)?; let (access_token, refresh_token, protected_key) = client .login( email, diff --git a/src/api/client.rs b/src/api/client.rs index 34b9df63..094015a5 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -328,13 +328,13 @@ impl Client { ) -> Result<()> { let connect_req = ConnectTokenReq { auth: ConnectTokenAuth::ClientCredentials { - username: &email, - client_secret: str::from_utf8(apikey.client_secret()).unwrap(), + username: email, + client_secret: std::str::from_utf8(apikey.client_secret()).unwrap(), }, grant_type: "client_credentials", scope: "api", // XXX unwraps here are not necessarily safe - client_id: str::from_utf8(apikey.client_id()).unwrap(), + client_id: std::str::from_utf8(apikey.client_id()).unwrap(), device_type: u32::from(DEVICE_TYPE), device_identifier: device_id, device_name: "rbw", @@ -385,14 +385,14 @@ impl Client { let connect_req = ConnectTokenReq { auth, - grant_type: grant_type, - scope: scope, + grant_type, + scope, client_id: "cli", device_type: u32::from(DEVICE_TYPE), device_identifier: device_id, device_name: "rbw", device_push_token: "", - two_factor_token: two_factor_token, + two_factor_token, two_factor_provider: two_factor_provider.map(|ty| ty as u32), }; diff --git a/src/api/mod.rs b/src/api/mod.rs index 31f211f5..9f02e157 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -301,7 +301,7 @@ impl TryFrom for Error { Some("Two factor required.") => { if let Some(providers) = value.two_factor_providers { return Ok(Error::TwoFactorRequired { - providers: providers, + providers, sso_email_2fa_session_token: value.sso_email_2fa_session_token, }); } @@ -830,9 +830,7 @@ impl From for CipherHistoryEntry { impl From for Option { fn from(value: CipherHistoryEntry) -> Self { - let Some(password) = value.password else { - return None; - }; + let password = value.password?; Some(crate::db::HistoryEntry { last_used_date: value.last_used_date, @@ -921,7 +919,7 @@ impl SyncResCipher { id: self.id, org_id: self.organization_id, folder, - folder_id: folder_id, + folder_id, name: self.name, data: self.data.try_into()?, fields, @@ -989,8 +987,6 @@ fn _cipher_data_type(data: &CipherData) -> u32 { 4 } else if data.secure_note.is_some() { 2 - } else if data.ssh_key.is_some() { - unreachable!() } else { unreachable!() } diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index f3fac6a2..7d469a4d 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -126,11 +126,11 @@ impl Agent { with_retry(|e| async move { let (client_id, client_secret) = - self.get_client_id_secret(&host, &e, environment).await?; + self.get_client_id_secret(host, &e, environment).await?; let apikey = rbw::locked::ApiKey::new(client_id, client_secret); - Ok(rbw::actions::register(&email, apikey).await?) + Ok(rbw::actions::register(email, apikey).await?) }) .await .context("failed to log in to bitwarden instance")?; @@ -239,7 +239,7 @@ impl Agent { .get_password(&format!("Log in to {host}"), &err, environment) .await?; - let r = match rbw::actions::login(&email, &password, None, None).await { + let r = match rbw::actions::login(email, &password, None, None).await { Err(Error::TwoFactorRequired { providers, sso_email_2fa_session_token, @@ -328,7 +328,7 @@ impl Agent { pub async fn sync(&self, sock: Option<&mut crate::sock::Sock>) -> anyhow::Result<()> { // Sync is the only one that reads an updated copy of the db from disk - let db = Db::load_async(&self.server_name(), &self.email()?).await?; + let db = Db::load_async(&self.server_name(), self.email()?).await?; log::trace!("Read fresh db from disk"); let Some(access_token) = &db.access_token else { @@ -497,12 +497,12 @@ impl Agent { .ok_or(Error::UnavailableDbProtectedKeys)?; let (keys, org_keys) = rbw::actions::unlock( - &self.email()?, + self.email()?, password, &db.get_crypto_parameters()?, - &protected_key, - &protected_private_key, - &protected_org_keys, + protected_key, + protected_private_key, + protected_org_keys, )?; self.set_keys(keys, org_keys).await; diff --git a/src/bin/rbw-agent/agent/ssh_agent.rs b/src/bin/rbw-agent/agent/ssh_agent.rs index 6fadc9e3..2a7952b2 100644 --- a/src/bin/rbw-agent/agent/ssh_agent.rs +++ b/src/bin/rbw-agent/agent/ssh_agent.rs @@ -59,7 +59,7 @@ impl ssh_agent_lib::agent::Session for SshAgent { "Received SSH signature request for {}", pubkey .to_openssh() - .map_err(|e| ssh_agent_lib::error::AgentError::other(e))? + .map_err(ssh_agent_lib::error::AgentError::other)? ); let private_key = self @@ -70,7 +70,7 @@ impl ssh_agent_lib::agent::Session for SshAgent { if self.agent.confirm_ssh() { let confirmed = rbw::pinentry::confirm( - &self.agent.config_pinentry(), + self.agent.config_pinentry(), "Allow SSH key use?", &self.agent.last_environment().await.clone(), true, diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index d62b486a..f48b4191 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -98,6 +98,7 @@ impl SearchEntry { .map_or_else(|| self.name.clone(), |user| format!("{user}@{}", self.name)) } + #[allow(clippy::too_many_arguments)] fn matches( &self, needle: &Needle, diff --git a/src/db.rs b/src/db.rs index 150272c4..b373c12c 100644 --- a/src/db.rs +++ b/src/db.rs @@ -140,6 +140,7 @@ pub struct DynamicField { pub linked_id: Option, } +#[allow(clippy::large_enum_variant)] #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)] pub enum EntryData { Login { diff --git a/src/locked.rs b/src/locked.rs index 862feb6d..de324395 100644 --- a/src/locked.rs +++ b/src/locked.rs @@ -1,4 +1,7 @@ -use std::{ops::{Deref, DerefMut}, str::Utf8Error}; +use std::{ + ops::{Deref, DerefMut}, + str::Utf8Error, +}; use zeroize::Zeroize; @@ -61,14 +64,14 @@ impl LockedVec { } pub fn as_str(&self) -> Result<&str, Utf8Error> { - str::from_utf8(self) + std::str::from_utf8(self) } pub fn capacity(&self) -> usize { LEN } - pub fn len(&self) -> usize { + fn len(&self) -> usize { self.data.1 } diff --git a/src/pinentry.rs b/src/pinentry.rs index 2b99dd29..df22ba44 100644 --- a/src/pinentry.rs +++ b/src/pinentry.rs @@ -117,7 +117,7 @@ impl Pinentry { async fn command(&mut self, command: &str) -> Result { self.writer - .write_all(&format!("{command}\n").as_bytes()) + .write_all(format!("{command}\n").as_bytes()) .await .map_err(|source| Error::WriteStdin { source })?; @@ -128,8 +128,7 @@ impl Pinentry { if line_str.starts_with("OK") { return Ok(line); - } else if line_str.starts_with("ERR ") { - let err = &line_str[4..]; + } else if let Some(err) = line_str.strip_prefix("ERR ") { let mut split = err.splitn(2, ' '); let code = split.next(); match code { diff --git a/src/protocol.rs b/src/protocol.rs index 23577977..1a0ffc61 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -142,7 +142,7 @@ impl Environment { self.tty.as_ref().map(|tty| tty.0.as_os_str()) } - pub fn env_vars<'a>(&'a self) -> std::collections::HashMap<&'a OsStr, &'a OsStr> { + pub fn env_vars(&self) -> std::collections::HashMap<&OsStr, &OsStr> { self.env_vars .iter() .map(|(var, val)| (var.0.as_os_str(), val.0.as_os_str())) From d0e5ad0c98107786e05fabb5073f336e881089db Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 10 Jun 2026 18:01:59 +0200 Subject: [PATCH 265/273] move guard up to make clippy happy --- src/api/mod.rs | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 9f02e157..cd0da340 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -314,22 +314,20 @@ impl TryFrom for Error { "invalid_client" => { return Ok(Error::IncorrectApiKey); } - "" => { + "" if error_desc.is_none() || error_desc == Some("") => { // bitwarden_rs returns an empty error and error_description for // this case, for some reason - if error_desc.is_none() || error_desc == Some("") { - if let Some(model) = value.error_model.as_ref() { - let message = model.message.clone(); - match message.as_str() { - "Username or password is incorrect. Try again" - | "TOTP code is not a number" => { + if let Some(model) = value.error_model.as_ref() { + let message = model.message.clone(); + match message.as_str() { + "Username or password is incorrect. Try again" + | "TOTP code is not a number" => { + return Ok(Error::IncorrectPassword { message }); + } + s => { + if s.starts_with("Invalid TOTP code! Server time: ") { return Ok(Error::IncorrectPassword { message }); } - s => { - if s.starts_with("Invalid TOTP code! Server time: ") { - return Ok(Error::IncorrectPassword { message }); - } - } } } } From 1baf27507c092efe47d0f2acd4fce7449782e011 Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 10 Jun 2026 18:06:31 +0200 Subject: [PATCH 266/273] add cargo-deny --- shell.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/shell.nix b/shell.nix index 89f4dffa..a0adb894 100644 --- a/shell.nix +++ b/shell.nix @@ -7,6 +7,7 @@ pkgs.mkShell { gdb rustc cargo + cargo-deny rust-analyzer rustfmt clippy From adb793122f6c8bfb027b7a765db654c8c5ba8cdc Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 10 Jun 2026 18:07:48 +0200 Subject: [PATCH 267/273] update dependencies to fix vulns --- Cargo.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4577e35a..4b48c3a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -260,9 +260,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cbc" @@ -1327,7 +1327,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -1721,7 +1721,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -1764,9 +1764,9 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -1775,9 +1775,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -1862,8 +1862,8 @@ dependencies = [ "pbkdf2", "percent-encoding", "pkcs8", - "rand 0.8.5", - "rand 0.9.2", + "rand 0.8.6", + "rand 0.9.4", "regex", "region", "reqwest", @@ -2121,9 +2121,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -2776,7 +2776,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.2", + "rand 0.9.4", "rustls", "rustls-pki-types", "sha1", From 09e07417509b295222bf0c606ca5be45b068edfe Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 10 Jun 2026 18:59:12 +0200 Subject: [PATCH 268/273] bump version to 1.16.0-rc1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4b48c3a9..c4fa2226 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1832,7 +1832,7 @@ dependencies = [ [[package]] name = "rbw" -version = "1.15.0" +version = "1.16.0-rc1" dependencies = [ "aes", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index bcc69fb4..423d68e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rbw" -version = "1.15.0" +version = "1.16.0-rc1" authors = ["Jesse Luehrs "] edition = "2021" rust-version = "1.82.0" From 4df9835114b77581ad13be7c2d13c0b22d48fa6a Mon Sep 17 00:00:00 2001 From: Francesco Pompo Date: Wed, 10 Jun 2026 19:02:51 +0200 Subject: [PATCH 269/273] move Needle into search.rs --- src/bin/rbw/commands.rs | 42 +++++------------------------------------ src/bin/rbw/main.rs | 2 +- src/lib.rs | 1 + src/search.rs | 36 +++++++++++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 38 deletions(-) create mode 100644 src/search.rs diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index f48b4191..f7f3b861 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1,10 +1,13 @@ use std::{ - fmt::Display, io::Write as _, os::unix::ffi::OsStrExt as _, path::PathBuf, str::FromStr, + io::Write as _, os::unix::ffi::OsStrExt as _, path::PathBuf, time::SystemTime, }; use anyhow::Context as _; -use rbw::db::{Decrypted, Decrypter, Encrypted, Encrypter, EntryData}; +use rbw::{ + db::{Decrypted, Decrypter, Encrypted, Encrypter, EntryData}, + search::Needle, +}; use crate::FindArgs; @@ -42,41 +45,6 @@ impl rbw::db::Decrypter for RemoteDecrypter { } } -#[derive(Debug, Clone)] -pub enum Needle { - Name(String), - Uri(url::Url), - Uuid(uuid::Uuid, String), -} - -impl Display for Needle { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let value = match &self { - Self::Name(name) => name.clone(), - Self::Uri(uri) => uri.to_string(), - Self::Uuid(_, s) => s.clone(), - }; - write!(f, "{value}") - } -} - -impl FromStr for Needle { - type Err = std::convert::Infallible; - - fn from_str(s: &str) -> Result { - if let Ok(uuid) = uuid::Uuid::parse_str(s) { - return Ok(Needle::Uuid(uuid, s.to_string())); - } - if let Ok(url) = url::Url::parse(s) { - if url.is_special() { - return Ok(Needle::Uri(url)); - } - } - - Ok(Needle::Name(s.to_string())) - } -} - #[derive(Debug, Clone, serde::Serialize)] #[cfg_attr(test, derive(Eq, PartialEq))] struct SearchEntry { diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index 213702af..8147bbca 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -10,7 +10,7 @@ mod sock; #[derive(Debug, clap::Args)] pub struct FindArgs { #[arg(help = "Name, URI or UUID of the entry to display")] - needle: commands::Needle, + needle: rbw::search::Needle, #[arg(help = "Username of the entry to display")] user: Option, #[arg(long, help = "Folder name to search in")] diff --git a/src/lib.rs b/src/lib.rs index b0f7c788..692423b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,4 +14,5 @@ pub mod pinentry; mod prelude; pub mod protocol; pub mod pwgen; +pub mod search; pub mod wordlist; diff --git a/src/search.rs b/src/search.rs new file mode 100644 index 00000000..a77d44e6 --- /dev/null +++ b/src/search.rs @@ -0,0 +1,36 @@ +use std::{fmt::Display, str::FromStr}; + +#[derive(Debug, Clone)] +pub enum Needle { + Name(String), + Uri(url::Url), + Uuid(uuid::Uuid, String), +} + +impl Display for Needle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let value = match &self { + Self::Name(name) => name.clone(), + Self::Uri(uri) => uri.to_string(), + Self::Uuid(_, s) => s.clone(), + }; + write!(f, "{value}") + } +} + +impl FromStr for Needle { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + if let Ok(uuid) = uuid::Uuid::parse_str(s) { + return Ok(Needle::Uuid(uuid, s.to_string())); + } + if let Ok(url) = url::Url::parse(s) { + if url.is_special() { + return Ok(Needle::Uri(url)); + } + } + + Ok(Needle::Name(s.to_string())) + } +} From 4f1ed4f10ef949102fab39106510be5d15263324 Mon Sep 17 00:00:00 2001 From: Tin Lai Date: Tue, 16 Jun 2026 15:49:48 +1000 Subject: [PATCH 270/273] check HTTP status before parsing token refresh response exchange_refresh_token and exchange_refresh_token_async were calling json_with_path() directly on the response without first checking the HTTP status. A 4xx from the identity server (e.g. {"error":"invalid_grant"}) was therefore parsed as ConnectRefreshTokenRes, failing with a cryptic "missing field `access_token`" JSON error instead of surfacing the actual HTTP status. --- src/api/client.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/api/client.rs b/src/api/client.rs index 094015a5..cfcc337b 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -586,6 +586,7 @@ impl Client { let res = ClientRequest::ExchangeRefreshToken(refresh_token) .req(self) .await?; + let res = Self::check_connect_token_res(res).await?; let connect_res: ConnectRefreshTokenRes = res.json_with_path().await?; Ok(connect_res.access_token) } @@ -594,6 +595,7 @@ impl Client { let res = ClientRequest::ExchangeRefreshToken(refresh_token) .req(self) .await?; + let res = Self::check_connect_token_res(res).await?; let connect_res: ConnectRefreshTokenRes = res.json_with_path().await?; Ok(connect_res.access_token) } From 94bbe0131e7bac7f5b24fc90436f2a1f0910b24f Mon Sep 17 00:00:00 2001 From: Tin Lai Date: Tue, 16 Jun 2026 15:50:00 +1000 Subject: [PATCH 271/273] persist rotated refresh token from Bitwarden token exchange Bitwarden (and Vaultwarden) rotate the refresh token on every exchange: alongside the new access_token, the server returns a new refresh_token and immediately invalidates the old one. ConnectRefreshTokenRes only captured access_token, so the stored refresh token became stale after the first refresh cycle. The next sync would attempt to use the already-rotated token and receive HTTP 400 invalid_grant, surfacing as "api request returned error: 400". Fix: capture refresh_token in ConnectRefreshTokenRes, thread it back through with_exchange_refresh_token_async (return type expands from (Option, T) to (Option, Option, T)), and persist it in Db via a new update_refresh_token method. All six public action functions (sync, add, edit, remove, list_folders, create_folder) and their callers in both the agent and the rbw binary are updated. Also adds body logging to client::sync() on non-2xx responses so future failures surface the server's error body alongside the status code. --- src/actions.rs | 24 +++++++++++++----------- src/api/client.rs | 29 +++++++++++++++++++++-------- src/api/mod.rs | 1 + src/bin/rbw-agent/agent/actions.rs | 12 ++++++++---- src/bin/rbw/commands.rs | 30 ++++++++++++++++++------------ src/db.rs | 9 +++++++++ 6 files changed, 70 insertions(+), 35 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index f53d1762..5079e5f3 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -116,6 +116,7 @@ pub async fn sync( access_token: &str, refresh_token: &str, ) -> Result<( + Option, Option, ( String, @@ -149,7 +150,7 @@ pub async fn add( data: &crate::db::EntryData, notes: Option<&str>, folder_id: Option<&str>, -) -> Result<(Option, ())> { +) -> Result<(Option, Option, ())> { with_exchange_refresh_token_async(access_token, refresh_token, |token| async move { add_once(&token, name, data, notes, folder_id).await }) @@ -179,7 +180,7 @@ pub async fn edit( access_token: &str, refresh_token: &str, entry: &Entry, -) -> Result<(Option, ())> { +) -> Result<(Option, Option, ())> { with_exchange_refresh_token_async(access_token, refresh_token, |token| async move { edit_once(&token, entry).await }) @@ -190,7 +191,7 @@ pub async fn remove( access_token: &str, refresh_token: &str, id: &str, -) -> Result<(Option, ())> { +) -> Result<(Option, Option, ())> { with_exchange_refresh_token_async(access_token, refresh_token, |token| async move { remove_once(&token, id).await }) @@ -206,7 +207,7 @@ async fn remove_once(access_token: &str, id: &str) -> Result<()> { pub async fn list_folders( access_token: &str, refresh_token: &str, -) -> Result<(Option, Vec<(String, String)>)> { +) -> Result<(Option, Option, Vec<(String, String)>)> { with_exchange_refresh_token_async(access_token, refresh_token, |token| async move { list_folders_once(&token).await }) @@ -222,7 +223,7 @@ pub async fn create_folder( access_token: &str, refresh_token: &str, name: &str, -) -> Result<(Option, String)> { +) -> Result<(Option, Option, String)> { with_exchange_refresh_token_async(access_token, refresh_token, |token| async move { create_folder_once(&token, name).await }) @@ -238,23 +239,24 @@ async fn with_exchange_refresh_token_async( access_token: &str, refresh_token: &str, mut f: F, -) -> Result<(Option, T)> +) -> Result<(Option, Option, T)> where F: FnMut(String) -> Fut, Fut: std::future::Future>, { match f(access_token.to_string()).await { - Ok(t) => Ok((None, t)), + Ok(t) => Ok((None, None, t)), Err(Error::RequestUnauthorized) => { - let access_token = exchange_refresh_token_async(refresh_token).await?; - let t = f(access_token.clone()).await?; - Ok((Some(access_token), t)) + let (new_access, new_refresh) = + exchange_refresh_token_async(refresh_token).await?; + let t = f(new_access.clone()).await?; + Ok((Some(new_access), new_refresh, t)) } Err(e) => Err(e), } } -async fn exchange_refresh_token_async(refresh_token: &str) -> Result { +async fn exchange_refresh_token_async(refresh_token: &str) -> Result<(String, Option)> { let (client, _) = api_client_async().await?; client.exchange_refresh_token_async(refresh_token).await } diff --git a/src/api/client.rs b/src/api/client.rs index cfcc337b..6bbc0579 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -480,10 +480,17 @@ impl Client { HashMap, Vec>, )> { - let res = ClientRequest::Sync(access_token) - .req(self) - .await? - .error_for_status()?; + let res = ClientRequest::Sync(access_token).req(self).await?; + let status = res.status(); + if !status.is_success() { + if let Ok(body) = res.text().await { + log::warn!("sync request failed with {status}: {body}"); + } + return Err(match status { + reqwest::StatusCode::UNAUTHORIZED => Error::RequestUnauthorized, + s => Error::RequestFailed { status: s.as_u16() }, + }); + } let sync_res: SyncRes = res.json_with_path().await?; @@ -582,22 +589,28 @@ impl Client { Ok(folders_res.id) } - pub async fn exchange_refresh_token(&self, refresh_token: &str) -> Result { + pub async fn exchange_refresh_token( + &self, + refresh_token: &str, + ) -> Result<(String, Option)> { let res = ClientRequest::ExchangeRefreshToken(refresh_token) .req(self) .await?; let res = Self::check_connect_token_res(res).await?; let connect_res: ConnectRefreshTokenRes = res.json_with_path().await?; - Ok(connect_res.access_token) + Ok((connect_res.access_token, connect_res.refresh_token)) } - pub async fn exchange_refresh_token_async(&self, refresh_token: &str) -> Result { + pub async fn exchange_refresh_token_async( + &self, + refresh_token: &str, + ) -> Result<(String, Option)> { let res = ClientRequest::ExchangeRefreshToken(refresh_token) .req(self) .await?; let res = Self::check_connect_token_res(res).await?; let connect_res: ConnectRefreshTokenRes = res.json_with_path().await?; - Ok(connect_res.access_token) + Ok((connect_res.access_token, connect_res.refresh_token)) } pub(super) fn api_url(&self, path: &str) -> String { diff --git a/src/api/mod.rs b/src/api/mod.rs index cd0da340..0ab27579 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -348,6 +348,7 @@ struct ConnectErrorResErrorModel { #[derive(Deserialize, Debug)] struct ConnectRefreshTokenRes { access_token: String, + refresh_token: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] diff --git a/src/bin/rbw-agent/agent/actions.rs b/src/bin/rbw-agent/agent/actions.rs index 7d469a4d..87f9a2a7 100644 --- a/src/bin/rbw-agent/agent/actions.rs +++ b/src/bin/rbw-agent/agent/actions.rs @@ -341,10 +341,13 @@ impl Agent { log::trace!("Obtained access and refresh tokens"); - let (access_token, (protected_key, protected_private_key, protected_org_keys, entries)) = - rbw::actions::sync(access_token, refresh_token) - .await - .context("failed to sync database from server")?; + let ( + access_token, + refresh_token_new, + (protected_key, protected_private_key, protected_org_keys, entries), + ) = rbw::actions::sync(access_token, refresh_token) + .await + .context("failed to sync database from server")?; log::trace!("Sync operation finished"); @@ -359,6 +362,7 @@ impl Agent { log::trace!("Opened cached db for write operation"); db.update_access_token(access_token); + db.update_refresh_token(refresh_token_new); db.protected_key = Some(protected_key); db.protected_private_key = Some(protected_private_key); diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index f7f3b861..66991500 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -746,13 +746,13 @@ async fn find_or_create_folder(db: &mut rbw::db::Db, folder: &str) -> anyhow::Re let enc: &mut dyn Encrypter<()> = &mut RemoteEncrypter {}; // fat ptr trick let dec: &mut dyn Decrypter<()> = &mut RemoteDecrypter {}; - let (new_access_token, folders) = rbw::actions::list_folders( + let (new_access_token, new_refresh_token, folders) = rbw::actions::list_folders( db.access_token.as_ref().unwrap(), db.refresh_token.as_ref().unwrap(), ) .await?; - update_token(db, new_access_token).await?; + update_token(db, new_access_token, new_refresh_token).await?; let folders: Vec<(String, String)> = folders .into_iter() @@ -766,14 +766,14 @@ async fn find_or_create_folder(db: &mut rbw::db::Db, folder: &str) -> anyhow::Re let folder_id = if let Some(folder_id) = folder_id { folder_id } else { - let (new_access_token, id) = rbw::actions::create_folder( + let (new_access_token, new_refresh_token, id) = rbw::actions::create_folder( db.access_token.as_ref().unwrap(), db.refresh_token.as_ref().unwrap(), &enc.encrypt_field(None, folder)?, ) .await?; - update_token(db, new_access_token).await?; + update_token(db, new_access_token, new_refresh_token).await?; id }; @@ -801,8 +801,14 @@ fn parse_editor(contents: &str) -> (Option, Option) { (password, notes) } -async fn update_token(db: &mut rbw::db::Db, new_token: Option) -> anyhow::Result<()> { - if db.update_access_token(new_token) { +async fn update_token( + db: &mut rbw::db::Db, + new_access_token: Option, + new_refresh_token: Option, +) -> anyhow::Result<()> { + let a = db.update_access_token(new_access_token); + let b = db.update_refresh_token(new_refresh_token); + if a || b { save_db(db).await?; } @@ -870,7 +876,7 @@ pub async fn add( None => None, }; - let (new_token, ()) = rbw::actions::add( + let (new_token, new_refresh_token, ()) = rbw::actions::add( db.access_token.as_ref().unwrap(), db.refresh_token.as_ref().unwrap(), &name, @@ -885,7 +891,7 @@ pub async fn add( ) .await?; - update_token(&mut db, new_token).await?; + update_token(&mut db, new_token, new_refresh_token).await?; crate::actions::sync() } @@ -967,14 +973,14 @@ pub async fn edit( entry.notes = entry.encrypt_optstring(&dec_notes, &mut enc)?; - let (new_token, ()) = rbw::actions::edit( + let (new_token, new_refresh_token, ()) = rbw::actions::edit( db.access_token.as_ref().unwrap(), db.refresh_token.as_ref().unwrap(), &entry, ) .await?; - update_token(&mut db, new_token).await?; + update_token(&mut db, new_token, new_refresh_token).await?; crate::actions::sync() } @@ -1000,14 +1006,14 @@ pub async fn remove( let entry = find_entry(&db, needle, user.as_deref(), folder.as_deref(), ignorecase) .with_context(|| format!("couldn't find entry for '{desc}'"))?; - let (new_access_token, ()) = rbw::actions::remove( + let (new_access_token, new_refresh_token, ()) = rbw::actions::remove( db.access_token.as_ref().unwrap(), db.refresh_token.as_ref().unwrap(), &entry.id, ) .await?; - update_token(&mut db, new_access_token).await?; + update_token(&mut db, new_access_token, new_refresh_token).await?; crate::actions::sync() } diff --git a/src/db.rs b/src/db.rs index b373c12c..734f4fac 100644 --- a/src/db.rs +++ b/src/db.rs @@ -913,6 +913,15 @@ impl Db { } } + pub fn update_refresh_token(&mut self, refresh_token: Option) -> bool { + if let Some(refresh_token) = refresh_token { + self.refresh_token = Some(refresh_token); + true + } else { + false + } + } + pub fn apply_session_parameters(&mut self, params: &SessionParameters) { self.access_token = Some(params.access_token.clone()); self.refresh_token = Some(params.refresh_token.clone()); From 9a967be0ad6cc2e7ead6947f49a9891774841e5f Mon Sep 17 00:00:00 2001 From: Tin Lai Date: Thu, 9 Jul 2026 18:37:25 +1000 Subject: [PATCH 272/273] surface error when it happens Signed-off-by: Tin Lai --- src/actions.rs | 3 +-- src/bin/rbw-agent/agent/mod.rs | 16 +++++++++++++--- src/bin/rbw-agent/main.rs | 30 +++++++++++++++++++++++++----- src/bin/rbw/actions.rs | 8 ++------ src/bin/rbw/commands.rs | 16 +++++++++------- src/error.rs | 8 ++++---- 6 files changed, 54 insertions(+), 27 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index 5079e5f3..5943675a 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -247,8 +247,7 @@ where match f(access_token.to_string()).await { Ok(t) => Ok((None, None, t)), Err(Error::RequestUnauthorized) => { - let (new_access, new_refresh) = - exchange_refresh_token_async(refresh_token).await?; + let (new_access, new_refresh) = exchange_refresh_token_async(refresh_token).await?; let t = f(new_access.clone()).await?; Ok((Some(new_access), new_refresh, t)) } diff --git a/src/bin/rbw-agent/agent/mod.rs b/src/bin/rbw-agent/agent/mod.rs index 7481882a..044ede95 100644 --- a/src/bin/rbw-agent/agent/mod.rs +++ b/src/bin/rbw-agent/agent/mod.rs @@ -367,8 +367,15 @@ impl Agent { tokio::select! { message = nchannel.recv() => { - let message = message?; - self.on_notification(message).await; + match message { + Ok(message) => self.on_notification(message).await, + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + log::warn!("notifications channel lagged by {n} messages"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + anyhow::bail!("notifications channel closed"); + } + } }, // TODO: The client does like a hundred connections to do basic things. Maybe it // makes sense to create more comprehensive opcodes. @@ -460,7 +467,10 @@ impl Agent { self.clipboard_store(sock, text).await?; } // TODO: It's better to handle the closing more gracefully - rbw::protocol::Action::Quit => std::process::exit(0), + rbw::protocol::Action::Quit => { + log::info!("received quit request (environment: {environment:?}); exiting"); + std::process::exit(0); + } rbw::protocol::Action::Version => { sock.send(&rbw::protocol::Response::Version { version: rbw::protocol::VERSION, diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs index 35e704a8..3565d3bd 100644 --- a/src/bin/rbw-agent/main.rs +++ b/src/bin/rbw-agent/main.rs @@ -24,8 +24,12 @@ async fn async_main(startup_ack: Option) -> anyhow::R let mut sigint = signal(SignalKind::interrupt())?; tokio::select!( - _ = agent.run(listener) => {}, - _ = ssh_agent.run() => {}, + res = agent.run(listener) => { + log::error!("agent run loop exited unexpectedly: {res:?}"); + }, + res = ssh_agent.run() => { + log::error!("ssh agent exited unexpectedly: {res:?}"); + }, _ = sigint.recv() => { log::warn!("SIGINT received. Closing the application."); }, @@ -40,9 +44,25 @@ async fn async_main(startup_ack: Option) -> anyhow::R fn main() -> anyhow::Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); - let no_daemonize = std::env::args() - .nth(1) - .is_some_and(|arg| arg == "--no-daemonize"); + let mut no_daemonize = false; + for arg in std::env::args().skip(1) { + match arg.as_str() { + "--no-daemonize" => no_daemonize = true, + "--version" | "-V" => { + println!("rbw-agent {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + "--help" | "-h" => { + println!("usage: rbw-agent [--no-daemonize]"); + return Ok(()); + } + _ => { + eprintln!("rbw-agent: unrecognized argument '{arg}'"); + eprintln!("usage: rbw-agent [--no-daemonize]"); + std::process::exit(2); + } + } + } rbw::dirs::make_all()?; diff --git a/src/bin/rbw/actions.rs b/src/bin/rbw/actions.rs index f916176d..46c4e6ac 100644 --- a/src/bin/rbw/actions.rs +++ b/src/bin/rbw/actions.rs @@ -87,9 +87,7 @@ pub fn decrypt( match res { rbw::protocol::Response::Decrypt { plaintext } => Ok(plaintext), - rbw::protocol::Response::Error { error } => { - Err(anyhow::anyhow!("failed to decrypt: {error}")) - } + rbw::protocol::Response::Error { error } => Err(anyhow::anyhow!("{error}")), _ => Err(anyhow::anyhow!("unexpected message: {res:?}")), } } @@ -102,9 +100,7 @@ pub fn encrypt(plaintext: &str, org_id: Option<&str>) -> anyhow::Result match res { rbw::protocol::Response::Encrypt { cipherstring } => Ok(cipherstring), - rbw::protocol::Response::Error { error } => { - Err(anyhow::anyhow!("failed to encrypt: {error}")) - } + rbw::protocol::Response::Error { error } => Err(anyhow::anyhow!("{error}")), _ => Err(anyhow::anyhow!("unexpected message: {res:?}")), } } diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 66991500..c5124344 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1,7 +1,4 @@ -use std::{ - io::Write as _, os::unix::ffi::OsStrExt as _, path::PathBuf, - time::SystemTime, -}; +use std::{io::Write as _, os::unix::ffi::OsStrExt as _, path::PathBuf, time::SystemTime}; use anyhow::Context as _; use rbw::{ @@ -21,8 +18,11 @@ impl rbw::db::Encrypter for RemoteEncrypter { entry: Option<&rbw::db::Entry>, field: &str, ) -> rbw::error::Result { - crate::actions::encrypt(field, entry.and_then(|e| e.org_id.as_deref())) - .map_err(|_e| rbw::error::Error::EncryptRemote) + crate::actions::encrypt(field, entry.and_then(|e| e.org_id.as_deref())).map_err(|e| { + rbw::error::Error::EncryptRemote { + message: format!("{e:#}"), + } + }) } } @@ -41,7 +41,9 @@ impl rbw::db::Decrypter for RemoteDecrypter { entry.and_then(|e| e.key.as_deref()), entry.and_then(|e| e.org_id.as_deref()), ) - .map_err(|_e| rbw::error::Error::DecryptRemote) + .map_err(|e| rbw::error::Error::DecryptRemote { + message: format!("{e:#}"), + }) } } diff --git a/src/error.rs b/src/error.rs index b87aa730..81fb6004 100644 --- a/src/error.rs +++ b/src/error.rs @@ -23,14 +23,14 @@ pub enum Error { #[error("failed to create sso callback server: {err}")] CreateSSOCallbackServer { err: std::io::Error }, - #[error("failed to encrypt remotely")] - EncryptRemote, + #[error("failed to encrypt remotely: {message}")] + EncryptRemote { message: String }, #[error("failed to decrypt")] Decrypt { source: block_padding::UnpadError }, - #[error("failed to decrypt remotely")] - DecryptRemote, + #[error("failed to decrypt remotely: {message}")] + DecryptRemote { message: String }, #[error("failed to find data directory")] FailedToFindDataDirectory, From 5e1abccd79db3e46fcf5138b87268244e5a254ab Mon Sep 17 00:00:00 2001 From: Tin Lai Date: Thu, 9 Jul 2026 18:38:48 +1000 Subject: [PATCH 273/273] Decrypt folder names with user key Folder names are encrypted with the local user vault key, even for organization entries. Passing the entry context sent org/item keys to the agent, which caused invalid MAC failures during list/search for entries in folders. --- src/bin/rbw/commands.rs | 3 ++- src/db.rs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index c5124344..cb03d688 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -177,7 +177,8 @@ impl TryFrom<&rbw::db::Entry> for SearchEntry { }; let name = entry.decrypt_string(&entry.name, &mut dec)?; - let folder = entry.decrypt_optstring(&entry.folder, &mut dec)?; + let folder = + dec.decrypt_optfield(None::<&rbw::db::Entry>, &entry.folder.as_deref())?; let notes = entry.decrypt_optstring(&entry.notes, &mut dec)?; let uris = entry diff --git a/src/db.rs b/src/db.rs index 734f4fac..9fa6972a 100644 --- a/src/db.rs +++ b/src/db.rs @@ -541,7 +541,8 @@ impl Entry { pub fn decrypt(&self, decrypter: &mut impl Decrypter) -> Result> { // folder name should always be decrypted with the local key because // folders are local to a specific user's vault, not the organization - let folder = self.decrypt_optstring(&self.folder, decrypter)?; + let folder = + decrypter.decrypt_optfield(None::<&Entry>, &self.folder.as_deref())?; let fields = self.decrypt_custom_fields(decrypter)?;