From f728963aaa146587bb5d9db9efc37c779fea129c Mon Sep 17 00:00:00 2001 From: Zaid Marji Date: Sun, 24 May 2026 14:16:42 +0300 Subject: [PATCH] Tighten passkey credential persistence and enrollment Re-prompt master password or OTP at POST /webauthn, matching the sibling 2FA WebAuthn activation path. Bind registration and PRF-update ceremonies to random challenge tokens, consume saved challenge rows atomically, and add the authenticated assertion-options/update endpoints for completing PRF unlock setup. Persist complete PRF unlock keysets with a dedicated column-scoped update helper. Mirror password-login 2FA policy handling for passkey login: enforce RequireTwoFactor revocation when no providers exist, and reject accounts whose TwoFactor rows are all disabled or unusable. --- src/api/core/mod.rs | 223 ++++++++++++++++++++++++-- src/api/core/two_factor/mod.rs | 1 + src/api/identity.rs | 17 ++ src/db/models/two_factor.rs | 28 ++++ src/db/models/web_authn_credential.rs | 15 ++ 5 files changed, 272 insertions(+), 12 deletions(-) diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index 5aeb00ea..2d4f3306 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -17,16 +17,17 @@ pub use sends::purge_sends; use reqwest::Method; use rocket::{Catcher, Route, http::Status, serde::json::Json, serde::json::Value}; -use webauthn_rs::prelude::{Passkey, PasskeyRegistration}; +use webauthn_rs::prelude::{Passkey, PasskeyAuthentication, PasskeyRegistration}; use webauthn_rs_proto::UserVerificationPolicy; use crate::{ CONFIG, api::{ ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, - core::two_factor::webauthn::{RegisterPublicKeyCredentialCopy, WEBAUTHN}, + core::two_factor::webauthn::{PublicKeyCredentialCopy, RegisterPublicKeyCredentialCopy, WEBAUTHN}, }, auth::Headers, + crypto, db::{ DbConn, models::{ @@ -37,7 +38,7 @@ use crate::{ error::Error, http_client::make_http_request, mail, - util::{FeatureFlagFilter, parse_experimental_client_feature_flags}, + util::{FeatureFlagFilter, get_uuid, parse_experimental_client_feature_flags}, }; pub fn routes() -> Vec { @@ -50,6 +51,8 @@ pub fn routes() -> Vec { config, get_api_webauthn, post_api_webauthn, + put_api_webauthn, + post_api_webauthn_assertion_options, post_api_webauthn_attestation_options, post_api_webauthn_delete ]; @@ -239,6 +242,42 @@ async fn get_api_webauthn(headers: Headers, conn: DbConn) -> Json { })) } +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WebAuthnPasskeyRegistrationChallenge { + token: String, + state: PasskeyRegistration, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WebAuthnPasskeyAssertionChallenge { + token: String, + state: PasskeyAuthentication, +} + +fn passkey_registration_challenge_state(data: &str, token: Option<&str>) -> ApiResult { + if let Ok(saved) = serde_json::from_str::(data) { + if token != Some(saved.token.as_str()) { + err!("Invalid registration challenge. Please try again.") + } + Ok(saved.state) + } else { + if token.is_some() { + err!("Invalid registration challenge. Please try again.") + } + Ok(serde_json::from_str::(data)?) + } +} + +fn passkey_assertion_challenge_state(data: &str, token: &str) -> ApiResult { + let saved: WebAuthnPasskeyAssertionChallenge = serde_json::from_str(data)?; + if token != saved.token.as_str() { + err!("Invalid assertion challenge. Please try again.") + } + Ok(saved.state) +} + #[post("/webauthn/attestation-options", data = "")] async fn post_api_webauthn_attestation_options( data: Json, @@ -252,7 +291,7 @@ async fn post_api_webauthn_attestation_options( err!("Passkeys cannot be created when SSO sign-in is required") } - data.validate(&user, false, &conn).await?; + data.validate(&user, true, &conn).await?; let all_creds = WebAuthnCredential::find_by_user(&user.uuid, &conn).await; let existing_cred_ids: Vec<_> = all_creds @@ -289,10 +328,20 @@ async fn post_api_webauthn_attestation_options( tf.delete(&conn).await?; } + let token = get_uuid(); + let saved_challenge = WebAuthnPasskeyRegistrationChallenge { + token: token.clone(), + state, + }; + // Persist the registration state in the database (same pattern as 2FA webauthn) - TwoFactor::new(user.uuid, TwoFactorType::WebauthnPasskeyRegisterChallenge, serde_json::to_string(&state)?) - .save(&conn) - .await?; + TwoFactor::new( + user.uuid, + TwoFactorType::WebauthnPasskeyRegisterChallenge, + serde_json::to_string(&saved_challenge)?, + ) + .save(&conn) + .await?; let mut options = serde_json::to_value(challenge.public_key)?; options["status"] = "ok".into(); @@ -300,15 +349,78 @@ async fn post_api_webauthn_attestation_options( Ok(Json(json!({ "options": options, + "token": token, "object": "webauthnCredentialCreateOptions" }))) } +#[post("/webauthn/assertion-options", data = "")] +async fn post_api_webauthn_assertion_options( + data: Json, + headers: Headers, + conn: DbConn, +) -> JsonResult { + if !CONFIG.is_webauthn_2fa_supported() { + err!("Configured `DOMAIN` is not compatible with Webauthn") + } + + crate::ratelimit::check_limit_login(&headers.ip.ip)?; + + let data: PasswordOrOtpData = data.into_inner(); + let user = headers.user; + + if CONFIG.sso_enabled() && CONFIG.sso_only() { + err!("Passkeys cannot be updated when SSO sign-in is required") + } + + data.validate(&user, true, &conn).await?; + + let credentials: Vec = WebAuthnCredential::find_by_user(&user.uuid, &conn) + .await + .into_iter() + .filter(|wac| wac.supports_prf) + .filter_map(|wac| serde_json::from_str(&wac.credential).ok()) + .collect(); + + if credentials.is_empty() { + err!("No PRF-capable passkeys registered") + } + + let (response, state) = WEBAUTHN.start_passkey_authentication(&credentials)?; + + if let Some(tf) = + TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::WebauthnPasskeyAssertionChallenge as i32, &conn) + .await + { + tf.delete(&conn).await?; + } + + let token = get_uuid(); + let saved_challenge = WebAuthnPasskeyAssertionChallenge { + token: token.clone(), + state, + }; + TwoFactor::new( + user.uuid, + TwoFactorType::WebauthnPasskeyAssertionChallenge, + serde_json::to_string(&saved_challenge)?, + ) + .save(&conn) + .await?; + + Ok(Json(json!({ + "options": response.public_key, + "token": token, + "object": "webauthnCredentialAssertionOptions" + }))) +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct WebAuthnLoginCredentialCreateRequest { device_response: RegisterPublicKeyCredentialCopy, name: String, + token: Option, supports_prf: bool, encrypted_user_key: Option, encrypted_public_key: Option, @@ -328,13 +440,15 @@ async fn post_api_webauthn( err!("Passkeys cannot be created when SSO sign-in is required") } - // Retrieve and delete the saved challenge state from the database + // Atomically take the saved challenge state (single-use): concurrent + // finishes for the same registration row cannot both succeed and create + // duplicate `web_authn_credentials` entries — only the caller whose DELETE + // removes the row proceeds. let type_ = TwoFactorType::WebauthnPasskeyRegisterChallenge as i32; - let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await else { + let Some(tf) = TwoFactor::take_by_user_and_type(&user.uuid, type_, &conn).await else { err!("No registration challenge found. Please try again.") }; - let state: PasskeyRegistration = serde_json::from_str(&tf.data)?; - tf.delete(&conn).await?; + let state = passkey_registration_challenge_state(&tf.data, data.token.as_deref())?; let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?; WebAuthnCredential::new( @@ -352,6 +466,91 @@ async fn post_api_webauthn( Ok(Status::Ok) } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WebAuthnLoginCredentialUpdateRequest { + device_response: PublicKeyCredentialCopy, + token: String, + encrypted_user_key: Option, + encrypted_public_key: Option, + encrypted_private_key: Option, +} + +#[put("/webauthn", data = "")] +async fn put_api_webauthn( + data: Json, + headers: Headers, + conn: DbConn, +) -> ApiResult { + crate::ratelimit::check_limit_login(&headers.ip.ip)?; + + let data: WebAuthnLoginCredentialUpdateRequest = data.into_inner(); + let user = headers.user; + + if CONFIG.sso_enabled() && CONFIG.sso_only() { + err!("Passkeys cannot be updated when SSO sign-in is required") + } + + let Some(encrypted_user_key) = data.encrypted_user_key else { + err!("Encrypted user key is required") + }; + let Some(encrypted_public_key) = data.encrypted_public_key else { + err!("Encrypted public key is required") + }; + let Some(encrypted_private_key) = data.encrypted_private_key else { + err!("Encrypted private key is required") + }; + + // Atomically take the saved challenge state (single-use): concurrent + // updates for the same assertion row cannot both succeed and apply + // different blob payloads — only the caller whose DELETE removes the row + // proceeds. + let type_ = TwoFactorType::WebauthnPasskeyAssertionChallenge as i32; + let Some(tf) = TwoFactor::take_by_user_and_type(&user.uuid, type_, &conn).await else { + err!("No assertion challenge found. Please try again.") + }; + let state = passkey_assertion_challenge_state(&tf.data, &data.token)?; + + let credential_response = data.device_response.into(); + let mut parsed_credentials: Vec<(WebAuthnCredential, Passkey)> = + WebAuthnCredential::find_by_user(&user.uuid, &conn) + .await + .into_iter() + .filter_map(|wac| { + let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?; + Some((wac, passkey)) + }) + .collect(); + + if parsed_credentials.is_empty() { + err!("No passkeys registered") + } + + let authentication_result = WEBAUTHN.finish_passkey_authentication(&credential_response, &state)?; + let Some((matched_wac, passkey)) = parsed_credentials + .iter_mut() + .find(|(_, passkey)| crypto::ct_eq(passkey.cred_id().as_slice(), authentication_result.cred_id().as_slice())) + else { + err!("Verified credential is not registered") + }; + + if !matched_wac.supports_prf { + err!("Passkey does not support PRF") + } + + if passkey.update_credential(&authentication_result) == Some(true) { + matched_wac.credential = serde_json::to_string(passkey)?; + matched_wac.update_credential(&conn).await?; + } + + matched_wac.encrypted_user_key = Some(encrypted_user_key); + matched_wac.encrypted_public_key = Some(encrypted_public_key); + matched_wac.encrypted_private_key = Some(encrypted_private_key); + matched_wac.update_prf_keyset(&conn).await?; + + Ok(Status::Ok) +} + #[post("/webauthn//delete", data = "")] async fn post_api_webauthn_delete( data: Json, @@ -362,7 +561,7 @@ async fn post_api_webauthn_delete( let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; - data.validate(&user, false, &conn).await?; + data.validate(&user, true, &conn).await?; WebAuthnCredential::delete_by_uuid_and_user(&WebAuthnCredentialId::from(uuid.to_owned()), &user.uuid, &conn) .await?; diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index 58a87d48..2f2e47d3 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -65,6 +65,7 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data | TwoFactorType::WebauthnRegisterChallenge | TwoFactorType::WebauthnLoginChallenge | TwoFactorType::WebauthnPasskeyRegisterChallenge + | TwoFactorType::WebauthnPasskeyAssertionChallenge | TwoFactorType::ProtectedActions => false, } } diff --git a/src/api/identity.rs b/src/api/identity.rs index e99c14b4..c4845e49 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -1033,6 +1033,7 @@ async fn json_err_twofactor( | TwoFactorType::U2fRegisterChallenge | TwoFactorType::Webauthn | TwoFactorType::WebauthnLoginChallenge + | TwoFactorType::WebauthnPasskeyAssertionChallenge | TwoFactorType::WebauthnPasskeyRegisterChallenge | TwoFactorType::WebauthnRegisterChallenge, ) => { /* Nothing special to do for these providers */ } @@ -1352,6 +1353,22 @@ async fn webauthn_login(data: ConnectData, user_id: &mut Option, conn: & let mut device = get_device(&data, conn, &user).await?; + // Mirror the 2FA-state gate that password login applies (twofactor_auth): + // - no providers at all → enforce_2fa_policy (revoke from RequireTwoFactor + // orgs the user no longer satisfies) and let the passkey login proceed. + // - rows exist but every provider is disabled or unusable → reject, same + // message the password path returns. The passkey is the auth, so we + // don't ask for a 2FA token when usable providers exist. + let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await; + if twofactors.is_empty() { + enforce_2fa_policy(&user, &user.uuid, device.atype, &ip.ip, conn).await?; + } else if !twofactors.iter().any(|tf| { + TwoFactorType::from_i32(tf.atype) + .is_some_and(|t| tf.enabled && is_twofactor_provider_usable(&t, Some(&tf.data))) + }) { + err!("No enabled and usable two factor providers are available for this account") + } + let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::WebAuthn, data.client_id); // Build response using the common authenticated_response helper diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index ab47866d..82da4e48 100644 --- a/src/db/models/two_factor.rs +++ b/src/db/models/two_factor.rs @@ -43,6 +43,7 @@ pub enum TwoFactorType { WebauthnRegisterChallenge = 1003, WebauthnLoginChallenge = 1004, WebauthnPasskeyRegisterChallenge = 1005, + WebauthnPasskeyAssertionChallenge = 1006, // Special type for Protected Actions verification via email ProtectedActions = 2000, @@ -149,6 +150,33 @@ impl TwoFactor { .await } + /// Atomically fetch and delete the row for this user+type. Returns Some + /// only when the caller's DELETE actually removed a row, so two concurrent + /// callers (e.g. a double-clicked enrollment finish) cannot both proceed + /// with the same single-use challenge state — the loser sees None. The + /// surrounding transaction rolls back the SELECT+DELETE pair atomically + /// on a DB error, leaving the row intact rather than silently consuming it. + pub async fn take_by_user_and_type(user_uuid: &UserId, atype: i32, conn: &DbConn) -> Option { + let user_uuid = user_uuid.clone(); + conn.run(move |conn| { + conn.transaction::, diesel::result::Error, _>(|conn| { + let tf = twofactor::table + .filter(twofactor::user_uuid.eq(&user_uuid)) + .filter(twofactor::atype.eq(atype)) + .first::(conn) + .optional()?; + let Some(existing) = &tf else { + return Ok(None); + }; + let deleted = + diesel::delete(twofactor::table.filter(twofactor::uuid.eq(&existing.uuid))).execute(conn)?; + Ok(tf.filter(|_| deleted == 1)) + }) + .unwrap_or(None) + }) + .await + } + pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { conn.run(move |conn| { diesel::delete(twofactor::table.filter(twofactor::user_uuid.eq(user_uuid))) diff --git a/src/db/models/web_authn_credential.rs b/src/db/models/web_authn_credential.rs index 01d19d78..795a5207 100644 --- a/src/db/models/web_authn_credential.rs +++ b/src/db/models/web_authn_credential.rs @@ -106,6 +106,21 @@ impl WebAuthnCredential { }} } + /// Persist a complete PRF unlock keyset after a user enables vault + /// encryption for an existing passkey-login credential. + pub async fn update_prf_keyset(&self, conn: &DbConn) -> EmptyResult { + db_run! { conn: { + diesel::update(web_authn_credentials::table.filter(web_authn_credentials::uuid.eq(&self.uuid))) + .set(( + web_authn_credentials::encrypted_user_key.eq(&self.encrypted_user_key), + web_authn_credentials::encrypted_public_key.eq(&self.encrypted_public_key), + web_authn_credentials::encrypted_private_key.eq(&self.encrypted_private_key), + )) + .execute(conn) + .map_res("Error updating web_authn_credential PRF keyset") + }} + } + pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { web_authn_credentials::table