Browse Source

feat: passwordless login

Co-authored-by: Sammy ETUR <sam@ekip.cc>
pull/7370/head
Raphaël Roumezin 4 weeks ago
parent
commit
7711b02153
  1. 1
      migrations/mysql/2026-06-23-120000_add_webauthn/down.sql
  2. 10
      migrations/mysql/2026-06-23-120000_add_webauthn/up.sql
  3. 1
      migrations/postgresql/2026-06-23-120000_add_webauthn/down.sql
  4. 10
      migrations/postgresql/2026-06-23-120000_add_webauthn/up.sql
  5. 1
      migrations/sqlite/2026-06-23-120000_add_webauthn/down.sql
  6. 10
      migrations/sqlite/2026-06-23-120000_add_webauthn/up.sql
  7. 16
      src/api/core/mod.rs
  8. 3
      src/api/core/two_factor/mod.rs
  9. 34
      src/api/core/two_factor/webauthn.rs
  10. 161
      src/api/core/webauthn.rs
  11. 228
      src/api/identity.rs
  12. 34
      src/auth.rs
  13. 2
      src/db/models/mod.rs
  14. 1
      src/db/models/two_factor.rs
  15. 3
      src/db/models/user.rs
  16. 153
      src/db/models/webauthn_credential.rs
  17. 15
      src/db/schema.rs
  18. 15
      src/static/templates/scss/vaultwarden.scss.hbs

1
migrations/mysql/2026-06-23-120000_add_webauthn/down.sql

@ -0,0 +1 @@
DROP TABLE webauthn_credentials;

10
migrations/mysql/2026-06-23-120000_add_webauthn/up.sql

@ -0,0 +1,10 @@
CREATE TABLE webauthn_credentials (
uuid VARCHAR(40) NOT NULL PRIMARY KEY,
user_uuid VARCHAR(40) NOT NULL REFERENCES users(uuid),
name TEXT NOT NULL,
credential TEXT NOT NULL,
supports_prf BOOLEAN NOT NULL DEFAULT 0,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT
);

1
migrations/postgresql/2026-06-23-120000_add_webauthn/down.sql

@ -0,0 +1 @@
DROP TABLE webauthn_credentials;

10
migrations/postgresql/2026-06-23-120000_add_webauthn/up.sql

@ -0,0 +1,10 @@
CREATE TABLE webauthn_credentials (
uuid VARCHAR(40) NOT NULL PRIMARY KEY,
user_uuid VARCHAR(40) NOT NULL REFERENCES users(uuid),
name TEXT NOT NULL,
credential TEXT NOT NULL,
supports_prf BOOLEAN NOT NULL DEFAULT FALSE,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT
);

1
migrations/sqlite/2026-06-23-120000_add_webauthn/down.sql

@ -0,0 +1 @@
DROP TABLE webauthn_credentials;

10
migrations/sqlite/2026-06-23-120000_add_webauthn/up.sql

@ -0,0 +1,10 @@
CREATE TABLE webauthn_credentials (
uuid TEXT NOT NULL PRIMARY KEY,
user_uuid TEXT NOT NULL REFERENCES users(uuid),
name TEXT NOT NULL,
credential TEXT NOT NULL,
supports_prf BOOLEAN NOT NULL DEFAULT 0,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT
);

16
src/api/core/mod.rs

@ -8,6 +8,7 @@ mod folders;
mod organizations;
mod public;
mod sends;
mod webauthn;
pub use accounts::purge_auth_requests;
pub use ciphers::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciphers};
@ -35,7 +36,7 @@ use crate::{
pub fn routes() -> Vec<Route> {
let mut eq_domains_routes = routes![get_settings_domains, post_settings_domains, put_settings_domains];
let mut hibp_routes = routes![hibp_breach];
let mut meta_routes = routes![alive, now, version, config, get_api_webauthn];
let mut meta_routes = routes![alive, now, version, config];
let mut routes = Vec::new();
routes.append(&mut accounts::routes());
@ -47,6 +48,7 @@ pub fn routes() -> Vec<Route> {
routes.append(&mut two_factor::routes());
routes.append(&mut sends::routes());
routes.append(&mut public::routes());
routes.append(&mut webauthn::routes());
routes.append(&mut eq_domains_routes);
routes.append(&mut hibp_routes);
routes.append(&mut meta_routes);
@ -195,18 +197,6 @@ fn version() -> Json<&'static str> {
Json(crate::VERSION.unwrap_or_default())
}
#[get("/webauthn")]
fn get_api_webauthn(_headers: Headers) -> Json<Value> {
// Prevent a 404 error, which also causes key-rotation issues
// It looks like this is used when login with passkeys is enabled, which Vaultwarden does not (yet) support
// An empty list/data also works fine
Json(json!({
"object": "list",
"data": [],
"continuationToken": null
}))
}
#[get("/config")]
fn config() -> Json<Value> {
let domain = CONFIG.domain();

3
src/api/core/two_factor/mod.rs

@ -64,7 +64,8 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data
| TwoFactorType::EmailVerificationChallenge
| TwoFactorType::WebauthnRegisterChallenge
| TwoFactorType::WebauthnLoginChallenge
| TwoFactorType::ProtectedActions => false,
| TwoFactorType::ProtectedActions
| TwoFactorType::WebauthnPasskeyRegisterChallenge => false,
}
}

34
src/api/core/two_factor/webauthn.rs

@ -1,8 +1,6 @@
use std::{str::FromStr, sync::LazyLock, time::Duration};
use rocket::{Route, serde::json::Json};
use serde_json::Value;
use url::Url;
use std::{str::FromStr, sync::LazyLock};
use uuid::Uuid;
use webauthn_rs::{
Webauthn, WebauthnBuilder,
@ -14,6 +12,20 @@ use webauthn_rs_proto::{
RequestAuthenticationExtensions, UserVerificationPolicy,
};
pub static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
let domain = CONFIG.domain();
let domain_origin = CONFIG.domain_origin();
let rp_id = url::Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default();
let rp_origin = url::Url::parse(&domain_origin).unwrap();
let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin)
.expect("Creating WebauthnBuilder failed")
.rp_name(&domain)
.timeout(tokio::time::Duration::from_mins(1));
webauthn.build().expect("Building Webauthn failed")
});
use crate::{
CONFIG,
api::{
@ -30,20 +42,6 @@ use crate::{
util::NumberOrString,
};
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
let domain = CONFIG.domain();
let domain_origin = CONFIG.domain_origin();
let rp_id = Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default();
let rp_origin = Url::parse(&domain_origin).unwrap();
let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin)
.expect("Creating WebauthnBuilder failed")
.rp_name(&domain)
.timeout(Duration::from_mins(1));
webauthn.build().expect("Building Webauthn failed")
});
pub fn routes() -> Vec<Route> {
routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,]
}
@ -181,7 +179,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,

161
src/api/core/webauthn.rs

@ -0,0 +1,161 @@
use rocket::{http::Status, serde::json::Json};
use serde_json::Value;
use webauthn_rs::prelude::{Passkey, PasskeyRegistration};
use webauthn_rs_proto::UserVerificationPolicy;
use crate::{
api::{
ApiResult, JsonResult, PasswordOrOtpData,
core::two_factor::webauthn::{RegisterPublicKeyCredentialCopy, WEBAUTHN},
},
auth::Headers,
db::{
DbConn,
models::{TwoFactor, TwoFactorType, WebauthnCredential, WebauthnCredentialId},
},
};
pub fn routes() -> Vec<rocket::Route> {
routes![get_webauthn, post_webauthn, post_webauthn_attestation_options, post_webauthn_delete]
}
#[get("/webauthn")]
async fn get_webauthn(headers: Headers, conn: DbConn) -> Json<Value> {
let user = headers.user;
let data: Vec<WebauthnCredential> = WebauthnCredential::find_all_by_user(&user.uuid, &conn).await;
let data = data
.into_iter()
.map(|wac| {
json!({
"id": wac.uuid,
"name": wac.name,
"prfStatus": wac.get_prf_status() as u8,
"encryptedUserKey": wac.encrypted_user_key,
"encryptedPublicKey": wac.encrypted_public_key,
"object": "webauthnCredential",
})
})
.collect::<Value>();
Json(json!({
"object": "list",
"data": data,
"continuationToken": null
}))
}
#[post("/webauthn/attestation-options", data = "<data>")]
async fn post_webauthn_attestation_options(
data: Json<PasswordOrOtpData>,
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> = 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::Discouraged_DO_NOT_USE;
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<String>,
encrypted_public_key: Option<String>,
encrypted_private_key: Option<String>,
}
#[post("/webauthn", data = "<data>")]
async fn post_webauthn(
data: Json<WebAuthnLoginCredentialCreateRequest>,
headers: Headers,
conn: DbConn,
) -> ApiResult<Status> {
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/<uuid>/delete", data = "<data>")]
async fn post_webauthn_delete(
data: Json<PasswordOrOtpData>,
uuid: &str,
headers: Headers,
conn: DbConn,
) -> ApiResult<Status> {
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)
}

228
src/api/identity.rs

@ -8,6 +8,8 @@ use rocket::{
serde::json::Json,
};
use serde_json::Value;
use webauthn_rs::prelude::{Passkey, PasskeyAuthentication};
use webauthn_rs_proto::{PublicKeyCredential, RequestAuthenticationExtensions, UserVerificationPolicy};
use crate::{
CONFIG,
@ -17,27 +19,30 @@ use crate::{
accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register},
log_user_event,
two_factor::{
authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn,
authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable,
webauthn::{self, WEBAUTHN},
yubikey,
},
},
master_password_policy,
push::register_push_device,
},
auth,
auth::{AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure, generate_organization_api_key_login_claims},
auth::{
self, AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure, generate_organization_api_key_login_claims,
generate_passwordless_claims,
},
crypto,
db::{
DbConn,
models::{
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError,
OrganizationApiKey, OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User,
UserId,
UserId, WebauthnCredential,
},
},
error::MapResult,
mail, sso,
sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState},
mail,
sso::{self, OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState},
util,
};
@ -52,7 +57,8 @@ pub fn routes() -> Vec<Route> {
prevalidate,
authorize,
oidcsignin,
oidcsignin_error
oidcsignin_error,
get_webauthn_assertion_options
]
}
@ -108,6 +114,19 @@ async fn login(
sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await
}
"authorization_code" => err!("SSO sign-in is not available"),
"webauthn" => {
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
check_is_some(data.device_response.as_ref(), "device_response cannot be blank")?;
check_is_some(data.token.as_ref(), "token cannot be blank")?;
webauthn_login(data, &mut user_id, &conn, &client_header.ip).await
}
t => err!("Invalid type", t),
};
@ -467,6 +486,164 @@ async fn password_login(
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
}
async fn webauthn_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
// Validate scope
AuthMethod::Webauthn.check_scope(data.scope.as_ref())?;
// Ratelimit the login
crate::ratelimit::check_limit_login(&ip.ip)?;
let device_response: PublicKeyCredential = serde_json::from_str(data.device_response.as_ref().unwrap())?;
let user = if let Some(ref uuid_bytes) = device_response.response.user_handle {
// The user_handle contains the raw UUID bytes (16 bytes) set during passkey registration.
// We need to reconstruct the UUID string from these bytes.
let bytes: &[u8] = uuid_bytes.as_ref();
let uuid_str = uuid::Uuid::from_slice(bytes)
.map(|u| u.to_string())
.or_else(|_| {
// Fallback: try interpreting as UTF-8 string (for compatibility)
String::from_utf8(bytes.to_vec())
})
.map_err(|_| crate::error::Error::new("Invalid user handle encoding", ""))?;
let uuid = UserId::from(uuid_str);
User::find_by_uuid(&uuid, conn).await
} else {
None
};
let Some(user) = user else {
err!(
"Passkey authentication failed. User not found",
format!("IP: {}. Could not find user from device response.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
};
// Retrieve the username to be used for logging
let username = user.email.clone();
// Set the user_id here to be passed back used for event logging.
*user_id = Some(user.uuid.clone());
// Check if the user is disabled
if !user.enabled {
err!(
"This user has been disabled",
format!("IP: {}. Username: {username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
// Retrieve all webauthn login credentials for this user
let user_webauthn_credentials: Vec<(WebauthnCredential, Passkey)> =
WebauthnCredential::find_all_by_user(&user.uuid, conn)
.await
.into_iter()
.filter_map(|wac| {
let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?;
Some((wac, passkey))
})
.collect();
if user_webauthn_credentials.is_empty() {
err!(
"No passkey credentials registered for this user.",
format!("IP: {}. Username: {username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
// Unpack the token claims, which holds authentication state
let claims = match auth::decode_passwordless(data.token.as_ref().unwrap()) {
Ok(claims) => claims,
Err(e) => {
err!(
"Invalid token",
format!("IP: {}. Error: {e:#?}", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
};
// HACK: Inject the credentials into the state, since they were not included at creation time.
let state: PasskeyAuthentication = if let Ok(mut raw_state) = serde_json::to_value(&claims.state) {
if let Some(credentials) =
raw_state.get_mut("ast").and_then(|v| v.get_mut("credentials")).and_then(|v| v.as_array_mut())
{
credentials.clear();
for (_, passkey) in user_webauthn_credentials.iter() {
let passkey_owned: Passkey = passkey.clone();
let cred = <webauthn_rs::prelude::Credential>::from(passkey_owned);
credentials.push(serde_json::to_value(&cred)?);
}
};
serde_json::from_value(raw_state)?
} else {
err!(
"Invalid state in token",
format!("IP: {}. Could not parse state from token.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
};
// Perform passkey authentication
let authentication_result = match WEBAUTHN.finish_passkey_authentication(&device_response, &state) {
Ok(result) => result,
Err(e) => {
err!(
"Passkey authentication failed.",
format!("IP: {}. Username: {username}. WebAuthn error: {e:?}", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
};
// Retrieve the matched credential based on the passkey from the authentication result
let (matched_wac, _) = user_webauthn_credentials
.iter()
.find(|(_, p): &&(WebauthnCredential, Passkey)| {
crypto::ct_eq(p.cred_id().as_slice(), authentication_result.cred_id().as_slice())
})
.unwrap();
// Update the credential in the database if necessary (e.g., counter incremented)
let mut passkey: Passkey = serde_json::from_str(&matched_wac.credential)?;
if passkey.update_credential(&authentication_result) == Some(true) {
WebauthnCredential::update_credential_by_uuid(&matched_wac.uuid, serde_json::to_string(&passkey)?, conn)
.await?;
}
let mut device = get_device(&data, conn, &user).await?;
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Webauthn, data.client_id);
let mut result = authenticated_response(&user, &mut device, auth_tokens, None, conn, ip).await?;
// Add WebAuthnPrfOption if the credential has 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)
}
async fn authenticated_response(
user: &User,
device: &mut Device,
@ -1001,7 +1178,8 @@ async fn json_err_twofactor(
| TwoFactorType::U2fRegisterChallenge
| TwoFactorType::Webauthn
| TwoFactorType::WebauthnLoginChallenge
| TwoFactorType::WebauthnRegisterChallenge,
| TwoFactorType::WebauthnRegisterChallenge
| TwoFactorType::WebauthnPasskeyRegisterChallenge,
) => { /* Nothing special to do for these providers */ }
}
}
@ -1091,7 +1269,7 @@ async fn register_finish(data: Json<RegisterData>, 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"))]
@ -1144,6 +1322,12 @@ struct ConnectData {
code: Option<OIDCCode>,
#[field(name = uncased("code_verifier"))]
code_verifier: Option<OIDCCodeVerifier>,
// Needed for grant_type="webauthn"
#[field(name = uncased("deviceresponse"))]
device_response: Option<String>,
#[field(name = uncased("token"))]
token: Option<String>,
}
fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult {
if value.is_none() {
@ -1303,3 +1487,29 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure,
Ok(Redirect::temporary(String::from(auth_url)))
}
#[get("/accounts/webauthn/assertion-options")]
fn get_webauthn_assertion_options() -> JsonResult {
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,
});
// Generate JWT token
let claims = generate_passwordless_claims(state);
let token = auth::encode_jwt(&claims);
let options = serde_json::to_value(response.public_key)?;
Ok(Json(json!({
"options": options,
"token": token,
"object": "webAuthnLoginAssertionOptions"
})))
}

34
src/auth.rs

@ -14,6 +14,7 @@ use rocket::{
outcome::try_outcome,
request::{FromRequest, Outcome, Request},
};
use webauthn_rs::prelude::PasskeyAuthentication;
use crate::{
CONFIG,
@ -56,6 +57,7 @@ static JWT_FILE_DOWNLOAD_ISSUER: LazyLock<String> =
static JWT_REGISTER_VERIFY_ISSUER: LazyLock<String> =
LazyLock::new(|| format!("{}|register_verify", CONFIG.domain_origin()));
static JWT_2FA_REMEMBER_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|2faremember", CONFIG.domain_origin()));
static JWT_PASSWORDLESS_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|passwordless", CONFIG.domain_origin()));
static PRIVATE_RSA_KEY: OnceLock<EncodingKey> = OnceLock::new();
static PUBLIC_RSA_KEY: OnceLock<DecodingKey> = OnceLock::new();
@ -170,6 +172,10 @@ pub fn decode_2fa_remember(token: &str) -> Result<TwoFactorRememberClaims, Error
decode_jwt(token, JWT_2FA_REMEMBER_ISSUER.to_string())
}
pub fn decode_passwordless(token: &str) -> Result<PasswordlessJwtClaims, Error> {
decode_jwt(token, JWT_PASSWORDLESS_ISSUER.to_string())
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LoginJwtClaims {
// Not before
@ -328,6 +334,29 @@ pub fn generate_invite_claims(
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PasswordlessJwtClaims {
// Not before
pub nbf: i64,
// Expiration time
pub exp: i64,
// Issuer
pub iss: String,
pub state: PasskeyAuthentication,
}
pub fn generate_passwordless_claims(state: PasskeyAuthentication) -> PasswordlessJwtClaims {
let time_now = Utc::now();
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
PasswordlessJwtClaims {
nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
iss: JWT_PASSWORDLESS_ISSUER.to_string(),
state,
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EmergencyAccessInviteJwtClaims {
// Not before
@ -1147,6 +1176,7 @@ pub enum AuthMethod {
Password,
Sso,
UserApiKey,
Webauthn,
}
impl AuthMethod {
@ -1154,7 +1184,7 @@ impl AuthMethod {
match self {
AuthMethod::OrgApiKey => "api.organization".to_owned(),
AuthMethod::UserApiKey => "api".to_owned(),
AuthMethod::Password | AuthMethod::Sso => "api offline_access".to_owned(),
AuthMethod::Password | AuthMethod::Sso | AuthMethod::Webauthn => "api offline_access".to_owned(),
}
}
@ -1292,7 +1322,7 @@ pub async fn refresh_tokens(
}
AuthMethod::Sso => err!("SSO is now disabled, Login again using email and master password"),
AuthMethod::Password if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO is now required, Login again"),
AuthMethod::Password => AuthTokens::new(&device, &user, refresh_claims.sub, client_id),
AuthMethod::Password | AuthMethod::Webauthn => AuthTokens::new(&device, &user, refresh_claims.sub, client_id),
_ => err!("Invalid auth method, cannot refresh token"),
};

2
src/db/models/mod.rs

@ -17,6 +17,7 @@ mod two_factor;
mod two_factor_duo_context;
mod two_factor_incomplete;
mod user;
mod webauthn_credential;
pub use self::archive::Archive;
pub use self::attachment::{Attachment, AttachmentId};
@ -43,3 +44,4 @@ pub use self::two_factor::{TwoFactor, TwoFactorType};
pub use self::two_factor_duo_context::TwoFactorDuoContext;
pub use self::two_factor_incomplete::TwoFactorIncomplete;
pub use self::user::{Invitation, SsoUser, User, UserId, UserKdfType, UserStampException};
pub use self::webauthn_credential::{WebauthnCredential, WebauthnCredentialId};

1
src/db/models/two_factor.rs

@ -42,6 +42,7 @@ pub enum TwoFactorType {
EmailVerificationChallenge = 1002,
WebauthnRegisterChallenge = 1003,
WebauthnLoginChallenge = 1004,
WebauthnPasskeyRegisterChallenge = 1005,
// Special type for Protected Actions verification via email
ProtectedActions = 2000,

3
src/db/models/user.rs

@ -9,7 +9,7 @@ use crate::{
crypto,
db::{
DbConn,
models::DeviceId,
models::{DeviceId, WebauthnCredential},
schema::{invitations, sso_users, twofactor_incomplete, users},
},
error::MapResult,
@ -341,6 +341,7 @@ impl User {
TwoFactor::delete_all_by_user(&self.uuid, conn).await?;
TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?;
Invitation::take(&self.email, conn).await; // Delete invitation if any
WebauthnCredential::delete_all_by_user(&self.uuid, conn).await?;
conn.run(move |conn| {
diesel::delete(users::table.filter(users::uuid.eq(self.uuid))).execute(conn).map_res("Error deleting user")

153
src/db/models/webauthn_credential.rs

@ -0,0 +1,153 @@
use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*;
use macros::UuidFromParam;
use crate::api::EmptyResult;
use crate::db::DbConn;
use crate::db::schema::webauthn_credentials;
use crate::error::MapResult;
use super::UserId;
#[derive(num_derive::FromPrimitive, Serialize)]
pub enum WebauthnCredentialPrfStatus {
Enabled = 0,
Disabled = 1,
NotSupported = 2,
}
#[derive(Debug, Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = webauthn_credentials)]
#[diesel(treat_none_as_null = true)]
#[diesel(primary_key(uuid))]
pub struct WebauthnCredential {
pub uuid: WebauthnCredentialId,
pub user_uuid: UserId,
pub name: String,
pub credential: String,
pub supports_prf: bool,
pub encrypted_user_key: Option<String>,
pub encrypted_public_key: Option<String>,
pub encrypted_private_key: Option<String>,
}
/// Local methods
impl WebauthnCredential {
pub fn new(
user_uuid: UserId,
name: String,
credential: String,
supports_prf: bool,
encrypted_user_key: Option<String>,
encrypted_public_key: Option<String>,
encrypted_private_key: Option<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 fn get_prf_status(&self) -> WebauthnCredentialPrfStatus {
if self.supports_prf {
if self.encrypted_user_key.is_some()
&& self.encrypted_public_key.is_some()
&& self.encrypted_private_key.is_some()
{
WebauthnCredentialPrfStatus::Enabled
} else {
WebauthnCredentialPrfStatus::Disabled
}
} else {
WebauthnCredentialPrfStatus::NotSupported
}
}
}
/// Database methods
impl WebauthnCredential {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn: {
diesel::insert_into(webauthn_credentials::table)
.values(self)
.execute(conn)
.map_res("Error saving webauthn_credential")
}}
}
pub async fn find_all_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
db_run! { conn: {
webauthn_credentials::table
.filter(webauthn_credentials::user_uuid.eq(user_uuid))
.load::<Self>(conn)
.unwrap_or_default()
}}
}
pub async fn delete_by_uuid_and_user(
uuid: &WebauthnCredentialId,
user_uuid: &UserId,
conn: &DbConn,
) -> EmptyResult {
db_run! { conn: {
diesel::delete(
webauthn_credentials::table
.filter(webauthn_credentials::uuid.eq(uuid))
.filter(webauthn_credentials::user_uuid.eq(user_uuid)),
)
.execute(conn)
.map_res("Error removing webauthn_credential")
}}
}
pub async fn update_credential_by_uuid(
uuid: &WebauthnCredentialId,
credential: String,
conn: &DbConn,
) -> EmptyResult {
db_run! { conn: {
diesel::update(
webauthn_credentials::table
.filter(webauthn_credentials::uuid.eq(uuid)),
)
.set(webauthn_credentials::credential.eq(credential))
.execute(conn)
.map_res("Error updating credential for webauthn_credential")
}}
}
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
db_run! { conn: {
diesel::delete(
webauthn_credentials::table
.filter(webauthn_credentials::user_uuid.eq(user_uuid)),
)
.execute(conn)
.map_res("Error deleting all webauthn_credentials for user")
}}
}
}
#[derive(
Clone,
Debug,
AsRef,
Deref,
DieselNewType,
Display,
From,
FromForm,
Hash,
PartialEq,
Eq,
Serialize,
Deserialize,
UuidFromParam,
)]
pub struct WebauthnCredentialId(String);

15
src/db/schema.rs

@ -351,6 +351,19 @@ table! {
}
}
table! {
webauthn_credentials (uuid) {
uuid -> Text,
user_uuid -> Text,
name -> Text,
credential -> Text,
supports_prf -> Bool,
encrypted_user_key -> Nullable<Text>,
encrypted_public_key -> Nullable<Text>,
encrypted_private_key -> Nullable<Text>,
}
}
joinable!(archives -> users (user_uuid));
joinable!(archives -> ciphers (cipher_uuid));
joinable!(attachments -> ciphers (cipher_uuid));
@ -382,6 +395,7 @@ joinable!(collections_groups -> groups (groups_uuid));
joinable!(event -> users_organizations (uuid));
joinable!(auth_requests -> users (user_uuid));
joinable!(sso_users -> users (user_uuid));
joinable!(webauthn_credentials -> users (user_uuid));
allow_tables_to_appear_in_same_query!(
archives,
@ -408,4 +422,5 @@ allow_tables_to_appear_in_same_query!(
collections_groups,
event,
auth_requests,
webauthn_credentials,
);

15
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)}}

Loading…
Cancel
Save