From 63cdded59117fee577d74ee85d888e9bb65d937a Mon Sep 17 00:00:00 2001 From: Sammy ETUR Date: Thu, 12 Feb 2026 04:28:36 +0100 Subject: [PATCH 1/2] Fix dockerfile for podman MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit podman build runs RUN ... with /bin/sh -c by default, and many /bin/sh implementations don’t have the source builtin --- docker/Dockerfile.alpine | 6 +++--- docker/Dockerfile.debian | 9 +++++---- docker/Dockerfile.j2 | 8 ++++---- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docker/Dockerfile.alpine b/docker/Dockerfile.alpine index f006f5b4..7ee59676 100644 --- a/docker/Dockerfile.alpine +++ b/docker/Dockerfile.alpine @@ -71,7 +71,7 @@ RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \ # Output the current contents of the file cat /env-cargo -RUN source /env-cargo && \ +RUN . /env-cargo && \ rustup target add "${CARGO_TARGET}" # Copies over *only* your manifests and build files @@ -87,7 +87,7 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc # Builds your dependencies and removes the # dummy project, except the target folder # This folder contains the compiled dependencies -RUN source /env-cargo && \ +RUN . /env-cargo && \ cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ find . -not -path "./target*" -delete @@ -98,7 +98,7 @@ COPY . . ARG VW_VERSION # Builds again, this time it will be the actual source files being build -RUN source /env-cargo && \ +RUN . /env-cargo && \ # Make sure that we actually build the project by updating the src/main.rs timestamp # Also do this for build.rs to ensure the version is rechecked touch build.rs src/main.rs && \ diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian index 449bbcfd..7d07c74c 100644 --- a/docker/Dockerfile.debian +++ b/docker/Dockerfile.debian @@ -86,7 +86,8 @@ WORKDIR /app # Environment variables for Cargo on Debian based builds ARG TARGET_PKG_CONFIG_PATH -RUN source /env-cargo && \ + +RUN . /env-cargo && \ if xx-info is-cross ; then \ # We can't use xx-cargo since that uses clang, which doesn't work for our libraries. # Because of this we generate the needed environment variables here which we can load in the needed steps. @@ -106,7 +107,7 @@ RUN source /env-cargo && \ # Output the current contents of the file cat /env-cargo -RUN source /env-cargo && \ +RUN . /env-cargo && \ rustup target add "${CARGO_TARGET}" # Copies over *only* your manifests and build files @@ -121,7 +122,7 @@ ARG DB=sqlite,mysql,postgresql # Builds your dependencies and removes the # dummy project, except the target folder # This folder contains the compiled dependencies -RUN source /env-cargo && \ +RUN . /env-cargo && \ cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ find . -not -path "./target*" -delete @@ -132,7 +133,7 @@ COPY . . ARG VW_VERSION # Builds again, this time it will be the actual source files being build -RUN source /env-cargo && \ +RUN . /env-cargo && \ # Make sure that we actually build the project by updating the src/main.rs timestamp # Also do this for build.rs to ensure the version is rechecked touch build.rs src/main.rs && \ diff --git a/docker/Dockerfile.j2 b/docker/Dockerfile.j2 index f745780e..eb1a9ee0 100644 --- a/docker/Dockerfile.j2 +++ b/docker/Dockerfile.j2 @@ -106,7 +106,7 @@ WORKDIR /app # Environment variables for Cargo on Debian based builds ARG TARGET_PKG_CONFIG_PATH -RUN source /env-cargo && \ +RUN . /env-cargo /env-cargo && \ if xx-info is-cross ; then \ # We can't use xx-cargo since that uses clang, which doesn't work for our libraries. # Because of this we generate the needed environment variables here which we can load in the needed steps. @@ -133,7 +133,7 @@ RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \ cat /env-cargo {% endif %} -RUN source /env-cargo && \ +RUN . /env-cargo /env-cargo && \ rustup target add "${CARGO_TARGET}" # Copies over *only* your manifests and build files @@ -153,7 +153,7 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc # Builds your dependencies and removes the # dummy project, except the target folder # This folder contains the compiled dependencies -RUN source /env-cargo && \ +RUN . /env-cargo /env-cargo && \ cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ find . -not -path "./target*" -delete @@ -164,7 +164,7 @@ COPY . . ARG VW_VERSION # Builds again, this time it will be the actual source files being build -RUN source /env-cargo && \ +RUN . /env-cargo /env-cargo && \ # Make sure that we actually build the project by updating the src/main.rs timestamp # Also do this for build.rs to ensure the version is rechecked touch build.rs src/main.rs && \ From a3d16b7dce5ec2769314ddac4c54900aabc70c43 Mon Sep 17 00:00:00 2001 From: Sammy ETUR Date: Thu, 12 Feb 2026 04:31:09 +0100 Subject: [PATCH 2/2] Feat add webauthn login --- .../down.sql | 1 + .../up.sql | 10 + .../down.sql | 1 + .../up.sql | 10 + .../down.sql | 1 + .../up.sql | 10 + src/api/core/mod.rs | 163 ++++++++- src/api/core/two_factor/webauthn.rs | 4 +- src/api/identity.rs | 320 +++++++++++++++++- src/auth.rs | 3 + src/db/models/mod.rs | 2 + src/db/models/two_factor.rs | 1 + src/db/models/user.rs | 2 + src/db/models/web_authn_credential.rs | 127 +++++++ src/db/schema.rs | 15 + .../templates/scss/vaultwarden.scss.hbs | 15 - 16 files changed, 657 insertions(+), 28 deletions(-) create mode 100644 migrations/mysql/2026-02-12-000000_add_web_authn_credentials/down.sql create mode 100644 migrations/mysql/2026-02-12-000000_add_web_authn_credentials/up.sql create mode 100644 migrations/postgresql/2026-02-12-000000_add_web_authn_credentials/down.sql create mode 100644 migrations/postgresql/2026-02-12-000000_add_web_authn_credentials/up.sql create mode 100644 migrations/sqlite/2026-02-12-000000_add_web_authn_credentials/down.sql create mode 100644 migrations/sqlite/2026-02-12-000000_add_web_authn_credentials/up.sql create mode 100644 src/db/models/web_authn_credential.rs diff --git a/migrations/mysql/2026-02-12-000000_add_web_authn_credentials/down.sql b/migrations/mysql/2026-02-12-000000_add_web_authn_credentials/down.sql new file mode 100644 index 00000000..29de34b7 --- /dev/null +++ b/migrations/mysql/2026-02-12-000000_add_web_authn_credentials/down.sql @@ -0,0 +1 @@ +DROP TABLE web_authn_credentials; diff --git a/migrations/mysql/2026-02-12-000000_add_web_authn_credentials/up.sql b/migrations/mysql/2026-02-12-000000_add_web_authn_credentials/up.sql new file mode 100644 index 00000000..a8cfed5f --- /dev/null +++ b/migrations/mysql/2026-02-12-000000_add_web_authn_credentials/up.sql @@ -0,0 +1,10 @@ +CREATE TABLE web_authn_credentials ( + uuid VARCHAR(40) NOT NULL PRIMARY KEY, + user_uuid VARCHAR(40) NOT NULL REFERENCES users(uuid), + name TEXT NOT NULL, + credential TEXT NOT NULL, + supports_prf BOOLEAN NOT NULL DEFAULT 0, + encrypted_user_key TEXT, + encrypted_public_key TEXT, + encrypted_private_key TEXT +); diff --git a/migrations/postgresql/2026-02-12-000000_add_web_authn_credentials/down.sql b/migrations/postgresql/2026-02-12-000000_add_web_authn_credentials/down.sql new file mode 100644 index 00000000..29de34b7 --- /dev/null +++ b/migrations/postgresql/2026-02-12-000000_add_web_authn_credentials/down.sql @@ -0,0 +1 @@ +DROP TABLE web_authn_credentials; diff --git a/migrations/postgresql/2026-02-12-000000_add_web_authn_credentials/up.sql b/migrations/postgresql/2026-02-12-000000_add_web_authn_credentials/up.sql new file mode 100644 index 00000000..c07154e9 --- /dev/null +++ b/migrations/postgresql/2026-02-12-000000_add_web_authn_credentials/up.sql @@ -0,0 +1,10 @@ +CREATE TABLE web_authn_credentials ( + uuid VARCHAR(40) NOT NULL PRIMARY KEY, + user_uuid VARCHAR(40) NOT NULL REFERENCES users(uuid), + name TEXT NOT NULL, + credential TEXT NOT NULL, + supports_prf BOOLEAN NOT NULL DEFAULT FALSE, + encrypted_user_key TEXT, + encrypted_public_key TEXT, + encrypted_private_key TEXT +); diff --git a/migrations/sqlite/2026-02-12-000000_add_web_authn_credentials/down.sql b/migrations/sqlite/2026-02-12-000000_add_web_authn_credentials/down.sql new file mode 100644 index 00000000..29de34b7 --- /dev/null +++ b/migrations/sqlite/2026-02-12-000000_add_web_authn_credentials/down.sql @@ -0,0 +1 @@ +DROP TABLE web_authn_credentials; diff --git a/migrations/sqlite/2026-02-12-000000_add_web_authn_credentials/up.sql b/migrations/sqlite/2026-02-12-000000_add_web_authn_credentials/up.sql new file mode 100644 index 00000000..e7770ca4 --- /dev/null +++ b/migrations/sqlite/2026-02-12-000000_add_web_authn_credentials/up.sql @@ -0,0 +1,10 @@ +CREATE TABLE web_authn_credentials ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT NOT NULL REFERENCES users(uuid), + name TEXT NOT NULL, + credential TEXT NOT NULL, + supports_prf BOOLEAN NOT NULL DEFAULT 0, + encrypted_user_key TEXT, + encrypted_public_key TEXT, + encrypted_private_key TEXT +); diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index dc7f4628..eeedf10b 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -18,7 +18,16 @@ 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, + post_api_webauthn_delete + ]; let mut routes = Vec::new(); routes.append(&mut accounts::routes()); @@ -47,13 +56,21 @@ pub fn events_routes() -> Vec { // // Move this somewhere else // +use rocket::http::Status; use rocket::{serde::json::Json, serde::json::Value, Catcher, Route}; +use webauthn_rs::prelude::{Passkey, PasskeyRegistration}; +use webauthn_rs_proto::UserVerificationPolicy; + +use crate::api::core::two_factor::webauthn::{RegisterPublicKeyCredentialCopy, WEBAUTHN}; use crate::{ - api::{EmptyResult, JsonResult, Notify, UpdateType}, + api::{ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType}, auth::Headers, db::{ - models::{Membership, MembershipStatus, OrgPolicy, Organization, User}, + models::{ + Membership, MembershipStatus, OrgPolicy, Organization, TwoFactor, TwoFactorType, User, WebAuthnCredential, + WebAuthnCredentialId, + }, DbConn, }, error::Error, @@ -184,17 +201,147 @@ fn version() -> Json<&'static str> { } #[get("/webauthn")] -fn get_api_webauthn(_headers: Headers) -> Json { - // Prevent a 404 error, which also causes key-rotation issues - // It looks like this is used when login with passkeys is enabled, which Vaultwarden does not (yet) support - // An empty list/data also works fine +async fn get_api_webauthn(headers: Headers, conn: DbConn) -> Json { + let user = headers.user; + + let data: Vec = WebAuthnCredential::find_all_by_user(&user.uuid, &conn).await; + let data = data + .into_iter() + .map(|wac| { + json!({ + "id": wac.uuid, + "name": wac.name, + // TODO: Generate prfStatus like GetPrfStatus() does in the C# implementation + "prfStatus": if wac.supports_prf { 1 } else { 0 }, + "encryptedUserKey": wac.encrypted_user_key, + "encryptedPublicKey": wac.encrypted_public_key, + "object": "webauthnCredential", + }) + }) + .collect::(); + Json(json!({ "object": "list", - "data": [], + "data": data, "continuationToken": null })) } +#[post("/webauthn/attestation-options", data = "")] +async fn post_api_webauthn_attestation_options( + data: Json, + headers: Headers, + conn: DbConn, +) -> JsonResult { + let data: PasswordOrOtpData = data.into_inner(); + let user = headers.user; + + data.validate(&user, false, &conn).await?; + + let all_creds: Vec = WebAuthnCredential::find_all_by_user(&user.uuid, &conn).await; + let existing_cred_ids: Vec<_> = all_creds + .into_iter() + .filter_map(|wac| { + let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?; + Some(passkey.cred_id().to_owned()) + }) + .collect(); + + let user_uuid = uuid::Uuid::parse_str(&user.uuid).expect("Failed to parse user UUID"); + + let (mut challenge, state) = + WEBAUTHN.start_passkey_registration(user_uuid, &user.email, user.display_name(), Some(existing_cred_ids))?; + + // For passkey login, we need discoverable credentials (resident keys) + // and require user verification. + // start_passkey_registration() defaults to require_resident_key=false, but passkey login + // requires the credential to be discoverable (resident) so the authenticator can find it + // without the server providing allowCredentials. + if let Some(asc) = challenge.public_key.authenticator_selection.as_mut() { + asc.user_verification = UserVerificationPolicy::Required; + asc.require_resident_key = true; + asc.resident_key = Some(webauthn_rs_proto::ResidentKeyRequirement::Required); + } + + // Persist the registration state in the database (same pattern as 2FA webauthn) + TwoFactor::new(user.uuid, TwoFactorType::WebauthnPasskeyRegisterChallenge, serde_json::to_string(&state)?) + .save(&conn) + .await?; + + let mut options = serde_json::to_value(challenge.public_key)?; + options["status"] = "ok".into(); + options["errorMessage"] = "".into(); + + Ok(Json(json!({ + "options": options, + "object": "webauthnCredentialCreateOptions" + }))) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WebAuthnLoginCredentialCreateRequest { + device_response: RegisterPublicKeyCredentialCopy, + name: String, + supports_prf: bool, + encrypted_user_key: Option, + encrypted_public_key: Option, + encrypted_private_key: Option, +} + +#[post("/webauthn", data = "")] +async fn post_api_webauthn( + data: Json, + headers: Headers, + conn: DbConn, +) -> ApiResult { + let data: WebAuthnLoginCredentialCreateRequest = data.into_inner(); + let user = headers.user; + + // Retrieve and delete the saved challenge state from the database + let type_ = TwoFactorType::WebauthnPasskeyRegisterChallenge as i32; + let credential = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { + Some(tf) => { + let state: PasskeyRegistration = serde_json::from_str(&tf.data)?; + tf.delete(&conn).await?; + WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)? + } + None => err!("No registration challenge found. Please try again."), + }; + + WebAuthnCredential::new( + user.uuid, + data.name, + serde_json::to_string(&credential)?, + data.supports_prf, + data.encrypted_user_key, + data.encrypted_public_key, + data.encrypted_private_key, + ) + .save(&conn) + .await?; + + Ok(Status::Ok) +} + +#[post("/webauthn//delete", data = "")] +async fn post_api_webauthn_delete( + data: Json, + uuid: &str, + headers: Headers, + conn: DbConn, +) -> ApiResult { + let data: PasswordOrOtpData = data.into_inner(); + let user = headers.user; + + data.validate(&user, false, &conn).await?; + + WebAuthnCredential::delete_by_uuid_and_user(&WebAuthnCredentialId::from(uuid.to_string()), &user.uuid, &conn) + .await?; + + Ok(Status::Ok) +} + #[get("/config")] fn config() -> Json { let domain = crate::CONFIG.domain(); diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index b10a5ded..c205d711 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -29,7 +29,7 @@ use webauthn_rs_proto::{ RequestAuthenticationExtensions, UserVerificationPolicy, }; -static WEBAUTHN: LazyLock = LazyLock::new(|| { +pub static WEBAUTHN: LazyLock = LazyLock::new(|| { let domain = CONFIG.domain(); let domain_origin = CONFIG.domain_origin(); let rp_id = Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default(); @@ -180,7 +180,7 @@ struct EnableWebauthnData { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -struct RegisterPublicKeyCredentialCopy { +pub struct RegisterPublicKeyCredentialCopy { pub id: String, pub raw_id: Base64UrlSafeData, pub response: AuthenticatorAttestationResponseRawCopy, diff --git a/src/api/identity.rs b/src/api/identity.rs index f5f2afd6..af28b740 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -1,3 +1,6 @@ +use std::sync::{Arc, LazyLock}; +use std::time::Duration; + use chrono::Utc; use num_traits::FromPrimitive; use rocket::{ @@ -8,7 +11,12 @@ use rocket::{ Route, }; use serde_json::Value; +use webauthn_rs::prelude::{Base64UrlSafeData, Passkey, PasskeyAuthentication}; +use webauthn_rs_proto::{ + AuthenticatorAssertionResponseRaw, PublicKeyCredential, RequestAuthenticationExtensions, UserVerificationPolicy, +}; +use crate::api::core::two_factor::webauthn::WEBAUTHN; use crate::{ api::{ core::{ @@ -26,6 +34,7 @@ use crate::{ models::{ AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeWrapper, OrganizationApiKey, OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId, + WebAuthnCredential, }, DbConn, }, @@ -45,7 +54,8 @@ pub fn routes() -> Vec { prevalidate, authorize, oidcsignin, - oidcsignin_error + oidcsignin_error, + get_web_authn_assertion_options ] } @@ -101,6 +111,19 @@ async fn login( _sso_login(data, &mut user_id, &conn, &client_header.ip, &client_version).await } "authorization_code" => err!("SSO sign-in is not available"), + "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, &conn, &client_header.ip).await + } t => err!("Invalid type", t), }; @@ -981,7 +1004,7 @@ async fn register_verification_email( let mut rng = SmallRng::from_os_rng(); let delta: i32 = 100; let sleep_ms = (1_000 + rng.random_range(-delta..=delta)) as u64; - tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await; + tokio::time::sleep(Duration::from_millis(sleep_ms)).await; } else { mail::send_register_verify_email(&data.email, &token).await?; } @@ -999,13 +1022,297 @@ async fn register_finish(data: Json, conn: DbConn) -> JsonResult { _register(data, true, conn).await } +// Cache for webauthn authentication states, keyed by a random token. +// Entries expire after 5 minutes (matching the WebAuthn ceremony timeout of 60s with margin). +// This is used for the discoverable credential (passkey login) flow where we don't know +// the user until the authenticator response arrives. +// Wrapped in Arc because PasskeyAuthentication does not implement Clone. +static WEBAUTHN_AUTHENTICATION_STATES: LazyLock>> = + LazyLock::new(|| { + mini_moka::sync::Cache::builder().max_capacity(10_000).time_to_live(Duration::from_secs(300)).build() + }); + +// Copied from webauthn-rs to rename clientDataJSON -> clientDataJson for Bitwarden compatibility +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AssertionResponseCopy { + pub authenticator_data: Base64UrlSafeData, + #[serde(rename = "clientDataJson", alias = "clientDataJSON")] + pub client_data_json: Base64UrlSafeData, + pub signature: Base64UrlSafeData, + pub user_handle: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PublicKeyCredentialCopy { + pub id: String, + pub raw_id: Base64UrlSafeData, + pub response: AssertionResponseCopy, + pub r#type: String, + #[allow(dead_code)] + pub extensions: 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: Default::default(), + type_: p.r#type, + } + } +} + +#[get("/accounts/webauthn/assertion-options")] +fn get_web_authn_assertion_options() -> JsonResult { + let (mut response, state) = WEBAUTHN.start_passkey_authentication(&[])?; + + // Allow any credential (discoverable) and require user verification + response.public_key.allow_credentials = vec![]; + response.public_key.user_verification = UserVerificationPolicy::Required; + response.public_key.extensions = Some(RequestAuthenticationExtensions { + appid: None, + uvm: None, + hmac_get_secret: None, + }); + + let token = util::get_uuid(); + WEBAUTHN_AUTHENTICATION_STATES.insert(token.clone(), Arc::new(state)); + + let options = serde_json::to_value(response.public_key)?; + + Ok(Json(json!({ + "options": options, + "token": token, + "object": "webAuthnLoginAssertionOptions" + }))) +} + +async fn _webauthn_login(data: ConnectData, user_id: &mut Option, conn: &DbConn, ip: &ClientIp) -> JsonResult { + // Validate scope + AuthMethod::WebAuthn.check_scope(data.scope.as_ref())?; + + // Ratelimit the login + crate::ratelimit::check_limit_login(&ip.ip)?; + + // Parse the device response to get the user handle (user UUID) + let device_response: PublicKeyCredentialCopy = serde_json::from_str(data.device_response.as_ref().unwrap())?; + + let user = if let Some(ref uuid_bytes) = device_response.response.user_handle { + // The user_handle contains the raw UUID bytes (16 bytes) set during passkey registration. + // We need to reconstruct the UUID string from these bytes. + let bytes: &[u8] = uuid_bytes.as_ref(); + let uuid_str = uuid::Uuid::from_slice(bytes) + .map(|u| u.to_string()) + .or_else(|_| { + // Fallback: try interpreting as UTF-8 string (for compatibility) + String::from_utf8(bytes.to_vec()) + }) + .map_err(|_| crate::error::Error::new("Invalid user handle encoding", ""))?; + let uuid = UserId::from(uuid_str); + User::find_by_uuid(&uuid, conn).await + } else { + None + }; + + let Some(user) = user else { + err!( + "Passkey authentication failed.", + format!("IP: {}. Could not find user from device response.", ip.ip), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + }; + + let username = user.display_name().to_string(); + + // Set the user_id here to be passed back used for event logging. + *user_id = Some(user.uuid.clone()); + + // Check if the user is disabled + if !user.enabled { + err!( + "This user has been disabled", + format!("IP: {}. Username: {username}.", ip.ip), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + + // Retrieve all webauthn login credentials for this user + let web_authn_credentials: Vec = WebAuthnCredential::find_all_by_user(&user.uuid, conn).await; + + let parsed_credentials: Vec<(WebAuthnCredential, Passkey)> = web_authn_credentials + .into_iter() + .filter_map(|wac| { + let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?; + Some((wac, passkey)) + }) + .collect(); + + if parsed_credentials.is_empty() { + err!( + "No passkey credentials registered for this user.", + format!("IP: {}. Username: {username}.", ip.ip), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + + // Retrieve and consume the saved authentication state (one-time use) + let token = data.token.as_ref().unwrap(); + let state = WEBAUTHN_AUTHENTICATION_STATES.get(token); + // Invalidate immediately to prevent replay + WEBAUTHN_AUTHENTICATION_STATES.invalidate(token); + debug!( + "WebAuthn login: found {} credentials for user, state present: {}", + parsed_credentials.len(), + state.is_some() + ); + + let Some(state_arc) = state else { + err!( + "Passkey authentication failed. Please try again.", + format!("IP: {}. Username: {username}. Missing authentication state.", ip.ip), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + }; + + // Inject the user's credentials into the state so the library can verify against them. + // We serialize the state to JSON, inject the user's credentials, then deserialize back. + // This is necessary because for discoverable credentials (passkey login), the initial + // assertion was created without knowing which user will authenticate, so the state has + // no credentials to verify against. This is the same pattern used by + // check_and_update_backup_eligible() in two_factor/webauthn.rs. + let passkeys: Vec = + parsed_credentials.iter().map(|(_, p): &(WebAuthnCredential, Passkey)| p.clone()).collect(); + + let mut raw_state = serde_json::to_value(&*state_arc)?; + if let Some(credentials) = + raw_state.get_mut("ast").and_then(|v| v.get_mut("credentials")).and_then(|v| v.as_array_mut()) + { + credentials.clear(); + for passkey in &passkeys { + let passkey_owned: Passkey = passkey.clone(); + let cred = ::from(passkey_owned); + credentials.push(serde_json::to_value(&cred)?); + } + } + let state: PasskeyAuthentication = serde_json::from_value(raw_state).map_err(|e| { + error!("Failed to deserialize PasskeyAuthentication state after credential injection: {e:?}"); + e + })?; + + let rsp: PublicKeyCredential = device_response.into(); + let authentication_result = match WEBAUTHN.finish_passkey_authentication(&rsp, &state) { + Ok(result) => result, + Err(e) => { + err!( + "Passkey authentication failed.", + format!("IP: {}. Username: {username}. WebAuthn error: {e:?}", ip.ip), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + }; + + // Find the matching credential and update its counter + let matched_wac = parsed_credentials.iter().find(|(_, p): &&(WebAuthnCredential, Passkey)| { + crate::crypto::ct_eq(p.cred_id().as_slice(), authentication_result.cred_id().as_slice()) + }); + + let matched_wac = match matched_wac { + Some((wac, _)) => wac, + None => { + err!( + "Passkey authentication failed.", + format!("IP: {}. Username: {username}. Credential not found.", ip.ip), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + }; + + // Update the credential counter + let mut passkey: Passkey = serde_json::from_str(&matched_wac.credential)?; + if passkey.update_credential(&authentication_result) == Some(true) { + WebAuthnCredential::update_credential_by_uuid(&matched_wac.uuid, serde_json::to_string(&passkey)?, conn) + .await?; + } + + // Email verification check + 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 { + let mut user = user; + 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:#?}"); + } + } + } + + err!( + "Please verify your email before trying again.", + format!("IP: {}. Username: {username}.", ip.ip), + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + + let mut device = get_device(&data, conn, &user).await?; + + let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::WebAuthn, data.client_id); + + // Build response using the common authenticated_response helper + let mut result = authenticated_response(&user, &mut device, auth_tokens, None, conn, ip).await?; + + // Add WebAuthnPrfOption if the credential has encrypted keys (PRF-based decryption) + if matched_wac.encrypted_private_key.is_some() && matched_wac.encrypted_user_key.is_some() { + let Json(ref mut val) = result; + val["UserDecryptionOptions"]["WebAuthnPrfOption"] = json!({ + "EncryptedPrivateKey": matched_wac.encrypted_private_key, + "EncryptedUserKey": matched_wac.encrypted_user_key, + }); + } + + Ok(result) +} + // https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts // https://github.com/bitwarden/mobile/blob/master/src/Core/Models/Request/TokenRequest.cs #[derive(Debug, Clone, Default, FromForm)] 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"))] @@ -1058,6 +1365,13 @@ struct ConnectData { code: Option, #[field(name = uncased("code_verifier"))] code_verifier: Option, + + // Needed for grant_type = "webauthn" + #[field(name = uncased("deviceresponse"))] + device_response: Option, + // Token identifying the webauthn authentication state + #[field(name = uncased("token"))] + token: Option, } fn _check_is_some(value: &Option, msg: &str) -> EmptyResult { if value.is_none() { diff --git a/src/auth.rs b/src/auth.rs index ab41898f..24f65f6d 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1103,6 +1103,7 @@ pub enum AuthMethod { Password, Sso, UserApiKey, + WebAuthn, } impl AuthMethod { @@ -1112,6 +1113,7 @@ impl AuthMethod { AuthMethod::Password => "api offline_access".to_string(), AuthMethod::Sso => "api offline_access".to_string(), AuthMethod::UserApiKey => "api".to_string(), + AuthMethod::WebAuthn => "api offline_access".to_string(), } } @@ -1252,6 +1254,7 @@ pub async fn refresh_tokens( AuthMethod::Sso => err!("SSO is now disabled, Login again using email and master password"), AuthMethod::Password if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO is now required, Login again"), AuthMethod::Password => AuthTokens::new(&device, &user, refresh_claims.sub, client_id), + AuthMethod::WebAuthn => AuthTokens::new(&device, &user, refresh_claims.sub, client_id), _ => err!("Invalid auth method, cannot refresh token"), }; diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index b4fcf658..e17485f2 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -16,6 +16,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}; @@ -41,3 +42,4 @@ pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_incomplete::TwoFactorIncomplete; pub use self::user::{Invitation, SsoUser, User, UserId, UserKdfType, UserStampException}; +pub use self::web_authn_credential::{WebAuthnCredential, WebAuthnCredentialId}; diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index f0a1e663..d1a418c2 100644 --- a/src/db/models/two_factor.rs +++ b/src/db/models/two_factor.rs @@ -39,6 +39,7 @@ pub enum TwoFactorType { EmailVerificationChallenge = 1002, WebauthnRegisterChallenge = 1003, WebauthnLoginChallenge = 1004, + WebauthnPasskeyRegisterChallenge = 1005, // Special type for Protected Actions verification via email ProtectedActions = 2000, diff --git a/src/db/models/user.rs b/src/db/models/user.rs index e88c7296..35551f38 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -6,6 +6,7 @@ use serde_json::Value; use super::{ Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete, + WebAuthnCredential, }; use crate::{ api::EmptyResult, @@ -331,6 +332,7 @@ impl User { Device::delete_all_by_user(&self.uuid, conn).await?; TwoFactor::delete_all_by_user(&self.uuid, conn).await?; TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?; + WebAuthnCredential::delete_all_by_user(&self.uuid, conn).await?; Invitation::take(&self.email, conn).await; // Delete invitation if any db_run! { conn: { diff --git a/src/db/models/web_authn_credential.rs b/src/db/models/web_authn_credential.rs new file mode 100644 index 00000000..055180a9 --- /dev/null +++ b/src/db/models/web_authn_credential.rs @@ -0,0 +1,127 @@ +use derive_more::{AsRef, Deref, Display, From}; +use diesel::prelude::*; +use macros::UuidFromParam; + +use crate::api::EmptyResult; +use crate::db::schema::web_authn_credentials; +use crate::db::DbConn; +use crate::error::MapResult; + +use super::UserId; + +#[derive(Debug, Identifiable, Queryable, Insertable, AsChangeset)] +#[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: Option, + pub encrypted_public_key: Option, + pub encrypted_private_key: Option, +} + +impl WebAuthnCredential { + pub fn new( + user_uuid: UserId, + name: String, + credential: String, + supports_prf: bool, + encrypted_user_key: Option, + encrypted_public_key: Option, + encrypted_private_key: Option, + ) -> Self { + Self { + uuid: WebAuthnCredentialId(crate::util::get_uuid()), + user_uuid, + name, + credential, + supports_prf, + encrypted_user_key, + encrypted_public_key, + encrypted_private_key, + } + } + + pub async fn save(&self, conn: &DbConn) -> EmptyResult { + db_run! { conn: { + diesel::insert_into(web_authn_credentials::table) + .values(self) + .execute(conn) + .map_res("Error saving web_authn_credential") + }} + } + + pub async fn find_all_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { + db_run! { conn: { + web_authn_credentials::table + .filter(web_authn_credentials::user_uuid.eq(user_uuid)) + .load::(conn) + .unwrap_or_default() + }} + } + + pub async fn delete_by_uuid_and_user( + uuid: &WebAuthnCredentialId, + user_uuid: &UserId, + conn: &DbConn, + ) -> EmptyResult { + db_run! { conn: { + diesel::delete( + 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") + }} + } + + pub async fn update_credential_by_uuid( + uuid: &WebAuthnCredentialId, + credential: String, + conn: &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") + }} + } + + pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { + db_run! { conn: { + diesel::delete( + web_authn_credentials::table + .filter(web_authn_credentials::user_uuid.eq(user_uuid)), + ) + .execute(conn) + .map_res("Error deleting all web_authn_credentials for user") + }} + } +} + +#[derive( + Clone, + Debug, + AsRef, + Deref, + DieselNewType, + Display, + From, + FromForm, + Hash, + PartialEq, + Eq, + Serialize, + Deserialize, + UuidFromParam, +)] +pub struct WebAuthnCredentialId(String); diff --git a/src/db/schema.rs b/src/db/schema.rs index 914b4fe9..259acb54 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -341,6 +341,19 @@ table! { } } +table! { + web_authn_credentials (uuid) { + uuid -> Text, + user_uuid -> Text, + name -> Text, + credential -> Text, + supports_prf -> Bool, + encrypted_user_key -> Nullable, + encrypted_public_key -> Nullable, + encrypted_private_key -> Nullable, + } +} + joinable!(attachments -> ciphers (cipher_uuid)); joinable!(ciphers -> organizations (organization_uuid)); joinable!(ciphers -> users (user_uuid)); @@ -370,6 +383,7 @@ joinable!(collections_groups -> groups (groups_uuid)); joinable!(event -> users_organizations (uuid)); joinable!(auth_requests -> users (user_uuid)); joinable!(sso_users -> users (user_uuid)); +joinable!(web_authn_credentials -> users (user_uuid)); allow_tables_to_appear_in_same_query!( attachments, @@ -395,4 +409,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 1859c1ea..ccaf9d70 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -54,21 +54,6 @@ app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secon } {{/if}} -/* Hide the `Log in with passkey` settings */ -app-user-layout app-password-settings app-webauthn-login-settings { - @extend %vw-hide; -} -/* Hide Log in with passkey on the login page */ -{{#if (webver ">=2025.5.1")}} -.vw-passkey-login { - @extend %vw-hide; -} -{{else}} -app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secondary"].\!tw-text-primary-600:nth-child(3) { - @extend %vw-hide; -} -{{/if}} - /* Hide the or text followed by the two buttons hidden above */ {{#if (webver ">=2025.5.1")}} {{#if (or (not sso_enabled) sso_only)}}