From f5bf0edf8c3e87d1b0e6d51e6d1753fa25a2bc50 Mon Sep 17 00:00:00 2001 From: zUnixorn Date: Wed, 4 Jun 2025 03:08:12 +0200 Subject: [PATCH] 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;