Browse Source

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.
pull/7297/head
Zaid Marji 3 weeks ago
parent
commit
f728963aaa
  1. 223
      src/api/core/mod.rs
  2. 1
      src/api/core/two_factor/mod.rs
  3. 17
      src/api/identity.rs
  4. 28
      src/db/models/two_factor.rs
  5. 15
      src/db/models/web_authn_credential.rs

223
src/api/core/mod.rs

@ -17,16 +17,17 @@ pub use sends::purge_sends;
use reqwest::Method; use reqwest::Method;
use rocket::{Catcher, Route, http::Status, serde::json::Json, serde::json::Value}; 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 webauthn_rs_proto::UserVerificationPolicy;
use crate::{ use crate::{
CONFIG, CONFIG,
api::{ api::{
ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
core::two_factor::webauthn::{RegisterPublicKeyCredentialCopy, WEBAUTHN}, core::two_factor::webauthn::{PublicKeyCredentialCopy, RegisterPublicKeyCredentialCopy, WEBAUTHN},
}, },
auth::Headers, auth::Headers,
crypto,
db::{ db::{
DbConn, DbConn,
models::{ models::{
@ -37,7 +38,7 @@ use crate::{
error::Error, error::Error,
http_client::make_http_request, http_client::make_http_request,
mail, mail,
util::{FeatureFlagFilter, parse_experimental_client_feature_flags}, util::{FeatureFlagFilter, get_uuid, parse_experimental_client_feature_flags},
}; };
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
@ -50,6 +51,8 @@ pub fn routes() -> Vec<Route> {
config, config,
get_api_webauthn, get_api_webauthn,
post_api_webauthn, post_api_webauthn,
put_api_webauthn,
post_api_webauthn_assertion_options,
post_api_webauthn_attestation_options, post_api_webauthn_attestation_options,
post_api_webauthn_delete post_api_webauthn_delete
]; ];
@ -239,6 +242,42 @@ async fn get_api_webauthn(headers: Headers, conn: DbConn) -> Json<Value> {
})) }))
} }
#[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<PasskeyRegistration> {
if let Ok(saved) = serde_json::from_str::<WebAuthnPasskeyRegistrationChallenge>(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::<PasskeyRegistration>(data)?)
}
}
fn passkey_assertion_challenge_state(data: &str, token: &str) -> ApiResult<PasskeyAuthentication> {
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 = "<data>")] #[post("/webauthn/attestation-options", data = "<data>")]
async fn post_api_webauthn_attestation_options( async fn post_api_webauthn_attestation_options(
data: Json<PasswordOrOtpData>, data: Json<PasswordOrOtpData>,
@ -252,7 +291,7 @@ async fn post_api_webauthn_attestation_options(
err!("Passkeys cannot be created when SSO sign-in is required") 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 all_creds = WebAuthnCredential::find_by_user(&user.uuid, &conn).await;
let existing_cred_ids: Vec<_> = all_creds let existing_cred_ids: Vec<_> = all_creds
@ -289,10 +328,20 @@ async fn post_api_webauthn_attestation_options(
tf.delete(&conn).await?; 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) // Persist the registration state in the database (same pattern as 2FA webauthn)
TwoFactor::new(user.uuid, TwoFactorType::WebauthnPasskeyRegisterChallenge, serde_json::to_string(&state)?) TwoFactor::new(
.save(&conn) user.uuid,
.await?; TwoFactorType::WebauthnPasskeyRegisterChallenge,
serde_json::to_string(&saved_challenge)?,
)
.save(&conn)
.await?;
let mut options = serde_json::to_value(challenge.public_key)?; let mut options = serde_json::to_value(challenge.public_key)?;
options["status"] = "ok".into(); options["status"] = "ok".into();
@ -300,15 +349,78 @@ async fn post_api_webauthn_attestation_options(
Ok(Json(json!({ Ok(Json(json!({
"options": options, "options": options,
"token": token,
"object": "webauthnCredentialCreateOptions" "object": "webauthnCredentialCreateOptions"
}))) })))
} }
#[post("/webauthn/assertion-options", data = "<data>")]
async fn post_api_webauthn_assertion_options(
data: Json<PasswordOrOtpData>,
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<Passkey> = 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)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct WebAuthnLoginCredentialCreateRequest { struct WebAuthnLoginCredentialCreateRequest {
device_response: RegisterPublicKeyCredentialCopy, device_response: RegisterPublicKeyCredentialCopy,
name: String, name: String,
token: Option<String>,
supports_prf: bool, supports_prf: bool,
encrypted_user_key: Option<String>, encrypted_user_key: Option<String>,
encrypted_public_key: Option<String>, encrypted_public_key: Option<String>,
@ -328,13 +440,15 @@ async fn post_api_webauthn(
err!("Passkeys cannot be created when SSO sign-in is required") 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 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.") err!("No registration challenge found. Please try again.")
}; };
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?; let state = passkey_registration_challenge_state(&tf.data, data.token.as_deref())?;
tf.delete(&conn).await?;
let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?; let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?;
WebAuthnCredential::new( WebAuthnCredential::new(
@ -352,6 +466,91 @@ async fn post_api_webauthn(
Ok(Status::Ok) Ok(Status::Ok)
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WebAuthnLoginCredentialUpdateRequest {
device_response: PublicKeyCredentialCopy,
token: String,
encrypted_user_key: Option<String>,
encrypted_public_key: Option<String>,
encrypted_private_key: Option<String>,
}
#[put("/webauthn", data = "<data>")]
async fn put_api_webauthn(
data: Json<WebAuthnLoginCredentialUpdateRequest>,
headers: Headers,
conn: DbConn,
) -> ApiResult<Status> {
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/<uuid>/delete", data = "<data>")] #[post("/webauthn/<uuid>/delete", data = "<data>")]
async fn post_api_webauthn_delete( async fn post_api_webauthn_delete(
data: Json<PasswordOrOtpData>, data: Json<PasswordOrOtpData>,
@ -362,7 +561,7 @@ async fn post_api_webauthn_delete(
let data: PasswordOrOtpData = data.into_inner(); let data: PasswordOrOtpData = data.into_inner();
let user = headers.user; 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) WebAuthnCredential::delete_by_uuid_and_user(&WebAuthnCredentialId::from(uuid.to_owned()), &user.uuid, &conn)
.await?; .await?;

1
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::WebauthnRegisterChallenge
| TwoFactorType::WebauthnLoginChallenge | TwoFactorType::WebauthnLoginChallenge
| TwoFactorType::WebauthnPasskeyRegisterChallenge | TwoFactorType::WebauthnPasskeyRegisterChallenge
| TwoFactorType::WebauthnPasskeyAssertionChallenge
| TwoFactorType::ProtectedActions => false, | TwoFactorType::ProtectedActions => false,
} }
} }

17
src/api/identity.rs

@ -1033,6 +1033,7 @@ async fn json_err_twofactor(
| TwoFactorType::U2fRegisterChallenge | TwoFactorType::U2fRegisterChallenge
| TwoFactorType::Webauthn | TwoFactorType::Webauthn
| TwoFactorType::WebauthnLoginChallenge | TwoFactorType::WebauthnLoginChallenge
| TwoFactorType::WebauthnPasskeyAssertionChallenge
| TwoFactorType::WebauthnPasskeyRegisterChallenge | TwoFactorType::WebauthnPasskeyRegisterChallenge
| TwoFactorType::WebauthnRegisterChallenge, | TwoFactorType::WebauthnRegisterChallenge,
) => { /* Nothing special to do for these providers */ } ) => { /* Nothing special to do for these providers */ }
@ -1352,6 +1353,22 @@ async fn webauthn_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
let mut device = get_device(&data, conn, &user).await?; 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); let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::WebAuthn, data.client_id);
// Build response using the common authenticated_response helper // Build response using the common authenticated_response helper

28
src/db/models/two_factor.rs

@ -43,6 +43,7 @@ pub enum TwoFactorType {
WebauthnRegisterChallenge = 1003, WebauthnRegisterChallenge = 1003,
WebauthnLoginChallenge = 1004, WebauthnLoginChallenge = 1004,
WebauthnPasskeyRegisterChallenge = 1005, WebauthnPasskeyRegisterChallenge = 1005,
WebauthnPasskeyAssertionChallenge = 1006,
// Special type for Protected Actions verification via email // Special type for Protected Actions verification via email
ProtectedActions = 2000, ProtectedActions = 2000,
@ -149,6 +150,33 @@ impl TwoFactor {
.await .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<Self> {
let user_uuid = user_uuid.clone();
conn.run(move |conn| {
conn.transaction::<Option<Self>, diesel::result::Error, _>(|conn| {
let tf = twofactor::table
.filter(twofactor::user_uuid.eq(&user_uuid))
.filter(twofactor::atype.eq(atype))
.first::<Self>(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 { pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| { conn.run(move |conn| {
diesel::delete(twofactor::table.filter(twofactor::user_uuid.eq(user_uuid))) diesel::delete(twofactor::table.filter(twofactor::user_uuid.eq(user_uuid)))

15
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<Self> { pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
db_run! { conn: { db_run! { conn: {
web_authn_credentials::table web_authn_credentials::table

Loading…
Cancel
Save