From 7711b02153d01386a9543b19d5141e2f8e99b04a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Roumezin?= <94066559+RaphaelRoumezin@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:27:37 +0200 Subject: [PATCH 1/7] feat: passwordless login Co-authored-by: Sammy ETUR --- .../2026-06-23-120000_add_webauthn/down.sql | 1 + .../2026-06-23-120000_add_webauthn/up.sql | 10 + .../2026-06-23-120000_add_webauthn/down.sql | 1 + .../2026-06-23-120000_add_webauthn/up.sql | 10 + .../2026-06-23-120000_add_webauthn/down.sql | 1 + .../2026-06-23-120000_add_webauthn/up.sql | 10 + src/api/core/mod.rs | 16 +- src/api/core/two_factor/mod.rs | 3 +- src/api/core/two_factor/webauthn.rs | 34 ++- src/api/core/webauthn.rs | 161 +++++++++++++ src/api/identity.rs | 228 +++++++++++++++++- src/auth.rs | 34 ++- src/db/models/mod.rs | 2 + src/db/models/two_factor.rs | 1 + src/db/models/user.rs | 3 +- src/db/models/webauthn_credential.rs | 153 ++++++++++++ src/db/schema.rs | 15 ++ .../templates/scss/vaultwarden.scss.hbs | 15 -- 18 files changed, 639 insertions(+), 59 deletions(-) create mode 100644 migrations/mysql/2026-06-23-120000_add_webauthn/down.sql create mode 100644 migrations/mysql/2026-06-23-120000_add_webauthn/up.sql create mode 100644 migrations/postgresql/2026-06-23-120000_add_webauthn/down.sql create mode 100644 migrations/postgresql/2026-06-23-120000_add_webauthn/up.sql create mode 100644 migrations/sqlite/2026-06-23-120000_add_webauthn/down.sql create mode 100644 migrations/sqlite/2026-06-23-120000_add_webauthn/up.sql create mode 100644 src/api/core/webauthn.rs create mode 100644 src/db/models/webauthn_credential.rs diff --git a/migrations/mysql/2026-06-23-120000_add_webauthn/down.sql b/migrations/mysql/2026-06-23-120000_add_webauthn/down.sql new file mode 100644 index 00000000..bb2d5fab --- /dev/null +++ b/migrations/mysql/2026-06-23-120000_add_webauthn/down.sql @@ -0,0 +1 @@ +DROP TABLE webauthn_credentials; \ No newline at end of file diff --git a/migrations/mysql/2026-06-23-120000_add_webauthn/up.sql b/migrations/mysql/2026-06-23-120000_add_webauthn/up.sql new file mode 100644 index 00000000..353961f8 --- /dev/null +++ b/migrations/mysql/2026-06-23-120000_add_webauthn/up.sql @@ -0,0 +1,10 @@ +CREATE TABLE webauthn_credentials ( + uuid VARCHAR(40) NOT NULL PRIMARY KEY, + user_uuid VARCHAR(40) NOT NULL REFERENCES users(uuid), + name TEXT NOT NULL, + credential TEXT NOT NULL, + supports_prf BOOLEAN NOT NULL DEFAULT 0, + encrypted_user_key TEXT, + encrypted_public_key TEXT, + encrypted_private_key TEXT +); \ No newline at end of file diff --git a/migrations/postgresql/2026-06-23-120000_add_webauthn/down.sql b/migrations/postgresql/2026-06-23-120000_add_webauthn/down.sql new file mode 100644 index 00000000..bb2d5fab --- /dev/null +++ b/migrations/postgresql/2026-06-23-120000_add_webauthn/down.sql @@ -0,0 +1 @@ +DROP TABLE webauthn_credentials; \ No newline at end of file diff --git a/migrations/postgresql/2026-06-23-120000_add_webauthn/up.sql b/migrations/postgresql/2026-06-23-120000_add_webauthn/up.sql new file mode 100644 index 00000000..e59cbec3 --- /dev/null +++ b/migrations/postgresql/2026-06-23-120000_add_webauthn/up.sql @@ -0,0 +1,10 @@ +CREATE TABLE webauthn_credentials ( + uuid VARCHAR(40) NOT NULL PRIMARY KEY, + user_uuid VARCHAR(40) NOT NULL REFERENCES users(uuid), + name TEXT NOT NULL, + credential TEXT NOT NULL, + supports_prf BOOLEAN NOT NULL DEFAULT FALSE, + encrypted_user_key TEXT, + encrypted_public_key TEXT, + encrypted_private_key TEXT +); \ No newline at end of file diff --git a/migrations/sqlite/2026-06-23-120000_add_webauthn/down.sql b/migrations/sqlite/2026-06-23-120000_add_webauthn/down.sql new file mode 100644 index 00000000..bb2d5fab --- /dev/null +++ b/migrations/sqlite/2026-06-23-120000_add_webauthn/down.sql @@ -0,0 +1 @@ +DROP TABLE webauthn_credentials; \ No newline at end of file diff --git a/migrations/sqlite/2026-06-23-120000_add_webauthn/up.sql b/migrations/sqlite/2026-06-23-120000_add_webauthn/up.sql new file mode 100644 index 00000000..d9a35da8 --- /dev/null +++ b/migrations/sqlite/2026-06-23-120000_add_webauthn/up.sql @@ -0,0 +1,10 @@ +CREATE TABLE webauthn_credentials ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT NOT NULL REFERENCES users(uuid), + name TEXT NOT NULL, + credential TEXT NOT NULL, + supports_prf BOOLEAN NOT NULL DEFAULT 0, + encrypted_user_key TEXT, + encrypted_public_key TEXT, + encrypted_private_key TEXT +); \ No newline at end of file diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index 2ea8ab21..0518642a 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -8,6 +8,7 @@ mod folders; mod organizations; mod public; mod sends; +mod webauthn; pub use accounts::purge_auth_requests; pub use ciphers::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciphers}; @@ -35,7 +36,7 @@ use crate::{ pub fn routes() -> Vec { let mut eq_domains_routes = routes![get_settings_domains, post_settings_domains, put_settings_domains]; let mut hibp_routes = routes![hibp_breach]; - let mut meta_routes = routes![alive, now, version, config, get_api_webauthn]; + let mut meta_routes = routes![alive, now, version, config]; let mut routes = Vec::new(); routes.append(&mut accounts::routes()); @@ -47,6 +48,7 @@ pub fn routes() -> Vec { routes.append(&mut two_factor::routes()); routes.append(&mut sends::routes()); routes.append(&mut public::routes()); + routes.append(&mut webauthn::routes()); routes.append(&mut eq_domains_routes); routes.append(&mut hibp_routes); routes.append(&mut meta_routes); @@ -195,18 +197,6 @@ fn version() -> Json<&'static str> { Json(crate::VERSION.unwrap_or_default()) } -#[get("/webauthn")] -fn get_api_webauthn(_headers: Headers) -> Json { - // Prevent a 404 error, which also causes key-rotation issues - // It looks like this is used when login with passkeys is enabled, which Vaultwarden does not (yet) support - // An empty list/data also works fine - Json(json!({ - "object": "list", - "data": [], - "continuationToken": null - })) -} - #[get("/config")] fn config() -> Json { let domain = CONFIG.domain(); diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index 8869d23d..4d60e056 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -64,7 +64,8 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data | TwoFactorType::EmailVerificationChallenge | TwoFactorType::WebauthnRegisterChallenge | TwoFactorType::WebauthnLoginChallenge - | TwoFactorType::ProtectedActions => false, + | TwoFactorType::ProtectedActions + | TwoFactorType::WebauthnPasskeyRegisterChallenge => false, } } diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 07b964e5..51f85026 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -1,8 +1,6 @@ -use std::{str::FromStr, sync::LazyLock, time::Duration}; - use rocket::{Route, serde::json::Json}; use serde_json::Value; -use url::Url; +use std::{str::FromStr, sync::LazyLock}; use uuid::Uuid; use webauthn_rs::{ Webauthn, WebauthnBuilder, @@ -14,6 +12,20 @@ use webauthn_rs_proto::{ RequestAuthenticationExtensions, UserVerificationPolicy, }; +pub static WEBAUTHN: LazyLock = LazyLock::new(|| { + let domain = CONFIG.domain(); + let domain_origin = CONFIG.domain_origin(); + let rp_id = url::Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default(); + let rp_origin = url::Url::parse(&domain_origin).unwrap(); + + let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin) + .expect("Creating WebauthnBuilder failed") + .rp_name(&domain) + .timeout(tokio::time::Duration::from_mins(1)); + + webauthn.build().expect("Building Webauthn failed") +}); + use crate::{ CONFIG, api::{ @@ -30,20 +42,6 @@ use crate::{ util::NumberOrString, }; -static WEBAUTHN: LazyLock = LazyLock::new(|| { - let domain = CONFIG.domain(); - let domain_origin = CONFIG.domain_origin(); - let rp_id = Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default(); - let rp_origin = Url::parse(&domain_origin).unwrap(); - - let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin) - .expect("Creating WebauthnBuilder failed") - .rp_name(&domain) - .timeout(Duration::from_mins(1)); - - webauthn.build().expect("Building Webauthn failed") -}); - pub fn routes() -> Vec { routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,] } @@ -181,7 +179,7 @@ struct EnableWebauthnData { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -struct RegisterPublicKeyCredentialCopy { +pub struct RegisterPublicKeyCredentialCopy { pub id: String, pub raw_id: Base64UrlSafeData, pub response: AuthenticatorAttestationResponseRawCopy, diff --git a/src/api/core/webauthn.rs b/src/api/core/webauthn.rs new file mode 100644 index 00000000..32c7bdad --- /dev/null +++ b/src/api/core/webauthn.rs @@ -0,0 +1,161 @@ +use rocket::{http::Status, serde::json::Json}; +use serde_json::Value; +use webauthn_rs::prelude::{Passkey, PasskeyRegistration}; +use webauthn_rs_proto::UserVerificationPolicy; + +use crate::{ + api::{ + ApiResult, JsonResult, PasswordOrOtpData, + core::two_factor::webauthn::{RegisterPublicKeyCredentialCopy, WEBAUTHN}, + }, + auth::Headers, + db::{ + DbConn, + models::{TwoFactor, TwoFactorType, WebauthnCredential, WebauthnCredentialId}, + }, +}; + +pub fn routes() -> Vec { + routes![get_webauthn, post_webauthn, post_webauthn_attestation_options, post_webauthn_delete] +} + +#[get("/webauthn")] +async fn get_webauthn(headers: Headers, conn: DbConn) -> Json { + let user = headers.user; + + let data: Vec = WebauthnCredential::find_all_by_user(&user.uuid, &conn).await; + let data = data + .into_iter() + .map(|wac| { + json!({ + "id": wac.uuid, + "name": wac.name, + "prfStatus": wac.get_prf_status() as u8, + "encryptedUserKey": wac.encrypted_user_key, + "encryptedPublicKey": wac.encrypted_public_key, + "object": "webauthnCredential", + }) + }) + .collect::(); + + Json(json!({ + "object": "list", + "data": data, + "continuationToken": null + })) +} + +#[post("/webauthn/attestation-options", data = "")] +async fn post_webauthn_attestation_options( + data: Json, + headers: Headers, + conn: DbConn, +) -> JsonResult { + let data: PasswordOrOtpData = data.into_inner(); + let user = headers.user; + + data.validate(&user, false, &conn).await?; + + let all_creds: Vec = WebauthnCredential::find_all_by_user(&user.uuid, &conn).await; + let existing_cred_ids: Vec<_> = all_creds + .into_iter() + .filter_map(|wac| { + let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?; + Some(passkey.cred_id().to_owned()) + }) + .collect(); + + let user_uuid = uuid::Uuid::parse_str(&user.uuid).expect("Failed to parse user UUID"); + + let (mut challenge, state) = + WEBAUTHN.start_passkey_registration(user_uuid, &user.email, user.display_name(), Some(existing_cred_ids))?; + + // For passkey login, we need discoverable credentials (resident keys) + // and require user verification. + // start_passkey_registration() defaults to require_resident_key=false, but passkey login + // requires the credential to be discoverable (resident) so the authenticator can find it + // without the server providing allowCredentials. + if let Some(asc) = challenge.public_key.authenticator_selection.as_mut() { + asc.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE; + asc.require_resident_key = true; + asc.resident_key = Some(webauthn_rs_proto::ResidentKeyRequirement::Required); + } + + // 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?; + + let mut options = serde_json::to_value(challenge.public_key)?; + options["status"] = "ok".into(); + options["errorMessage"] = "".into(); + + Ok(Json(json!({ + "options": options, + "object": "webauthnCredentialCreateOptions" + }))) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WebAuthnLoginCredentialCreateRequest { + device_response: RegisterPublicKeyCredentialCopy, + name: String, + supports_prf: bool, + encrypted_user_key: Option, + encrypted_public_key: Option, + encrypted_private_key: Option, +} + +#[post("/webauthn", data = "")] +async fn post_webauthn( + data: Json, + headers: Headers, + conn: DbConn, +) -> ApiResult { + let data: WebAuthnLoginCredentialCreateRequest = data.into_inner(); + let user = headers.user; + + // Retrieve and delete the saved challenge state from the database + let type_ = TwoFactorType::WebauthnPasskeyRegisterChallenge as i32; + let credential = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { + Some(tf) => { + let state: PasskeyRegistration = serde_json::from_str(&tf.data)?; + tf.delete(&conn).await?; + WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)? + } + None => err!("No registration challenge found. Please try again."), + }; + + WebauthnCredential::new( + user.uuid, + data.name, + serde_json::to_string(&credential)?, + data.supports_prf, + data.encrypted_user_key, + data.encrypted_public_key, + data.encrypted_private_key, + ) + .save(&conn) + .await?; + + Ok(Status::Ok) +} + +#[post("/webauthn//delete", data = "")] +async fn post_webauthn_delete( + data: Json, + uuid: &str, + headers: Headers, + conn: DbConn, +) -> ApiResult { + let data: PasswordOrOtpData = data.into_inner(); + let user = headers.user; + + data.validate(&user, false, &conn).await?; + + WebauthnCredential::delete_by_uuid_and_user(&WebauthnCredentialId::from(uuid.to_string()), &user.uuid, &conn) + .await?; + + Ok(Status::Ok) +} diff --git a/src/api/identity.rs b/src/api/identity.rs index 3962827d..0d8c605b 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -8,6 +8,8 @@ use rocket::{ serde::json::Json, }; use serde_json::Value; +use webauthn_rs::prelude::{Passkey, PasskeyAuthentication}; +use webauthn_rs_proto::{PublicKeyCredential, RequestAuthenticationExtensions, UserVerificationPolicy}; use crate::{ CONFIG, @@ -17,27 +19,30 @@ use crate::{ accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register}, log_user_event, two_factor::{ - authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, + authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, + webauthn::{self, WEBAUTHN}, yubikey, }, }, master_password_policy, push::register_push_device, }, - auth, - auth::{AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure, generate_organization_api_key_login_claims}, + auth::{ + self, AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure, generate_organization_api_key_login_claims, + generate_passwordless_claims, + }, crypto, db::{ DbConn, models::{ AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError, OrganizationApiKey, OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, - UserId, + UserId, WebauthnCredential, }, }, error::MapResult, - mail, sso, - sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState}, + mail, + sso::{self, OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState}, util, }; @@ -52,7 +57,8 @@ pub fn routes() -> Vec { prevalidate, authorize, oidcsignin, - oidcsignin_error + oidcsignin_error, + get_webauthn_assertion_options ] } @@ -108,6 +114,19 @@ async fn login( sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await } "authorization_code" => err!("SSO sign-in is not available"), + "webauthn" => { + check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; + check_is_some(data.scope.as_ref(), "scope cannot be blank")?; + + check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?; + check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; + check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; + + check_is_some(data.device_response.as_ref(), "device_response cannot be blank")?; + check_is_some(data.token.as_ref(), "token cannot be blank")?; + + webauthn_login(data, &mut user_id, &conn, &client_header.ip).await + } t => err!("Invalid type", t), }; @@ -467,6 +486,164 @@ async fn password_login( authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await } +async fn webauthn_login(data: ConnectData, user_id: &mut Option, conn: &DbConn, ip: &ClientIp) -> JsonResult { + // Validate scope + AuthMethod::Webauthn.check_scope(data.scope.as_ref())?; + + // Ratelimit the login + crate::ratelimit::check_limit_login(&ip.ip)?; + + let device_response: PublicKeyCredential = serde_json::from_str(data.device_response.as_ref().unwrap())?; + + let user = if let Some(ref uuid_bytes) = device_response.response.user_handle { + // The user_handle contains the raw UUID bytes (16 bytes) set during passkey registration. + // We need to reconstruct the UUID string from these bytes. + let bytes: &[u8] = uuid_bytes.as_ref(); + let uuid_str = uuid::Uuid::from_slice(bytes) + .map(|u| u.to_string()) + .or_else(|_| { + // Fallback: try interpreting as UTF-8 string (for compatibility) + String::from_utf8(bytes.to_vec()) + }) + .map_err(|_| crate::error::Error::new("Invalid user handle encoding", ""))?; + let uuid = UserId::from(uuid_str); + User::find_by_uuid(&uuid, conn).await + } else { + None + }; + + let Some(user) = user else { + err!( + "Passkey authentication failed. User not found", + format!("IP: {}. Could not find user from device response.", ip.ip), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + }; + + // Retrieve the username to be used for logging + let username = user.email.clone(); + + // Set the user_id here to be passed back used for event logging. + *user_id = Some(user.uuid.clone()); + + // Check if the user is disabled + if !user.enabled { + err!( + "This user has been disabled", + format!("IP: {}. Username: {username}.", ip.ip), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + + // Retrieve all webauthn login credentials for this user + let user_webauthn_credentials: Vec<(WebauthnCredential, Passkey)> = + WebauthnCredential::find_all_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 user_webauthn_credentials.is_empty() { + err!( + "No passkey credentials registered for this user.", + format!("IP: {}. Username: {username}.", ip.ip), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + + // Unpack the token claims, which holds authentication state + let claims = match auth::decode_passwordless(data.token.as_ref().unwrap()) { + Ok(claims) => claims, + Err(e) => { + err!( + "Invalid token", + format!("IP: {}. Error: {e:#?}", ip.ip), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + }; + + // HACK: Inject the credentials into the state, since they were not included at creation time. + let state: PasskeyAuthentication = if let Ok(mut raw_state) = serde_json::to_value(&claims.state) { + if let Some(credentials) = + raw_state.get_mut("ast").and_then(|v| v.get_mut("credentials")).and_then(|v| v.as_array_mut()) + { + credentials.clear(); + for (_, passkey) in user_webauthn_credentials.iter() { + let passkey_owned: Passkey = passkey.clone(); + let cred = ::from(passkey_owned); + credentials.push(serde_json::to_value(&cred)?); + } + }; + serde_json::from_value(raw_state)? + } else { + err!( + "Invalid state in token", + format!("IP: {}. Could not parse state from token.", ip.ip), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + }; + + // Perform passkey authentication + let authentication_result = match WEBAUTHN.finish_passkey_authentication(&device_response, &state) { + Ok(result) => result, + Err(e) => { + err!( + "Passkey authentication failed.", + format!("IP: {}. Username: {username}. WebAuthn error: {e:?}", ip.ip), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + }; + + // Retrieve the matched credential based on the passkey from the authentication result + let (matched_wac, _) = user_webauthn_credentials + .iter() + .find(|(_, p): &&(WebauthnCredential, Passkey)| { + crypto::ct_eq(p.cred_id().as_slice(), authentication_result.cred_id().as_slice()) + }) + .unwrap(); + + // Update the credential in the database if necessary (e.g., counter incremented) + let mut passkey: Passkey = serde_json::from_str(&matched_wac.credential)?; + if passkey.update_credential(&authentication_result) == Some(true) { + WebauthnCredential::update_credential_by_uuid(&matched_wac.uuid, serde_json::to_string(&passkey)?, conn) + .await?; + } + + let mut device = get_device(&data, conn, &user).await?; + + let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Webauthn, data.client_id); + + let mut result = authenticated_response(&user, &mut device, auth_tokens, None, conn, ip).await?; + + // Add WebAuthnPrfOption if the credential has encrypted keys (PRF-based decryption) + if matched_wac.encrypted_private_key.is_some() && matched_wac.encrypted_user_key.is_some() { + let Json(ref mut val) = result; + val["UserDecryptionOptions"]["WebAuthnPrfOption"] = json!({ + "EncryptedPrivateKey": matched_wac.encrypted_private_key, + "EncryptedUserKey": matched_wac.encrypted_user_key, + }); + } + + Ok(result) +} + async fn authenticated_response( user: &User, device: &mut Device, @@ -1001,7 +1178,8 @@ async fn json_err_twofactor( | TwoFactorType::U2fRegisterChallenge | TwoFactorType::Webauthn | TwoFactorType::WebauthnLoginChallenge - | TwoFactorType::WebauthnRegisterChallenge, + | TwoFactorType::WebauthnRegisterChallenge + | TwoFactorType::WebauthnPasskeyRegisterChallenge, ) => { /* Nothing special to do for these providers */ } } } @@ -1091,7 +1269,7 @@ async fn register_finish(data: Json, conn: DbConn) -> JsonResult { struct ConnectData { #[field(name = uncased("grant_type"))] #[field(name = uncased("granttype"))] - grant_type: String, // refresh_token, password, client_credentials (API key) + grant_type: String, // refresh_token, password, client_credentials (API key), webauthn // Needed for grant_type="refresh_token" #[field(name = uncased("refresh_token"))] @@ -1144,6 +1322,12 @@ struct ConnectData { code: Option, #[field(name = uncased("code_verifier"))] code_verifier: Option, + + // Needed for grant_type="webauthn" + #[field(name = uncased("deviceresponse"))] + device_response: Option, + #[field(name = uncased("token"))] + token: Option, } fn check_is_some(value: Option<&T>, msg: &str) -> EmptyResult { if value.is_none() { @@ -1303,3 +1487,29 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure, Ok(Redirect::temporary(String::from(auth_url))) } + +#[get("/accounts/webauthn/assertion-options")] +fn get_webauthn_assertion_options() -> JsonResult { + let (mut response, state) = WEBAUTHN.start_passkey_authentication(&[])?; + + // Allow any credential (discoverable) and require user verification + response.public_key.allow_credentials = vec![]; + response.public_key.user_verification = UserVerificationPolicy::Required; + response.public_key.extensions = Some(RequestAuthenticationExtensions { + appid: None, + uvm: None, + hmac_get_secret: None, + }); + + // Generate JWT token + let claims = generate_passwordless_claims(state); + let token = auth::encode_jwt(&claims); + + let options = serde_json::to_value(response.public_key)?; + + Ok(Json(json!({ + "options": options, + "token": token, + "object": "webAuthnLoginAssertionOptions" + }))) +} diff --git a/src/auth.rs b/src/auth.rs index 2ad95036..be8d1083 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -14,6 +14,7 @@ use rocket::{ outcome::try_outcome, request::{FromRequest, Outcome, Request}, }; +use webauthn_rs::prelude::PasskeyAuthentication; use crate::{ CONFIG, @@ -56,6 +57,7 @@ static JWT_FILE_DOWNLOAD_ISSUER: LazyLock = static JWT_REGISTER_VERIFY_ISSUER: LazyLock = LazyLock::new(|| format!("{}|register_verify", CONFIG.domain_origin())); static JWT_2FA_REMEMBER_ISSUER: LazyLock = LazyLock::new(|| format!("{}|2faremember", CONFIG.domain_origin())); +static JWT_PASSWORDLESS_ISSUER: LazyLock = LazyLock::new(|| format!("{}|passwordless", CONFIG.domain_origin())); static PRIVATE_RSA_KEY: OnceLock = OnceLock::new(); static PUBLIC_RSA_KEY: OnceLock = OnceLock::new(); @@ -170,6 +172,10 @@ pub fn decode_2fa_remember(token: &str) -> Result Result { + decode_jwt(token, JWT_PASSWORDLESS_ISSUER.to_string()) +} + #[derive(Debug, Serialize, Deserialize)] pub struct LoginJwtClaims { // Not before @@ -328,6 +334,29 @@ pub fn generate_invite_claims( } } +#[derive(Debug, Serialize, Deserialize)] +pub struct PasswordlessJwtClaims { + // Not before + pub nbf: i64, + // Expiration time + pub exp: i64, + // Issuer + pub iss: String, + + pub state: PasskeyAuthentication, +} + +pub fn generate_passwordless_claims(state: PasskeyAuthentication) -> PasswordlessJwtClaims { + let time_now = Utc::now(); + let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); + PasswordlessJwtClaims { + nbf: time_now.timestamp(), + exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), + iss: JWT_PASSWORDLESS_ISSUER.to_string(), + state, + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct EmergencyAccessInviteJwtClaims { // Not before @@ -1147,6 +1176,7 @@ pub enum AuthMethod { Password, Sso, UserApiKey, + Webauthn, } impl AuthMethod { @@ -1154,7 +1184,7 @@ impl AuthMethod { match self { AuthMethod::OrgApiKey => "api.organization".to_owned(), AuthMethod::UserApiKey => "api".to_owned(), - AuthMethod::Password | AuthMethod::Sso => "api offline_access".to_owned(), + AuthMethod::Password | AuthMethod::Sso | AuthMethod::Webauthn => "api offline_access".to_owned(), } } @@ -1292,7 +1322,7 @@ pub async fn refresh_tokens( } AuthMethod::Sso => err!("SSO is now disabled, Login again using email and master password"), AuthMethod::Password if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO is now required, Login again"), - AuthMethod::Password => AuthTokens::new(&device, &user, refresh_claims.sub, client_id), + AuthMethod::Password | AuthMethod::Webauthn => AuthTokens::new(&device, &user, refresh_claims.sub, client_id), _ => err!("Invalid auth method, cannot refresh token"), }; diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 1cacbcac..aa5d2a41 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -17,6 +17,7 @@ mod two_factor; mod two_factor_duo_context; mod two_factor_incomplete; mod user; +mod webauthn_credential; pub use self::archive::Archive; pub use self::attachment::{Attachment, AttachmentId}; @@ -43,3 +44,4 @@ pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_incomplete::TwoFactorIncomplete; pub use self::user::{Invitation, SsoUser, User, UserId, UserKdfType, UserStampException}; +pub use self::webauthn_credential::{WebauthnCredential, WebauthnCredentialId}; diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index 5f57635e..ab47866d 100644 --- a/src/db/models/two_factor.rs +++ b/src/db/models/two_factor.rs @@ -42,6 +42,7 @@ pub enum TwoFactorType { EmailVerificationChallenge = 1002, WebauthnRegisterChallenge = 1003, WebauthnLoginChallenge = 1004, + WebauthnPasskeyRegisterChallenge = 1005, // Special type for Protected Actions verification via email ProtectedActions = 2000, diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 24bee751..34b77ca8 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -9,7 +9,7 @@ use crate::{ crypto, db::{ DbConn, - models::DeviceId, + models::{DeviceId, WebauthnCredential}, schema::{invitations, sso_users, twofactor_incomplete, users}, }, error::MapResult, @@ -341,6 +341,7 @@ impl User { TwoFactor::delete_all_by_user(&self.uuid, conn).await?; TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?; Invitation::take(&self.email, conn).await; // Delete invitation if any + WebauthnCredential::delete_all_by_user(&self.uuid, conn).await?; conn.run(move |conn| { diesel::delete(users::table.filter(users::uuid.eq(self.uuid))).execute(conn).map_res("Error deleting user") diff --git a/src/db/models/webauthn_credential.rs b/src/db/models/webauthn_credential.rs new file mode 100644 index 00000000..fb3e9e90 --- /dev/null +++ b/src/db/models/webauthn_credential.rs @@ -0,0 +1,153 @@ +use derive_more::{AsRef, Deref, Display, From}; +use diesel::prelude::*; +use macros::UuidFromParam; + +use crate::api::EmptyResult; +use crate::db::DbConn; +use crate::db::schema::webauthn_credentials; +use crate::error::MapResult; + +use super::UserId; + +#[derive(num_derive::FromPrimitive, Serialize)] +pub enum WebauthnCredentialPrfStatus { + Enabled = 0, + Disabled = 1, + NotSupported = 2, +} + +#[derive(Debug, Identifiable, Queryable, Insertable, AsChangeset)] +#[diesel(table_name = webauthn_credentials)] +#[diesel(treat_none_as_null = true)] +#[diesel(primary_key(uuid))] +pub struct WebauthnCredential { + pub uuid: WebauthnCredentialId, + pub user_uuid: UserId, + pub name: String, + pub credential: String, + pub supports_prf: bool, + pub encrypted_user_key: Option, + pub encrypted_public_key: Option, + pub encrypted_private_key: Option, +} + +/// Local methods +impl WebauthnCredential { + pub fn new( + user_uuid: UserId, + name: String, + credential: String, + supports_prf: bool, + encrypted_user_key: Option, + encrypted_public_key: Option, + encrypted_private_key: Option, + ) -> Self { + Self { + uuid: WebauthnCredentialId(crate::util::get_uuid()), + user_uuid, + name, + credential, + supports_prf, + encrypted_user_key, + encrypted_public_key, + encrypted_private_key, + } + } + + pub fn get_prf_status(&self) -> WebauthnCredentialPrfStatus { + if self.supports_prf { + if self.encrypted_user_key.is_some() + && self.encrypted_public_key.is_some() + && self.encrypted_private_key.is_some() + { + WebauthnCredentialPrfStatus::Enabled + } else { + WebauthnCredentialPrfStatus::Disabled + } + } else { + WebauthnCredentialPrfStatus::NotSupported + } + } +} + +/// Database methods +impl WebauthnCredential { + pub async fn save(&self, conn: &DbConn) -> EmptyResult { + db_run! { conn: { + diesel::insert_into(webauthn_credentials::table) + .values(self) + .execute(conn) + .map_res("Error saving webauthn_credential") + }} + } + + pub async fn find_all_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { + db_run! { conn: { + webauthn_credentials::table + .filter(webauthn_credentials::user_uuid.eq(user_uuid)) + .load::(conn) + .unwrap_or_default() + }} + } + + pub async fn delete_by_uuid_and_user( + uuid: &WebauthnCredentialId, + user_uuid: &UserId, + conn: &DbConn, + ) -> EmptyResult { + db_run! { conn: { + diesel::delete( + webauthn_credentials::table + .filter(webauthn_credentials::uuid.eq(uuid)) + .filter(webauthn_credentials::user_uuid.eq(user_uuid)), + ) + .execute(conn) + .map_res("Error removing webauthn_credential") + }} + } + + pub async fn update_credential_by_uuid( + uuid: &WebauthnCredentialId, + credential: String, + conn: &DbConn, + ) -> EmptyResult { + db_run! { conn: { + diesel::update( + webauthn_credentials::table + .filter(webauthn_credentials::uuid.eq(uuid)), + ) + .set(webauthn_credentials::credential.eq(credential)) + .execute(conn) + .map_res("Error updating credential for webauthn_credential") + }} + } + + pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { + db_run! { conn: { + diesel::delete( + webauthn_credentials::table + .filter(webauthn_credentials::user_uuid.eq(user_uuid)), + ) + .execute(conn) + .map_res("Error deleting all webauthn_credentials for user") + }} + } +} + +#[derive( + Clone, + Debug, + AsRef, + Deref, + DieselNewType, + Display, + From, + FromForm, + Hash, + PartialEq, + Eq, + Serialize, + Deserialize, + UuidFromParam, +)] +pub struct WebauthnCredentialId(String); diff --git a/src/db/schema.rs b/src/db/schema.rs index af342186..fba770c7 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -351,6 +351,19 @@ table! { } } +table! { + webauthn_credentials (uuid) { + uuid -> Text, + user_uuid -> Text, + name -> Text, + credential -> Text, + supports_prf -> Bool, + encrypted_user_key -> Nullable, + encrypted_public_key -> Nullable, + encrypted_private_key -> Nullable, + } +} + joinable!(archives -> users (user_uuid)); joinable!(archives -> ciphers (cipher_uuid)); joinable!(attachments -> ciphers (cipher_uuid)); @@ -382,6 +395,7 @@ joinable!(collections_groups -> groups (groups_uuid)); joinable!(event -> users_organizations (uuid)); joinable!(auth_requests -> users (user_uuid)); joinable!(sso_users -> users (user_uuid)); +joinable!(webauthn_credentials -> users (user_uuid)); allow_tables_to_appear_in_same_query!( archives, @@ -408,4 +422,5 @@ allow_tables_to_appear_in_same_query!( collections_groups, event, auth_requests, + webauthn_credentials, ); diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index 477cdd34..8b35c549 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -54,21 +54,6 @@ app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secon } {{/if}} -/* Hide the `Log in with passkey` settings */ -app-user-layout app-password-settings app-webauthn-login-settings { - @extend %vw-hide; -} -/* Hide Log in with passkey on the login page */ -{{#if (webver ">=2025.5.1")}} -.vw-passkey-login { - @extend %vw-hide; -} -{{else}} -app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secondary"].\!tw-text-primary-600:nth-child(3) { - @extend %vw-hide; -} -{{/if}} - /* Hide the or text followed by the two buttons hidden above */ {{#if (webver ">=2025.5.1")}} {{#if (or (not sso_enabled) sso_only)}} From 007ac4f99234d11609e7027daf3181e447e88bb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Roumezin?= <94066559+RaphaelRoumezin@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:01:02 +0200 Subject: [PATCH 2/7] fix: Correct attestation options --- src/api/core/webauthn.rs | 58 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/src/api/core/webauthn.rs b/src/api/core/webauthn.rs index 32c7bdad..c1c8be37 100644 --- a/src/api/core/webauthn.rs +++ b/src/api/core/webauthn.rs @@ -1,7 +1,7 @@ use rocket::{http::Status, serde::json::Json}; use serde_json::Value; use webauthn_rs::prelude::{Passkey, PasskeyRegistration}; -use webauthn_rs_proto::UserVerificationPolicy; +use webauthn_rs_proto::{COSEAlgorithm, UserVerificationPolicy, options::PubKeyCredParams}; use crate::{ api::{ @@ -70,8 +70,7 @@ async fn post_webauthn_attestation_options( let (mut challenge, state) = WEBAUTHN.start_passkey_registration(user_uuid, &user.email, user.display_name(), Some(existing_cred_ids))?; - // For passkey login, we need discoverable credentials (resident keys) - // and require user verification. + // For passkey login, we need discoverable credentials (resident keys). // start_passkey_registration() defaults to require_resident_key=false, but passkey login // requires the credential to be discoverable (resident) so the authenticator can find it // without the server providing allowCredentials. @@ -81,6 +80,59 @@ async fn post_webauthn_attestation_options( asc.resident_key = Some(webauthn_rs_proto::ResidentKeyRequirement::Required); } + // Safely clear the extensions field to match Bitwarden behavior + if let Some(exts) = challenge.public_key.extensions.as_mut() { + exts.cred_props = None; + exts.uvm = None; + exts.cred_protect = None; + exts.hmac_create_secret = None; + exts.min_pin_length = None; + } + + // Set algorithms list to {ES,RS,PS}{256,384,512},EDDSA to match Bitwarden behavior + challenge.public_key.pub_key_cred_params = vec![ + PubKeyCredParams { + alg: COSEAlgorithm::ES256 as i64, + type_: "public-key".to_string(), + }, + PubKeyCredParams { + alg: COSEAlgorithm::ES384 as i64, + type_: "public-key".to_string(), + }, + PubKeyCredParams { + alg: COSEAlgorithm::ES512 as i64, + type_: "public-key".to_string(), + }, + PubKeyCredParams { + alg: COSEAlgorithm::RS256 as i64, + type_: "public-key".to_string(), + }, + PubKeyCredParams { + alg: COSEAlgorithm::RS384 as i64, + type_: "public-key".to_string(), + }, + PubKeyCredParams { + alg: COSEAlgorithm::RS512 as i64, + type_: "public-key".to_string(), + }, + PubKeyCredParams { + alg: COSEAlgorithm::PS256 as i64, + type_: "public-key".to_string(), + }, + PubKeyCredParams { + alg: COSEAlgorithm::PS384 as i64, + type_: "public-key".to_string(), + }, + PubKeyCredParams { + alg: COSEAlgorithm::PS512 as i64, + type_: "public-key".to_string(), + }, + PubKeyCredParams { + alg: COSEAlgorithm::EDDSA as i64, + type_: "public-key".to_string(), + }, + ]; + // 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) From 8cba57a1afb82e0652c0dfe8835ce2b609470bab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Roumezin?= <94066559+RaphaelRoumezin@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:44:53 +0200 Subject: [PATCH 3/7] feat(config): Add passkey_login_allowed config option --- .env.template | 3 ++ src/api/core/webauthn.rs | 28 +++++++++++++++++-- src/api/identity.rs | 7 ++++- src/api/web.rs | 1 + src/config.rs | 3 ++ .../templates/scss/vaultwarden.scss.hbs | 17 +++++++++++ 6 files changed, 55 insertions(+), 4 deletions(-) diff --git a/.env.template b/.env.template index a12559ad..008fa09d 100644 --- a/.env.template +++ b/.env.template @@ -316,6 +316,9 @@ ## unauthenticated access to potentially sensitive data. # SHOW_PASSWORD_HINT=false +## Controls whether users can setup and use passkeys to log in, which also bypasses 2FA. This setting applies globally to all users. +# PASSKEY_LOGIN_ALLOWED=true + ######################### ### Advanced settings ### ######################### diff --git a/src/api/core/webauthn.rs b/src/api/core/webauthn.rs index c1c8be37..a8a7847e 100644 --- a/src/api/core/webauthn.rs +++ b/src/api/core/webauthn.rs @@ -4,6 +4,7 @@ use webauthn_rs::prelude::{Passkey, PasskeyRegistration}; use webauthn_rs_proto::{COSEAlgorithm, UserVerificationPolicy, options::PubKeyCredParams}; use crate::{ + CONFIG, api::{ ApiResult, JsonResult, PasswordOrOtpData, core::two_factor::webauthn::{RegisterPublicKeyCredentialCopy, WEBAUTHN}, @@ -20,7 +21,16 @@ pub fn routes() -> Vec { } #[get("/webauthn")] -async fn get_webauthn(headers: Headers, conn: DbConn) -> Json { +async fn get_webauthn(headers: Headers, conn: DbConn) -> JsonResult { + if !CONFIG.passkey_login_allowed() { + // The web vault will query this endpoint weather passkey login is allowed, so we should return an empty list instead of an error. + return Ok(Json(json!({ + "object": "list", + "data": [], + "continuationToken": null + }))) + } + let user = headers.user; let data: Vec = WebauthnCredential::find_all_by_user(&user.uuid, &conn).await; @@ -38,11 +48,11 @@ async fn get_webauthn(headers: Headers, conn: DbConn) -> Json { }) .collect::(); - Json(json!({ + Ok(Json(json!({ "object": "list", "data": data, "continuationToken": null - })) + }))) } #[post("/webauthn/attestation-options", data = "")] @@ -51,6 +61,10 @@ async fn post_webauthn_attestation_options( headers: Headers, conn: DbConn, ) -> JsonResult { + if !CONFIG.passkey_login_allowed() { + err!("Passkey login is not allowed") + } + let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; @@ -165,6 +179,10 @@ async fn post_webauthn( headers: Headers, conn: DbConn, ) -> ApiResult { + if !CONFIG.passkey_login_allowed() { + err!("Passkey login is not allowed") + } + let data: WebAuthnLoginCredentialCreateRequest = data.into_inner(); let user = headers.user; @@ -201,6 +219,10 @@ async fn post_webauthn_delete( headers: Headers, conn: DbConn, ) -> ApiResult { + if !CONFIG.passkey_login_allowed() { + err!("Passkey login is not allowed") + } + let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; diff --git a/src/api/identity.rs b/src/api/identity.rs index 0d8c605b..3f7cc0cd 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -114,7 +114,7 @@ async fn login( sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await } "authorization_code" => err!("SSO sign-in is not available"), - "webauthn" => { + "webauthn" if CONFIG.passkey_login_allowed() => { check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; check_is_some(data.scope.as_ref(), "scope cannot be blank")?; @@ -127,6 +127,7 @@ async fn login( webauthn_login(data, &mut user_id, &conn, &client_header.ip).await } + "webauthn" => err!("Passkey login is not allowed"), t => err!("Invalid type", t), }; @@ -1490,6 +1491,10 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure, #[get("/accounts/webauthn/assertion-options")] fn get_webauthn_assertion_options() -> JsonResult { + if !CONFIG.passkey_login_allowed() { + err!("Passkey login is not allowed") + } + let (mut response, state) = WEBAUTHN.start_passkey_authentication(&[])?; // Allow any credential (discoverable) and require user verification diff --git a/src/api/web.rs b/src/api/web.rs index 5bd4c85d..2050ada1 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -78,6 +78,7 @@ fn vaultwarden_css() -> Cached> { "sso_only": CONFIG.sso_enabled() && CONFIG.sso_only(), "webauthn_2fa_supported": CONFIG.is_webauthn_2fa_supported(), "yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(), + "passkey_login_allowed": CONFIG.passkey_login_allowed(), }); let scss = match CONFIG.render_template("scss/vaultwarden.scss", &css_options) { diff --git a/src/config.rs b/src/config.rs index 3656d0d9..3bd57e97 100644 --- a/src/config.rs +++ b/src/config.rs @@ -649,6 +649,9 @@ make_config! { /// because this provides unauthenticated access to potentially sensitive data. show_password_hint: bool, true, def, false; + /// Allow passkey login |> Controls whether users can setup and use passkeys to log in, which also bypasses 2FA. This setting applies globally to all users. + passkey_login_allowed: bool, true, def, true; + /// Admin token/Argon2 PHC |> The plain text token or Argon2 PHC string used to authenticate in this very same page. Changing it here will not deauthorize the current session! admin_token: Pass, true, option; diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index 8b35c549..f9b84d80 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -54,6 +54,23 @@ app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secon } {{/if}} +{{#if (not passkey_login_allowed)}} +/* Hide the `Log in with passkey` settings */ +app-user-layout app-password-settings app-webauthn-login-settings { + @extend %vw-hide; +} +/* Hide Log in with passkey on the login page */ +{{#if (webver ">=2025.5.1")}} +.vw-passkey-login { + @extend %vw-hide; +} +{{else}} +app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secondary"].\!tw-text-primary-600:nth-child(3) { + @extend %vw-hide; +} +{{/if}} +{{/if}} + /* Hide the or text followed by the two buttons hidden above */ {{#if (webver ">=2025.5.1")}} {{#if (or (not sso_enabled) sso_only)}} From 224ad8a4065d62abba69a33da2056d540db96385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Roumezin?= <94066559+RaphaelRoumezin@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:00:48 +0200 Subject: [PATCH 4/7] fix: typos --- src/api/core/two_factor/webauthn.rs | 31 ++++++++++++++--------------- src/api/core/webauthn.rs | 4 ++-- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 51f85026..ba1a25fe 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -1,3 +1,18 @@ +use crate::{ + CONFIG, + api::{ + EmptyResult, JsonResult, PasswordOrOtpData, + core::{log_user_event, two_factor::generate_recover_code}, + }, + auth::Headers, + crypto::ct_eq, + db::{ + DbConn, + models::{EventType, TwoFactor, TwoFactorType, UserId}, + }, + error::Error, + util::NumberOrString, +}; use rocket::{Route, serde::json::Json}; use serde_json::Value; use std::{str::FromStr, sync::LazyLock}; @@ -26,22 +41,6 @@ pub static WEBAUTHN: LazyLock = LazyLock::new(|| { webauthn.build().expect("Building Webauthn failed") }); -use crate::{ - CONFIG, - api::{ - EmptyResult, JsonResult, PasswordOrOtpData, - core::{log_user_event, two_factor::generate_recover_code}, - }, - auth::Headers, - crypto::ct_eq, - db::{ - DbConn, - models::{EventType, TwoFactor, TwoFactorType, UserId}, - }, - error::Error, - util::NumberOrString, -}; - pub fn routes() -> Vec { routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,] } diff --git a/src/api/core/webauthn.rs b/src/api/core/webauthn.rs index a8a7847e..6b6b1aa2 100644 --- a/src/api/core/webauthn.rs +++ b/src/api/core/webauthn.rs @@ -23,12 +23,12 @@ pub fn routes() -> Vec { #[get("/webauthn")] async fn get_webauthn(headers: Headers, conn: DbConn) -> JsonResult { if !CONFIG.passkey_login_allowed() { - // The web vault will query this endpoint weather passkey login is allowed, so we should return an empty list instead of an error. + // The web vault will query this endpoint whether passkey login is allowed or not, so we should return an empty list instead of an error. return Ok(Json(json!({ "object": "list", "data": [], "continuationToken": null - }))) + }))); } let user = headers.user; From 05c9af4d1e931b6d8cf3ed42a75998edb8b83c51 Mon Sep 17 00:00:00 2001 From: Raphael Roumezin <94066559+RaphaelRoumezin@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:30:32 +0200 Subject: [PATCH 5/7] fix: Allowed Chromium extensions as origins for passwordless login Co-authored-by: Keng <39216134+kengzzzz@users.noreply.github.com> --- src/api/core/mod.rs | 1 + src/api/core/two_factor/webauthn.rs | 10 +++---- src/api/core/webauthn.rs | 44 +++++++++++++++++++++++------ src/api/identity.rs | 8 +++--- 4 files changed, 46 insertions(+), 17 deletions(-) diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index 0518642a..c28ebae5 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -15,6 +15,7 @@ pub use ciphers::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciph pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job}; pub use events::{event_cleanup_job, log_event, log_user_event}; pub use sends::purge_sends; +pub use webauthn::WEBAUTHN_PASSWORDLESS; use reqwest::Method; use rocket::{Catcher, Route, serde::json::Json, serde::json::Value}; diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index ba1a25fe..e6a6c516 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -27,7 +27,7 @@ use webauthn_rs_proto::{ RequestAuthenticationExtensions, UserVerificationPolicy, }; -pub static WEBAUTHN: LazyLock = LazyLock::new(|| { +pub static WEBAUTHN_2FA: LazyLock = LazyLock::new(|| { let domain = CONFIG.domain(); let domain_origin = CONFIG.domain_origin(); let rp_id = url::Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default(); @@ -139,7 +139,7 @@ async fn generate_webauthn_challenge(data: Json, headers: Hea .map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering .collect(); - let (mut challenge, state) = WEBAUTHN.start_passkey_registration( + let (mut challenge, state) = WEBAUTHN_2FA.start_passkey_registration( Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail &user.email, user.display_name(), @@ -272,7 +272,7 @@ async fn activate_webauthn(data: Json, headers: Headers, con }; // Verify the credentials with the saved state - let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?; + let credential = WEBAUTHN_2FA.finish_passkey_registration(&data.device_response.into(), &state)?; let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1; // TODO: Check for repeated ID's @@ -382,7 +382,7 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes } // Generate a challenge based on the credentials - let (mut response, state) = WEBAUTHN.start_passkey_authentication(&creds)?; + let (mut response, state) = WEBAUTHN_2FA.start_passkey_authentication(&creds)?; // Modify to discourage user verification let mut state = serde_json::to_value(&state)?; @@ -437,7 +437,7 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &Db // Because of this we check the flag at runtime and update the registrations and state when needed let backup_flags_updated = check_and_update_backup_eligible(&rsp, &mut registrations, &mut state)?; - let authentication_result = WEBAUTHN.finish_passkey_authentication(&rsp, &state)?; + let authentication_result = WEBAUTHN_2FA.finish_passkey_authentication(&rsp, &state)?; for reg in &mut registrations { if ct_eq(reg.credential.cred_id(), authentication_result.cred_id()) { diff --git a/src/api/core/webauthn.rs b/src/api/core/webauthn.rs index 6b6b1aa2..766efa3e 100644 --- a/src/api/core/webauthn.rs +++ b/src/api/core/webauthn.rs @@ -1,14 +1,16 @@ +use std::sync::LazyLock; + use rocket::{http::Status, serde::json::Json}; use serde_json::Value; -use webauthn_rs::prelude::{Passkey, PasskeyRegistration}; +use webauthn_rs::{ + Webauthn, WebauthnBuilder, + prelude::{Passkey, PasskeyRegistration}, +}; use webauthn_rs_proto::{COSEAlgorithm, UserVerificationPolicy, options::PubKeyCredParams}; use crate::{ CONFIG, - api::{ - ApiResult, JsonResult, PasswordOrOtpData, - core::two_factor::webauthn::{RegisterPublicKeyCredentialCopy, WEBAUTHN}, - }, + api::{ApiResult, JsonResult, PasswordOrOtpData, core::two_factor::webauthn::RegisterPublicKeyCredentialCopy}, auth::Headers, db::{ DbConn, @@ -16,6 +18,28 @@ use crate::{ }, }; +pub static WEBAUTHN_PASSWORDLESS: LazyLock = LazyLock::new(|| { + let domain = CONFIG.domain(); + let domain_origin = CONFIG.domain_origin(); + let rp_id = url::Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default(); + let rp_origin = url::Url::parse(&domain_origin).unwrap(); + + let mut webauthn = WebauthnBuilder::new(&rp_id, &rp_origin) + .expect("Creating WebauthnBuilder failed") + .rp_name(&domain) + .timeout(tokio::time::Duration::from_mins(1)); + + for origin in [ + "chrome-extension://nngceckbapebfimnlniiiahkandclblb", // Chrome Web Store + "chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh", // Edge Add-ons + ] { + let origin = url::Url::parse(origin).expect("origin should parse"); + webauthn = webauthn.append_allowed_origin(&origin); + } + + webauthn.build().expect("Building Webauthn failed") +}); + pub fn routes() -> Vec { routes![get_webauthn, post_webauthn, post_webauthn_attestation_options, post_webauthn_delete] } @@ -81,8 +105,12 @@ async fn post_webauthn_attestation_options( let user_uuid = uuid::Uuid::parse_str(&user.uuid).expect("Failed to parse user UUID"); - let (mut challenge, state) = - WEBAUTHN.start_passkey_registration(user_uuid, &user.email, user.display_name(), Some(existing_cred_ids))?; + let (mut challenge, state) = WEBAUTHN_PASSWORDLESS.start_passkey_registration( + user_uuid, + &user.email, + user.display_name(), + Some(existing_cred_ids), + )?; // For passkey login, we need discoverable credentials (resident keys). // start_passkey_registration() defaults to require_resident_key=false, but passkey login @@ -192,7 +220,7 @@ async fn post_webauthn( Some(tf) => { let state: PasskeyRegistration = serde_json::from_str(&tf.data)?; tf.delete(&conn).await?; - WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)? + WEBAUTHN_PASSWORDLESS.finish_passkey_registration(&data.device_response.into(), &state)? } None => err!("No registration challenge found. Please try again."), }; diff --git a/src/api/identity.rs b/src/api/identity.rs index 3f7cc0cd..3d43801c 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -16,11 +16,11 @@ use crate::{ api::{ ApiResult, EmptyResult, JsonResult, core::{ + WEBAUTHN_PASSWORDLESS, accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register}, log_user_event, two_factor::{ - authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, - webauthn::{self, WEBAUTHN}, + authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, yubikey, }, }, @@ -599,7 +599,7 @@ async fn webauthn_login(data: ConnectData, user_id: &mut Option, conn: & }; // Perform passkey authentication - let authentication_result = match WEBAUTHN.finish_passkey_authentication(&device_response, &state) { + let authentication_result = match WEBAUTHN_PASSWORDLESS.finish_passkey_authentication(&device_response, &state) { Ok(result) => result, Err(e) => { err!( @@ -1495,7 +1495,7 @@ fn get_webauthn_assertion_options() -> JsonResult { err!("Passkey login is not allowed") } - let (mut response, state) = WEBAUTHN.start_passkey_authentication(&[])?; + let (mut response, state) = WEBAUTHN_PASSWORDLESS.start_passkey_authentication(&[])?; // Allow any credential (discoverable) and require user verification response.public_key.allow_credentials = vec![]; From 3b669264bb247351129916bcbbf2bf125b6c939d Mon Sep 17 00:00:00 2001 From: Raphael Roumezin <94066559+RaphaelRoumezin@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:55:05 +0200 Subject: [PATCH 6/7] fix(clippy): few refactors --- src/api/core/webauthn.rs | 35 +++++++++++++++++------------------ src/api/identity.rs | 4 ++-- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/api/core/webauthn.rs b/src/api/core/webauthn.rs index 766efa3e..d7aedcd9 100644 --- a/src/api/core/webauthn.rs +++ b/src/api/core/webauthn.rs @@ -135,43 +135,43 @@ async fn post_webauthn_attestation_options( challenge.public_key.pub_key_cred_params = vec![ PubKeyCredParams { alg: COSEAlgorithm::ES256 as i64, - type_: "public-key".to_string(), + type_: "public-key".to_owned(), }, PubKeyCredParams { alg: COSEAlgorithm::ES384 as i64, - type_: "public-key".to_string(), + type_: "public-key".to_owned(), }, PubKeyCredParams { alg: COSEAlgorithm::ES512 as i64, - type_: "public-key".to_string(), + type_: "public-key".to_owned(), }, PubKeyCredParams { alg: COSEAlgorithm::RS256 as i64, - type_: "public-key".to_string(), + type_: "public-key".to_owned(), }, PubKeyCredParams { alg: COSEAlgorithm::RS384 as i64, - type_: "public-key".to_string(), + type_: "public-key".to_owned(), }, PubKeyCredParams { alg: COSEAlgorithm::RS512 as i64, - type_: "public-key".to_string(), + type_: "public-key".to_owned(), }, PubKeyCredParams { alg: COSEAlgorithm::PS256 as i64, - type_: "public-key".to_string(), + type_: "public-key".to_owned(), }, PubKeyCredParams { alg: COSEAlgorithm::PS384 as i64, - type_: "public-key".to_string(), + type_: "public-key".to_owned(), }, PubKeyCredParams { alg: COSEAlgorithm::PS512 as i64, - type_: "public-key".to_string(), + type_: "public-key".to_owned(), }, PubKeyCredParams { alg: COSEAlgorithm::EDDSA as i64, - type_: "public-key".to_string(), + type_: "public-key".to_owned(), }, ]; @@ -216,13 +216,12 @@ async fn post_webauthn( // Retrieve and delete the saved challenge state from the database let type_ = TwoFactorType::WebauthnPasskeyRegisterChallenge as i32; - let credential = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { - Some(tf) => { - let state: PasskeyRegistration = serde_json::from_str(&tf.data)?; - tf.delete(&conn).await?; - WEBAUTHN_PASSWORDLESS.finish_passkey_registration(&data.device_response.into(), &state)? - } - None => err!("No registration challenge found. Please try again."), + let credential = if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { + let state: PasskeyRegistration = serde_json::from_str(&tf.data)?; + tf.delete(&conn).await?; + WEBAUTHN_PASSWORDLESS.finish_passkey_registration(&data.device_response.into(), &state)? + } else { + err!("No registration challenge found. Please try again.") }; WebauthnCredential::new( @@ -256,7 +255,7 @@ async fn post_webauthn_delete( data.validate(&user, false, &conn).await?; - WebauthnCredential::delete_by_uuid_and_user(&WebauthnCredentialId::from(uuid.to_string()), &user.uuid, &conn) + WebauthnCredential::delete_by_uuid_and_user(&WebauthnCredentialId::from(uuid.to_owned()), &user.uuid, &conn) .await?; Ok(Status::Ok) diff --git a/src/api/identity.rs b/src/api/identity.rs index 3d43801c..a6a36794 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -581,12 +581,12 @@ async fn webauthn_login(data: ConnectData, user_id: &mut Option, conn: & raw_state.get_mut("ast").and_then(|v| v.get_mut("credentials")).and_then(|v| v.as_array_mut()) { credentials.clear(); - for (_, passkey) in user_webauthn_credentials.iter() { + for (_, passkey) in &user_webauthn_credentials { let passkey_owned: Passkey = passkey.clone(); let cred = ::from(passkey_owned); credentials.push(serde_json::to_value(&cred)?); } - }; + } serde_json::from_value(raw_state)? } else { err!( From 9e4aa5efe76f78d349492bc36731f2117b488a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Roumezin?= <94066559+RaphaelRoumezin@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:07:00 +0200 Subject: [PATCH 7/7] feat: Added support for passkey vault unlock feature Co-authored-by: Keng <39216134+kengzzzz@users.noreply.github.com> --- src/api/core/ciphers.rs | 30 +++++++++++++++++++++++------- src/api/core/mod.rs | 7 ++++++- src/api/core/webauthn.rs | 26 +++++++++++++++++++++++++- src/api/identity.rs | 10 ++++------ src/config.rs | 1 + src/db/models/mod.rs | 2 +- 6 files changed, 60 insertions(+), 16 deletions(-) diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 6b9994cf..9437f22f 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -12,9 +12,11 @@ use serde_json::Value; use crate::{ CONFIG, - api::{self, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event}, - auth::ClientVersion, - auth::{Headers, OrgIdGuard, OwnerHeaders}, + api::{ + self, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, + core::{log_event, webauthn_prf_option}, + }, + auth::{ClientVersion, Headers, OrgIdGuard, OwnerHeaders}, config::PathType, crypto, db::{ @@ -22,7 +24,7 @@ use crate::{ models::{ Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership, - MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, + MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, WebauthnCredential, }, }, util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, @@ -188,6 +190,12 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option) -> Value { + let mut user_decryption = json!({ + "masterPasswordUnlock": master_password_unlock, + }); + if !webauthn_prf_options.is_empty() { + user_decryption["webAuthnPrfOptions"] = webauthn_prf_options.into(); + } + user_decryption +} + #[get("/ciphers")] async fn get_ciphers(headers: Headers, conn: DbConn) -> JsonResult { let ciphers = Cipher::find_by_user_visible(&headers.user.uuid, &conn).await; diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index c28ebae5..fdbed750 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -15,7 +15,7 @@ pub use ciphers::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciph pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job}; pub use events::{event_cleanup_job, log_event, log_user_event}; pub use sends::purge_sends; -pub use webauthn::WEBAUTHN_PASSWORDLESS; +pub use webauthn::{WEBAUTHN_PASSWORDLESS, webauthn_prf_option}; use reqwest::Method; use rocket::{Catcher, Route, serde::json::Json, serde::json::Value}; @@ -211,6 +211,11 @@ fn config() -> Json { &FeatureFlagFilter::ValidOnly, ); feature_states.insert("pm-19148-innovation-archive".to_owned(), true); + if CONFIG.passkey_login_allowed() { + feature_states.insert("pm-2035-passkey-unlock".to_owned(), true); + } else { + feature_states.remove("pm-2035-passkey-unlock"); + } Json(json!({ // Note: The clients use this version to handle backwards compatibility concerns diff --git a/src/api/core/webauthn.rs b/src/api/core/webauthn.rs index d7aedcd9..97c35556 100644 --- a/src/api/core/webauthn.rs +++ b/src/api/core/webauthn.rs @@ -14,7 +14,7 @@ use crate::{ auth::Headers, db::{ DbConn, - models::{TwoFactor, TwoFactorType, WebauthnCredential, WebauthnCredentialId}, + models::{TwoFactor, TwoFactorType, WebauthnCredential, WebauthnCredentialId, WebauthnCredentialPrfStatus}, }, }; @@ -44,6 +44,30 @@ pub fn routes() -> Vec { routes![get_webauthn, post_webauthn, post_webauthn_attestation_options, post_webauthn_delete] } +pub fn webauthn_prf_option(wac: &WebauthnCredential, pascal_case: bool) -> Option { + if !matches!(wac.get_prf_status(), WebauthnCredentialPrfStatus::Enabled) { + return None; + } + + let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?; + + if pascal_case { + Some(json!({ + "CredentialId": passkey.cred_id().to_owned(), + "Transports": [], + "EncryptedPrivateKey": wac.encrypted_private_key.as_ref()?, + "EncryptedUserKey": wac.encrypted_user_key.as_ref()?, + })) + } else { + Some(json!({ + "credentialId": passkey.cred_id().to_owned(), + "transports": [], + "encryptedPrivateKey": wac.encrypted_private_key.as_ref()?, + "encryptedUserKey": wac.encrypted_user_key.as_ref()?, + })) + } +} + #[get("/webauthn")] async fn get_webauthn(headers: Headers, conn: DbConn) -> JsonResult { if !CONFIG.passkey_login_allowed() { diff --git a/src/api/identity.rs b/src/api/identity.rs index a6a36794..a727118c 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -23,6 +23,7 @@ use crate::{ authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, yubikey, }, + webauthn_prf_option, }, master_password_policy, push::register_push_device, @@ -633,13 +634,10 @@ async fn webauthn_login(data: ConnectData, user_id: &mut Option, conn: & let mut result = authenticated_response(&user, &mut device, auth_tokens, None, conn, ip).await?; - // Add WebAuthnPrfOption if the credential has encrypted keys (PRF-based decryption) - if matched_wac.encrypted_private_key.is_some() && matched_wac.encrypted_user_key.is_some() { + // Add WebAuthnPrfOption if the credential has enabled PRF-based decryption. + if let Some(prf_option) = webauthn_prf_option(matched_wac, true) { let Json(ref mut val) = result; - val["UserDecryptionOptions"]["WebAuthnPrfOption"] = json!({ - "EncryptedPrivateKey": matched_wac.encrypted_private_key, - "EncryptedUserKey": matched_wac.encrypted_user_key, - }); + val["UserDecryptionOptions"]["WebAuthnPrfOption"] = prf_option; } Ok(result) diff --git a/src/config.rs b/src/config.rs index 3bd57e97..db1351f3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1406,6 +1406,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ "ssh-agent-v2", // Key Management Team "ssh-key-vault-item", + "pm-2035-passkey-unlock", "pm-25373-windows-biometrics-v2", // Mobile Team "anon-addy-self-host-alias", diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index aa5d2a41..fb499962 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -44,4 +44,4 @@ pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_incomplete::TwoFactorIncomplete; pub use self::user::{Invitation, SsoUser, User, UserId, UserKdfType, UserStampException}; -pub use self::webauthn_credential::{WebauthnCredential, WebauthnCredentialId}; +pub use self::webauthn_credential::{WebauthnCredential, WebauthnCredentialId, WebauthnCredentialPrfStatus};