Skip to content

Commit 5ee9085

Browse files
authored
Fix Webauthn/Passkey 2FA migration/validation issues (#6190)
* Apply Passkey fixes from zUnixorn Applied SecurityKey to Passkey fixes from @zUnixorn Co-authored-by: zUnixorn <[email protected]> Signed-off-by: BlackDex <[email protected]> * Fix Webauthn/Passkey 2FA migration issues Because the webauthn-rs v0.3 crate did not know or store new flags currently used in v0.5, some verifications failed. This mainly failed because of a check if a key was backuped or not, and if it was allowed to do so. Most hardware keys like YubiKey's do not have this flag enabled and can't be duplicated or faked via software. Since the rise of Passkey's, like Bitwarden's own implementation, and other platforms like Android, and Apple use Software keys which are shared between devices, they set these backup flags to true. This broke the login attempts, because the default during the migration was `false`, and cause an error during validation. This PR checks for the flags during the response/verification step, and if these flags are `true`, then search for the stored key, adjust it's value, and also update the current challenge state to match, to prevent the first login attempt to fail. This should not cause any issue, since the credential-id is checked and matched, and only updated when needed. Fixes #6154 Signed-off-by: BlackDex <[email protected]> * Fix comments Signed-off-by: BlackDex <[email protected]> --------- Signed-off-by: BlackDex <[email protected]>
1 parent 55577fa commit 5ee9085

File tree

2 files changed

+114
-17
lines changed

2 files changed

+114
-17
lines changed

Cargo.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,7 @@ yubico = { package = "yubico_ng", version = "0.14.1", features = ["online-tokio"
126126
# WebAuthn libraries
127127
# danger-allow-state-serialisation is needed to save the state in the db
128128
# danger-credential-internals is needed to support U2F to Webauthn migration
129-
# danger-user-presence-only-security-keys is needed to disable UV
130-
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals", "danger-user-presence-only-security-keys"] }
129+
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
131130
webauthn-rs-proto = "0.5.2"
132131
webauthn-rs-core = "0.5.2"
133132

src/api/core/two_factor/webauthn.rs

Lines changed: 113 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ use std::sync::{Arc, LazyLock};
2121
use std::time::Duration;
2222
use url::Url;
2323
use uuid::Uuid;
24-
use webauthn_rs::prelude::{Base64UrlSafeData, SecurityKey, SecurityKeyAuthentication, SecurityKeyRegistration};
24+
use webauthn_rs::prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration};
2525
use webauthn_rs::{Webauthn, WebauthnBuilder};
2626
use webauthn_rs_proto::{
2727
AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw,
2828
PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs,
29-
RequestAuthenticationExtensions,
29+
RequestAuthenticationExtensions, UserVerificationPolicy,
3030
};
3131

3232
pub static WEBAUTHN_2FA_CONFIG: LazyLock<Arc<Webauthn>> = LazyLock::new(|| {
@@ -38,8 +38,7 @@ pub static WEBAUTHN_2FA_CONFIG: LazyLock<Arc<Webauthn>> = LazyLock::new(|| {
3838
let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin)
3939
.expect("Creating WebauthnBuilder failed")
4040
.rp_name(&domain)
41-
.timeout(Duration::from_millis(60000))
42-
.danger_set_user_presence_only_security_keys(true);
41+
.timeout(Duration::from_millis(60000));
4342

4443
Arc::new(webauthn.build().expect("Building Webauthn failed"))
4544
});
@@ -78,7 +77,7 @@ pub struct WebauthnRegistration {
7877
pub name: String,
7978
pub migrated: bool,
8079

81-
pub credential: SecurityKey,
80+
pub credential: Passkey,
8281
}
8382

8483
impl WebauthnRegistration {
@@ -89,6 +88,24 @@ impl WebauthnRegistration {
8988
"migrated": self.migrated,
9089
})
9190
}
91+
92+
fn set_backup_eligible(&mut self, backup_eligible: bool, backup_state: bool) -> bool {
93+
let mut changed = false;
94+
let mut cred: Credential = self.credential.clone().into();
95+
96+
if cred.backup_state != backup_state {
97+
cred.backup_state = backup_state;
98+
changed = true;
99+
}
100+
101+
if backup_eligible && !cred.backup_eligible {
102+
cred.backup_eligible = true;
103+
changed = true;
104+
}
105+
106+
self.credential = cred.into();
107+
changed
108+
}
92109
}
93110

94111
#[post("/two-factor/get-webauthn", data = "<data>")]
@@ -131,18 +148,27 @@ async fn generate_webauthn_challenge(
131148
.map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering
132149
.collect();
133150

134-
let (challenge, state) = webauthn.start_securitykey_registration(
151+
let (mut challenge, state) = webauthn.start_passkey_registration(
135152
Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail
136153
&user.email,
137154
&user.name,
138155
Some(registrations),
139-
None,
140-
None,
141156
)?;
142157

158+
let mut state = serde_json::to_value(&state)?;
159+
state["rs"]["policy"] = Value::String("discouraged".to_string());
160+
state["rs"]["extensions"].as_object_mut().unwrap().clear();
161+
143162
let type_ = TwoFactorType::WebauthnRegisterChallenge;
144163
TwoFactor::new(user.uuid.clone(), type_, serde_json::to_string(&state)?).save(&mut conn).await?;
145164

165+
// Because for this flow we abuse the passkeys as 2FA, and use it more like a securitykey
166+
// we need to modify some of the default settings defined by `start_passkey_registration()`.
167+
challenge.public_key.extensions = None;
168+
if let Some(asc) = challenge.public_key.authenticator_selection.as_mut() {
169+
asc.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE;
170+
}
171+
146172
let mut challenge_value = serde_json::to_value(challenge.public_key)?;
147173
challenge_value["status"] = "ok".into();
148174
challenge_value["errorMessage"] = "".into();
@@ -253,15 +279,15 @@ async fn activate_webauthn(
253279
let type_ = TwoFactorType::WebauthnRegisterChallenge as i32;
254280
let state = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await {
255281
Some(tf) => {
256-
let state: SecurityKeyRegistration = serde_json::from_str(&tf.data)?;
282+
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
257283
tf.delete(&mut conn).await?;
258284
state
259285
}
260286
None => err!("Can't recover challenge"),
261287
};
262288

263289
// Verify the credentials with the saved state
264-
let credential = webauthn.finish_securitykey_registration(&data.device_response.into(), &state)?;
290+
let credential = webauthn.finish_passkey_registration(&data.device_response.into(), &state)?;
265291

266292
let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &mut conn).await?.1;
267293
// TODO: Check for repeated ID's
@@ -372,21 +398,25 @@ pub async fn generate_webauthn_login(
372398
conn: &mut DbConn,
373399
) -> JsonResult {
374400
// Load saved credentials
375-
let creds: Vec<_> = get_webauthn_registrations(user_id, conn).await?.1.into_iter().map(|r| r.credential).collect();
401+
let creds: Vec<Passkey> =
402+
get_webauthn_registrations(user_id, conn).await?.1.into_iter().map(|r| r.credential).collect();
376403

377404
if creds.is_empty() {
378405
err!("No Webauthn devices registered")
379406
}
380407

381408
// Generate a challenge based on the credentials
382-
let (mut response, state) = webauthn.start_securitykey_authentication(&creds)?;
409+
let (mut response, state) = webauthn.start_passkey_authentication(&creds)?;
383410

384411
// Modify to discourage user verification
385412
let mut state = serde_json::to_value(&state)?;
413+
state["ast"]["policy"] = Value::String("discouraged".to_string());
386414

387415
// Add appid, this is only needed for U2F compatibility, so maybe it can be removed as well
388416
let app_id = format!("{}/app-id.json", &CONFIG.domain());
389417
state["ast"]["appid"] = Value::String(app_id.clone());
418+
419+
response.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE;
390420
response
391421
.public_key
392422
.extensions
@@ -413,9 +443,9 @@ pub async fn validate_webauthn_login(
413443
conn: &mut DbConn,
414444
) -> EmptyResult {
415445
let type_ = TwoFactorType::WebauthnLoginChallenge as i32;
416-
let state = match TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
446+
let mut state = match TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
417447
Some(tf) => {
418-
let state: SecurityKeyAuthentication = serde_json::from_str(&tf.data)?;
448+
let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?;
419449
tf.delete(conn).await?;
420450
state
421451
}
@@ -432,7 +462,12 @@ pub async fn validate_webauthn_login(
432462

433463
let mut registrations = get_webauthn_registrations(user_id, conn).await?.1;
434464

435-
let authentication_result = webauthn.finish_securitykey_authentication(&rsp, &state)?;
465+
// We need to check for and update the backup_eligible flag when needed.
466+
// Vaultwarden did not have knowledge of this flag prior to migrating to webauthn-rs v0.5.x
467+
// Because of this we check the flag at runtime and update the registrations and state when needed
468+
check_and_update_backup_eligible(user_id, &rsp, &mut registrations, &mut state, conn).await?;
469+
470+
let authentication_result = webauthn.finish_passkey_authentication(&rsp, &state)?;
436471

437472
for reg in &mut registrations {
438473
if ct_eq(reg.credential.cred_id(), authentication_result.cred_id()) {
@@ -454,3 +489,66 @@ pub async fn validate_webauthn_login(
454489
}
455490
)
456491
}
492+
493+
async fn check_and_update_backup_eligible(
494+
user_id: &UserId,
495+
rsp: &PublicKeyCredential,
496+
registrations: &mut Vec<WebauthnRegistration>,
497+
state: &mut PasskeyAuthentication,
498+
conn: &mut DbConn,
499+
) -> EmptyResult {
500+
// The feature flags from the response
501+
// For details see: https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data
502+
const FLAG_BACKUP_ELIGIBLE: u8 = 0b0000_1000;
503+
const FLAG_BACKUP_STATE: u8 = 0b0001_0000;
504+
505+
if let Some(bits) = rsp.response.authenticator_data.get(32) {
506+
let backup_eligible = 0 != (bits & FLAG_BACKUP_ELIGIBLE);
507+
let backup_state = 0 != (bits & FLAG_BACKUP_STATE);
508+
509+
// If the current key is backup eligible, then we probably need to update one of the keys already stored in the database
510+
// 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
511+
// 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
512+
if backup_eligible {
513+
let rsp_id = rsp.raw_id.as_slice();
514+
for reg in &mut *registrations {
515+
if ct_eq(reg.credential.cred_id().as_slice(), rsp_id) {
516+
// Try to update the key, and if needed also update the database, before the actual state check is done
517+
if reg.set_backup_eligible(backup_eligible, backup_state) {
518+
TwoFactor::new(
519+
user_id.clone(),
520+
TwoFactorType::Webauthn,
521+
serde_json::to_string(&registrations)?,
522+
)
523+
.save(conn)
524+
.await?;
525+
526+
// We also need to adjust the current state which holds the challenge used to start the authentication verification
527+
// Because Vaultwarden supports multiple keys, we need to loop through the deserialized state and check which key to update
528+
let mut raw_state = serde_json::to_value(&state)?;
529+
if let Some(credentials) = raw_state
530+
.get_mut("ast")
531+
.and_then(|v| v.get_mut("credentials"))
532+
.and_then(|v| v.as_array_mut())
533+
{
534+
for cred in credentials.iter_mut() {
535+
if cred.get("cred_id").is_some_and(|v| {
536+
// Deserialize to a [u8] so it can be compared using `ct_eq` with the `rsp_id`
537+
let cred_id_slice: Base64UrlSafeData = serde_json::from_value(v.clone()).unwrap();
538+
ct_eq(cred_id_slice, rsp_id)
539+
}) {
540+
cred["backup_eligible"] = Value::Bool(backup_eligible);
541+
cred["backup_state"] = Value::Bool(backup_state);
542+
}
543+
}
544+
}
545+
546+
*state = serde_json::from_value(raw_state)?;
547+
}
548+
break;
549+
}
550+
}
551+
}
552+
}
553+
Ok(())
554+
}

0 commit comments

Comments
 (0)