Browse Source

Merge 89bcfe67f8 into 4a9bcb0694

pull/7370/merge
Raphaël Roumezin 1 day ago
committed by GitHub
parent
commit
f2eecf2536
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 3
      .env.template
  2. 1
      migrations/mysql/2026-06-23-120000_add_webauthn/down.sql
  3. 10
      migrations/mysql/2026-06-23-120000_add_webauthn/up.sql
  4. 1
      migrations/postgresql/2026-06-23-120000_add_webauthn/down.sql
  5. 10
      migrations/postgresql/2026-06-23-120000_add_webauthn/up.sql
  6. 1
      migrations/sqlite/2026-06-23-120000_add_webauthn/down.sql
  7. 10
      migrations/sqlite/2026-06-23-120000_add_webauthn/up.sql
  8. 30
      src/api/core/ciphers.rs
  9. 22
      src/api/core/mod.rs
  10. 3
      src/api/core/two_factor/mod.rs
  11. 47
      src/api/core/two_factor/webauthn.rs
  12. 286
      src/api/core/webauthn.rs
  13. 229
      src/api/identity.rs
  14. 1
      src/api/web.rs
  15. 34
      src/auth.rs
  16. 4
      src/config.rs
  17. 2
      src/db/models/mod.rs
  18. 1
      src/db/models/two_factor.rs
  19. 3
      src/db/models/user.rs
  20. 153
      src/db/models/webauthn_credential.rs
  21. 15
      src/db/schema.rs
  22. 2
      src/static/templates/scss/vaultwarden.scss.hbs

3
.env.template

@ -316,6 +316,9 @@
## unauthenticated access to potentially sensitive data.
# SHOW_PASSWORD_HINT=false
## Controls whether users can setup and use passkeys to log in, which also bypasses 2FA. This setting applies globally to all users.
# PASSKEY_LOGIN_ALLOWED=true
#########################
### Advanced settings ###
#########################

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
);

30
src/api/core/ciphers.rs

@ -12,9 +12,11 @@ use serde_json::Value;
use crate::{
CONFIG,
api::{self, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event},
auth::ClientVersion,
auth::{Headers, OrgIdGuard, OwnerHeaders},
api::{
self, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
core::{log_event, webauthn_prf_option},
},
auth::{ClientVersion, Headers, OrgIdGuard, OwnerHeaders},
config::PathType,
crypto,
db::{
@ -22,7 +24,7 @@ use crate::{
models::{
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership,
MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, WebauthnCredential,
},
},
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
@ -188,6 +190,12 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
Value::Null
};
let webauthn_prf_options = WebauthnCredential::find_all_by_user(&headers.user.uuid, &conn)
.await
.iter()
.filter_map(|wac| webauthn_prf_option(wac, false))
.collect();
Ok(Json(json!({
"profile": user_json,
"folders": folders_json,
@ -196,13 +204,21 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
"ciphers": ciphers_json,
"domains": domains_json,
"sends": sends_json,
"userDecryption": {
"masterPasswordUnlock": master_password_unlock,
},
"userDecryption": sync_user_decryption(&master_password_unlock, webauthn_prf_options),
"object": "sync"
})))
}
fn sync_user_decryption(master_password_unlock: &Value, webauthn_prf_options: Vec<Value>) -> Value {
let mut user_decryption = json!({
"masterPasswordUnlock": master_password_unlock,
});
if !webauthn_prf_options.is_empty() {
user_decryption["webAuthnPrfOptions"] = webauthn_prf_options.into();
}
user_decryption
}
#[get("/ciphers")]
async fn get_ciphers(headers: Headers, conn: DbConn) -> JsonResult {
let ciphers = Cipher::find_by_user_visible(&headers.user.uuid, &conn).await;

22
src/api/core/mod.rs

@ -8,12 +8,14 @@ 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};
pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job};
pub use events::{event_cleanup_job, log_event, log_user_event};
pub use sends::purge_sends;
pub use webauthn::{WEBAUTHN_PASSWORDLESS, webauthn_prf_option};
use reqwest::Method;
use rocket::{Catcher, Route, serde::json::Json, serde::json::Value};
@ -35,7 +37,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 +49,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 +198,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();
@ -220,6 +211,11 @@ fn config() -> Json<Value> {
&FeatureFlagFilter::ValidOnly,
);
feature_states.insert("pm-19148-innovation-archive".to_owned(), true);
if CONFIG.passkey_login_allowed() {
feature_states.insert("pm-2035-passkey-unlock".to_owned(), true);
} else {
feature_states.remove("pm-2035-passkey-unlock");
}
Json(json!({
// Note: The clients use this version to handle backwards compatibility concerns

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,
}
}

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

@ -1,19 +1,3 @@
use std::{str::FromStr, sync::LazyLock, time::Duration};
use rocket::{Route, serde::json::Json};
use serde_json::Value;
use url::Url;
use uuid::Uuid;
use webauthn_rs::{
Webauthn, WebauthnBuilder,
prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration},
};
use webauthn_rs_proto::{
AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw,
PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs,
RequestAuthenticationExtensions, UserVerificationPolicy,
};
use crate::{
CONFIG,
api::{
@ -29,17 +13,30 @@ use crate::{
error::Error,
util::NumberOrString,
};
use rocket::{Route, serde::json::Json};
use serde_json::Value;
use std::{str::FromStr, sync::LazyLock};
use uuid::Uuid;
use webauthn_rs::{
Webauthn, WebauthnBuilder,
prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration},
};
use webauthn_rs_proto::{
AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw,
PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs,
RequestAuthenticationExtensions, UserVerificationPolicy,
};
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
pub static WEBAUTHN_2FA: 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 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(Duration::from_mins(1));
.timeout(tokio::time::Duration::from_mins(1));
webauthn.build().expect("Building Webauthn failed")
});
@ -142,7 +139,7 @@ async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Hea
.map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering
.collect();
let (mut challenge, state) = WEBAUTHN.start_passkey_registration(
let (mut challenge, state) = WEBAUTHN_2FA.start_passkey_registration(
Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail
&user.email,
user.display_name(),
@ -181,7 +178,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,
@ -275,7 +272,7 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
};
// Verify the credentials with the saved state
let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?;
let credential = WEBAUTHN_2FA.finish_passkey_registration(&data.device_response.into(), &state)?;
let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1;
// TODO: Check for repeated ID's
@ -385,7 +382,7 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes
}
// Generate a challenge based on the credentials
let (mut response, state) = WEBAUTHN.start_passkey_authentication(&creds)?;
let (mut response, state) = WEBAUTHN_2FA.start_passkey_authentication(&creds)?;
// Modify to discourage user verification
let mut state = serde_json::to_value(&state)?;
@ -440,7 +437,7 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &Db
// Because of this we check the flag at runtime and update the registrations and state when needed
let backup_flags_updated = check_and_update_backup_eligible(&rsp, &mut registrations, &mut state)?;
let authentication_result = WEBAUTHN.finish_passkey_authentication(&rsp, &state)?;
let authentication_result = WEBAUTHN_2FA.finish_passkey_authentication(&rsp, &state)?;
for reg in &mut registrations {
if ct_eq(reg.credential.cred_id(), authentication_result.cred_id()) {

286
src/api/core/webauthn.rs

@ -0,0 +1,286 @@
use std::sync::LazyLock;
use rocket::{http::Status, serde::json::Json};
use serde_json::Value;
use webauthn_rs::{
Webauthn, WebauthnBuilder,
prelude::{Passkey, PasskeyRegistration},
};
use webauthn_rs_proto::{COSEAlgorithm, UserVerificationPolicy, options::PubKeyCredParams};
use crate::{
CONFIG,
api::{ApiResult, JsonResult, PasswordOrOtpData, core::two_factor::webauthn::RegisterPublicKeyCredentialCopy},
auth::Headers,
db::{
DbConn,
models::{TwoFactor, TwoFactorType, WebauthnCredential, WebauthnCredentialId, WebauthnCredentialPrfStatus},
},
};
pub static WEBAUTHN_PASSWORDLESS: 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 mut webauthn = WebauthnBuilder::new(&rp_id, &rp_origin)
.expect("Creating WebauthnBuilder failed")
.rp_name(&domain)
.timeout(tokio::time::Duration::from_mins(1));
for origin in [
"chrome-extension://nngceckbapebfimnlniiiahkandclblb", // Chrome Web Store
"chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh", // Edge Add-ons
] {
let origin = url::Url::parse(origin).expect("origin should parse");
webauthn = webauthn.append_allowed_origin(&origin);
}
webauthn.build().expect("Building Webauthn failed")
});
pub fn routes() -> Vec<rocket::Route> {
routes![get_webauthn, post_webauthn, post_webauthn_attestation_options, post_webauthn_delete]
}
pub fn webauthn_prf_option(wac: &WebauthnCredential, pascal_case: bool) -> Option<Value> {
if !matches!(wac.get_prf_status(), WebauthnCredentialPrfStatus::Enabled) {
return None;
}
let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?;
if pascal_case {
Some(json!({
"CredentialId": passkey.cred_id().to_owned(),
"Transports": [],
"EncryptedPrivateKey": wac.encrypted_private_key.as_ref()?,
"EncryptedUserKey": wac.encrypted_user_key.as_ref()?,
}))
} else {
Some(json!({
"credentialId": passkey.cred_id().to_owned(),
"transports": [],
"encryptedPrivateKey": wac.encrypted_private_key.as_ref()?,
"encryptedUserKey": wac.encrypted_user_key.as_ref()?,
}))
}
}
#[get("/webauthn")]
async fn get_webauthn(headers: Headers, conn: DbConn) -> JsonResult {
if !CONFIG.passkey_login_allowed() {
// The web vault will query this endpoint whether passkey login is allowed or not, so we should return an empty list instead of an error.
return Ok(Json(json!({
"object": "list",
"data": [],
"continuationToken": null
})));
}
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>();
Ok(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 {
if !CONFIG.passkey_login_allowed() {
err!("Passkey login is not allowed")
}
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_PASSWORDLESS.start_passkey_registration(
user_uuid,
&user.email,
user.display_name(),
Some(existing_cred_ids),
)?;
// For passkey login, we need discoverable credentials (resident keys).
// 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);
}
// Safely clear the extensions field to match Bitwarden behavior
if let Some(exts) = challenge.public_key.extensions.as_mut() {
exts.cred_props = None;
exts.uvm = None;
exts.cred_protect = None;
exts.hmac_create_secret = None;
exts.min_pin_length = None;
}
// Set algorithms list to {ES,RS,PS}{256,384,512},EDDSA to match Bitwarden behavior
challenge.public_key.pub_key_cred_params = vec![
PubKeyCredParams {
alg: COSEAlgorithm::ES256 as i64,
type_: "public-key".to_owned(),
},
PubKeyCredParams {
alg: COSEAlgorithm::ES384 as i64,
type_: "public-key".to_owned(),
},
PubKeyCredParams {
alg: COSEAlgorithm::ES512 as i64,
type_: "public-key".to_owned(),
},
PubKeyCredParams {
alg: COSEAlgorithm::RS256 as i64,
type_: "public-key".to_owned(),
},
PubKeyCredParams {
alg: COSEAlgorithm::RS384 as i64,
type_: "public-key".to_owned(),
},
PubKeyCredParams {
alg: COSEAlgorithm::RS512 as i64,
type_: "public-key".to_owned(),
},
PubKeyCredParams {
alg: COSEAlgorithm::PS256 as i64,
type_: "public-key".to_owned(),
},
PubKeyCredParams {
alg: COSEAlgorithm::PS384 as i64,
type_: "public-key".to_owned(),
},
PubKeyCredParams {
alg: COSEAlgorithm::PS512 as i64,
type_: "public-key".to_owned(),
},
PubKeyCredParams {
alg: COSEAlgorithm::EDDSA as i64,
type_: "public-key".to_owned(),
},
];
// 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> {
if !CONFIG.passkey_login_allowed() {
err!("Passkey login is not allowed")
}
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 = if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
tf.delete(&conn).await?;
WEBAUTHN_PASSWORDLESS.finish_passkey_registration(&data.device_response.into(), &state)?
} else {
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> {
if !CONFIG.passkey_login_allowed() {
err!("Passkey login is not allowed")
}
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_owned()), &user.uuid, &conn)
.await?;
Ok(Status::Ok)
}

229
src/api/identity.rs

@ -8,36 +8,42 @@ 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,
api::{
ApiResult, EmptyResult, JsonResult,
core::{
WEBAUTHN_PASSWORDLESS,
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,
yubikey,
},
webauthn_prf_option,
},
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, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete,
TwoFactorType, User, UserId,
TwoFactorType, User, UserId, WebauthnCredential,
},
},
error::MapResult,
mail, sso,
sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState},
mail,
sso::{self, OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState},
util,
};
@ -52,7 +58,8 @@ pub fn routes() -> Vec<Route> {
prevalidate,
authorize,
oidcsignin,
oidcsignin_error
oidcsignin_error,
get_webauthn_assertion_options
]
}
@ -108,6 +115,20 @@ 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" if CONFIG.passkey_login_allowed() => {
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
}
"webauthn" => err!("Passkey login is not allowed"),
"send_access" => {
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
check_is_some(data.send_id.as_ref(), "send_id cannot be blank")?;
@ -480,6 +501,161 @@ 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 {
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_PASSWORDLESS.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 enabled PRF-based decryption.
if let Some(prf_option) = webauthn_prf_option(matched_wac, true) {
let Json(ref mut val) = result;
val["UserDecryptionOptions"]["WebAuthnPrfOption"] = prf_option;
}
Ok(result)
}
async fn authenticated_response(
user: &User,
device: &mut Device,
@ -1014,7 +1190,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 */ }
}
}
@ -1104,7 +1281,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"))]
@ -1158,6 +1335,12 @@ struct ConnectData {
#[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>,
// Needed for send access
send_id: Option<SendId>,
password_hash_b64: Option<String>,
@ -1320,3 +1503,33 @@ 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 {
if !CONFIG.passkey_login_allowed() {
err!("Passkey login is not allowed")
}
let (mut response, state) = WEBAUTHN_PASSWORDLESS.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"
})))
}

1
src/api/web.rs

@ -78,6 +78,7 @@ fn vaultwarden_css() -> Cached<Css<String>> {
"sso_only": CONFIG.sso_enabled() && CONFIG.sso_only(),
"webauthn_2fa_supported": CONFIG.is_webauthn_2fa_supported(),
"yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(),
"passkey_login_allowed": CONFIG.passkey_login_allowed(),
});
let scss = match CONFIG.render_template("scss/vaultwarden.scss", &css_options) {

34
src/auth.rs

@ -19,6 +19,7 @@ use rocket::{
outcome::try_outcome,
request::{FromRequest, Outcome, Request},
};
use webauthn_rs::prelude::PasskeyAuthentication;
use crate::{
CONFIG,
@ -61,6 +62,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();
@ -175,6 +177,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
@ -333,6 +339,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
@ -1162,6 +1191,7 @@ pub enum AuthMethod {
Password,
Sso,
UserApiKey,
Webauthn,
}
impl AuthMethod {
@ -1169,7 +1199,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(),
}
}
@ -1307,7 +1337,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"),
};

4
src/config.rs

@ -649,6 +649,9 @@ make_config! {
/// because this provides unauthenticated access to potentially sensitive data.
show_password_hint: bool, true, def, false;
/// Allow passkey login |> Controls whether users can setup and use passkeys to log in, which also bypasses 2FA. This setting applies globally to all users.
passkey_login_allowed: bool, true, def, true;
/// Admin token/Argon2 PHC |> The plain text token or Argon2 PHC string used to authenticate in this very same page. Changing it here will not deauthorize the current session!
admin_token: Pass, true, option;
@ -1403,6 +1406,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
"ssh-agent-v2",
// Key Management Team
"ssh-key-vault-item",
"pm-2035-passkey-unlock",
"pm-25373-windows-biometrics-v2",
"pm-26340-linux-biometrics-v2",
// Mobile Team

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};
@ -40,3 +41,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, WebauthnCredentialPrfStatus};

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,
);

2
src/static/templates/scss/vaultwarden.scss.hbs

@ -54,6 +54,7 @@ app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secon
}
{{/if}}
{{#if (not passkey_login_allowed)}}
/* Hide the `Log in with passkey` settings */
app-user-layout app-password-settings app-webauthn-login-settings {
@extend %vw-hide;
@ -68,6 +69,7 @@ app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secon
@extend %vw-hide;
}
{{/if}}
{{/if}}
/* Hide the or text followed by the two buttons hidden above */
{{#if (webver ">=2025.5.1")}}

Loading…
Cancel
Save