From f5bf0edf8c3e87d1b0e6d51e6d1753fa25a2bc50 Mon Sep 17 00:00:00 2001 From: zUnixorn Date: Wed, 4 Jun 2025 03:08:12 +0200 Subject: [PATCH 1/5] implement working webauthn login * without encryption not implemented * deletion not implemented * does not handle errors well --- .../down.sql | 1 + .../up.sql | 11 + src/api/core/mod.rs | 153 ++++++- src/api/core/two_factor/webauthn.rs | 39 +- src/api/identity.rs | 303 ++++++++++++- src/db/models/mod.rs | 2 + src/db/models/user.rs | 3 +- src/db/models/web_authn_credential.rs | 89 ++++ src/db/schema.rs | 398 ++++++++++++++++++ src/db/schemas/sqlite/schema.rs | 15 + .../templates/scss/vaultwarden.scss.hbs | 6 +- 11 files changed, 998 insertions(+), 22 deletions(-) create mode 100644 migrations/sqlite/2025-06-03-173809_create_web_authn_credentials_table/down.sql create mode 100644 migrations/sqlite/2025-06-03-173809_create_web_authn_credentials_table/up.sql create mode 100644 src/db/models/web_authn_credential.rs create mode 100644 src/db/schema.rs diff --git a/migrations/sqlite/2025-06-03-173809_create_web_authn_credentials_table/down.sql b/migrations/sqlite/2025-06-03-173809_create_web_authn_credentials_table/down.sql new file mode 100644 index 00000000..d9a93fe9 --- /dev/null +++ b/migrations/sqlite/2025-06-03-173809_create_web_authn_credentials_table/down.sql @@ -0,0 +1 @@ +-- This file should undo anything in `up.sql` diff --git a/migrations/sqlite/2025-06-03-173809_create_web_authn_credentials_table/up.sql b/migrations/sqlite/2025-06-03-173809_create_web_authn_credentials_table/up.sql new file mode 100644 index 00000000..9851088c --- /dev/null +++ b/migrations/sqlite/2025-06-03-173809_create_web_authn_credentials_table/up.sql @@ -0,0 +1,11 @@ +CREATE TABLE web_authn_credentials ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT NOT NULL, + name TEXT NOT NULL, + credential TEXT NOT NULL, + supports_prf BOOLEAN NOT NULL, + encrypted_user_key TEXT NOT NULL, + encrypted_public_key TEXT NOT NULL, + encrypted_private_key TEXT NOT NULL, + FOREIGN KEY(user_uuid) REFERENCES users(uuid) +); diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index 51c49cf6..73c3d813 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -8,6 +8,9 @@ mod public; mod sends; pub mod two_factor; +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; +use crate::db::models::WebAuthnCredential; pub use accounts::purge_auth_requests; pub use ciphers::{purge_trashed_ciphers, CipherData, CipherSyncData, CipherSyncType}; pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job}; @@ -18,7 +21,7 @@ pub use sends::purge_sends; pub fn routes() -> 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, post_api_webauthn, post_api_webauthn_attestation_options]; let mut routes = Vec::new(); routes.append(&mut accounts::routes()); @@ -48,15 +51,20 @@ pub fn events_routes() -> Vec { // Move this somewhere else // use rocket::{serde::json::Json, serde::json::Value, Catcher, Route}; - +use rocket::http::Status; +use webauthn_rs::proto::UserVerificationPolicy; +use webauthn_rs::RegistrationState; use crate::{ api::{JsonResult, Notify, UpdateType}, auth::Headers, db::DbConn, error::Error, http_client::make_http_request, - util::parse_experimental_client_feature_flags, }; +use crate::api::core::two_factor::webauthn::{RegisterPublicKeyCredentialCopy, WebauthnConfig}; +use crate::api::{ApiResult, PasswordOrOtpData}; +use crate::db::models::UserId; +use crate::util::parse_experimental_client_feature_flags; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -184,14 +192,143 @@ fn version() -> Json<&'static str> { Json(crate::VERSION.unwrap_or_default()) } +static WEBAUTHN_STATES: OnceLock>> = OnceLock::new(); + +#[post("/webauthn/attestation-options", data = "")] +async fn post_api_webauthn_attestation_options(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { + let data: PasswordOrOtpData = data.into_inner(); + let user = headers.user; + + // TODO what does delete_if_valid do? + data.validate(&user, false, &mut conn).await?; + + // C# does this check as well + // await ValidateIfUserCanUsePasskeyLogin(user.Id); + + // TODO add existing keys here when the table exists + // let registrations = get_webauthn_registrations(&user.uuid, &mut conn) + // .await? + // .1 + // .into_iter() + // .map(|r| r.credential.cred_id) // We return the credentialIds to the clients to avoid double registering + // .collect(); + + let registrations = Vec::new(); + + let (challenge, state) = WebauthnConfig::load(true).generate_challenge_register_options( + user.uuid.as_bytes().to_vec(), + user.email, + user.name, + Some(registrations), + Some(UserVerificationPolicy::Required), + None, + )?; + + WEBAUTHN_STATES.get_or_init(|| Mutex::new(HashMap::new())).lock().unwrap().insert(user.uuid, state); + + let mut options = serde_json::to_value(challenge.public_key)?; + options["status"] = "ok".into(); + options["errorMessage"] = "".into(); + // TODO does this need to be set? + options["extensions"] = Value::Object(serde_json::Map::new()); + + // TODO make this nicer + let mut webauthn_credential_create_options = Value::Object(serde_json::Map::new()); + webauthn_credential_create_options["options"] = options; + webauthn_credential_create_options["object"] = "webauthnCredentialCreateOptions".into(); + + // TODO this hopefully shouldn't be needed + // webauthn_credential_create_options["token"] = "atoken".into(); + + Ok(Json(webauthn_credential_create_options)) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +// TODO remove allow dead_code +#[allow(dead_code)] +struct WebAuthnLoginCredentialCreateRequest { + device_response: RegisterPublicKeyCredentialCopy, + name: String, + // TODO this is hopefully not needed + // token: String, + supports_prf: bool, + encrypted_user_key: String, + encrypted_public_key: String, + encrypted_private_key: String, +} + +#[post("/webauthn", data = "")] +async fn post_api_webauthn(data: Json, headers: Headers, mut conn: DbConn) -> ApiResult { + // this check await ValidateIfUserCanUsePasskeyLogin(user.Id); again + let data: WebAuthnLoginCredentialCreateRequest = data.into_inner(); + // let data: WebAuthnLoginCredentialCreateRequest = serde_json::from_str(&data)?; + let user = headers.user; + + // TODO Retrieve and delete the saved challenge state here + + + // Verify the credentials with the saved state + let (credential, _data) = { + let mut states = WEBAUTHN_STATES.get().unwrap().lock().unwrap(); + let state = states.remove(&user.uuid).unwrap(); + + WebauthnConfig::load(true).register_credential(&data.device_response.into(), &state, |_| Ok(false))? + }; + + // TODO add existing keys here when the table exists + // let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &mut conn).await?.1; + // // TODO: Check for repeated ID's + // registrations.push(WebauthnRegistration { + // id: data.id.into_i32()?, + // name: data.name, + // migrated: false, + // + // credential, + // }); + + // let registrations = Vec::new(); + + // TODO Save the registration + // TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?) + // .save(&mut conn) + // .await?; + + 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(&mut conn).await?; + + Ok(Status::Ok) +} + #[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 +async fn get_api_webauthn(headers: Headers, mut conn: DbConn) -> Json { + let user = headers.user; + + let data = WebAuthnCredential::find_all_by_user(&user.uuid, &mut conn) + .await + .into_iter() + .map(|wac| { + // TODO generate prfStatus from GetPrfStatus() in C# + json!({ + "id": wac.uuid, + "name": wac.name, + "prfStatus": 0, + "encryptedUserKey": wac.encrypted_user_key, + "encryptedPublicKey": wac.encrypted_public_key, + "object": "webauthnCredential", + }) + }).collect::(); + Json(json!({ "object": "list", - "data": [], + "data": data, "continuationToken": null })) } diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 614c5df3..9600573e 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -45,20 +45,22 @@ pub struct U2FRegistration { pub migrated: Option, } -struct WebauthnConfig { +pub(crate) struct WebauthnConfig { url: String, origin: Url, rpid: String, + require_resident_key: bool, } impl WebauthnConfig { - fn load() -> Webauthn { + pub(crate) fn load(require_resident_key: bool) -> Webauthn { let domain = CONFIG.domain(); let domain_origin = CONFIG.domain_origin(); Webauthn::new(Self { rpid: Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default(), url: domain, origin: Url::parse(&domain_origin).unwrap(), + require_resident_key, }) } } @@ -82,6 +84,26 @@ impl webauthn_rs::WebauthnConfig for WebauthnConfig { fn get_require_uv_consistency(&self) -> bool { false } + + fn get_require_resident_key(&self) -> bool { + self.require_resident_key + } + + // TODO check if this still works with 2FA + fn get_credential_algorithms(&self) -> Vec { + vec![ + COSEAlgorithm::ES256, + COSEAlgorithm::RS256, + COSEAlgorithm::PS256, + COSEAlgorithm::ES384, + COSEAlgorithm::RS384, + COSEAlgorithm::PS384, + COSEAlgorithm::ES512, + COSEAlgorithm::RS512, + COSEAlgorithm::PS512, + COSEAlgorithm::EDDSA, + ] + } } #[derive(Debug, Serialize, Deserialize)] @@ -124,6 +146,7 @@ async fn get_webauthn(data: Json, headers: Headers, mut conn: }))) } +// TODO Creation call #[post("/two-factor/get-webauthn-challenge", data = "")] async fn generate_webauthn_challenge(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let data: PasswordOrOtpData = data.into_inner(); @@ -138,7 +161,7 @@ async fn generate_webauthn_challenge(data: Json, headers: Hea .map(|r| r.credential.cred_id) // We return the credentialIds to the clients to avoid double registering .collect(); - let (challenge, state) = WebauthnConfig::load().generate_challenge_register_options( + let (challenge, state) = WebauthnConfig::load(false).generate_challenge_register_options( user.uuid.as_bytes().to_vec(), user.email, user.name, @@ -168,11 +191,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, pub r#type: String, + pub extensions: Option, } // This is copied from AuthenticatorAttestationResponseRaw to change clientDataJSON to clientDataJson @@ -237,6 +261,7 @@ impl From for PublicKeyCredential { } } +// TODO Confirmation call #[post("/two-factor/webauthn", data = "")] async fn activate_webauthn(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let data: EnableWebauthnData = data.into_inner(); @@ -262,7 +287,7 @@ async fn activate_webauthn(data: Json, headers: Headers, mut // Verify the credentials with the saved state let (credential, _data) = - WebauthnConfig::load().register_credential(&data.device_response.into(), &state, |_| Ok(false))?; + WebauthnConfig::load(false).register_credential(&data.device_response.into(), &state, |_| Ok(false))?; let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &mut conn).await?.1; // TODO: Check for repeated ID's @@ -373,7 +398,7 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &mut DbConn) -> Jso // Generate a challenge based on the credentials let ext = RequestAuthenticationExtensions::builder().appid(format!("{}/app-id.json", &CONFIG.domain())).build(); - let (response, state) = WebauthnConfig::load().generate_challenge_authenticate_options(creds, Some(ext))?; + let (response, state) = WebauthnConfig::load(false).generate_challenge_authenticate_options(creds, Some(ext))?; // Save the challenge state for later validation TwoFactor::new(user_id.clone(), TwoFactorType::WebauthnLoginChallenge, serde_json::to_string(&state)?) @@ -407,7 +432,7 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &mu // If the credential we received is migrated from U2F, enable the U2F compatibility //let use_u2f = registrations.iter().any(|r| r.migrated && r.credential.cred_id == rsp.raw_id.0); - let (cred_id, auth_data) = WebauthnConfig::load().authenticate_credential(&rsp, &state)?; + let (cred_id, auth_data) = WebauthnConfig::load(false).authenticate_credential(&rsp, &state)?; for reg in &mut registrations { if ®.credential.cred_id == cred_id { diff --git a/src/api/identity.rs b/src/api/identity.rs index 9aba23d2..fca0aced 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; use chrono::Utc; use num_traits::FromPrimitive; use rocket::serde::json::Json; @@ -6,7 +8,9 @@ use rocket::{ Route, }; use serde_json::Value; - +use webauthn_rs::AuthenticationState; +use webauthn_rs::base64_data::Base64UrlSafeData; +use webauthn_rs::proto::{AuthenticatorAssertionResponseRaw, Credential, PublicKeyCredential}; use crate::{ api::{ core::{ @@ -23,9 +27,10 @@ use crate::{ error::MapResult, mail, util, CONFIG, }; +use crate::api::core::two_factor::webauthn::WebauthnConfig; pub fn routes() -> Vec { - routes![login, prelogin, identity_register, register_verification_email, register_finish] + routes![login, prelogin, identity_register, register_verification_email, register_finish, get_web_authn_assertion_options] } #[post("/connect/token", data = "")] @@ -66,7 +71,20 @@ async fn login( _check_is_some(&data.device_type, "device_type cannot be blank")?; _api_key_login(data, &mut user_id, &mut conn, &client_header.ip).await - } + }, + "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.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")?; + + _check_is_some(&data.device_response, "device_response cannot be blank")?; + _check_is_some(&data.token, "token cannot be blank")?; + + _webauthn_login(data, &mut user_id, &mut conn, &client_header.ip).await + }, t => err!("Invalid type", t), }; @@ -100,6 +118,254 @@ async fn login( login_result } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PublicKeyCredentialCopy { + pub id: String, + pub raw_id: Base64UrlSafeData, + pub response: AuthenticatorAssertionResponseRawCopy, + pub r#type: String, + // TODO think about what to do with this field, currently this is ignored in the conversion + pub extensions: Option, +} + +// This is copied from AuthenticatorAssertionResponseRaw to change clientDataJSON to clientDataJson +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthenticatorAssertionResponseRawCopy { + pub authenticator_data: Base64UrlSafeData, + #[serde(rename = "clientDataJson", alias = "clientDataJSON")] + pub client_data_json: Base64UrlSafeData, + pub signature: Base64UrlSafeData, + pub user_handle: Option, +} + +impl From for PublicKeyCredential { + fn from(p: PublicKeyCredentialCopy) -> Self { + Self { + id: p.id, + raw_id: p.raw_id, + response: AuthenticatorAssertionResponseRaw { + authenticator_data: p.response.authenticator_data, + client_data_json: p.response.client_data_json, + signature: p.response.signature, + user_handle: p.response.user_handle, + }, + extensions: None, + type_: p.r#type, + } + } +} + +async fn _webauthn_login( + data: ConnectData, + user_id: &mut Option, + conn: &mut DbConn, + ip: &ClientIp, +) -> JsonResult { + // Validate scope + let scope = data.scope.as_ref().unwrap(); + if scope != "api offline_access" { + err!("Scope not supported") + } + let scope_vec = vec!["api".into(), "offline_access".into()]; + + // Ratelimit the login + crate::ratelimit::check_limit_login(&ip.ip)?; + + let device_response: PublicKeyCredentialCopy = serde_json::from_str(&data.device_response.as_ref().unwrap())?; + let mut user = if let Some(uuid) = device_response.response.user_handle.clone() { + // TODO handle error + let uuid = UserId(String::from_utf8(uuid.0).unwrap()); + User::find_by_uuid(&uuid, conn).await.unwrap() + } else { + err!("DeviceResponse needs the userHandle field") + }; + let username = user.name.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 + } + ) + } + + let web_authn_credentials = WebAuthnCredential::find_all_by_user(&user.uuid, conn).await; + + let credentials = web_authn_credentials + .iter() + .map(|c| { + serde_json::from_str(&c.credential) + }).collect::, _>>()?; + + let web_authn_credential = { + let token = data.token.as_ref().unwrap(); + let mut states = WEBAUTHN_AUTHENTICATION_STATES.get().unwrap().lock().unwrap(); + let mut state = states.remove(token).unwrap(); + let resp = device_response.into(); + + state.set_allowed_credentials(credentials); + + // TODO update respective credential in database + let (credential_id, auth_data) = WebauthnConfig::load(true) + .authenticate_credential(&resp, &state)?; + + if !auth_data.user_verified { + // TODO throw an error here + panic!() + } + + web_authn_credentials.into_iter() + .find(|c| &serde_json::from_str::(&c.credential).unwrap().cred_id == credential_id) + .unwrap() + + /* TODO return this error on failure + err!( + "Username or password is incorrect. Try again", + format!("IP: {}. Username: {username}.", ip.ip), + ErrorEvent { + event: EventType::UserFailedLogIn, + } + ) + */ + }; + + let now = Utc::now().naive_utc(); + + if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() { + if user.last_verifying_at.is_none() + || now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds() + > CONFIG.signups_verify_resend_time() as i64 + { + let resend_limit = CONFIG.signups_verify_resend_limit() as i32; + if resend_limit == 0 || user.login_verify_count < resend_limit { + // We want to send another email verification if we require signups to verify + // their email address, and we haven't sent them a reminder in a while... + user.last_verifying_at = Some(now); + user.login_verify_count += 1; + + if let Err(e) = user.save(conn).await { + error!("Error updating user: {e:#?}"); + } + + if let Err(e) = mail::send_verify_email(&user.email, &user.uuid).await { + error!("Error auto-sending email verification email: {e:#?}"); + } + } + } + + // We still want the login to fail until they actually verified the email address + err!( + "Please verify your email before trying again.", + format!("IP: {}. Username: {username}.", ip.ip), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + + let (mut device, new_device) = get_device(&data, conn, &user).await; + + // TODO is this needed with passkeys? + if CONFIG.mail_enabled() && new_device { + if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await { + error!("Error sending new device email: {e:#?}"); + + if CONFIG.require_device_email() { + err!( + "Could not send login notification email. Please contact your administrator.", + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + } + } + + // register push device + if !new_device { + register_push_device(&mut device, conn).await?; + } + + // Common + // --- + // Disabled this variable, it was used to generate the JWT + // Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out + // See: https://github.com/dani-garcia/vaultwarden/issues/4156 + // --- + // let members = Membership::find_confirmed_by_user(&user.uuid, conn).await; + let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec, data.client_id); + device.save(conn).await?; + + // Fetch all valid Master Password Policies and merge them into one with all trues and largest numbers as one policy + let master_password_policies: Vec = + OrgPolicy::find_accepted_and_confirmed_by_user_and_active_policy( + &user.uuid, + OrgPolicyType::MasterPassword, + conn, + ) + .await + .into_iter() + .filter_map(|p| serde_json::from_str(&p.data).ok()) + .collect(); + + // NOTE: Upstream still uses PascalCase here for `Object`! + let master_password_policy = if !master_password_policies.is_empty() { + let mut mpp_json = json!(master_password_policies.into_iter().reduce(|acc, policy| { + MasterPasswordPolicy { + min_complexity: acc.min_complexity.max(policy.min_complexity), + min_length: acc.min_length.max(policy.min_length), + require_lower: acc.require_lower || policy.require_lower, + require_upper: acc.require_upper || policy.require_upper, + require_numbers: acc.require_numbers || policy.require_numbers, + require_special: acc.require_special || policy.require_special, + enforce_on_login: acc.enforce_on_login || policy.enforce_on_login, + } + })); + mpp_json["Object"] = json!("masterPasswordPolicy"); + mpp_json + } else { + json!({"Object": "masterPasswordPolicy"}) + }; + + let result = json!({ + "access_token": access_token, + "expires_in": expires_in, + "token_type": "Bearer", + "refresh_token": device.refresh_token, + "Key": user.akey, + "PrivateKey": user.private_key, + + "Kdf": user.client_kdf_type, + "KdfIterations": user.client_kdf_iter, + "KdfMemory": user.client_kdf_memory, + "KdfParallelism": user.client_kdf_parallelism, + "ResetMasterPassword": false, // TODO: Same as above + "ForcePasswordReset": false, + "MasterPasswordPolicy": master_password_policy, + + "scope": scope, + "UserDecryptionOptions": { + "HasMasterPassword": !user.password_hash.is_empty(), + "WebAuthnPrfOption": { + "EncryptedPrivateKey": web_authn_credential.encrypted_private_key, + "EncryptedUserKey": web_authn_credential.encrypted_user_key, + }, + "Object": "userDecryptionOptions" + }, + }); + + info!("User {username} logged in successfully. IP: {}", ip.ip); + Ok(Json(result)) +} + async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult { // Extract token let token = data.refresh_token.unwrap(); @@ -697,6 +963,30 @@ async fn identity_register(data: Json, conn: DbConn) -> JsonResult _register(data, false, conn).await } +static WEBAUTHN_AUTHENTICATION_STATES: OnceLock>> = OnceLock::new(); + +#[get("/accounts/webauthn/assertion-options")] +fn get_web_authn_assertion_options() -> JsonResult { + let (options, state) = WebauthnConfig::load(true) + .generate_challenge_authenticate_options( + Vec::new(), + None, + )?; + + // TODO this needs to be solved in another way to avoid DoS + let t = util::get_uuid(); + WEBAUTHN_AUTHENTICATION_STATES.get_or_init(|| Mutex::new(HashMap::new())).lock().unwrap().insert(t.clone(), state); + + let options = serde_json::to_value(options.public_key)?; + + + Ok(Json(json!({ + "options": options, + "token": t, + "object": "webAuthnLoginAssertionOptions" + }))) +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RegisterVerificationData { @@ -809,6 +1099,13 @@ struct ConnectData { two_factor_remember: Option, #[field(name = uncased("authrequest"))] auth_request: Option, + + // Needed for "login with passkey" + #[field(name = uncased("deviceresponse"))] + device_response: Option, + // TODO this may be removed again if implemented correctly + #[field(name = uncased("token"))] + token: Option, } fn _check_is_some(value: &Option, msg: &str) -> EmptyResult { diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 6b569a56..d33a0477 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -15,6 +15,7 @@ mod two_factor; mod two_factor_duo_context; mod two_factor_incomplete; mod user; +mod web_authn_credential; pub use self::attachment::{Attachment, AttachmentId}; pub use self::auth_request::{AuthRequest, AuthRequestId}; @@ -39,3 +40,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, User, UserId, UserKdfType, UserStampException}; +pub use self::web_authn_credential::WebAuthnCredential; diff --git a/src/db/models/user.rs b/src/db/models/user.rs index b5b78ad0..d38ea5ba 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -476,4 +476,5 @@ impl Invitation { )] #[deref(forward)] #[from(forward)] -pub struct UserId(String); +// TODO create a way to construct this +pub struct UserId(pub String); diff --git a/src/db/models/web_authn_credential.rs b/src/db/models/web_authn_credential.rs new file mode 100644 index 00000000..5e8c1a06 --- /dev/null +++ b/src/db/models/web_authn_credential.rs @@ -0,0 +1,89 @@ +use derive_more::{AsRef, Deref, Display, From}; +use macros::UuidFromParam; +use crate::api::EmptyResult; +use crate::db::DbConn; +use super::UserId; + +db_object! { + #[derive(Debug, Identifiable, Queryable, Insertable, AsChangeset, Deserialize, Serialize)] + #[diesel(table_name = web_authn_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: String, + pub encrypted_public_key: String, + pub encrypted_private_key: String, + } +} + +impl WebAuthnCredential { + pub fn new( + user_uuid: UserId, + name: String, + credential: String, + supports_prf: bool, + encrypted_user_key: String, + encrypted_public_key: String, + encrypted_private_key: String, + ) -> Self { + Self { + uuid: WebAuthnCredentialId(crate::util::get_uuid()), + user_uuid, + name, + credential, + supports_prf, + encrypted_user_key, + encrypted_public_key, + encrypted_private_key, + } + } + + pub async fn save(&self, conn: &mut DbConn) -> EmptyResult { + // TODO add mysql and postgres + db_run! { conn: + sqlite { + match diesel::insert_into(web_authn_credentials::table) + .values(WebAuthnCredentialDb::to_db(self)) + .execute(conn) + { + Ok(_) => Ok(()), + Err(e) => Err(e.into()), + } + } + } + } + + pub async fn find_all_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Vec { + db_run! { conn: { + web_authn_credentials::table + .filter(web_authn_credentials::user_uuid.eq(user_uuid)) + .load::(conn) + .ok() + .from_db() + // TODO do not unwrap + }}.unwrap() + } +} + +#[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 new file mode 100644 index 00000000..279f3000 --- /dev/null +++ b/src/db/schema.rs @@ -0,0 +1,398 @@ +// @generated automatically by Diesel CLI. + +diesel::table! { + attachments (id) { + id -> Text, + cipher_uuid -> Text, + file_name -> Text, + file_size -> Integer, + akey -> Nullable, + } +} + +diesel::table! { + auth_requests (uuid) { + uuid -> Text, + user_uuid -> Text, + organization_uuid -> Nullable, + request_device_identifier -> Text, + device_type -> Integer, + request_ip -> Text, + response_device_id -> Nullable, + access_code -> Text, + public_key -> Text, + enc_key -> Nullable, + master_password_hash -> Nullable, + approved -> Nullable, + creation_date -> Timestamp, + response_date -> Nullable, + authentication_date -> Nullable, + } +} + +diesel::table! { + ciphers (uuid) { + uuid -> Text, + created_at -> Timestamp, + updated_at -> Timestamp, + user_uuid -> Nullable, + organization_uuid -> Nullable, + atype -> Integer, + name -> Text, + notes -> Nullable, + fields -> Nullable, + data -> Text, + password_history -> Nullable, + deleted_at -> Nullable, + reprompt -> Nullable, + key -> Nullable, + } +} + +diesel::table! { + ciphers_collections (cipher_uuid, collection_uuid) { + cipher_uuid -> Text, + collection_uuid -> Text, + } +} + +diesel::table! { + collections (uuid) { + uuid -> Text, + org_uuid -> Text, + name -> Text, + external_id -> Nullable, + } +} + +diesel::table! { + collections_groups (rowid) { + rowid -> Integer, + collections_uuid -> Text, + groups_uuid -> Text, + read_only -> Bool, + hide_passwords -> Bool, + manage -> Bool, + } +} + +diesel::table! { + devices (uuid, user_uuid) { + uuid -> Text, + created_at -> Timestamp, + updated_at -> Timestamp, + user_uuid -> Text, + name -> Text, + atype -> Integer, + push_token -> Nullable, + refresh_token -> Text, + twofactor_remember -> Nullable, + push_uuid -> Nullable, + } +} + +diesel::table! { + emergency_access (uuid) { + uuid -> Text, + grantor_uuid -> Nullable, + grantee_uuid -> Nullable, + email -> Nullable, + key_encrypted -> Nullable, + atype -> Integer, + status -> Integer, + wait_time_days -> Integer, + recovery_initiated_at -> Nullable, + last_notification_at -> Nullable, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + +diesel::table! { + event (uuid) { + uuid -> Text, + event_type -> Integer, + user_uuid -> Nullable, + org_uuid -> Nullable, + cipher_uuid -> Nullable, + collection_uuid -> Nullable, + group_uuid -> Nullable, + org_user_uuid -> Nullable, + act_user_uuid -> Nullable, + device_type -> Nullable, + ip_address -> Nullable, + event_date -> Timestamp, + policy_uuid -> Nullable, + provider_uuid -> Nullable, + provider_user_uuid -> Nullable, + provider_org_uuid -> Nullable, + } +} + +diesel::table! { + favorites (user_uuid, cipher_uuid) { + user_uuid -> Text, + cipher_uuid -> Text, + } +} + +diesel::table! { + folders (uuid) { + uuid -> Text, + created_at -> Timestamp, + updated_at -> Timestamp, + user_uuid -> Text, + name -> Text, + } +} + +diesel::table! { + folders_ciphers (cipher_uuid, folder_uuid) { + cipher_uuid -> Text, + folder_uuid -> Text, + } +} + +diesel::table! { + groups (uuid) { + uuid -> Text, + organizations_uuid -> Text, + name -> Text, + access_all -> Bool, + external_id -> Nullable, + creation_date -> Timestamp, + revision_date -> Timestamp, + } +} + +diesel::table! { + groups_users (rowid) { + rowid -> Integer, + groups_uuid -> Text, + users_organizations_uuid -> Text, + } +} + +diesel::table! { + invitations (email) { + email -> Text, + } +} + +diesel::table! { + org_policies (uuid) { + uuid -> Text, + org_uuid -> Text, + atype -> Integer, + enabled -> Bool, + data -> Text, + } +} + +diesel::table! { + organization_api_key (uuid, org_uuid) { + uuid -> Text, + org_uuid -> Text, + atype -> Integer, + api_key -> Text, + revision_date -> Timestamp, + } +} + +diesel::table! { + organizations (uuid) { + uuid -> Text, + name -> Text, + billing_email -> Text, + private_key -> Nullable, + public_key -> Nullable, + } +} + +diesel::table! { + sends (uuid) { + uuid -> Text, + user_uuid -> Nullable, + organization_uuid -> Nullable, + name -> Text, + notes -> Nullable, + atype -> Integer, + data -> Text, + akey -> Text, + password_hash -> Nullable, + password_salt -> Nullable, + password_iter -> Nullable, + max_access_count -> Nullable, + access_count -> Integer, + creation_date -> Timestamp, + revision_date -> Timestamp, + expiration_date -> Nullable, + deletion_date -> Timestamp, + disabled -> Bool, + hide_email -> Nullable, + } +} + +diesel::table! { + twofactor (uuid) { + uuid -> Text, + user_uuid -> Text, + atype -> Integer, + enabled -> Bool, + data -> Text, + last_used -> Integer, + } +} + +diesel::table! { + twofactor_duo_ctx (state) { + state -> Text, + user_email -> Text, + nonce -> Text, + exp -> Integer, + } +} + +diesel::table! { + twofactor_incomplete (user_uuid, device_uuid) { + user_uuid -> Text, + device_uuid -> Text, + device_name -> Text, + login_time -> Timestamp, + ip_address -> Text, + device_type -> Integer, + } +} + +diesel::table! { + users (uuid) { + uuid -> Text, + created_at -> Timestamp, + updated_at -> Timestamp, + email -> Text, + name -> Text, + password_hash -> Binary, + salt -> Binary, + password_iterations -> Integer, + password_hint -> Nullable, + akey -> Text, + private_key -> Nullable, + public_key -> Nullable, + totp_secret -> Nullable, + totp_recover -> Nullable, + security_stamp -> Text, + equivalent_domains -> Text, + excluded_globals -> Text, + client_kdf_type -> Integer, + client_kdf_iter -> Integer, + verified_at -> Nullable, + last_verifying_at -> Nullable, + login_verify_count -> Integer, + email_new -> Nullable, + email_new_token -> Nullable, + enabled -> Bool, + stamp_exception -> Nullable, + api_key -> Nullable, + avatar_color -> Nullable, + client_kdf_memory -> Nullable, + client_kdf_parallelism -> Nullable, + external_id -> Nullable, + } +} + +diesel::table! { + users_collections (user_uuid, collection_uuid) { + user_uuid -> Text, + collection_uuid -> Text, + read_only -> Bool, + hide_passwords -> Bool, + manage -> Bool, + } +} + +diesel::table! { + users_organizations (uuid) { + uuid -> Text, + user_uuid -> Text, + org_uuid -> Text, + access_all -> Bool, + akey -> Text, + status -> Integer, + atype -> Integer, + reset_password_key -> Nullable, + external_id -> Nullable, + } +} + +diesel::table! { + web_authn_credentials (uuid) { + uuid -> Text, + user_uuid -> Text, + name -> Text, + credential -> Text, + supports_prf -> Bool, + encrypted_user_key -> Text, + encrypted_public_key -> Text, + encrypted_private_key -> Text, + } +} + +diesel::joinable!(attachments -> ciphers (cipher_uuid)); +diesel::joinable!(auth_requests -> organizations (organization_uuid)); +diesel::joinable!(auth_requests -> users (user_uuid)); +diesel::joinable!(ciphers -> organizations (organization_uuid)); +diesel::joinable!(ciphers -> users (user_uuid)); +diesel::joinable!(ciphers_collections -> ciphers (cipher_uuid)); +diesel::joinable!(ciphers_collections -> collections (collection_uuid)); +diesel::joinable!(collections -> organizations (org_uuid)); +diesel::joinable!(collections_groups -> collections (collections_uuid)); +diesel::joinable!(collections_groups -> groups (groups_uuid)); +diesel::joinable!(devices -> users (user_uuid)); +diesel::joinable!(favorites -> ciphers (cipher_uuid)); +diesel::joinable!(favorites -> users (user_uuid)); +diesel::joinable!(folders -> users (user_uuid)); +diesel::joinable!(folders_ciphers -> ciphers (cipher_uuid)); +diesel::joinable!(folders_ciphers -> folders (folder_uuid)); +diesel::joinable!(groups -> organizations (organizations_uuid)); +diesel::joinable!(groups_users -> groups (groups_uuid)); +diesel::joinable!(groups_users -> users_organizations (users_organizations_uuid)); +diesel::joinable!(org_policies -> organizations (org_uuid)); +diesel::joinable!(organization_api_key -> organizations (org_uuid)); +diesel::joinable!(sends -> organizations (organization_uuid)); +diesel::joinable!(sends -> users (user_uuid)); +diesel::joinable!(twofactor -> users (user_uuid)); +diesel::joinable!(twofactor_incomplete -> users (user_uuid)); +diesel::joinable!(users_collections -> collections (collection_uuid)); +diesel::joinable!(users_collections -> users (user_uuid)); +diesel::joinable!(users_organizations -> organizations (org_uuid)); +diesel::joinable!(users_organizations -> users (user_uuid)); +diesel::joinable!(web_authn_credentials -> users (user_uuid)); + +diesel::allow_tables_to_appear_in_same_query!( + attachments, + auth_requests, + ciphers, + ciphers_collections, + collections, + collections_groups, + devices, + emergency_access, + event, + favorites, + folders, + folders_ciphers, + groups, + groups_users, + invitations, + org_policies, + organization_api_key, + organizations, + sends, + twofactor, + twofactor_duo_ctx, + twofactor_incomplete, + users, + users_collections, + users_organizations, + web_authn_credentials, +); diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs index a3707adf..6674cdba 100644 --- a/src/db/schemas/sqlite/schema.rs +++ b/src/db/schemas/sqlite/schema.rs @@ -320,6 +320,19 @@ table! { } } +table! { + web_authn_credentials (uuid) { + uuid -> Text, + user_uuid -> Text, + name -> Text, + credential -> Text, + supports_prf -> Bool, + encrypted_user_key -> Text, + encrypted_public_key -> Text, + encrypted_private_key -> Text, + } +} + joinable!(attachments -> ciphers (cipher_uuid)); joinable!(ciphers -> organizations (organization_uuid)); joinable!(ciphers -> users (user_uuid)); @@ -348,6 +361,7 @@ joinable!(collections_groups -> collections (collections_uuid)); joinable!(collections_groups -> groups (groups_uuid)); joinable!(event -> users_organizations (uuid)); joinable!(auth_requests -> users (user_uuid)); +joinable!(web_authn_credentials -> users (user_uuid)); allow_tables_to_appear_in_same_query!( attachments, @@ -372,4 +386,5 @@ allow_tables_to_appear_in_same_query!( collections_groups, event, auth_requests, + web_authn_credentials, ); diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index b632f396..fdc1c59e 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -25,9 +25,9 @@ app-root form.ng-untouched button.\!tw-text-primary-600:nth-child(4) { @extend %vw-hide; } /* Hide Log in with passkey on the login page */ -app-root form.ng-untouched > div > div > button.\!tw-text-primary-600:nth-child(3) { - @extend %vw-hide; -} +/* app-root form.ng-untouched > div > div > button.\!tw-text-primary-600:nth-child(3) { */ +/* @extend %vw-hide; */ +/* } */ /* Hide the or text followed by the two buttons hidden above */ app-root form.ng-untouched > div:nth-child(1) > div:nth-child(3) > div:nth-child(2) { @extend %vw-hide; From 9d4b94bb95844cac0d686b547d55912d1c98b0af Mon Sep 17 00:00:00 2001 From: zUnixorn Date: Wed, 4 Jun 2025 03:20:40 +0200 Subject: [PATCH 2/5] delete schema.rs --- src/db/schema.rs | 398 ----------------------------------------------- 1 file changed, 398 deletions(-) delete mode 100644 src/db/schema.rs diff --git a/src/db/schema.rs b/src/db/schema.rs deleted file mode 100644 index 279f3000..00000000 --- a/src/db/schema.rs +++ /dev/null @@ -1,398 +0,0 @@ -// @generated automatically by Diesel CLI. - -diesel::table! { - attachments (id) { - id -> Text, - cipher_uuid -> Text, - file_name -> Text, - file_size -> Integer, - akey -> Nullable, - } -} - -diesel::table! { - auth_requests (uuid) { - uuid -> Text, - user_uuid -> Text, - organization_uuid -> Nullable, - request_device_identifier -> Text, - device_type -> Integer, - request_ip -> Text, - response_device_id -> Nullable, - access_code -> Text, - public_key -> Text, - enc_key -> Nullable, - master_password_hash -> Nullable, - approved -> Nullable, - creation_date -> Timestamp, - response_date -> Nullable, - authentication_date -> Nullable, - } -} - -diesel::table! { - ciphers (uuid) { - uuid -> Text, - created_at -> Timestamp, - updated_at -> Timestamp, - user_uuid -> Nullable, - organization_uuid -> Nullable, - atype -> Integer, - name -> Text, - notes -> Nullable, - fields -> Nullable, - data -> Text, - password_history -> Nullable, - deleted_at -> Nullable, - reprompt -> Nullable, - key -> Nullable, - } -} - -diesel::table! { - ciphers_collections (cipher_uuid, collection_uuid) { - cipher_uuid -> Text, - collection_uuid -> Text, - } -} - -diesel::table! { - collections (uuid) { - uuid -> Text, - org_uuid -> Text, - name -> Text, - external_id -> Nullable, - } -} - -diesel::table! { - collections_groups (rowid) { - rowid -> Integer, - collections_uuid -> Text, - groups_uuid -> Text, - read_only -> Bool, - hide_passwords -> Bool, - manage -> Bool, - } -} - -diesel::table! { - devices (uuid, user_uuid) { - uuid -> Text, - created_at -> Timestamp, - updated_at -> Timestamp, - user_uuid -> Text, - name -> Text, - atype -> Integer, - push_token -> Nullable, - refresh_token -> Text, - twofactor_remember -> Nullable, - push_uuid -> Nullable, - } -} - -diesel::table! { - emergency_access (uuid) { - uuid -> Text, - grantor_uuid -> Nullable, - grantee_uuid -> Nullable, - email -> Nullable, - key_encrypted -> Nullable, - atype -> Integer, - status -> Integer, - wait_time_days -> Integer, - recovery_initiated_at -> Nullable, - last_notification_at -> Nullable, - updated_at -> Timestamp, - created_at -> Timestamp, - } -} - -diesel::table! { - event (uuid) { - uuid -> Text, - event_type -> Integer, - user_uuid -> Nullable, - org_uuid -> Nullable, - cipher_uuid -> Nullable, - collection_uuid -> Nullable, - group_uuid -> Nullable, - org_user_uuid -> Nullable, - act_user_uuid -> Nullable, - device_type -> Nullable, - ip_address -> Nullable, - event_date -> Timestamp, - policy_uuid -> Nullable, - provider_uuid -> Nullable, - provider_user_uuid -> Nullable, - provider_org_uuid -> Nullable, - } -} - -diesel::table! { - favorites (user_uuid, cipher_uuid) { - user_uuid -> Text, - cipher_uuid -> Text, - } -} - -diesel::table! { - folders (uuid) { - uuid -> Text, - created_at -> Timestamp, - updated_at -> Timestamp, - user_uuid -> Text, - name -> Text, - } -} - -diesel::table! { - folders_ciphers (cipher_uuid, folder_uuid) { - cipher_uuid -> Text, - folder_uuid -> Text, - } -} - -diesel::table! { - groups (uuid) { - uuid -> Text, - organizations_uuid -> Text, - name -> Text, - access_all -> Bool, - external_id -> Nullable, - creation_date -> Timestamp, - revision_date -> Timestamp, - } -} - -diesel::table! { - groups_users (rowid) { - rowid -> Integer, - groups_uuid -> Text, - users_organizations_uuid -> Text, - } -} - -diesel::table! { - invitations (email) { - email -> Text, - } -} - -diesel::table! { - org_policies (uuid) { - uuid -> Text, - org_uuid -> Text, - atype -> Integer, - enabled -> Bool, - data -> Text, - } -} - -diesel::table! { - organization_api_key (uuid, org_uuid) { - uuid -> Text, - org_uuid -> Text, - atype -> Integer, - api_key -> Text, - revision_date -> Timestamp, - } -} - -diesel::table! { - organizations (uuid) { - uuid -> Text, - name -> Text, - billing_email -> Text, - private_key -> Nullable, - public_key -> Nullable, - } -} - -diesel::table! { - sends (uuid) { - uuid -> Text, - user_uuid -> Nullable, - organization_uuid -> Nullable, - name -> Text, - notes -> Nullable, - atype -> Integer, - data -> Text, - akey -> Text, - password_hash -> Nullable, - password_salt -> Nullable, - password_iter -> Nullable, - max_access_count -> Nullable, - access_count -> Integer, - creation_date -> Timestamp, - revision_date -> Timestamp, - expiration_date -> Nullable, - deletion_date -> Timestamp, - disabled -> Bool, - hide_email -> Nullable, - } -} - -diesel::table! { - twofactor (uuid) { - uuid -> Text, - user_uuid -> Text, - atype -> Integer, - enabled -> Bool, - data -> Text, - last_used -> Integer, - } -} - -diesel::table! { - twofactor_duo_ctx (state) { - state -> Text, - user_email -> Text, - nonce -> Text, - exp -> Integer, - } -} - -diesel::table! { - twofactor_incomplete (user_uuid, device_uuid) { - user_uuid -> Text, - device_uuid -> Text, - device_name -> Text, - login_time -> Timestamp, - ip_address -> Text, - device_type -> Integer, - } -} - -diesel::table! { - users (uuid) { - uuid -> Text, - created_at -> Timestamp, - updated_at -> Timestamp, - email -> Text, - name -> Text, - password_hash -> Binary, - salt -> Binary, - password_iterations -> Integer, - password_hint -> Nullable, - akey -> Text, - private_key -> Nullable, - public_key -> Nullable, - totp_secret -> Nullable, - totp_recover -> Nullable, - security_stamp -> Text, - equivalent_domains -> Text, - excluded_globals -> Text, - client_kdf_type -> Integer, - client_kdf_iter -> Integer, - verified_at -> Nullable, - last_verifying_at -> Nullable, - login_verify_count -> Integer, - email_new -> Nullable, - email_new_token -> Nullable, - enabled -> Bool, - stamp_exception -> Nullable, - api_key -> Nullable, - avatar_color -> Nullable, - client_kdf_memory -> Nullable, - client_kdf_parallelism -> Nullable, - external_id -> Nullable, - } -} - -diesel::table! { - users_collections (user_uuid, collection_uuid) { - user_uuid -> Text, - collection_uuid -> Text, - read_only -> Bool, - hide_passwords -> Bool, - manage -> Bool, - } -} - -diesel::table! { - users_organizations (uuid) { - uuid -> Text, - user_uuid -> Text, - org_uuid -> Text, - access_all -> Bool, - akey -> Text, - status -> Integer, - atype -> Integer, - reset_password_key -> Nullable, - external_id -> Nullable, - } -} - -diesel::table! { - web_authn_credentials (uuid) { - uuid -> Text, - user_uuid -> Text, - name -> Text, - credential -> Text, - supports_prf -> Bool, - encrypted_user_key -> Text, - encrypted_public_key -> Text, - encrypted_private_key -> Text, - } -} - -diesel::joinable!(attachments -> ciphers (cipher_uuid)); -diesel::joinable!(auth_requests -> organizations (organization_uuid)); -diesel::joinable!(auth_requests -> users (user_uuid)); -diesel::joinable!(ciphers -> organizations (organization_uuid)); -diesel::joinable!(ciphers -> users (user_uuid)); -diesel::joinable!(ciphers_collections -> ciphers (cipher_uuid)); -diesel::joinable!(ciphers_collections -> collections (collection_uuid)); -diesel::joinable!(collections -> organizations (org_uuid)); -diesel::joinable!(collections_groups -> collections (collections_uuid)); -diesel::joinable!(collections_groups -> groups (groups_uuid)); -diesel::joinable!(devices -> users (user_uuid)); -diesel::joinable!(favorites -> ciphers (cipher_uuid)); -diesel::joinable!(favorites -> users (user_uuid)); -diesel::joinable!(folders -> users (user_uuid)); -diesel::joinable!(folders_ciphers -> ciphers (cipher_uuid)); -diesel::joinable!(folders_ciphers -> folders (folder_uuid)); -diesel::joinable!(groups -> organizations (organizations_uuid)); -diesel::joinable!(groups_users -> groups (groups_uuid)); -diesel::joinable!(groups_users -> users_organizations (users_organizations_uuid)); -diesel::joinable!(org_policies -> organizations (org_uuid)); -diesel::joinable!(organization_api_key -> organizations (org_uuid)); -diesel::joinable!(sends -> organizations (organization_uuid)); -diesel::joinable!(sends -> users (user_uuid)); -diesel::joinable!(twofactor -> users (user_uuid)); -diesel::joinable!(twofactor_incomplete -> users (user_uuid)); -diesel::joinable!(users_collections -> collections (collection_uuid)); -diesel::joinable!(users_collections -> users (user_uuid)); -diesel::joinable!(users_organizations -> organizations (org_uuid)); -diesel::joinable!(users_organizations -> users (user_uuid)); -diesel::joinable!(web_authn_credentials -> users (user_uuid)); - -diesel::allow_tables_to_appear_in_same_query!( - attachments, - auth_requests, - ciphers, - ciphers_collections, - collections, - collections_groups, - devices, - emergency_access, - event, - favorites, - folders, - folders_ciphers, - groups, - groups_users, - invitations, - org_policies, - organization_api_key, - organizations, - sends, - twofactor, - twofactor_duo_ctx, - twofactor_incomplete, - users, - users_collections, - users_organizations, - web_authn_credentials, -); From a4b480dc9ff7647c06de8f6d1edf6a281b3cc977 Mon Sep 17 00:00:00 2001 From: zUnixorn Date: Wed, 4 Jun 2025 04:03:06 +0200 Subject: [PATCH 3/5] implement webauthn login deletion (untested) --- src/api/core/mod.rs | 19 +++++++++++++++---- src/db/models/mod.rs | 2 +- src/db/models/web_authn_credential.rs | 12 +++++++++++- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index 73c3d813..0b46dfa8 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -10,7 +10,7 @@ pub mod two_factor; use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; -use crate::db::models::WebAuthnCredential; +use crate::db::models::{WebAuthnCredential, WebAuthnCredentialId}; pub use accounts::purge_auth_requests; pub use ciphers::{purge_trashed_ciphers, CipherData, CipherSyncData, CipherSyncType}; pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job}; @@ -21,7 +21,7 @@ pub use sends::purge_sends; pub fn routes() -> 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, post_api_webauthn, post_api_webauthn_attestation_options]; + let mut meta_routes = routes![alive, now, version, config, get_api_webauthn, post_api_webauthn, post_api_webauthn_attestation_options, post_api_webauthn_delete]; let mut routes = Vec::new(); routes.append(&mut accounts::routes()); @@ -192,14 +192,25 @@ fn version() -> Json<&'static str> { Json(crate::VERSION.unwrap_or_default()) } +#[post("/webauthn//delete", data = "")] +async fn post_api_webauthn_delete(data: Json, uuid: String, headers: Headers, mut conn: DbConn) -> ApiResult { + let data: PasswordOrOtpData = data.into_inner(); + let user = headers.user; + + data.validate(&user, false, &mut conn).await?; + + WebAuthnCredential::delete_by_uuid_and_user(&WebAuthnCredentialId(uuid), &user.uuid, &mut conn).await?; + + Ok(Status::Ok) +} + static WEBAUTHN_STATES: OnceLock>> = OnceLock::new(); #[post("/webauthn/attestation-options", data = "")] async fn post_api_webauthn_attestation_options(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; - - // TODO what does delete_if_valid do? + data.validate(&user, false, &mut conn).await?; // C# does this check as well diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index d33a0477..5f31a26c 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -40,4 +40,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, User, UserId, UserKdfType, UserStampException}; -pub use self::web_authn_credential::WebAuthnCredential; +pub use self::web_authn_credential::{WebAuthnCredential, WebAuthnCredentialId}; diff --git a/src/db/models/web_authn_credential.rs b/src/db/models/web_authn_credential.rs index 5e8c1a06..a548aaaf 100644 --- a/src/db/models/web_authn_credential.rs +++ b/src/db/models/web_authn_credential.rs @@ -2,6 +2,7 @@ use derive_more::{AsRef, Deref, Display, From}; use macros::UuidFromParam; use crate::api::EmptyResult; use crate::db::DbConn; +use crate::MapResult; use super::UserId; db_object! { @@ -68,6 +69,15 @@ impl WebAuthnCredential { // TODO do not unwrap }}.unwrap() } + + pub async fn delete_by_uuid_and_user(uuid: &WebAuthnCredentialId, user_uuid: &UserId, conn: &mut DbConn) -> EmptyResult { + db_run! { conn: { + diesel::delete(web_authn_credentials::table + .filter(web_authn_credentials::uuid.eq(uuid)) + .filter(web_authn_credentials::user_uuid.eq(user_uuid)) + ).execute(conn).map_res("Error removing web_authn_credential for user") + }} + } } #[derive( @@ -86,4 +96,4 @@ impl WebAuthnCredential { Deserialize, UuidFromParam, )] -pub struct WebAuthnCredentialId(String); +pub struct WebAuthnCredentialId(pub String); From 22a3571c46bd0044a017bdac8abb8dcb424a7555 Mon Sep 17 00:00:00 2001 From: zUnixorn Date: Wed, 4 Jun 2025 15:27:31 +0200 Subject: [PATCH 4/5] implement unecrypted passkey login and cleanup code a bit mor --- .../up.sql | 6 +- src/api/core/mod.rs | 61 ++++---------- src/api/core/two_factor/webauthn.rs | 2 - src/api/identity.rs | 84 +++++++++++-------- src/db/models/user.rs | 2 +- src/db/models/web_authn_credential.rs | 23 +++-- src/db/schemas/sqlite/schema.rs | 6 +- 7 files changed, 90 insertions(+), 94 deletions(-) diff --git a/migrations/sqlite/2025-06-03-173809_create_web_authn_credentials_table/up.sql b/migrations/sqlite/2025-06-03-173809_create_web_authn_credentials_table/up.sql index 9851088c..9f885c34 100644 --- a/migrations/sqlite/2025-06-03-173809_create_web_authn_credentials_table/up.sql +++ b/migrations/sqlite/2025-06-03-173809_create_web_authn_credentials_table/up.sql @@ -4,8 +4,8 @@ CREATE TABLE web_authn_credentials ( name TEXT NOT NULL, credential TEXT NOT NULL, supports_prf BOOLEAN NOT NULL, - encrypted_user_key TEXT NOT NULL, - encrypted_public_key TEXT NOT NULL, - encrypted_private_key TEXT NOT NULL, + encrypted_user_key TEXT, + encrypted_public_key TEXT, + encrypted_private_key TEXT, FOREIGN KEY(user_uuid) REFERENCES users(uuid) ); diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index 0b46dfa8..7238fa4a 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -204,6 +204,7 @@ async fn post_api_webauthn_delete(data: Json, uuid: String, h Ok(Status::Ok) } +// TODO replace this with something else static WEBAUTHN_STATES: OnceLock>> = OnceLock::new(); #[post("/webauthn/attestation-options", data = "")] @@ -213,7 +214,7 @@ async fn post_api_webauthn_attestation_options(data: Json, he data.validate(&user, false, &mut conn).await?; - // C# does this check as well + // TODO C# does this check as well, should there be an option in the admin panel to disable passkey login? // await ValidateIfUserCanUsePasskeyLogin(user.Id); // TODO add existing keys here when the table exists @@ -240,71 +241,41 @@ async fn post_api_webauthn_attestation_options(data: Json, he let mut options = serde_json::to_value(challenge.public_key)?; options["status"] = "ok".into(); options["errorMessage"] = "".into(); - // TODO does this need to be set? + + // TODO test if the client actually expects this field to exist options["extensions"] = Value::Object(serde_json::Map::new()); - - // TODO make this nicer - let mut webauthn_credential_create_options = Value::Object(serde_json::Map::new()); - webauthn_credential_create_options["options"] = options; - webauthn_credential_create_options["object"] = "webauthnCredentialCreateOptions".into(); - - // TODO this hopefully shouldn't be needed - // webauthn_credential_create_options["token"] = "atoken".into(); - - Ok(Json(webauthn_credential_create_options)) + + Ok(Json(json!({ + "options": options, + "object": "webauthnCredentialCreateOptions" + }))) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -// TODO remove allow dead_code -#[allow(dead_code)] struct WebAuthnLoginCredentialCreateRequest { device_response: RegisterPublicKeyCredentialCopy, name: String, - // TODO this is hopefully not needed - // token: String, supports_prf: bool, - encrypted_user_key: String, - encrypted_public_key: String, - encrypted_private_key: String, + encrypted_user_key: Option, + encrypted_public_key: Option, + encrypted_private_key: Option, } #[post("/webauthn", data = "")] async fn post_api_webauthn(data: Json, headers: Headers, mut conn: DbConn) -> ApiResult { - // this check await ValidateIfUserCanUsePasskeyLogin(user.Id); again let data: WebAuthnLoginCredentialCreateRequest = data.into_inner(); - // let data: WebAuthnLoginCredentialCreateRequest = serde_json::from_str(&data)?; let user = headers.user; - - // TODO Retrieve and delete the saved challenge state here - - + // Verify the credentials with the saved state let (credential, _data) = { let mut states = WEBAUTHN_STATES.get().unwrap().lock().unwrap(); let state = states.remove(&user.uuid).unwrap(); + // TODO make the closure check if the credential already exists WebauthnConfig::load(true).register_credential(&data.device_response.into(), &state, |_| Ok(false))? }; - - // TODO add existing keys here when the table exists - // let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &mut conn).await?.1; - // // TODO: Check for repeated ID's - // registrations.push(WebauthnRegistration { - // id: data.id.into_i32()?, - // name: data.name, - // migrated: false, - // - // credential, - // }); - - // let registrations = Vec::new(); - - // TODO Save the registration - // TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?) - // .save(&mut conn) - // .await?; - + WebAuthnCredential::new( user.uuid, data.name, @@ -326,10 +297,10 @@ async fn get_api_webauthn(headers: Headers, mut conn: DbConn) -> Json { .await .into_iter() .map(|wac| { - // TODO generate prfStatus from GetPrfStatus() in C# json!({ "id": wac.uuid, "name": wac.name, + // TODO generate prfStatus like GetPrfStatus() does in the C# implementation "prfStatus": 0, "encryptedUserKey": wac.encrypted_user_key, "encryptedPublicKey": wac.encrypted_public_key, diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 9600573e..fe9f84d3 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -146,7 +146,6 @@ async fn get_webauthn(data: Json, headers: Headers, mut conn: }))) } -// TODO Creation call #[post("/two-factor/get-webauthn-challenge", data = "")] async fn generate_webauthn_challenge(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let data: PasswordOrOtpData = data.into_inner(); @@ -261,7 +260,6 @@ impl From for PublicKeyCredential { } } -// TODO Confirmation call #[post("/two-factor/webauthn", data = "")] async fn activate_webauthn(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { let data: EnableWebauthnData = data.into_inner(); diff --git a/src/api/identity.rs b/src/api/identity.rs index fca0aced..b71697e2 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -125,7 +125,7 @@ pub struct PublicKeyCredentialCopy { pub raw_id: Base64UrlSafeData, pub response: AuthenticatorAssertionResponseRawCopy, pub r#type: String, - // TODO think about what to do with this field, currently this is ignored in the conversion + // This field is unused and discarded when converted to PublicKeyCredential pub extensions: Option, } @@ -199,43 +199,56 @@ async fn _webauthn_login( let web_authn_credentials = WebAuthnCredential::find_all_by_user(&user.uuid, conn).await; - let credentials = web_authn_credentials + let parsed_credentials = web_authn_credentials .iter() .map(|c| { serde_json::from_str(&c.credential) }).collect::, _>>()?; - - let web_authn_credential = { + + let pairs = web_authn_credentials.into_iter() + .zip(parsed_credentials.clone()) + .collect::>(); + + let authenticator_data; + let (web_authn_credential, mut credential) = { let token = data.token.as_ref().unwrap(); let mut states = WEBAUTHN_AUTHENTICATION_STATES.get().unwrap().lock().unwrap(); let mut state = states.remove(token).unwrap(); let resp = device_response.into(); - state.set_allowed_credentials(credentials); - - // TODO update respective credential in database - let (credential_id, auth_data) = WebauthnConfig::load(true) - .authenticate_credential(&resp, &state)?; - - if !auth_data.user_verified { - // TODO throw an error here - panic!() - } - - web_authn_credentials.into_iter() - .find(|c| &serde_json::from_str::(&c.credential).unwrap().cred_id == credential_id) - .unwrap() - - /* TODO return this error on failure - err!( - "Username or password is incorrect. Try again", + state.set_allowed_credentials(parsed_credentials); + + let credential_id; + + if let Ok((cred_id, auth_data)) = WebauthnConfig::load(true) + .authenticate_credential(&resp, &state) { + credential_id = cred_id; + authenticator_data = auth_data; + } else { + err!( + "Passkey authentication Failed.", format!("IP: {}. Username: {username}.", ip.ip), ErrorEvent { event: EventType::UserFailedLogIn, } ) - */ + } + + // TODO should this check be done? Since we need to trust the client here anyway ... + // if !auth_data.user_verified { some_error } + + pairs.into_iter() + .find(|(_, c)| &c.cred_id == credential_id) + .unwrap() }; + + // update the counter + credential.counter = authenticator_data.counter; + WebAuthnCredential::update_credential_by_uuid( + &web_authn_credential.uuid, + serde_json::to_string(&credential)?, + conn + ).await?; let now = Utc::now().naive_utc(); @@ -273,7 +286,7 @@ async fn _webauthn_login( let (mut device, new_device) = get_device(&data, conn, &user).await; - // TODO is this needed with passkeys? + // TODO is this wanted with passkeys? if CONFIG.mail_enabled() && new_device { if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await { error!("Error sending new device email: {e:#?}"); @@ -335,7 +348,7 @@ async fn _webauthn_login( json!({"Object": "masterPasswordPolicy"}) }; - let result = json!({ + let mut result = json!({ "access_token": access_token, "expires_in": expires_in, "token_type": "Bearer", @@ -354,13 +367,16 @@ async fn _webauthn_login( "scope": scope, "UserDecryptionOptions": { "HasMasterPassword": !user.password_hash.is_empty(), - "WebAuthnPrfOption": { - "EncryptedPrivateKey": web_authn_credential.encrypted_private_key, - "EncryptedUserKey": web_authn_credential.encrypted_user_key, - }, "Object": "userDecryptionOptions" }, }); + + if web_authn_credential.encrypted_private_key.is_some() && web_authn_credential.encrypted_user_key.is_some() { + result["UserDecryptionOptions"]["WebAuthnPrfOption"] = json!({ + "EncryptedPrivateKey": web_authn_credential.encrypted_private_key, + "EncryptedUserKey": web_authn_credential.encrypted_user_key, + }) + } info!("User {username} logged in successfully. IP: {}", ip.ip); Ok(Json(result)) @@ -963,6 +979,7 @@ async fn identity_register(data: Json, conn: DbConn) -> JsonResult _register(data, false, conn).await } +// TODO this should be removed and either use something similar to what bitwarden employs or something else static WEBAUTHN_AUTHENTICATION_STATES: OnceLock>> = OnceLock::new(); #[get("/accounts/webauthn/assertion-options")] @@ -972,8 +989,7 @@ fn get_web_authn_assertion_options() -> JsonResult { Vec::new(), None, )?; - - // TODO this needs to be solved in another way to avoid DoS + let t = util::get_uuid(); WEBAUTHN_AUTHENTICATION_STATES.get_or_init(|| Mutex::new(HashMap::new())).lock().unwrap().insert(t.clone(), state); @@ -1052,7 +1068,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"))] @@ -1100,10 +1116,10 @@ struct ConnectData { #[field(name = uncased("authrequest"))] auth_request: Option, - // Needed for "login with passkey" + // Needed for grant_type = "webauthn" #[field(name = uncased("deviceresponse"))] device_response: Option, - // TODO this may be removed again if implemented correctly + // TODO this may be removed when `WEBAUTHN_AUTHENTICATION_STATES` is removed #[field(name = uncased("token"))] token: Option, } diff --git a/src/db/models/user.rs b/src/db/models/user.rs index d38ea5ba..f3f54720 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -476,5 +476,5 @@ impl Invitation { )] #[deref(forward)] #[from(forward)] -// TODO create a way to construct this +// TODO this also shouldn't be public pub struct UserId(pub String); diff --git a/src/db/models/web_authn_credential.rs b/src/db/models/web_authn_credential.rs index a548aaaf..75b1f792 100644 --- a/src/db/models/web_authn_credential.rs +++ b/src/db/models/web_authn_credential.rs @@ -16,9 +16,9 @@ db_object! { pub name: String, pub credential: String, pub supports_prf: bool, - pub encrypted_user_key: String, - pub encrypted_public_key: String, - pub encrypted_private_key: String, + pub encrypted_user_key: Option, + pub encrypted_public_key: Option, + pub encrypted_private_key: Option, } } @@ -28,9 +28,9 @@ impl WebAuthnCredential { name: String, credential: String, supports_prf: bool, - encrypted_user_key: String, - encrypted_public_key: String, - encrypted_private_key: String, + encrypted_user_key: Option, + encrypted_public_key: Option, + encrypted_private_key: Option, ) -> Self { Self { uuid: WebAuthnCredentialId(crate::util::get_uuid()), @@ -78,6 +78,16 @@ impl WebAuthnCredential { ).execute(conn).map_res("Error removing web_authn_credential for user") }} } + + pub async fn update_credential_by_uuid(uuid: &WebAuthnCredentialId, credential: String, conn: &mut DbConn) -> EmptyResult { + db_run! { conn: { + diesel::update(web_authn_credentials::table + .filter(web_authn_credentials::uuid.eq(uuid)) + ).set(web_authn_credentials::credential.eq(credential)) + .execute(conn) + .map_res("Error updating credential for web_authn_credential") + }} + } } #[derive( @@ -96,4 +106,5 @@ impl WebAuthnCredential { Deserialize, UuidFromParam, )] +// TODO this probably shouldn't need to be public pub struct WebAuthnCredentialId(pub String); diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs index 6674cdba..194b3f91 100644 --- a/src/db/schemas/sqlite/schema.rs +++ b/src/db/schemas/sqlite/schema.rs @@ -327,9 +327,9 @@ table! { name -> Text, credential -> Text, supports_prf -> Bool, - encrypted_user_key -> Text, - encrypted_public_key -> Text, - encrypted_private_key -> Text, + encrypted_user_key -> Nullable, + encrypted_public_key -> Nullable, + encrypted_private_key -> Nullable, } } From bbe3a7b244daad30424e2447e2be7cdb28c35f69 Mon Sep 17 00:00:00 2001 From: zUnixorn Date: Wed, 4 Jun 2025 15:36:36 +0200 Subject: [PATCH 5/5] rebase onto main --- src/api/identity.rs | 31 +------------------------------ 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/src/api/identity.rs b/src/api/identity.rs index b71697e2..6ace324d 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -317,36 +317,7 @@ async fn _webauthn_login( let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec, data.client_id); device.save(conn).await?; - // Fetch all valid Master Password Policies and merge them into one with all trues and largest numbers as one policy - let master_password_policies: Vec = - OrgPolicy::find_accepted_and_confirmed_by_user_and_active_policy( - &user.uuid, - OrgPolicyType::MasterPassword, - conn, - ) - .await - .into_iter() - .filter_map(|p| serde_json::from_str(&p.data).ok()) - .collect(); - - // NOTE: Upstream still uses PascalCase here for `Object`! - let master_password_policy = if !master_password_policies.is_empty() { - let mut mpp_json = json!(master_password_policies.into_iter().reduce(|acc, policy| { - MasterPasswordPolicy { - min_complexity: acc.min_complexity.max(policy.min_complexity), - min_length: acc.min_length.max(policy.min_length), - require_lower: acc.require_lower || policy.require_lower, - require_upper: acc.require_upper || policy.require_upper, - require_numbers: acc.require_numbers || policy.require_numbers, - require_special: acc.require_special || policy.require_special, - enforce_on_login: acc.enforce_on_login || policy.enforce_on_login, - } - })); - mpp_json["Object"] = json!("masterPasswordPolicy"); - mpp_json - } else { - json!({"Object": "masterPasswordPolicy"}) - }; + let master_password_policy = master_password_policy(&user, conn).await; let mut result = json!({ "access_token": access_token,