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] 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)}}