diff --git a/Cargo.toml b/Cargo.toml index c4e5fc1e..206d9c79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,8 +126,7 @@ yubico = { package = "yubico_ng", version = "0.14.1", features = ["online-tokio" # WebAuthn libraries # danger-allow-state-serialisation is needed to save the state in the db # danger-credential-internals is needed to support U2F to Webauthn migration -# danger-user-presence-only-security-keys is needed to disable UV -webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals", "danger-user-presence-only-security-keys"] } +webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } webauthn-rs-proto = "0.5.2" webauthn-rs-core = "0.5.2" diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 049145e0..d49a80ae 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -21,12 +21,12 @@ use std::sync::{Arc, LazyLock}; use std::time::Duration; use url::Url; use uuid::Uuid; -use webauthn_rs::prelude::{Base64UrlSafeData, SecurityKey, SecurityKeyAuthentication, SecurityKeyRegistration}; +use webauthn_rs::prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration}; use webauthn_rs::{Webauthn, WebauthnBuilder}; use webauthn_rs_proto::{ AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs, - RequestAuthenticationExtensions, + RequestAuthenticationExtensions, UserVerificationPolicy, }; pub static WEBAUTHN_2FA_CONFIG: LazyLock> = LazyLock::new(|| { @@ -38,8 +38,7 @@ pub static WEBAUTHN_2FA_CONFIG: LazyLock> = LazyLock::new(|| { let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin) .expect("Creating WebauthnBuilder failed") .rp_name(&domain) - .timeout(Duration::from_millis(60000)) - .danger_set_user_presence_only_security_keys(true); + .timeout(Duration::from_millis(60000)); Arc::new(webauthn.build().expect("Building Webauthn failed")) }); @@ -78,7 +77,7 @@ pub struct WebauthnRegistration { pub name: String, pub migrated: bool, - pub credential: SecurityKey, + pub credential: Passkey, } impl WebauthnRegistration { @@ -89,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 = "")] @@ -131,18 +148,27 @@ async fn generate_webauthn_challenge( .map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering .collect(); - let (challenge, state) = webauthn.start_securitykey_registration( + let (mut challenge, state) = webauthn.start_passkey_registration( Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail &user.email, &user.name, Some(registrations), - None, - None, )?; + let mut state = serde_json::to_value(&state)?; + state["rs"]["policy"] = Value::String("discouraged".to_string()); + state["rs"]["extensions"].as_object_mut().unwrap().clear(); + 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 + // we need to modify some of the default settings 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(); @@ -253,7 +279,7 @@ async fn activate_webauthn( let type_ = TwoFactorType::WebauthnRegisterChallenge as i32; let state = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await { Some(tf) => { - let state: SecurityKeyRegistration = serde_json::from_str(&tf.data)?; + let state: PasskeyRegistration = serde_json::from_str(&tf.data)?; tf.delete(&mut conn).await?; state } @@ -261,7 +287,7 @@ async fn activate_webauthn( }; // Verify the credentials with the saved state - let credential = webauthn.finish_securitykey_registration(&data.device_response.into(), &state)?; + let credential = webauthn.finish_passkey_registration(&data.device_response.into(), &state)?; let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &mut conn).await?.1; // TODO: Check for repeated ID's @@ -372,21 +398,25 @@ 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") } // Generate a challenge based on the credentials - let (mut response, state) = webauthn.start_securitykey_authentication(&creds)?; + let (mut response, state) = webauthn.start_passkey_authentication(&creds)?; // Modify to discourage user verification let mut state = serde_json::to_value(&state)?; + state["ast"]["policy"] = Value::String("discouraged".to_string()); // 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 @@ -413,9 +443,9 @@ 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: SecurityKeyAuthentication = serde_json::from_str(&tf.data)?; + let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?; tf.delete(conn).await?; state } @@ -432,7 +462,12 @@ pub async fn validate_webauthn_login( let mut registrations = get_webauthn_registrations(user_id, conn).await?.1; - let authentication_result = webauthn.finish_securitykey_authentication(&rsp, &state)?; + // 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 the flag 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 { if ct_eq(reg.credential.cred_id(), authentication_result.cred_id()) { @@ -454,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 is needed because Vaultwarden didn't store this information when using the previous version of webauthn-rs 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 its 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(()) +}