Browse Source

Add passkey login support

pull/6819/head
a15355447898a 4 weeks ago
parent
commit
ff65da293d
Failed to extract signature
  1. 3
      Cargo.toml
  2. 2
      src/api/admin.rs
  3. 84
      src/api/core/accounts.rs
  4. 37
      src/api/core/ciphers.rs
  5. 2
      src/api/core/emergency_access.rs
  6. 238
      src/api/core/mod.rs
  7. 2
      src/api/core/two_factor/mod.rs
  8. 370
      src/api/core/two_factor/webauthn.rs
  9. 138
      src/api/identity.rs
  10. 25
      src/db/models/org_policy.rs
  11. 23
      src/db/models/two_factor.rs
  12. 16
      src/static/templates/scss/vaultwarden.scss.hbs

3
Cargo.toml

@ -128,7 +128,8 @@ yubico = { package = "yubico_ng", version = "0.14.1", features = ["online-tokio"
# WebAuthn libraries
# danger-allow-state-serialisation is needed to save the state in the db
# danger-credential-internals is needed to support U2F to Webauthn migration
webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
# conditional-ui is needed for discoverable (username-less) passkey login
webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-credential-internals", "conditional-ui"] }
webauthn-rs-proto = "0.5.4"
webauthn-rs-core = "0.5.4"

2
src/api/admin.rs

@ -502,7 +502,7 @@ async fn enable_user(user_id: UserId, _token: AdminToken, conn: DbConn) -> Empty
#[post("/users/<user_id>/remove-2fa", format = "application/json")]
async fn remove_2fa(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult {
let mut user = get_user_or_404(&user_id, &conn).await?;
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
TwoFactor::delete_all_2fa_by_user(&user.uuid, &conn).await?;
two_factor::enforce_2fa_policy(&user, &ACTING_ADMIN_USER.into(), 14, &token.ip.ip, &conn).await?;
user.totp_recover = None;
user.save(&conn).await

84
src/api/core/accounts.rs

@ -1,4 +1,4 @@
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use crate::db::DbPool;
use chrono::Utc;
@ -7,7 +7,7 @@ use serde_json::Value;
use crate::{
api::{
core::{accept_org_invite, log_user_event, two_factor::email},
core::{accept_org_invite, log_user_event, two_factor::{email, webauthn}},
master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, ApiResult, EmptyResult,
JsonResult, Notify, PasswordOrOtpData, UpdateType,
},
@ -17,7 +17,7 @@ use crate::{
models::{
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, EmergencyAccess,
EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, OrgPolicy,
OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType,
OrgPolicyType, Organization, OrganizationId, Send, SendId, TwoFactor, TwoFactorType, User, UserId, UserKdfType,
},
DbConn,
},
@ -689,6 +689,17 @@ struct RotateAccountUnlockData {
emergency_access_unlock_data: Vec<UpdateEmergencyAccessData>,
master_password_unlock_data: MasterPasswordUnlockData,
organization_account_recovery_unlock_data: Vec<UpdateResetPasswordData>,
passkey_unlock_data: Vec<UpdatePasskeyData>,
#[serde(rename = "deviceKeyUnlockData")]
_device_key_unlock_data: Vec<Value>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdatePasskeyData {
id: NumberOrString,
encrypted_user_key: Option<String>,
encrypted_public_key: Option<String>,
}
#[derive(Deserialize)]
@ -725,6 +736,7 @@ fn validate_keydata(
existing_emergency_access: &[EmergencyAccess],
existing_memberships: &[Membership],
existing_sends: &[Send],
existing_webauthn_credentials: &[webauthn::WebauthnRegistration],
user: &User,
) -> EmptyResult {
if user.client_kdf_type != data.account_unlock_data.master_password_unlock_data.kdf_type
@ -793,6 +805,32 @@ fn validate_keydata(
err!("All existing sends must be included in the rotation")
}
let keys_to_rotate = data
.account_unlock_data
.passkey_unlock_data
.iter()
.map(|credential| (credential.id.clone().into_string(), credential))
.collect::<HashMap<_, _>>();
let valid_webauthn_credentials: Vec<_> =
existing_webauthn_credentials.iter().filter(|credential| credential.prf_status() == 0).collect();
for webauthn_credential in valid_webauthn_credentials {
let key_to_rotate = keys_to_rotate
.get(&webauthn_credential.login_credential_api_id())
.or_else(|| keys_to_rotate.get(&webauthn_credential.id.to_string()));
let Some(key_to_rotate) = key_to_rotate else {
err!("All existing webauthn prf keys must be included in the rotation.");
};
if key_to_rotate.encrypted_user_key.is_none() {
err!("WebAuthn prf keys must have user-key during rotation.");
}
if key_to_rotate.encrypted_public_key.is_none() {
err!("WebAuthn prf keys must have public-key during rotation.");
}
}
Ok(())
}
@ -822,6 +860,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
// We only rotate the reset password key if it is set.
existing_memberships.retain(|m| m.reset_password_key.is_some());
let mut existing_sends = Send::find_by_user(user_id, &conn).await;
let mut existing_webauthn_credentials = webauthn::get_webauthn_login_registrations(user_id, &conn).await?;
validate_keydata(
&data,
@ -830,6 +869,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
&existing_emergency_access,
&existing_memberships,
&existing_sends,
&existing_webauthn_credentials,
&headers.user,
)?;
@ -871,6 +911,44 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
membership.save(&conn).await?
}
let passkey_unlock_data = data
.account_unlock_data
.passkey_unlock_data
.iter()
.map(|credential| (credential.id.clone().into_string(), credential))
.collect::<HashMap<_, _>>();
let mut webauthn_credentials_changed = false;
for webauthn_credential in existing_webauthn_credentials.iter_mut().filter(|credential| credential.prf_status() == 0) {
let key_to_rotate = passkey_unlock_data
.get(&webauthn_credential.login_credential_api_id())
.or_else(|| passkey_unlock_data.get(&webauthn_credential.id.to_string()))
.expect("Missing webauthn prf key after successful validation");
let encrypted_user_key =
key_to_rotate.encrypted_user_key.clone().expect("Missing user-key after successful validation");
let encrypted_public_key =
key_to_rotate.encrypted_public_key.clone().expect("Missing public-key after successful validation");
if webauthn_credential.encrypted_user_key.as_ref() != Some(&encrypted_user_key)
|| webauthn_credential.encrypted_public_key.as_ref() != Some(&encrypted_public_key)
{
webauthn_credentials_changed = true;
}
webauthn_credential.encrypted_user_key = Some(encrypted_user_key);
webauthn_credential.encrypted_public_key = Some(encrypted_public_key);
}
if webauthn_credentials_changed {
TwoFactor::new(
user_id.clone(),
TwoFactorType::WebauthnLoginCredential,
serde_json::to_string(&existing_webauthn_credentials)?,
)
.save(&conn)
.await?;
}
// Update send data
for send_data in data.account_data.sends {
let Some(send) = existing_sends.iter_mut().find(|s| &s.uuid == send_data.id.as_ref().unwrap()) else {

37
src/api/core/ciphers.rs

@ -1,6 +1,7 @@
use std::collections::{HashMap, HashSet};
use chrono::{NaiveDateTime, Utc};
use data_encoding::BASE64URL_NOPAD;
use num_traits::ToPrimitive;
use rocket::fs::TempFile;
use rocket::serde::json::Json;
@ -127,6 +128,28 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
} else {
false
};
let webauthn_prf_options: Vec<Value> = api::core::two_factor::webauthn::get_webauthn_login_registrations(
&headers.user.uuid,
&conn,
)
.await?
.into_iter()
.filter(|registration| registration.prf_status() == 0)
.filter_map(|registration| {
let encrypted_private_key = registration.encrypted_private_key?;
let encrypted_user_key = registration.encrypted_user_key?;
registration.encrypted_public_key.as_ref()?;
Some(json!({
"encryptedPrivateKey": encrypted_private_key,
"encryptedUserKey": encrypted_user_key,
"credentialId": BASE64URL_NOPAD.encode(registration.credential.cred_id().as_slice()),
"transports": Vec::<String>::new(),
}))
})
.collect();
if !show_ssh_keys {
ciphers.retain(|c| c.atype != 5);
}
@ -173,16 +196,20 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
"memory": headers.user.client_kdf_memory,
"parallelism": headers.user.client_kdf_parallelism
},
// This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps.
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
"masterKeyEncryptedUserKey": headers.user.akey,
"masterKeyWrappedUserKey": headers.user.akey,
"salt": headers.user.email
})
} else {
Value::Null
};
let mut user_decryption = json!({
"masterPasswordUnlock": master_password_unlock,
});
if !webauthn_prf_options.is_empty() {
user_decryption["webAuthnPrfOptions"] = Value::Array(webauthn_prf_options);
}
Ok(Json(json!({
"profile": user_json,
"folders": folders_json,
@ -191,9 +218,7 @@ 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": user_decryption,
"object": "sync"
})))
}

2
src/api/core/emergency_access.rs

@ -657,7 +657,7 @@ async fn password_emergency_access(
grantor_user.save(&conn).await?;
// Disable TwoFactor providers since they will otherwise block logins
TwoFactor::delete_all_by_user(&grantor_user.uuid, &conn).await?;
TwoFactor::delete_all_2fa_by_user(&grantor_user.uuid, &conn).await?;
// Remove grantor from all organisations unless Owner
for member in Membership::find_any_state_by_user(&grantor_user.uuid, &conn).await {

238
src/api/core/mod.rs

@ -14,11 +14,23 @@ pub use emergency_access::{emergency_notification_reminder_job, emergency_reques
pub use events::{event_cleanup_job, log_event, log_user_event};
use reqwest::Method;
pub use sends::purge_sends;
use std::sync::LazyLock;
pub fn routes() -> Vec<Route> {
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,
get_api_webauthn_attestation_options,
post_api_webauthn,
post_api_webauthn_assertion_options,
put_api_webauthn,
delete_api_webauthn
];
let mut routes = Vec::new();
routes.append(&mut accounts::routes());
@ -50,13 +62,13 @@ pub fn events_routes() -> Vec<Route> {
use rocket::{serde::json::Json, serde::json::Value, Catcher, Route};
use crate::{
api::{EmptyResult, JsonResult, Notify, UpdateType},
auth::Headers,
api::{EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType},
auth::{self, Headers},
db::{
models::{Membership, MembershipStatus, OrgPolicy, Organization, User},
models::{Membership, MembershipStatus, OrgPolicy, Organization, User, UserId},
DbConn,
},
error::Error,
error::{Error, MapResult},
http_client::make_http_request,
mail,
util::parse_experimental_client_feature_flags,
@ -72,6 +84,98 @@ struct GlobalDomain {
const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json");
static WEBAUTHN_CREATE_OPTIONS_ISSUER: LazyLock<String> =
LazyLock::new(|| format!("{}|webauthn_create_options", crate::CONFIG.domain_origin()));
static WEBAUTHN_UPDATE_ASSERTION_OPTIONS_ISSUER: LazyLock<String> =
LazyLock::new(|| format!("{}|webauthn_update_assertion_options", crate::CONFIG.domain_origin()));
const REQUIRE_SSO_POLICY_TYPE: i32 = 4;
#[derive(Debug, Serialize, Deserialize)]
struct WebauthnCreateOptionsClaims {
nbf: i64,
exp: i64,
iss: String,
sub: UserId,
state: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct WebauthnUpdateAssertionOptionsClaims {
nbf: i64,
exp: i64,
iss: String,
sub: UserId,
state: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WebauthnCredentialCreateRequest {
device_response: two_factor::webauthn::RegisterPublicKeyCredentialCopy,
name: String,
token: String,
supports_prf: bool,
encrypted_user_key: Option<String>,
encrypted_public_key: Option<String>,
encrypted_private_key: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WebauthnCredentialUpdateRequest {
device_response: two_factor::webauthn::PublicKeyCredentialCopy,
token: String,
encrypted_user_key: String,
encrypted_public_key: String,
encrypted_private_key: String,
}
fn encode_webauthn_create_options_token(user_id: &UserId, state: String) -> String {
let now = chrono::Utc::now();
let claims = WebauthnCreateOptionsClaims {
nbf: now.timestamp(),
exp: (now + chrono::TimeDelta::try_minutes(7).unwrap()).timestamp(),
iss: WEBAUTHN_CREATE_OPTIONS_ISSUER.to_string(),
sub: user_id.clone(),
state,
};
auth::encode_jwt(&claims)
}
fn decode_webauthn_create_options_token(token: &str) -> Result<WebauthnCreateOptionsClaims, Error> {
auth::decode_jwt(token, WEBAUTHN_CREATE_OPTIONS_ISSUER.to_string()).map_res("Invalid WebAuthn token")
}
fn encode_webauthn_update_assertion_options_token(user_id: &UserId, state: String) -> String {
let now = chrono::Utc::now();
let claims = WebauthnUpdateAssertionOptionsClaims {
nbf: now.timestamp(),
exp: (now + chrono::TimeDelta::try_minutes(17).unwrap()).timestamp(),
iss: WEBAUTHN_UPDATE_ASSERTION_OPTIONS_ISSUER.to_string(),
sub: user_id.clone(),
state,
};
auth::encode_jwt(&claims)
}
fn decode_webauthn_update_assertion_options_token(
token: &str,
) -> Result<WebauthnUpdateAssertionOptionsClaims, Error> {
auth::decode_jwt(token, WEBAUTHN_UPDATE_ASSERTION_OPTIONS_ISSUER.to_string()).map_res("Invalid WebAuthn token")
}
async fn ensure_passkey_creation_allowed(user_id: &UserId, conn: &DbConn) -> EmptyResult {
// `RequireSso` (policy type 4) is not fully supported in Vaultwarden, but if present in DB
// we still mirror official behavior by blocking passkey creation.
if OrgPolicy::has_active_raw_policy_for_user(user_id, REQUIRE_SSO_POLICY_TYPE, conn).await {
err!("Passkeys cannot be created for your account. SSO login is required.")
}
Ok(())
}
#[get("/settings/domains")]
fn get_eq_domains(headers: Headers) -> Json<Value> {
_get_eq_domains(&headers, false)
@ -184,15 +288,125 @@ fn version() -> Json<&'static str> {
}
#[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!({
async fn get_api_webauthn(headers: Headers, conn: DbConn) -> JsonResult {
let registrations = two_factor::webauthn::get_webauthn_login_registrations(&headers.user.uuid, &conn).await?;
let data: Vec<Value> = registrations
.into_iter()
.map(|registration| {
json!({
"id": registration.login_credential_api_id(),
"name": registration.name,
"prfStatus": registration.prf_status(),
"encryptedUserKey": registration.encrypted_user_key,
"encryptedPublicKey": registration.encrypted_public_key,
"object": "webauthnCredential"
})
})
.collect();
Ok(Json(json!({
"object": "list",
"data": [],
"data": data,
"continuationToken": null
}))
})))
}
#[post("/webauthn/attestation-options", data = "<data>")]
async fn get_api_webauthn_attestation_options(
data: Json<PasswordOrOtpData>,
headers: Headers,
conn: DbConn,
) -> JsonResult {
if !crate::CONFIG.domain_set() {
err!("`DOMAIN` environment variable is not set. Webauthn disabled")
}
data.into_inner().validate(&headers.user, false, &conn).await?;
ensure_passkey_creation_allowed(&headers.user.uuid, &conn).await?;
let (options, state) = two_factor::webauthn::generate_webauthn_attestation_options(&headers.user, &conn).await?;
let token = encode_webauthn_create_options_token(&headers.user.uuid, state);
Ok(Json(json!({
"options": options,
"token": token,
"object": "webauthnCredentialCreateOptions"
})))
}
#[post("/webauthn", data = "<data>")]
async fn post_api_webauthn(data: Json<WebauthnCredentialCreateRequest>, headers: Headers, conn: DbConn) -> EmptyResult {
let data = data.into_inner();
let claims = decode_webauthn_create_options_token(&data.token)?;
if claims.sub != headers.user.uuid {
err!("The token associated with your request is expired. A valid token is required to continue.")
}
ensure_passkey_creation_allowed(&headers.user.uuid, &conn).await?;
two_factor::webauthn::create_webauthn_login_credential(
&headers.user.uuid,
&claims.state,
data.name,
data.device_response,
data.supports_prf,
data.encrypted_user_key,
data.encrypted_public_key,
data.encrypted_private_key,
&conn,
)
.await?;
Ok(())
}
#[post("/webauthn/assertion-options", data = "<data>")]
async fn post_api_webauthn_assertion_options(
data: Json<PasswordOrOtpData>,
headers: Headers,
conn: DbConn,
) -> JsonResult {
data.into_inner().validate(&headers.user, false, &conn).await?;
let (options, state) = two_factor::webauthn::generate_webauthn_discoverable_login()?;
let token = encode_webauthn_update_assertion_options_token(&headers.user.uuid, state);
Ok(Json(json!({
"options": options,
"token": token,
"object": "webAuthnLoginAssertionOptions"
})))
}
#[put("/webauthn", data = "<data>")]
async fn put_api_webauthn(data: Json<WebauthnCredentialUpdateRequest>, headers: Headers, conn: DbConn) -> EmptyResult {
let data = data.into_inner();
let claims = decode_webauthn_update_assertion_options_token(&data.token)?;
if claims.sub != headers.user.uuid {
err!("The token associated with your request is invalid or has expired. A valid token is required to continue.")
}
two_factor::webauthn::update_webauthn_login_credential_keys(
&headers.user.uuid,
&claims.state,
data.device_response,
data.encrypted_user_key,
data.encrypted_public_key,
data.encrypted_private_key,
&conn,
)
.await?;
Ok(())
}
#[post("/webauthn/<id>/delete", data = "<data>")]
async fn delete_api_webauthn(id: String, data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> EmptyResult {
data.into_inner().validate(&headers.user, false, &conn).await?;
two_factor::webauthn::delete_webauthn_login_credential(&headers.user.uuid, &id, &conn).await?;
Ok(())
}
#[get("/config")]

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

@ -106,7 +106,7 @@ async fn recover(data: Json<RecoverTwoFactor>, client_headers: ClientHeaders, co
}
// Remove all twofactors from the user
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
TwoFactor::delete_all_2fa_by_user(&user.uuid, &conn).await?;
enforce_2fa_policy(&user, &user.uuid, client_headers.device_type, &client_headers.ip.ip, &conn).await?;
log_user_event(

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

@ -6,13 +6,14 @@ use crate::{
auth::Headers,
crypto::ct_eq,
db::{
models::{EventType, TwoFactor, TwoFactorType, UserId},
models::{EventType, TwoFactor, TwoFactorType, User, UserId},
DbConn,
},
error::Error,
util::NumberOrString,
CONFIG,
};
use data_encoding::BASE64URL_NOPAD;
use rocket::serde::json::Json;
use rocket::Route;
use serde_json::Value;
@ -21,7 +22,10 @@ use std::sync::LazyLock;
use std::time::Duration;
use url::Url;
use uuid::Uuid;
use webauthn_rs::prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration};
use webauthn_rs::prelude::{
Base64UrlSafeData, Credential, DiscoverableAuthentication, DiscoverableKey, Passkey, PasskeyAuthentication,
PasskeyRegistration,
};
use webauthn_rs::{Webauthn, WebauthnBuilder};
use webauthn_rs_proto::{
AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw,
@ -72,10 +76,32 @@ pub struct U2FRegistration {
#[derive(Debug, Serialize, Deserialize)]
pub struct WebauthnRegistration {
pub id: i32,
#[serde(default)]
pub api_id: Option<String>,
pub name: String,
pub migrated: bool,
pub credential: Passkey,
#[serde(default)]
pub supports_prf: bool,
pub encrypted_user_key: Option<String>,
pub encrypted_public_key: Option<String>,
pub encrypted_private_key: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct WebauthnPrfDecryptionOption {
pub encrypted_private_key: String,
pub encrypted_user_key: String,
pub credential_id: String,
pub transports: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct WebauthnDiscoverableLoginResult {
pub user_id: UserId,
pub prf_decryption_option: Option<WebauthnPrfDecryptionOption>,
}
impl WebauthnRegistration {
@ -104,6 +130,48 @@ impl WebauthnRegistration {
self.credential = cred.into();
changed
}
pub fn prf_status(&self) -> i32 {
if !self.supports_prf {
// Unsupported
2
} else if self.encrypted_user_key.is_some()
&& self.encrypted_public_key.is_some()
&& self.encrypted_private_key.is_some()
{
// Enabled
0
} else {
// Supported
1
}
}
fn ensure_api_id(&mut self) -> bool {
if self.api_id.is_none() {
self.api_id = Some(Uuid::new_v4().to_string());
return true;
}
false
}
pub fn login_credential_api_id(&self) -> String {
self.api_id.clone().unwrap_or_else(|| self.id.to_string())
}
pub fn matches_login_credential_id(&self, id: &str) -> bool {
self.api_id.as_deref() == Some(id) || self.id.to_string() == id
}
}
fn normalize_optional_secret(value: Option<String>) -> Option<String> {
value.and_then(|s| {
if s.trim().is_empty() {
None
} else {
Some(s)
}
})
}
#[post("/two-factor/get-webauthn", data = "<data>")]
@ -180,10 +248,12 @@ struct EnableWebauthnData {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegisterPublicKeyCredentialCopy {
pub struct RegisterPublicKeyCredentialCopy {
pub id: String,
pub raw_id: Base64UrlSafeData,
pub response: AuthenticatorAttestationResponseRawCopy,
#[serde(default, alias = "clientExtensionResults")]
pub extensions: RegistrationExtensionsClientOutputs,
pub r#type: String,
}
@ -208,7 +278,7 @@ impl From<RegisterPublicKeyCredentialCopy> for RegisterPublicKeyCredential {
transports: None,
},
type_: r.r#type,
extensions: RegistrationExtensionsClientOutputs::default(),
extensions: r.extensions,
}
}
}
@ -281,10 +351,15 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
// TODO: Check for repeated ID's
registrations.push(WebauthnRegistration {
id: data.id.into_i32()?,
api_id: None,
name: data.name,
migrated: false,
credential,
supports_prf: false,
encrypted_user_key: None,
encrypted_public_key: None,
encrypted_private_key: None,
});
// Save the registrations and return them
@ -374,6 +449,205 @@ pub async fn get_webauthn_registrations(
}
}
pub async fn get_webauthn_login_registrations(user_id: &UserId, conn: &DbConn) -> Result<Vec<WebauthnRegistration>, Error> {
let type_ = TwoFactorType::WebauthnLoginCredential as i32;
let Some(mut tf) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await else {
return Ok(Vec::new());
};
let mut registrations: Vec<WebauthnRegistration> = serde_json::from_str(&tf.data)?;
let mut changed = false;
for registration in &mut registrations {
changed |= registration.ensure_api_id();
}
if changed {
tf.data = serde_json::to_string(&registrations)?;
tf.save(conn).await?;
}
Ok(registrations)
}
pub async fn generate_webauthn_attestation_options(user: &User, conn: &DbConn) -> Result<(Value, String), Error> {
let registrations = get_webauthn_login_registrations(&user.uuid, conn)
.await?
.into_iter()
.map(|r| r.credential.cred_id().to_owned())
.collect();
let (challenge, state) = WEBAUTHN.start_passkey_registration(
Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail
&user.email,
user.display_name(),
Some(registrations),
)?;
let mut options = serde_json::to_value(challenge.public_key)?;
if let Some(obj) = options.as_object_mut() {
obj.insert("userVerification".to_string(), Value::String("required".to_string()));
let auth_sel = obj
.entry("authenticatorSelection")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if let Some(auth_sel_obj) = auth_sel.as_object_mut() {
auth_sel_obj.insert("requireResidentKey".to_string(), Value::Bool(true));
auth_sel_obj.insert("residentKey".to_string(), Value::String("required".to_string()));
auth_sel_obj.insert("userVerification".to_string(), Value::String("required".to_string()));
}
}
let mut state = serde_json::to_value(&state)?;
if let Some(rs) = state.get_mut("rs").and_then(Value::as_object_mut) {
rs.insert("policy".to_string(), Value::String("required".to_string()));
}
Ok((options, serde_json::to_string(&state)?))
}
pub async fn create_webauthn_login_credential(
user_id: &UserId,
serialized_state: &str,
name: String,
device_response: RegisterPublicKeyCredentialCopy,
supports_prf: bool,
encrypted_user_key: Option<String>,
encrypted_public_key: Option<String>,
encrypted_private_key: Option<String>,
conn: &DbConn,
) -> EmptyResult {
const MAX_CREDENTIALS_PER_USER: usize = 5;
let state: PasskeyRegistration = serde_json::from_str(serialized_state)?;
let credential = WEBAUTHN.finish_passkey_registration(&device_response.into(), &state)?;
let encrypted_user_key = normalize_optional_secret(encrypted_user_key);
let encrypted_public_key = normalize_optional_secret(encrypted_public_key);
let encrypted_private_key = normalize_optional_secret(encrypted_private_key);
let mut registrations = get_webauthn_login_registrations(user_id, conn).await?;
if registrations.len() >= MAX_CREDENTIALS_PER_USER {
err!("Unable to complete WebAuthn registration.")
}
// Avoid duplicate credential IDs.
if registrations.iter().any(|r| ct_eq(r.credential.cred_id(), credential.cred_id())) {
err!("Unable to complete WebAuthn registration.")
}
let id = registrations.iter().map(|r| r.id).max().unwrap_or(0).saturating_add(1);
registrations.push(WebauthnRegistration {
id,
api_id: Some(Uuid::new_v4().to_string()),
name,
migrated: false,
credential,
supports_prf,
encrypted_user_key,
encrypted_public_key,
encrypted_private_key,
});
TwoFactor::new(
user_id.clone(),
TwoFactorType::WebauthnLoginCredential,
serde_json::to_string(&registrations)?,
)
.save(conn)
.await?;
Ok(())
}
pub async fn update_webauthn_login_credential_keys(
user_id: &UserId,
serialized_state: &str,
device_response: PublicKeyCredentialCopy,
encrypted_user_key: String,
encrypted_public_key: String,
encrypted_private_key: String,
conn: &DbConn,
) -> EmptyResult {
if encrypted_user_key.trim().is_empty()
|| encrypted_public_key.trim().is_empty()
|| encrypted_private_key.trim().is_empty()
{
err!("Unable to update credential.")
}
let state: DiscoverableAuthentication = serde_json::from_str(serialized_state)?;
let rsp: PublicKeyCredential = device_response.into();
let (asserted_uuid, credential_id) = WEBAUTHN.identify_discoverable_authentication(&rsp)?;
let asserted_user_id: UserId = asserted_uuid.to_string().into();
if asserted_user_id != *user_id {
err!("Invalid credential.")
}
let mut registrations = get_webauthn_login_registrations(user_id, conn).await?;
let Some(registration) = registrations
.iter_mut()
.find(|r| ct_eq(r.credential.cred_id().as_slice(), credential_id))
else {
err!("Invalid credential.")
};
let discoverable_key: DiscoverableKey = registration.credential.clone().into();
let authentication_result =
WEBAUTHN.finish_discoverable_authentication(&rsp, state, &[discoverable_key])?;
if !registration.supports_prf {
err!("Unable to update credential.")
}
registration.encrypted_user_key = Some(encrypted_user_key);
registration.encrypted_public_key = Some(encrypted_public_key);
registration.encrypted_private_key = Some(encrypted_private_key);
registration.credential.update_credential(&authentication_result);
TwoFactor::new(
user_id.clone(),
TwoFactorType::WebauthnLoginCredential,
serde_json::to_string(&registrations)?,
)
.save(conn)
.await?;
Ok(())
}
pub async fn delete_webauthn_login_credential(user_id: &UserId, id: &str, conn: &DbConn) -> EmptyResult {
let Some(mut tf) =
TwoFactor::find_by_user_and_type(user_id, TwoFactorType::WebauthnLoginCredential as i32, conn).await
else {
err!("Credential not found.")
};
let mut data: Vec<WebauthnRegistration> = serde_json::from_str(&tf.data)?;
let Some(item_pos) = data.iter().position(|r| r.matches_login_credential_id(id)) else {
err!("Credential not found.")
};
let removed_item = data.remove(item_pos);
tf.data = serde_json::to_string(&data)?;
tf.save(conn).await?;
drop(tf);
// If entry is migrated from u2f, delete the u2f entry as well.
if let Some(mut u2f) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::U2f as i32, conn).await {
let mut data: Vec<U2FRegistration> = match serde_json::from_str(&u2f.data) {
Ok(d) => d,
Err(_) => err!("Error parsing U2F data"),
};
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice());
u2f.data = serde_json::to_string(&data)?;
u2f.save(conn).await?;
}
Ok(())
}
pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonResult {
// Load saved credentials
let creds: Vec<Passkey> =
@ -463,6 +737,94 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &Db
)
}
pub fn generate_webauthn_discoverable_login() -> Result<(Value, String), Error> {
let (response, state) = WEBAUTHN.start_discoverable_authentication()?;
let mut options = serde_json::to_value(response.public_key)?;
if let Some(obj) = options.as_object_mut() {
obj.insert("userVerification".to_string(), Value::String("required".to_string()));
let need_empty_allow_credentials = match obj.get("allowCredentials") {
Some(value) => value.is_null(),
None => true,
};
if need_empty_allow_credentials {
obj.insert("allowCredentials".to_string(), Value::Array(Vec::new()));
}
}
let mut state = serde_json::to_value(&state)?;
if let Some(ast) = state.get_mut("ast").and_then(Value::as_object_mut) {
ast.insert("policy".to_string(), Value::String("required".to_string()));
}
Ok((options, serde_json::to_string(&state)?))
}
pub async fn validate_webauthn_discoverable_login(
serialized_state: &str,
response: &str,
conn: &DbConn,
) -> Result<WebauthnDiscoverableLoginResult, Error> {
let state: DiscoverableAuthentication = serde_json::from_str(serialized_state)?;
let rsp: PublicKeyCredentialCopy = serde_json::from_str(response)?;
let rsp: PublicKeyCredential = rsp.into();
let (uuid, credential_id) = WEBAUTHN.identify_discoverable_authentication(&rsp)?;
let user_id: UserId = uuid.to_string().into();
let mut registrations = get_webauthn_login_registrations(&user_id, conn).await?;
let Some(registration_idx) = registrations
.iter()
.position(|r| ct_eq(r.credential.cred_id().as_slice(), credential_id))
else {
err!("Invalid credential.")
};
let discoverable_key: DiscoverableKey = registrations[registration_idx].credential.clone().into();
let authentication_result =
WEBAUTHN.finish_discoverable_authentication(&rsp, state, &[discoverable_key])?;
// Keep signature counters in sync.
let credential_updated = {
let registration = &mut registrations[registration_idx];
registration.credential.update_credential(&authentication_result) == Some(true)
};
if credential_updated {
TwoFactor::new(
user_id.clone(),
TwoFactorType::WebauthnLoginCredential,
serde_json::to_string(&registrations)?,
)
.save(conn)
.await?;
}
let prf_decryption_option = {
let registration = &registrations[registration_idx];
if registration.supports_prf {
match (
registration.encrypted_private_key.clone(),
registration.encrypted_user_key.clone(),
registration.encrypted_public_key.as_ref(),
) {
(Some(encrypted_private_key), Some(encrypted_user_key), Some(_)) => Some(WebauthnPrfDecryptionOption {
encrypted_private_key,
encrypted_user_key,
credential_id: BASE64URL_NOPAD.encode(registration.credential.cred_id().as_slice()),
transports: Vec::new(),
}),
_ => None,
}
} else {
None
}
};
Ok(WebauthnDiscoverableLoginResult {
user_id,
prf_decryption_option,
})
}
async fn check_and_update_backup_eligible(
user_id: &UserId,
rsp: &PublicKeyCredential,

138
src/api/identity.rs

@ -8,6 +8,7 @@ use rocket::{
Route,
};
use serde_json::Value;
use std::sync::LazyLock;
use crate::{
api::{
@ -38,6 +39,7 @@ use crate::{
pub fn routes() -> Vec<Route> {
routes![
login,
get_webauthn_login_assertion_options,
prelogin,
identity_register,
register_verification_email,
@ -49,6 +51,17 @@ pub fn routes() -> Vec<Route> {
]
}
static WEBAUTHN_LOGIN_ASSERTION_ISSUER: LazyLock<String> =
LazyLock::new(|| format!("{}|webauthn_login_assertion", CONFIG.domain_origin()));
#[derive(Debug, Serialize, Deserialize)]
struct WebauthnLoginAssertionClaims {
nbf: i64,
exp: i64,
iss: String,
state: String,
}
#[post("/connect/token", data = "<data>")]
async fn login(
data: Form<ConnectData>,
@ -78,6 +91,19 @@ async fn login(
_password_login(data, &mut user_id, &conn, &client_header.ip, &client_version).await
}
"webauthn" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"),
"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.token, "token cannot be blank")?;
_check_is_some(&data.device_response, "device_response 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")?;
_webauthn_login(data, &mut user_id, &conn, &client_header.ip).await
}
"client_credentials" => {
_check_is_some(&data.client_id, "client_id cannot be blank")?;
_check_is_some(&data.client_secret, "client_secret cannot be blank")?;
@ -304,7 +330,7 @@ async fn _sso_login(
// We passed 2FA get auth tokens
let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, conn).await?;
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, None, conn, ip).await
}
async fn _password_login(
@ -426,7 +452,74 @@ async fn _password_login(
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id);
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, None, conn, ip).await
}
fn encode_webauthn_login_assertion_token(state: String) -> String {
let now = Utc::now();
let claims = WebauthnLoginAssertionClaims {
nbf: now.timestamp(),
exp: (now + chrono::TimeDelta::try_minutes(17).unwrap()).timestamp(),
iss: WEBAUTHN_LOGIN_ASSERTION_ISSUER.to_string(),
state,
};
auth::encode_jwt(&claims)
}
fn decode_webauthn_login_assertion_token(token: &str) -> ApiResult<WebauthnLoginAssertionClaims> {
auth::decode_jwt(token, WEBAUTHN_LOGIN_ASSERTION_ISSUER.to_string()).map_res("Invalid passkey login token")
}
async fn _webauthn_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
AuthMethod::Password.check_scope(data.scope.as_ref())?;
crate::ratelimit::check_limit_login(&ip.ip)?;
let assertion_token = data.token.as_ref().expect("No passkey assertion token provided");
let device_response = data.device_response.as_ref().expect("No passkey device response provided");
let assertion_claims = decode_webauthn_login_assertion_token(assertion_token)?;
let webauthn_login_result =
webauthn::validate_webauthn_discoverable_login(&assertion_claims.state, device_response, conn).await?;
let Some(user) = User::find_by_uuid(&webauthn_login_result.user_id, conn).await else {
err!("Invalid credential.")
};
// Set the user_id here to be passed back used for event logging.
*user_id = Some(user.uuid.clone());
if !user.enabled {
err!(
"This user has been disabled",
format!("IP: {}. Username: {}.", ip.ip, user.display_name()),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
err!(
"Please verify your email before trying again.",
format!("IP: {}. Username: {}.", ip.ip, user.display_name()),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
let mut device = get_device(&data, conn, &user).await?;
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id);
authenticated_response(
&user,
&mut device,
auth_tokens,
None,
webauthn_login_result.prf_decryption_option,
conn,
ip,
)
.await
}
async fn authenticated_response(
@ -434,6 +527,7 @@ async fn authenticated_response(
device: &mut Device,
auth_tokens: auth::AuthTokens,
twofactor_token: Option<String>,
webauthn_prf_option: Option<webauthn::WebauthnPrfDecryptionOption>,
conn: &DbConn,
ip: &ClientIp,
) -> JsonResult {
@ -472,10 +566,7 @@ async fn authenticated_response(
"Memory": user.client_kdf_memory,
"Parallelism": user.client_kdf_parallelism
},
// This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps.
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
"MasterKeyEncryptedUserKey": user.akey,
"MasterKeyWrappedUserKey": user.akey,
"Salt": user.email
})
} else {
@ -517,6 +608,15 @@ async fn authenticated_response(
},
});
if let Some(webauthn_prf_option) = webauthn_prf_option {
result["UserDecryptionOptions"]["WebAuthnPrfOption"] = json!({
"EncryptedPrivateKey": webauthn_prf_option.encrypted_private_key,
"EncryptedUserKey": webauthn_prf_option.encrypted_user_key,
"CredentialId": webauthn_prf_option.credential_id,
"Transports": webauthn_prf_option.transports,
});
}
if !user.akey.is_empty() {
result["Key"] = Value::String(user.akey.clone());
}
@ -623,10 +723,7 @@ async fn _user_api_key_login(
"Memory": user.client_kdf_memory,
"Parallelism": user.client_kdf_parallelism
},
// This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps.
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
"MasterKeyEncryptedUserKey": user.akey,
"MasterKeyWrappedUserKey": user.akey,
"Salt": user.email
})
} else {
@ -792,7 +889,7 @@ async fn twofactor_auth(
}
// Remove all twofactors from the user
TwoFactor::delete_all_by_user(&user.uuid, conn).await?;
TwoFactor::delete_all_2fa_by_user(&user.uuid, conn).await?;
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?;
log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await;
@ -927,6 +1024,22 @@ async fn _json_err_twofactor(
Ok(result)
}
#[get("/accounts/webauthn/assertion-options")]
fn get_webauthn_login_assertion_options() -> JsonResult {
if !CONFIG.domain_set() {
err!("`DOMAIN` environment variable is not set. Webauthn disabled")
}
let (options, serialized_state) = webauthn::generate_webauthn_discoverable_login()?;
let token = encode_webauthn_login_assertion_token(serialized_state);
Ok(Json(json!({
"options": options,
"token": token,
"object": "webAuthnLoginAssertionOptions"
})))
}
#[post("/accounts/prelogin", data = "<data>")]
async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
_prelogin(data, conn).await
@ -1012,7 +1125,7 @@ struct ConnectData {
#[field(name = uncased("refreshtoken"))]
refresh_token: Option<String>,
// Needed for grant_type = "password" | "client_credentials"
// Needed for grant_type = "password" | "client_credentials" | "webauthn"
#[field(name = uncased("client_id"))]
#[field(name = uncased("clientid"))]
client_id: Option<String>, // web, cli, desktop, browser, mobile
@ -1025,6 +1138,11 @@ struct ConnectData {
scope: Option<String>,
#[field(name = uncased("username"))]
username: Option<String>,
#[field(name = uncased("token"))]
token: Option<String>,
#[field(name = uncased("device_response"))]
#[field(name = uncased("deviceresponse"))]
device_response: Option<String>,
#[field(name = uncased("device_identifier"))]
#[field(name = uncased("deviceidentifier"))]

25
src/db/models/org_policy.rs

@ -252,6 +252,31 @@ impl OrgPolicy {
}}
}
/// Returns true if the user belongs to an accepted/confirmed org that has
/// an enabled policy with the given raw policy type ID.
pub async fn has_active_raw_policy_for_user(user_uuid: &UserId, policy_type: i32, conn: &DbConn) -> bool {
db_run! { conn: {
use diesel::dsl::count_star;
org_policies::table
.inner_join(
users_organizations::table.on(
users_organizations::org_uuid.eq(org_policies::org_uuid)
.and(users_organizations::user_uuid.eq(user_uuid)))
)
.filter(
users_organizations::status.eq(MembershipStatus::Accepted as i32)
.or(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
)
.filter(org_policies::atype.eq(policy_type))
.filter(org_policies::enabled.eq(true))
.select(count_star())
.first::<i64>(conn)
.map(|count| count > 0)
.unwrap_or(false)
}}
}
/// Returns true if the user belongs to an org that has enabled the specified policy type,
/// and the user is not an owner or admin of that org. This is only useful for checking
/// applicability of policy types that have these particular semantics.

23
src/db/models/two_factor.rs

@ -39,6 +39,7 @@ pub enum TwoFactorType {
EmailVerificationChallenge = 1002,
WebauthnRegisterChallenge = 1003,
WebauthnLoginChallenge = 1004,
WebauthnLoginCredential = 1005,
// Special type for Protected Actions verification via email
ProtectedActions = 2000,
@ -150,6 +151,18 @@ impl TwoFactor {
}}
}
pub async fn delete_all_2fa_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
db_run! { conn: {
diesel::delete(
twofactor::table
.filter(twofactor::user_uuid.eq(user_uuid))
.filter(twofactor::atype.lt(1000))
)
.execute(conn)
.map_res("Error deleting 2fa providers")
}}
}
pub async fn migrate_u2f_to_webauthn(conn: &DbConn) -> EmptyResult {
let u2f_factors = db_run! { conn: {
twofactor::table
@ -192,6 +205,7 @@ impl TwoFactor {
let new_reg = WebauthnRegistration {
id: reg.id,
api_id: None,
migrated: true,
name: reg.name.clone(),
credential: Credential {
@ -209,6 +223,10 @@ impl TwoFactor {
attestation_format: AttestationFormat::None,
}
.into(),
supports_prf: false,
encrypted_user_key: None,
encrypted_public_key: None,
encrypted_private_key: None,
};
webauthn_regs.push(new_reg);
@ -268,9 +286,14 @@ impl From<WebauthnRegistrationV3> for WebauthnRegistration {
fn from(value: WebauthnRegistrationV3) -> Self {
Self {
id: value.id,
api_id: None,
name: value.name,
migrated: value.migrated,
credential: Credential::from(value.credential).into(),
supports_prf: false,
encrypted_user_key: None,
encrypted_public_key: None,
encrypted_private_key: None,
}
}
}

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

@ -54,33 +54,35 @@ 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 */
/* Hide Log in with passkey on the login page when SSO-only mode is enabled */
{{#if (webver ">=2025.5.1")}}
{{#if sso_only}}
.vw-passkey-login {
@extend %vw-hide;
}
{{/if}}
{{else}}
{{#if sso_only}}
app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secondary"].\!tw-text-primary-600:nth-child(3) {
@extend %vw-hide;
}
{{/if}}
{{/if}}
/* Hide the or text followed by the two buttons hidden above */
/* Hide the or text only if passkey login is hidden (SSO-only mode) */
{{#if (webver ">=2025.5.1")}}
{{#if (or (not sso_enabled) sso_only)}}
{{#if sso_only}}
.vw-or-text {
@extend %vw-hide;
}
{{/if}}
{{else}}
{{#if sso_only}}
app-root ng-component > form > div:nth-child(1) > div:nth-child(3) > div:nth-child(2) {
@extend %vw-hide;
}
{{/if}}
{{/if}}
/* Hide the `Other` button on the login page */
{{#if (or (not sso_enabled) sso_only)}}

Loading…
Cancel
Save