From 2485ba6a3860f8e13ca56cf83a9f002e7342331e Mon Sep 17 00:00:00 2001 From: Hans Date: Mon, 16 Mar 2026 18:23:26 +0800 Subject: [PATCH] fix: correct new device verification implementation - Remove client-side OTP request call (server sends email automatically) - Rename form field from deviceVerificationCode to newDeviceOtp to match Bitwarden server API expectation - Remove unused RequestDeviceOtpReq struct and related methods Co-Authored-By: Claude Sonnet 4.6 --- src/actions.rs | 2 + src/api.rs | 14 ++++ src/bin/rbw-agent/actions.rs | 134 ++++++++++++++++++++++++++++++++++- src/error.rs | 3 + 4 files changed, 151 insertions(+), 2 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index 79d304d..1412deb 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -18,6 +18,7 @@ pub async fn login( password: crate::locked::Password, two_factor_token: Option<&str>, two_factor_provider: Option, + device_verification_code: Option<&str>, ) -> Result<( String, String, @@ -47,6 +48,7 @@ pub async fn login( &identity.master_password_hash, two_factor_token, two_factor_provider, + device_verification_code, ) .await?; diff --git a/src/api.rs b/src/api.rs index a817fb2..b66c9c1 100644 --- a/src/api.rs +++ b/src/api.rs @@ -302,6 +302,11 @@ struct ConnectTokenReq { two_factor_token: Option, #[serde(rename = "twoFactorProvider")] two_factor_provider: Option, + #[serde( + rename = "newDeviceOtp", + skip_serializing_if = "Option::is_none" + )] + device_verification_code: Option, #[serde(flatten)] auth: ConnectTokenAuth, } @@ -943,6 +948,7 @@ impl Client { device_push_token: String::new(), two_factor_token: None, two_factor_provider: None, + device_verification_code: None, }; let client = self.reqwest_client().await?; let res = client @@ -979,6 +985,7 @@ impl Client { password_hash: &crate::locked::PasswordHash, two_factor_token: Option<&str>, two_factor_provider: Option, + device_verification_code: Option<&str>, ) -> Result<(String, String, String)> { let connect_req = match sso_id { Some(sso_id) => { @@ -1002,6 +1009,8 @@ impl Client { .map(std::string::ToString::to_string), two_factor_provider: two_factor_provider .map(|ty| ty as u32), + device_verification_code: device_verification_code + .map(std::string::ToString::to_string), } } None => ConnectTokenReq { @@ -1020,6 +1029,8 @@ impl Client { two_factor_token: two_factor_token .map(std::string::ToString::to_string), two_factor_provider: two_factor_provider.map(|ty| ty as u32), + device_verification_code: device_verification_code + .map(std::string::ToString::to_string), }, }; @@ -1738,6 +1749,9 @@ fn classify_login_error(error_res: &ConnectErrorRes, code: u16) -> Error { "invalid_client" => { return Error::IncorrectApiKey; } + "device_error" => { + return Error::NewDeviceVerificationRequired; + } "" => { // bitwarden_rs returns an empty error and error_description for // this case, for some reason diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 9ddd2ad..14a2d9e 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -113,8 +113,14 @@ 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, + None, + ) + .await { Ok(( access_token, @@ -141,6 +147,37 @@ pub async fn login( .await?; break 'attempts; } + Err(rbw::error::Error::NewDeviceVerificationRequired) => { + let ( + access_token, + refresh_token, + kdf, + iterations, + memory, + parallelism, + protected_key, + ) = device_verification( + environment, + &email, + password.clone(), + ) + .await?; + login_success( + state.clone(), + access_token, + refresh_token, + kdf, + iterations, + memory, + parallelism, + protected_key, + password, + db, + email, + ) + .await?; + break 'attempts; + } Err(rbw::error::Error::TwoFactorRequired { providers, sso_email_2fa_session_token, @@ -264,6 +301,7 @@ async fn two_factor( password.clone(), Some(code), Some(provider), + None, ) .await { @@ -316,6 +354,98 @@ async fn two_factor( unreachable!() } +async fn device_verification( + environment: &rbw::protocol::Environment, + email: &str, + password: rbw::locked::Password, +) -> anyhow::Result<( + String, + String, + rbw::api::KdfType, + u32, + Option, + Option, + String, +)> { + 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 code = rbw::pinentry::getpin( + &config_pinentry().await?, + "New Device Verification", + "Check your email for a verification code", + err.as_deref(), + environment, + false, + ) + .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(), + None, + None, + Some(code), + ) + .await + { + Ok(( + access_token, + refresh_token, + kdf, + iterations, + memory, + parallelism, + protected_key, + )) => { + return Ok(( + access_token, + refresh_token, + kdf, + iterations, + memory, + parallelism, + 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_msg = Some(message); + } + Err(rbw::error::Error::NewDeviceVerificationRequired) => { + let message = + "New device verification code is incorrect".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(e) => { + return Err(e) + .context("failed to log in to bitwarden instance") + } + } + } + + unreachable!() +} + async fn login_success( state: std::sync::Arc>, access_token: String, diff --git a/src/error.rs b/src/error.rs index b7789a9..557e503 100644 --- a/src/error.rs +++ b/src/error.rs @@ -235,6 +235,9 @@ pub enum Error { sso_email_2fa_session_token: Option, }, + #[error("new device verification required")] + NewDeviceVerificationRequired, + #[error("unimplemented cipherstring type: {ty}")] UnimplementedCipherStringType { ty: String },