diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 0fab2cb2..1c3bce4e 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -21,7 +21,7 @@ use std::sync::{Arc, LazyLock}; use std::time::Duration; use url::Url; use uuid::Uuid; -use webauthn_rs::prelude::{Base64UrlSafeData, Passkey, PasskeyAuthentication, PasskeyRegistration}; +use webauthn_rs::prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration}; use webauthn_rs::{Webauthn, WebauthnBuilder}; use webauthn_rs_proto::{ AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, @@ -88,6 +88,24 @@ impl WebauthnRegistration { "migrated": self.migrated, }) } + + fn set_backup_eligible(&mut self, backup_eligible: bool, backup_state: bool) -> bool { + let mut changed = false; + let mut cred: Credential = self.credential.clone().into(); + + if cred.backup_state != backup_state { + cred.backup_state = backup_state; + changed = true; + } + + if backup_eligible && !cred.backup_eligible { + cred.backup_eligible = true; + changed = true; + } + + self.credential = cred.into(); + changed + } } #[post("/two-factor/get-webauthn", data = "")] @@ -137,12 +155,6 @@ async fn generate_webauthn_challenge( Some(registrations), )?; - // this is done since `start_passkey_registration()` always sets this to `Required` which shouldn't be needed for 2FA - challenge.public_key.extensions = None; - if let Some(asc) = challenge.public_key.authenticator_selection.as_mut() { - asc.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE; - } - let mut state = serde_json::to_value(&state)?; state["rs"]["policy"] = Value::String("discouraged".to_string()); state["rs"]["extensions"].as_object_mut().unwrap().clear(); @@ -150,6 +162,13 @@ async fn generate_webauthn_challenge( let type_ = TwoFactorType::WebauthnRegisterChallenge; TwoFactor::new(user.uuid.clone(), type_, serde_json::to_string(&state)?).save(&mut conn).await?; + // Because for this flow we abuse the passkeys as 2FA, and use it more like a securitykey + // wee modify some default defined by `start_passkey_registration()`. + challenge.public_key.extensions = None; + if let Some(asc) = challenge.public_key.authenticator_selection.as_mut() { + asc.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE; + } + let mut challenge_value = serde_json::to_value(challenge.public_key)?; challenge_value["status"] = "ok".into(); challenge_value["errorMessage"] = "".into(); @@ -379,7 +398,8 @@ pub async fn generate_webauthn_login( conn: &mut DbConn, ) -> JsonResult { // Load saved credentials - let creds: Vec<_> = get_webauthn_registrations(user_id, conn).await?.1.into_iter().map(|r| r.credential).collect(); + let creds: Vec = + get_webauthn_registrations(user_id, conn).await?.1.into_iter().map(|r| r.credential).collect(); if creds.is_empty() { err!("No Webauthn devices registered") @@ -391,11 +411,12 @@ pub async fn generate_webauthn_login( // Modify to discourage user verification let mut state = serde_json::to_value(&state)?; state["ast"]["policy"] = Value::String("discouraged".to_string()); - response.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE; // Add appid, this is only needed for U2F compatibility, so maybe it can be removed as well let app_id = format!("{}/app-id.json", &CONFIG.domain()); state["ast"]["appid"] = Value::String(app_id.clone()); + + response.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE; response .public_key .extensions @@ -422,7 +443,7 @@ pub async fn validate_webauthn_login( conn: &mut DbConn, ) -> EmptyResult { let type_ = TwoFactorType::WebauthnLoginChallenge as i32; - let state = match TwoFactor::find_by_user_and_type(user_id, type_, conn).await { + let mut state = match TwoFactor::find_by_user_and_type(user_id, type_, conn).await { Some(tf) => { let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?; tf.delete(conn).await?; @@ -441,6 +462,11 @@ pub async fn validate_webauthn_login( let mut registrations = get_webauthn_registrations(user_id, conn).await?.1; + // We need to check for and update the backup_eligible flag when needed. + // Vaultwarden did not have knowledge of this flag prior to migrating to webauthn-rs v0.5.x + // Because of this we check this at runtime and update the registrations and state when needed + check_and_update_backup_eligible(user_id, &rsp, &mut registrations, &mut state, conn).await?; + let authentication_result = webauthn.finish_passkey_authentication(&rsp, &state)?; for reg in &mut registrations { @@ -463,3 +489,66 @@ pub async fn validate_webauthn_login( } ) } + +async fn check_and_update_backup_eligible( + user_id: &UserId, + rsp: &PublicKeyCredential, + registrations: &mut Vec, + state: &mut PasskeyAuthentication, + conn: &mut DbConn, +) -> EmptyResult { + // The feature flags from the response + // For details see: https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data + const FLAG_BACKUP_ELIGIBLE: u8 = 0b0000_1000; + const FLAG_BACKUP_STATE: u8 = 0b0001_0000; + + if let Some(bits) = rsp.response.authenticator_data.get(32) { + let backup_eligible = 0 != (bits & FLAG_BACKUP_ELIGIBLE); + let backup_state = 0 != (bits & FLAG_BACKUP_STATE); + + // If the current key is backup eligible, then we probably need to update one of the keys already stored in the database + // This, because the previous version of webauthn-rs Vaultwarden used did not stored this information since it was a new addition to the protocol + // Because we store multiple keys in one json string, we need to fetch the correct key first, and update it's information before we let it verify + if backup_eligible { + let rsp_id = rsp.raw_id.as_slice(); + for reg in &mut *registrations { + if ct_eq(reg.credential.cred_id().as_slice(), rsp_id) { + // Try to update the key, and if needed also update the database, before the actual state check is done + if reg.set_backup_eligible(backup_eligible, backup_state) { + TwoFactor::new( + user_id.clone(), + TwoFactorType::Webauthn, + serde_json::to_string(®istrations)?, + ) + .save(conn) + .await?; + + // We also need to adjust the current state which holds the challenge used to start the authentication verification + // Because Vaultwarden supports multiple keys, we need to loop through the deserialized state and check which key to update + let mut raw_state = serde_json::to_value(&state)?; + if let Some(credentials) = raw_state + .get_mut("ast") + .and_then(|v| v.get_mut("credentials")) + .and_then(|v| v.as_array_mut()) + { + for cred in credentials.iter_mut() { + if cred.get("cred_id").is_some_and(|v| { + // Deserialize to a [u8] so it can be compared using `ct_eq` with the `rsp_id` + let cred_id_slice: Base64UrlSafeData = serde_json::from_value(v.clone()).unwrap(); + ct_eq(cred_id_slice, rsp_id) + }) { + cred["backup_eligible"] = Value::Bool(backup_eligible); + cred["backup_state"] = Value::Bool(backup_state); + } + } + } + + *state = serde_json::from_value(raw_state)?; + } + break; + } + } + } + } + Ok(()) +}