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 1/2] 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>) -> Result { + use diesel::connection::TransactionManager; + type Manager = ::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(&self, f: F) -> R where F: FnOnce(&mut DbConnInner) -> R + Send, diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0e4073a5..591a52e9 100644 --- a/src/db/models/mod.rs +++ b/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}; diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index 5f57635e..396656cc 100644 --- a/src/db/models/two_factor.rs +++ b/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, diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 3412b142..79625553 100644 --- a/src/db/models/user.rs +++ b/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::(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?; diff --git a/src/db/models/web_authn_credential.rs b/src/db/models/web_authn_credential.rs new file mode 100644 index 00000000..1e422fa5 --- /dev/null +++ b/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, + pub encrypted_public_key: Option, + pub encrypted_private_key: Option, +} + +#[derive(Clone)] +pub struct PasskeyAccount { + pub uuid: UserId, + stamp: String, + private_key: String, +} +impl PasskeyAccount { + pub fn from_user(user: &User) -> ApiResult { + 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::(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> { + 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> { + 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> { + 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> { + 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::(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::(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::(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 { + 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 { + 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> { + 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(); + } +} diff --git a/src/db/schema.rs b/src/db/schema.rs index 98b1eda6..e2801a34 100644 --- a/src/db/schema.rs +++ b/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, + encrypted_public_key -> Nullable, + encrypted_private_key -> Nullable, + } +} + +table! { + web_authn_login_challenges (token_hash) { + token_hash -> Text, + state -> Text, + created_at -> BigInt, + } +} diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index 5bbe5db2..6c3b41db 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/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")}} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..246d24a0 --- /dev/null +++ b/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. diff --git a/tests/passkey_unlock.rs b/tests/passkey_unlock.rs new file mode 100644 index 00000000..2ba97c6c --- /dev/null +++ b/tests/passkey_unlock.rs @@ -0,0 +1,1127 @@ +#![cfg(all(unix, any(feature = "sqlite", feature = "sqlite_system")))] +// Synthetic HTTP/storage contracts. These do not prove PRF hardware or client decryption. +use diesel::{Connection, RunQueryDsl, connection::SimpleConnection}; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; +use openssl::{hash::MessageDigest, rsa::Rsa}; +use reqwest::{Client, Response, StatusCode}; +use serde_json::{Value, json}; +use std::{ + fs, + net::SocketAddr, + os::unix::fs::{DirBuilderExt, PermissionsExt}, + path::PathBuf, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; +const ORIGIN: &str = "http://localhost"; +const PASSWORD: &str = "synthetic-master-password-hash"; + +// Real native-format AES/HMAC/RSA envelopes over disposable synthetic key material. +// These exercise wire validation; hardware PRF and stock-client decryption remain separate. +struct WrappedKeys { + user_key: String, + public_key: String, + private_key: String, +} +struct KeyVectors { + initial: WrappedKeys, + updated: WrappedKeys, + rotated: WrappedKeys, + original_account_private_key: String, + repaired_public_key: String, + repaired_private_key: String, +} +fn keys() -> &'static KeyVectors { + static KEYS: std::sync::LazyLock = std::sync::LazyLock::new(|| { + use openssl::{ + encrypt::Encrypter, + pkey::{PKey, Private}, + rsa::Padding, + symm::{Cipher, encrypt}, + }; + fn aes(plain: &[u8], key: &[u8; 64]) -> String { + let mut iv = [0; 16]; + openssl::rand::rand_bytes(&mut iv).unwrap(); + let ciphertext = encrypt(Cipher::aes_256_cbc(), &key[..32], Some(&iv), plain).unwrap(); + let mac_key = PKey::hmac(&key[32..]).unwrap(); + let mut mac = openssl::sign::Signer::new(MessageDigest::sha256(), &mac_key).unwrap(); + mac.update(&iv).unwrap(); + mac.update(&ciphertext).unwrap(); + format!( + "2.{}|{}|{}", + data_encoding::BASE64.encode(&iv), + data_encoding::BASE64.encode(&ciphertext), + data_encoding::BASE64.encode(&mac.sign_to_vec().unwrap()) + ) + } + fn wrap(pair: &PKey, user_key: &[u8; 64]) -> WrappedKeys { + let mut rsa = Encrypter::new(pair).unwrap(); + rsa.set_rsa_padding(Padding::PKCS1_OAEP).unwrap(); + rsa.set_rsa_oaep_md(MessageDigest::sha1()).unwrap(); + rsa.set_rsa_mgf1_md(MessageDigest::sha1()).unwrap(); + let mut ciphertext = vec![0; rsa.encrypt_len(user_key).unwrap()]; + let len = rsa.encrypt(user_key, &mut ciphertext).unwrap(); + ciphertext.truncate(len); + WrappedKeys { + user_key: format!("4.{}", data_encoding::BASE64.encode(&ciphertext)), + public_key: aes(&pair.public_key_to_der().unwrap(), user_key), + private_key: aes(&pair.private_key_to_pkcs8().unwrap(), &[0x33; 64]), + } + } + let original = PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap(); + let replacement = PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap(); + let account_pair = PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap(); + let repaired_public_key = data_encoding::BASE64.encode(&account_pair.public_key_to_der().unwrap()); + let original_account_private_key = aes(&account_pair.private_key_to_pkcs8().unwrap(), &[0x11; 64]); + let repaired_private_key = aes(&account_pair.private_key_to_pkcs8().unwrap(), &[0x11; 64]); + let initial = wrap(&original, &[0x11; 64]); + let updated = wrap(&replacement, &[0x11; 64]); + let mut rotated = wrap(&original, &[0x22; 64]); + rotated.private_key.clone_from(&initial.private_key); + KeyVectors { + initial, + updated, + rotated, + original_account_private_key, + repaired_public_key, + repaired_private_key, + } + }); + &KEYS +} + +struct Fixture { + path: PathBuf, + process: Child, + url: String, + client: Client, + user: String, + device: String, + token: String, + postgres: Option<(String, String)>, +} +impl Fixture { + async fn new(enabled: bool) -> Self { + Self::with_policy(enabled, true).await + } + async fn with_policy(enabled: bool, sso_only: bool) -> Self { + static CRYPTO: std::sync::Once = std::sync::Once::new(); + CRYPTO.call_once(|| rustls::crypto::ring::default_provider().install_default().unwrap()); + let id = || uuid::Uuid::new_v4().to_string(); + let path = std::env::temp_dir().join(format!("vaultwarden-passkey-test-{}", id())); + fs::DirBuilder::new().mode(0o700).create(&path).unwrap(); + let key = Rsa::generate(2048).unwrap().private_key_to_pem().unwrap(); + fs::write(path.join("rsa.pem"), &key).unwrap(); + fs::set_permissions(path.join("rsa.pem"), fs::Permissions::from_mode(0o600)).unwrap(); + fs::write(path.join("config.json"), "{}").unwrap(); + fs::write(path.join("empty.env"), "").unwrap(); + fs::write(path.join("db.sqlite3"), "").unwrap(); + let log = fs::File::create(path.join("server.log")).unwrap(); + let postgres = local_postgres_database(); + let database_url = postgres + .as_ref() + .map_or_else(|| path.join("db.sqlite3").to_string_lossy().into_owned(), |(_, url)| url.clone()); + let mut command = Command::new(env!("CARGO_BIN_EXE_vaultwarden")); + command + .env_clear() + .current_dir(&path) + .env("PATH", std::env::var_os("PATH").unwrap_or_default()) + .env("DATA_FOLDER", &path) + .env("DATABASE_URL", database_url) + .env("CONFIG_FILE", path.join("config.json")) + .env("ENV_FILE", path.join("empty.env")) + .env("RSA_KEY_FILENAME", path.join("rsa")) + .env("DOMAIN", ORIGIN) + .env("ROCKET_ADDRESS", "127.0.0.1") + .env("ROCKET_PORT", "0") + .env("WEB_VAULT_ENABLED", "false") + .env("ENABLE_WEBSOCKET", "false") + .env("PUSH_ENABLED", "false") + .env("DISABLE_ICON_DOWNLOAD", "true") + .env("JOB_POLL_INTERVAL_MS", "0") + .env("LOGIN_RATELIMIT_MAX_BURST", "100") + .env("DATABASE_MAX_CONNS", "4") + .env("DATABASE_MIN_CONNS", "1") + .env("PASSKEYS_ENABLED", enabled.to_string()) + .env("SSO_ENABLED", sso_only.to_string()) + .env("SSO_ONLY", sso_only.to_string()) + .env("SSO_CLIENT_ID", "synthetic-client") + .env("SSO_CLIENT_SECRET", "synthetic-secret") + .env("SSO_AUTHORITY", "http://127.0.0.1:9") + .stdout(Stdio::from(log.try_clone().unwrap())) + .stderr(Stdio::from(log)); + for name in ["DYLD_LIBRARY_PATH", "LD_LIBRARY_PATH"] { + if let Some(value) = std::env::var_os(name) { + command.env(name, value); + } + } + let user = id(); + let device = id(); + let now = chrono::Utc::now().timestamp(); + let token = jsonwebtoken::encode( + &Header::new(Algorithm::RS256), + &json!({ + "nbf":now-30,"exp":now+3600,"iss":format!("{ORIGIN}|login"),"sub":user, + "premium":true,"name":"Synthetic user","email":"user@example.test","email_verified":true, + "sstamp":"synthetic-stamp","device":device,"devicetype":"14","client_id":"web", + "scope":["api","offline_access"],"amr":["Application"] + }), + &EncodingKey::from_rsa_pem(&key).unwrap(), + ) + .unwrap(); + let mut f = Self { + path, + process: command.spawn().unwrap(), + url: String::new(), + client: Client::builder().no_proxy().timeout(Duration::from_secs(15)).build().unwrap(), + user, + device, + token, + postgres, + }; + let until = Instant::now() + Duration::from_secs(20); + loop { + assert!( + f.process.try_wait().unwrap().is_none() && Instant::now() < until, + "fixture failed: {}\n{}", + f.path.display(), + fs::read_to_string(f.path.join("server.log")).unwrap_or_default() + ); + if f.url.is_empty() { + let log = fs::read_to_string(f.path.join("server.log")).unwrap(); + if let Some(address) = log.split_inclusive('\n').find_map(|line| { + let (_, a) = line.strip_suffix('\n')?.split_once("Rocket has launched from http://")?; + let a = a.trim().parse::().ok()?; + (a.ip().is_loopback() && a.port() != 0).then_some(a) + }) { + f.url = format!("http://{address}"); + } + } + if !f.url.is_empty() + && let Ok(r) = f.client.get(format!("{}/alive", f.url)).send().await + && r.status() == StatusCode::OK + { + break; + } + tokio::time::sleep(Duration::from_millis(40)).await; + } + let salt = [0u8; 64]; + let mut hash = [0u8; 32]; + openssl::pkcs5::pbkdf2_hmac(PASSWORD.as_bytes(), &salt, 1000, MessageDigest::sha256(), &mut hash).unwrap(); + let hash = data_encoding::HEXLOWER.encode(&hash); + // All interpolated values are generated UUIDs or fixed synthetic fixture values. + f.sql(&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 ('{}',1,datetime('now'),datetime('now'),0,'user@example.test','Synthetic user',X'{hash}',zeroblob(64),1000,'synthetic-key','synthetic-private-key','synthetic-stamp','[]','[]',0,600000); INSERT INTO devices (uuid,user_uuid,created_at,updated_at,name,atype,refresh_token) VALUES ('{}','{}',datetime('now'),datetime('now'),'Synthetic browser',14,'synthetic-refresh');", f.user,f.device,f.user)); + f + } + fn sql(&self, sql: &str) { + #[cfg(feature = "postgresql")] + if let Some((_, url)) = &self.postgres { + let mut c = diesel::pg::PgConnection::establish(url).unwrap(); + let sql = sql + .replace("datetime('now')", "CURRENT_TIMESTAMP") + .replace(",1,CURRENT_TIMESTAMP", ",true,CURRENT_TIMESTAMP") + .replace("zeroblob(64)", "decode(repeat('00',64),'hex')"); + let sql = regex::Regex::new("X'([0-9a-f]+)'").unwrap().replace_all(&sql, "decode('$1','hex')"); + let sql = sql + .replace("json_set(data,'$.createdAt',0)", "jsonb_set(data::jsonb,'{createdAt}','0')::text") + .replace( + "json_set(data,'$.deviceUuid','another-device')", + "jsonb_set(data::jsonb,'{deviceUuid}','\"another-device\"')::text", + ) + .replace( + "json_set(data,'$.accountKeyBinding','another-key')", + "jsonb_set(data::jsonb,'{accountKeyBinding}','\"another-key\"')::text", + ); + c.batch_execute(&sql).unwrap(); + return; + } + + let mut c = + diesel::sqlite::SqliteConnection::establish(self.path.join("db.sqlite3").to_str().unwrap()).unwrap(); + c.batch_execute("PRAGMA busy_timeout=5000;").unwrap(); + c.batch_execute(sql).unwrap(); + } + fn count(&self, table: &str) -> i64 { + #[derive(diesel::QueryableByName)] + struct Count { + #[diesel(sql_type=diesel::sql_types::BigInt)] + count: i64, + } + #[cfg(feature = "postgresql")] + if let Some((_, url)) = &self.postgres { + let mut c = diesel::pg::PgConnection::establish(url).unwrap(); + return diesel::sql_query(format!("SELECT count(*) AS count FROM {table}")) + .get_result::(&mut c) + .unwrap() + .count; + } + let mut c = + diesel::sqlite::SqliteConnection::establish(self.path.join("db.sqlite3").to_str().unwrap()).unwrap(); + diesel::sql_query(format!("SELECT count(*) AS count FROM {table}")).get_result::(&mut c).unwrap().count + } + async fn post(&self, path: &str, data: Value) -> Response { + self.client.post(format!("{}{path}", self.url)).bearer_auth(&self.token).json(&data).send().await.unwrap() + } + async fn get(&self, path: &str) -> Value { + let r = self.client.get(format!("{}{path}", self.url)).bearer_auth(&self.token).send().await.unwrap(); + assert_eq!(r.status(), StatusCode::OK); + r.json().await.unwrap() + } + async fn options(&self) -> Value { + let r = self.post("/api/webauthn/attestation-options", json!({"masterPasswordHash":PASSWORD})).await; + assert_eq!(r.status(), StatusCode::OK); + r.json().await.unwrap() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + drop(self.process.kill()); + drop(self.process.wait()); + #[cfg(feature = "postgresql")] + if let Some((admin, url)) = &self.postgres { + let parsed = url::Url::parse(url).unwrap(); + let database = parsed.path().trim_start_matches('/'); + assert!( + database.starts_with("vw_passkey_") && database.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + ); + if let Ok(mut conn) = diesel::pg::PgConnection::establish(admin) { + drop(conn.batch_execute(&format!("DROP DATABASE {database} WITH (FORCE)"))); + } + } + drop(fs::remove_dir_all(&self.path)); + } +} + +#[tokio::test] +async fn passkey_enrollment_requires_step_up_and_never_adds_an_sso_bypass() { + let f = Fixture::new(true).await; + for proof in [json!({}), json!({"masterPasswordHash":"wrong"})] { + assert_eq!(f.post("/api/webauthn/attestation-options", proof).await.status(), StatusCode::BAD_REQUEST); + } + let options = f.options().await; + assert_eq!(options["options"]["rp"]["id"], "localhost"); + assert_eq!(options["options"]["authenticatorSelection"]["userVerification"], "required"); + assert!(options["token"].as_str().is_some_and(|s| !s.is_empty())); + assert_eq!(f.get("/api/webauthn").await["data"], json!([])); + let public_options = + f.client.get(format!("{}/identity/accounts/webauthn/assertion-options", f.url)).send().await.unwrap(); + assert!(!public_options.status().is_success()); + let r = f + .client + .post(format!("{}/identity/connect/token", f.url)) + .form(&[("grant_type", "webauthn"), ("client_id", "web"), ("token", "synthetic"), ("deviceResponse", "{}")]) + .send() + .await + .unwrap(); + assert!(!r.status().is_success()); + let sync = f.get("/api/sync").await; + assert_eq!(sync["userDecryption"]["masterPasswordUnlock"]["masterKeyWrappedUserKey"], "synthetic-key"); + assert!(sync["userDecryption"]["webAuthnPrfOptions"].is_null()); + assert_eq!(f.count("web_authn_credentials"), 0); +} + +#[tokio::test] +async fn passkey_disabled_preserves_existing_sync_and_list_and_denies_enrollment() { + let f = Fixture::new(false).await; + // The shared keypair endpoint retains its ordinary behavior with the new + // feature disabled, including first initialization and serial replacement. + f.sql(&format!("UPDATE users SET private_key=NULL,public_key=NULL WHERE uuid='{}'", f.user)); + for private_key in [&keys().original_account_private_key, &keys().repaired_private_key] { + assert_eq!( + f.post( + "/api/accounts/keys", + json!({"publicKey":keys().repaired_public_key, + "encryptedPrivateKey":private_key}) + ) + .await + .status(), + StatusCode::OK + ); + } + + assert!( + !f.post("/api/webauthn/attestation-options", json!({"masterPasswordHash":PASSWORD})) + .await + .status() + .is_success() + ); + assert_eq!(f.get("/api/webauthn").await["data"], json!([])); + let sync = f.get("/api/sync").await; + assert_eq!(sync["userDecryption"]["masterPasswordUnlock"]["masterKeyWrappedUserKey"], "synthetic-key"); + assert!(sync["userDecryption"]["webAuthnPrfOptions"].is_null()); +} + +fn registration_with_key( + options: &Value, + origin: &str, + uv: bool, +) -> (Value, openssl::ec::EcKey) { + use openssl::{ + bn::{BigNum, BigNumContext}, + ec::{EcGroup, EcKey}, + nid::Nid, + }; + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap(); + let key = EcKey::generate(&group).unwrap(); + let (mut x, mut y, mut ctx) = (BigNum::new().unwrap(), BigNum::new().unwrap(), BigNumContext::new().unwrap()); + key.public_key().affine_coordinates_gfp(&group, &mut x, &mut y, &mut ctx).unwrap(); + let mut cose = vec![0xa5, 1, 2, 3, 0x26, 0x20, 1, 0x21, 0x58, 32]; + cose.extend(x.to_vec_padded(32).unwrap()); + cose.extend([0x22, 0x58, 32]); + cose.extend(y.to_vec_padded(32).unwrap()); + let credential = uuid::Uuid::new_v4(); + let mut auth = openssl::sha::sha256(b"localhost").to_vec(); + auth.push(if uv { + 0x45 + } else { + 0x41 + }); + auth.extend([0; 4]); + auth.extend([0; 16]); + auth.extend([0, 16]); + auth.extend(credential.as_bytes()); + auth.extend(cose); + // Minimal standards-format 'none' attestation from a synthetic EC credential. + // This exercises webauthn-rs verification; it does not simulate PRF hardware. + let mut attestation = b"\xa3\x63fmt\x64none\x67attStmt\xa0\x68authData\x58".to_vec(); + attestation.push(u8::try_from(auth.len()).unwrap()); + attestation.extend(auth); + let client = json!({"type":"webauthn.create","challenge":options["options"]["challenge"],"origin":origin,"crossOrigin":false}); + let encode = |b: &[u8]| data_encoding::BASE64URL_NOPAD.encode(b); + let response = json!({"deviceResponse":{"id":encode(credential.as_bytes()),"rawId":encode(credential.as_bytes()), + "response":{"attestationObject":encode(&attestation),"clientDataJson":encode(&serde_json::to_vec(&client).unwrap()),"transports":["usb"]}, + "type":"public-key","clientExtensionResults":{"prf":{"enabled":true}}}, + "name":"Synthetic passkey","token":options["token"],"supportsPrf":true, + "encryptedUserKey":keys().initial.user_key.as_str(),"encryptedPublicKey":keys().initial.public_key.as_str(),"encryptedPrivateKey":keys().initial.private_key.as_str()}); + (response, key) +} + +fn registration(options: &Value, origin: &str, uv: bool) -> Value { + registration_with_key(options, origin, uv).0 +} + +#[tokio::test] +async fn passkey_registration_verifies_origin_uv_replay_and_complete_wrapped_keys() { + let f = Fixture::new(true).await; + for (origin, uv) in [("http://unrelated.example", true), (ORIGIN, false)] { + let o = f.options().await; + let r = f.post("/api/webauthn", registration(&o, origin, uv)).await; + assert!(!r.status().is_success()); + assert_eq!(f.count("web_authn_credentials"), 0); + } + let o = f.options().await; + let mut incomplete = registration(&o, ORIGIN, true); + incomplete.as_object_mut().unwrap().remove("encryptedPrivateKey"); + assert!(!f.post("/api/webauthn", incomplete).await.status().is_success()); + assert_eq!(f.count("web_authn_credentials"), 0); + let o = f.options().await; + let data = registration(&o, ORIGIN, true); + assert_eq!(f.post("/api/webauthn", data.clone()).await.status(), StatusCode::OK); + assert!(!f.post("/api/webauthn", data).await.status().is_success()); + assert_eq!(f.count("web_authn_credentials"), 1); + let list = f.get("/api/webauthn").await; + assert_eq!(list["data"][0]["prfStatus"], 0); + let sync = f.get("/api/sync").await; + assert_eq!( + sync["userDecryption"]["webAuthnPrfOptions"][0]["encryptedPrivateKey"], + keys().initial.private_key.as_str() + ); + assert_eq!(sync["userDecryption"]["masterPasswordUnlock"]["masterKeyWrappedUserKey"], "synthetic-key"); +} + +#[tokio::test] +async fn passkey_multiple_options_removal_and_password_wrapping_are_consistent() { + let f = Fixture::new(true).await; + for _ in 0..2 { + let o = f.options().await; + assert_eq!(f.post("/api/webauthn", registration(&o, ORIGIN, true)).await.status(), StatusCode::OK); + } + assert_eq!(f.get("/api/sync").await["userDecryption"]["webAuthnPrfOptions"].as_array().unwrap().len(), 2); + let list = f.get("/api/webauthn").await; + let id = list["data"][0]["id"].as_str().unwrap(); + assert_eq!( + f.post(&format!("/api/webauthn/{id}/delete"), json!({"masterPasswordHash":PASSWORD})).await.status(), + StatusCode::OK + ); + assert_eq!(f.get("/api/sync").await["userDecryption"]["webAuthnPrfOptions"].as_array().unwrap().len(), 1); + // A master-password wrapper/KDF change preserves the actual user key. + f.sql(&format!("UPDATE users SET akey='new-master-wrapper',client_kdf_iter=650000 WHERE uuid='{}'", f.user)); + assert_eq!(f.get("/api/sync").await["userDecryption"]["webAuthnPrfOptions"].as_array().unwrap().len(), 1); +} + +fn rotate_request(passkeys: &Value) -> Value { + json!({"oldMasterKeyAuthenticationHash":PASSWORD, + "accountUnlockData":{"emergencyAccessUnlockData":[],"organizationAccountRecoveryUnlockData":[], + "passkeyUnlockData":passkeys,"masterPasswordUnlockData":{"kdfType":0,"kdfIterations":600_000, + "kdfMemory":null,"kdfParallelism":null,"email":"user@example.test","masterKeyAuthenticationHash":PASSWORD, + "masterKeyEncryptedUserKey":"new-master-wrapper"}}, + "accountKeys":{"userKeyEncryptedAccountPrivateKey":"new-account-private-wrapper","accountPublicKey":"synthetic-public-key"}, + "accountData":{"ciphers":[],"folders":[],"sends":[]}}) +} + +#[tokio::test] +async fn passkey_rotation_validates_before_mutation_and_rewraps_all_credentials() { + let f = Fixture::new(true).await; + f.sql(&format!("UPDATE users SET public_key='synthetic-public-key' WHERE uuid='{}'", f.user)); + let o = f.options().await; + assert_eq!(f.post("/api/webauthn", registration(&o, ORIGIN, true)).await.status(), StatusCode::OK); + let list = f.get("/api/webauthn").await; + let id = list["data"][0]["id"].as_str().unwrap(); + assert_eq!( + f.post("/api/accounts/key-management/rotate-user-account-keys", rotate_request(&json!([]))).await.status(), + StatusCode::BAD_REQUEST + ); + assert_eq!( + f.get("/api/sync").await["userDecryption"]["masterPasswordUnlock"]["masterKeyWrappedUserKey"], + "synthetic-key" + ); + for field in ["encryptedUserKey", "encryptedPublicKey"] { + for invalid in [Value::Null, json!("not-an-encrypted-string")] { + let mut changes = json!([{"id":id,"encryptedPublicKey":keys().rotated.public_key.as_str(),"encryptedUserKey":keys().rotated.user_key.as_str()}]); + let expected = if invalid.is_null() { + StatusCode::UNPROCESSABLE_ENTITY + } else { + StatusCode::BAD_REQUEST + }; + changes[0][field] = invalid; + assert_eq!( + f.post("/api/accounts/key-management/rotate-user-account-keys", rotate_request(&changes)) + .await + .status(), + expected + ); + assert_eq!( + f.get("/api/sync").await["userDecryption"]["masterPasswordUnlock"]["masterKeyWrappedUserKey"], + "synthetic-key" + ); + } + } + let changes = json!([{"id":id,"encryptedPublicKey":keys().rotated.public_key.as_str(),"encryptedUserKey":keys().rotated.user_key.as_str()}]); + assert_eq!( + f.post("/api/accounts/key-management/rotate-user-account-keys", rotate_request(&changes)).await.status(), + StatusCode::OK + ); + // Read only synthetic persistence. Token invalidation is native rotation behavior. + assert_eq!( + f.count(&format!( + "web_authn_credentials WHERE encrypted_user_key='{}' AND encrypted_private_key='{}'", + keys().rotated.user_key, + keys().initial.private_key + )), + 1 + ); + assert_eq!(f.count("users WHERE akey='new-master-wrapper' AND private_key='new-account-private-wrapper'"), 1); +} + +#[tokio::test] +async fn passkey_challenges_are_fresh_session_bound_and_consumed_exactly_once() { + let f = Fixture::new(true).await; + for change in [ + "json_set(data,'$.createdAt',0)", + "json_set(data,'$.deviceUuid','another-device')", + "json_set(data,'$.accountKeyBinding','another-key')", + ] { + let o = f.options().await; + f.sql(&format!("UPDATE twofactor SET data={change} WHERE atype=1005")); + assert!(!f.post("/api/webauthn", registration(&o, ORIGIN, true)).await.status().is_success()); + assert_eq!(f.count("web_authn_credentials"), 0); + } + let stale = f.options().await; + let current = f.options().await; + assert!(!f.post("/api/webauthn", registration(&stale, ORIGIN, true)).await.status().is_success()); + let data = registration(¤t, ORIGIN, true); + let (first, second) = tokio::join!(f.post("/api/webauthn", data.clone()), f.post("/api/webauthn", data)); + assert_eq!(usize::from(first.status().is_success()) + usize::from(second.status().is_success()), 1); + assert_eq!(f.count("web_authn_credentials"), 1); + assert_eq!(f.count("twofactor WHERE atype=1005"), 0); +} + +#[tokio::test] +async fn passkey_prf_update_verifies_assertion_and_rejects_replay() { + let f = Fixture::new(true).await; + let o = f.options().await; + let (registration, key) = registration_with_key(&o, ORIGIN, true); + let credential = registration["deviceResponse"]["rawId"].clone(); + assert_eq!(f.post("/api/webauthn", registration).await.status(), StatusCode::OK); + let response = f.post("/api/webauthn/assertion-options", json!({"masterPasswordHash":PASSWORD})).await; + assert_eq!(response.status(), StatusCode::OK); + let o: Value = response.json().await.unwrap(); + let client = + json!({"type":"webauthn.get","challenge":o["options"]["challenge"],"origin":ORIGIN,"crossOrigin":false}); + let client = serde_json::to_vec(&client).unwrap(); + let mut auth = openssl::sha::sha256(b"localhost").to_vec(); + auth.push(5); + auth.extend(1u32.to_be_bytes()); + let key = openssl::pkey::PKey::from_ec_key(key).unwrap(); + let mut signer = openssl::sign::Signer::new(MessageDigest::sha256(), &key).unwrap(); + signer.update(&auth).unwrap(); + signer.update(&openssl::sha::sha256(&client)).unwrap(); + let encode = |b: &[u8]| data_encoding::BASE64URL_NOPAD.encode(b); + let data = json!({"token":o["token"],"deviceResponse":{"id":credential,"rawId":credential,"type":"public-key", + "extensions":{},"response":{"authenticatorData":encode(&auth),"clientDataJson":encode(&client),"signature":encode(&signer.sign_to_vec().unwrap()),"userHandle":null}}, + "encryptedUserKey":keys().updated.user_key.as_str(),"encryptedPublicKey":keys().updated.public_key.as_str(),"encryptedPrivateKey":keys().updated.private_key.as_str()}); + let send = |data| f.client.put(format!("{}/api/webauthn", f.url)).bearer_auth(&f.token).json(&data).send(); + assert_eq!(send(data.clone()).await.unwrap().status(), StatusCode::OK); + assert!(!send(data).await.unwrap().status().is_success()); + assert_eq!( + f.get("/api/sync").await["userDecryption"]["webAuthnPrfOptions"][0]["encryptedPrivateKey"], + keys().updated.private_key.as_str() + ); +} + +#[tokio::test] +async fn passkey_credentials_cannot_be_read_or_removed_by_another_account() { + let f = Fixture::new(true).await; + let o = f.options().await; + assert_eq!(f.post("/api/webauthn", registration(&o, ORIGIN, true)).await.status(), StatusCode::OK); + let list = f.get("/api/webauthn").await; + let id = list["data"][0]["id"].as_str().unwrap(); + let other = uuid::Uuid::new_v4().to_string(); + let device = uuid::Uuid::new_v4().to_string(); + f.sql(&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) SELECT '{other}',enabled,created_at,updated_at,login_verify_count,'other@example.test','Other synthetic user',password_hash,salt,password_iterations,akey,private_key,'other-stamp',equivalent_domains,excluded_globals,client_kdf_type,client_kdf_iter FROM users WHERE uuid='{}'; INSERT INTO devices (uuid,user_uuid,created_at,updated_at,name,atype,refresh_token) VALUES ('{device}','{other}',datetime('now'),datetime('now'),'Other browser',14,'other-refresh');",f.user)); + let now = chrono::Utc::now().timestamp(); + let token=jsonwebtoken::encode(&Header::new(Algorithm::RS256),&json!({"nbf":now-30,"exp":now+3600,"iss":format!("{ORIGIN}|login"),"sub":other,"premium":true,"name":"Other user","email":"other@example.test","email_verified":true,"sstamp":"other-stamp","device":device,"devicetype":"14","client_id":"web","scope":["api","offline_access"],"amr":["Application"]}),&EncodingKey::from_rsa_pem(&fs::read(f.path.join("rsa.pem")).unwrap()).unwrap()).unwrap(); + let r = f.client.get(format!("{}/api/webauthn", f.url)).bearer_auth(&token).send().await.unwrap(); + assert_eq!(r.json::().await.unwrap()["data"], json!([])); + assert_eq!( + f.client + .post(format!("{}/api/webauthn/{id}/delete", f.url)) + .bearer_auth(&token) + .json(&json!({"masterPasswordHash":PASSWORD})) + .send() + .await + .unwrap() + .status(), + StatusCode::OK + ); + assert_eq!(f.get("/api/webauthn").await["data"].as_array().unwrap().len(), 1); + let r = f.client.get(format!("{}/api/webauthn", f.url)).send().await.unwrap(); + assert_eq!(r.status(), StatusCode::UNAUTHORIZED); +} + +fn local_postgres_database() -> Option<(String, String)> { + let Ok(value) = std::env::var("VW_PASSKEY_TEST_DATABASE_URL") else { + return None; + }; + let url = url::Url::parse(&value).expect("local test URL"); + assert_eq!(url.scheme(), "postgresql"); + assert_eq!(url.host_str(), Some("127.0.0.1")); + assert!(url.username() == "passkey_test", "Expected the disposable test account"); + assert!(url.password() == Some("synthetic-passkey-test"), "Expected synthetic test credentials"); + assert_eq!(url.path(), "/postgres"); + assert!(url.fragment().is_none()); + let parameters = url.query_pairs().collect::>(); + if !parameters.is_empty() { + assert_eq!(parameters.len(), 1); + assert_eq!(parameters[0].0, "host"); + let directory = PathBuf::from(parameters[0].1.as_ref()); + let canonical = directory.canonicalize().expect("private fixture socket directory"); + assert_eq!(canonical, directory); + assert_eq!(directory.parent(), Some(std::env::temp_dir().canonicalize().unwrap().as_path())); + assert!(directory.file_name().unwrap().to_str().unwrap().starts_with("vw-passkey-pg-")); + assert_eq!(fs::metadata(&directory).unwrap().permissions().mode() & 0o777, 0o700); + } + #[cfg(feature = "postgresql")] + { + let database = format!("vw_passkey_{}", uuid::Uuid::new_v4().simple()); + let mut c = diesel::pg::PgConnection::establish(&value).unwrap(); + c.batch_execute(&format!("CREATE DATABASE {database}")).unwrap(); + let mut database_url = url.clone(); + database_url.set_path(&database); + Some((value, database_url.to_string())) + } + #[cfg(not(feature = "postgresql"))] + panic!("PostgreSQL fixture requires the postgresql feature"); +} + +#[tokio::test] +async fn passkey_rewrap_failure_rolls_back_account_keys_and_can_retry() { + let f = Fixture::new(true).await; + f.sql(&format!("UPDATE users SET public_key='synthetic-public-key' WHERE uuid='{}'", f.user)); + let o = f.options().await; + assert_eq!(f.post("/api/webauthn", registration(&o, ORIGIN, true)).await.status(), StatusCode::OK); + let list = f.get("/api/webauthn").await; + let data = rotate_request( + &json!([{"id":list["data"][0]["id"],"encryptedPublicKey":keys().rotated.public_key.as_str(),"encryptedUserKey":keys().rotated.user_key.as_str()}]), + ); + if f.postgres.is_some() { + f.sql("CREATE FUNCTION reject_rewrap() RETURNS trigger LANGUAGE plpgsql AS $$BEGIN RAISE EXCEPTION 'synthetic failure'; END;$$; CREATE TRIGGER reject_rewrap BEFORE UPDATE OF encrypted_user_key ON web_authn_credentials FOR EACH ROW EXECUTE FUNCTION reject_rewrap();"); + } else { + f.sql("CREATE TRIGGER reject_rewrap BEFORE UPDATE OF encrypted_user_key ON web_authn_credentials BEGIN SELECT RAISE(ABORT,'synthetic failure'); END;"); + } + assert!(!f.post("/api/accounts/key-management/rotate-user-account-keys", data.clone()).await.status().is_success()); + assert_eq!(f.count("users WHERE akey='synthetic-key' AND private_key='synthetic-private-key' AND security_stamp='synthetic-stamp'"),1); + assert_eq!(f.get("/api/sync").await["userDecryption"]["webAuthnPrfOptions"].as_array().unwrap().len(), 1); + if f.postgres.is_some() { + f.sql("DROP TRIGGER reject_rewrap ON web_authn_credentials; DROP FUNCTION reject_rewrap();"); + } else { + f.sql("DROP TRIGGER reject_rewrap;"); + } + assert_eq!(f.post("/api/accounts/key-management/rotate-user-account-keys", data).await.status(), StatusCode::OK); +} + +#[cfg(feature = "postgresql")] +struct PausedRotation { + control: diesel::pg::PgConnection, +} +#[cfg(feature = "postgresql")] +impl PausedRotation { + fn new(f: &Fixture) -> Self { + let (_, url) = f.postgres.as_ref().expect("run through the disposable PostgreSQL gate"); + let mut control = diesel::pg::PgConnection::establish(url).unwrap(); + control.batch_execute("SELECT pg_advisory_lock(424242);").unwrap(); + f.sql("CREATE FUNCTION pause_rotation() RETURNS trigger LANGUAGE plpgsql AS $$BEGIN PERFORM pg_advisory_xact_lock(424242); RETURN NEW; END;$$; CREATE TRIGGER pause_rotation AFTER UPDATE OF akey ON users FOR EACH ROW WHEN (NEW.akey='new-master-wrapper') EXECUTE FUNCTION pause_rotation();"); + Self { + control, + } + } + fn release(&mut self) { + self.control.batch_execute("SELECT pg_advisory_unlock(424242);").unwrap(); + } +} +#[cfg(feature = "postgresql")] +impl Drop for PausedRotation { + fn drop(&mut self) { + drop(self.control.batch_execute("SELECT pg_advisory_unlock_all();")); + } +} + +#[cfg(feature = "postgresql")] +impl Fixture { + async fn paused_rotation_data(&self) -> (Value, String) { + assert!(self.postgres.is_some(), "PostgreSQL gate must supply a disposable database"); + self.sql(&format!("UPDATE users SET public_key='synthetic-public-key' WHERE uuid='{}'", self.user)); + let o = self.options().await; + assert_eq!(self.post("/api/webauthn", registration(&o, ORIGIN, true)).await.status(), StatusCode::OK); + let list = self.get("/api/webauthn").await; + let id = list["data"][0]["id"].as_str().unwrap().to_owned(); + let folder = uuid::Uuid::new_v4(); + self.sql(&format!("INSERT INTO folders (uuid,user_uuid,created_at,updated_at,name) VALUES ('{folder}','{}',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,'original-folder-wrapper')",self.user)); + let mut data = rotate_request( + &json!([{"id":id,"encryptedPublicKey":keys().rotated.public_key.as_str(),"encryptedUserKey":keys().rotated.user_key.as_str()}]), + ); + data["accountData"]["folders"] = json!([{"id":folder,"name":"rotated-folder-wrapper"}]); + (data, id) + } + fn start_rotation(&self, data: &Value) -> tokio::task::JoinHandle> { + let request = self + .client + .post(format!("{}/api/accounts/key-management/rotate-user-account-keys", self.url)) + .bearer_auth(&self.token) + .json(data); + tokio::spawn(async move { request.send().await }) + } + async fn wait_for_database_wait(&self, condition: &str) { + let until = Instant::now() + Duration::from_secs(3); + while self.count(condition) == 0 { + assert!(Instant::now() < until, "expected database wait did not occur"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + async fn wait_for_paused_rotation(&self) { + self.wait_for_database_wait("pg_locks WHERE database=(SELECT oid FROM pg_database WHERE datname=current_database()) AND locktype='advisory' AND objid=424242 AND NOT granted").await; + } + fn assert_original_key_state(&self) { + assert_eq!(self.count("users WHERE akey='synthetic-key' AND private_key='synthetic-private-key' AND security_stamp='synthetic-stamp'"),1); + assert_eq!(self.count("folders WHERE name='original-folder-wrapper'"), 1); + assert_eq!(self.count("devices WHERE refresh_token='synthetic-refresh'"), 1); + assert_eq!( + self.count(&format!("web_authn_credentials WHERE encrypted_user_key='{}'", keys().initial.user_key)), + 1 + ); + } +} + +#[cfg(feature = "postgresql")] +#[tokio::test] +#[ignore = "requires a disposable PostgreSQL instance; see tests/README.md"] +async fn passkey_postgres_rotation_serializes_deletion_and_stale_rotation() { + for competing_action in ["delete", "rotate", "repair"] { + let f = Fixture::new(true).await; + let (data, id) = f.paused_rotation_data().await; + let mut pause = PausedRotation::new(&f); + let first = f.start_rotation(&data); + f.wait_for_paused_rotation().await; + let competing = if competing_action == "delete" { + let request = f + .client + .post(format!("{}/api/webauthn/{id}/delete", f.url)) + .bearer_auth(&f.token) + .json(&json!({"masterPasswordHash":PASSWORD})); + tokio::spawn(async move { request.send().await }) + } else if competing_action == "repair" { + let request = f.client.post(format!("{}/api/accounts/keys", f.url)).bearer_auth(&f.token).json( + &json!({"publicKey":keys().repaired_public_key,"encryptedPrivateKey":keys().repaired_private_key}), + ); + tokio::spawn(async move { request.send().await }) + } else { + f.start_rotation(&data) + }; + // A real PG wait proves the competing request reached the shared account lock. + f.wait_for_database_wait("pg_stat_activity WHERE datname=current_database() AND wait_event='transactionid'") + .await; + assert!(!competing.is_finished()); + pause.release(); + assert_eq!(first.await.unwrap().unwrap().status(), StatusCode::OK); + let result = competing.await.unwrap().unwrap(); + if competing_action == "delete" { + assert_eq!(result.status(), StatusCode::OK); + } else { + assert!(!result.status().is_success()); + } + assert_eq!(f.count("users WHERE akey='new-master-wrapper' AND private_key='new-account-private-wrapper'"), 1); + assert_eq!(f.count("folders WHERE name='rotated-folder-wrapper'"), 1); + assert_eq!(f.count("web_authn_credentials"), i64::from(competing_action != "delete")); + } +} + +#[cfg(feature = "postgresql")] +#[tokio::test] +#[ignore = "requires a disposable PostgreSQL instance; see tests/README.md"] +async fn passkey_postgres_cancelled_statement_rolls_back_and_retries() { + let f = Fixture::new(true).await; + let (data, _) = f.paused_rotation_data().await; + let mut pause = PausedRotation::new(&f); + let request = f.start_rotation(&data); + f.wait_for_paused_rotation().await; + // Target only the waiting statement in this test's generated database. + f.sql("SELECT pg_cancel_backend(pid) FROM pg_locks WHERE database=(SELECT oid FROM pg_database WHERE datname=current_database()) AND locktype='advisory' AND objid=424242 AND NOT granted;"); + assert!(!request.await.unwrap().unwrap().status().is_success()); + f.assert_original_key_state(); + assert_eq!(f.get("/api/sync").await["userDecryption"]["webAuthnPrfOptions"].as_array().unwrap().len(), 1); + pause.release(); + assert_eq!(f.post("/api/accounts/key-management/rotate-user-account-keys", data).await.status(), StatusCode::OK); +} + +#[cfg(feature = "postgresql")] +#[tokio::test] +#[ignore = "requires a disposable PostgreSQL instance; see tests/README.md"] +async fn passkey_postgres_server_interruption_rolls_back_vault_and_keys() { + let mut f = Fixture::new(true).await; + let (data, _) = f.paused_rotation_data().await; + let mut pause = PausedRotation::new(&f); + let request = f.start_rotation(&data); + f.wait_for_paused_rotation().await; + f.process.kill().unwrap(); + f.process.wait().unwrap(); + assert!(request.await.unwrap().is_err()); + // PostgreSQL can finish a statement before noticing the client's closed socket. + // Remove our artificial pause, then wait for all service sessions to exit; + // there can be no client COMMIT after the server process has been killed. + pause.release(); + drop(pause); + let until = Instant::now() + Duration::from_secs(3); + while f.count("pg_stat_activity WHERE datname=current_database() AND pid<>pg_backend_pid()") != 0 { + assert!(Instant::now() < until); + tokio::time::sleep(Duration::from_millis(10)).await; + } + f.assert_original_key_state(); +} + +#[tokio::test] +async fn passkey_native_registration_response_and_options_match_bitwarden() { + let f = Fixture::new(true).await; + let options = f.options().await; + assert!(options["options"]["extensions"]["hmacCreateSecret"].is_null()); + let mut request = registration(&options, ORIGIN, true); + // Match the pinned stock web DTO: standard-base64 rawId, empty client + // extensions, and no transport hint (id itself stays base64url). + let raw_id = + data_encoding::BASE64URL_NOPAD.decode(request["deviceResponse"]["rawId"].as_str().unwrap().as_bytes()).unwrap(); + request["deviceResponse"]["rawId"] = json!(data_encoding::BASE64.encode(&raw_id)); + request["deviceResponse"]["extensions"] = json!({}); + request["deviceResponse"].as_object_mut().unwrap().remove("clientExtensionResults"); + request["deviceResponse"]["response"].as_object_mut().unwrap().remove("transports"); + let response = f.post("/api/webauthn", request).await; + assert_eq!(response.status(), StatusCode::OK); + let credential: Value = response.json().await.expect("native credential response JSON"); + assert_eq!(credential["object"], "webauthnCredential"); + assert_eq!(credential["name"], "Synthetic passkey"); + assert_eq!(credential["prfStatus"], 0); + assert_eq!(credential["encryptedUserKey"], keys().initial.user_key); + assert_eq!(credential["encryptedPublicKey"], keys().initial.public_key); + assert!(credential.get("encryptedPrivateKey").is_none()); + assert!( + f.get("/api/webauthn").await["data"][0] == credential, + "keypair repair must preserve enabled passkey metadata" + ); + assert_eq!(f.get("/api/sync").await["userDecryption"]["webAuthnPrfOptions"][0]["transports"], json!([])); +} + +#[tokio::test] +async fn passkey_rejects_malformed_wrapped_keys_before_advertising_them() { + let f = Fixture::new(true).await; + for field in ["encryptedUserKey", "encryptedPublicKey", "encryptedPrivateKey"] { + for invalid in [ + "plain text".to_owned(), + "9.AAAA".to_owned(), + "2.AA==|AA==|AA==".to_owned(), + format!("4.{}", "A".repeat(2000)), + ] { + let options = f.options().await; + let mut request = registration(&options, ORIGIN, true); + request[field] = json!(invalid); + assert_eq!(f.post("/api/webauthn", request).await.status(), StatusCode::BAD_REQUEST); + assert_eq!(f.count("web_authn_credentials"), 0); + } + } + assert!(f.get("/api/sync").await["userDecryption"]["webAuthnPrfOptions"].is_null()); +} + +#[tokio::test] +async fn passkey_excludes_registered_credentials_and_limits_enrollment_to_five() { + let f = Fixture::new(true).await; + let mut ids = Vec::new(); + for count in 0..5 { + let options = f.options().await; + let excluded = options["options"]["excludeCredentials"].as_array().unwrap(); + assert_eq!(excluded.len(), count); + for id in &ids { + assert!(excluded.iter().any(|item| &item["id"] == id)); + } + let request = registration(&options, ORIGIN, true); + ids.push(request["deviceResponse"]["rawId"].clone()); + assert_eq!(f.post("/api/webauthn", request).await.status(), StatusCode::OK); + } + assert_eq!( + f.post("/api/webauthn/attestation-options", json!({"masterPasswordHash":PASSWORD})).await.status(), + StatusCode::BAD_REQUEST + ); + assert_eq!(f.count("web_authn_credentials"), 5); +} + +#[tokio::test] +async fn passkey_survives_account_keypair_repair_and_remains_in_rotation() { + let f = Fixture::new(true).await; + f.sql(&format!("UPDATE users SET private_key=NULL,public_key=NULL WHERE uuid='{}'", f.user)); + assert_eq!( + f.post( + "/api/accounts/keys", + json!({"publicKey":keys().repaired_public_key, + "encryptedPrivateKey":keys().original_account_private_key}) + ) + .await + .status(), + StatusCode::OK + ); + let options = f.options().await; + assert_eq!(f.post("/api/webauthn", registration(&options, ORIGIN, true)).await.status(), StatusCode::OK); + let before = f.get("/api/sync").await["userDecryption"].clone(); + let credential = f.get("/api/webauthn").await["data"][0].clone(); + // Rewrapping the same account asymmetric pair changes ciphertext, but not + // that pair or the user key. It must not hide an unchanged PRF keyset. + let response = f + .post( + "/api/accounts/keys", + json!({"publicKey":keys().repaired_public_key, + "encryptedPrivateKey":keys().repaired_private_key}), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert!( + f.get("/api/sync").await["userDecryption"] == before, + "keypair repair must preserve both native unlock options" + ); + assert!( + f.get("/api/webauthn").await["data"][0] == credential, + "keypair repair must preserve enabled passkey metadata" + ); + + let mut missing = rotate_request(&json!([])); + missing["accountKeys"]["accountPublicKey"] = json!(keys().repaired_public_key); + assert_eq!( + f.post("/api/accounts/key-management/rotate-user-account-keys", missing).await.status(), + StatusCode::BAD_REQUEST + ); + let mut rotation = rotate_request(&json!([{"id":credential["id"],"encryptedUserKey":keys().rotated.user_key, + "encryptedPublicKey":keys().rotated.public_key}])); + rotation["accountKeys"]["accountPublicKey"] = json!(keys().repaired_public_key); + assert_eq!( + f.post("/api/accounts/key-management/rotate-user-account-keys", rotation).await.status(), + StatusCode::OK + ); + assert_eq!( + f.count(&format!( + "web_authn_credentials WHERE encrypted_user_key='{}' AND encrypted_private_key='{}'", + keys().rotated.user_key, + keys().initial.private_key + )), + 1 + ); +} + +fn native_assertion( + options: &Value, + credential: &str, + key: &openssl::ec::EcKey, + user: &str, + origin: &str, + uv: bool, +) -> Value { + let client = + json!({"type":"webauthn.get","challenge":options["options"]["challenge"],"origin":origin,"crossOrigin":false}); + let client = serde_json::to_vec(&client).unwrap(); + let mut auth = openssl::sha::sha256(b"localhost").to_vec(); + auth.push(if uv { + 5 + } else { + 1 + }); + // Synced passkeys may always report zero; challenge consumption must still block replay. + auth.extend(0u32.to_be_bytes()); + let key = openssl::pkey::PKey::from_ec_key(key.clone()).unwrap(); + let mut signer = openssl::sign::Signer::new(MessageDigest::sha256(), &key).unwrap(); + signer.update(&auth).unwrap(); + signer.update(&openssl::sha::sha256(&client)).unwrap(); + let encode = |b: &[u8]| data_encoding::BASE64URL_NOPAD.encode(b); + let handle = uuid::Uuid::parse_str(user).unwrap(); + json!({"id":credential,"rawId":credential,"type":"public-key","extensions":{}, + "response":{"authenticatorData":encode(&auth),"clientDataJSON":encode(&client), + "signature":encode(&signer.sign_to_vec().unwrap()),"userHandle":encode(handle.as_bytes())}}) +} + +#[tokio::test] +async fn native_passkey_login_uses_one_challenge_and_returns_prf_unlock() { + let f = Fixture::with_policy(true, false).await; + let registration_options = f.options().await; + let (registration, key) = registration_with_key(®istration_options, ORIGIN, true); + let credential = registration["deviceResponse"]["rawId"].as_str().unwrap().to_owned(); + assert_eq!(f.post("/api/webauthn", registration).await.status(), StatusCode::OK); + let factor = uuid::Uuid::new_v4(); + f.sql(&format!( + "INSERT INTO twofactor (uuid,user_uuid,atype,enabled,data,last_used) VALUES ('{factor}','{}',0,TRUE,'JBSWY3DPEHPK3PXP',0)", + f.user + )); + let options: Value = f + .client + .get(format!("{}/identity/accounts/webauthn/assertion-options", f.url)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(options["options"]["userVerification"], "required"); + let response = native_assertion(&options, &credential, &key, &f.user, ORIGIN, true); + let response = response.to_string(); + let token = options["token"].as_str().unwrap(); + let form = [ + ("grant_type", "webauthn"), + ("scope", "api offline_access"), + ("client_id", "web"), + ("token", token), + ("deviceResponse", response.as_str()), + ("deviceIdentifier", f.device.as_str()), + ("deviceName", "Synthetic browser"), + ("deviceType", "14"), + ]; + let first = f.client.post(format!("{}/identity/connect/token", f.url)).form(&form).send().await.unwrap(); + assert_eq!(first.status(), StatusCode::OK); + let result: Value = first.json().await.unwrap(); + assert!(result["access_token"].as_str().is_some_and(|v| !v.is_empty())); + assert!(result["refresh_token"].as_str().is_some_and(|v| !v.is_empty())); + assert_eq!(result["UserDecryptionOptions"]["WebAuthnPrfOption"]["EncryptedUserKey"], keys().initial.user_key); + assert_eq!(f.count("twofactor WHERE atype=0"), 1); + let second = f.client.post(format!("{}/identity/connect/token", f.url)).form(&form).send().await.unwrap(); + assert!(!second.status().is_success()); +} + +#[tokio::test] +async fn native_passkey_login_rejects_wrong_handle_origin_and_uv() { + let f = Fixture::with_policy(true, false).await; + let registration_options = f.options().await; + let (registration, key) = registration_with_key(®istration_options, ORIGIN, true); + let credential = registration["deviceResponse"]["rawId"].as_str().unwrap().to_owned(); + assert_eq!(f.post("/api/webauthn", registration).await.status(), StatusCode::OK); + for case in 0..3 { + let options: Value = f + .client + .get(format!("{}/identity/accounts/webauthn/assertion-options", f.url)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let origin = if case == 1 { + "https://attacker.example" + } else { + ORIGIN + }; + let mut response = native_assertion(&options, &credential, &key, &f.user, origin, case != 2); + if case == 0 { + response["response"]["userHandle"] = + json!(data_encoding::BASE64URL_NOPAD.encode(uuid::Uuid::new_v4().as_bytes())); + } + let response = response.to_string(); + let form = [ + ("grant_type", "webauthn"), + ("scope", "api offline_access"), + ("client_id", "web"), + ("token", options["token"].as_str().unwrap()), + ("deviceResponse", response.as_str()), + ("deviceIdentifier", f.device.as_str()), + ("deviceName", "Synthetic browser"), + ("deviceType", "14"), + ]; + let result = f.client.post(format!("{}/identity/connect/token", f.url)).form(&form).send().await.unwrap(); + assert!(!result.status().is_success(), "case {case} unexpectedly authenticated"); + } +} + +#[tokio::test] +async fn native_passkey_without_prf_keeps_master_password_unlock_and_refresh() { + let f = Fixture::with_policy(true, false).await; + let registration_options = f.options().await; + let (mut registration, key) = registration_with_key(®istration_options, ORIGIN, true); + let credential = registration["deviceResponse"]["rawId"].as_str().unwrap().to_owned(); + registration["supportsPrf"] = json!(false); + for field in ["encryptedUserKey", "encryptedPublicKey", "encryptedPrivateKey"] { + registration.as_object_mut().unwrap().remove(field); + } + assert_eq!(f.post("/api/webauthn", registration).await.status(), StatusCode::OK); + let options: Value = f + .client + .get(format!("{}/identity/accounts/webauthn/assertion-options", f.url)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let response = native_assertion(&options, &credential, &key, &f.user, ORIGIN, true).to_string(); + let form = [ + ("grant_type", "webauthn"), + ("scope", "api offline_access"), + ("client_id", "web"), + ("token", options["token"].as_str().unwrap()), + ("deviceResponse", response.as_str()), + ("deviceIdentifier", f.device.as_str()), + ("deviceName", "Synthetic browser"), + ("deviceType", "14"), + ]; + let result: Value = f + .client + .post(format!("{}/identity/connect/token", f.url)) + .form(&form) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(result["UserDecryptionOptions"]["MasterPasswordUnlock"]["MasterKeyWrappedUserKey"], "synthetic-key"); + assert!(result["UserDecryptionOptions"].get("WebAuthnPrfOption").is_none()); + let refresh_token = result["refresh_token"].as_str().unwrap(); + let refresh: Value = f + .client + .post(format!("{}/identity/connect/token", f.url)) + .form(&[("grant_type", "refresh_token"), ("client_id", "web"), ("refresh_token", refresh_token)]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(refresh["access_token"].as_str().is_some_and(|v| !v.is_empty())); +} From 55801ccd342106f4c61506651a2fd1e3d1c61564 Mon Sep 17 00:00:00 2001 From: Akhil Babel <335565203+akh1lb@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:00:29 +0200 Subject: [PATCH 2/2] fix(auth): preserve rotated keys during key ID initialization --- src/api/core/accounts.rs | 5 ++--- src/db/models/user.rs | 41 ++++++++++++++++++++++++++++++++++++++++ tests/passkey_unlock.rs | 28 ++++++++++++++++++++++++++- 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 6132cfcf..da882d10 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -1093,13 +1093,12 @@ struct KeyIdData { #[post("/accounts/key-management/user-key-id", data = "")] async fn post_user_key(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { - let mut user = headers.user; + let user = headers.user; if user.key_id.is_some() { err_code!("Unexpected data", Status::UnprocessableEntity.code); } - user.key_id = Some(data.into_inner().user_key_id); - user.save(&conn).await + user.set_key_id_if_current(data.into_inner().user_key_id, &conn).await } #[post("/accounts/security-stamp", data = "")] diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 566b3506..74187f0a 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -362,6 +362,47 @@ impl User { } } + /// Initialize the client key ID without overwriting a concurrent key rotation. + pub async fn set_key_id_if_current(&self, key_id: KeyId, conn: &DbConn) -> EmptyResult { + let expected = ( + self.uuid.clone(), + self.security_stamp.clone(), + self.akey.clone(), + self.private_key.clone(), + self.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 user key ID"); + // Match the portable account lock used by passkey key rotation. + 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::(conn).map_err(|_| failed())?; + if current.key_id.is_some() { + return Err(Error::new_msg("Unexpected data").with_code(422)); + } + 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::key_id.eq(&key_id), users::updated_at.eq(updated_at))) + .execute(conn) + .map_err(|_| failed())?; + Ok(()) + }) + }) + .await + } + /// 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( diff --git a/tests/passkey_unlock.rs b/tests/passkey_unlock.rs index bfefa545..3870b951 100644 --- a/tests/passkey_unlock.rs +++ b/tests/passkey_unlock.rs @@ -330,6 +330,22 @@ async fn passkey_enrollment_requires_step_up_and_never_adds_an_sso_bypass() { assert_eq!(f.count("web_authn_credentials"), 0); } +#[tokio::test] +async fn user_key_id_initialization_preserves_existing_keys_and_rejects_replacement() { + let f = Fixture::with_policy(false).await; + let key_id = uuid::Uuid::new_v4().to_string(); + let data = json!({"userKeyId":key_id}); + assert_eq!(f.post("/api/accounts/key-management/user-key-id", data.clone()).await.status(), StatusCode::OK); + assert_eq!(f.count(&format!("users WHERE key_id='{key_id}' AND akey='synthetic-key' AND private_key='synthetic-private-key' AND security_stamp='synthetic-stamp'")),1); + assert_eq!( + f.post("/api/accounts/key-management/user-key-id", data).await.status(), + StatusCode::UNPROCESSABLE_ENTITY + ); + let sync = f.get("/api/sync").await; + assert_eq!(sync["userDecryption"]["userKeyId"], key_id); + assert_eq!(sync["userDecryption"]["masterPasswordUnlock"]["masterKeyWrappedUserKey"], "synthetic-key"); +} + #[tokio::test] async fn passkeys_are_available_without_opt_in_and_keep_master_password_unlock() { let f = Fixture::with_policy(false).await; @@ -733,7 +749,7 @@ impl Fixture { #[tokio::test] #[ignore = "requires a disposable PostgreSQL instance; see tests/README.md"] async fn passkey_postgres_rotation_serializes_deletion_and_stale_rotation() { - for competing_action in ["delete", "rotate"] { + for competing_action in ["delete", "rotate", "user-key-id"] { let f = Fixture::new().await; let (data, id) = f.paused_rotation_data().await; let mut pause = PausedRotation::new(&f); @@ -746,6 +762,13 @@ async fn passkey_postgres_rotation_serializes_deletion_and_stale_rotation() { .bearer_auth(&f.token) .json(&json!({"masterPasswordHash":PASSWORD})); tokio::spawn(async move { request.send().await }) + } else if competing_action == "user-key-id" { + let request = f + .client + .post(format!("{}/api/accounts/key-management/user-key-id", f.url)) + .bearer_auth(&f.token) + .json(&json!({"userKeyId": uuid::Uuid::new_v4().to_string()})); + tokio::spawn(async move { request.send().await }) } else { f.start_rotation(&data) }; @@ -764,6 +787,9 @@ async fn passkey_postgres_rotation_serializes_deletion_and_stale_rotation() { assert_eq!(f.count("users WHERE akey='new-master-wrapper' AND private_key='new-account-private-wrapper'"), 1); assert_eq!(f.count("folders WHERE name='rotated-folder-wrapper'"), 1); assert_eq!(f.count("web_authn_credentials"), i64::from(competing_action != "delete")); + if competing_action == "user-key-id" { + assert_eq!(f.count("users WHERE key_id IS NOT NULL"), 0); + } } }