From 019a1adf98decd4f0a7d5e1010ac76cdf099a6a0 Mon Sep 17 00:00:00 2001 From: Zaid Marji Date: Tue, 2 Jun 2026 19:09:41 +0300 Subject: [PATCH] webauthn: passkey-management module + login hardening + Result-typed 2FA lookups - src/api/core/passkeys.rs: extract passkey-management endpoints into their own module from api/core/mod.rs - src/api/core/two_factor/webauthn.rs: add extensions field to the PublicKeyCredentialCopy / RegisterPublicKeyCredentialCopy serde wrappers with serde(default, alias = clientExtensionResults), and propagate through the From impls - src/api/core/two_factor/{authenticator,duo,email,protected_actions,yubikey}.rs: propagate ? cascade for find_by_user_and_type's new Result, Error> signature - src/api/core/{accounts,ciphers,mod}.rs: rotate-key PRF rewrap, /sync webauthn_prf_options gating - src/api/identity.rs: webauthn_login grant hardening + defensive documentation justifying unauth-grant 503-vs-AUTH_FAILED asymmetries - src/db/models/two_factor.rs: try_find_by_user, find_by_user_and_type Result-ification, take_by_user_and_type, is_policy_provider* predicates, POLICY_PROVIDER_TYPES const - src/db/models/user.rs: try_find_by_uuid sibling, WebAuthnCredential delete cascade in User::delete - src/db/models/web_authn_credential.rs: model hardening - migrations/*: schema additions for webauthn passkey credentials - playwright/tests/passkey.spec.ts: passkey feature tests - README.md, scss.hbs, config.rs: docs, UI hides, feature flag --- README.md | 4 + .../up.sql | 2 +- .../up.sql | 2 +- .../up.sql | 2 +- playwright/tests/passkey.spec.ts | 46 +- src/api/core/accounts.rs | 147 ++- src/api/core/ciphers.rs | 10 +- src/api/core/mod.rs | 721 +------------ src/api/core/passkeys.rs | 953 ++++++++++++++++++ src/api/core/two_factor/webauthn.rs | 45 +- src/api/identity.rs | 649 ++++++++---- src/config.rs | 9 +- src/db/models/two_factor.rs | 75 ++ src/db/models/user.rs | 35 +- src/db/models/web_authn_credential.rs | 334 +++++- .../templates/scss/vaultwarden.scss.hbs | 7 + src/util.rs | 12 + 17 files changed, 1980 insertions(+), 1073 deletions(-) create mode 100644 src/api/core/passkeys.rs diff --git a/README.md b/README.md index 0b24ba69..3807b7d2 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,10 @@ A nearly complete implementation of the Bitwarden Client API is provided, includ [FIDO2 WebAuthn](https://bitwarden.com/help/setup-two-step-login-fido/), [YubiKey](https://bitwarden.com/help/setup-two-step-login-yubikey/), [Duo](https://bitwarden.com/help/setup-two-step-login-duo/) + * Log in with passkey (account passkeys). A successful account-passkey login is treated as a primary authentication + method, so it does not prompt again for configured two-step login providers. Require SSO disables account-passkey + sign-in and enrollment. Account-passkey ceremonies are bound to the configured `DOMAIN` origin; same-site split + web/API deployments must still serve the web vault from that origin. * [Emergency Access](https://bitwarden.com/help/emergency-access/) * [Vaultwarden Admin Backend](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page) * [Modified Web Vault client](https://github.com/dani-garcia/bw_web_builds) (Bundled within our containers) diff --git a/migrations/mysql/2026-02-12-000000_add_web_authn_credentials/up.sql b/migrations/mysql/2026-02-12-000000_add_web_authn_credentials/up.sql index 29d7f544..632a9e68 100644 --- a/migrations/mysql/2026-02-12-000000_add_web_authn_credentials/up.sql +++ b/migrations/mysql/2026-02-12-000000_add_web_authn_credentials/up.sql @@ -12,4 +12,4 @@ CREATE TABLE web_authn_credentials ( ); CREATE INDEX idx_web_authn_credentials_user_uuid ON web_authn_credentials (user_uuid); -CREATE UNIQUE INDEX idx_web_authn_credentials_credential_id_hash ON web_authn_credentials (credential_id_hash); +CREATE UNIQUE INDEX idx_web_authn_credentials_credential_id_hash ON web_authn_credentials (user_uuid, credential_id_hash); diff --git a/migrations/postgresql/2026-02-12-000000_add_web_authn_credentials/up.sql b/migrations/postgresql/2026-02-12-000000_add_web_authn_credentials/up.sql index 0819a037..a2c0aab2 100644 --- a/migrations/postgresql/2026-02-12-000000_add_web_authn_credentials/up.sql +++ b/migrations/postgresql/2026-02-12-000000_add_web_authn_credentials/up.sql @@ -11,4 +11,4 @@ CREATE TABLE web_authn_credentials ( ); CREATE INDEX idx_web_authn_credentials_user_uuid ON web_authn_credentials (user_uuid); -CREATE UNIQUE INDEX idx_web_authn_credentials_credential_id_hash ON web_authn_credentials (credential_id_hash); +CREATE UNIQUE INDEX idx_web_authn_credentials_credential_id_hash ON web_authn_credentials (user_uuid, credential_id_hash); diff --git a/migrations/sqlite/2026-02-12-000000_add_web_authn_credentials/up.sql b/migrations/sqlite/2026-02-12-000000_add_web_authn_credentials/up.sql index 2b286ea6..f87e37a4 100644 --- a/migrations/sqlite/2026-02-12-000000_add_web_authn_credentials/up.sql +++ b/migrations/sqlite/2026-02-12-000000_add_web_authn_credentials/up.sql @@ -11,4 +11,4 @@ CREATE TABLE web_authn_credentials ( ); CREATE INDEX idx_web_authn_credentials_user_uuid ON web_authn_credentials (user_uuid); -CREATE UNIQUE INDEX idx_web_authn_credentials_credential_id_hash ON web_authn_credentials (credential_id_hash); +CREATE UNIQUE INDEX idx_web_authn_credentials_credential_id_hash ON web_authn_credentials (user_uuid, credential_id_hash); diff --git a/playwright/tests/passkey.spec.ts b/playwright/tests/passkey.spec.ts index a19a57b6..08fab773 100644 --- a/playwright/tests/passkey.spec.ts +++ b/playwright/tests/passkey.spec.ts @@ -70,6 +70,15 @@ test.describe('Passkey login challenge endpoint', () => { expect(body.token.length).toBeGreaterThan(0); }); + test('GET assertion-options rejects cross-site Fetch Metadata before minting a challenge', async ({ request }) => { + const res = await request.get('/identity/accounts/webauthn/assertion-options', { + headers: { 'Sec-Fetch-Site': 'cross-site' }, + }); + expect(res.status()).toBe(403); + const body: any = await res.json(); + expect(body?.message ?? '').toMatch(/same-site/i); + }); + test('assertion-options returns a fresh token and challenge on every call', async ({ request }) => { // Each call inserts a row in `web_authn_login_challenges`. Token AND // challenge bytes must both be unique across calls; if a future @@ -165,11 +174,14 @@ test.describe('Passkey grant rejects all bad input with the same message', () => const { token } = await (await request.get('/identity/accounts/webauthn/assertion-options')).json(); // A shape that parses as PublicKeyCredentialCopy but cannot identify // any registered discoverable credential — same end state as garbage, - // but reaches a deeper branch in `webauthn_login`. + // but reaches a deeper branch in `webauthn_login`. Keep an explicit + // empty extensions object here to mirror the modern browser shape, + // even though the server also accepts older clients that omit it. const fakeAssertion = JSON.stringify({ id: 'AAAA', rawId: 'AAAA', type: 'public-key', + extensions: {}, response: { authenticatorData: 'AAAA', clientDataJson: 'AAAA', @@ -303,8 +315,18 @@ test.describe('Passkey UI surface', () => { // treated as a proven login attempt for that user. // --------------------------------------------------------------------------- -function base64url(s: string): string { - return Buffer.from(s, 'utf8').toString('base64') +// WebAuthn user_handle is the user's 16-byte UUID (raw bytes), base64url-encoded. +// The server parses it via `Uuid::from_slice(decoded)` which requires exactly 16 +// bytes — encoding the UUID's 36-character ASCII representation would short-circuit +// at `identify_discoverable_authentication` before the test reaches the +// pre-verification find_by_uuid path it intends to exercise. +function uuidToUserHandle(uuid: string): string { + const hex = uuid.replace(/-/g, ''); + if (hex.length !== 32) { + throw new Error(`uuidToUserHandle: expected 32 hex chars after stripping dashes, got ${hex.length} (${uuid})`); + } + return Buffer.from(hex, 'hex') + .toString('base64') .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } @@ -320,11 +342,18 @@ function webauthnGrantTargetingUser(token: string, userUuid: string): Record { // SPA fetches this BEFORE invoking the WebAuthn ceremony, so the // server-side gate here is what prevents an attacker from // attempting passkey login even with a credential a victim has - // previously enrolled. Mirrors `src/api/identity.rs` line 1250. + // previously enrolled. Mirrors the SSO_ONLY gate in + // `get_web_authn_assertion_options` (src/api/identity.rs). const res = await request.get('/identity/accounts/webauthn/assertion-options'); expect(res.status()).toBeGreaterThanOrEqual(400); const body: any = await res.json(); @@ -510,9 +540,9 @@ test.describe('Passkey grant is rejected when SSO_ONLY is on', () => { }); test.describe('Passkey enrolment is rejected when SSO_ONLY is on', () => { - // Defends the deny-by-default gate on the management-side endpoints - // (`src/api/core/mod.rs` lines 308, 390, 459, 516 — guarded by - // `sso_enabled() && sso_only()`). + // Defends the deny-by-default gate on the management-side endpoints: + // `check_passkey_endpoint_preconditions` (src/api/core/passkeys.rs) + // refuses them under `sso_enabled() && sso_only()`. // // The enrol endpoints are authenticated, so we need a Bearer token // to reach the gate; under `SSO_ONLY=true` fresh logins must go diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 43bdc967..c0065c6b 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -26,7 +26,6 @@ use crate::{ WebAuthnCredential, WebAuthnCredentialId, }, }, - error::Error, mail, util::{NumberOrString, deser_opt_nonempty_str, format_date}, }; @@ -849,6 +848,28 @@ fn validate_passkey_rotation_data( Ok(()) } +fn prepare_rewrapped_passkey_credentials( + passkey_unlock_data: &[UpdateWebAuthnLoginData], + existing_webauthn_credentials: &[WebAuthnCredential], +) -> ApiResult> { + let mut rewrapped_credentials = Vec::with_capacity(passkey_unlock_data.len()); + for passkey_data in passkey_unlock_data { + let Some(mut credential) = existing_webauthn_credentials.iter().find(|c| c.uuid == passkey_data.id).cloned() + else { + err!("Passkey doesn't exist") + }; + if !credential.has_prf_keyset() { + err!("Passkey is not in a PRF-enabled state") + } + + credential.encrypted_public_key = Some(passkey_data.encrypted_public_key.clone()); + credential.encrypted_user_key = Some(passkey_data.encrypted_user_key.clone()); + rewrapped_credentials.push(credential); + } + + Ok(rewrapped_credentials) +} + #[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. @@ -888,20 +909,16 @@ async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: &headers.user, )?; - let snapshot_prf_ids = existing_webauthn_credentials - .iter() - .filter(|c| c.has_prf_keyset()) - .map(|c| c.uuid.clone()) - .collect::>(); - let current_prf_ids = WebAuthnCredential::find_by_user(user_id, &conn) - .await? - .into_iter() - .filter(WebAuthnCredential::has_prf_keyset) - .map(|c| c.uuid) - .collect::>(); - if current_prf_ids != snapshot_prf_ids { - err!("Passkey credentials changed during key rotation; please retry") - } + // Prepare the PRF rewrap payload before mutating any vault data. The + // rotate-key endpoint is not fully transactional (see TODO above), so no + // passkey-specific validation/read may run after folders/sends/ciphers + // have been rewritten but before the user key is committed; that would add + // a new branch-specific way to return an error while vault data already + // expects the new account key. + let rewrapped_credentials = prepare_rewrapped_passkey_credentials( + &data.account_unlock_data.passkey_unlock_data, + &existing_webauthn_credentials, + )?; // Update folder data for folder_data in data.account_data.folders { @@ -965,24 +982,6 @@ async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: } } - // Re-check the PRF credential set immediately before committing the new - // account key. A concurrent enrol (another session/tab) can register a - // PRF passkey between the validation re-check above and now — that - // credential is missing from `passkey_unlock_data`, so its - // `encrypted_user_key` is still wrapped under the OLD account key. - // Erroring out here forces the caller to retry the rotation; without - // this guard, the new akey commits and the orphan credential can never - // unlock the vault again. - let post_loop_prf_ids = WebAuthnCredential::find_by_user(user_id, &conn) - .await? - .into_iter() - .filter(WebAuthnCredential::has_prf_keyset) - .map(|c| c.uuid) - .collect::>(); - if post_loop_prf_ids != snapshot_prf_ids { - err!("Passkey credentials changed during key rotation; please retry") - } - // Update user data let mut user = headers.user; @@ -998,62 +997,36 @@ async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: let save_result = user.save(&conn).await; - // Rewrap the passkey-login PRF blobs ONLY if `user.save` committed the new - // account key. The proper fix is a single transaction over the user-record - // write and every per-credential `update_keys` (the function-head comment - // already calls this out); until that's in place, this ordering is the - // strict improvement over running the loop before `user.save`: - // • `user.save` fails → skip the rewrap entirely. The user's old master - // password still works and the credential blobs still match the - // (uncommitted) old account key. - // • `user.save` commits but a credential rewrap fails mid-loop → the - // user has the new master password, the rewrapped credentials match - // the new akey, and the not-yet-rewrapped credentials are stranded - // (their PRF blobs reference the old akey). Recoverable: log in via - // the new master password and re-enrol the stranded credentials. - // Running the loop before `user.save` had the inverse failure: rewrapped - // credentials referenced an akey that was never committed, so they - // couldn't unlock at all and the user could only log in via the still- - // old master password — strictly worse. - let rewrap_result: EmptyResult = if save_result.is_ok() { - let mut outcome: EmptyResult = Ok(()); - for passkey_data in data.account_unlock_data.passkey_unlock_data { - let Some(mut credential) = - WebAuthnCredential::find_by_uuid_and_user(&passkey_data.id, &user.uuid, &conn).await - else { - outcome = Err(Error::new("Passkey doesn't exist", "Passkey doesn't exist")); - break; - }; - // Refuse to write rotation data to a credential that isn't in the - // "Enabled" PRF state. Otherwise a credential with only the two - // rotated columns set (and `encrypted_private_key` still NULL) - // would be left as a partial keyset that `prf_status` reports as - // Supported. - if !credential.has_prf_keyset() { - outcome = - Err(Error::new("Passkey is not in a PRF-enabled state", "Passkey is not in a PRF-enabled state")); - break; - } - // Mutate only the two columns `update_keys` persists; other - // fields on the loaded credential are not written back. - credential.encrypted_public_key = Some(passkey_data.encrypted_public_key); - credential.encrypted_user_key = Some(passkey_data.encrypted_user_key); + // Once the new account key is committed, make a best-effort pass over the + // PRF credentials that were present in the initial snapshot. A late + // passkey-row delete or DB transient must not turn a completed master-key + // rotation into an API error: the user row and vault data are already on + // the new key, and returning failure would mislead the client into trying + // the old password. A failed PRF rewrap only degrades passkey unlock for + // that credential; the master password remains authoritative. + if save_result.is_ok() { + for credential in &rewrapped_credentials { if let Err(e) = credential.update_keys(&conn).await { - outcome = Err(e); - break; + warn!( + "post_rotatekey: failed to rewrap WebAuthn PRF credential {} for user {} after key rotation: {e:#?}", + credential.uuid, user.uuid + ); + if let Err(clear_error) = credential.clear_prf_keyset(&conn).await { + warn!( + "post_rotatekey: failed to clear stale WebAuthn PRF keyset for credential {} after rewrap failure: {clear_error:#?}", + credential.uuid + ); + } } } - outcome - } else { - Ok(()) - }; + } // 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; - save_result.and(rewrap_result) + save_result } #[post("/accounts/security-stamp", data = "")] @@ -1884,6 +1857,20 @@ mod tests { assert!(validate_passkey_rotation_data(&data, &credentials).is_ok()); } + #[test] + fn passkey_rotation_prepares_rewrap_payload_from_initial_snapshot() { + let credentials = vec![webauthn_credential("prf", true, true)]; + let data = vec![passkey_unlock_data("prf")]; + + let prepared = prepare_rewrapped_passkey_credentials(&data, &credentials).unwrap(); + + assert_eq!(prepared.len(), 1); + assert_eq!(prepared[0].uuid, WebAuthnCredentialId::from(String::from("prf"))); + assert_eq!(prepared[0].encrypted_user_key.as_deref(), Some("user")); + assert_eq!(prepared[0].encrypted_public_key.as_deref(), Some("public")); + assert_eq!(prepared[0].encrypted_private_key.as_deref(), Some("key")); + } + #[test] fn passkey_rotation_rejects_missing_prf_credential() { let credentials = vec![webauthn_credential("prf", true, true)]; diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index b38cbc56..7050b30d 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -191,12 +191,12 @@ 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, - post_api_webauthn, - put_api_webauthn, - post_api_webauthn_assertion_options, - post_api_webauthn_attestation_options, - post_api_webauthn_delete - ]; + let mut meta_routes = routes![alive, now, version, config]; let mut routes = Vec::new(); routes.append(&mut accounts::routes()); @@ -70,6 +50,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); @@ -218,687 +199,9 @@ fn version() -> Json<&'static str> { Json(crate::VERSION.unwrap_or_default()) } -#[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(|wac| { - json!({ - "id": wac.uuid, - "name": wac.name, - // 0 = Enabled, 1 = Supported (PRF-capable, keyset not set up), 2 = Unsupported. - "prfStatus": wac.prf_status(), - "encryptedUserKey": wac.encrypted_user_key, - "encryptedPublicKey": wac.encrypted_public_key, - "object": "webauthnCredential", - }) - }) - .collect(); - - Ok(Json(json!({ - "object": "list", - "data": data, - "continuationToken": null - }))) -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct WebAuthnPasskeyRegistrationChallenge { - token: String, - created_at: i64, - user_security_stamp: String, - state: PasskeyRegistration, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct WebAuthnPasskeyAssertionChallenge { - token: String, - created_at: i64, - user_security_stamp: String, - state: PasskeyAuthentication, -} - -fn passkey_management_challenge_is_fresh(created_at: i64) -> bool { - // Lower-bound-only: `created_at` is server-stamped at challenge issuance - // and cannot legitimately be in the future. An upper bound here would only - // matter under a backward clock-jump (NTP correction), and in that case - // it would *extend* the validity window past the documented TTL — strictly - // worse than the one-sided check used by `WebAuthnLoginChallenge::take`. - let cutoff = Utc::now().timestamp() - WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS; - created_at >= cutoff -} - -fn passkey_registration_challenge_state( - data: &str, - token: Option<&str>, - user_security_stamp: &str, -) -> ApiResult { - // Persisted challenge rows are always the `{token, state}` wrapper — - // nothing in the current code path writes the bare `PasskeyRegistration` - // shape. Reject a row that doesn't deserialise (corrupted, stale schema) - // with the same generic message we use for token mismatch, rather than - // falling through to an un-tokened legacy path. - 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.") - } - Ok(saved.state) -} - -fn passkey_assertion_challenge_state( - data: &str, - token: &str, - user_security_stamp: &str, -) -> ApiResult { - // Same shape contract as `passkey_registration_challenge_state` above — - // reject undecodable rows with the generic message rather than leaking - // the underlying serde error. - 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.") - } - Ok(saved.state) -} - -fn passkey_credential_id_hash(passkey: &Passkey) -> String { - crypto::sha256_hex(passkey.cred_id().as_slice()) -} - -#[post("/webauthn/attestation-options", data = "")] -async fn post_api_webauthn_attestation_options( - data: Json, - headers: Headers, - conn: DbConn, -) -> JsonResult { - // Same gate the 2FA WebAuthn entry point uses; cleanly rejects requests - // when DOMAIN is incompatible with WebAuthn rather than panicking inside - // the `WEBAUTHN` `LazyLock` initializer. - if !CONFIG.is_webauthn_2fa_supported() { - err!("Configured `DOMAIN` is not compatible with Webauthn") - } - - crate::ratelimit::check_limit_login(&headers.ip.ip)?; - - let data: PasswordOrOtpData = data.into_inner(); - let user = headers.user; - - if CONFIG.sso_enabled() && CONFIG.sso_only() { - err!("Passkeys cannot be created when SSO sign-in is required") - } - - data.validate(&user, true, &conn).await?; - - let all_creds = WebAuthnCredential::find_by_user(&user.uuid, &conn).await?; - let existing_cred_ids: Vec<_> = all_creds - .into_iter() - .filter_map(|wac| { - let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?; - Some(passkey.cred_id().to_owned()) - }) - .collect(); - - let user_uuid = uuid::Uuid::parse_str(&user.uuid) - .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))?; - - // Tell the client we want a discoverable (resident) credential with UV. - // `start_passkey_registration` already pins UV=Required in the stored - // `state`; resident-key is NOT enforced server-side by webauthn-rs's - // `register_credential` (the corresponding field on the state is - // destructured as unused), so this mutation is a client-side hint only: - // it controls the challenge JSON shipped to the browser, not what the - // server validates against. A non-resident credential that completes - // the ceremony would still be accepted but later fail discoverable- - // login, so honest clients comply and tampering clients shoot themselves. - 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); - } - - // Atomically drop any abandoned challenge from a previous, unfinished - // registration attempt so only one in-flight challenge state per user - // exists at any time. - TwoFactor::take_by_user_and_type(&user.uuid, TwoFactorType::WebauthnPasskeyRegisterChallenge as i32, &conn).await?; - - let token = get_uuid(); - let saved_challenge = WebAuthnPasskeyRegistrationChallenge { - token: token.clone(), - created_at: Utc::now().timestamp(), - user_security_stamp: user.security_stamp, - state, - }; - - // Persist the registration state in the database (same pattern as 2FA webauthn) - TwoFactor::new( - user.uuid, - TwoFactorType::WebauthnPasskeyRegisterChallenge, - serde_json::to_string(&saved_challenge)?, - ) - .save(&conn) - .await?; - - let mut options = serde_json::to_value(challenge.public_key)?; - options["status"] = "ok".into(); - options["errorMessage"] = "".into(); - - Ok(Json(json!({ - "options": options, - "token": token, - "object": "webauthnCredentialCreateOptions" - }))) -} - -#[post("/webauthn/assertion-options", data = "")] -async fn post_api_webauthn_assertion_options( - data: Json, - headers: Headers, - conn: DbConn, -) -> JsonResult { - if !CONFIG.is_webauthn_2fa_supported() { - err!("Configured `DOMAIN` is not compatible with Webauthn") - } - - crate::ratelimit::check_limit_login(&headers.ip.ip)?; - - let data: PasswordOrOtpData = data.into_inner(); - let user = headers.user; - - if CONFIG.sso_enabled() && CONFIG.sso_only() { - err!("Passkeys cannot be updated when SSO sign-in is required") - } - - 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| serde_json::from_str(&wac.credential).ok()) - .collect(); - - if credentials.is_empty() { - err!("No PRF-capable passkeys registered") - } - - let (response, state) = WEBAUTHN.start_passkey_authentication(&credentials)?; - - // Atomically drop any abandoned challenge from a previous attempt — see - // the comment on `post_api_webauthn_attestation_options`. - TwoFactor::take_by_user_and_type(&user.uuid, TwoFactorType::WebauthnPasskeyAssertionChallenge as i32, &conn) - .await?; - - let token = get_uuid(); - let saved_challenge = WebAuthnPasskeyAssertionChallenge { - token: token.clone(), - created_at: Utc::now().timestamp(), - user_security_stamp: user.security_stamp, - state, - }; - TwoFactor::new( - user.uuid, - TwoFactorType::WebauthnPasskeyAssertionChallenge, - serde_json::to_string(&saved_challenge)?, - ) - .save(&conn) - .await?; - - Ok(Json(json!({ - "options": response.public_key, - "token": token, - "object": "webAuthnLoginAssertionOptions" - }))) -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct WebAuthnLoginCredentialCreateRequest { - device_response: RegisterPublicKeyCredentialCopy, - 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<'_>, -) -> ApiResult { - crate::ratelimit::check_limit_login(&headers.ip.ip)?; - - let data: WebAuthnLoginCredentialCreateRequest = data.into_inner(); - let user = headers.user; - - // Same gate the `*-options` endpoints use (lines 333, 423). Without it, a - // misconfigured `DOMAIN` that passes startup's `http://` prefix check but - // fails `Url::parse` panics on the `WEBAUTHN` `LazyLock` initializer below. - if !CONFIG.is_webauthn_2fa_supported() { - err!("Webauthn is not supported on this server. Set `DOMAIN` to a valid URL with a parseable host.") - } - - if CONFIG.sso_enabled() && CONFIG.sso_only() { - err!("Passkeys cannot be created when SSO sign-in is required") - } - - // Atomically take the saved challenge state (single-use): concurrent - // finishes for the same registration row cannot both succeed and create - // duplicate `web_authn_credentials` entries — only the caller whose DELETE - // removes the row proceeds. - let Some(mut current_user) = User::find_by_uuid(&user.uuid, &conn).await else { - err!("User not found") - }; - - let type_ = TwoFactorType::WebauthnPasskeyRegisterChallenge as i32; - let Some(tf) = TwoFactor::take_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)?; - let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?; - let credential_id_hash = passkey_credential_id_hash(&credential); - - // Duplicate detection is enforced by the `web_authn_credentials` table's - // unique index on `credential_id_hash`: `WebAuthnCredential::save` below - // maps the resulting `UniqueViolation` to "Passkey is already registered", - // so an explicit pre-check would just double the queries on the happy - // path and (because it collapses transient read errors to `false`) is - // not even a reliable guard. - - WebAuthnCredential::new( - current_user.uuid.clone(), - data.name, - serde_json::to_string(&credential)?, - credential_id_hash, - data.supports_prf, - data.encrypted_user_key, - data.encrypted_public_key, - data.encrypted_private_key, - ) - .save(&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) -} - -#[derive(Debug, 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 { - crate::ratelimit::check_limit_login(&headers.ip.ip)?; - - let data: WebAuthnLoginCredentialUpdateRequest = data.into_inner(); - let user = headers.user; - - // Same gate the `*-options` endpoints use (lines 333, 423). Without it, a - // misconfigured `DOMAIN` that passes startup's `http://` prefix check but - // fails `Url::parse` panics on the `WEBAUTHN` `LazyLock` initializer below. - if !CONFIG.is_webauthn_2fa_supported() { - err!("Webauthn is not supported on this server. Set `DOMAIN` to a valid URL with a parseable host.") - } - - if CONFIG.sso_enabled() && CONFIG.sso_only() { - err!("Passkeys cannot be updated when SSO sign-in is required") - } - - 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") - }; - - // Atomically take the saved challenge state (single-use): concurrent - // updates for the same assertion row cannot both succeed and apply - // different blob payloads — only the caller whose DELETE removes the row - // proceeds. - 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::take_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)?; - - let credential_response = data.device_response.into(); - let mut parsed_credentials: Vec<(WebAuthnCredential, Passkey)> = - WebAuthnCredential::find_by_user(¤t_user.uuid, &conn) - .await? - .into_iter() - .filter_map(|wac| { - let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?; - Some((wac, passkey)) - }) - .collect(); - - if parsed_credentials.is_empty() { - err!("No passkeys registered") - } - - let authentication_result = WEBAUTHN.finish_passkey_authentication(&credential_response, &state)?; - let Some((matched_wac, passkey)) = parsed_credentials - .iter_mut() - .find(|(_, passkey)| crypto::ct_eq(passkey.cred_id().as_slice(), authentication_result.cred_id().as_slice())) - else { - err!("Verified credential is not registered") - }; - - if !matched_wac.supports_prf { - err!("Passkey does not support PRF") - } - - if passkey.update_credential(&authentication_result) == Some(true) { - matched_wac.credential = serde_json::to_string(passkey)?; - matched_wac.update_credential(&conn).await?; - } - - 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_keyset(&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) -} - -// NOTE: unlike the four enrol/update entry points above, this delete handler -// is intentionally NOT gated on `sso_enabled() && sso_only()`. SSO_ONLY restricts -// what a user can ADD to their own account — deletion only narrows capability -// (revoke a credential, never grants one), so leaving it accessible under -// SSO_ONLY lets an SSO-authenticated user clean up credentials that were -// enrolled when the policy was different. The session is still -// SSO-authenticated by the time this handler runs, so there's no auth bypass. -#[post("/webauthn//delete", data = "")] -async fn post_api_webauthn_delete( - data: Json, - uuid: WebAuthnCredentialId, - 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?; - - WebAuthnCredential::delete_by_uuid_and_user(&uuid, &user.uuid, &conn).await?; - - user.update_revision(&conn).await?; - nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await; - - Ok(Status::Ok) -} - #[cfg(test)] mod tests { use super::*; - use webauthn_rs::prelude::{ - AttestationFormat, COSEAlgorithm, COSEEC2Key, COSEKey, COSEKeyType, Credential, ECDSACurve, ParsedAttestation, - Url, Webauthn, WebauthnBuilder, - }; - use webauthn_rs_proto::{AuthenticatorTransport, RegisteredExtensions}; - - fn webauthn() -> Webauthn { - let origin = Url::parse("http://localhost").unwrap(); - WebauthnBuilder::new("localhost", &origin).unwrap().rp_name("localhost").build().unwrap() - } - - fn passkey() -> Passkey { - Credential { - cred_id: [1, 2, 3, 4].into(), - cred: COSEKey { - type_: COSEAlgorithm::ES256, - key: COSEKeyType::EC_EC2(COSEEC2Key { - curve: ECDSACurve::SECP256R1, - x: [1; 32].into(), - y: [2; 32].into(), - }), - }, - counter: 0, - transports: Some(vec![AuthenticatorTransport::Internal, AuthenticatorTransport::Hybrid]), - user_verified: true, - backup_eligible: false, - backup_state: false, - registration_policy: UserVerificationPolicy::Required, - extensions: RegisteredExtensions::none(), - attestation: ParsedAttestation::default(), - attestation_format: AttestationFormat::None, - } - .into() - } - - fn registration_state() -> PasskeyRegistration { - let user_uuid = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000000").unwrap(); - let (_challenge, state) = - webauthn().start_passkey_registration(user_uuid, "user@example.com", "user", None).unwrap(); - state - } - - #[test] - fn registration_challenge_accepts_wrapped_state_with_matching_token() { - let saved = WebAuthnPasskeyRegistrationChallenge { - token: String::from("token"), - created_at: Utc::now().timestamp(), - user_security_stamp: String::from("stamp"), - state: registration_state(), - }; - let data = serde_json::to_string(&saved).unwrap(); - - assert!(passkey_registration_challenge_state(&data, Some("token"), "stamp").is_ok()); - } - - #[test] - fn registration_challenge_rejects_wrapped_state_without_matching_token() { - let saved = WebAuthnPasskeyRegistrationChallenge { - token: String::from("token"), - created_at: Utc::now().timestamp(), - user_security_stamp: String::from("stamp"), - state: registration_state(), - }; - let data = serde_json::to_string(&saved).unwrap(); - - assert!(passkey_registration_challenge_state(&data, Some("wrong"), "stamp").is_err()); - assert!(passkey_registration_challenge_state(&data, None, "stamp").is_err()); - } - - #[test] - fn registration_challenge_rejects_expired_state() { - let saved = WebAuthnPasskeyRegistrationChallenge { - token: String::from("token"), - created_at: Utc::now().timestamp() - WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS - 1, - user_security_stamp: String::from("stamp"), - state: registration_state(), - }; - let data = serde_json::to_string(&saved).unwrap(); - - assert!(passkey_registration_challenge_state(&data, Some("token"), "stamp").is_err()); - } - - #[test] - fn registration_challenge_rejects_stale_account_revision() { - let saved = WebAuthnPasskeyRegistrationChallenge { - token: String::from("token"), - created_at: Utc::now().timestamp(), - user_security_stamp: String::from("old-stamp"), - state: registration_state(), - }; - let data = serde_json::to_string(&saved).unwrap(); - - assert!(passkey_registration_challenge_state(&data, Some("token"), "new-stamp").is_err()); - } - - /// `passkey_registration_challenge_state` has no legacy unwrapped fallback — - /// the only writer is the attestation-options endpoint, and it always - /// persists the `{token, state}` wrapper. A bare `PasskeyRegistration` - /// blob in `twofactor.data` (corrupted row, hand-crafted attack) must - /// be rejected regardless of whether a token is sent — accepting it - /// without a token would let an attacker bypass the token-binding - /// check by writing the wrong shape. - #[test] - fn registration_challenge_rejects_unwrapped_legacy_state() { - let data = serde_json::to_string(®istration_state()).unwrap(); - - assert!(passkey_registration_challenge_state(&data, None, "stamp").is_err()); - assert!(passkey_registration_challenge_state(&data, Some("any-token"), "stamp").is_err()); - assert!(passkey_registration_challenge_state(&data, Some(""), "stamp").is_err()); - } - - #[test] - fn assertion_challenge_rejects_mismatched_token() { - let (_response, state) = webauthn().start_passkey_authentication(&[passkey()]).unwrap(); - let saved = WebAuthnPasskeyAssertionChallenge { - token: String::from("token"), - created_at: Utc::now().timestamp(), - user_security_stamp: String::from("stamp"), - state, - }; - let data = serde_json::to_string(&saved).unwrap(); - - assert!(passkey_assertion_challenge_state(&data, "token", "stamp").is_ok()); - assert!(passkey_assertion_challenge_state(&data, "wrong", "stamp").is_err()); - } - - #[test] - fn assertion_challenge_rejects_expired_state() { - let (_response, state) = webauthn().start_passkey_authentication(&[passkey()]).unwrap(); - let saved = WebAuthnPasskeyAssertionChallenge { - token: String::from("token"), - created_at: Utc::now().timestamp() - WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS - 1, - user_security_stamp: String::from("stamp"), - state, - }; - let data = serde_json::to_string(&saved).unwrap(); - - assert!(passkey_assertion_challenge_state(&data, "token", "stamp").is_err()); - } - - #[test] - fn assertion_challenge_rejects_stale_account_revision() { - let (_response, state) = webauthn().start_passkey_authentication(&[passkey()]).unwrap(); - let saved = WebAuthnPasskeyAssertionChallenge { - token: String::from("token"), - created_at: Utc::now().timestamp(), - user_security_stamp: String::from("old-stamp"), - state, - }; - let data = serde_json::to_string(&saved).unwrap(); - - assert!(passkey_assertion_challenge_state(&data, "token", "new-stamp").is_err()); - } - - #[test] - fn passkey_credential_id_hash_uses_raw_credential_id_bytes() { - assert_eq!( - passkey_credential_id_hash(&passkey()), - "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a" - ); - } - - fn passkey_with_cred_id(cred_id: &[u8]) -> Passkey { - Credential { - cred_id: cred_id.to_vec().into(), - cred: COSEKey { - type_: COSEAlgorithm::ES256, - key: COSEKeyType::EC_EC2(COSEEC2Key { - curve: ECDSACurve::SECP256R1, - x: [1; 32].into(), - y: [2; 32].into(), - }), - }, - counter: 0, - transports: None, - user_verified: true, - backup_eligible: false, - backup_state: false, - registration_policy: UserVerificationPolicy::Required, - extensions: RegisteredExtensions::none(), - attestation: ParsedAttestation::default(), - attestation_format: AttestationFormat::None, - } - .into() - } - - #[test] - fn passkey_credential_id_hash_is_deterministic() { - let cred_id: &[u8] = &[10, 20, 30, 40, 50]; - assert_eq!( - passkey_credential_id_hash(&passkey_with_cred_id(cred_id)), - passkey_credential_id_hash(&passkey_with_cred_id(cred_id)), - ); - } - - #[test] - fn passkey_credential_id_hash_distinguishes_different_credentials() { - let a = passkey_credential_id_hash(&passkey_with_cred_id(&[1, 2, 3, 4])); - let b = passkey_credential_id_hash(&passkey_with_cred_id(&[4, 3, 2, 1])); - let c = passkey_credential_id_hash(&passkey_with_cred_id(&[1, 2, 3])); - assert_ne!(a, b, "different bytes must produce different hashes"); - assert_ne!(a, c, "different lengths must produce different hashes"); - assert_ne!(b, c); - } - - /// `passkey_assertion_challenge_state` has no legacy unwrapped fallback — - /// the assertion-options endpoint was introduced together with the - /// wrapping struct, so any persisted state must carry the binding token. - #[test] - fn assertion_challenge_rejects_unwrapped_legacy_state() { - let (_response, state) = webauthn().start_passkey_authentication(&[passkey()]).unwrap(); - let bare = serde_json::to_string(&state).unwrap(); - - assert!(passkey_assertion_challenge_state(&bare, "any-token", "stamp").is_err()); - assert!(passkey_assertion_challenge_state(&bare, "", "stamp").is_err()); - } /// `build_feature_states` must emit `pm-2035-passkey-unlock = true` when /// account passkeys are allowed; without it, the web vault's @@ -970,8 +273,12 @@ fn build_feature_states( #[get("/config")] fn config() -> Json { let domain = CONFIG.domain(); - let feature_states = - build_feature_states(&CONFIG.experimental_client_feature_flags(), !(CONFIG.sso_enabled() && CONFIG.sso_only())); + // The pm-2035 unlock affordance is meaningless on a DOMAIN that webauthn + // can't bind to (IP literal, missing host) — every passkey endpoint + // refuses up front, so advertising the lock-screen button would render + // an option that always 4xxs. Gating the feature-flag here keeps the + // client UI consistent with the server's actual capabilities. + let feature_states = build_feature_states(&CONFIG.experimental_client_feature_flags(), account_passkeys_allowed()); Json(json!({ // Note: The clients use this version to handle backwards compatibility concerns diff --git a/src/api/core/passkeys.rs b/src/api/core/passkeys.rs new file mode 100644 index 00000000..2f21a636 --- /dev/null +++ b/src/api/core/passkeys.rs @@ -0,0 +1,953 @@ +use chrono::Utc; +use rocket::{Route, http::Status, serde::json::Json, serde::json::Value}; +use webauthn_rs::prelude::{ + CreationChallengeResponse, Credential as WebauthnCredentialData, Passkey, PasskeyAuthentication, + PasskeyRegistration, +}; +use webauthn_rs_proto::{ExtnState, RequestRegistrationExtensions, UserVerificationPolicy}; + +use crate::{ + CONFIG, + api::{ + ApiResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, + core::two_factor::webauthn::{PublicKeyCredentialCopy, RegisterPublicKeyCredentialCopy, WEBAUTHN}, + }, + auth::Headers, + crypto, + db::{ + DbConn, + models::{TwoFactor, TwoFactorType, User, WebAuthnCredential, WebAuthnCredentialId}, + }, + error::Error, + util::get_uuid, +}; + +const WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS: i64 = 300; +const WEBAUTHN_PASSKEY_CHALLENGE_CLOCK_SKEW_SECONDS: i64 = 30; +// Bitwarden currently caps account-login passkeys at five per user. +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(|wac| { + json!({ + "id": wac.uuid, + "name": wac.name, + // 0 = Enabled, 1 = Supported (PRF-capable, keyset not set up), 2 = Unsupported. + "prfStatus": wac.prf_status(), + "encryptedUserKey": wac.encrypted_user_key, + "encryptedPublicKey": wac.encrypted_public_key, + "object": "webauthnCredential", + }) + }) + .collect(); + + Ok(Json(json!({ + "object": "list", + "data": data, + "continuationToken": null + }))) +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WebAuthnPasskeyRegistrationChallenge { + token: String, + created_at: i64, + user_security_stamp: String, + state: PasskeyRegistration, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WebAuthnPasskeyAssertionChallenge { + token: String, + created_at: i64, + user_security_stamp: String, + state: PasskeyAuthentication, +} + +fn passkey_management_challenge_is_fresh(created_at: i64) -> bool { + // The timestamp is server-stamped, so a future value only happens after a + // clock step backwards or manual DB tampering. Allow a small skew window + // for harmless corrections, but reject anything beyond it so a pre-step + // challenge cannot remain valid for longer than the documented TTL. + passkey_management_challenge_is_fresh_at(created_at, Utc::now().timestamp()) +} + +fn passkey_management_challenge_is_fresh_at(created_at: i64, now: i64) -> bool { + crate::util::is_within_freshness_window( + created_at, + now, + WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS, + WEBAUTHN_PASSKEY_CHALLENGE_CLOCK_SKEW_SECONDS, + ) +} + +fn passkey_registration_challenge_state( + data: &str, + token: Option<&str>, + user_security_stamp: &str, +) -> ApiResult { + // Persisted challenge rows are always the `{token, state}` wrapper — + // nothing in the current code path writes the bare `PasskeyRegistration` + // shape. Reject a row that doesn't deserialise (corrupted, stale schema) + // with the same generic message we use for token mismatch, rather than + // falling through to an un-tokened legacy path. + 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.") + } + Ok(saved.state) +} + +fn passkey_assertion_challenge_state( + data: &str, + token: &str, + user_security_stamp: &str, +) -> ApiResult { + // Same shape contract as `passkey_registration_challenge_state` above — + // reject undecodable rows with the generic message rather than leaking + // the underlying serde error. + 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.") + } + 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.sso_enabled() && CONFIG.sso_only()) && CONFIG.is_webauthn_2fa_supported() +} + +fn request_passkey_prf_extension( + mut challenge: CreationChallengeResponse, + state: &PasskeyRegistration, +) -> ApiResult<(CreationChallengeResponse, PasskeyRegistration)> { + challenge.public_key.extensions.get_or_insert_with(RequestRegistrationExtensions::default).hmac_create_secret = + Some(true); + + let mut state_value = serde_json::to_value(state)?; + let Some(extensions) = + state_value.get_mut("rs").and_then(|rs| rs.get_mut("extensions")).and_then(Value::as_object_mut) + else { + return Err(Error::new("Invalid passkey registration state", "Missing WebAuthn registration extensions")); + }; + extensions.insert("hmacCreateSecret".to_owned(), Value::Bool(true)); + + let state = serde_json::from_value(state_value)?; + Ok((challenge, state)) +} + +fn passkey_supports_prf(passkey: &Passkey) -> bool { + let credential: WebauthnCredentialData = passkey.clone().into(); + matches!(credential.extensions.hmac_create_secret, ExtnState::Set(true)) +} + +type PasskeyRegistrationPrfData = (bool, Option, Option, Option); + +fn passkey_registration_prf_data( + client_supports_prf: bool, + encrypted_user_key: Option, + encrypted_public_key: Option, + encrypted_private_key: Option, + server_supports_prf: bool, +) -> ApiResult { + let supports_prf = client_supports_prf || server_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((false, None, None, None)); + } + + // Chromium/CDP does not consistently reflect the registration PRF + // extension in the attested credential, but the web vault still reports + // whether the browser ceremony supports PRF. Store that client capability + // signal; only the presence of wrapped key blobs controls unlock. + if !has_key_material { + return Ok((true, None, None, 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((true, Some(encrypted_user_key), Some(encrypted_public_key), Some(encrypted_private_key))) +} + +/// Gates every passkey-management entry point in this module on the same +/// three preconditions: +/// • `check_limit_login` — IP-level rate limit shared with the password +/// login path. Runs FIRST so a misconfigured DOMAIN can't be turned +/// into an uncapped error-log generator: every refused request would +/// otherwise short-circuit on `is_webauthn_2fa_supported` without +/// consuming a rate-limit token. +/// • `is_webauthn_2fa_supported` — refuses cleanly when DOMAIN is +/// incompatible with WebAuthn (must run before the `WEBAUTHN` LazyLock +/// is touched, which would otherwise panic). +/// • SSO_ONLY — refuses passkey mutations when the operator has required +/// SSO sign-in. +/// +/// `action_verb` parameterises the SSO refusal message between the create +/// and update endpoints ("created" / "updated"). The delete endpoint is +/// intentionally NOT gated — see the comment on `post_api_webauthn_delete`. +pub(crate) fn check_passkey_endpoint_preconditions(ip: &std::net::IpAddr, action_verb: &str) -> ApiResult<()> { + crate::ratelimit::check_limit_login(ip)?; + if !CONFIG.is_webauthn_2fa_supported() { + err!("Webauthn is not supported on this server. Set `DOMAIN` to a valid URL with a parseable host.") + } + if CONFIG.sso_enabled() && CONFIG.sso_only() { + err!(format!("Passkeys cannot be {action_verb} when SSO sign-in is required")) + } + 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| { + let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?; + Some(passkey.cred_id().to_owned()) + }) + .collect(); + + let user_uuid = uuid::Uuid::parse_str(&user.uuid) + .map_err(|_| Error::new("Invalid user", "Could not parse user UUID for passkey registration"))?; + + let (challenge, state) = + WEBAUTHN.start_passkey_registration(user_uuid, &user.email, user.display_name(), Some(existing_cred_ids))?; + let (mut challenge, state) = request_passkey_prf_extension(challenge, &state)?; + + // Ask the client for a discoverable (resident) credential with UV. + // `start_passkey_registration` already pins UV=Required in `state`; + // resident-key is NOT enforced server-side by webauthn-rs, so this is a + // client-side hint on the challenge JSON only. A non-resident credential + // would still be accepted here but later fail discoverable-login, so + // tampering clients only hurt themselves. + 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); + } + + // Atomically drop any abandoned challenge from a previous, unfinished + // registration attempt so only one in-flight challenge state per user + // exists at any time. + TwoFactor::take_by_user_and_type(&user.uuid, TwoFactorType::WebauthnPasskeyRegisterChallenge as i32, &conn).await?; + + let token = get_uuid(); + let saved_challenge = WebAuthnPasskeyRegistrationChallenge { + token: token.clone(), + created_at: Utc::now().timestamp(), + user_security_stamp: user.security_stamp, + state, + }; + + // Persist the registration state in the database (same pattern as 2FA webauthn) + TwoFactor::new( + user.uuid, + TwoFactorType::WebauthnPasskeyRegisterChallenge, + serde_json::to_string(&saved_challenge)?, + ) + .save(&conn) + .await?; + + let mut options = serde_json::to_value(challenge.public_key)?; + options["status"] = "ok".into(); + options["errorMessage"] = "".into(); + + Ok(Json(json!({ + "options": options, + "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| serde_json::from_str(&wac.credential).ok()) + .collect(); + + if credentials.is_empty() { + err!("No PRF-capable passkeys registered") + } + + let (response, state) = WEBAUTHN.start_passkey_authentication(&credentials)?; + + // Atomically drop any abandoned challenge from a previous attempt — see + // the comment on `post_api_webauthn_attestation_options`. + TwoFactor::take_by_user_and_type(&user.uuid, TwoFactorType::WebauthnPasskeyAssertionChallenge as i32, &conn) + .await?; + + let token = get_uuid(); + let saved_challenge = WebAuthnPasskeyAssertionChallenge { + token: token.clone(), + created_at: Utc::now().timestamp(), + user_security_stamp: user.security_stamp, + state, + }; + TwoFactor::new( + user.uuid, + TwoFactorType::WebauthnPasskeyAssertionChallenge, + serde_json::to_string(&saved_challenge)?, + ) + .save(&conn) + .await?; + + Ok(Json(json!({ + "options": response.public_key, + "token": token, + "object": "webAuthnLoginAssertionOptions" + }))) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WebAuthnLoginCredentialCreateRequest { + device_response: RegisterPublicKeyCredentialCopy, + 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<'_>, +) -> ApiResult { + check_passkey_endpoint_preconditions(&headers.ip.ip, "created")?; + + let data: WebAuthnLoginCredentialCreateRequest = data.into_inner(); + let user = headers.user; + + // Atomically take the saved challenge state (single-use): concurrent + // finishes for the same registration row cannot both succeed and create + // duplicate `web_authn_credentials` entries — only the caller whose DELETE + // removes the row proceeds. + let Some(mut current_user) = User::try_find_by_uuid(&user.uuid, &conn).await? else { + err!("User not found") + }; + + if passkey_count_limit_reached(WebAuthnCredential::count_by_user(¤t_user.uuid, &conn).await?) { + err!("Maximum number of passkeys reached") + } + + let type_ = TwoFactorType::WebauthnPasskeyRegisterChallenge as i32; + let Some(tf) = TwoFactor::take_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)?; + let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?; + let credential_id_hash = passkey_credential_id_hash(credential.cred_id().as_slice()); + let (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, + passkey_supports_prf(&credential), + )?; + + // Duplicate detection rests on the UNIQUE `(user_uuid, credential_id_hash)` + // index: `save_with_user_limit` below maps the `UniqueViolation` to + // "Passkey is already registered". Scoping it per-user means a cross-account + // hash collision (trivial if an attacker echoes an observed cred_id) inserts + // cleanly without signalling that another account holds that hash. + + WebAuthnCredential::new( + current_user.uuid.clone(), + data.name, + serde_json::to_string(&credential)?, + credential_id_hash, + supports_prf, + encrypted_user_key, + encrypted_public_key, + encrypted_private_key, + ) + .save_with_user_limit(MAX_WEBAUTHN_CREDENTIALS, &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) +} + +#[derive(Debug, 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") + }; + + // Atomically take the saved challenge state (single-use): concurrent + // updates for the same assertion row cannot both succeed and apply + // different blob payloads — only the caller whose DELETE removes the row + // proceeds. + let Some(mut current_user) = User::try_find_by_uuid(&user.uuid, &conn).await? else { + err!("User not found") + }; + + let type_ = TwoFactorType::WebauthnPasskeyAssertionChallenge as i32; + let Some(tf) = TwoFactor::take_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)?; + + let credential_response = data.device_response.into(); + + // Verify the assertion against the saved challenge state. `state` + // already carries the credential set the challenge was issued against, + // so we don't need to pass credentials again here. After verification + // we know the exact cred_id and can index directly into the + // credential table via the per-user UNIQUE on credential_id_hash — + // avoiding the full passkey-set scan + N JSON parses the previous + // shape did. + let authentication_result = WEBAUTHN.finish_passkey_authentication(&credential_response, &state)?; + 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 mut passkey: Passkey = serde_json::from_str(&matched_wac.credential)?; + + // Persist the (optional) signature-counter advance and the PRF keyset + // together. The assertion challenge was atomically consumed via + // `take_by_user_and_type` above, so a half-applied state would block + // any retry — the helper folds both writes into a single UPDATE. + // + // `advanced_counter` gates the `credential` column write. Passing `false` + // when the counter did not advance avoids clobbering a counter blob a + // parallel replica may have just persisted via `webauthn_login`'s + // counter advance (the per-process DashMap lock does not serialise + // across replicas). The helper surfaces 0-rows as a Simple error so a + // concurrent DELETE doesn't yield a misleading 200 OK with no row. + let advanced_counter = passkey.update_credential(&authentication_result) == Some(true); + 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_credential_and_prf_keyset(advanced_counter, &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) +} + +// Intentionally NOT gated on SSO_ONLY or DOMAIN-misconfigured: delete +// narrows capability (revokes, never grants), the session is still +// SSO-authenticated when this handler runs, and delete never touches the +// `WEBAUTHN` LazyLock so DOMAIN parseability is irrelevant. Lets users +// clean up credentials regardless of later deployment-config changes. +#[post("/webauthn//delete", data = "")] +async fn post_api_webauthn_delete( + data: Json, + uuid: WebAuthnCredentialId, + 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?; + + WebAuthnCredential::delete_by_uuid_and_user(&uuid, &user.uuid, &conn).await?; + + user.update_revision(&conn).await?; + nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await; + + Ok(Status::Ok) +} + +#[cfg(test)] +mod tests { + use super::*; + use webauthn_rs::prelude::{ + AttestationFormat, COSEAlgorithm, COSEEC2Key, COSEKey, COSEKeyType, Credential, ECDSACurve, ParsedAttestation, + Url, Webauthn, WebauthnBuilder, + }; + use webauthn_rs_proto::{AuthenticatorTransport, RegisteredExtensions}; + + fn webauthn() -> Webauthn { + let origin = Url::parse("http://localhost").unwrap(); + WebauthnBuilder::new("localhost", &origin).unwrap().rp_name("localhost").build().unwrap() + } + + fn passkey() -> Passkey { + Credential { + cred_id: [1, 2, 3, 4].into(), + cred: COSEKey { + type_: COSEAlgorithm::ES256, + key: COSEKeyType::EC_EC2(COSEEC2Key { + curve: ECDSACurve::SECP256R1, + x: [1; 32].into(), + y: [2; 32].into(), + }), + }, + counter: 0, + transports: Some(vec![AuthenticatorTransport::Internal, AuthenticatorTransport::Hybrid]), + user_verified: true, + backup_eligible: false, + backup_state: false, + registration_policy: UserVerificationPolicy::Required, + extensions: RegisteredExtensions::none(), + attestation: ParsedAttestation::default(), + attestation_format: AttestationFormat::None, + } + .into() + } + + fn registration_state() -> PasskeyRegistration { + let user_uuid = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000000").unwrap(); + let (_challenge, state) = + webauthn().start_passkey_registration(user_uuid, "user@example.com", "user", None).unwrap(); + state + } + + fn passkey_with_hmac_secret_state(hmac_create_secret: ExtnState) -> Passkey { + let mut extensions = RegisteredExtensions::none(); + extensions.hmac_create_secret = hmac_create_secret; + + Credential { + cred_id: [1, 2, 3, 4].into(), + cred: COSEKey { + type_: COSEAlgorithm::ES256, + key: COSEKeyType::EC_EC2(COSEEC2Key { + curve: ECDSACurve::SECP256R1, + x: [1; 32].into(), + y: [2; 32].into(), + }), + }, + counter: 0, + transports: None, + user_verified: true, + backup_eligible: false, + backup_state: false, + registration_policy: UserVerificationPolicy::Required, + extensions, + attestation: ParsedAttestation::default(), + attestation_format: AttestationFormat::None, + } + .into() + } + + #[test] + fn request_passkey_prf_extension_marks_challenge_and_stored_state() { + let user_uuid = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000000").unwrap(); + let (challenge, state) = + webauthn().start_passkey_registration(user_uuid, "user@example.com", "user", None).unwrap(); + + let (challenge, state) = request_passkey_prf_extension(challenge, &state).unwrap(); + + assert_eq!(challenge.public_key.extensions.as_ref().and_then(|e| e.hmac_create_secret), Some(true)); + assert_eq!(serde_json::to_value(&state).unwrap()["rs"]["extensions"]["hmacCreateSecret"], Value::Bool(true)); + } + + #[test] + fn passkey_supports_prf_only_when_requested_extension_was_set() { + assert!(passkey_supports_prf(&passkey_with_hmac_secret_state(ExtnState::Set(true)))); + assert!(!passkey_supports_prf(&passkey_with_hmac_secret_state(ExtnState::Set(false)))); + assert!(!passkey_supports_prf(&passkey_with_hmac_secret_state(ExtnState::Ignored))); + assert!(!passkey_supports_prf(&passkey_with_hmac_secret_state(ExtnState::Unsolicited(true)))); + assert!(!passkey_supports_prf(&passkey_with_hmac_secret_state(ExtnState::NotRequested))); + } + + #[test] + fn passkey_registration_prf_data_trusts_client_prf_support_when_keyset_is_complete() { + assert_eq!( + passkey_registration_prf_data( + true, + Some("user-key".to_owned()), + Some("public-key".to_owned()), + Some("private-key".to_owned()), + false, + ) + .unwrap(), + (true, Some("user-key".to_owned()), Some("public-key".to_owned()), Some("private-key".to_owned()),) + ); + } + + #[test] + fn passkey_registration_prf_data_requires_complete_keyset_when_any_prf_key_is_sent() { + assert!( + passkey_registration_prf_data( + true, + None, + Some("public-key".to_owned()), + Some("private-key".to_owned()), + true + ) + .is_err() + ); + assert!( + passkey_registration_prf_data( + true, + Some("user-key".to_owned()), + None, + Some("private-key".to_owned()), + true + ) + .is_err() + ); + assert!( + passkey_registration_prf_data(true, Some("user-key".to_owned()), Some("public-key".to_owned()), None, true) + .is_err() + ); + + assert_eq!( + passkey_registration_prf_data( + true, + Some("user-key".to_owned()), + Some("public-key".to_owned()), + Some("private-key".to_owned()), + true, + ) + .unwrap(), + (true, Some("user-key".to_owned()), Some("public-key".to_owned()), Some("private-key".to_owned()),) + ); + } + + #[test] + fn passkey_registration_prf_data_records_prf_support_even_without_client_keyset() { + assert_eq!(passkey_registration_prf_data(false, None, None, None, true).unwrap(), (true, None, None, None)); + assert_eq!(passkey_registration_prf_data(true, None, None, None, false).unwrap(), (true, None, None, None)); + assert_eq!(passkey_registration_prf_data(false, None, None, None, false).unwrap(), (false, None, None, None)); + } + + #[test] + fn passkey_registration_prf_data_rejects_key_material_without_prf_support() { + assert!( + passkey_registration_prf_data( + false, + Some("user-key".to_owned()), + Some("public-key".to_owned()), + Some("private-key".to_owned()), + false, + ) + .is_err() + ); + } + + #[test] + fn passkey_count_limit_matches_bitwarden_account_passkey_cap() { + assert!(!passkey_count_limit_reached(MAX_WEBAUTHN_CREDENTIALS - 1)); + assert!(passkey_count_limit_reached(MAX_WEBAUTHN_CREDENTIALS)); + assert!(passkey_count_limit_reached(MAX_WEBAUTHN_CREDENTIALS + 1)); + } + + #[test] + fn registration_challenge_accepts_wrapped_state_with_matching_token() { + let saved = WebAuthnPasskeyRegistrationChallenge { + token: String::from("token"), + created_at: Utc::now().timestamp(), + user_security_stamp: String::from("stamp"), + state: registration_state(), + }; + let data = serde_json::to_string(&saved).unwrap(); + + assert!(passkey_registration_challenge_state(&data, Some("token"), "stamp").is_ok()); + } + + #[test] + fn registration_challenge_rejects_wrapped_state_without_matching_token() { + let saved = WebAuthnPasskeyRegistrationChallenge { + token: String::from("token"), + created_at: Utc::now().timestamp(), + user_security_stamp: String::from("stamp"), + state: registration_state(), + }; + let data = serde_json::to_string(&saved).unwrap(); + + assert!(passkey_registration_challenge_state(&data, Some("wrong"), "stamp").is_err()); + assert!(passkey_registration_challenge_state(&data, None, "stamp").is_err()); + } + + #[test] + fn registration_challenge_rejects_expired_state() { + let saved = WebAuthnPasskeyRegistrationChallenge { + token: String::from("token"), + created_at: Utc::now().timestamp() - WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS - 1, + user_security_stamp: String::from("stamp"), + state: registration_state(), + }; + let data = serde_json::to_string(&saved).unwrap(); + + assert!(passkey_registration_challenge_state(&data, Some("token"), "stamp").is_err()); + } + + #[test] + fn registration_challenge_rejects_stale_account_revision() { + let saved = WebAuthnPasskeyRegistrationChallenge { + token: String::from("token"), + created_at: Utc::now().timestamp(), + user_security_stamp: String::from("old-stamp"), + state: registration_state(), + }; + let data = serde_json::to_string(&saved).unwrap(); + + assert!(passkey_registration_challenge_state(&data, Some("token"), "new-stamp").is_err()); + } + + /// `passkey_registration_challenge_state` has no legacy unwrapped fallback — + /// the only writer is the attestation-options endpoint, and it always + /// persists the `{token, state}` wrapper. A bare `PasskeyRegistration` + /// blob in `twofactor.data` (corrupted row, hand-crafted attack) must + /// be rejected regardless of whether a token is sent — accepting it + /// without a token would let an attacker bypass the token-binding + /// check by writing the wrong shape. + #[test] + fn registration_challenge_rejects_unwrapped_legacy_state() { + let data = serde_json::to_string(®istration_state()).unwrap(); + + assert!(passkey_registration_challenge_state(&data, None, "stamp").is_err()); + assert!(passkey_registration_challenge_state(&data, Some("any-token"), "stamp").is_err()); + assert!(passkey_registration_challenge_state(&data, Some(""), "stamp").is_err()); + } + + #[test] + fn assertion_challenge_rejects_mismatched_token() { + let (_response, state) = webauthn().start_passkey_authentication(&[passkey()]).unwrap(); + let saved = WebAuthnPasskeyAssertionChallenge { + token: String::from("token"), + created_at: Utc::now().timestamp(), + user_security_stamp: String::from("stamp"), + state, + }; + let data = serde_json::to_string(&saved).unwrap(); + + assert!(passkey_assertion_challenge_state(&data, "token", "stamp").is_ok()); + assert!(passkey_assertion_challenge_state(&data, "wrong", "stamp").is_err()); + } + + #[test] + fn assertion_challenge_rejects_expired_state() { + let (_response, state) = webauthn().start_passkey_authentication(&[passkey()]).unwrap(); + let saved = WebAuthnPasskeyAssertionChallenge { + token: String::from("token"), + created_at: Utc::now().timestamp() - WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS - 1, + user_security_stamp: String::from("stamp"), + state, + }; + let data = serde_json::to_string(&saved).unwrap(); + + assert!(passkey_assertion_challenge_state(&data, "token", "stamp").is_err()); + } + + #[test] + fn assertion_challenge_rejects_stale_account_revision() { + let (_response, state) = webauthn().start_passkey_authentication(&[passkey()]).unwrap(); + let saved = WebAuthnPasskeyAssertionChallenge { + token: String::from("token"), + created_at: Utc::now().timestamp(), + user_security_stamp: String::from("old-stamp"), + state, + }; + let data = serde_json::to_string(&saved).unwrap(); + + assert!(passkey_assertion_challenge_state(&data, "token", "new-stamp").is_err()); + } + + #[test] + fn passkey_credential_id_hash_uses_raw_credential_id_bytes() { + assert_eq!( + passkey_credential_id_hash(passkey().cred_id().as_slice()), + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a" + ); + } + + #[test] + fn passkey_credential_id_hash_is_deterministic() { + let cred_id: &[u8] = &[10, 20, 30, 40, 50]; + assert_eq!(passkey_credential_id_hash(cred_id), passkey_credential_id_hash(cred_id)); + } + + #[test] + fn passkey_credential_id_hash_distinguishes_different_credentials() { + let a = passkey_credential_id_hash(&[1, 2, 3, 4]); + let b = passkey_credential_id_hash(&[4, 3, 2, 1]); + let c = passkey_credential_id_hash(&[1, 2, 3]); + assert_ne!(a, b, "different bytes must produce different hashes"); + assert_ne!(a, c, "different lengths must produce different hashes"); + assert_ne!(b, c); + } + + #[test] + fn passkey_management_challenge_freshness_allows_current_window() { + let now = Utc::now().timestamp(); + + assert!(passkey_management_challenge_is_fresh(now)); + assert!(passkey_management_challenge_is_fresh(now - WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS + 5)); + assert!(passkey_management_challenge_is_fresh(now + WEBAUTHN_PASSKEY_CHALLENGE_CLOCK_SKEW_SECONDS - 5)); + } + + #[test] + fn passkey_management_challenge_freshness_rejects_old_or_far_future_rows() { + let now = Utc::now().timestamp(); + + assert!(!passkey_management_challenge_is_fresh(now - WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS - 1)); + assert!(!passkey_management_challenge_is_fresh(now + WEBAUTHN_PASSKEY_CHALLENGE_CLOCK_SKEW_SECONDS + 1)); + } + + /// Exact-boundary coverage. The production wrapper reads `Utc::now()` + /// inside the function, so a test against `now - TTL` would race the + /// internal clock read and assert FALSE for the row that should be + /// inclusive. `_is_fresh_at` takes `now` as a parameter so the inclusive + /// `>=` / `<=` boundaries are exercised deterministically. + #[test] + fn passkey_management_challenge_freshness_inclusive_at_both_boundaries() { + let now = Utc::now().timestamp(); + + assert!( + passkey_management_challenge_is_fresh_at(now - WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS, now), + "created_at exactly TTL old must remain fresh (`>=` is inclusive)" + ); + assert!( + passkey_management_challenge_is_fresh_at(now + WEBAUTHN_PASSKEY_CHALLENGE_CLOCK_SKEW_SECONDS, now), + "created_at exactly skew seconds ahead must remain fresh (`<=` is inclusive)" + ); + assert!( + !passkey_management_challenge_is_fresh_at(now - WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS - 1, now), + "one second past TTL must reject" + ); + assert!( + !passkey_management_challenge_is_fresh_at(now + WEBAUTHN_PASSKEY_CHALLENGE_CLOCK_SKEW_SECONDS + 1, now), + "one second past skew must reject" + ); + } + + /// `passkey_assertion_challenge_state` has no legacy unwrapped fallback — + /// the assertion-options endpoint was introduced together with the + /// wrapping struct, so any persisted state must carry the binding token. + #[test] + fn assertion_challenge_rejects_unwrapped_legacy_state() { + let (_response, state) = webauthn().start_passkey_authentication(&[passkey()]).unwrap(); + let bare = serde_json::to_string(&state).unwrap(); + + assert!(passkey_assertion_challenge_state(&bare, "any-token", "stamp").is_err()); + assert!(passkey_assertion_challenge_state(&bare, "", "stamp").is_err()); + } +} diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index f0bd912c..f52a098f 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -189,6 +189,8 @@ pub struct RegisterPublicKeyCredentialCopy { pub id: String, pub raw_id: Base64UrlSafeData, pub response: AuthenticatorAttestationResponseRawCopy, + #[serde(default, alias = "clientExtensionResults")] + pub extensions: RegistrationExtensionsClientOutputs, pub r#type: String, } @@ -215,7 +217,7 @@ impl From for RegisterPublicKeyCredential { transports, }, type_: r.r#type, - extensions: RegistrationExtensionsClientOutputs::default(), + extensions: r.extensions, } } } @@ -226,6 +228,7 @@ pub struct PublicKeyCredentialCopy { pub id: String, pub raw_id: Base64UrlSafeData, pub response: AuthenticatorAssertionResponseRawCopy, + #[serde(default, alias = "clientExtensionResults")] pub extensions: AuthenticationExtensionsClientOutputs, pub r#type: String, } @@ -542,6 +545,7 @@ mod tests { client_data_json: Base64UrlSafeData::from([7, 8, 9]), transports: Some(transports.clone()), }, + extensions: RegistrationExtensionsClientOutputs::default(), r#type: String::from("public-key"), }; @@ -560,6 +564,7 @@ mod tests { client_data_json: Base64UrlSafeData::from([7, 8, 9]), transports: None, }, + extensions: RegistrationExtensionsClientOutputs::default(), r#type: String::from("public-key"), }; @@ -567,4 +572,42 @@ mod tests { assert_eq!(converted.response.transports, None); } + + #[test] + fn register_public_key_credential_copy_accepts_missing_extensions() { + let copy = serde_json::from_value::(json!({ + "id": "credential", + "rawId": "AQID", + "response": { + "attestationObject": "BAUG", + "clientDataJson": "BwgJ" + }, + "type": "public-key" + })) + .unwrap(); + + let converted: RegisterPublicKeyCredential = copy.into(); + + assert_eq!(converted.id, "credential"); + } + + #[test] + fn public_key_credential_copy_accepts_client_extension_results_alias() { + let copy = serde_json::from_value::(json!({ + "id": "credential", + "rawId": "AQID", + "response": { + "authenticatorData": "BAUG", + "clientDataJson": "BwgJ", + "signature": "CgsM" + }, + "clientExtensionResults": {}, + "type": "public-key" + })) + .unwrap(); + + let converted: PublicKeyCredential = copy.into(); + + assert_eq!(converted.id, "credential"); + } } diff --git a/src/api/identity.rs b/src/api/identity.rs index 191fffff..3797c171 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -3,12 +3,13 @@ use num_traits::FromPrimitive; use rocket::{ Route, form::{Form, FromForm}, - http::{Cookie, CookieJar, SameSite}, + http::{Cookie, CookieJar, SameSite, Status}, + request::{FromRequest, Outcome, Request}, response::Redirect, serde::json::Json, }; use serde_json::Value; -use webauthn_rs::prelude::{DiscoverableAuthentication, DiscoverableKey, Passkey}; +use webauthn_rs::prelude::{Credential, DiscoverableAuthentication, DiscoverableKey, Passkey}; use webauthn_rs_proto::PublicKeyCredential; use crate::api::core::two_factor::webauthn::{PublicKeyCredentialCopy, WEBAUTHN}; @@ -18,7 +19,7 @@ use crate::{ ApiResult, EmptyResult, JsonResult, core::{ accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register}, - log_user_event, + check_passkey_endpoint_preconditions, log_user_event, passkey_credential_id_hash, two_factor::{ authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, yubikey, @@ -126,15 +127,15 @@ async fn login( } "authorization_code" => err!("SSO sign-in is not available"), "webauthn" => { - check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; - check_is_some(data.scope.as_ref(), "scope cannot be blank")?; + check_is_nonempty(data.client_id.as_ref(), "client_id cannot be blank")?; + check_is_nonempty(data.scope.as_ref(), "scope cannot be blank")?; - check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?; - check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; - check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; + check_is_nonempty(data.device_identifier.as_ref(), "device_identifier cannot be blank")?; + check_is_nonempty(data.device_name.as_ref(), "device_name cannot be blank")?; + check_is_nonempty(data.device_type.as_ref(), "device_type cannot be blank")?; - check_is_some(data.device_response.as_ref(), "device_response cannot be blank")?; - check_is_some(data.token.as_ref(), "token cannot be blank")?; + check_is_nonempty(data.device_response.as_ref(), "device_response cannot be blank")?; + check_is_nonempty(data.token.as_ref(), "token cannot be blank")?; webauthn_login(data, &mut user_id, &conn, &client_header.ip).await } @@ -773,7 +774,7 @@ async fn organization_api_key_login(data: ConnectData, conn: &DbConn, ip: &Clien async fn get_device(data: &ConnectData, conn: &DbConn, user: &User) -> ApiResult { // On iOS, device_type sends "iOS", on others it sends a number // When unknown or unable to parse, return 14, which is 'Unknown Browser' - let device_type = util::try_parse_string(data.device_type.as_ref()).unwrap_or(14); + let device_type = connect_device_type(data); let device_id = data.device_identifier.clone().expect("No device id provided"); let device_name = data.device_name.clone().expect("No device name provided"); @@ -796,7 +797,7 @@ async fn twofactor_auth( client_version: Option<&ClientVersion>, conn: &DbConn, ) -> ApiResult> { - let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await; + let twofactors = TwoFactor::try_find_by_user(&user.uuid, conn).await?; // No twofactor token if twofactor is disabled if twofactors.is_empty() { @@ -1117,31 +1118,21 @@ async fn register_finish(data: Json, conn: DbConn) -> JsonResult { register(data, true, conn).await } -fn passkey_credential_id(passkey: &Passkey) -> ApiResult { - serde_json::to_value(passkey.cred_id())? - .as_str() - .map(str::to_owned) - .ok_or_else(|| crate::error::Error::new("Invalid passkey credential", "Could not serialize credential id")) +fn passkey_credential_id(passkey: &Passkey) -> String { + // `Passkey::cred_id()` returns bytes; the wire format the Bitwarden + // client expects is base64url-no-pad. Encoding directly via + // `data_encoding::BASE64URL_NOPAD` skips the `serde_json::to_value` + // round-trip the earlier shape did (one extra Value allocation + + // .as_str()+to_owned per credential per /sync). The encoding step is + // infallible (`Encoding::encode` returns `String`), so the function + // returns `String` directly — callers no longer need a `.ok()?` + // adapter that could silently swallow a fictitious failure mode. + data_encoding::BASE64URL_NOPAD.encode(passkey.cred_id().as_slice()) } fn passkey_transports(passkey: &Passkey) -> Vec { - // Serializing a `webauthn_rs::Passkey` should never fail in practice - // (it's a derive(Serialize) on a well-typed struct); if it does, log - // and fall through to an empty list rather than silently masking it - // — clients can't distinguish "authenticator reported no transports" - // from "we failed to encode the passkey" otherwise. - let value = match serde_json::to_value(passkey) { - Ok(v) => v, - Err(e) => { - error!("Failed to serialise passkey for transport extraction: {e:#?}"); - return Vec::new(); - } - }; - value - .pointer("/cred/transports") - .and_then(Value::as_array) - .map(|transports| transports.iter().filter_map(Value::as_str).map(str::to_owned).collect::>()) - .unwrap_or_default() + let credential: Credential = passkey.clone().into(); + credential.transports.into_iter().flatten().map(|transport| transport.to_string()).collect() } /// Augments the base login response (from `authenticated_response`) with the credential- @@ -1151,8 +1142,10 @@ fn passkey_transports(passkey: &Passkey) -> Vec { /// from the PRF secret it just derived — login completes, vault stays locked. pub(crate) fn build_webauthn_login_response(base: Value, matched_wac: &WebAuthnCredential, passkey: &Passkey) -> Value { let mut result = base; - if let Some(prf_option) = build_webauthn_login_prf_option(matched_wac, passkey) { - result["UserDecryptionOptions"]["WebAuthnPrfOption"] = prf_option; + if let Some(prf_option) = build_webauthn_login_prf_option(matched_wac, passkey) + && let Some(user_decryption_options) = result.get_mut("UserDecryptionOptions").and_then(Value::as_object_mut) + { + user_decryption_options.insert("WebAuthnPrfOption".to_owned(), prf_option); } result } @@ -1165,13 +1158,11 @@ pub(crate) fn build_webauthn_login_prf_option(matched_wac: &WebAuthnCredential, if !matched_wac.has_prf_keyset() { return None; } - let credential_id = passkey_credential_id(passkey).ok()?; - let transports = passkey_transports(passkey); Some(json!({ "EncryptedPrivateKey": matched_wac.encrypted_private_key, "EncryptedUserKey": matched_wac.encrypted_user_key, - "CredentialId": credential_id, - "Transports": transports, + "CredentialId": passkey_credential_id(passkey), + "Transports": passkey_transports(passkey), })) } @@ -1184,38 +1175,25 @@ pub(crate) fn build_webauthn_login_prf_option(matched_wac: &WebAuthnCredential, pub(crate) fn build_webauthn_prf_options(credentials: &[WebAuthnCredential]) -> Vec { credentials .iter() + // Cheap structural check first — skips the heavyweight Passkey JSON + // parse for `Supported` (PRF-capable but no keyset) and `Unsupported` + // rows. On /sync this matters because clients sync frequently and + // a user with many passkeys may have only a subset PRF-enabled. .filter(|wac| wac.has_prf_keyset()) .filter_map(|wac| { + // The Passkey blob must parse for the entry to contribute; a + // single corrupted row silently drops out, matching the + // `filter_map` shape on the login path. let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?; - let credential_id = passkey_credential_id(&passkey).ok()?; - let transports = passkey_transports(&passkey); - Some(json!({ - "EncryptedPrivateKey": wac.encrypted_private_key, - "EncryptedUserKey": wac.encrypted_user_key, - "CredentialId": credential_id, - "Transports": transports, - })) + build_webauthn_login_prf_option(wac, &passkey) }) .collect() } #[get("/accounts/webauthn/assertion-options")] -async fn get_web_authn_assertion_options(ip: ClientIp, conn: DbConn) -> JsonResult { - // Same gate the 2FA WebAuthn entry point uses, applied here so a - // misconfigured DOMAIN (e.g., IP literal that has no parseable host) - // returns a clean error instead of panicking inside the `WEBAUTHN` - // `LazyLock` initializer. - if !CONFIG.is_webauthn_2fa_supported() { - err!("Configured `DOMAIN` is not compatible with Webauthn") - } - - if CONFIG.sso_enabled() && CONFIG.sso_only() { - err!("SSO sign-in is required") - } - - // This endpoint is unauthenticated; rate-limit it so it cannot be abused to - // flood the challenge table. Expired rows are removed by a scheduled job. - crate::ratelimit::check_limit_login(&ip.ip)?; +async fn get_web_authn_assertion_options(ip: ClientIp, fetch_metadata: FetchMetadata, conn: DbConn) -> JsonResult { + reject_cross_site_passkey_challenge_request(&fetch_metadata)?; + check_passkey_endpoint_preconditions(&ip.ip, "used")?; // start_discoverable_authentication() requests an empty allow-list // (discoverable credentials) and user verification. @@ -1239,211 +1217,386 @@ async fn get_web_authn_assertion_options(ip: ClientIp, conn: DbConn) -> JsonResu }))) } +struct FetchMetadata { + sec_fetch_site: Option, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for FetchMetadata { + type Error = (); + + async fn from_request(request: &'r Request<'_>) -> Outcome { + Outcome::Success(Self { + sec_fetch_site: request.headers().get_one("Sec-Fetch-Site").map(str::to_owned), + }) + } +} + +/// The passkey-login challenge endpoint is a state-changing GET because the +/// bundled clients fetch it that way. Fetch Metadata lets modern browsers tell +/// us when that GET was initiated from another site; reject those before we +/// spend a login-rate-limit token or insert a challenge row. Missing headers +/// are allowed for older browsers and non-browser clients. +fn reject_cross_site_passkey_challenge_request(fetch_metadata: &FetchMetadata) -> EmptyResult { + let Some(sec_fetch_site) = fetch_metadata.sec_fetch_site.as_deref() else { + return Ok(()); + }; + + if sec_fetch_site.eq_ignore_ascii_case("same-origin") + || sec_fetch_site.eq_ignore_ascii_case("same-site") + || sec_fetch_site.eq_ignore_ascii_case("none") + { + return Ok(()); + } + + err_code!("Passkey login challenge requests must be same-site", Status::Forbidden.code) +} + async fn webauthn_login(data: ConnectData, user_id: &mut Option, conn: &DbConn, ip: &ClientIp) -> JsonResult { - // A single generic message is returned to the client for every failure so the - // endpoint cannot be used to probe which accounts exist or have passkeys. + // Credential and account-state failures use a single generic message so + // the endpoint cannot be used to probe which accounts exist or have passkeys. + // Server-side availability failures get a distinct temporary-unavailable + // response below so real users are not told their credential failed when + // the backend simply could not complete the login. const AUTH_FAILED: &str = "Passkey authentication failed."; + const SERVER_ERROR: &str = "A server error occurred. Try again later."; + + /// `err!(AUTH_FAILED, format!(detail), ErrorEvent { event: UserFailedLogIn })` + /// in one line. Credential and account-state failure branches use exactly + /// this triple — a Simple-variant error with the constant user-facing + /// message and a UserFailedLogIn audit event — so the unauthenticated + /// grant cannot be used as an account-state oracle. + /// + /// Scoped INSIDE `webauthn_login` so the macro is lexically unreachable + /// from any other function. A future contributor adding an auth handler + /// elsewhere in the module cannot accidentally invoke this macro (it + /// would fail to resolve), forcing them to make an explicit decision + /// about what their function's audit-event semantics should be rather + /// than silently inheriting `EventType::UserFailedLogIn` here. + macro_rules! auth_fail { + ($($fmt_arg:tt)*) => { + err!( + AUTH_FAILED, + format!($($fmt_arg)*), + ErrorEvent { event: EventType::UserFailedLogIn } + ) + }; + } + + macro_rules! server_fail { + ($($fmt_arg:tt)*) => { + err_code!( + SERVER_ERROR, + format!("IP: {}. {}", ip.ip, format!($($fmt_arg)*)), + Status::ServiceUnavailable.code + ) + }; + } // Validate scope and rate-limit the login. AuthMethod::WebAuthn.check_scope(data.scope.as_ref())?; crate::ratelimit::check_limit_login(&ip.ip)?; + // Match the gate the four management endpoints in `src/api/core/mod.rs` + // apply. Without it a misconfigured `DOMAIN` (passes the startup `http://` + // prefix check, fails `Url::parse().domain()`) panics the `WEBAUTHN` + // `LazyLock` on first touch inside `identify_discoverable_authentication` + // below. Returning AUTH_FAILED rather than the descriptive admin message + // keeps the unauthenticated grant from leaking server-config detail. + if !CONFIG.is_webauthn_2fa_supported() { + auth_fail!("IP: {}. Webauthn unsupported on this server.", ip.ip) + } + // Recover and consume (single-use) the saved challenge state. Every // submission carrying a valid token is spent, regardless of body content // or which user the assertion later claims — a caller with a valid token // cannot repeatedly replay it with malformed bodies. - let token = WebAuthnLoginChallengeId::from(data.token.as_ref().unwrap().clone()); - let Some(saved_challenge) = WebAuthnLoginChallenge::take(&token, conn).await? else { - err!( - AUTH_FAILED, - format!("IP: {}. Missing or expired passkey login challenge.", ip.ip), - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) + // + // A DB transient (deadlock-victim, conn drop, lock timeout) is converted + // to a generic 503 here rather than propagated via `?`: the endpoint is + // unauthenticated, so the response must not include raw DB wording, but + // legitimate users should still learn this is a server-side retry-later + // condition rather than a passkey-authentication failure. + // + // Defense-in-depth on the form-extracted fields: the dispatch site + // pre-validates `data.token`/`data.device_response` via `check_is_some`, + // but a future refactor of that dispatch could omit the pre-check; an + // explicit `let Some(...) else err!(AUTH_FAILED, ...)` keeps the + // unauthenticated grant from ever panicking the worker on missing + // fields regardless of how the function is reached. + let Some(token_str) = data.token.as_ref().filter(|token| !token.is_empty()) else { + auth_fail!("IP: {}. Missing passkey login token.", ip.ip) + }; + let token = WebAuthnLoginChallengeId::from(token_str.clone()); + let saved_challenge = match WebAuthnLoginChallenge::take(&token, conn).await { + Ok(Some(c)) => c, + Ok(None) => auth_fail!("IP: {}. Missing or expired passkey login challenge.", ip.ip), + Err(e) => server_fail!("DB error taking passkey login challenge: {e:#?}"), }; let Ok(state) = serde_json::from_str::(&saved_challenge.challenge) else { - err!( - AUTH_FAILED, - format!("IP: {}. Corrupt passkey login challenge state.", ip.ip), - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) + auth_fail!("IP: {}. Corrupt passkey login challenge state.", ip.ip) }; // Parse the authenticator assertion. A malformed body must yield the same // generic error as any other failure, not a raw deserialization error. - let Ok(device_response) = serde_json::from_str::(data.device_response.as_ref().unwrap()) - else { - err!( - AUTH_FAILED, - format!("IP: {}. Malformed passkey assertion.", ip.ip), - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) + // + // Same defense-in-depth as the `data.token` check above — refuse a + // missing field with AUTH_FAILED rather than .unwrap-panic. + let Some(device_response_str) = data.device_response.as_ref().filter(|response| !response.is_empty()) else { + auth_fail!("IP: {}. Missing passkey device_response.", ip.ip) + }; + let Ok(device_response) = serde_json::from_str::(device_response_str) else { + auth_fail!("IP: {}. Malformed passkey assertion.", ip.ip) }; let credential: PublicKeyCredential = device_response.into(); - // Identify which user the discoverable credential claims to belong to from - // its user handle. This only parses client-supplied data; user-scoped event - // logging is delayed until the assertion is cryptographically verified. - let user_uuid = match WEBAUTHN.identify_discoverable_authentication(&credential) { - Ok((user_uuid, _)) => UserId::from(user_uuid.to_string()), - Err(e) => err!( - AUTH_FAILED, - format!("IP: {}. Could not identify passkey credential: {e:?}", ip.ip), - ErrorEvent { - event: EventType::UserFailedLogIn - } - ), + // Identify which user AND credential the discoverable assertion claims + // to belong to from its user handle and rawId. Both values are + // client-supplied at this point — the cryptographic signature check is + // still pending — but they let us narrow the credential lookup to a + // single indexed row instead of loading every passkey the user owns. + // User-scoped event logging is delayed until the assertion is verified. + let (user_uuid, cred_id) = match WEBAUTHN.identify_discoverable_authentication(&credential) { + Ok((user_uuid, cred_id)) => (UserId::from(user_uuid.to_string()), cred_id), + Err(e) => auth_fail!("IP: {}. Could not identify passkey credential: {e:?}", ip.ip), }; - let Some(user) = User::find_by_uuid(&user_uuid, conn).await else { - err!( - AUTH_FAILED, - format!("IP: {}. No user matches passkey user handle {user_uuid}.", ip.ip), - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) + // Use the Result-returning sibling so a DB transient on the user lookup + // is distinguishable from a genuinely missing user. The default + // `find_by_uuid` collapses both to `None`, which would mask DB pressure + // as a misleading 'no user matches' diagnostic on the unauthenticated + // grant — masking a retry-later condition as a missing-user auth failure. + let user = match User::try_find_by_uuid(&user_uuid, conn).await { + Ok(Some(u)) => u, + Ok(None) => auth_fail!("IP: {}. No user matches passkey user handle {user_uuid}.", ip.ip), + Err(e) => server_fail!("DB error loading user {user_uuid}: {e:#?}"), }; - let username = user.email.clone(); - - // Load this user's passkey-login credentials. A DB transient (deadlock- - // victim, conn drop, lock timeout) is converted to AUTH_FAILED here rather - // than propagating: this endpoint is unauthenticated, so a panic or a - // distinct DB-error response would let an attacker amplify DB pressure - // into worker DoS or fingerprint server state. - let parsed_credentials: Vec<(WebAuthnCredential, Passkey)> = - match WebAuthnCredential::find_by_user(&user.uuid, conn).await { - Ok(creds) => creds - .into_iter() - .filter_map(|wac| { - let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?; - Some((wac, passkey)) - }) - .collect(), - Err(e) => err!( - AUTH_FAILED, - format!("IP: {}. Username: {username}. DB error loading passkey credentials: {e:#?}", ip.ip), - ErrorEvent { - event: EventType::UserFailedLogIn - } - ), + // The load-bearing protection against a `User::delete` cascade racing + // this flow is `WebAuthnCredential::update_credential`'s rowcount check: + // a concurrent cascade deletes the credential rows before the counter + // UPDATE, the UPDATE matches 0 rows, the helper returns an Err, and the + // surrounding failure handling refuses the login. + + // Locate the single credential the assertion claims to be for via the + // indexed `(user_uuid, credential_id_hash)` UNIQUE index. The cred_id + // we hash is client-supplied (no signature check yet), so the lookup + // may legitimately miss — that case yields AUTH_FAILED indistinguishable + // from a verification failure. Loading only the matched row keeps this + // hot login path O(1) per credential rather than O(N) parse-every-blob. + // + // DB transients become a generic 503 (not a raw `?`) for the same + // unauthenticated-endpoint reason as the challenge-take above. + let credential_id_hash = passkey_credential_id_hash(cred_id); + // `mut` here so the counter-advance commit below can update + // `matched_wac.credential` in place without a subsequent rebind. + let mut matched_wac = + match WebAuthnCredential::find_by_user_and_credential_id_hash(&user.uuid, &credential_id_hash, conn).await { + // Pre-verification: identify the user only by UUID, not email. The + // user_handle that resolved to this UserId is client-supplied and + // attacker-controlled; logging `user.email` here would let any + // caller who can read the server log harvest UUID->email + // mappings via deliberately-malformed assertions. + Ok(Some(wac)) => wac, + Ok(None) => auth_fail!("IP: {}. UserUuid: {user_uuid}. No matching passkey credential.", ip.ip), + Err(e) => server_fail!("UserUuid: {user_uuid}. DB error loading passkey credential: {e:#?}"), }; - if parsed_credentials.is_empty() { - err!( - AUTH_FAILED, - format!("IP: {}. Username: {username}. No passkey credentials registered.", ip.ip), - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) - } - - let discoverable_keys: Vec = - parsed_credentials.iter().map(|(_, passkey)| DiscoverableKey::from(passkey)).collect(); + // Parse the stored Passkey blob. A corrupt row blocks login for THIS + // credential only; the user can retry with another passkey, which + // routes through its own one-row lookup. `warn!` lets ops find the + // bad row immediately rather than via manual DB inspection. + let mut passkey: Passkey = match serde_json::from_str(&matched_wac.credential) { + Ok(p) => p, + Err(e) => { + warn!( + "webauthn_login: failed to deserialize stored Passkey blob for credential {} (user {}): {e:#?}", + matched_wac.uuid, matched_wac.user_uuid + ); + auth_fail!("IP: {}. UserUuid: {user_uuid}. Corrupt passkey credential blob.", ip.ip) + } + }; - // Verify the assertion. webauthn-rs checks the signature, challenge, origin, - // user verification and the signature counter against the registered keys. + // Verify the assertion against the single matched credential. webauthn-rs + // checks the signature, challenge, origin, user verification and the + // signature counter against the supplied DiscoverableKey set. A stack + // array (rather than a heap-allocated Vec) for the single element + // suffices — `finish_discoverable_authentication` takes `&[DiscoverableKey]`. + let discoverable_keys = [DiscoverableKey::from(&passkey)]; let authentication_result = match WEBAUTHN.finish_discoverable_authentication(&credential, state, &discoverable_keys) { Ok(result) => result, - Err(e) => err!( - AUTH_FAILED, - format!("IP: {}. Username: {username}. WebAuthn verification failed: {e:?}", ip.ip), - ErrorEvent { - event: EventType::UserFailedLogIn - } - ), + // Still pre-verification: UUID, not email. (See the + // load-credentials branch above for rationale.) + Err(e) => auth_fail!("IP: {}. UserUuid: {user_uuid}. WebAuthn verification failed: {e:?}", ip.ip), }; // The assertion is now bound to a registered credential for this user. From // this point on, failed account-state checks can be attributed to the user - // without allowing arbitrary user-handle event log pollution. + // without allowing arbitrary user-handle event log pollution. The single + // matched_wac IS the verified credential — we looked it up by this exact + // cred_id's hash before verification, and webauthn-rs's verifier just + // confirmed the signature matches that credential, so no second lookup is + // needed. *user_id = Some(user.uuid.clone()); + let username = user.email.clone(); if !user.enabled { - err!( - AUTH_FAILED, - format!("IP: {}. Username: {username}. Account is disabled.", ip.ip), - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) + auth_fail!("IP: {}. Username: {username}. Account is disabled.", ip.ip) } // Reject an unverified account before doing any server-side persistence. // Mirrors the password-login email-verify gate but elides the verification // reminder email and the distinguishable error message. Returning the same - // `AUTH_FAILED` as every other branch prevents using passkey login as an - // oracle for verification state. The descriptive hint still reaches - // legitimate users via password login. + // `AUTH_FAILED` as other account-state branches prevents using passkey + // login as an oracle for verification state. The descriptive hint still + // reaches legitimate users via password login. if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() { - err!( - AUTH_FAILED, - format!("IP: {}. Username: {username}. Account is not email-verified.", ip.ip), - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) + auth_fail!("IP: {}. Username: {username}. Account is not email-verified.", ip.ip) } - // Locate the credential that was actually used and persist any counter update. - let Some((mut matched_wac, mut passkey)) = parsed_credentials - .into_iter() - .find(|(_, passkey)| crypto::ct_eq(passkey.cred_id().as_slice(), authentication_result.cred_id().as_slice())) - else { - err!( - AUTH_FAILED, - format!("IP: {}. Username: {username}. Verified credential is not registered.", ip.ip), - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) + // Compute the 2FA-state gate decision BEFORE committing the + // signature-counter advance. Mirrors the gate password login applies + // (twofactor_auth): + // - no providers at all → enforce_2fa_policy below (revoke from + // RequireTwoFactor orgs the user no longer satisfies) and let the + // passkey login proceed. + // - rows exist but every provider is disabled or unusable → reject, + // same message the password path returns. The passkey is the auth, + // so we don't ask for a 2FA token when usable providers exist. + // + // Doing the gate up front narrows the window where the counter has + // advanced but the login was ultimately refused (see the counter-write + // site below for why that residual window is acceptable). + // + // `try_find_by_user` returns `Result`, so the transient is mapped to a + // generic 503 here (same unauthenticated-endpoint reason as above) rather + // than inheriting the authenticated handlers' backend-specific error body. + let twofactors = match TwoFactor::try_find_by_user(&user.uuid, conn).await { + Ok(t) => t, + Err(e) => server_fail!("Username: {username}. DB error loading 2FA state: {e:#?}"), }; + // Only real 2FA providers satisfy RequireTwoFactor policy. Remember tokens + // and recovery codes are bypass/fallback mechanisms, and implementation + // rows are challenge state, so those rows are ignored for policy purposes. + // Unknown atype<1000 rows are treated as a configured provider for + // forward-migration tolerance: if an operator rolls back after a newer + // build introduced a provider, this older build must not revoke the user + // from RequireTwoFactor orgs just because it cannot decode the row. + let needs_2fa_policy_enforcement = !twofactors.iter().any(TwoFactor::is_policy_provider_or_unknown); + if !needs_2fa_policy_enforcement && !twofactors.iter().any(passkey_policy_provider_usable_or_unknown) { + // Use the AUTH_FAILED facade and emit a UserFailedLogIn event, like + // other credential/account-state failures in this function. The unauthenticated + // grant must NOT return a distinguishable error body — a passkey + // holder could otherwise fingerprint the victim's 2FA configuration + // (rows-exist-but-disabled vs no-rows-at-all) by observing which + // branch fires. + auth_fail!("IP: {}. Username: {username}. No enabled and usable two-factor providers configured.", ip.ip) + } - // Persist any signature-counter advance from this assertion. + // Gate passed — now safe to commit the counter advance. Exempt from the + // `update_revision` + `send_user_update(SyncVault, ...)` pair the four + // management endpoints in `src/api/core/mod.rs` emit: the signature + // counter is not part of any sync payload the clients read, so a + // notify here would be a no-op. + // + // Counter semantics note: the persisted counter is webauthn-rs's + // clone-detection primitive, not an audit-trail signal. A successful + // assertion may still fail post-verification (e.g. SMTP timeout inside + // `enforce_2fa_policy` / `authenticated_response` below), leaving the + // counter advanced without an issued auth token. This is intentional: + // the contract is monotonicity, not strict 1:1 with completed logins. // - // Exempt from the `update_revision` + `send_user_update(SyncVault, ...)` - // pair the four management endpoints in `src/api/core/mod.rs` emit after - // credential mutations: the signature counter is not part of any sync - // payload the clients read, so a notify here would be a no-op. + // A DB transient on this write is converted to a generic 503 rather than + // propagated as `?`. The endpoint is unauthenticated; raw DB wording would + // leak server internals, but users still need a retry-later signal. if passkey.update_credential(&authentication_result) == Some(true) { - matched_wac.credential = serde_json::to_string(&passkey)?; - matched_wac.update_credential(conn).await?; + matched_wac.credential = match serde_json::to_string(&passkey) { + Ok(credential) => credential, + Err(e) => server_fail!("Username: {username}. Failed to serialize updated passkey credential: {e:#?}"), + }; + if let Err(e) = matched_wac.update_credential(conn).await { + server_fail!("Username: {username}. DB error persisting signature counter: {e:#?}") + } + } else if let Err(e) = matched_wac.ensure_still_registered(conn).await { + server_fail!("Username: {username}. DB error checking passkey credential presence: {e:#?}") } - let mut device = get_device(&data, conn, &user).await?; + let device_type = connect_device_type(&data); + + if needs_2fa_policy_enforcement { + // 2FA-state TOCTOU note: `disable_twofactor` / `activate_authenticator` + // / etc. do NOT acquire the per-user passkey lock, so the snapshot we + // captured above can drift before this enforcement runs. Holding the + // lock longer would not close the race (the disable path is + // unsynchronised regardless). The realistic worst case is a single + // user with two concurrent sessions racing a 2FA change against their + // own passkey login — bounded policy drift, not auth bypass. + if let Err(e) = enforce_2fa_policy(&user, &user.uuid, device_type, &ip.ip, conn).await { + server_fail!("Username: {username}. 2FA policy enforcement failed: {e:#?}") + } + } - // Mirror the 2FA-state gate that password login applies (twofactor_auth): - // - no providers at all → enforce_2fa_policy (revoke from RequireTwoFactor - // orgs the user no longer satisfies) and let the passkey login proceed. - // - rows exist but every provider is disabled or unusable → reject, same - // message the password path returns. The passkey is the auth, so we - // don't ask for a 2FA token when usable providers exist. - let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await; - if twofactors.is_empty() { - enforce_2fa_policy(&user, &user.uuid, device.atype, &ip.ip, conn).await?; - } else if !twofactors.iter().any(|tf| { - TwoFactorType::from_i32(tf.atype) - .is_some_and(|t| tf.enabled && is_twofactor_provider_usable(&t, Some(&tf.data))) - }) { - err!("No enabled and usable two factor providers are available for this account") + // Re-read the mutable account/credential state before any login side + // effects (device persistence, notification mail, success log). The + // process-local passkey lock blocks same-node delete/rotation while this + // request is active; this final DB read closes the common multi-replica + // window where another node deleted the matched credential or rotated the + // account key after our initial credential load. + match User::try_find_by_uuid(&user.uuid, conn).await { + Ok(Some(current_user)) => { + if current_user.security_stamp != user.security_stamp { + server_fail!("Username: {username}. Account key changed during passkey login.") + } + if !current_user.enabled { + auth_fail!("IP: {}. Username: {username}. Account is disabled.", ip.ip) + } + if current_user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() { + auth_fail!("IP: {}. Username: {username}. Account is not email-verified.", ip.ip) + } + } + Ok(None) => server_fail!("Username: {username}. Account deleted during passkey login."), + Err(e) => server_fail!("Username: {username}. DB error revalidating account state: {e:#?}"), } + let response_wac = + match WebAuthnCredential::find_by_user_and_credential_id_hash(&user.uuid, &credential_id_hash, conn).await { + Ok(Some(wac)) => wac, + Ok(None) => server_fail!("Username: {username}. Passkey credential deleted during login."), + Err(e) => server_fail!("Username: {username}. DB error revalidating passkey credential: {e:#?}"), + }; + + // Post-verification downstream calls (get_device / authenticated_response) + // must not leak raw Error::Db / Error::Smtp / Error::Lettre details on + // this unauthenticated grant. Collapse them to a generic 503 instead of + // AUTH_FAILED so a valid passkey user gets the correct "server + // unavailable" guidance. + let mut device = match get_device(&data, conn, &user).await { + Ok(d) => d, + Err(e) => server_fail!("Username: {username}. Device lookup/create failed: {e:#?}"), + }; + let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::WebAuthn, data.client_id); // Build the common response, then attach the credential-specific `WebAuthnPrfOption` // upstream populates via `WithWebAuthnLoginCredential` after a webauthn-grant assertion. // The wrapped-key payload lets the client unlock the vault using the PRF secret it just // derived; without it, login completes but the vault stays locked. - let Json(base) = authenticated_response(&user, &mut device, auth_tokens, None, conn, ip).await?; - Ok(Json(build_webauthn_login_response(base, &matched_wac, &passkey))) + let Json(base) = match authenticated_response(&user, &mut device, auth_tokens, None, conn, ip).await { + Ok(j) => j, + Err(e) => server_fail!("Username: {username}. Authenticated response build failed: {e:#?}"), + }; + + Ok(Json(build_webauthn_login_response(base, &response_wac, &passkey))) +} + +fn passkey_policy_provider_usable_or_unknown(tf: &TwoFactor) -> bool { + match TwoFactorType::from_i32(tf.atype) { + Some(provider_type) => { + tf.is_policy_provider() && tf.enabled && is_twofactor_provider_usable(&provider_type, Some(&tf.data)) + } + None => tf.is_policy_provider_or_unknown(), + } } // https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts @@ -1513,11 +1666,23 @@ struct ConnectData { #[field(name = uncased("token"))] token: Option, } +fn connect_device_type(data: &ConnectData) -> i32 { + util::try_parse_string(data.device_type.as_ref()).unwrap_or(14) +} + fn check_is_some(value: Option<&T>, msg: &str) -> EmptyResult { - if value.is_none() { + if value.is_some() { + Ok(()) + } else { err!(msg) } - Ok(()) +} + +fn check_is_nonempty(value: Option<&T>, msg: &str) -> EmptyResult { + match value { + Some(value) if !value.to_string().is_empty() => Ok(()), + _ => err!(msg), + } } #[get("/sso/prevalidate")] @@ -1704,9 +1869,29 @@ mod tests { .into() } + #[test] + fn check_is_some_only_rejects_missing_values() { + let value = String::from("present"); + let empty = String::new(); + + assert!(check_is_some(Some(&value), "value cannot be blank").is_ok()); + assert!(check_is_some(Some(&empty), "value cannot be blank").is_ok()); + assert!(check_is_some::(None, "value cannot be blank").is_err()); + } + + #[test] + fn check_is_nonempty_rejects_missing_or_empty_values() { + let value = String::from("present"); + let empty = String::new(); + + assert!(check_is_nonempty(Some(&value), "value cannot be blank").is_ok()); + assert!(check_is_nonempty(Some(&empty), "value cannot be blank").is_err()); + assert!(check_is_nonempty::(None, "value cannot be blank").is_err()); + } + #[test] fn passkey_credential_id_returns_browser_credential_id() { - assert_eq!(passkey_credential_id(&passkey(None)).unwrap(), "AQIDBA"); + assert_eq!(passkey_credential_id(&passkey(None)), "AQIDBA"); } #[test] @@ -1721,6 +1906,32 @@ mod tests { assert!(passkey_transports(&passkey(None)).is_empty()); } + #[test] + fn passkey_challenge_fetch_metadata_allows_same_site_same_origin_or_absent() { + for sec_fetch_site in + [None, Some("same-origin".to_owned()), Some("same-site".to_owned()), Some("none".to_owned())] + { + assert!( + reject_cross_site_passkey_challenge_request(&FetchMetadata { + sec_fetch_site + }) + .is_ok() + ); + } + } + + #[test] + fn passkey_challenge_fetch_metadata_rejects_cross_origin_contexts() { + for sec_fetch_site in ["cross-site", "Cross-Site", "unexpected-value"] { + assert!( + reject_cross_site_passkey_challenge_request(&FetchMetadata { + sec_fetch_site: Some(sec_fetch_site.to_owned()) + }) + .is_err() + ); + } + } + fn make_credential( supports_prf: bool, encrypted_user_key: Option<&str>, @@ -1835,6 +2046,20 @@ mod tests { assert_eq!(response["UserDecryptionOptions"]["HasMasterPassword"], true); } + #[test] + fn webauthn_login_response_is_noop_when_user_decryption_options_is_not_an_object() { + let pk = passkey(None); + let wac = make_credential(true, Some("user"), Some("pub"), Some("priv"), &serde_json::to_string(&pk).unwrap()); + let base = json!({ + "UserDecryptionOptions": null, + "Other": true + }); + + let response = build_webauthn_login_response(base.clone(), &wac, &pk); + + assert_eq!(response, base); + } + #[test] fn webauthn_login_response_is_noop_for_prf_unsupported_credential() { // Behavior: a credential whose authenticator doesn't support PRF (`supports_prf=false`) diff --git a/src/config.rs b/src/config.rs index 42a8e5e3..a4301f5d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1597,7 +1597,14 @@ impl Config { } pub fn is_webauthn_2fa_supported(&self) -> bool { - Url::parse(&self.domain()).expect("DOMAIN not a valid URL").domain().is_some() + // The startup `starts_with("http://"|"https://")` check accepts values + // like `"http://"` (empty host) that `Url::parse` rejects with + // `EmptyHost`. This gate IS the protection against the WEBAUTHN + // LazyLock's `.expect` initializer panicking, so the gate itself must + // not panic on the same input. Map any parse failure to `false` so + // every webauthn entry point cleanly refuses instead of crashing the + // worker thread. + Url::parse(&self.domain()).ok().is_some_and(|u| u.domain().is_some()) } /// Tests whether the admin token is set to a non-empty value. diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index 33acc3bf..50e60350 100644 --- a/src/db/models/two_factor.rs +++ b/src/db/models/two_factor.rs @@ -51,6 +51,16 @@ pub enum TwoFactorType { /// Local methods impl TwoFactor { + const POLICY_PROVIDER_TYPES: [i32; 7] = [ + TwoFactorType::Authenticator as i32, + TwoFactorType::Email as i32, + TwoFactorType::Duo as i32, + TwoFactorType::YubiKey as i32, + TwoFactorType::U2f as i32, + TwoFactorType::OrganizationDuo as i32, + TwoFactorType::Webauthn as i32, + ]; + pub fn new(user_uuid: UserId, atype: TwoFactorType, data: String) -> Self { Self { uuid: TwoFactorId(crate::util::get_uuid()), @@ -62,6 +72,22 @@ impl TwoFactor { } } + pub fn is_policy_provider(&self) -> bool { + Self::POLICY_PROVIDER_TYPES.contains(&self.atype) + } + + pub fn is_policy_provider_or_unknown(&self) -> bool { + if self.atype >= 1000 { + return false; + } + + match ::from_i32(self.atype) { + Some(TwoFactorType::Remember | TwoFactorType::RecoveryCode) => false, + Some(_) => self.is_policy_provider(), + None => true, + } + } + pub fn to_json(&self) -> Value { json!({ "enabled": self.enabled, @@ -128,6 +154,25 @@ impl TwoFactor { .await } + /// Load provider rows while preserving DB transients for the + /// unauthenticated `webauthn_login` grant. Authenticated 2FA flows + /// still use the panicking `find_by_user` sibling (see upstream + /// pattern); the unauthenticated grant must distinguish a transient + /// 503 from a 401 to avoid the grant becoming an account-state oracle + /// and to give legitimate users a "retry later" signal instead of + /// AUTH_FAILED. + pub async fn try_find_by_user(user_uuid: &UserId, conn: &DbConn) -> Result, Error> { + let user_uuid = user_uuid.clone(); + conn.run(move |conn| { + twofactor::table + .filter(twofactor::user_uuid.eq(&user_uuid)) + .filter(twofactor::atype.lt(1000)) // Filter implementation types + .load::(conn) + .map_res("Error loading twofactor") + }) + .await + } + pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { conn.run(move |conn| { twofactor::table @@ -318,3 +363,33 @@ impl From for WebauthnRegistration { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn twofactor(atype: i32) -> TwoFactor { + TwoFactor { + uuid: TwoFactorId(String::from("tf")), + user_uuid: UserId::from(String::from("user")), + atype, + enabled: true, + data: String::new(), + last_used: 0, + } + } + + #[test] + fn policy_provider_or_unknown_counts_forward_migrated_rows() { + assert!(twofactor(9).is_policy_provider_or_unknown()); + } + + #[test] + fn policy_provider_or_unknown_ignores_bypass_and_challenge_rows() { + assert!(!twofactor(TwoFactorType::Remember as i32).is_policy_provider_or_unknown()); + assert!(!twofactor(TwoFactorType::RecoveryCode as i32).is_policy_provider_or_unknown()); + assert!(!twofactor(TwoFactorType::WebauthnLoginChallenge as i32).is_policy_provider_or_unknown()); + assert!(!twofactor(TwoFactorType::WebauthnPasskeyRegisterChallenge as i32).is_policy_provider_or_unknown()); + assert!(!twofactor(TwoFactorType::WebauthnPasskeyAssertionChallenge as i32).is_policy_provider_or_unknown()); + } +} diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 9651f116..3d164ec1 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}, }; @@ -344,10 +344,15 @@ impl User { WebAuthnCredential::delete_all_by_user(&self.uuid, conn).await?; Invitation::take(&self.email, conn).await; // Delete invitation if any - conn.run(move |conn| { - diesel::delete(users::table.filter(users::uuid.eq(self.uuid))).execute(conn).map_res("Error deleting user") - }) - .await + let delete_result: EmptyResult = conn + .run(move |conn| { + diesel::delete(users::table.filter(users::uuid.eq(self.uuid))) + .execute(conn) + .map_res("Error deleting user") + }) + .await; + delete_result?; + Ok(()) } pub async fn update_uuid_revision(uuid: &UserId, conn: &DbConn) { @@ -392,8 +397,26 @@ impl User { conn.run(move |conn| users::table.filter(users::email.eq(lower_mail)).first::(conn).ok()).await } + /// Convenience wrapper around [`Self::try_find_by_uuid`] for the many + /// callers that don't need to distinguish a DB transient from a missing + /// row. Keeps a single source of truth for the underlying Diesel query. pub async fn find_by_uuid(uuid: &UserId, conn: &DbConn) -> Option { - conn.run(move |conn| users::table.filter(users::uuid.eq(uuid)).first::(conn).ok()).await + Self::try_find_by_uuid(uuid, conn).await.ok().flatten() + } + + /// Result-returning counterpart of `find_by_uuid` for callers that need + /// to distinguish a DB transient (deadlock-victim, conn drop, lock + /// timeout) from a genuinely missing row. The unauthenticated + /// `webauthn_login` grant requires this distinction so a transient + /// during user lookup doesn't fall through to the indistinguishable + /// "No user matches passkey user handle" branch — masking the cause + /// from operators and breaking the function-wide AUTH_FAILED-uniformity + /// contract for transient vs. missing. + pub async fn try_find_by_uuid(uuid: &UserId, conn: &DbConn) -> Result, Error> { + conn.run(move |conn| { + users::table.filter(users::uuid.eq(uuid)).first::(conn).optional().map_res("Error loading user") + }) + .await } pub async fn find_by_device_for_email2fa(device_uuid: &DeviceId, conn: &DbConn) -> Option { diff --git a/src/db/models/web_authn_credential.rs b/src/db/models/web_authn_credential.rs index 726542c0..e9c27d03 100644 --- a/src/db/models/web_authn_credential.rs +++ b/src/db/models/web_authn_credential.rs @@ -13,8 +13,9 @@ use super::UserId; /// How long a pending passkey-login challenge stays valid before it is rejected. const WEBAUTHN_LOGIN_CHALLENGE_TTL_SECONDS: i64 = 300; +const WEBAUTHN_LOGIN_CHALLENGE_CLOCK_SKEW_SECONDS: i64 = 30; -#[derive(Debug, Identifiable, Queryable, Insertable)] +#[derive(Clone, Debug, Identifiable, Queryable, Insertable)] #[diesel(table_name = web_authn_credentials)] #[diesel(primary_key(uuid))] pub struct WebAuthnCredential { @@ -76,64 +77,158 @@ impl WebAuthnCredential { } } - pub async fn save(&self, conn: &DbConn) -> EmptyResult { + pub async fn save_with_user_limit(&self, limit: usize, conn: &DbConn) -> EmptyResult { + let credential = self.clone(); + let limit = i64::try_from(limit).map_err(|_| Error::new("Invalid passkey limit", "Passkey limit overflow"))?; + db_run! { conn: { - let result = diesel::insert_into(web_authn_credentials::table) - .values(self) - .execute(conn); + conn.transaction::<(), Error, _>(|conn| { + let count = web_authn_credentials::table + .filter(web_authn_credentials::user_uuid.eq(&credential.user_uuid)) + .count() + .get_result::(conn) + .map_res("Error counting web_authn_credentials")?; + if count >= limit { + return Err(Error::new("Maximum number of passkeys reached", "WebAuthn credential limit reached")); + } - match result { - Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation, _)) => { - Err(Error::new( - "Passkey is already registered", - "Duplicate WebAuthn credential ID", - )) + let result = diesel::insert_into(web_authn_credentials::table) + .values(&credential) + .execute(conn); + match result { + Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation, _)) => { + Err(Error::new("Passkey is already registered", "Duplicate WebAuthn credential ID")) + } + result => result.map_res("Error saving web_authn_credential"), } - result => result.map_res("Error saving web_authn_credential"), - } + }) }} } + pub async fn count_by_user(user_uuid: &UserId, conn: &DbConn) -> Result { + let user_uuid = user_uuid.clone(); + let count = db_run! { conn: { + web_authn_credentials::table + .filter(web_authn_credentials::user_uuid.eq(user_uuid)) + .count() + .get_result::(conn) + .map_res("Error counting web_authn_credentials") + }}?; + + usize::try_from(count) + .map_err(|_| Error::new("Error counting web_authn_credentials", "Credential count overflow")) + } + + /// Guard the rowcount returned by an UPDATE against a concurrent + /// credential delete. A 0-row result means a `post_api_webauthn_delete` + /// (or `User::delete` cascade) removed the row inside our window — a + /// concurrent-state outcome that deserves a clean refusal. + /// + /// Returns a Simple error so the routine concurrent-delete race does NOT + /// emit an `error!()` log line from the Rocket responder. The message + /// itself describes an expected concurrent-state outcome, not a server + /// bug, and a multi-replica deployment would otherwise spam operator logs. + /// Visible to sibling modules so credential rewrap paths can share the + /// same rowcount-zero handling rather than re-implementing it for every + /// update statement. + pub(crate) fn ensure_credential_present(rows: usize) -> EmptyResult { + if rows == 0 { + return Err(Error::new_msg("Webauthn credential modified concurrently")); + } + Ok(()) + } + /// Persist the serialized passkey blob after a successful assertion advances /// its signature counter. Touches only the `credential` column so a concurrent /// key rotation cannot clobber it (see [`Self::update_keys`]). pub async fn update_credential(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: { - diesel::update(web_authn_credentials::table.filter(web_authn_credentials::uuid.eq(&self.uuid))) + let rows: usize = diesel::update(web_authn_credentials::table.filter(web_authn_credentials::uuid.eq(&self.uuid))) .set(web_authn_credentials::credential.eq(&self.credential)) .execute(conn) - .map_res("Error updating web_authn_credential signature counter") + .map_res("Error updating web_authn_credential signature counter")?; + Self::ensure_credential_present(rows) }} } - /// Persist the PRF unlock blobs that the rotation flow re-encrypts under the - /// new account key. Touches only the two columns that key rotation actually - /// changes, so it cannot clobber a concurrent signature-counter advance (see - /// [`Self::update_credential`]) nor the enrollment-time `encrypted_private_key`. + /// Persist the PRF unlock blobs the rotation flow re-encrypts under the new + /// account key. Touches only the two account-key-wrapped columns, so it + /// cannot clobber a concurrent counter advance (see [`Self::update_credential`]) + /// nor the enrollment-time `encrypted_private_key`. The + /// `ensure_credential_present` guard reports a 0-row UPDATE (row deleted + /// inside our window) so callers can treat the rewrap as a degraded no-op. pub async fn update_keys(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: { - diesel::update(web_authn_credentials::table.filter(web_authn_credentials::uuid.eq(&self.uuid))) + let rows: usize = diesel::update(web_authn_credentials::table.filter(web_authn_credentials::uuid.eq(&self.uuid))) .set(( web_authn_credentials::encrypted_user_key.eq(&self.encrypted_user_key), web_authn_credentials::encrypted_public_key.eq(&self.encrypted_public_key), )) .execute(conn) - .map_res("Error updating web_authn_credential keys") + .map_res("Error updating web_authn_credential keys")?; + Self::ensure_credential_present(rows) }} } - /// Persist a complete PRF unlock keyset after a user enables vault - /// encryption for an existing passkey-login credential. - pub async fn update_prf_keyset(&self, conn: &DbConn) -> EmptyResult { + /// Drop all PRF unlock blobs so clients stop advertising unlock-with-passkey + /// for a credential whose key material could not be rewrapped after account + /// key rotation. + pub async fn clear_prf_keyset(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: { - diesel::update(web_authn_credentials::table.filter(web_authn_credentials::uuid.eq(&self.uuid))) + let rows: usize = diesel::update(web_authn_credentials::table.filter(web_authn_credentials::uuid.eq(&self.uuid))) .set(( - web_authn_credentials::encrypted_user_key.eq(&self.encrypted_user_key), - web_authn_credentials::encrypted_public_key.eq(&self.encrypted_public_key), - web_authn_credentials::encrypted_private_key.eq(&self.encrypted_private_key), + web_authn_credentials::encrypted_user_key.eq::>(None), + web_authn_credentials::encrypted_public_key.eq::>(None), + web_authn_credentials::encrypted_private_key.eq::>(None), )) .execute(conn) - .map_res("Error updating web_authn_credential PRF keyset") + .map_res("Error clearing web_authn_credential PRF keyset")?; + Self::ensure_credential_present(rows) + }} + } + + /// Persist a complete PRF unlock keyset after a user enables vault + /// encryption for an existing passkey-login credential, optionally + /// folding the signature-counter advance from the assertion that + /// authorised the enrolment into the same UPDATE — both the keyset + /// and the counter are written in one statement so a half-applied + /// state is impossible without involving a separate transaction. + /// + /// `advanced_counter` gates the `credential` blob write. The caller + /// passes `true` only when `Passkey::update_credential` reported a real + /// counter advance; otherwise the column is left untouched so a + /// concurrent counter advance committed by another instance (e.g. a + /// parallel `webauthn_login` in a multi-replica deployment) is not + /// silently overwritten with the stale blob loaded here. + /// + /// A 0-rows result is surfaced as a `Simple` error (NOT `Db(NotFound)`) + /// via [`Self::ensure_credential_present`] so the renderer at + /// `error.rs` does not log a routine concurrent-delete race at ERROR + /// level. + pub async fn update_credential_and_prf_keyset(&self, advanced_counter: bool, conn: &DbConn) -> EmptyResult { + db_run! { conn: { + let target = web_authn_credentials::table.filter(web_authn_credentials::uuid.eq(&self.uuid)); + let rows: usize = if advanced_counter { + diesel::update(target) + .set(( + web_authn_credentials::credential.eq(&self.credential), + web_authn_credentials::encrypted_user_key.eq(&self.encrypted_user_key), + web_authn_credentials::encrypted_public_key.eq(&self.encrypted_public_key), + web_authn_credentials::encrypted_private_key.eq(&self.encrypted_private_key), + )) + .execute(conn) + .map_res("Error updating web_authn_credential PRF keyset")? + } else { + diesel::update(target) + .set(( + web_authn_credentials::encrypted_user_key.eq(&self.encrypted_user_key), + web_authn_credentials::encrypted_public_key.eq(&self.encrypted_public_key), + web_authn_credentials::encrypted_private_key.eq(&self.encrypted_private_key), + )) + .execute(conn) + .map_res("Error updating web_authn_credential PRF keyset")? + }; + Self::ensure_credential_present(rows) }} } @@ -151,13 +246,39 @@ impl WebAuthnCredential { }} } - pub async fn find_by_uuid_and_user(uuid: &WebAuthnCredentialId, user_uuid: &UserId, conn: &DbConn) -> Option { + /// Look up a single credential by `(user_uuid, credential_id_hash)`, + /// using the UNIQUE index of the same name. Used by `put_api_webauthn` + /// to locate the row matching a verified assertion without loading the + /// user's entire passkey set. + pub async fn find_by_user_and_credential_id_hash( + user_uuid: &UserId, + credential_id_hash: &str, + conn: &DbConn, + ) -> Result, Error> { db_run! { conn: { web_authn_credentials::table - .filter(web_authn_credentials::uuid.eq(uuid)) .filter(web_authn_credentials::user_uuid.eq(user_uuid)) + .filter(web_authn_credentials::credential_id_hash.eq(credential_id_hash)) .first::(conn) - .ok() + .optional() + .map_res("Error loading web_authn_credential by credential_id_hash") + }} + } + + /// Re-check that the credential row still exists without changing it. + /// This protects successful assertions whose authenticators do not + /// advance a signature counter: there is no UPDATE rowcount to observe in + /// that case, so callers need an explicit existence probe before they + /// mint a login response. + pub async fn ensure_still_registered(&self, conn: &DbConn) -> EmptyResult { + db_run! { conn: { + let rows: i64 = web_authn_credentials::table + .filter(web_authn_credentials::uuid.eq(&self.uuid)) + .filter(web_authn_credentials::user_uuid.eq(&self.user_uuid)) + .count() + .get_result(conn) + .map_res("Error checking web_authn_credential presence")?; + Self::ensure_credential_present(usize::from(rows > 0)) }} } @@ -167,13 +288,14 @@ impl WebAuthnCredential { conn: &DbConn, ) -> EmptyResult { db_run! { conn: { - diesel::delete( + let rows = 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_res("Error removing web_authn_credential") + .map_res("Error removing web_authn_credential")?; + Self::ensure_credential_present(rows) }} } @@ -217,6 +339,27 @@ impl WebAuthnLoginChallenge { }} } + fn is_fresh(created_at: NaiveDateTime) -> bool { + Self::is_fresh_at(created_at, Utc::now().naive_utc()) + } + + /// Pure freshness predicate. The `now` parameter exists so tests can + /// assert the inclusive `>=` / `<=` boundaries deterministically without + /// racing the `Utc::now()` call inside the production wrapper above. + fn is_fresh_at(created_at: NaiveDateTime, now: NaiveDateTime) -> bool { + crate::util::is_within_freshness_window( + created_at, + now, + TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_TTL_SECONDS), + TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_CLOCK_SKEW_SECONDS), + ) + } + + #[cfg(test)] + fn is_purgeable_at(created_at: NaiveDateTime, now: NaiveDateTime) -> bool { + !Self::is_fresh_at(created_at, now) + } + /// Fetch and delete a pending challenge (single-use). Three outcomes: /// - `Ok(Some(_))` — winner of the SELECT+DELETE race; the row has been /// removed and the caller may verify the assertion against the state. @@ -232,15 +375,17 @@ impl WebAuthnLoginChallenge { /// response. pub async fn take(id: &WebAuthnLoginChallengeId, conn: &DbConn) -> Result, Error> { db_run! { conn: { - // Single-use is enforced by the `deleted == 1` row-count guard, not - // by isolation: concurrent callers may all see the row in the - // SELECT, but only the one whose DELETE removes it (returns 1) is - // allowed to use the challenge. The remaining callers see - // `deleted == 0` and get `None`. The surrounding transaction - // ensures the SELECT+DELETE pair rolls back atomically on a DB - // error, leaving the challenge intact rather than silently - // consuming it. - let taken = conn + // Single-use rests on the `deleted == 1` row-count guard, not on + // isolation: concurrent callers may all SELECT the row, but only + // the one whose DELETE returns 1 may use it; the rest get `None`. + // The transaction rolls the SELECT+DELETE back atomically on a DB + // error, leaving the row intact rather than silently consumed. + // + // `is_fresh` runs INSIDE the transaction closure, AFTER the DELETE, + // reading the wall clock at consume time, so a stale row is still + // purged on a consumption attempt rather than lingering until the + // background sweeper runs. + conn .transaction::, diesel::result::Error, _>(|conn| { let challenge = web_authn_login_challenges::table .filter(web_authn_login_challenges::id.eq(id)) @@ -250,12 +395,9 @@ impl WebAuthnLoginChallenge { web_authn_login_challenges::table.filter(web_authn_login_challenges::id.eq(id)), ) .execute(conn)?; - Ok(challenge.filter(|_| deleted == 1)) + Ok(challenge.filter(|c| deleted == 1 && Self::is_fresh(c.created_at))) }) - .map_res("Error taking web_authn_login_challenge")?; - - let cutoff = Utc::now().naive_utc() - TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_TTL_SECONDS); - Ok(taken.filter(|c| c.created_at >= cutoff)) + .map_res("Error taking web_authn_login_challenge") }} } @@ -263,9 +405,15 @@ impl WebAuthnLoginChallenge { pub async fn delete_expired(pool: DbPool) -> EmptyResult { debug!("Purging expired web_authn_login_challenges"); if let Ok(conn) = pool.get().await { - let cutoff = Utc::now().naive_utc() - TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_TTL_SECONDS); + let now = Utc::now().naive_utc(); + let oldest = now - TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_TTL_SECONDS); + let newest = now + TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_CLOCK_SKEW_SECONDS); db_run! { conn: { - diesel::delete(web_authn_login_challenges::table.filter(web_authn_login_challenges::created_at.lt(cutoff))) + diesel::delete(web_authn_login_challenges::table.filter( + web_authn_login_challenges::created_at + .lt(oldest) + .or(web_authn_login_challenges::created_at.gt(newest)), + )) .execute(conn) .map_res("Error deleting expired web_authn_login_challenges") }} @@ -379,6 +527,12 @@ mod tests { assert_eq!(cred.encrypted_private_key.as_deref(), Some("private-key")); } + #[test] + fn credential_rowcount_guard_rejects_missing_rows() { + assert!(WebAuthnCredential::ensure_credential_present(1).is_ok()); + assert!(WebAuthnCredential::ensure_credential_present(0).is_err()); + } + /// Exhaust the 2^4 truth table for `has_prf_keyset` and `prf_status`: /// only `(supports_prf=true, all three blobs Some)` reports /// `has_prf_keyset() == true` / `prf_status() == 0` (Enabled). Any @@ -417,4 +571,84 @@ mod tests { } } } + + #[test] + fn login_challenge_freshness_allows_current_window() { + let now = Utc::now().naive_utc(); + + assert!(WebAuthnLoginChallenge::is_fresh(now)); + assert!(WebAuthnLoginChallenge::is_fresh(now - TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_TTL_SECONDS - 5))); + assert!(WebAuthnLoginChallenge::is_fresh( + now + TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_CLOCK_SKEW_SECONDS - 5) + )); + } + + #[test] + fn login_challenge_freshness_rejects_old_or_far_future_rows() { + let now = Utc::now().naive_utc(); + + assert!(!WebAuthnLoginChallenge::is_fresh(now - TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_TTL_SECONDS + 1))); + assert!(!WebAuthnLoginChallenge::is_fresh( + now + TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_CLOCK_SKEW_SECONDS + 1) + )); + } + + /// Exact-boundary coverage. The production `is_fresh` reads `Utc::now()` + /// inside the function, so a test against `now - TTL` would race the + /// internal clock read by microseconds and assert FALSE for the boundary + /// row that should be inclusive. `is_fresh_at` takes `now` as a parameter + /// so the comparison is deterministic. + #[test] + fn login_challenge_freshness_inclusive_at_both_boundaries() { + let now = Utc::now().naive_utc(); + + assert!( + WebAuthnLoginChallenge::is_fresh_at(now - TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_TTL_SECONDS), now), + "created_at exactly TTL old must remain fresh (`>=` is inclusive)" + ); + assert!( + WebAuthnLoginChallenge::is_fresh_at( + now + TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_CLOCK_SKEW_SECONDS), + now + ), + "created_at exactly skew seconds ahead must remain fresh (`<=` is inclusive)" + ); + assert!( + !WebAuthnLoginChallenge::is_fresh_at( + now - TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_TTL_SECONDS) - TimeDelta::nanoseconds(1), + now + ), + "one nanosecond past the TTL boundary must reject" + ); + assert!( + !WebAuthnLoginChallenge::is_fresh_at( + now + TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_CLOCK_SKEW_SECONDS) + TimeDelta::nanoseconds(1), + now + ), + "one nanosecond past the skew boundary must reject" + ); + } + + #[test] + fn login_challenge_cleanup_purges_only_outside_fresh_window() { + let now = Utc::now().naive_utc(); + + assert!(!WebAuthnLoginChallenge::is_purgeable_at(now, now)); + assert!(!WebAuthnLoginChallenge::is_purgeable_at( + now - TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_TTL_SECONDS), + now, + )); + assert!(!WebAuthnLoginChallenge::is_purgeable_at( + now + TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_CLOCK_SKEW_SECONDS), + now, + )); + assert!(WebAuthnLoginChallenge::is_purgeable_at( + now - TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_TTL_SECONDS) - TimeDelta::nanoseconds(1), + now, + )); + assert!(WebAuthnLoginChallenge::is_purgeable_at( + now + TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_CLOCK_SKEW_SECONDS) + TimeDelta::nanoseconds(1), + now, + )); + } } diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index 7fb0d3d5..2e52bd4f 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -78,6 +78,13 @@ app-webauthn-login-settings > button[bitbutton] { } {{/if}} +{{#unless webauthn_2fa_supported}} +.vw-passkey-login, +app-webauthn-login-settings > button[bitbutton] { + @extend %vw-hide; +} +{{/unless}} + /* Hide the or text followed by the two buttons hidden above */ {{#if (webver ">=2025.5.1")}} {{#if (or (not sso_enabled) sso_only)}} diff --git a/src/util.rs b/src/util.rs index 91f075d1..471366b1 100644 --- a/src/util.rs +++ b/src/util.rs @@ -357,6 +357,18 @@ pub fn get_uuid() -> String { uuid::Uuid::new_v4().to_string() } +/// Inclusive freshness-window check: whether `created_at` lies within +/// `[now - ttl, now + skew]`. Generic over the timestamp type so the +/// epoch-second (passkey-management) and `NaiveDateTime` (passkey-login) +/// challenge checks share one definition of the window semantics while each +/// keeps its own TTL/skew constants. +pub fn is_within_freshness_window(created_at: T, now: T, ttl: D, skew: D) -> bool +where + T: Copy + PartialOrd + std::ops::Sub + std::ops::Add, +{ + created_at >= now - ttl && created_at <= now + skew +} + // // String util methods //