diff --git a/.env.template b/.env.template index 0d922774..3b10dd26 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/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/ciphers.rs b/src/api/core/ciphers.rs index 14e9f72f..12a6afaa 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 2ea8ab21..fdbed750 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -8,12 +8,14 @@ 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}; 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, webauthn_prf_option}; use reqwest::Method; use rocket::{Catcher, Route, serde::json::Json, serde::json::Value}; @@ -35,7 +37,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 +49,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 +198,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(); @@ -220,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/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..e6a6c516 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -1,19 +1,3 @@ -use std::{str::FromStr, sync::LazyLock, time::Duration}; - -use rocket::{Route, serde::json::Json}; -use serde_json::Value; -use url::Url; -use uuid::Uuid; -use webauthn_rs::{ - Webauthn, WebauthnBuilder, - prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration}, -}; -use webauthn_rs_proto::{ - AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, - PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs, - RequestAuthenticationExtensions, UserVerificationPolicy, -}; - use crate::{ CONFIG, api::{ @@ -29,17 +13,30 @@ use crate::{ error::Error, util::NumberOrString, }; +use rocket::{Route, serde::json::Json}; +use serde_json::Value; +use std::{str::FromStr, sync::LazyLock}; +use uuid::Uuid; +use webauthn_rs::{ + Webauthn, WebauthnBuilder, + prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration}, +}; +use webauthn_rs_proto::{ + AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, + PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs, + RequestAuthenticationExtensions, UserVerificationPolicy, +}; -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::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default(); - let rp_origin = Url::parse(&domain_origin).unwrap(); + 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(Duration::from_mins(1)); + .timeout(tokio::time::Duration::from_mins(1)); webauthn.build().expect("Building Webauthn failed") }); @@ -142,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(), @@ -181,7 +178,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, @@ -275,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 @@ -385,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)?; @@ -440,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 new file mode 100644 index 00000000..97c35556 --- /dev/null +++ b/src/api/core/webauthn.rs @@ -0,0 +1,286 @@ +use std::sync::LazyLock; + +use rocket::{http::Status, serde::json::Json}; +use serde_json::Value; +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}, + auth::Headers, + db::{ + DbConn, + models::{TwoFactor, TwoFactorType, WebauthnCredential, WebauthnCredentialId, WebauthnCredentialPrfStatus}, + }, +}; + +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] +} + +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() { + // 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; + + 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::(); + + Ok(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 { + if !CONFIG.passkey_login_allowed() { + err!("Passkey login is not allowed") + } + + 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_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 + // 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); + } + + // 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_owned(), + }, + PubKeyCredParams { + alg: COSEAlgorithm::ES384 as i64, + type_: "public-key".to_owned(), + }, + PubKeyCredParams { + alg: COSEAlgorithm::ES512 as i64, + type_: "public-key".to_owned(), + }, + PubKeyCredParams { + alg: COSEAlgorithm::RS256 as i64, + type_: "public-key".to_owned(), + }, + PubKeyCredParams { + alg: COSEAlgorithm::RS384 as i64, + type_: "public-key".to_owned(), + }, + PubKeyCredParams { + alg: COSEAlgorithm::RS512 as i64, + type_: "public-key".to_owned(), + }, + PubKeyCredParams { + alg: COSEAlgorithm::PS256 as i64, + type_: "public-key".to_owned(), + }, + PubKeyCredParams { + alg: COSEAlgorithm::PS384 as i64, + type_: "public-key".to_owned(), + }, + PubKeyCredParams { + alg: COSEAlgorithm::PS512 as i64, + type_: "public-key".to_owned(), + }, + PubKeyCredParams { + alg: COSEAlgorithm::EDDSA as i64, + type_: "public-key".to_owned(), + }, + ]; + + // 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 { + if !CONFIG.passkey_login_allowed() { + err!("Passkey login is not allowed") + } + + 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 = 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( + 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 { + if !CONFIG.passkey_login_allowed() { + err!("Passkey login is not allowed") + } + + 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_owned()), &user.uuid, &conn) + .await?; + + Ok(Status::Ok) +} diff --git a/src/api/identity.rs b/src/api/identity.rs index 1597698f..85116bb6 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -8,36 +8,42 @@ 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, 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, yubikey, }, + webauthn_prf_option, }, 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, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, - TwoFactorType, User, UserId, + TwoFactorType, User, UserId, WebauthnCredential, }, }, error::MapResult, - mail, sso, - sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState}, + mail, + sso::{self, OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState}, util, }; @@ -52,7 +58,8 @@ pub fn routes() -> Vec { prevalidate, authorize, oidcsignin, - oidcsignin_error + oidcsignin_error, + get_webauthn_assertion_options ] } @@ -108,6 +115,20 @@ 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" 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")?; + + 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 + } + "webauthn" => err!("Passkey login is not allowed"), "send_access" => { check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; check_is_some(data.send_id.as_ref(), "send_id cannot be blank")?; @@ -480,6 +501,161 @@ 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 { + 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_PASSWORDLESS.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 enabled PRF-based decryption. + if let Some(prf_option) = webauthn_prf_option(matched_wac, true) { + let Json(ref mut val) = result; + val["UserDecryptionOptions"]["WebAuthnPrfOption"] = prf_option; + } + + Ok(result) +} + async fn authenticated_response( user: &User, device: &mut Device, @@ -1014,7 +1190,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 */ } } } @@ -1104,7 +1281,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"))] @@ -1158,6 +1335,12 @@ struct ConnectData { #[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, + // Needed for send access send_id: Option, password_hash_b64: Option, @@ -1320,3 +1503,33 @@ 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 { + if !CONFIG.passkey_login_allowed() { + err!("Passkey login is not allowed") + } + + let (mut response, state) = WEBAUTHN_PASSWORDLESS.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/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/auth.rs b/src/auth.rs index 88a59b4b..aea44ecb 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -19,6 +19,7 @@ use rocket::{ outcome::try_outcome, request::{FromRequest, Outcome, Request}, }; +use webauthn_rs::prelude::PasskeyAuthentication; use crate::{ CONFIG, @@ -61,6 +62,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(); @@ -175,6 +177,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 @@ -333,6 +339,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 @@ -1162,6 +1191,7 @@ pub enum AuthMethod { Password, Sso, UserApiKey, + Webauthn, } impl AuthMethod { @@ -1169,7 +1199,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(), } } @@ -1307,7 +1337,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/config.rs b/src/config.rs index 49281b6c..a8a09f11 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; @@ -1403,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", "pm-26340-linux-biometrics-v2", // Mobile Team diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0ed8ef91..28385b40 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}; @@ -40,3 +41,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, WebauthnCredentialPrfStatus}; 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..f9b84d80 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -54,6 +54,7 @@ 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; @@ -68,6 +69,7 @@ app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secon @extend %vw-hide; } {{/if}} +{{/if}} /* Hide the or text followed by the two buttons hidden above */ {{#if (webver ">=2025.5.1")}}