From b596e026164ede3489f7b0e4ba33522c704e0b98 Mon Sep 17 00:00:00 2001
From: Akhil Babel <335565203+akh1lb@users.noreply.github.com>
Date: Tue, 29 Sep 2026 15:54:33 +0200
Subject: [PATCH] feat(auth): implement passkey login and vault unlock
---
.env.template | 4 +
README.md | 17 +
.../2026-09-28-000000_passkey_unlock/down.sql | 2 +
.../2026-09-28-000000_passkey_unlock/up.sql | 19 +
.../2026-09-28-000000_passkey_unlock/down.sql | 2 +
.../2026-09-28-000000_passkey_unlock/up.sql | 19 +
.../2026-09-28-000000_passkey_unlock/down.sql | 2 +
.../2026-09-28-000000_passkey_unlock/up.sql | 19 +
src/api/core/accounts.rs | 226 ++--
src/api/core/ciphers.rs | 15 +-
src/api/core/mod.rs | 16 +-
src/api/core/passkeys.rs | 766 +++++++++++
src/api/core/two_factor/mod.rs | 2 +
src/api/core/two_factor/webauthn.rs | 2 +-
src/api/identity.rs | 129 +-
src/api/web.rs | 3 +
src/auth.rs | 11 +-
src/config.rs | 2 +
src/db/mod.rs | 22 +
src/db/models/mod.rs | 3 +
src/db/models/two_factor.rs | 2 +
src/db/models/user.rs | 55 +-
src/db/models/web_authn_credential.rs | 448 +++++++
src/db/schema.rs | 24 +
.../templates/scss/vaultwarden.scss.hbs | 5 +-
tests/README.md | 33 +
tests/passkey_unlock.rs | 1127 +++++++++++++++++
27 files changed, 2853 insertions(+), 122 deletions(-)
create mode 100644 migrations/mysql/2026-09-28-000000_passkey_unlock/down.sql
create mode 100644 migrations/mysql/2026-09-28-000000_passkey_unlock/up.sql
create mode 100644 migrations/postgresql/2026-09-28-000000_passkey_unlock/down.sql
create mode 100644 migrations/postgresql/2026-09-28-000000_passkey_unlock/up.sql
create mode 100644 migrations/sqlite/2026-09-28-000000_passkey_unlock/down.sql
create mode 100644 migrations/sqlite/2026-09-28-000000_passkey_unlock/up.sql
create mode 100644 src/api/core/passkeys.rs
create mode 100644 src/db/models/web_authn_credential.rs
create mode 100644 tests/README.md
create mode 100644 tests/passkey_unlock.rs
diff --git a/.env.template b/.env.template
index 62231776..fab3c464 100644
--- a/.env.template
+++ b/.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
diff --git a/README.md b/README.md
index 0b24ba69..7dc80746 100644
--- a/README.md
+++ b/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.
diff --git a/migrations/mysql/2026-09-28-000000_passkey_unlock/down.sql b/migrations/mysql/2026-09-28-000000_passkey_unlock/down.sql
new file mode 100644
index 00000000..660ab474
--- /dev/null
+++ b/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;
diff --git a/migrations/mysql/2026-09-28-000000_passkey_unlock/up.sql b/migrations/mysql/2026-09-28-000000_passkey_unlock/up.sql
new file mode 100644
index 00000000..c07ebfba
--- /dev/null
+++ b/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
+);
diff --git a/migrations/postgresql/2026-09-28-000000_passkey_unlock/down.sql b/migrations/postgresql/2026-09-28-000000_passkey_unlock/down.sql
new file mode 100644
index 00000000..660ab474
--- /dev/null
+++ b/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;
diff --git a/migrations/postgresql/2026-09-28-000000_passkey_unlock/up.sql b/migrations/postgresql/2026-09-28-000000_passkey_unlock/up.sql
new file mode 100644
index 00000000..7184e772
--- /dev/null
+++ b/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
+);
diff --git a/migrations/sqlite/2026-09-28-000000_passkey_unlock/down.sql b/migrations/sqlite/2026-09-28-000000_passkey_unlock/down.sql
new file mode 100644
index 00000000..660ab474
--- /dev/null
+++ b/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;
diff --git a/migrations/sqlite/2026-09-28-000000_passkey_unlock/up.sql b/migrations/sqlite/2026-09-28-000000_passkey_unlock/up.sql
new file mode 100644
index 00000000..7184e772
--- /dev/null
+++ b/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
+);
diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs
index 8cc5e55b..b9b9ca9c 100644
--- a/src/api/core/accounts.rs
+++ b/src/api/core/accounts.rs
@@ -581,10 +581,7 @@ async fn post_keys(data: Json, 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,
emergency_access_unlock_data: Vec,
master_password_unlock_data: MasterPasswordUnlockData,
organization_account_recovery_unlock_data: Vec,
@@ -907,8 +906,7 @@ fn validate_keydata(
#[post("/accounts/key-management/rotate-user-account-keys", data = "")]
async fn post_rotatekey(data: Json, 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, 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)]
diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs
index a8c6aea0..357ef015 100644
--- a/src/api/core/ciphers.rs
+++ b/src/api/core/ciphers.rs
@@ -195,6 +195,16 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option Vec {
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 {
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 {
- // 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 {
let domain = CONFIG.domain();
diff --git a/src/api/core/passkeys.rs b/src/api/core/passkeys.rs
new file mode 100644
index 00000000..2a36c93e
--- /dev/null
+++ b/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 {
+ 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 = 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::().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>,
+}
+
+impl From 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 {
+ let Ok(saved) = serde_json::from_str::(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 {
+ let Ok(saved) = serde_json::from_str::(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,
+ encrypted_public_key: Option,
+ encrypted_private_key: Option,
+}
+
+fn passkey_registration_prf_data(
+ client_supports_prf: bool,
+ encrypted_user_key: Option,
+ encrypted_public_key: Option,
+ encrypted_private_key: Option,
+) -> ApiResult {
+ 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 = "")]
+async fn post_api_webauthn_attestation_options(
+ data: Json,
+ 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::(&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 = "")]
+async fn post_api_webauthn_assertion_options(
+ data: Json,
+ 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 = 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::(&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,
+ supports_prf: bool,
+ encrypted_user_key: Option,
+ encrypted_public_key: Option,
+ encrypted_private_key: Option,
+}
+
+#[post("/webauthn", data = "")]
+async fn post_api_webauthn(
+ data: Json,
+ headers: Headers,
+ conn: DbConn,
+ 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(¤t_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(),
+ ¤t_user.security_stamp,
+ &headers.device.uuid.to_string(),
+ &PasskeyAccount::from_user(¤t_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(¤t_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, ¤t_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,
+ encrypted_public_key: Option,
+ encrypted_private_key: Option,
+}
+
+#[put("/webauthn", data = "")]
+async fn put_api_webauthn(
+ data: Json,
+ headers: Headers,
+ conn: DbConn,
+ nt: Notify<'_>,
+) -> ApiResult {
+ 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,
+ ¤t_user.security_stamp,
+ &headers.device.uuid.to_string(),
+ &PasskeyAccount::from_user(¤t_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(¤t_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(¤t_user)?, &previous_credential, &conn).await?;
+
+ current_user.update_revision(&conn).await?;
+ nt.send_user_update(UpdateType::SyncVault, ¤t_user, headers.device.push_uuid.as_ref(), &conn).await;
+
+ Ok(Status::Ok)
+}
+
+#[post("/webauthn//delete", data = "")]
+async fn post_api_webauthn_delete(
+ data: Json,
+ uuid: String,
+ headers: Headers,
+ conn: DbConn,
+ nt: Notify<'_>,
+) -> ApiResult {
+ 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> {
+ 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::(&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,
+ conn: &DbConn,
+) -> ApiResult> {
+ 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::>();
+ 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));
+ }
+ }
+}
diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs
index 0eb6563e..71517df2 100644
--- a/src/api/core/two_factor/mod.rs
+++ b/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,
diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs
index 07b964e5..39a0dc0b 100644
--- a/src/api/core/two_factor/webauthn.rs
+++ b/src/api/core/two_factor/webauthn.rs
@@ -30,7 +30,7 @@ use crate::{
util::NumberOrString,
};
-static WEBAUTHN: LazyLock = LazyLock::new(|| {
+pub(crate) static WEBAUTHN: LazyLock = LazyLock::new(|| {
let domain = CONFIG.domain();
let domain_origin = CONFIG.domain_origin();
let rp_id = Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default();
diff --git a/src/api/identity.rs b/src/api/identity.rs
index 6808ddde..b4147589 100644
--- a/src/api/identity.rs
+++ b/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 {
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, 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, ¤t_credential, ¤t_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, ¤t_user).await?;
+ let tokens = auth::AuthTokens::new(&device, ¤t_user, AuthMethod::Webauthn, data.client_id);
+ let mut result = authenticated_response(¤t_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,
+ // Needed for grant_type="webauthn". Native clients send deviceResponse as JSON text.
+ #[field(name = uncased("token"))]
+ token: Option,
+ #[field(name = uncased("deviceResponse"))]
+ #[field(name = uncased("device_response"))]
+ device_response: Option,
+
// Needed for send access
send_id: Option,
password_hash_b64: Option,
diff --git a/src/api/web.rs b/src/api/web.rs
index d6d8d62c..1d62e7e7 100644
--- a/src/api/web.rs
+++ b/src/api/web.rs
@@ -100,6 +100,9 @@ fn vaultwarden_css() -> EtagCached> {
"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(),
});
diff --git a/src/auth.rs b/src/auth.rs
index 07373389..caa0855b 100644
--- a/src/auth.rs
+++ b/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"),
};
diff --git a/src/config.rs b/src/config.rs
index 9f0ae2e1..9e396220 100644
--- a/src/config.rs
+++ b/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.
diff --git a/src/db/mod.rs b/src/db/mod.rs
index 2eae3f3c..8a0a1eb5 100644
--- a/src/db/mod.rs
+++ b/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(&self, operation: impl Future