diff --git a/Cargo.toml b/Cargo.toml index 88d8b3c4..67d7acf0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -128,7 +128,8 @@ yubico = { package = "yubico_ng", version = "0.14.1", features = ["online-tokio" # WebAuthn libraries # danger-allow-state-serialisation is needed to save the state in the db # danger-credential-internals is needed to support U2F to Webauthn migration -webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } +# conditional-ui is needed for discoverable (username-less) passkey login +webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-credential-internals", "conditional-ui"] } webauthn-rs-proto = "0.5.4" webauthn-rs-core = "0.5.4" diff --git a/src/api/admin.rs b/src/api/admin.rs index badfaa3a..323b888c 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -502,7 +502,7 @@ async fn enable_user(user_id: UserId, _token: AdminToken, conn: DbConn) -> Empty #[post("/users//remove-2fa", format = "application/json")] async fn remove_2fa(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult { let mut user = get_user_or_404(&user_id, &conn).await?; - TwoFactor::delete_all_by_user(&user.uuid, &conn).await?; + TwoFactor::delete_all_2fa_by_user(&user.uuid, &conn).await?; two_factor::enforce_2fa_policy(&user, &ACTING_ADMIN_USER.into(), 14, &token.ip.ip, &conn).await?; user.totp_recover = None; user.save(&conn).await diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 0e01c1c4..3b0e4a4d 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use crate::db::DbPool; use chrono::Utc; @@ -7,7 +7,7 @@ use serde_json::Value; use crate::{ api::{ - core::{accept_org_invite, log_user_event, two_factor::email}, + core::{accept_org_invite, log_user_event, two_factor::{email, webauthn}}, master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, }, @@ -17,7 +17,7 @@ use crate::{ models::{ AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, OrgPolicy, - OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType, + OrgPolicyType, Organization, OrganizationId, Send, SendId, TwoFactor, TwoFactorType, User, UserId, UserKdfType, }, DbConn, }, @@ -689,6 +689,17 @@ struct RotateAccountUnlockData { emergency_access_unlock_data: Vec, master_password_unlock_data: MasterPasswordUnlockData, organization_account_recovery_unlock_data: Vec, + passkey_unlock_data: Vec, + #[serde(rename = "deviceKeyUnlockData")] + _device_key_unlock_data: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UpdatePasskeyData { + id: NumberOrString, + encrypted_user_key: Option, + encrypted_public_key: Option, } #[derive(Deserialize)] @@ -725,6 +736,7 @@ fn validate_keydata( existing_emergency_access: &[EmergencyAccess], existing_memberships: &[Membership], existing_sends: &[Send], + existing_webauthn_credentials: &[webauthn::WebauthnRegistration], user: &User, ) -> EmptyResult { if user.client_kdf_type != data.account_unlock_data.master_password_unlock_data.kdf_type @@ -793,6 +805,32 @@ fn validate_keydata( err!("All existing sends must be included in the rotation") } + let keys_to_rotate = data + .account_unlock_data + .passkey_unlock_data + .iter() + .map(|credential| (credential.id.clone().into_string(), credential)) + .collect::>(); + let valid_webauthn_credentials: Vec<_> = + existing_webauthn_credentials.iter().filter(|credential| credential.prf_status() == 0).collect(); + + for webauthn_credential in valid_webauthn_credentials { + let key_to_rotate = keys_to_rotate + .get(&webauthn_credential.login_credential_api_id()) + .or_else(|| keys_to_rotate.get(&webauthn_credential.id.to_string())); + + let Some(key_to_rotate) = key_to_rotate else { + err!("All existing webauthn prf keys must be included in the rotation."); + }; + + if key_to_rotate.encrypted_user_key.is_none() { + err!("WebAuthn prf keys must have user-key during rotation."); + } + if key_to_rotate.encrypted_public_key.is_none() { + err!("WebAuthn prf keys must have public-key during rotation."); + } + } + Ok(()) } @@ -822,6 +860,7 @@ async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: // We only rotate the reset password key if it is set. existing_memberships.retain(|m| m.reset_password_key.is_some()); let mut existing_sends = Send::find_by_user(user_id, &conn).await; + let mut existing_webauthn_credentials = webauthn::get_webauthn_login_registrations(user_id, &conn).await?; validate_keydata( &data, @@ -830,6 +869,7 @@ async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: &existing_emergency_access, &existing_memberships, &existing_sends, + &existing_webauthn_credentials, &headers.user, )?; @@ -871,6 +911,44 @@ async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: membership.save(&conn).await? } + let passkey_unlock_data = data + .account_unlock_data + .passkey_unlock_data + .iter() + .map(|credential| (credential.id.clone().into_string(), credential)) + .collect::>(); + let mut webauthn_credentials_changed = false; + for webauthn_credential in existing_webauthn_credentials.iter_mut().filter(|credential| credential.prf_status() == 0) { + let key_to_rotate = passkey_unlock_data + .get(&webauthn_credential.login_credential_api_id()) + .or_else(|| passkey_unlock_data.get(&webauthn_credential.id.to_string())) + .expect("Missing webauthn prf key after successful validation"); + + let encrypted_user_key = + key_to_rotate.encrypted_user_key.clone().expect("Missing user-key after successful validation"); + let encrypted_public_key = + key_to_rotate.encrypted_public_key.clone().expect("Missing public-key after successful validation"); + + if webauthn_credential.encrypted_user_key.as_ref() != Some(&encrypted_user_key) + || webauthn_credential.encrypted_public_key.as_ref() != Some(&encrypted_public_key) + { + webauthn_credentials_changed = true; + } + + webauthn_credential.encrypted_user_key = Some(encrypted_user_key); + webauthn_credential.encrypted_public_key = Some(encrypted_public_key); + } + + if webauthn_credentials_changed { + TwoFactor::new( + user_id.clone(), + TwoFactorType::WebauthnLoginCredential, + serde_json::to_string(&existing_webauthn_credentials)?, + ) + .save(&conn) + .await?; + } + // Update send data for send_data in data.account_data.sends { let Some(send) = existing_sends.iter_mut().find(|s| &s.uuid == send_data.id.as_ref().unwrap()) else { diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index d5f244f4..85e09e92 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -1,6 +1,7 @@ use std::collections::{HashMap, HashSet}; use chrono::{NaiveDateTime, Utc}; +use data_encoding::BASE64URL_NOPAD; use num_traits::ToPrimitive; use rocket::fs::TempFile; use rocket::serde::json::Json; @@ -127,6 +128,28 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option = api::core::two_factor::webauthn::get_webauthn_login_registrations( + &headers.user.uuid, + &conn, + ) + .await? + .into_iter() + .filter(|registration| registration.prf_status() == 0) + .filter_map(|registration| { + let encrypted_private_key = registration.encrypted_private_key?; + let encrypted_user_key = registration.encrypted_user_key?; + registration.encrypted_public_key.as_ref()?; + + Some(json!({ + "encryptedPrivateKey": encrypted_private_key, + "encryptedUserKey": encrypted_user_key, + "credentialId": BASE64URL_NOPAD.encode(registration.credential.cred_id().as_slice()), + "transports": Vec::::new(), + })) + }) + .collect(); + if !show_ssh_keys { ciphers.retain(|c| c.atype != 5); } @@ -173,16 +196,20 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option Vec { let mut eq_domains_routes = routes![get_eq_domains, post_eq_domains, put_eq_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, + get_api_webauthn, + get_api_webauthn_attestation_options, + post_api_webauthn, + post_api_webauthn_assertion_options, + put_api_webauthn, + delete_api_webauthn + ]; let mut routes = Vec::new(); routes.append(&mut accounts::routes()); @@ -50,13 +62,13 @@ pub fn events_routes() -> Vec { use rocket::{serde::json::Json, serde::json::Value, Catcher, Route}; use crate::{ - api::{EmptyResult, JsonResult, Notify, UpdateType}, - auth::Headers, + api::{EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType}, + auth::{self, Headers}, db::{ - models::{Membership, MembershipStatus, OrgPolicy, Organization, User}, + models::{Membership, MembershipStatus, OrgPolicy, Organization, User, UserId}, DbConn, }, - error::Error, + error::{Error, MapResult}, http_client::make_http_request, mail, util::parse_experimental_client_feature_flags, @@ -72,6 +84,98 @@ struct GlobalDomain { const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json"); +static WEBAUTHN_CREATE_OPTIONS_ISSUER: LazyLock = + LazyLock::new(|| format!("{}|webauthn_create_options", crate::CONFIG.domain_origin())); +static WEBAUTHN_UPDATE_ASSERTION_OPTIONS_ISSUER: LazyLock = + LazyLock::new(|| format!("{}|webauthn_update_assertion_options", crate::CONFIG.domain_origin())); +const REQUIRE_SSO_POLICY_TYPE: i32 = 4; + +#[derive(Debug, Serialize, Deserialize)] +struct WebauthnCreateOptionsClaims { + nbf: i64, + exp: i64, + iss: String, + sub: UserId, + state: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct WebauthnUpdateAssertionOptionsClaims { + nbf: i64, + exp: i64, + iss: String, + sub: UserId, + state: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WebauthnCredentialCreateRequest { + device_response: two_factor::webauthn::RegisterPublicKeyCredentialCopy, + name: String, + token: String, + supports_prf: bool, + encrypted_user_key: Option, + encrypted_public_key: Option, + encrypted_private_key: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WebauthnCredentialUpdateRequest { + device_response: two_factor::webauthn::PublicKeyCredentialCopy, + token: String, + encrypted_user_key: String, + encrypted_public_key: String, + encrypted_private_key: String, +} + +fn encode_webauthn_create_options_token(user_id: &UserId, state: String) -> String { + let now = chrono::Utc::now(); + let claims = WebauthnCreateOptionsClaims { + nbf: now.timestamp(), + exp: (now + chrono::TimeDelta::try_minutes(7).unwrap()).timestamp(), + iss: WEBAUTHN_CREATE_OPTIONS_ISSUER.to_string(), + sub: user_id.clone(), + state, + }; + + auth::encode_jwt(&claims) +} + +fn decode_webauthn_create_options_token(token: &str) -> Result { + auth::decode_jwt(token, WEBAUTHN_CREATE_OPTIONS_ISSUER.to_string()).map_res("Invalid WebAuthn token") +} + +fn encode_webauthn_update_assertion_options_token(user_id: &UserId, state: String) -> String { + let now = chrono::Utc::now(); + let claims = WebauthnUpdateAssertionOptionsClaims { + nbf: now.timestamp(), + exp: (now + chrono::TimeDelta::try_minutes(17).unwrap()).timestamp(), + iss: WEBAUTHN_UPDATE_ASSERTION_OPTIONS_ISSUER.to_string(), + sub: user_id.clone(), + state, + }; + + auth::encode_jwt(&claims) +} + +fn decode_webauthn_update_assertion_options_token( + token: &str, +) -> Result { + auth::decode_jwt(token, WEBAUTHN_UPDATE_ASSERTION_OPTIONS_ISSUER.to_string()).map_res("Invalid WebAuthn token") +} + +async fn ensure_passkey_creation_allowed(user_id: &UserId, conn: &DbConn) -> EmptyResult { + // `RequireSso` (policy type 4) is not fully supported in Vaultwarden, but if present in DB + // we still mirror official behavior by blocking passkey creation. + if OrgPolicy::has_active_raw_policy_for_user(user_id, REQUIRE_SSO_POLICY_TYPE, conn).await { + err!("Passkeys cannot be created for your account. SSO login is required.") + } + + Ok(()) +} + #[get("/settings/domains")] fn get_eq_domains(headers: Headers) -> Json { _get_eq_domains(&headers, false) @@ -184,15 +288,125 @@ fn version() -> Json<&'static str> { } #[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!({ +async fn get_api_webauthn(headers: Headers, conn: DbConn) -> JsonResult { + let registrations = two_factor::webauthn::get_webauthn_login_registrations(&headers.user.uuid, &conn).await?; + + let data: Vec = registrations + .into_iter() + .map(|registration| { + json!({ + "id": registration.login_credential_api_id(), + "name": registration.name, + "prfStatus": registration.prf_status(), + "encryptedUserKey": registration.encrypted_user_key, + "encryptedPublicKey": registration.encrypted_public_key, + "object": "webauthnCredential" + }) + }) + .collect(); + + Ok(Json(json!({ "object": "list", - "data": [], + "data": data, "continuationToken": null - })) + }))) +} + +#[post("/webauthn/attestation-options", data = "")] +async fn get_api_webauthn_attestation_options( + data: Json, + headers: Headers, + conn: DbConn, +) -> JsonResult { + if !crate::CONFIG.domain_set() { + err!("`DOMAIN` environment variable is not set. Webauthn disabled") + } + + data.into_inner().validate(&headers.user, false, &conn).await?; + ensure_passkey_creation_allowed(&headers.user.uuid, &conn).await?; + + let (options, state) = two_factor::webauthn::generate_webauthn_attestation_options(&headers.user, &conn).await?; + let token = encode_webauthn_create_options_token(&headers.user.uuid, state); + + Ok(Json(json!({ + "options": options, + "token": token, + "object": "webauthnCredentialCreateOptions" + }))) +} + +#[post("/webauthn", data = "")] +async fn post_api_webauthn(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + let data = data.into_inner(); + let claims = decode_webauthn_create_options_token(&data.token)?; + + if claims.sub != headers.user.uuid { + err!("The token associated with your request is expired. A valid token is required to continue.") + } + ensure_passkey_creation_allowed(&headers.user.uuid, &conn).await?; + + two_factor::webauthn::create_webauthn_login_credential( + &headers.user.uuid, + &claims.state, + data.name, + data.device_response, + data.supports_prf, + data.encrypted_user_key, + data.encrypted_public_key, + data.encrypted_private_key, + &conn, + ) + .await?; + + Ok(()) +} + +#[post("/webauthn/assertion-options", data = "")] +async fn post_api_webauthn_assertion_options( + data: Json, + headers: Headers, + conn: DbConn, +) -> JsonResult { + data.into_inner().validate(&headers.user, false, &conn).await?; + + let (options, state) = two_factor::webauthn::generate_webauthn_discoverable_login()?; + let token = encode_webauthn_update_assertion_options_token(&headers.user.uuid, state); + + Ok(Json(json!({ + "options": options, + "token": token, + "object": "webAuthnLoginAssertionOptions" + }))) +} + +#[put("/webauthn", data = "")] +async fn put_api_webauthn(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + let data = data.into_inner(); + let claims = decode_webauthn_update_assertion_options_token(&data.token)?; + + if claims.sub != headers.user.uuid { + err!("The token associated with your request is invalid or has expired. A valid token is required to continue.") + } + + two_factor::webauthn::update_webauthn_login_credential_keys( + &headers.user.uuid, + &claims.state, + data.device_response, + data.encrypted_user_key, + data.encrypted_public_key, + data.encrypted_private_key, + &conn, + ) + .await?; + + Ok(()) +} + +#[post("/webauthn//delete", data = "")] +async fn delete_api_webauthn(id: String, data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + data.into_inner().validate(&headers.user, false, &conn).await?; + two_factor::webauthn::delete_webauthn_login_credential(&headers.user.uuid, &id, &conn).await?; + Ok(()) } #[get("/config")] diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index dfaae77a..5b334e33 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -106,7 +106,7 @@ async fn recover(data: Json, client_headers: ClientHeaders, co } // Remove all twofactors from the user - TwoFactor::delete_all_by_user(&user.uuid, &conn).await?; + TwoFactor::delete_all_2fa_by_user(&user.uuid, &conn).await?; enforce_2fa_policy(&user, &user.uuid, client_headers.device_type, &client_headers.ip.ip, &conn).await?; log_user_event( diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index b10a5ded..ec1159a2 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -6,13 +6,14 @@ use crate::{ auth::Headers, crypto::ct_eq, db::{ - models::{EventType, TwoFactor, TwoFactorType, UserId}, + models::{EventType, TwoFactor, TwoFactorType, User, UserId}, DbConn, }, error::Error, util::NumberOrString, CONFIG, }; +use data_encoding::BASE64URL_NOPAD; use rocket::serde::json::Json; use rocket::Route; use serde_json::Value; @@ -21,7 +22,10 @@ use std::sync::LazyLock; use std::time::Duration; use url::Url; use uuid::Uuid; -use webauthn_rs::prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration}; +use webauthn_rs::prelude::{ + Base64UrlSafeData, Credential, DiscoverableAuthentication, DiscoverableKey, Passkey, PasskeyAuthentication, + PasskeyRegistration, +}; use webauthn_rs::{Webauthn, WebauthnBuilder}; use webauthn_rs_proto::{ AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, @@ -72,10 +76,32 @@ pub struct U2FRegistration { #[derive(Debug, Serialize, Deserialize)] pub struct WebauthnRegistration { pub id: i32, + #[serde(default)] + pub api_id: Option, pub name: String, pub migrated: bool, pub credential: Passkey, + #[serde(default)] + pub supports_prf: bool, + pub encrypted_user_key: Option, + pub encrypted_public_key: Option, + pub encrypted_private_key: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct WebauthnPrfDecryptionOption { + pub encrypted_private_key: String, + pub encrypted_user_key: String, + pub credential_id: String, + pub transports: Vec, +} + +#[derive(Debug, Clone)] +pub struct WebauthnDiscoverableLoginResult { + pub user_id: UserId, + pub prf_decryption_option: Option, } impl WebauthnRegistration { @@ -104,6 +130,48 @@ impl WebauthnRegistration { self.credential = cred.into(); changed } + + pub fn prf_status(&self) -> i32 { + if !self.supports_prf { + // Unsupported + 2 + } else if self.encrypted_user_key.is_some() + && self.encrypted_public_key.is_some() + && self.encrypted_private_key.is_some() + { + // Enabled + 0 + } else { + // Supported + 1 + } + } + + fn ensure_api_id(&mut self) -> bool { + if self.api_id.is_none() { + self.api_id = Some(Uuid::new_v4().to_string()); + return true; + } + false + } + + pub fn login_credential_api_id(&self) -> String { + self.api_id.clone().unwrap_or_else(|| self.id.to_string()) + } + + pub fn matches_login_credential_id(&self, id: &str) -> bool { + self.api_id.as_deref() == Some(id) || self.id.to_string() == id + } +} + +fn normalize_optional_secret(value: Option) -> Option { + value.and_then(|s| { + if s.trim().is_empty() { + None + } else { + Some(s) + } + }) } #[post("/two-factor/get-webauthn", data = "")] @@ -180,10 +248,12 @@ struct EnableWebauthnData { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -struct RegisterPublicKeyCredentialCopy { +pub struct RegisterPublicKeyCredentialCopy { pub id: String, pub raw_id: Base64UrlSafeData, pub response: AuthenticatorAttestationResponseRawCopy, + #[serde(default, alias = "clientExtensionResults")] + pub extensions: RegistrationExtensionsClientOutputs, pub r#type: String, } @@ -208,7 +278,7 @@ impl From for RegisterPublicKeyCredential { transports: None, }, type_: r.r#type, - extensions: RegistrationExtensionsClientOutputs::default(), + extensions: r.extensions, } } } @@ -281,10 +351,15 @@ async fn activate_webauthn(data: Json, headers: Headers, con // TODO: Check for repeated ID's registrations.push(WebauthnRegistration { id: data.id.into_i32()?, + api_id: None, name: data.name, migrated: false, credential, + supports_prf: false, + encrypted_user_key: None, + encrypted_public_key: None, + encrypted_private_key: None, }); // Save the registrations and return them @@ -374,6 +449,205 @@ pub async fn get_webauthn_registrations( } } +pub async fn get_webauthn_login_registrations(user_id: &UserId, conn: &DbConn) -> Result, Error> { + let type_ = TwoFactorType::WebauthnLoginCredential as i32; + let Some(mut tf) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await else { + return Ok(Vec::new()); + }; + + let mut registrations: Vec = serde_json::from_str(&tf.data)?; + let mut changed = false; + for registration in &mut registrations { + changed |= registration.ensure_api_id(); + } + + if changed { + tf.data = serde_json::to_string(®istrations)?; + tf.save(conn).await?; + } + + Ok(registrations) +} + +pub async fn generate_webauthn_attestation_options(user: &User, conn: &DbConn) -> Result<(Value, String), Error> { + let registrations = get_webauthn_login_registrations(&user.uuid, conn) + .await? + .into_iter() + .map(|r| r.credential.cred_id().to_owned()) + .collect(); + + let (challenge, state) = WEBAUTHN.start_passkey_registration( + Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail + &user.email, + user.display_name(), + Some(registrations), + )?; + + let mut options = serde_json::to_value(challenge.public_key)?; + if let Some(obj) = options.as_object_mut() { + obj.insert("userVerification".to_string(), Value::String("required".to_string())); + + let auth_sel = obj + .entry("authenticatorSelection") + .or_insert_with(|| Value::Object(serde_json::Map::new())); + if let Some(auth_sel_obj) = auth_sel.as_object_mut() { + auth_sel_obj.insert("requireResidentKey".to_string(), Value::Bool(true)); + auth_sel_obj.insert("residentKey".to_string(), Value::String("required".to_string())); + auth_sel_obj.insert("userVerification".to_string(), Value::String("required".to_string())); + } + } + + let mut state = serde_json::to_value(&state)?; + if let Some(rs) = state.get_mut("rs").and_then(Value::as_object_mut) { + rs.insert("policy".to_string(), Value::String("required".to_string())); + } + + Ok((options, serde_json::to_string(&state)?)) +} + +pub async fn create_webauthn_login_credential( + user_id: &UserId, + serialized_state: &str, + name: String, + device_response: RegisterPublicKeyCredentialCopy, + supports_prf: bool, + encrypted_user_key: Option, + encrypted_public_key: Option, + encrypted_private_key: Option, + conn: &DbConn, +) -> EmptyResult { + const MAX_CREDENTIALS_PER_USER: usize = 5; + + let state: PasskeyRegistration = serde_json::from_str(serialized_state)?; + let credential = WEBAUTHN.finish_passkey_registration(&device_response.into(), &state)?; + + let encrypted_user_key = normalize_optional_secret(encrypted_user_key); + let encrypted_public_key = normalize_optional_secret(encrypted_public_key); + let encrypted_private_key = normalize_optional_secret(encrypted_private_key); + + let mut registrations = get_webauthn_login_registrations(user_id, conn).await?; + if registrations.len() >= MAX_CREDENTIALS_PER_USER { + err!("Unable to complete WebAuthn registration.") + } + + // Avoid duplicate credential IDs. + if registrations.iter().any(|r| ct_eq(r.credential.cred_id(), credential.cred_id())) { + err!("Unable to complete WebAuthn registration.") + } + + let id = registrations.iter().map(|r| r.id).max().unwrap_or(0).saturating_add(1); + registrations.push(WebauthnRegistration { + id, + api_id: Some(Uuid::new_v4().to_string()), + name, + migrated: false, + credential, + supports_prf, + encrypted_user_key, + encrypted_public_key, + encrypted_private_key, + }); + + TwoFactor::new( + user_id.clone(), + TwoFactorType::WebauthnLoginCredential, + serde_json::to_string(®istrations)?, + ) + .save(conn) + .await?; + + Ok(()) +} + +pub async fn update_webauthn_login_credential_keys( + user_id: &UserId, + serialized_state: &str, + device_response: PublicKeyCredentialCopy, + encrypted_user_key: String, + encrypted_public_key: String, + encrypted_private_key: String, + conn: &DbConn, +) -> EmptyResult { + if encrypted_user_key.trim().is_empty() + || encrypted_public_key.trim().is_empty() + || encrypted_private_key.trim().is_empty() + { + err!("Unable to update credential.") + } + + let state: DiscoverableAuthentication = serde_json::from_str(serialized_state)?; + let rsp: PublicKeyCredential = device_response.into(); + + let (asserted_uuid, credential_id) = WEBAUTHN.identify_discoverable_authentication(&rsp)?; + let asserted_user_id: UserId = asserted_uuid.to_string().into(); + if asserted_user_id != *user_id { + err!("Invalid credential.") + } + + let mut registrations = get_webauthn_login_registrations(user_id, conn).await?; + let Some(registration) = registrations + .iter_mut() + .find(|r| ct_eq(r.credential.cred_id().as_slice(), credential_id)) + else { + err!("Invalid credential.") + }; + + let discoverable_key: DiscoverableKey = registration.credential.clone().into(); + let authentication_result = + WEBAUTHN.finish_discoverable_authentication(&rsp, state, &[discoverable_key])?; + + if !registration.supports_prf { + err!("Unable to update credential.") + } + + registration.encrypted_user_key = Some(encrypted_user_key); + registration.encrypted_public_key = Some(encrypted_public_key); + registration.encrypted_private_key = Some(encrypted_private_key); + registration.credential.update_credential(&authentication_result); + + TwoFactor::new( + user_id.clone(), + TwoFactorType::WebauthnLoginCredential, + serde_json::to_string(®istrations)?, + ) + .save(conn) + .await?; + + Ok(()) +} + +pub async fn delete_webauthn_login_credential(user_id: &UserId, id: &str, conn: &DbConn) -> EmptyResult { + let Some(mut tf) = + TwoFactor::find_by_user_and_type(user_id, TwoFactorType::WebauthnLoginCredential as i32, conn).await + else { + err!("Credential not found.") + }; + + let mut data: Vec = serde_json::from_str(&tf.data)?; + let Some(item_pos) = data.iter().position(|r| r.matches_login_credential_id(id)) else { + err!("Credential not found.") + }; + + let removed_item = data.remove(item_pos); + tf.data = serde_json::to_string(&data)?; + tf.save(conn).await?; + drop(tf); + + // If entry is migrated from u2f, delete the u2f entry as well. + if let Some(mut u2f) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::U2f as i32, conn).await { + let mut data: Vec = match serde_json::from_str(&u2f.data) { + Ok(d) => d, + Err(_) => err!("Error parsing U2F data"), + }; + + data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice()); + u2f.data = serde_json::to_string(&data)?; + u2f.save(conn).await?; + } + + Ok(()) +} + pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonResult { // Load saved credentials let creds: Vec = @@ -463,6 +737,94 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &Db ) } +pub fn generate_webauthn_discoverable_login() -> Result<(Value, String), Error> { + let (response, state) = WEBAUTHN.start_discoverable_authentication()?; + let mut options = serde_json::to_value(response.public_key)?; + if let Some(obj) = options.as_object_mut() { + obj.insert("userVerification".to_string(), Value::String("required".to_string())); + let need_empty_allow_credentials = match obj.get("allowCredentials") { + Some(value) => value.is_null(), + None => true, + }; + if need_empty_allow_credentials { + obj.insert("allowCredentials".to_string(), Value::Array(Vec::new())); + } + } + + let mut state = serde_json::to_value(&state)?; + if let Some(ast) = state.get_mut("ast").and_then(Value::as_object_mut) { + ast.insert("policy".to_string(), Value::String("required".to_string())); + } + + Ok((options, serde_json::to_string(&state)?)) +} + +pub async fn validate_webauthn_discoverable_login( + serialized_state: &str, + response: &str, + conn: &DbConn, +) -> Result { + let state: DiscoverableAuthentication = serde_json::from_str(serialized_state)?; + let rsp: PublicKeyCredentialCopy = serde_json::from_str(response)?; + let rsp: PublicKeyCredential = rsp.into(); + + let (uuid, credential_id) = WEBAUTHN.identify_discoverable_authentication(&rsp)?; + let user_id: UserId = uuid.to_string().into(); + + let mut registrations = get_webauthn_login_registrations(&user_id, conn).await?; + let Some(registration_idx) = registrations + .iter() + .position(|r| ct_eq(r.credential.cred_id().as_slice(), credential_id)) + else { + err!("Invalid credential.") + }; + + let discoverable_key: DiscoverableKey = registrations[registration_idx].credential.clone().into(); + let authentication_result = + WEBAUTHN.finish_discoverable_authentication(&rsp, state, &[discoverable_key])?; + + // Keep signature counters in sync. + let credential_updated = { + let registration = &mut registrations[registration_idx]; + registration.credential.update_credential(&authentication_result) == Some(true) + }; + if credential_updated { + TwoFactor::new( + user_id.clone(), + TwoFactorType::WebauthnLoginCredential, + serde_json::to_string(®istrations)?, + ) + .save(conn) + .await?; + } + + let prf_decryption_option = { + let registration = ®istrations[registration_idx]; + if registration.supports_prf { + match ( + registration.encrypted_private_key.clone(), + registration.encrypted_user_key.clone(), + registration.encrypted_public_key.as_ref(), + ) { + (Some(encrypted_private_key), Some(encrypted_user_key), Some(_)) => Some(WebauthnPrfDecryptionOption { + encrypted_private_key, + encrypted_user_key, + credential_id: BASE64URL_NOPAD.encode(registration.credential.cred_id().as_slice()), + transports: Vec::new(), + }), + _ => None, + } + } else { + None + } + }; + + Ok(WebauthnDiscoverableLoginResult { + user_id, + prf_decryption_option, + }) +} + async fn check_and_update_backup_eligible( user_id: &UserId, rsp: &PublicKeyCredential, diff --git a/src/api/identity.rs b/src/api/identity.rs index f5f2afd6..96e23188 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -8,6 +8,7 @@ use rocket::{ Route, }; use serde_json::Value; +use std::sync::LazyLock; use crate::{ api::{ @@ -38,6 +39,7 @@ use crate::{ pub fn routes() -> Vec { routes![ login, + get_webauthn_login_assertion_options, prelogin, identity_register, register_verification_email, @@ -49,6 +51,17 @@ pub fn routes() -> Vec { ] } +static WEBAUTHN_LOGIN_ASSERTION_ISSUER: LazyLock = + LazyLock::new(|| format!("{}|webauthn_login_assertion", CONFIG.domain_origin())); + +#[derive(Debug, Serialize, Deserialize)] +struct WebauthnLoginAssertionClaims { + nbf: i64, + exp: i64, + iss: String, + state: String, +} + #[post("/connect/token", data = "")] async fn login( data: Form, @@ -78,6 +91,19 @@ async fn login( _password_login(data, &mut user_id, &conn, &client_header.ip, &client_version).await } + "webauthn" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"), + "webauthn" => { + _check_is_some(&data.client_id, "client_id cannot be blank")?; + _check_is_some(&data.scope, "scope cannot be blank")?; + _check_is_some(&data.token, "token cannot be blank")?; + _check_is_some(&data.device_response, "device_response cannot be blank")?; + + _check_is_some(&data.device_identifier, "device_identifier cannot be blank")?; + _check_is_some(&data.device_name, "device_name cannot be blank")?; + _check_is_some(&data.device_type, "device_type cannot be blank")?; + + _webauthn_login(data, &mut user_id, &conn, &client_header.ip).await + } "client_credentials" => { _check_is_some(&data.client_id, "client_id cannot be blank")?; _check_is_some(&data.client_secret, "client_secret cannot be blank")?; @@ -304,7 +330,7 @@ async fn _sso_login( // We passed 2FA get auth tokens let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, conn).await?; - authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await + authenticated_response(&user, &mut device, auth_tokens, twofactor_token, None, conn, ip).await } async fn _password_login( @@ -426,7 +452,74 @@ async fn _password_login( let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id); - authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await + authenticated_response(&user, &mut device, auth_tokens, twofactor_token, None, conn, ip).await +} + +fn encode_webauthn_login_assertion_token(state: String) -> String { + let now = Utc::now(); + let claims = WebauthnLoginAssertionClaims { + nbf: now.timestamp(), + exp: (now + chrono::TimeDelta::try_minutes(17).unwrap()).timestamp(), + iss: WEBAUTHN_LOGIN_ASSERTION_ISSUER.to_string(), + state, + }; + + auth::encode_jwt(&claims) +} + +fn decode_webauthn_login_assertion_token(token: &str) -> ApiResult { + auth::decode_jwt(token, WEBAUTHN_LOGIN_ASSERTION_ISSUER.to_string()).map_res("Invalid passkey login token") +} + +async fn _webauthn_login(data: ConnectData, user_id: &mut Option, conn: &DbConn, ip: &ClientIp) -> JsonResult { + AuthMethod::Password.check_scope(data.scope.as_ref())?; + crate::ratelimit::check_limit_login(&ip.ip)?; + + let assertion_token = data.token.as_ref().expect("No passkey assertion token provided"); + let device_response = data.device_response.as_ref().expect("No passkey device response provided"); + let assertion_claims = decode_webauthn_login_assertion_token(assertion_token)?; + + let webauthn_login_result = + webauthn::validate_webauthn_discoverable_login(&assertion_claims.state, device_response, conn).await?; + let Some(user) = User::find_by_uuid(&webauthn_login_result.user_id, conn).await else { + err!("Invalid credential.") + }; + + // Set the user_id here to be passed back used for event logging. + *user_id = Some(user.uuid.clone()); + + if !user.enabled { + err!( + "This user has been disabled", + format!("IP: {}. Username: {}.", ip.ip, user.display_name()), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + + if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() { + err!( + "Please verify your email before trying again.", + format!("IP: {}. Username: {}.", ip.ip, user.display_name()), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + + let mut device = get_device(&data, conn, &user).await?; + let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id); + authenticated_response( + &user, + &mut device, + auth_tokens, + None, + webauthn_login_result.prf_decryption_option, + conn, + ip, + ) + .await } async fn authenticated_response( @@ -434,6 +527,7 @@ async fn authenticated_response( device: &mut Device, auth_tokens: auth::AuthTokens, twofactor_token: Option, + webauthn_prf_option: Option, conn: &DbConn, ip: &ClientIp, ) -> JsonResult { @@ -472,10 +566,7 @@ async fn authenticated_response( "Memory": user.client_kdf_memory, "Parallelism": user.client_kdf_parallelism }, - // This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps. - // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 "MasterKeyEncryptedUserKey": user.akey, - "MasterKeyWrappedUserKey": user.akey, "Salt": user.email }) } else { @@ -517,6 +608,15 @@ async fn authenticated_response( }, }); + if let Some(webauthn_prf_option) = webauthn_prf_option { + result["UserDecryptionOptions"]["WebAuthnPrfOption"] = json!({ + "EncryptedPrivateKey": webauthn_prf_option.encrypted_private_key, + "EncryptedUserKey": webauthn_prf_option.encrypted_user_key, + "CredentialId": webauthn_prf_option.credential_id, + "Transports": webauthn_prf_option.transports, + }); + } + if !user.akey.is_empty() { result["Key"] = Value::String(user.akey.clone()); } @@ -623,10 +723,7 @@ async fn _user_api_key_login( "Memory": user.client_kdf_memory, "Parallelism": user.client_kdf_parallelism }, - // This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps. - // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 "MasterKeyEncryptedUserKey": user.akey, - "MasterKeyWrappedUserKey": user.akey, "Salt": user.email }) } else { @@ -792,7 +889,7 @@ async fn twofactor_auth( } // Remove all twofactors from the user - TwoFactor::delete_all_by_user(&user.uuid, conn).await?; + TwoFactor::delete_all_2fa_by_user(&user.uuid, conn).await?; enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?; log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await; @@ -927,6 +1024,22 @@ async fn _json_err_twofactor( Ok(result) } +#[get("/accounts/webauthn/assertion-options")] +fn get_webauthn_login_assertion_options() -> JsonResult { + if !CONFIG.domain_set() { + err!("`DOMAIN` environment variable is not set. Webauthn disabled") + } + + let (options, serialized_state) = webauthn::generate_webauthn_discoverable_login()?; + let token = encode_webauthn_login_assertion_token(serialized_state); + + Ok(Json(json!({ + "options": options, + "token": token, + "object": "webAuthnLoginAssertionOptions" + }))) +} + #[post("/accounts/prelogin", data = "")] async fn prelogin(data: Json, conn: DbConn) -> Json { _prelogin(data, conn).await @@ -1012,7 +1125,7 @@ struct ConnectData { #[field(name = uncased("refreshtoken"))] refresh_token: Option, - // Needed for grant_type = "password" | "client_credentials" + // Needed for grant_type = "password" | "client_credentials" | "webauthn" #[field(name = uncased("client_id"))] #[field(name = uncased("clientid"))] client_id: Option, // web, cli, desktop, browser, mobile @@ -1025,6 +1138,11 @@ struct ConnectData { scope: Option, #[field(name = uncased("username"))] username: Option, + #[field(name = uncased("token"))] + token: Option, + #[field(name = uncased("device_response"))] + #[field(name = uncased("deviceresponse"))] + device_response: Option, #[field(name = uncased("device_identifier"))] #[field(name = uncased("deviceidentifier"))] diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 0607f146..1ba01222 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -252,6 +252,31 @@ impl OrgPolicy { }} } + /// Returns true if the user belongs to an accepted/confirmed org that has + /// an enabled policy with the given raw policy type ID. + pub async fn has_active_raw_policy_for_user(user_uuid: &UserId, policy_type: i32, conn: &DbConn) -> bool { + db_run! { conn: { + use diesel::dsl::count_star; + + org_policies::table + .inner_join( + users_organizations::table.on( + users_organizations::org_uuid.eq(org_policies::org_uuid) + .and(users_organizations::user_uuid.eq(user_uuid))) + ) + .filter( + users_organizations::status.eq(MembershipStatus::Accepted as i32) + .or(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) + ) + .filter(org_policies::atype.eq(policy_type)) + .filter(org_policies::enabled.eq(true)) + .select(count_star()) + .first::(conn) + .map(|count| count > 0) + .unwrap_or(false) + }} + } + /// Returns true if the user belongs to an org that has enabled the specified policy type, /// and the user is not an owner or admin of that org. This is only useful for checking /// applicability of policy types that have these particular semantics. diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index f0a1e663..29621b20 100644 --- a/src/db/models/two_factor.rs +++ b/src/db/models/two_factor.rs @@ -39,6 +39,7 @@ pub enum TwoFactorType { EmailVerificationChallenge = 1002, WebauthnRegisterChallenge = 1003, WebauthnLoginChallenge = 1004, + WebauthnLoginCredential = 1005, // Special type for Protected Actions verification via email ProtectedActions = 2000, @@ -150,6 +151,18 @@ impl TwoFactor { }} } + pub async fn delete_all_2fa_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { + db_run! { conn: { + diesel::delete( + twofactor::table + .filter(twofactor::user_uuid.eq(user_uuid)) + .filter(twofactor::atype.lt(1000)) + ) + .execute(conn) + .map_res("Error deleting 2fa providers") + }} + } + pub async fn migrate_u2f_to_webauthn(conn: &DbConn) -> EmptyResult { let u2f_factors = db_run! { conn: { twofactor::table @@ -192,6 +205,7 @@ impl TwoFactor { let new_reg = WebauthnRegistration { id: reg.id, + api_id: None, migrated: true, name: reg.name.clone(), credential: Credential { @@ -209,6 +223,10 @@ impl TwoFactor { attestation_format: AttestationFormat::None, } .into(), + supports_prf: false, + encrypted_user_key: None, + encrypted_public_key: None, + encrypted_private_key: None, }; webauthn_regs.push(new_reg); @@ -268,9 +286,14 @@ impl From for WebauthnRegistration { fn from(value: WebauthnRegistrationV3) -> Self { Self { id: value.id, + api_id: None, name: value.name, migrated: value.migrated, credential: Credential::from(value.credential).into(), + supports_prf: false, + encrypted_user_key: None, + encrypted_public_key: None, + encrypted_private_key: None, } } } diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index 1859c1ea..bbd31489 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -54,33 +54,35 @@ 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 */ +/* Hide Log in with passkey on the login page when SSO-only mode is enabled */ {{#if (webver ">=2025.5.1")}} +{{#if sso_only}} .vw-passkey-login { @extend %vw-hide; } +{{/if}} {{else}} +{{#if sso_only}} 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 */ +/* Hide the or text only if passkey login is hidden (SSO-only mode) */ {{#if (webver ">=2025.5.1")}} -{{#if (or (not sso_enabled) sso_only)}} +{{#if sso_only}} .vw-or-text { @extend %vw-hide; } {{/if}} {{else}} +{{#if sso_only}} app-root ng-component > form > div:nth-child(1) > div:nth-child(3) > div:nth-child(2) { @extend %vw-hide; } {{/if}} +{{/if}} /* Hide the `Other` button on the login page */ {{#if (or (not sso_enabled) sso_only)}}