Browse Source

feat(auth): implement passkey login and vault unlock

pull/7784/head
Akhil Babel 3 weeks ago
parent
commit
b596e02616
Failed to extract signature
  1. 4
      .env.template
  2. 17
      README.md
  3. 2
      migrations/mysql/2026-09-28-000000_passkey_unlock/down.sql
  4. 19
      migrations/mysql/2026-09-28-000000_passkey_unlock/up.sql
  5. 2
      migrations/postgresql/2026-09-28-000000_passkey_unlock/down.sql
  6. 19
      migrations/postgresql/2026-09-28-000000_passkey_unlock/up.sql
  7. 2
      migrations/sqlite/2026-09-28-000000_passkey_unlock/down.sql
  8. 19
      migrations/sqlite/2026-09-28-000000_passkey_unlock/up.sql
  9. 226
      src/api/core/accounts.rs
  10. 15
      src/api/core/ciphers.rs
  11. 16
      src/api/core/mod.rs
  12. 766
      src/api/core/passkeys.rs
  13. 2
      src/api/core/two_factor/mod.rs
  14. 2
      src/api/core/two_factor/webauthn.rs
  15. 129
      src/api/identity.rs
  16. 3
      src/api/web.rs
  17. 11
      src/auth.rs
  18. 2
      src/config.rs
  19. 22
      src/db/mod.rs
  20. 3
      src/db/models/mod.rs
  21. 2
      src/db/models/two_factor.rs
  22. 55
      src/db/models/user.rs
  23. 448
      src/db/models/web_authn_credential.rs
  24. 24
      src/db/schema.rs
  25. 5
      src/static/templates/scss/vaultwarden.scss.hbs
  26. 33
      tests/README.md
  27. 1127
      tests/passkey_unlock.rs

4
.env.template

@ -522,6 +522,10 @@
## Prevent users from logging in directly without going through SSO
# SSO_ONLY=false
## Passkey sign-in and optional PRF vault unlock. Disabled until compatible clients are qualified.
## Independent passkey sign-in is unavailable when SSO_ONLY is enabled.
# PASSKEYS_ENABLED=false
## Allow SSO flow to create account. You probably want to disable it when using a public provider.
# SSO_SIGNUPS_ALLOWED=true

17
README.md

@ -35,6 +35,7 @@ A nearly complete implementation of the Bitwarden Client API is provided, includ
* [Attachments](https://bitwarden.com/help/attachments/)
* [Website icons](https://bitwarden.com/help/website-icons/)
* [Personal API Key](https://bitwarden.com/help/personal-api-key/)
* [Passkey login and vault unlock](#passkeys-experimental) (experimental, opt-in)
* [Organizations](https://bitwarden.com/help/getting-started-organizations/)
- [Collections](https://bitwarden.com/help/about-collections/),
[Password Sharing](https://bitwarden.com/help/sharing/),
@ -75,6 +76,22 @@ While Vaultwarden is based upon the [Rocket web framework](https://rocket.rs) wh
> [!TIP]
>**For more detailed examples on how to install, use and configure Vaultwarden you can check our [Wiki](https://github.com/dani-garcia/vaultwarden/wiki).**
### Passkeys (experimental)
Set `PASSKEYS_ENABLED=true` to enable passkey registration and sign-in with
compatible clients. A passkey can also unlock the vault when the client, browser
and authenticator support WebAuthn PRF and encryption is enabled during
enrollment. Without PRF, a passkey signs you in but the master password is still
needed to unlock the vault. Existing master-password unlock remains available.
With `SSO_ONLY=true`, users must still sign in through their identity provider;
an enrolled PRF-capable passkey can unlock the vault afterward. Passkeys do not
reset a forgotten master password. Keep a working fallback and regular backups
of your database and files before testing this experimental feature. Disabling
the flag rejects passkey sign-in and stops advertising passkey unlock without
deleting registrations; it cannot revoke data already cached by a client. An
older server cannot provide this new unlock method.
### Docker/Podman CLI
Pull the container image and mount a volume from the host for persistent storage.<br>

2
migrations/mysql/2026-09-28-000000_passkey_unlock/down.sql

@ -0,0 +1,2 @@
DROP TABLE web_authn_login_challenges;
DROP TABLE web_authn_credentials;

19
migrations/mysql/2026-09-28-000000_passkey_unlock/up.sql

@ -0,0 +1,19 @@
CREATE TABLE web_authn_credentials (
uuid CHAR(36) PRIMARY KEY NOT NULL,
user_uuid CHAR(36) NOT NULL,
name TEXT NOT NULL,
credential TEXT NOT NULL,
credential_id_hash VARCHAR(64) NOT NULL,
supports_prf BOOLEAN NOT NULL,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT,
FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE,
UNIQUE (credential_id_hash)
);
CREATE TABLE web_authn_login_challenges (
token_hash VARCHAR(64) PRIMARY KEY NOT NULL,
state TEXT NOT NULL,
created_at BIGINT NOT NULL
);

2
migrations/postgresql/2026-09-28-000000_passkey_unlock/down.sql

@ -0,0 +1,2 @@
DROP TABLE web_authn_login_challenges;
DROP TABLE web_authn_credentials;

19
migrations/postgresql/2026-09-28-000000_passkey_unlock/up.sql

@ -0,0 +1,19 @@
CREATE TABLE web_authn_credentials (
uuid TEXT PRIMARY KEY NOT NULL,
user_uuid TEXT NOT NULL,
name TEXT NOT NULL,
credential TEXT NOT NULL,
credential_id_hash TEXT NOT NULL,
supports_prf BOOLEAN NOT NULL,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT,
FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE,
UNIQUE (credential_id_hash)
);
CREATE TABLE web_authn_login_challenges (
token_hash TEXT PRIMARY KEY NOT NULL,
state TEXT NOT NULL,
created_at BIGINT NOT NULL
);

2
migrations/sqlite/2026-09-28-000000_passkey_unlock/down.sql

@ -0,0 +1,2 @@
DROP TABLE web_authn_login_challenges;
DROP TABLE web_authn_credentials;

19
migrations/sqlite/2026-09-28-000000_passkey_unlock/up.sql

@ -0,0 +1,19 @@
CREATE TABLE web_authn_credentials (
uuid TEXT PRIMARY KEY NOT NULL,
user_uuid TEXT NOT NULL,
name TEXT NOT NULL,
credential TEXT NOT NULL,
credential_id_hash TEXT NOT NULL,
supports_prf BOOLEAN NOT NULL,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT,
FOREIGN KEY (user_uuid) REFERENCES users(uuid) ON DELETE CASCADE,
UNIQUE (credential_id_hash)
);
CREATE TABLE web_authn_login_challenges (
token_hash TEXT PRIMARY KEY NOT NULL,
state TEXT NOT NULL,
created_at BIGINT NOT NULL
);

226
src/api/core/accounts.rs

@ -581,10 +581,7 @@ async fn post_keys(data: Json<KeysData>, headers: Headers, conn: DbConn) -> Json
let mut user = headers.user;
user.private_key = Some(data.encrypted_private_key);
user.public_key = Some(data.public_key);
user.save(&conn).await?;
user.replace_keypair_if_current(data.encrypted_private_key, data.public_key, &conn).await?;
Ok(Json(json!({
"privateKey": user.private_key,
@ -795,6 +792,8 @@ struct KeyData {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct RotateAccountUnlockData {
#[serde(default)]
passkey_unlock_data: Vec<super::passkeys::PasskeyUnlockData>,
emergency_access_unlock_data: Vec<UpdateEmergencyAccessData>,
master_password_unlock_data: MasterPasswordUnlockData,
organization_account_recovery_unlock_data: Vec<UpdateResetPasswordData>,
@ -907,8 +906,7 @@ fn validate_keydata(
#[post("/accounts/key-management/rotate-user-account-keys", data = "<data>")]
async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
// TODO: See if we can wrap everything within a SQL Transaction. If something fails it should revert everything.
let data: KeyData = data.into_inner();
let mut data: KeyData = data.into_inner();
if !headers.user.check_valid_password(&data.old_master_key_authentication_hash) {
err!("Invalid password")
@ -920,111 +918,143 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
// TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks.
Cipher::validate_cipher_data(&data.account_data.ciphers)?;
let user_id = &headers.user.uuid;
// TODO: Ideally we'd do everything after this point in a single transaction.
let mut existing_ciphers = Cipher::find_owned_by_user(user_id, &conn).await;
let mut existing_folders = Folder::find_by_user(user_id, &conn).await;
let mut existing_emergency_access = EmergencyAccess::find_all_confirmed_by_grantor_uuid(user_id, &conn).await;
let mut existing_memberships = Membership::find_by_user(user_id, &conn).await;
// 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;
validate_keydata(
&data,
&existing_ciphers,
&existing_folders,
&existing_emergency_access,
&existing_memberships,
&existing_sends,
&headers.user,
)?;
// Update folder data
for folder_data in data.account_data.folders {
// Skip `null` folder id entries.
// See: https://github.com/bitwarden/clients/issues/8453
if let Some(folder_id) = folder_data.id {
let Some(saved_folder) = existing_folders.iter_mut().find(|f| f.uuid == folder_id) else {
err!("Folder doesn't exist")
};
saved_folder.name = folder_data.name;
saved_folder.save(&conn).await?;
let atomic_passkeys = CONFIG.passkeys_enabled()
|| !crate::db::models::WebAuthnCredential::find_by_user(&headers.user.uuid, &conn).await?.is_empty();
let rotation = async {
if atomic_passkeys {
crate::db::models::PasskeyAccount::from_user(&headers.user)?.lock_for_rotation(&conn).await?;
}
let user_id = &headers.user.uuid;
let mut existing_ciphers = Cipher::find_owned_by_user(user_id, &conn).await;
let mut existing_folders = Folder::find_by_user(user_id, &conn).await;
let mut existing_emergency_access = EmergencyAccess::find_all_confirmed_by_grantor_uuid(user_id, &conn).await;
let mut existing_memberships = Membership::find_by_user(user_id, &conn).await;
// 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;
validate_keydata(
&data,
&existing_ciphers,
&existing_folders,
&existing_emergency_access,
&existing_memberships,
&existing_sends,
&headers.user,
)?;
// Validate native PRF rewraps before changing any existing vault data.
let passkey_rewraps = super::passkeys::prepare_rotation(
&headers.user,
std::mem::take(&mut data.account_unlock_data.passkey_unlock_data),
&conn,
)
.await?;
// Update folder data
for folder_data in data.account_data.folders {
// Skip `null` folder id entries.
// See: https://github.com/bitwarden/clients/issues/8453
if let Some(folder_id) = folder_data.id {
let Some(saved_folder) = existing_folders.iter_mut().find(|f| f.uuid == folder_id) else {
err!("Folder doesn't exist")
};
saved_folder.name = folder_data.name;
saved_folder.save(&conn).await?;
}
}
}
// Update emergency access data
for emergency_access_data in data.account_unlock_data.emergency_access_unlock_data {
let Some(saved_emergency_access) =
existing_emergency_access.iter_mut().find(|ea| ea.uuid == emergency_access_data.id)
else {
err!("Emergency access doesn't exist or is not owned by the user")
};
saved_emergency_access.key_encrypted = Some(emergency_access_data.key_encrypted);
saved_emergency_access.save(&conn).await?;
}
// Update reset password data
for reset_password_data in data.account_unlock_data.organization_account_recovery_unlock_data {
let Some(membership) =
existing_memberships.iter_mut().find(|m| m.org_uuid == reset_password_data.organization_id)
else {
err!("Reset password doesn't exist")
};
membership.reset_password_key = Some(reset_password_data.reset_password_key);
membership.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 {
err!("Send doesn't exist")
};
// Update emergency access data
for emergency_access_data in data.account_unlock_data.emergency_access_unlock_data {
let Some(saved_emergency_access) =
existing_emergency_access.iter_mut().find(|ea| ea.uuid == emergency_access_data.id)
else {
err!("Emergency access doesn't exist or is not owned by the user")
};
update_send_from_data(send, send_data, &headers, &conn, &nt, UpdateType::None).await?;
}
saved_emergency_access.key_encrypted = Some(emergency_access_data.key_encrypted);
saved_emergency_access.save(&conn).await?;
}
// Update cipher data
for cipher_data in data.account_data.ciphers {
if cipher_data.organization_id.is_none() {
let Some(saved_cipher) = existing_ciphers.iter_mut().find(|c| &c.uuid == cipher_data.id.as_ref().unwrap())
// Update reset password data
for reset_password_data in data.account_unlock_data.organization_account_recovery_unlock_data {
let Some(membership) =
existing_memberships.iter_mut().find(|m| m.org_uuid == reset_password_data.organization_id)
else {
err!("Cipher doesn't exist")
err!("Reset password doesn't exist")
};
// Prevent triggering cipher updates via WebSockets by settings UpdateType::None
// The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues.
// We force the users to logout after the user has been saved to try and prevent these issues.
update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
membership.reset_password_key = Some(reset_password_data.reset_password_key);
membership.save(&conn).await?;
}
}
// Update user data
let mut user = headers.user;
// 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 {
err!("Send doesn't exist")
};
user.private_key = Some(data.account_keys.user_key_encrypted_account_private_key);
user.set_password(
&data.account_unlock_data.master_password_unlock_data.master_key_authentication_hash,
Some(data.account_unlock_data.master_password_unlock_data.master_key_encrypted_user_key),
true,
None,
&conn,
)
.await?;
update_send_from_data(send, send_data, &headers, &conn, &nt, UpdateType::None).await?;
}
let save_result = user.save(&conn).await;
// Update cipher data
for cipher_data in data.account_data.ciphers {
if cipher_data.organization_id.is_none() {
let Some(saved_cipher) =
existing_ciphers.iter_mut().find(|c| &c.uuid == cipher_data.id.as_ref().unwrap())
else {
err!("Cipher doesn't exist")
};
// Prevent triggering cipher updates via WebSockets by settings UpdateType::None
// The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues.
// We force the users to logout after the user has been saved to try and prevent these issues.
update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None)
.await?;
}
}
// Prevent logging out the client where the user requested this endpoint from.
// If you do logout the user it will causes issues at the client side.
// Adding the device uuid will prevent this.
nt.send_logout(&user, Some(&headers.device), &conn).await;
// Update user data
let mut user = headers.user;
save_result
user.private_key = Some(data.account_keys.user_key_encrypted_account_private_key);
user.set_password(
&data.account_unlock_data.master_password_unlock_data.master_key_authentication_hash,
Some(data.account_unlock_data.master_password_unlock_data.master_key_encrypted_user_key),
true,
None,
&conn,
)
.await?;
let save_result = user.save(&conn).await;
let save_result = match save_result {
Ok(()) if !passkey_rewraps.is_empty() => {
// Old PRF rows are no longer exposed once the encrypted account private
// key changes. A failed atomic rewrap leaves master-password fallback.
match crate::db::models::PasskeyAccount::from_user(&user) {
Ok(account) => {
crate::db::models::WebAuthnCredential::rewrap_all(&account, passkey_rewraps, &conn).await
}
Err(error) => Err(error),
}
}
result => result,
};
save_result?;
Ok((user, headers.device))
};
let (user, device) = if atomic_passkeys {
conn.transaction(rotation).await?
} else {
rotation.await?
};
// Notify only after commit. Preserve the initiating client's native session behavior.
nt.send_logout(&user, Some(&device), &conn).await;
Ok(())
}
#[derive(Deserialize)]

15
src/api/core/ciphers.rs

@ -195,6 +195,16 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
Value::Null
};
let mut user_decryption = json!({"masterPasswordUnlock": master_password_unlock, "userKeyId": headers.user.key_id});
if super::passkeys::account_passkeys_allowed() {
let options = super::passkeys::sync_options(&headers.user, &conn).await?;
user_decryption["webAuthnPrfOptions"] = if options.is_empty() {
Value::Null
} else {
json!(options)
};
}
Ok(Json(json!({
"profile": user_json,
"folders": folders_json,
@ -204,10 +214,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,
"userKeyId": headers.user.key_id,
},
"userDecryption": user_decryption,
"object": "sync"
})))
}

16
src/api/core/mod.rs

@ -6,6 +6,7 @@ mod emergency_access;
mod events;
mod folders;
mod organizations;
pub mod passkeys;
mod public;
mod sends;
@ -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 passkeys::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();

766
src/api/core/passkeys.rs

@ -0,0 +1,766 @@
// Authenticated management adapted from Vaultwarden PR 7297 (AGPL-3.0),
// branch snapshot at 727fead3a06eabf477ae9abed2ff0328acba7a6f.
use chrono::Utc;
use rocket::{Route, http::Status, serde::json::Json, serde::json::Value};
use webauthn_rs::prelude::{
Base64UrlSafeData, Credential as WebauthnCredentialData, Passkey, PasskeyAuthentication, PasskeyRegistration,
};
use webauthn_rs_proto::{
AuthenticatorAttestationResponseRaw, AuthenticatorTransport, RegisterPublicKeyCredential,
RegistrationExtensionsClientOutputs, UserVerificationPolicy,
};
use crate::{
CONFIG,
api::{
ApiResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
core::two_factor::webauthn::{PublicKeyCredentialCopy, WEBAUTHN},
},
auth::Headers,
crypto,
db::{
DbConn,
models::{PasskeyAccount, TwoFactor, TwoFactorType, User, WebAuthnCredential},
},
error::Error,
util::get_uuid,
};
const WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS: i64 = 300;
const WEBAUTHN_PASSKEY_CHALLENGE_CLOCK_SKEW_SECONDS: i64 = 30;
const MAX_WEBAUTHN_CREDENTIALS: usize = 5;
pub fn routes() -> Vec<Route> {
routes![
get_api_webauthn,
post_api_webauthn,
put_api_webauthn,
post_api_webauthn_assertion_options,
post_api_webauthn_attestation_options,
post_api_webauthn_delete,
]
}
#[get("/webauthn")]
async fn get_api_webauthn(headers: Headers, conn: DbConn) -> JsonResult {
let user = headers.user;
let data: Vec<Value> = WebAuthnCredential::find_by_user(&user.uuid, &conn)
.await?
.into_iter()
.map(|credential| credential_response(&credential))
.collect();
Ok(Json(json!({
"object": "list",
"data": data,
"continuationToken": null
})))
}
fn credential_response(credential: &WebAuthnCredential) -> Value {
json!({"id":credential.uuid,"name":credential.name,"prfStatus":credential.prf_status(),
"encryptedUserKey":credential.encrypted_user_key,"encryptedPublicKey":credential.encrypted_public_key,
"object":"webauthnCredential"})
}
// EncString contract: bitwarden/server v2026.7.0 and v2026.9.1, AGPL source at
// src/Core/Utilities/EncryptedStringAttribute.cs and the WebAuthn request models.
// Retain the supported releases' legacy forms; validate IV/MAC dimensions too.
// Ciphertext stays opaque:
// structural validity cannot prove integrity or possession of the decryption key.
fn valid_wrapped_key(value: &str) -> bool {
if value.is_empty() || value.len() > 2000 {
return false;
}
let (scheme, body) = if let Some((header, body)) = value.split_once('.') {
let scheme = header.trim().parse::<u8>().ok().or_else(|| match header.trim() {
"AesCbc256_B64" => Some(0),
"AesCbc128_HmacSha256_B64" => Some(1),
"AesCbc256_HmacSha256_B64" => Some(2),
"Rsa2048_OaepSha256_B64" => Some(3),
"Rsa2048_OaepSha1_B64" => Some(4),
"Rsa2048_OaepSha256_HmacSha256_B64" => Some(5),
"Rsa2048_OaepSha1_HmacSha256_B64" => Some(6),
"XChaCha20Poly1305_B64" | "CoseEncrypt0B64" => Some(7),
_ => None,
});
let Some(scheme) = scheme else {
return false;
};
(scheme, body)
} else {
(u8::from(value.matches('|').count() == 2), value)
};
// Zero means variable-length ciphertext, not an empty allowed component.
let lengths: &[usize] = match scheme {
0 => &[16, 0],
1 | 2 => &[16, 0, 32],
3 | 4 | 7 => &[0],
5 | 6 => &[0, 0],
_ => return false,
};
let mut specification = data_encoding::BASE64.specification();
specification.check_trailing_bits = false;
" \t\r\n".clone_into(&mut specification.ignore);
let encoding = specification.encoding().expect("fixed base64 specification");
let mut pieces = body.split('|');
for length in lengths {
let Some(piece) = pieces.next() else {
return false;
};
let Ok(bytes) = encoding.decode(piece.as_bytes()) else {
return false;
};
if bytes.is_empty() || (*length != 0 && bytes.len() != *length) {
return false;
}
}
pieces.next().is_none()
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WebAuthnPasskeyRegistrationChallenge {
token: String,
created_at: i64,
user_security_stamp: String,
device_uuid: String,
account_key_binding: String,
state: PasskeyRegistration,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WebAuthnPasskeyAssertionChallenge {
token: String,
created_at: i64,
user_security_stamp: String,
device_uuid: String,
account_key_binding: String,
state: PasskeyAuthentication,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PasskeyRegisterPublicKeyCredentialCopy {
id: String,
raw_id: Base64UrlSafeData,
response: PasskeyAuthenticatorAttestationResponseRawCopy,
#[serde(default, alias = "clientExtensionResults")]
extensions: RegistrationExtensionsClientOutputs,
r#type: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PasskeyAuthenticatorAttestationResponseRawCopy {
#[serde(rename = "AttestationObject", alias = "attestationObject")]
attestation_object: Base64UrlSafeData,
#[serde(rename = "clientDataJson", alias = "clientDataJSON")]
client_data_json: Base64UrlSafeData,
transports: Option<Vec<AuthenticatorTransport>>,
}
impl From<PasskeyRegisterPublicKeyCredentialCopy> for RegisterPublicKeyCredential {
fn from(r: PasskeyRegisterPublicKeyCredentialCopy) -> Self {
Self {
id: r.id,
raw_id: r.raw_id,
response: AuthenticatorAttestationResponseRaw {
attestation_object: r.response.attestation_object,
client_data_json: r.response.client_data_json,
transports: r.response.transports,
},
type_: r.r#type,
extensions: r.extensions,
}
}
}
fn passkey_management_challenge_is_fresh(created_at: i64) -> bool {
passkey_management_challenge_is_fresh_at(created_at, Utc::now().timestamp())
}
fn passkey_management_challenge_is_fresh_at(created_at: i64, now: i64) -> bool {
created_at >= now.saturating_sub(WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS)
&& created_at <= now.saturating_add(WEBAUTHN_PASSKEY_CHALLENGE_CLOCK_SKEW_SECONDS)
}
fn passkey_registration_challenge_state(
data: &str,
token: Option<&str>,
user_security_stamp: &str,
device_uuid: &str,
account_key_binding: &str,
) -> ApiResult<PasskeyRegistration> {
let Ok(saved) = serde_json::from_str::<WebAuthnPasskeyRegistrationChallenge>(data) else {
err!("Invalid registration challenge. Please try again.")
};
if !token.is_some_and(|t| crypto::ct_eq(t, &saved.token)) {
err!("Invalid registration challenge. Please try again.")
}
if !passkey_management_challenge_is_fresh(saved.created_at) {
err!("Invalid registration challenge. Please try again.")
}
if !crypto::ct_eq(user_security_stamp, &saved.user_security_stamp) {
err!("Invalid registration challenge. Please try again.")
}
if !crypto::ct_eq(device_uuid, &saved.device_uuid)
|| !crypto::ct_eq(account_key_binding, &saved.account_key_binding)
{
err!("Account or session changed. Please try again.")
}
Ok(saved.state)
}
fn passkey_assertion_challenge_state(
data: &str,
token: &str,
user_security_stamp: &str,
device_uuid: &str,
account_key_binding: &str,
) -> ApiResult<PasskeyAuthentication> {
let Ok(saved) = serde_json::from_str::<WebAuthnPasskeyAssertionChallenge>(data) else {
err!("Invalid assertion challenge. Please try again.")
};
if !crypto::ct_eq(token, &saved.token) {
err!("Invalid assertion challenge. Please try again.")
}
if !passkey_management_challenge_is_fresh(saved.created_at) {
err!("Invalid assertion challenge. Please try again.")
}
if !crypto::ct_eq(user_security_stamp, &saved.user_security_stamp) {
err!("Invalid assertion challenge. Please try again.")
}
if !crypto::ct_eq(device_uuid, &saved.device_uuid)
|| !crypto::ct_eq(account_key_binding, &saved.account_key_binding)
{
err!("Account or session changed. Please try again.")
}
Ok(saved.state)
}
pub(crate) fn passkey_credential_id_hash(credential_id: &[u8]) -> String {
crypto::sha256_hex(credential_id)
}
fn passkey_count_limit_reached(count: usize) -> bool {
count >= MAX_WEBAUTHN_CREDENTIALS
}
pub(crate) fn account_passkeys_allowed() -> bool {
CONFIG.passkeys_enabled() && CONFIG.is_webauthn_2fa_supported()
}
pub(crate) fn passkey_counter(passkey: &Passkey) -> u32 {
let credential: WebauthnCredentialData = passkey.clone().into();
credential.counter
}
#[derive(PartialEq)]
struct PasskeyRegistrationPrfData {
supports_prf: bool,
encrypted_user_key: Option<String>,
encrypted_public_key: Option<String>,
encrypted_private_key: Option<String>,
}
fn passkey_registration_prf_data(
client_supports_prf: bool,
encrypted_user_key: Option<String>,
encrypted_public_key: Option<String>,
encrypted_private_key: Option<String>,
) -> ApiResult<PasskeyRegistrationPrfData> {
for key in [&encrypted_user_key, &encrypted_public_key, &encrypted_private_key].into_iter().flatten() {
if !valid_wrapped_key(key) {
err!("Invalid wrapped passkey key")
}
}
let supports_prf = client_supports_prf;
let has_key_material =
encrypted_user_key.is_some() || encrypted_public_key.is_some() || encrypted_private_key.is_some();
if !supports_prf {
if has_key_material {
err!("Passkey does not support PRF")
}
return Ok(PasskeyRegistrationPrfData {
supports_prf: false,
encrypted_user_key: None,
encrypted_public_key: None,
encrypted_private_key: None,
});
}
if !has_key_material {
return Ok(PasskeyRegistrationPrfData {
supports_prf: true,
encrypted_user_key: None,
encrypted_public_key: None,
encrypted_private_key: None,
});
}
let Some(encrypted_user_key) = encrypted_user_key else {
err!("Encrypted user key is required")
};
let Some(encrypted_public_key) = encrypted_public_key else {
err!("Encrypted public key is required")
};
let Some(encrypted_private_key) = encrypted_private_key else {
err!("Encrypted private key is required")
};
Ok(PasskeyRegistrationPrfData {
supports_prf: true,
encrypted_user_key: Some(encrypted_user_key),
encrypted_public_key: Some(encrypted_public_key),
encrypted_private_key: Some(encrypted_private_key),
})
}
fn check_passkey_endpoint_preconditions(ip: &std::net::IpAddr, _action_verb: &str) -> ApiResult<()> {
crate::ratelimit::check_limit_login(ip)?;
if !account_passkeys_allowed() {
err!("Passkeys are not enabled")
}
Ok(())
}
#[post("/webauthn/attestation-options", data = "<data>")]
async fn post_api_webauthn_attestation_options(
data: Json<PasswordOrOtpData>,
headers: Headers,
conn: DbConn,
) -> JsonResult {
check_passkey_endpoint_preconditions(&headers.ip.ip, "created")?;
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user;
data.validate(&user, true, &conn).await?;
let all_creds = WebAuthnCredential::find_by_user(&user.uuid, &conn).await?;
if passkey_count_limit_reached(all_creds.len()) {
err!("Maximum number of passkeys reached")
}
let existing_cred_ids: Vec<_> = all_creds
.into_iter()
.filter_map(|wac| {
if let Ok(passkey) = serde_json::from_str::<Passkey>(&wac.credential) {
Some(passkey.cred_id().to_owned())
} else {
warn!("Skipping an invalid stored passkey");
None
}
})
.collect();
let user_uuid = uuid::Uuid::parse_str(&user.uuid)
.map_err(|_| Error::new("Invalid user", "Could not parse user UUID for passkey registration"))?;
let (mut challenge, state) =
WEBAUTHN.start_passkey_registration(user_uuid, &user.email, user.display_name(), Some(existing_cred_ids))?;
if let Some(asc) = challenge.public_key.authenticator_selection.as_mut() {
asc.user_verification = UserVerificationPolicy::Required;
asc.require_resident_key = true;
asc.resident_key = Some(webauthn_rs_proto::ResidentKeyRequirement::Required);
}
let token = get_uuid();
let saved_challenge = WebAuthnPasskeyRegistrationChallenge {
token: token.clone(),
created_at: Utc::now().timestamp(),
user_security_stamp: user.security_stamp.clone(),
device_uuid: headers.device.uuid.to_string(),
account_key_binding: PasskeyAccount::from_user(&user)?.binding(),
state,
};
PasskeyAccount::from_user(&user)?
.replace_challenge(
TwoFactor::new(
user.uuid.clone(),
TwoFactorType::WebauthnPasskeyRegisterChallenge,
serde_json::to_string(&saved_challenge)?,
),
&conn,
)
.await?;
let mut options = serde_json::to_value(challenge.public_key)?;
options["status"] = "ok".into();
options["errorMessage"] = "".into();
Ok(Json(json!({
"options": options,
"token": token,
"object": "webauthnCredentialCreateOptions"
})))
}
#[post("/webauthn/assertion-options", data = "<data>")]
async fn post_api_webauthn_assertion_options(
data: Json<PasswordOrOtpData>,
headers: Headers,
conn: DbConn,
) -> JsonResult {
check_passkey_endpoint_preconditions(&headers.ip.ip, "updated")?;
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user;
data.validate(&user, true, &conn).await?;
let credentials: Vec<Passkey> = WebAuthnCredential::find_by_user(&user.uuid, &conn)
.await?
.into_iter()
.filter(|wac| wac.supports_prf)
.filter_map(|wac| {
if let Ok(passkey) = serde_json::from_str::<Passkey>(&wac.credential) {
Some(passkey)
} else {
warn!("Skipping an invalid stored passkey");
None
}
})
.collect();
if credentials.is_empty() {
err!("No PRF-capable passkeys registered")
}
let (response, state) = WEBAUTHN.start_passkey_authentication(&credentials)?;
let token = get_uuid();
let saved_challenge = WebAuthnPasskeyAssertionChallenge {
token: token.clone(),
created_at: Utc::now().timestamp(),
user_security_stamp: user.security_stamp.clone(),
device_uuid: headers.device.uuid.to_string(),
account_key_binding: PasskeyAccount::from_user(&user)?.binding(),
state,
};
PasskeyAccount::from_user(&user)?
.replace_challenge(
TwoFactor::new(
user.uuid.clone(),
TwoFactorType::WebauthnPasskeyAssertionChallenge,
serde_json::to_string(&saved_challenge)?,
),
&conn,
)
.await?;
Ok(Json(json!({
"options": response.public_key,
"token": token,
"object": "webAuthnLoginAssertionOptions"
})))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WebAuthnLoginCredentialCreateRequest {
device_response: PasskeyRegisterPublicKeyCredentialCopy,
name: String,
token: Option<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_api_webauthn(
data: Json<WebAuthnLoginCredentialCreateRequest>,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
check_passkey_endpoint_preconditions(&headers.ip.ip, "created")?;
let data: WebAuthnLoginCredentialCreateRequest = data.into_inner();
let user = headers.user;
let Some(mut current_user) = User::find_by_uuid(&user.uuid, &conn).await else {
err!("User not found")
};
if passkey_count_limit_reached(WebAuthnCredential::find_by_user(&current_user.uuid, &conn).await?.len()) {
err!("Maximum number of passkeys reached")
}
let type_ = TwoFactorType::WebauthnPasskeyRegisterChallenge as i32;
let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await else {
err!("No registration challenge found. Please try again.")
};
let state = passkey_registration_challenge_state(
&tf.data,
data.token.as_deref(),
&current_user.security_stamp,
&headers.device.uuid.to_string(),
&PasskeyAccount::from_user(&current_user)?.binding(),
)?;
if !WebAuthnCredential::consume_challenge(tf, &conn).await? {
err!("No registration challenge found. Please try again.")
}
let credential = WEBAUTHN
.finish_passkey_registration(&data.device_response.into(), &state)
.map_err(|_| Error::new_msg("Invalid passkey registration"))?;
let credential_id_hash = passkey_credential_id_hash(credential.cred_id().as_slice());
let PasskeyRegistrationPrfData {
supports_prf,
encrypted_user_key,
encrypted_public_key,
encrypted_private_key,
} = passkey_registration_prf_data(
data.supports_prf,
data.encrypted_user_key,
data.encrypted_public_key,
data.encrypted_private_key,
)?;
let account = PasskeyAccount::from_user(&current_user)?;
if data.name.is_empty() || data.name.len() > 256 {
err!("Invalid passkey name")
}
let credential = WebAuthnCredential {
uuid: WebAuthnCredential::id(),
user_uuid: current_user.uuid.clone(),
name: data.name,
credential: serde_json::to_string(&credential)?,
credential_id_hash,
supports_prf,
encrypted_user_key,
encrypted_public_key,
encrypted_private_key,
};
credential.insert(&account, &conn).await?;
current_user.update_revision(&conn).await?;
nt.send_user_update(UpdateType::SyncVault, &current_user, headers.device.push_uuid.as_ref(), &conn).await;
Ok(Json(credential_response(&credential)))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WebAuthnLoginCredentialUpdateRequest {
device_response: PublicKeyCredentialCopy,
token: String,
encrypted_user_key: Option<String>,
encrypted_public_key: Option<String>,
encrypted_private_key: Option<String>,
}
#[put("/webauthn", data = "<data>")]
async fn put_api_webauthn(
data: Json<WebAuthnLoginCredentialUpdateRequest>,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> ApiResult<Status> {
check_passkey_endpoint_preconditions(&headers.ip.ip, "updated")?;
let data: WebAuthnLoginCredentialUpdateRequest = data.into_inner();
let user = headers.user;
let Some(encrypted_user_key) = data.encrypted_user_key else {
err!("Encrypted user key is required")
};
let Some(encrypted_public_key) = data.encrypted_public_key else {
err!("Encrypted public key is required")
};
let Some(encrypted_private_key) = data.encrypted_private_key else {
err!("Encrypted private key is required")
};
let Some(mut current_user) = User::find_by_uuid(&user.uuid, &conn).await else {
err!("User not found")
};
let type_ = TwoFactorType::WebauthnPasskeyAssertionChallenge as i32;
let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await else {
err!("No assertion challenge found. Please try again.")
};
let state = passkey_assertion_challenge_state(
&tf.data,
&data.token,
&current_user.security_stamp,
&headers.device.uuid.to_string(),
&PasskeyAccount::from_user(&current_user)?.binding(),
)?;
if !WebAuthnCredential::consume_challenge(tf, &conn).await? {
err!("No assertion challenge found. Please try again.")
}
for key in [&encrypted_user_key, &encrypted_public_key, &encrypted_private_key] {
if !valid_wrapped_key(key) {
err!("Invalid wrapped passkey key")
}
}
let credential_response = data.device_response.into();
let authentication_result = WEBAUTHN
.finish_passkey_authentication(&credential_response, &state)
.map_err(|_| Error::new_msg("Invalid passkey assertion"))?;
let credential_id_hash = passkey_credential_id_hash(authentication_result.cred_id().as_slice());
let Some(mut matched_wac) =
WebAuthnCredential::find_by_user_and_credential_id_hash(&current_user.uuid, &credential_id_hash, &conn).await?
else {
err!("Verified credential is not registered")
};
if !matched_wac.supports_prf {
err!("Passkey does not support PRF")
}
let previous_credential = matched_wac.credential.clone();
let mut passkey: Passkey =
serde_json::from_str(&previous_credential).map_err(|_| Error::new_msg("Invalid stored passkey"))?;
let previous_counter = passkey_counter(&passkey);
let updated_credential = passkey.update_credential(&authentication_result) == Some(true);
let advanced_counter = updated_credential && authentication_result.counter() > previous_counter;
if advanced_counter {
matched_wac.credential = serde_json::to_string(&passkey)?;
}
matched_wac.encrypted_user_key = Some(encrypted_user_key);
matched_wac.encrypted_public_key = Some(encrypted_public_key);
matched_wac.encrypted_private_key = Some(encrypted_private_key);
matched_wac.update_prf(&PasskeyAccount::from_user(&current_user)?, &previous_credential, &conn).await?;
current_user.update_revision(&conn).await?;
nt.send_user_update(UpdateType::SyncVault, &current_user, headers.device.push_uuid.as_ref(), &conn).await;
Ok(Status::Ok)
}
#[post("/webauthn/<uuid>/delete", data = "<data>")]
async fn post_api_webauthn_delete(
data: Json<PasswordOrOtpData>,
uuid: String,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> ApiResult<Status> {
crate::ratelimit::check_limit_login(&headers.ip.ip)?;
let data: PasswordOrOtpData = data.into_inner();
let mut user = headers.user;
data.validate(&user, true, &conn).await?;
let deleted = WebAuthnCredential::delete_by_uuid_and_user(&uuid, &user.uuid, &conn).await?;
if deleted {
user.update_revision(&conn).await?;
nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await;
} else {
debug!("post_api_webauthn_delete: credential {uuid} was not registered for user {}", user.uuid);
}
Ok(Status::Ok)
}
pub async fn sync_options(user: &User, conn: &DbConn) -> ApiResult<Vec<Value>> {
let mut options = Vec::new();
if !account_passkeys_allowed() {
return Ok(options);
}
for row in WebAuthnCredential::find_by_user(&user.uuid, conn).await? {
if !row.has_prf_keyset() {
continue;
}
let Ok(passkey) = serde_json::from_str::<Passkey>(&row.credential) else {
warn!("Skipping an invalid stored passkey");
continue;
};
let credential: WebauthnCredentialData = passkey.into();
options.push(json!({"credentialId": credential.cred_id,
"transports": credential.transports.unwrap_or_default(), "encryptedUserKey": row.encrypted_user_key,
"encryptedPrivateKey": row.encrypted_private_key}));
}
Ok(options)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyUnlockData {
id: String,
encrypted_public_key: String,
encrypted_user_key: String,
}
pub async fn prepare_rotation(
user: &User,
data: Vec<PasskeyUnlockData>,
conn: &DbConn,
) -> ApiResult<Vec<(WebAuthnCredential, String, String)>> {
let mut existing = WebAuthnCredential::find_by_user(&user.uuid, conn)
.await?
.into_iter()
.filter(WebAuthnCredential::has_prf_keyset)
.map(|row| (row.uuid.clone(), row))
.collect::<std::collections::HashMap<_, _>>();
let mut prepared = Vec::new();
for item in data {
let Some(row) = existing.remove(&item.id) else {
err!("Unknown or duplicate passkey in key rotation")
};
for key in [&item.encrypted_public_key, &item.encrypted_user_key] {
if !valid_wrapped_key(key) {
err!("Invalid wrapped passkey key")
}
}
prepared.push((row, item.encrypted_user_key, item.encrypted_public_key));
}
if !existing.is_empty() {
err!("All enabled passkeys must be included in key rotation")
}
Ok(prepared)
}
#[cfg(test)]
mod tests {
use super::valid_wrapped_key;
#[test]
fn wrapped_key_wire_contract_preserves_supported_encstring_forms() {
let iv = data_encoding::BASE64.encode(&[0; 16]);
let ct = data_encoding::BASE64.encode(&[1; 32]);
let mac = data_encoding::BASE64.encode(&[2; 32]);
for value in [
format!("0.{iv}|{ct}"),
format!("1.{iv}|{ct}|{mac}"),
format!("2.{iv}|{ct}|{mac}"),
format!("3.{ct}"),
format!("4.{ct}"),
format!("5.{ct}|{mac}"),
format!("6.{ct}|{mac}"),
format!("7.{ct}"),
format!("{iv}|{ct}"),
format!("{iv}|{ct}|{mac}"),
format!("AesCbc256_HmacSha256_B64.{iv}|{ct}|{mac}"),
// Native clients accept non-zero unused base64 padding bits.
"0.AAECAwQFBgcICQoLDA0OD/==|lGD=".to_owned(),
] {
assert!(valid_wrapped_key(&value));
}
for value in [
String::new(),
"plaintext".to_owned(),
"9.AAAA".to_owned(),
"2.AA==|AA==|AA==".to_owned(),
format!("2.{iv}|{ct}|{mac}|{mac}"),
"4.!not-base64!".to_owned(),
format!("4.{}", "A".repeat(2000)),
] {
assert!(!valid_wrapped_key(&value));
}
}
}

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

@ -62,6 +62,8 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data
| TwoFactorType::U2fRegisterChallenge
| TwoFactorType::U2fLoginChallenge
| TwoFactorType::EmailVerificationChallenge
| TwoFactorType::WebauthnPasskeyRegisterChallenge
| TwoFactorType::WebauthnPasskeyAssertionChallenge
| TwoFactorType::WebauthnRegisterChallenge
| TwoFactorType::WebauthnLoginChallenge
| TwoFactorType::ProtectedActions => false,

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

@ -30,7 +30,7 @@ use crate::{
util::NumberOrString,
};
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
pub(crate) 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();

129
src/api/identity.rs

@ -8,6 +8,8 @@ use rocket::{
serde::json::Json,
};
use serde_json::Value;
use webauthn_rs::prelude::{Credential as WebauthnCredentialData, Passkey, PasskeyAuthentication};
use webauthn_rs_proto::{PublicKeyCredential, UserVerificationPolicy};
use crate::{
CONFIG,
@ -16,6 +18,7 @@ use crate::{
core::{
accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register},
log_user_event,
two_factor::webauthn::WEBAUTHN,
two_factor::{
authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn,
yubikey,
@ -32,7 +35,7 @@ use crate::{
models::{
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError,
OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete,
TwoFactorType, User, UserId,
TwoFactorType, User, UserId, WebAuthnCredential, WebAuthnLoginChallenge,
},
},
error::MapResult,
@ -52,7 +55,8 @@ pub fn routes() -> Vec<Route> {
prevalidate,
authorize,
oidcsignin,
oidcsignin_error
oidcsignin_error,
get_webauthn_login_assertion_options
]
}
@ -72,7 +76,18 @@ async fn login(
check_is_some(data.refresh_token.as_ref(), "refresh_token cannot be blank")?;
refresh_login(data, &conn, &client_header.ip).await
}
"password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"),
"webauthn" | "password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"),
"webauthn" if CONFIG.passkeys_enabled() && CONFIG.is_webauthn_2fa_supported() => {
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
check_is_some(data.token.as_ref(), "token cannot be blank")?;
check_is_some(data.device_response.as_ref(), "deviceResponse 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")?;
webauthn_login(data, &mut user_id, &conn, &client_header.ip).await
}
"webauthn" => err!("Passkey sign-in is not enabled"),
"password" => {
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
check_is_some(data.password.as_ref(), "password cannot be blank")?;
@ -507,6 +522,105 @@ async fn password_login(
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
}
// The discoverable request does not identify an account. Keep challenge state in the
// database and consume it atomically before WebAuthn verification.
#[get("/accounts/webauthn/assertion-options")]
async fn get_webauthn_login_assertion_options(conn: DbConn, ip: ClientIp) -> JsonResult {
if !CONFIG.passkeys_enabled() || !CONFIG.is_webauthn_2fa_supported() || (CONFIG.sso_enabled() && CONFIG.sso_only())
{
err!("Passkey sign-in is not enabled")
}
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let (mut response, state) = WEBAUTHN.start_passkey_authentication(&[])?;
response.public_key.allow_credentials.clear();
response.public_key.user_verification = UserVerificationPolicy::Required;
let token = util::get_uuid();
WebAuthnLoginChallenge::create(&token, serde_json::to_string(&state)?, Utc::now().timestamp(), &conn).await?;
Ok(Json(json!({
"options": response.public_key,
"token": token,
"object": "webAuthnLoginAssertionOptions"
})))
}
async fn webauthn_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
AuthMethod::Webauthn.check_scope(data.scope.as_ref())?;
crate::ratelimit::check_limit_login(&ip.ip)?;
let response: PublicKeyCredential = serde_json::from_str(data.device_response.as_deref().unwrap())
.map_err(|_| crate::error::Error::new_msg("Invalid passkey assertion"))?;
let token = data.token.as_deref().unwrap();
let Some(state_json) = WebAuthnLoginChallenge::consume(token, Utc::now().timestamp(), conn).await? else {
err!("Invalid or expired passkey challenge")
};
let hash = crate::api::core::passkeys::passkey_credential_id_hash(response.raw_id.as_slice());
let Some((mut credential, user)) = WebAuthnCredential::find_login_account(&hash, conn).await? else {
err!("Invalid passkey assertion")
};
*user_id = Some(user.uuid.clone());
if !user.enabled {
err!("This user has been disabled")
}
let Some(handle) = response.response.user_handle.as_ref() else {
err!("Invalid passkey user handle")
};
let expected_uuid =
uuid::Uuid::parse_str(&user.uuid).map_err(|_| crate::error::Error::new_msg("Invalid passkey user handle"))?;
if !crypto::ct_eq(handle.as_slice(), expected_uuid.as_bytes()) {
err!("Invalid passkey user handle")
}
let previous = credential.credential.clone();
let mut passkey: Passkey =
serde_json::from_str(&previous).map_err(|_| crate::error::Error::new_msg("Invalid stored passkey"))?;
if !crypto::ct_eq(passkey.cred_id().as_slice(), response.raw_id.as_slice()) {
err!("Invalid passkey credential")
}
// Discoverable assertion handling follows the state/credential approach in
// Vaultwarden PR 7370 (branch snapshot f7f2007), with database-backed one-use state here.
let mut state: Value = serde_json::from_str(&state_json)?;
let Some(credentials) = state.pointer_mut("/ast/credentials").and_then(Value::as_array_mut) else {
err!("Invalid passkey challenge state")
};
credentials.push(serde_json::to_value(WebauthnCredentialData::from(passkey.clone()))?);
let state: PasskeyAuthentication = serde_json::from_value(state)?;
let verified = WEBAUTHN
.finish_passkey_authentication(&response, &state)
.map_err(|_| crate::error::Error::new_msg("Invalid passkey assertion"))?;
if !crypto::ct_eq(verified.cred_id().as_slice(), passkey.cred_id().as_slice()) {
err!("Invalid passkey credential")
}
let previous_counter = crate::api::core::passkeys::passkey_counter(&passkey);
if passkey.update_credential(&verified) == Some(true) && verified.counter() > previous_counter {
credential.credential = serde_json::to_string(&passkey)?;
credential.update_authentication(&previous, conn).await?;
}
// Rotation or deletion may have committed during assertion verification.
// Build the token and PRF response from one current account/credential pair.
let Some((current_credential, current_user)) = WebAuthnCredential::find_login_account(&hash, conn).await? else {
err!("Passkey changed. Please try again.")
};
if !WebAuthnCredential::same_login_epoch(&credential, &user, &current_credential, &current_user) {
err!("Account or passkey changed. Please try again.")
}
if !current_user.enabled {
err!("This user has been disabled")
}
// User verification by the authenticator satisfies this grant's login factor.
// Other grants retain their existing two-step-login behavior and enrollment.
let mut device = get_device(&data, conn, &current_user).await?;
let tokens = auth::AuthTokens::new(&device, &current_user, AuthMethod::Webauthn, data.client_id);
let mut result = authenticated_response(&current_user, &mut device, tokens, None, conn, ip).await?;
if current_credential.has_prf_keyset() {
let value = &mut result.0["UserDecryptionOptions"]["WebAuthnPrfOption"];
*value = json!({
"CredentialId": passkey.cred_id(),
"Transports": WebauthnCredentialData::from(passkey).transports.unwrap_or_default(),
"EncryptedPrivateKey": current_credential.encrypted_private_key,
"EncryptedUserKey": current_credential.encrypted_user_key,
});
}
Ok(result)
}
async fn authenticated_response(
user: &User,
device: &mut Device,
@ -1047,6 +1161,8 @@ async fn json_err_twofactor(
| TwoFactorType::U2fRegisterChallenge
| TwoFactorType::Webauthn
| TwoFactorType::WebauthnLoginChallenge
| TwoFactorType::WebauthnPasskeyRegisterChallenge
| TwoFactorType::WebauthnPasskeyAssertionChallenge
| TwoFactorType::WebauthnRegisterChallenge,
) => { /* Nothing special to do for these providers */ }
}
@ -1205,6 +1321,13 @@ struct ConnectData {
#[field(name = uncased("code_verifier"))]
code_verifier: Option<OIDCCodeVerifier>,
// Needed for grant_type="webauthn". Native clients send deviceResponse as JSON text.
#[field(name = uncased("token"))]
token: Option<String>,
#[field(name = uncased("deviceResponse"))]
#[field(name = uncased("device_response"))]
device_response: Option<String>,
// Needed for send access
send_id: Option<SendId>,
password_hash_b64: Option<String>,

3
src/api/web.rs

@ -100,6 +100,9 @@ fn vaultwarden_css() -> EtagCached<Css<String>> {
"sso_enabled": CONFIG.sso_enabled(),
"sso_only": CONFIG.sso_enabled() && CONFIG.sso_only(),
"webauthn_2fa_supported": CONFIG.is_webauthn_2fa_supported(),
"passkeys_enabled": crate::api::core::passkeys::account_passkeys_allowed(),
"passkey_login_available": crate::api::core::passkeys::account_passkeys_allowed()
&& !(CONFIG.sso_enabled() && CONFIG.sso_only()),
"yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(),
});

11
src/auth.rs

@ -1206,6 +1206,7 @@ pub enum AuthMethod {
Password,
Sso,
UserApiKey,
Webauthn,
}
impl AuthMethod {
@ -1213,7 +1214,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(),
}
}
@ -1340,6 +1341,14 @@ pub async fn refresh_tokens(
AuthMethod::Sso => err!("SSO is now disabled, Login again using email and master password"),
AuthMethod::Password if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO is now required, Login again"),
AuthMethod::Password => AuthTokens::new(&device, &user, refresh_claims.sub, client_id),
AuthMethod::Webauthn
if CONFIG.passkeys_enabled()
&& CONFIG.is_webauthn_2fa_supported()
&& !(CONFIG.sso_enabled() && CONFIG.sso_only()) =>
{
AuthTokens::new(&device, &user, refresh_claims.sub, client_id)
}
AuthMethod::Webauthn => err!("Passkey sign-in is no longer available"),
_ => err!("Invalid auth method, cannot refresh token"),
};

2
src/config.rs

@ -582,6 +582,8 @@ make_config! {
domain_path: String, false, auto, |c| extract_url_path(&c.domain);
/// Enable web vault
web_vault_enabled: bool, false, def, true;
/// Passkey login and vault unlock |> Default-off support for passkey sign-in and optional PRF vault unlock.
passkeys_enabled: bool, false, def, false;
/// Allow Sends |> Controls whether users are allowed to create Bitwarden Sends.
/// This setting applies globally to all users. To control this on a per-org basis instead, use the "Disable Send" org policy.

22
src/db/mod.rs

@ -320,6 +320,28 @@ impl DbConnType {
}
impl DbConn {
/// Keep one request's asynchronous DB work in a Diesel-tracked transaction.
/// Cancellation leaves an open transaction: Diesel/r2d2 then discards the
/// pooled connection on DbConn drop rather than lending it to another request.
pub async fn transaction<T>(&self, operation: impl Future<Output = Result<T, Error>>) -> Result<T, Error> {
use diesel::connection::TransactionManager;
type Manager = <DbConnInner as Connection>::TransactionManager;
self.run(|conn| Manager::begin_transaction(conn).map_err(|_| Error::new_msg("Cannot start key rotation")))
.await?;
let result = operation.await;
let commit = result.is_ok();
self.run(move |conn| {
let finish = if commit {
Manager::commit_transaction(conn)
} else {
Manager::rollback_transaction(conn)
};
finish.map_err(|_| Error::new_msg("Cannot finish key rotation"))
})
.await?;
result
}
pub async fn run<F, R>(&self, f: F) -> R
where
F: FnOnce(&mut DbConnInner) -> R + Send,

3
src/db/models/mod.rs

@ -40,3 +40,6 @@ 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, KeyId, SsoUser, User, UserId, UserKdfType, UserStampException};
mod web_authn_credential;
pub use web_authn_credential::{PasskeyAccount, WebAuthnCredential, WebAuthnLoginChallenge};

2
src/db/models/two_factor.rs

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

55
src/db/models/user.rs

@ -12,7 +12,7 @@ use crate::{
models::DeviceId,
schema::{invitations, sso_users, twofactor_incomplete, users},
},
error::MapResult,
error::{Error, MapResult},
sso::OIDCIdentifier,
util::{format_date, get_uuid, retry},
};
@ -348,6 +348,58 @@ impl User {
}
}
/// Update only the asymmetric pair, without replaying unrelated fields from
/// an earlier request snapshot over a completed key rotation.
pub async fn replace_keypair_if_current(
&mut self,
private_key: String,
public_key: String,
conn: &DbConn,
) -> EmptyResult {
let expected = (
self.uuid.clone(),
self.security_stamp.clone(),
self.akey.clone(),
self.private_key.clone(),
self.public_key.clone(),
);
let replacement = (private_key.clone(), public_key.clone());
let updated_at = Utc::now().naive_utc();
conn.run(move |conn| {
conn.transaction::<_, Error, _>(|conn| {
let failed = || Error::new_msg("Unable to update account keys");
diesel::update(users::table.filter(users::uuid.eq(&expected.0)))
.set(users::uuid.eq(&expected.0))
.execute(conn)
.map_err(|_| failed())?;
let current =
users::table.filter(users::uuid.eq(&expected.0)).first::<Self>(conn).map_err(|_| failed())?;
if !current.enabled
|| current.security_stamp != expected.1
|| current.akey != expected.2
|| current.private_key != expected.3
|| current.public_key != expected.4
{
return Err(Error::new_msg("Account keys changed. Sync and try again."));
}
diesel::update(users::table.filter(users::uuid.eq(&expected.0)))
.set((
users::private_key.eq(&replacement.0),
users::public_key.eq(&replacement.1),
users::updated_at.eq(updated_at),
))
.execute(conn)
.map_err(|_| failed())?;
Ok(())
})
})
.await?;
self.private_key = Some(private_key);
self.public_key = Some(public_key);
self.updated_at = updated_at;
Ok(())
}
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
for member in Membership::find_confirmed_by_user(&self.uuid, conn).await {
if member.atype == MembershipType::Owner
@ -357,6 +409,7 @@ impl User {
}
}
super::WebAuthnCredential::delete_all_by_user(&self.uuid, conn).await?;
super::Send::delete_all_by_user(&self.uuid, conn).await?;
EmergencyAccess::delete_all_by_user(&self.uuid, conn).await?;
EmergencyAccess::delete_all_by_grantee_email(&self.email, conn).await?;

448
src/db/models/web_authn_credential.rs

@ -0,0 +1,448 @@
// Native credential/keyset contract informed by Vaultwarden PR 7297 (AGPL-3.0),
// branch snapshot at 727fead3a06eabf477ae9abed2ff0328acba7a6f.
// Transactional lifecycle guards keep vault key rotation and passkey keys consistent.
use super::{TwoFactor, User, UserId};
use crate::{
api::{ApiResult, EmptyResult},
crypto,
db::{
DbConn, DbConnInner,
schema::{twofactor, users, web_authn_credentials, web_authn_login_challenges},
},
error::Error,
util::get_uuid,
};
use diesel::prelude::*;
// Opaque encrypted fields and challenge data intentionally have no Debug implementation.
#[derive(Clone, Queryable, Insertable)]
#[diesel(table_name = web_authn_credentials)]
pub struct WebAuthnCredential {
pub uuid: String,
pub user_uuid: UserId,
pub name: String,
pub credential: String,
pub credential_id_hash: String,
pub supports_prf: bool,
pub encrypted_user_key: Option<String>,
pub encrypted_public_key: Option<String>,
pub encrypted_private_key: Option<String>,
}
#[derive(Clone)]
pub struct PasskeyAccount {
pub uuid: UserId,
stamp: String,
private_key: String,
}
impl PasskeyAccount {
pub fn from_user(user: &User) -> ApiResult<Self> {
let private_key = user
.private_key
.as_ref()
.filter(|v| !v.is_empty())
.ok_or_else(|| Error::new_msg("Account keys are not initialized"))?;
Ok(Self {
uuid: user.uuid.clone(),
stamp: user.security_stamp.clone(),
private_key: private_key.clone(),
})
}
pub fn binding(&self) -> String {
crypto::sha256_hex(self.private_key.as_bytes())
}
// No-op write serializes per-account credential changes on every backend.
// Read the condition back instead of relying on MySQL's changed-row count.
fn lock(&self, conn: &mut DbConnInner) -> EmptyResult {
diesel::update(users::table.filter(users::uuid.eq(&self.uuid)))
.set(users::uuid.eq(&self.uuid))
.execute(conn)
.map_err(|_| unavailable())?;
let count = users::table
.filter(users::uuid.eq(&self.uuid))
.filter(users::enabled.eq(true))
.filter(users::security_stamp.eq(&self.stamp))
.filter(users::private_key.eq(&self.private_key))
.count()
.get_result::<i64>(conn)
.map_err(|_| unavailable())?;
if count != 1 {
return Err(Error::new_msg("Account changed. Sync and try again."));
}
Ok(())
}
pub async fn lock_for_rotation(&self, conn: &DbConn) -> EmptyResult {
let account = self.clone();
conn.run(move |conn| account.lock(conn)).await
}
pub async fn replace_challenge(&self, row: TwoFactor, conn: &DbConn) -> EmptyResult {
let account = self.clone();
conn.run(move |conn| {
conn.transaction::<_, Error, _>(|conn| {
account.lock(conn)?;
diesel::delete(
twofactor::table
.filter(twofactor::user_uuid.eq(&account.uuid))
.filter(twofactor::atype.eq(row.atype)),
)
.execute(conn)
.map_err(|_| unavailable())?;
diesel::insert_into(twofactor::table).values(row).execute(conn).map_err(|_| unavailable())?;
Ok(())
})
})
.await
}
}
fn unavailable() -> Error {
Error::new_msg("Passkey storage unavailable")
}
fn concurrent() -> Error {
Error::new_msg("Passkey changed. Sync and try again.")
}
impl WebAuthnCredential {
pub fn same_login_epoch(expected_credential: &Self, expected_user: &User, credential: &Self, user: &User) -> bool {
expected_credential.uuid == credential.uuid
&& expected_credential.user_uuid == credential.user_uuid
&& expected_credential.credential == credential.credential
&& expected_credential.supports_prf == credential.supports_prf
&& expected_credential.encrypted_user_key == credential.encrypted_user_key
&& expected_credential.encrypted_public_key == credential.encrypted_public_key
&& expected_credential.encrypted_private_key == credential.encrypted_private_key
&& expected_user.uuid == user.uuid
&& expected_user.enabled == user.enabled
&& expected_user.security_stamp == user.security_stamp
&& expected_user.email == user.email
&& expected_user.akey == user.akey
&& expected_user.private_key == user.private_key
&& expected_user.public_key == user.public_key
&& expected_user.password_hash == user.password_hash
&& expected_user.client_kdf_type == user.client_kdf_type
&& expected_user.client_kdf_iter == user.client_kdf_iter
&& expected_user.client_kdf_memory == user.client_kdf_memory
&& expected_user.client_kdf_parallelism == user.client_kdf_parallelism
}
pub fn has_prf_keyset(&self) -> bool {
self.supports_prf
&& [&self.encrypted_user_key, &self.encrypted_public_key, &self.encrypted_private_key]
.iter()
.all(|v| v.as_ref().is_some_and(|v| !v.is_empty()))
}
pub fn prf_status(&self) -> i32 {
if self.supports_prf {
i32::from(!self.has_prf_keyset())
} else {
2
}
}
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> ApiResult<Vec<Self>> {
conn.run(move |conn| {
web_authn_credentials::table
.filter(web_authn_credentials::user_uuid.eq(user_uuid))
.order(web_authn_credentials::uuid.asc())
.load(conn)
.map_err(|_| unavailable())
})
.await
}
// A single statement gives the credential wrappers and account keys one
// database snapshot, including while key rotation is committing.
fn load_login_account(hash: &str, conn: &mut DbConnInner) -> ApiResult<Option<(Self, User)>> {
web_authn_credentials::table
.inner_join(users::table.on(users::uuid.eq(web_authn_credentials::user_uuid)))
.filter(web_authn_credentials::credential_id_hash.eq(hash))
.select((web_authn_credentials::all_columns, users::all_columns))
.first(conn)
.optional()
.map_err(|_| unavailable())
}
pub async fn find_login_account(hash: &str, conn: &DbConn) -> ApiResult<Option<(Self, User)>> {
conn.run(move |conn| Self::load_login_account(hash, conn)).await
}
pub async fn update_authentication(&self, previous_credential: &str, conn: &DbConn) -> EmptyResult {
let row = self.clone();
let previous = previous_credential.to_owned();
conn.run(move |conn| {
let changed = diesel::update(
web_authn_credentials::table
.filter(web_authn_credentials::uuid.eq(&row.uuid))
.filter(web_authn_credentials::user_uuid.eq(&row.user_uuid))
.filter(web_authn_credentials::credential.eq(previous)),
)
.set(web_authn_credentials::credential.eq(row.credential))
.execute(conn)
.map_err(|_| unavailable())?;
if changed != 1 {
return Err(concurrent());
}
Ok(())
})
.await
}
pub async fn find_by_user_and_credential_id_hash(
user_uuid: &UserId,
hash: &str,
conn: &DbConn,
) -> ApiResult<Option<Self>> {
conn.run(move |conn| {
web_authn_credentials::table
.filter(web_authn_credentials::user_uuid.eq(user_uuid))
.filter(web_authn_credentials::credential_id_hash.eq(hash))
.first(conn)
.optional()
.map_err(|_| unavailable())
})
.await
}
pub async fn insert(&self, account: &PasskeyAccount, conn: &DbConn) -> EmptyResult {
let row = self.clone();
let account = account.clone();
conn.run(move |conn| {
conn.transaction::<_, Error, _>(|conn| {
account.lock(conn)?;
let count = web_authn_credentials::table
.filter(web_authn_credentials::user_uuid.eq(&account.uuid))
.count()
.get_result::<i64>(conn)
.map_err(|_| unavailable())?;
if count >= 5 {
return Err(Error::new_msg("Maximum number of passkeys reached"));
}
diesel::insert_into(web_authn_credentials::table).values(row).execute(conn).map_err(
|error| match error {
diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation, _) => {
Error::new_msg("Passkey is already registered")
}
_ => unavailable(),
},
)?;
Ok(())
})
})
.await
}
pub async fn update_prf(&self, account: &PasskeyAccount, previous_credential: &str, conn: &DbConn) -> EmptyResult {
let row = self.clone();
let account = account.clone();
let previous = previous_credential.to_owned();
conn.run(move |conn| {
conn.transaction::<_, Error, _>(|conn| {
account.lock(conn)?;
let target = || {
web_authn_credentials::table
.filter(web_authn_credentials::uuid.eq(&row.uuid))
.filter(web_authn_credentials::user_uuid.eq(&account.uuid))
.filter(web_authn_credentials::credential.eq(&previous))
};
if target().count().get_result::<i64>(conn).map_err(|_| unavailable())? != 1 {
return Err(concurrent());
}
diesel::update(target())
.set((
web_authn_credentials::credential.eq(row.credential),
web_authn_credentials::encrypted_user_key.eq(row.encrypted_user_key),
web_authn_credentials::encrypted_public_key.eq(row.encrypted_public_key),
web_authn_credentials::encrypted_private_key.eq(row.encrypted_private_key),
))
.execute(conn)
.map_err(|_| unavailable())?;
Ok(())
})
})
.await
}
pub async fn rewrap_all(account: &PasskeyAccount, rows: Vec<(Self, String, String)>, conn: &DbConn) -> EmptyResult {
if rows.is_empty() {
return Ok(());
}
let account = account.clone();
conn.run(move |conn| {
conn.transaction::<_, Error, _>(|conn| {
account.lock(conn)?;
for (old, encrypted_user_key, encrypted_public_key) in rows {
let target = || {
web_authn_credentials::table
.filter(web_authn_credentials::uuid.eq(&old.uuid))
.filter(web_authn_credentials::user_uuid.eq(&account.uuid))
.filter(web_authn_credentials::credential.eq(&old.credential))
.filter(web_authn_credentials::encrypted_private_key.eq(&old.encrypted_private_key))
};
if target().count().get_result::<i64>(conn).map_err(|_| unavailable())? != 1 {
return Err(concurrent());
}
diesel::update(target())
.set((
web_authn_credentials::encrypted_user_key.eq(encrypted_user_key),
web_authn_credentials::encrypted_public_key.eq(encrypted_public_key),
))
.execute(conn)
.map_err(|_| unavailable())?;
}
Ok(())
})
})
.await
}
pub async fn delete_by_uuid_and_user(uuid: &str, user_uuid: &UserId, conn: &DbConn) -> ApiResult<bool> {
conn.run(move |conn| {
conn.transaction::<_, Error, _>(|conn| {
diesel::update(users::table.filter(users::uuid.eq(user_uuid)))
.set(users::uuid.eq(user_uuid))
.execute(conn)
.map_err(|_| unavailable())?;
diesel::delete(
web_authn_credentials::table
.filter(web_authn_credentials::uuid.eq(uuid))
.filter(web_authn_credentials::user_uuid.eq(user_uuid)),
)
.execute(conn)
.map(|n| n == 1)
.map_err(|_| unavailable())
})
})
.await
}
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
conn.transaction::<_, Error, _>(|conn| {
diesel::update(users::table.filter(users::uuid.eq(user_uuid)))
.set(users::uuid.eq(user_uuid))
.execute(conn)
.map_err(|_| unavailable())?;
diesel::delete(web_authn_credentials::table.filter(web_authn_credentials::user_uuid.eq(user_uuid)))
.execute(conn)
.map(|_| ())
.map_err(|_| unavailable())
})
})
.await
}
pub async fn consume_challenge(row: TwoFactor, conn: &DbConn) -> ApiResult<bool> {
conn.run(move |conn| {
diesel::delete(
twofactor::table
.filter(twofactor::uuid.eq(row.uuid))
.filter(twofactor::user_uuid.eq(row.user_uuid))
.filter(twofactor::data.eq(row.data)),
)
.execute(conn)
.map(|n| n == 1)
.map_err(|_| unavailable())
})
.await
}
pub fn id() -> String {
get_uuid()
}
}
// Anonymous WebAuthn state is stored server-side so every assertion consumes one challenge.
// This is required for synced authenticators whose signature counter stays at zero.
pub struct WebAuthnLoginChallenge;
impl WebAuthnLoginChallenge {
pub async fn create(token: &str, state: String, created_at: i64, conn: &DbConn) -> EmptyResult {
let token_hash = crypto::sha256_hex(token.as_bytes());
conn.run(move |conn| {
conn.transaction::<_, Error, _>(|conn| {
diesel::delete(
web_authn_login_challenges::table
.filter(web_authn_login_challenges::created_at.lt(created_at.saturating_sub(300))),
)
.execute(conn)
.map_err(|_| unavailable())?;
diesel::insert_into(web_authn_login_challenges::table)
.values((
web_authn_login_challenges::token_hash.eq(token_hash),
web_authn_login_challenges::state.eq(state),
web_authn_login_challenges::created_at.eq(created_at),
))
.execute(conn)
.map(|_| ())
.map_err(|_| unavailable())
})
})
.await
}
pub async fn consume(token: &str, now: i64, conn: &DbConn) -> ApiResult<Option<String>> {
let token_hash = crypto::sha256_hex(token.as_bytes());
conn.run(move |conn| {
conn.transaction::<_, Error, _>(|conn| {
let row: Option<(String, i64)> = web_authn_login_challenges::table
.filter(web_authn_login_challenges::token_hash.eq(&token_hash))
.select((web_authn_login_challenges::state, web_authn_login_challenges::created_at))
.first(conn)
.optional()
.map_err(|_| unavailable())?;
let deleted = diesel::delete(
web_authn_login_challenges::table.filter(web_authn_login_challenges::token_hash.eq(token_hash)),
)
.execute(conn)
.map_err(|_| unavailable())?;
match row {
Some((state, created)) if deleted == 1 && created <= now + 30 && created >= now - 300 => {
Ok(Some(state))
}
_ => Ok(None),
}
})
})
.await
}
}
#[cfg(all(test, sqlite))]
mod login_snapshot_tests {
use super::*;
use diesel::{Connection, connection::SimpleConnection, sqlite::SqliteConnection};
use diesel_migrations::MigrationHarness;
#[test]
fn login_snapshot_never_mixes_key_rotation_epochs_and_observes_deletion() {
let path = std::env::temp_dir().join(format!("vw-passkey-login-snapshot-{}.sqlite3", get_uuid()));
let db_url = path.to_str().unwrap();
let mut migration_conn = SqliteConnection::establish(db_url).unwrap();
migration_conn.run_pending_migrations(crate::db::sqlite_migrations::MIGRATIONS).unwrap();
migration_conn.batch_execute("PRAGMA journal_mode=WAL;").unwrap();
drop(migration_conn);
let mut reader = DbConnInner::Sqlite(SqliteConnection::establish(db_url).unwrap());
let mut writer = DbConnInner::Sqlite(SqliteConnection::establish(db_url).unwrap());
let user = get_uuid();
reader.batch_execute(&format!(
"INSERT INTO users (uuid,enabled,created_at,updated_at,login_verify_count,email,name,password_hash,salt,password_iterations,akey,private_key,security_stamp,equivalent_domains,excluded_globals,client_kdf_type,client_kdf_iter) VALUES ('{user}',1,datetime('now'),datetime('now'),0,'user@example.test','Synthetic user',X'00',zeroblob(64),1000,'old-account-wrapper','old-private-wrapper','old-stamp','[]','[]',0,600000); INSERT INTO web_authn_credentials (uuid,user_uuid,name,credential,credential_id_hash,supports_prf,encrypted_user_key,encrypted_public_key,encrypted_private_key) VALUES ('{}','{user}','Synthetic passkey','old-credential','fingerprint',1,'old-user-wrapper','old-public-wrapper','old-passkey-private-wrapper');",
get_uuid()
)).unwrap();
let (before_credential, before_user) =
WebAuthnCredential::load_login_account("fingerprint", &mut reader).unwrap().unwrap();
assert_eq!(before_credential.encrypted_user_key.as_deref(), Some("old-user-wrapper"));
assert_eq!(before_user.akey, "old-account-wrapper");
writer.transaction::<_, diesel::result::Error, _>(|db| {
db.batch_execute("UPDATE users SET akey='new-account-wrapper', private_key='new-private-wrapper', security_stamp='new-stamp' WHERE email='user@example.test'; UPDATE web_authn_credentials SET encrypted_user_key='new-user-wrapper', encrypted_public_key='new-public-wrapper' WHERE credential_id_hash='fingerprint';")?;
// The writer has changed both rows but has not committed. A login
// statement sees one committed epoch, never one row from each.
let (credential, user) = WebAuthnCredential::load_login_account("fingerprint", &mut reader).unwrap().unwrap();
assert_eq!(credential.encrypted_user_key.as_deref(), Some("old-user-wrapper"));
assert_eq!(user.akey, "old-account-wrapper");
Ok(())
}).unwrap();
let (after_credential, after_user) =
WebAuthnCredential::load_login_account("fingerprint", &mut reader).unwrap().unwrap();
assert_eq!(after_credential.encrypted_user_key.as_deref(), Some("new-user-wrapper"));
assert_eq!(after_user.akey, "new-account-wrapper");
assert!(!WebAuthnCredential::same_login_epoch(
&before_credential,
&before_user,
&after_credential,
&after_user
));
writer.batch_execute("DELETE FROM web_authn_credentials WHERE credential_id_hash='fingerprint';").unwrap();
assert!(WebAuthnCredential::load_login_account("fingerprint", &mut reader).unwrap().is_none());
drop(reader);
drop(writer);
std::fs::remove_file(path).unwrap();
}
}

24
src/db/schema.rs

@ -409,4 +409,28 @@ allow_tables_to_appear_in_same_query!(
collections_groups,
event,
auth_requests,
web_authn_credentials,
web_authn_login_challenges,
);
table! {
web_authn_credentials (uuid) {
uuid -> Text,
user_uuid -> Text,
name -> Text,
credential -> Text,
credential_id_hash -> Text,
supports_prf -> Bool,
encrypted_user_key -> Nullable<Text>,
encrypted_public_key -> Nullable<Text>,
encrypted_private_key -> Nullable<Text>,
}
}
table! {
web_authn_login_challenges (token_hash) {
token_hash -> Text,
state -> Text,
created_at -> BigInt,
}
}

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

@ -54,11 +54,13 @@ app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secon
}
{{/if}}
{{#unless passkeys_enabled}}
/* 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 */
{{/unless}}
{{#unless passkey_login_available}}
{{#if (webver ">=2025.5.1")}}
.vw-passkey-login {
@extend %vw-hide;
@ -68,6 +70,7 @@ app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secon
@extend %vw-hide;
}
{{/if}}
{{/unless}}
/* Hide the or text followed by the two buttons hidden above */
{{#if (webver ">=2025.5.1")}}

33
tests/README.md

@ -0,0 +1,33 @@
# Passkey regression tests
`passkey_unlock.rs` launches the real Vaultwarden binary on loopback with a
temporary database, synthetic accounts, signed WebAuthn assertions and encrypted
key fixtures. No identity provider or authenticator hardware is contacted. The
tests do not establish hardware PRF support or stock-client decryption.
Run the portable HTTP cases with SQLite:
```sh
cargo test --locked --features sqlite --test passkey_unlock
```
The PostgreSQL tests require a disposable local PostgreSQL instance. They create
and drop their own `vw_passkey_*` databases. The fixture deliberately accepts
only loopback, the `postgres` maintenance database, and the synthetic credentials
shown below. The test role needs permission to create databases; never use a
production instance or credentials.
```sh
VW_PASSKEY_TEST_DATABASE_URL='postgresql://passkey_test:synthetic-passkey-test@127.0.0.1:15432/postgres' \
cargo test --locked --features sqlite,postgresql --test passkey_unlock \
-- --include-ignored --test-threads=1
```
The PostgreSQL-only concurrency and interruption cases are marked `ignore`
because they need that instance. A default test run does not cover them.
Before enabling passkeys for real vaults, separately test compatible clients and
authenticators: enrollment, fresh sign-in, PRF decryption of existing personal
and shared items, master-password fallback, multiple passkeys, removal, and
password/KDF/account-key changes. For SSO-only installations, verify SSO is still
required. Keep backups and a working fallback throughout these checks.

1127
tests/passkey_unlock.rs

File diff suppressed because it is too large
Loading…
Cancel
Save