Browse Source

webauthn: harden passkey management flows

Tightens the passkey-management endpoints against concurrent-mutation
races, stale challenges, and token-replay; routes the unauthenticated-
login path through atomic challenge consumption; and pins the behaviour
with new test coverage.

Atomic challenge consumption:

- `WebAuthnLoginChallenge::take` performs a transactional
  SELECT+DELETE on the challenge row, replacing the previous "find
  then delete" pattern that could leave a stale challenge consumable
  across tabs.
- `TwoFactor::take_by_user_and_type` does the same for the
  passkey-management registration / assertion challenges, replacing
  the prior `find_by_user_and_type` + `tf.delete` pattern at every
  passkey-management call site.

Challenge state binding:

- `passkey_management_challenge_is_fresh` enforces a TTL + clock-skew
  window on persisted challenge rows.
- `passkey_registration_challenge_state` /
  `passkey_assertion_challenge_state` enforce per-request token
  equality, freshness, and user `security_stamp` equality before
  unwrapping the saved webauthn-rs state. A password change
  mid-ceremony invalidates the in-flight challenge; a stale tab
  cannot consume a fresh one.

Identity.rs (`webauthn_login`):

- Consume the challenge via `WebAuthnLoginChallenge::take` before any
  cryptographic verification, so a malformed `device_response` cannot
  poison subsequent grant attempts that share the same token.
- AUTH_FAILED-uniformity: every pre-bind error path returns the same
  generic "Passkey authentication failed" rather than leaking the
  underlying cause (DB transient vs missing user vs malformed
  assertion vs verification failure).
- Drop the local `AssertionResponseCopy` in favour of the shared
  `PublicKeyCredentialCopy` from `core::two_factor::webauthn`, so the
  passkey-login and 2FA-webauthn paths agree on the serde wire shape
  for the assertion response.

Other:

- `ciphers.rs` / `accounts.rs`: `/sync` gates `webAuthnPrfOptions` on
  `account_passkeys_allowed`; PRF rotation path tolerates a credential
  that was removed mid-rotation.
- `vaultwarden.scss.hbs`: hide the lock-screen "Unlock with passkey"
  button until PRF enrolment completes so the affordance only appears
  when the server will actually accept it.
- `playwright/tests/passkey.spec.ts`: additional coverage for the
  hardened flows.
pull/7297/head
Zaid Marji 2 weeks ago
parent
commit
b5678daf8e
  1. 2
      playwright/docker-compose.yml
  2. 52
      playwright/tests/passkey.spec.ts
  3. 82
      src/api/core/accounts.rs
  4. 9
      src/api/core/ciphers.rs
  5. 267
      src/api/core/mod.rs
  6. 85
      src/api/identity.rs
  7. 37
      src/db/models/two_factor.rs
  8. 58
      src/db/models/web_authn_credential.rs
  9. 13
      src/static/templates/scss/vaultwarden.scss.hbs

2
playwright/docker-compose.yml

@ -24,9 +24,11 @@ services:
environment: environment:
- ADMIN_TOKEN - ADMIN_TOKEN
- DATABASE_URL - DATABASE_URL
- DOMAIN
- I_REALLY_WANT_VOLATILE_STORAGE - I_REALLY_WANT_VOLATILE_STORAGE
- LOG_LEVEL - LOG_LEVEL
- LOGIN_RATELIMIT_MAX_BURST - LOGIN_RATELIMIT_MAX_BURST
- ROCKET_PORT
- SIGNUPS_VERIFY - SIGNUPS_VERIFY
- SMTP_HOST - SMTP_HOST
- SMTP_FROM - SMTP_FROM

52
playwright/tests/passkey.spec.ts

@ -455,6 +455,58 @@ test.describe('Passkey grant is rejected when SSO_ONLY is on', () => {
const body: any = await res.json(); const body: any = await res.json();
expect(body?.message ?? '').toMatch(/SSO sign-in is required/i); expect(body?.message ?? '').toMatch(/SSO sign-in is required/i);
}); });
test('Login page hides "Log in with passkey" button under SSO_ONLY', async ({ page }) => {
// Defends the `.vw-passkey-login` hide rule in
// `src/static/templates/scss/vaultwarden.scss.hbs` (under
// `sso_enabled && sso_only`). Without the hide, the SPA renders
// the affordance and clicking it dead-ends with the server's
// "SSO sign-in is required" response from the assertion-options
// endpoint above — UX dead end.
await utils.cleanLanding(page);
// The button is in the DOM but %vw-hide applies `display: none`, so
// `toBeHidden()` (not `toHaveCount(0)`) is the right check — but pin
// presence first so a future class rename can't make it pass vacuously
// (an absent element also satisfies toBeHidden()).
await expect(page.locator('.vw-passkey-login')).toHaveCount(1);
await expect(page.locator('.vw-passkey-login')).toBeHidden();
});
test('/api/config omits pm-2035-passkey-unlock under SSO_ONLY', async ({ request }) => {
// Server-side gate at `build_feature_states` (mod.rs). The bundled web
// vault's `WebAuthnPrfUnlockService.isPrfUnlockAvailable` short-circuits
// to false when the flag is absent, hiding the lock-screen "Unlock with
// passkey" option client-side.
const configRes = await request.get('/api/config');
expect(configRes.status()).toBe(200);
const config: any = await configRes.json();
expect(config.featureStates, 'featureStates must be present in /api/config').toBeTruthy();
expect(config.featureStates['pm-2035-passkey-unlock']).toBeUndefined();
});
test('/css/vaultwarden.css emits the Add-passkey-button hide selector under SSO_ONLY', async ({ request }) => {
// Defends the SCSS conditional in
// `src/static/templates/scss/vaultwarden.scss.hbs` that emits
// `app-webauthn-login-settings > button[bitbutton]` under
// `(and sso_enabled sso_only)`. Mirrors Bitwarden's upstream
// template gate `*ngIf="hasData && !limitReached && !requireSsoPolicyEnabled"`
// on the "Turn on" / "New passkey" Add button — vaultwarden
// doesn't surface org policies to the client, so we apply the
// same hide via CSS. The credentials list + per-row Remove
// buttons (`button[bitlink]`, deeper in the `<table>`) stay
// rendered so users can revoke legacy credentials.
//
// String check rather than live DOM: the management page
// requires authentication, and a browser flow under SSO_ONLY
// would need the full Keycloak setup, so this pins the rule at the
// CSS layer. Rendered-UI coverage of the hide *under SSO_ONLY* is
// covered separately; the `account-lifecycle-sso` project runs
// SSO_ONLY=false.
const cssRes = await request.get('/css/vaultwarden.css');
expect(cssRes.status()).toBe(200);
const css = await cssRes.text();
expect(css).toContain('app-webauthn-login-settings>button[bitbutton]');
});
}); });
test.describe('Passkey enrolment is rejected when SSO_ONLY is on', () => { test.describe('Passkey enrolment is rejected when SSO_ONLY is on', () => {

82
src/api/core/accounts.rs

@ -26,6 +26,7 @@ use crate::{
WebAuthnCredential, WebAuthnCredentialId, WebAuthnCredential, WebAuthnCredentialId,
}, },
}, },
error::Error,
mail, mail,
util::{NumberOrString, deser_opt_nonempty_str, format_date}, util::{NumberOrString, deser_opt_nonempty_str, format_date},
}; };
@ -874,7 +875,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
// We only rotate the reset password key if it is set. // We only rotate the reset password key if it is set.
existing_memberships.retain(|m| m.reset_password_key.is_some()); existing_memberships.retain(|m| m.reset_password_key.is_some());
let mut existing_sends = Send::find_by_user(user_id, &conn).await; let mut existing_sends = Send::find_by_user(user_id, &conn).await;
let existing_webauthn_credentials = WebAuthnCredential::find_by_user(user_id, &conn).await; let existing_webauthn_credentials = WebAuthnCredential::find_by_user(user_id, &conn).await?;
validate_keydata( validate_keydata(
&data, &data,
@ -893,7 +894,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
.map(|c| c.uuid.clone()) .map(|c| c.uuid.clone())
.collect::<HashSet<WebAuthnCredentialId>>(); .collect::<HashSet<WebAuthnCredentialId>>();
let current_prf_ids = WebAuthnCredential::find_by_user(user_id, &conn) let current_prf_ids = WebAuthnCredential::find_by_user(user_id, &conn)
.await .await?
.into_iter() .into_iter()
.filter(WebAuthnCredential::has_prf_keyset) .filter(WebAuthnCredential::has_prf_keyset)
.map(|c| c.uuid) .map(|c| c.uuid)
@ -940,29 +941,6 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
membership.save(&conn).await?; membership.save(&conn).await?;
} }
// Update passkey-login credential keys (the PRF "rotateable key set") so that
// passwordless decryption keeps working after the account key is rotated.
// The client only sends credentials whose PRF keyset is fully enabled.
//
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_id, &conn).await
else {
err!("Passkey doesn't exist")
};
// 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() {
err!("Passkey is not in a PRF-enabled state")
}
// 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);
credential.update_keys(&conn).await?;
}
// Update send data // Update send data
for send_data in data.account_data.sends { for send_data in data.account_data.sends {
let Some(send) = existing_sends.iter_mut().find(|s| &s.uuid == send_data.id.as_ref().unwrap()) else { let Some(send) = existing_sends.iter_mut().find(|s| &s.uuid == send_data.id.as_ref().unwrap()) else {
@ -996,7 +974,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
// this guard, the new akey commits and the orphan credential can never // this guard, the new akey commits and the orphan credential can never
// unlock the vault again. // unlock the vault again.
let post_loop_prf_ids = WebAuthnCredential::find_by_user(user_id, &conn) let post_loop_prf_ids = WebAuthnCredential::find_by_user(user_id, &conn)
.await .await?
.into_iter() .into_iter()
.filter(WebAuthnCredential::has_prf_keyset) .filter(WebAuthnCredential::has_prf_keyset)
.map(|c| c.uuid) .map(|c| c.uuid)
@ -1020,12 +998,62 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
let save_result = user.save(&conn).await; 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);
if let Err(e) = credential.update_keys(&conn).await {
outcome = Err(e);
break;
}
}
outcome
} else {
Ok(())
};
// Prevent logging out the client where the user requested this endpoint from. // 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. // If you do logout the user it will causes issues at the client side.
// Adding the device uuid will prevent this. // Adding the device uuid will prevent this.
nt.send_logout(&user, Some(&headers.device), &conn).await; nt.send_logout(&user, Some(&headers.device), &conn).await;
save_result save_result.and(rewrap_result)
} }
#[post("/accounts/security-stamp", data = "<data>")] #[post("/accounts/security-stamp", data = "<data>")]

9
src/api/core/ciphers.rs

@ -191,8 +191,13 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
// Always include `webAuthnPrfOptions` (possibly empty) so the client's lock-screen logic // Always include `webAuthnPrfOptions` (possibly empty) so the client's lock-screen logic
// can render the "Unlock with passkey" option without an extra round-trip. The shape mirrors // can render the "Unlock with passkey" option without an extra round-trip. The shape mirrors
// upstream `SyncResponseModel.UserDecryption.WebAuthnPrfOptions`. // upstream `SyncResponseModel.UserDecryption.WebAuthnPrfOptions`.
let webauthn_prf_options = // Bitwarden disallows account passkeys under Require SSO; expose the
api::identity::build_webauthn_prf_options(&WebAuthnCredential::find_by_user(&headers.user.uuid, &conn).await); // canonical empty array so clients keep the unlock affordance hidden.
let webauthn_prf_options = if CONFIG.sso_enabled() && CONFIG.sso_only() {
Vec::new()
} else {
api::identity::build_webauthn_prf_options(&WebAuthnCredential::find_by_user(&headers.user.uuid, &conn).await?)
};
Ok(Json(json!({ Ok(Json(json!({
"profile": user_json, "profile": user_json,

267
src/api/core/mod.rs

@ -15,6 +15,7 @@ pub use emergency_access::{emergency_notification_reminder_job, emergency_reques
pub use events::{event_cleanup_job, log_event, log_user_event}; pub use events::{event_cleanup_job, log_event, log_user_event};
pub use sends::purge_sends; pub use sends::purge_sends;
use chrono::Utc;
use reqwest::Method; use reqwest::Method;
use rocket::{Catcher, Route, http::Status, serde::json::Json, serde::json::Value}; use rocket::{Catcher, Route, http::Status, serde::json::Json, serde::json::Value};
use webauthn_rs::prelude::{Passkey, PasskeyAuthentication, PasskeyRegistration}; use webauthn_rs::prelude::{Passkey, PasskeyAuthentication, PasskeyRegistration};
@ -41,6 +42,8 @@ use crate::{
util::{FeatureFlagFilter, get_uuid, parse_experimental_client_feature_flags}, util::{FeatureFlagFilter, get_uuid, parse_experimental_client_feature_flags},
}; };
const WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS: i64 = 300;
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
let mut eq_domains_routes = routes![get_settings_domains, post_settings_domains, put_settings_domains]; let mut eq_domains_routes = routes![get_settings_domains, post_settings_domains, put_settings_domains];
let mut hibp_routes = routes![hibp_breach]; let mut hibp_routes = routes![hibp_breach];
@ -207,7 +210,7 @@ fn alive(_conn: DbConn) -> Json<String> {
#[get("/now")] #[get("/now")]
pub fn now() -> Json<String> { pub fn now() -> Json<String> {
Json(crate::util::format_date(&chrono::Utc::now().naive_utc())) Json(crate::util::format_date(&Utc::now().naive_utc()))
} }
#[get("/version")] #[get("/version")]
@ -216,11 +219,11 @@ fn version() -> Json<&'static str> {
} }
#[get("/webauthn")] #[get("/webauthn")]
async fn get_api_webauthn(headers: Headers, conn: DbConn) -> Json<Value> { async fn get_api_webauthn(headers: Headers, conn: DbConn) -> JsonResult {
let user = headers.user; let user = headers.user;
let data: Vec<Value> = WebAuthnCredential::find_by_user(&user.uuid, &conn) let data: Vec<Value> = WebAuthnCredential::find_by_user(&user.uuid, &conn)
.await .await?
.into_iter() .into_iter()
.map(|wac| { .map(|wac| {
json!({ json!({
@ -235,17 +238,19 @@ async fn get_api_webauthn(headers: Headers, conn: DbConn) -> Json<Value> {
}) })
.collect(); .collect();
Json(json!({ Ok(Json(json!({
"object": "list", "object": "list",
"data": data, "data": data,
"continuationToken": null "continuationToken": null
})) })))
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct WebAuthnPasskeyRegistrationChallenge { struct WebAuthnPasskeyRegistrationChallenge {
token: String, token: String,
created_at: i64,
user_security_stamp: String,
state: PasskeyRegistration, state: PasskeyRegistration,
} }
@ -253,10 +258,26 @@ struct WebAuthnPasskeyRegistrationChallenge {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct WebAuthnPasskeyAssertionChallenge { struct WebAuthnPasskeyAssertionChallenge {
token: String, token: String,
created_at: i64,
user_security_stamp: String,
state: PasskeyAuthentication, state: PasskeyAuthentication,
} }
fn passkey_registration_challenge_state(data: &str, token: Option<&str>) -> ApiResult<PasskeyRegistration> { 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<PasskeyRegistration> {
// Persisted challenge rows are always the `{token, state}` wrapper — // Persisted challenge rows are always the `{token, state}` wrapper —
// nothing in the current code path writes the bare `PasskeyRegistration` // nothing in the current code path writes the bare `PasskeyRegistration`
// shape. Reject a row that doesn't deserialise (corrupted, stale schema) // shape. Reject a row that doesn't deserialise (corrupted, stale schema)
@ -268,10 +289,20 @@ fn passkey_registration_challenge_state(data: &str, token: Option<&str>) -> ApiR
if !token.is_some_and(|t| crypto::ct_eq(t, &saved.token)) { if !token.is_some_and(|t| crypto::ct_eq(t, &saved.token)) {
err!("Invalid registration challenge. Please try again.") 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) Ok(saved.state)
} }
fn passkey_assertion_challenge_state(data: &str, token: &str) -> ApiResult<PasskeyAuthentication> { fn passkey_assertion_challenge_state(
data: &str,
token: &str,
user_security_stamp: &str,
) -> ApiResult<PasskeyAuthentication> {
// Same shape contract as `passkey_registration_challenge_state` above — // Same shape contract as `passkey_registration_challenge_state` above —
// reject undecodable rows with the generic message rather than leaking // reject undecodable rows with the generic message rather than leaking
// the underlying serde error. // the underlying serde error.
@ -281,6 +312,12 @@ fn passkey_assertion_challenge_state(data: &str, token: &str) -> ApiResult<Passk
if !crypto::ct_eq(token, &saved.token) { if !crypto::ct_eq(token, &saved.token) {
err!("Invalid assertion challenge. Please try again.") 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) Ok(saved.state)
} }
@ -312,7 +349,7 @@ async fn post_api_webauthn_attestation_options(
data.validate(&user, true, &conn).await?; data.validate(&user, true, &conn).await?;
let all_creds = WebAuthnCredential::find_by_user(&user.uuid, &conn).await; let all_creds = WebAuthnCredential::find_by_user(&user.uuid, &conn).await?;
let existing_cred_ids: Vec<_> = all_creds let existing_cred_ids: Vec<_> = all_creds
.into_iter() .into_iter()
.filter_map(|wac| { .filter_map(|wac| {
@ -342,20 +379,16 @@ async fn post_api_webauthn_attestation_options(
asc.resident_key = Some(webauthn_rs_proto::ResidentKeyRequirement::Required); asc.resident_key = Some(webauthn_rs_proto::ResidentKeyRequirement::Required);
} }
// Drop any abandoned challenge from a previous, unfinished registration // Atomically drop any abandoned challenge from a previous, unfinished
// attempt so these rows cannot accumulate. `TwoFactor::save` below uses // registration attempt so only one in-flight challenge state per user
// `replace_into` keyed on `uuid` (not on `(user_uuid, atype)`), so without // exists at any time.
// this delete, sqlite/mysql would insert a sibling row each retry. TwoFactor::take_by_user_and_type(&user.uuid, TwoFactorType::WebauthnPasskeyRegisterChallenge as i32, &conn).await?;
if let Some(tf) =
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::WebauthnPasskeyRegisterChallenge as i32, &conn)
.await
{
tf.delete(&conn).await?;
}
let token = get_uuid(); let token = get_uuid();
let saved_challenge = WebAuthnPasskeyRegistrationChallenge { let saved_challenge = WebAuthnPasskeyRegistrationChallenge {
token: token.clone(), token: token.clone(),
created_at: Utc::now().timestamp(),
user_security_stamp: user.security_stamp,
state, state,
}; };
@ -401,7 +434,7 @@ async fn post_api_webauthn_assertion_options(
data.validate(&user, true, &conn).await?; data.validate(&user, true, &conn).await?;
let credentials: Vec<Passkey> = WebAuthnCredential::find_by_user(&user.uuid, &conn) let credentials: Vec<Passkey> = WebAuthnCredential::find_by_user(&user.uuid, &conn)
.await .await?
.into_iter() .into_iter()
.filter(|wac| wac.supports_prf) .filter(|wac| wac.supports_prf)
.filter_map(|wac| serde_json::from_str(&wac.credential).ok()) .filter_map(|wac| serde_json::from_str(&wac.credential).ok())
@ -413,16 +446,16 @@ async fn post_api_webauthn_assertion_options(
let (response, state) = WEBAUTHN.start_passkey_authentication(&credentials)?; let (response, state) = WEBAUTHN.start_passkey_authentication(&credentials)?;
if let Some(tf) = // Atomically drop any abandoned challenge from a previous attempt — see
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::WebauthnPasskeyAssertionChallenge as i32, &conn) // the comment on `post_api_webauthn_attestation_options`.
.await TwoFactor::take_by_user_and_type(&user.uuid, TwoFactorType::WebauthnPasskeyAssertionChallenge as i32, &conn)
{ .await?;
tf.delete(&conn).await?;
}
let token = get_uuid(); let token = get_uuid();
let saved_challenge = WebAuthnPasskeyAssertionChallenge { let saved_challenge = WebAuthnPasskeyAssertionChallenge {
token: token.clone(), token: token.clone(),
created_at: Utc::now().timestamp(),
user_security_stamp: user.security_stamp,
state, state,
}; };
TwoFactor::new( TwoFactor::new(
@ -457,12 +490,20 @@ async fn post_api_webauthn(
data: Json<WebAuthnLoginCredentialCreateRequest>, data: Json<WebAuthnLoginCredentialCreateRequest>,
headers: Headers, headers: Headers,
conn: DbConn, conn: DbConn,
nt: Notify<'_>,
) -> ApiResult<Status> { ) -> ApiResult<Status> {
crate::ratelimit::check_limit_login(&headers.ip.ip)?; crate::ratelimit::check_limit_login(&headers.ip.ip)?;
let data: WebAuthnLoginCredentialCreateRequest = data.into_inner(); let data: WebAuthnLoginCredentialCreateRequest = data.into_inner();
let user = headers.user; 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() { if CONFIG.sso_enabled() && CONFIG.sso_only() {
err!("Passkeys cannot be created when SSO sign-in is required") err!("Passkeys cannot be created when SSO sign-in is required")
} }
@ -471,20 +512,27 @@ async fn post_api_webauthn(
// finishes for the same registration row cannot both succeed and create // finishes for the same registration row cannot both succeed and create
// duplicate `web_authn_credentials` entries — only the caller whose DELETE // duplicate `web_authn_credentials` entries — only the caller whose DELETE
// removes the row proceeds. // 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 type_ = TwoFactorType::WebauthnPasskeyRegisterChallenge as i32;
let Some(tf) = TwoFactor::take_by_user_and_type(&user.uuid, type_, &conn).await else { let Some(tf) = TwoFactor::take_by_user_and_type(&user.uuid, type_, &conn).await? else {
err!("No registration challenge found. Please try again.") err!("No registration challenge found. Please try again.")
}; };
let state = passkey_registration_challenge_state(&tf.data, data.token.as_deref())?; let state = passkey_registration_challenge_state(&tf.data, data.token.as_deref(), &current_user.security_stamp)?;
let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?; let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?;
let credential_id_hash = passkey_credential_id_hash(&credential); let credential_id_hash = passkey_credential_id_hash(&credential);
if WebAuthnCredential::credential_id_hash_exists(&credential_id_hash, &conn).await { // Duplicate detection is enforced by the `web_authn_credentials` table's
err!("Passkey is already registered") // 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( WebAuthnCredential::new(
user.uuid, current_user.uuid.clone(),
data.name, data.name,
serde_json::to_string(&credential)?, serde_json::to_string(&credential)?,
credential_id_hash, credential_id_hash,
@ -496,6 +544,9 @@ async fn post_api_webauthn(
.save(&conn) .save(&conn)
.await?; .await?;
current_user.update_revision(&conn).await?;
nt.send_user_update(UpdateType::SyncVault, &current_user, headers.device.push_uuid.as_ref(), &conn).await;
Ok(Status::Ok) Ok(Status::Ok)
} }
@ -514,12 +565,20 @@ async fn put_api_webauthn(
data: Json<WebAuthnLoginCredentialUpdateRequest>, data: Json<WebAuthnLoginCredentialUpdateRequest>,
headers: Headers, headers: Headers,
conn: DbConn, conn: DbConn,
nt: Notify<'_>,
) -> ApiResult<Status> { ) -> ApiResult<Status> {
crate::ratelimit::check_limit_login(&headers.ip.ip)?; crate::ratelimit::check_limit_login(&headers.ip.ip)?;
let data: WebAuthnLoginCredentialUpdateRequest = data.into_inner(); let data: WebAuthnLoginCredentialUpdateRequest = data.into_inner();
let user = headers.user; 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() { if CONFIG.sso_enabled() && CONFIG.sso_only() {
err!("Passkeys cannot be updated when SSO sign-in is required") err!("Passkeys cannot be updated when SSO sign-in is required")
} }
@ -538,16 +597,20 @@ async fn put_api_webauthn(
// updates for the same assertion row cannot both succeed and apply // updates for the same assertion row cannot both succeed and apply
// different blob payloads — only the caller whose DELETE removes the row // different blob payloads — only the caller whose DELETE removes the row
// proceeds. // 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 type_ = TwoFactorType::WebauthnPasskeyAssertionChallenge as i32;
let Some(tf) = TwoFactor::take_by_user_and_type(&user.uuid, type_, &conn).await else { let Some(tf) = TwoFactor::take_by_user_and_type(&user.uuid, type_, &conn).await? else {
err!("No assertion challenge found. Please try again.") err!("No assertion challenge found. Please try again.")
}; };
let state = passkey_assertion_challenge_state(&tf.data, &data.token)?; let state = passkey_assertion_challenge_state(&tf.data, &data.token, &current_user.security_stamp)?;
let credential_response = data.device_response.into(); let credential_response = data.device_response.into();
let mut parsed_credentials: Vec<(WebAuthnCredential, Passkey)> = let mut parsed_credentials: Vec<(WebAuthnCredential, Passkey)> =
WebAuthnCredential::find_by_user(&user.uuid, &conn) WebAuthnCredential::find_by_user(&current_user.uuid, &conn)
.await .await?
.into_iter() .into_iter()
.filter_map(|wac| { .filter_map(|wac| {
let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?; let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?;
@ -581,6 +644,9 @@ async fn put_api_webauthn(
matched_wac.encrypted_private_key = Some(encrypted_private_key); matched_wac.encrypted_private_key = Some(encrypted_private_key);
matched_wac.update_prf_keyset(&conn).await?; matched_wac.update_prf_keyset(&conn).await?;
current_user.update_revision(&conn).await?;
nt.send_user_update(UpdateType::SyncVault, &current_user, headers.device.push_uuid.as_ref(), &conn).await;
Ok(Status::Ok) Ok(Status::Ok)
} }
@ -597,16 +663,20 @@ async fn post_api_webauthn_delete(
uuid: WebAuthnCredentialId, uuid: WebAuthnCredentialId,
headers: Headers, headers: Headers,
conn: DbConn, conn: DbConn,
nt: Notify<'_>,
) -> ApiResult<Status> { ) -> ApiResult<Status> {
crate::ratelimit::check_limit_login(&headers.ip.ip)?; crate::ratelimit::check_limit_login(&headers.ip.ip)?;
let data: PasswordOrOtpData = data.into_inner(); let data: PasswordOrOtpData = data.into_inner();
let user = headers.user; let mut user = headers.user;
data.validate(&user, true, &conn).await?; data.validate(&user, true, &conn).await?;
WebAuthnCredential::delete_by_uuid_and_user(&uuid, &user.uuid, &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) Ok(Status::Ok)
} }
@ -659,23 +729,53 @@ mod tests {
fn registration_challenge_accepts_wrapped_state_with_matching_token() { fn registration_challenge_accepts_wrapped_state_with_matching_token() {
let saved = WebAuthnPasskeyRegistrationChallenge { let saved = WebAuthnPasskeyRegistrationChallenge {
token: String::from("token"), token: String::from("token"),
created_at: Utc::now().timestamp(),
user_security_stamp: String::from("stamp"),
state: registration_state(), state: registration_state(),
}; };
let data = serde_json::to_string(&saved).unwrap(); let data = serde_json::to_string(&saved).unwrap();
assert!(passkey_registration_challenge_state(&data, Some("token")).is_ok()); assert!(passkey_registration_challenge_state(&data, Some("token"), "stamp").is_ok());
} }
#[test] #[test]
fn registration_challenge_rejects_wrapped_state_without_matching_token() { fn registration_challenge_rejects_wrapped_state_without_matching_token() {
let saved = WebAuthnPasskeyRegistrationChallenge { let saved = WebAuthnPasskeyRegistrationChallenge {
token: String::from("token"), token: String::from("token"),
created_at: Utc::now().timestamp(),
user_security_stamp: String::from("stamp"),
state: registration_state(), state: registration_state(),
}; };
let data = serde_json::to_string(&saved).unwrap(); let data = serde_json::to_string(&saved).unwrap();
assert!(passkey_registration_challenge_state(&data, Some("wrong")).is_err()); assert!(passkey_registration_challenge_state(&data, Some("wrong"), "stamp").is_err());
assert!(passkey_registration_challenge_state(&data, None).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 — /// `passkey_registration_challenge_state` has no legacy unwrapped fallback —
@ -689,9 +789,9 @@ mod tests {
fn registration_challenge_rejects_unwrapped_legacy_state() { fn registration_challenge_rejects_unwrapped_legacy_state() {
let data = serde_json::to_string(&registration_state()).unwrap(); let data = serde_json::to_string(&registration_state()).unwrap();
assert!(passkey_registration_challenge_state(&data, None).is_err()); assert!(passkey_registration_challenge_state(&data, None, "stamp").is_err());
assert!(passkey_registration_challenge_state(&data, Some("any-token")).is_err()); assert!(passkey_registration_challenge_state(&data, Some("any-token"), "stamp").is_err());
assert!(passkey_registration_challenge_state(&data, Some("")).is_err()); assert!(passkey_registration_challenge_state(&data, Some(""), "stamp").is_err());
} }
#[test] #[test]
@ -699,12 +799,42 @@ mod tests {
let (_response, state) = webauthn().start_passkey_authentication(&[passkey()]).unwrap(); let (_response, state) = webauthn().start_passkey_authentication(&[passkey()]).unwrap();
let saved = WebAuthnPasskeyAssertionChallenge { let saved = WebAuthnPasskeyAssertionChallenge {
token: String::from("token"), token: String::from("token"),
created_at: Utc::now().timestamp(),
user_security_stamp: String::from("stamp"),
state, state,
}; };
let data = serde_json::to_string(&saved).unwrap(); let data = serde_json::to_string(&saved).unwrap();
assert!(passkey_assertion_challenge_state(&data, "token").is_ok()); assert!(passkey_assertion_challenge_state(&data, "token", "stamp").is_ok());
assert!(passkey_assertion_challenge_state(&data, "wrong").is_err()); 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] #[test]
@ -766,19 +896,22 @@ mod tests {
let (_response, state) = webauthn().start_passkey_authentication(&[passkey()]).unwrap(); let (_response, state) = webauthn().start_passkey_authentication(&[passkey()]).unwrap();
let bare = serde_json::to_string(&state).unwrap(); let bare = serde_json::to_string(&state).unwrap();
assert!(passkey_assertion_challenge_state(&bare, "any-token").is_err()); assert!(passkey_assertion_challenge_state(&bare, "any-token", "stamp").is_err());
assert!(passkey_assertion_challenge_state(&bare, "").is_err()); assert!(passkey_assertion_challenge_state(&bare, "", "stamp").is_err());
} }
/// `build_feature_states` must emit `pm-2035-passkey-unlock = true` /// `build_feature_states` must emit `pm-2035-passkey-unlock = true` when
/// unconditionally — without it, the web vault's /// account passkeys are allowed; without it, the web vault's
/// `WebAuthnPrfUnlockService.isPrfUnlockAvailable` short-circuits to false /// `WebAuthnPrfUnlockService.isPrfUnlockAvailable` short-circuits to false.
/// and the lock-screen "Unlock with passkey" option never renders even for
/// a user with a PRF-enabled passkey enrolled.
#[test] #[test]
fn feature_states_emits_passkey_unlock_flag_unconditionally() { fn feature_states_emits_passkey_unlock_flag_when_allowed() {
assert_eq!(build_feature_states("").get("pm-2035-passkey-unlock"), Some(&true)); assert_eq!(build_feature_states("", true).get("pm-2035-passkey-unlock"), Some(&true));
assert_eq!(build_feature_states("some-unrelated-flag").get("pm-2035-passkey-unlock"), Some(&true)); assert_eq!(build_feature_states("some-unrelated-flag", true).get("pm-2035-passkey-unlock"), Some(&true));
}
#[test]
fn feature_states_omits_passkey_unlock_flag_when_disallowed() {
assert!(!build_feature_states("", false).contains_key("pm-2035-passkey-unlock"));
} }
/// `build_feature_states` must also emit `pm-19148-innovation-archive` /// `build_feature_states` must also emit `pm-19148-innovation-archive`
@ -786,7 +919,8 @@ mod tests {
/// alongside the passkey-unlock entry. /// alongside the passkey-unlock entry.
#[test] #[test]
fn feature_states_emits_innovation_archive_flag_unconditionally() { fn feature_states_emits_innovation_archive_flag_unconditionally() {
assert_eq!(build_feature_states("").get("pm-19148-innovation-archive"), Some(&true)); assert_eq!(build_feature_states("", true).get("pm-19148-innovation-archive"), Some(&true));
assert_eq!(build_feature_states("", false).get("pm-19148-innovation-archive"), Some(&true));
} }
/// Valid experimental flags from the SUPPORTED list pass through; invalid /// Valid experimental flags from the SUPPORTED list pass through; invalid
@ -795,13 +929,13 @@ mod tests {
#[test] #[test]
fn feature_states_passes_through_valid_experimental_flag() { fn feature_states_passes_through_valid_experimental_flag() {
let probe = crate::config::SUPPORTED_FEATURE_FLAGS.iter().next().expect("at least one supported flag"); let probe = crate::config::SUPPORTED_FEATURE_FLAGS.iter().next().expect("at least one supported flag");
let states = build_feature_states(probe); let states = build_feature_states(probe, true);
assert_eq!(states.get(*probe), Some(&true)); assert_eq!(states.get(*probe), Some(&true));
} }
#[test] #[test]
fn feature_states_drops_unknown_experimental_flag() { fn feature_states_drops_unknown_experimental_flag() {
let states = build_feature_states("definitely-not-a-real-bitwarden-flag"); let states = build_feature_states("definitely-not-a-real-bitwarden-flag", true);
assert!(!states.contains_key("definitely-not-a-real-bitwarden-flag")); assert!(!states.contains_key("definitely-not-a-real-bitwarden-flag"));
} }
} }
@ -815,24 +949,29 @@ mod tests {
/// Client (v2026.2.1): https://github.com/bitwarden/clients/blob/f96380c3138291a028bdd2c7a5fee540d5c98ba5/libs/common/src/enums/feature-flag.enum.ts#L12 /// Client (v2026.2.1): https://github.com/bitwarden/clients/blob/f96380c3138291a028bdd2c7a5fee540d5c98ba5/libs/common/src/enums/feature-flag.enum.ts#L12
/// Android (v2026.2.1): https://github.com/bitwarden/android/blob/6902c19c0093fa476bbf74ccaa70c9f14afbb82f/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt#L31 /// Android (v2026.2.1): https://github.com/bitwarden/android/blob/6902c19c0093fa476bbf74ccaa70c9f14afbb82f/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt#L31
/// iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7 /// iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
fn build_feature_states(experimental_client_feature_flags: &str) -> std::collections::HashMap<String, bool> { fn build_feature_states(
experimental_client_feature_flags: &str,
account_passkeys_allowed: bool,
) -> std::collections::HashMap<String, bool> {
let mut feature_states = let mut feature_states =
parse_experimental_client_feature_flags(experimental_client_feature_flags, &FeatureFlagFilter::ValidOnly); parse_experimental_client_feature_flags(experimental_client_feature_flags, &FeatureFlagFilter::ValidOnly);
feature_states.insert("pm-19148-innovation-archive".to_owned(), true); feature_states.insert("pm-19148-innovation-archive".to_owned(), true);
// Gates the web-vault's `Unlock with passkey` lock-screen option (and the // Gates the web-vault's `Unlock with passkey` lock-screen option (and the
// matching desktop/mobile UI). `WebAuthnPrfUnlockService.isPrfUnlockAvailable` // matching desktop/mobile UI). `WebAuthnPrfUnlockService.isPrfUnlockAvailable`
// short-circuits to `false` when this flag is absent or unset, hiding the // short-circuits to `false` when this flag is absent or unset. Vaultwarden
// option even for users with a PRF-enabled passkey enrolled. Vaultwarden // advertises it whenever account passkeys are allowed; SSO_ONLY suppresses
// supports PRF-passkey unlock end-to-end via `userDecryption.webAuthnPrfOptions` // it to match Bitwarden's "Require SSO" passkey restriction.
// on /sync, so the flag is advertised unconditionally. if account_passkeys_allowed {
feature_states.insert("pm-2035-passkey-unlock".to_owned(), true); feature_states.insert("pm-2035-passkey-unlock".to_owned(), true);
}
feature_states feature_states
} }
#[get("/config")] #[get("/config")]
fn config() -> Json<Value> { fn config() -> Json<Value> {
let domain = CONFIG.domain(); let domain = CONFIG.domain();
let feature_states = build_feature_states(&CONFIG.experimental_client_feature_flags()); let feature_states =
build_feature_states(&CONFIG.experimental_client_feature_flags(), !(CONFIG.sso_enabled() && CONFIG.sso_only()));
Json(json!({ Json(json!({
// Note: The clients use this version to handle backwards compatibility concerns // Note: The clients use this version to handle backwards compatibility concerns

85
src/api/identity.rs

@ -8,12 +8,10 @@ use rocket::{
serde::json::Json, serde::json::Json,
}; };
use serde_json::Value; use serde_json::Value;
use webauthn_rs::prelude::{Base64UrlSafeData, DiscoverableAuthentication, DiscoverableKey, Passkey}; use webauthn_rs::prelude::{DiscoverableAuthentication, DiscoverableKey, Passkey};
use webauthn_rs_proto::{ use webauthn_rs_proto::PublicKeyCredential;
AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, PublicKeyCredential,
};
use crate::api::core::two_factor::webauthn::WEBAUTHN; use crate::api::core::two_factor::webauthn::{PublicKeyCredentialCopy, WEBAUTHN};
use crate::{ use crate::{
CONFIG, CONFIG,
api::{ api::{
@ -1119,45 +1117,6 @@ async fn register_finish(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
register(data, true, conn).await register(data, true, conn).await
} }
// Copied from webauthn-rs to rename clientDataJSON -> clientDataJson for Bitwarden compatibility
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AssertionResponseCopy {
pub authenticator_data: Base64UrlSafeData,
#[serde(rename = "clientDataJson", alias = "clientDataJSON")]
pub client_data_json: Base64UrlSafeData,
pub signature: Base64UrlSafeData,
pub user_handle: Option<Base64UrlSafeData>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PublicKeyCredentialCopy {
pub id: String,
pub raw_id: Base64UrlSafeData,
pub response: AssertionResponseCopy,
pub r#type: String,
#[allow(dead_code)]
pub extensions: Option<Value>,
}
impl From<PublicKeyCredentialCopy> for PublicKeyCredential {
fn from(p: PublicKeyCredentialCopy) -> Self {
Self {
id: p.id,
raw_id: p.raw_id,
response: AuthenticatorAssertionResponseRaw {
authenticator_data: p.response.authenticator_data,
client_data_json: p.response.client_data_json,
signature: p.response.signature,
user_handle: p.response.user_handle,
},
extensions: AuthenticationExtensionsClientOutputs::default(),
type_: p.r#type,
}
}
}
fn passkey_credential_id(passkey: &Passkey) -> ApiResult<String> { fn passkey_credential_id(passkey: &Passkey) -> ApiResult<String> {
serde_json::to_value(passkey.cred_id())? serde_json::to_value(passkey.cred_id())?
.as_str() .as_str()
@ -1294,7 +1253,7 @@ async fn webauthn_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
// or which user the assertion later claims — a caller with a valid token // or which user the assertion later claims — a caller with a valid token
// cannot repeatedly replay it with malformed bodies. // cannot repeatedly replay it with malformed bodies.
let token = WebAuthnLoginChallengeId::from(data.token.as_ref().unwrap().clone()); let token = WebAuthnLoginChallengeId::from(data.token.as_ref().unwrap().clone());
let Some(saved_challenge) = WebAuthnLoginChallenge::take(&token, conn).await else { let Some(saved_challenge) = WebAuthnLoginChallenge::take(&token, conn).await? else {
err!( err!(
AUTH_FAILED, AUTH_FAILED,
format!("IP: {}. Missing or expired passkey login challenge.", ip.ip), format!("IP: {}. Missing or expired passkey login challenge.", ip.ip),
@ -1353,15 +1312,28 @@ async fn webauthn_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
let username = user.email.clone(); let username = user.email.clone();
// Load this user's passkey-login credentials. // Load this user's passkey-login credentials. A DB transient (deadlock-
let parsed_credentials: Vec<(WebAuthnCredential, Passkey)> = WebAuthnCredential::find_by_user(&user.uuid, conn) // victim, conn drop, lock timeout) is converted to AUTH_FAILED here rather
.await // than propagating: this endpoint is unauthenticated, so a panic or a
.into_iter() // distinct DB-error response would let an attacker amplify DB pressure
.filter_map(|wac| { // into worker DoS or fingerprint server state.
let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?; let parsed_credentials: Vec<(WebAuthnCredential, Passkey)> =
Some((wac, passkey)) match WebAuthnCredential::find_by_user(&user.uuid, conn).await {
}) Ok(creds) => creds
.collect(); .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
}
),
};
if parsed_credentials.is_empty() { if parsed_credentials.is_empty() {
err!( err!(
@ -1436,6 +1408,11 @@ async fn webauthn_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
}; };
// Persist any signature-counter advance from this assertion. // Persist any signature-counter advance from this assertion.
//
// 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.
if passkey.update_credential(&authentication_result) == Some(true) { if passkey.update_credential(&authentication_result) == Some(true) {
matched_wac.credential = serde_json::to_string(&passkey)?; matched_wac.credential = serde_json::to_string(&passkey)?;
matched_wac.update_credential(conn).await?; matched_wac.update_credential(conn).await?;

37
src/db/models/two_factor.rs

@ -7,7 +7,7 @@ use webauthn_rs_proto::{AttestationFormat, RegisteredExtensions};
use crate::{ use crate::{
api::{EmptyResult, core::two_factor::webauthn::WebauthnRegistration}, api::{EmptyResult, core::two_factor::webauthn::WebauthnRegistration},
db::{DbConn, schema::twofactor}, db::{DbConn, schema::twofactor},
error::MapResult, error::{Error, MapResult},
}; };
use super::UserId; use super::UserId;
@ -150,16 +150,21 @@ impl TwoFactor {
.await .await
} }
/// Atomically fetch and delete the row for this user+type. Returns Some /// Atomically fetch and delete the row for this user+type. Three outcomes:
/// only when the caller's DELETE actually removed a row, so two concurrent /// - `Ok(Some(_))` — winner of the SELECT+DELETE race; the row has been
/// callers (e.g. a double-clicked enrollment finish) cannot both proceed /// removed and the caller may consume the single-use challenge state.
/// with the same single-use challenge state — the loser sees None. The /// - `Ok(None)` — token absent, already consumed by a concurrent caller
/// surrounding transaction rolls back the SELECT+DELETE pair atomically /// (e.g. a double-clicked enrolment finish), or never existed. The
/// on a DB error, leaving the row intact rather than silently consuming it. /// caller should treat this as a normal "stale challenge" response.
pub async fn take_by_user_and_type(user_uuid: &UserId, atype: i32, conn: &DbConn) -> Option<Self> { /// - `Err(_)` — DB degradation (deadlock, conn drop, lock timeout). The
/// surrounding transaction rolled back atomically, so the row is
/// intact rather than silently consumed; propagating via `?` lets the
/// caller surface a 5xx instead of an indistinguishable 4xx stale-token
/// response.
pub async fn take_by_user_and_type(user_uuid: &UserId, atype: i32, conn: &DbConn) -> Result<Option<Self>, Error> {
let user_uuid = user_uuid.clone(); let user_uuid = user_uuid.clone();
conn.run(move |conn| { conn.run(move |conn| {
let result = conn.transaction::<Option<Self>, diesel::result::Error, _>(|conn| { conn.transaction::<Option<Self>, diesel::result::Error, _>(|conn| {
let tf = twofactor::table let tf = twofactor::table
.filter(twofactor::user_uuid.eq(&user_uuid)) .filter(twofactor::user_uuid.eq(&user_uuid))
.filter(twofactor::atype.eq(atype)) .filter(twofactor::atype.eq(atype))
@ -171,18 +176,8 @@ impl TwoFactor {
let deleted = let deleted =
diesel::delete(twofactor::table.filter(twofactor::uuid.eq(&existing.uuid))).execute(conn)?; diesel::delete(twofactor::table.filter(twofactor::uuid.eq(&existing.uuid))).execute(conn)?;
Ok(tf.filter(|_| deleted == 1)) Ok(tf.filter(|_| deleted == 1))
}); })
match result { .map_res("Error taking twofactor challenge")
Ok(opt) => opt,
Err(e) => {
// Surface the underlying error so DB degradation
// (deadlock, conn drop, lock timeout) is operator-
// visible rather than indistinguishable from a normal
// "row consumed by a concurrent caller" result.
error!("TwoFactor::take_by_user_and_type failed for user {user_uuid} atype {atype}: {e:#?}");
None
}
}
}) })
.await .await
} }

58
src/db/models/web_authn_credential.rs

@ -6,7 +6,7 @@ use macros::UuidFromParam;
use crate::api::EmptyResult; use crate::api::EmptyResult;
use crate::db::schema::{web_authn_credentials, web_authn_login_challenges}; use crate::db::schema::{web_authn_credentials, web_authn_login_challenges};
use crate::db::{DbConn, DbPool}; use crate::db::{DbConn, DbPool};
use crate::error::MapResult; use crate::error::{Error, MapResult};
use crate::util::get_uuid; use crate::util::get_uuid;
use super::UserId; use super::UserId;
@ -84,7 +84,7 @@ impl WebAuthnCredential {
match result { match result {
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation, _)) => { Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation, _)) => {
Err(crate::error::Error::new( Err(Error::new(
"Passkey is already registered", "Passkey is already registered",
"Duplicate WebAuthn credential ID", "Duplicate WebAuthn credential ID",
)) ))
@ -94,17 +94,6 @@ impl WebAuthnCredential {
}} }}
} }
pub async fn credential_id_hash_exists(credential_id_hash: &str, conn: &DbConn) -> bool {
db_run! { conn: {
web_authn_credentials::table
.filter(web_authn_credentials::credential_id_hash.eq(credential_id_hash))
.select(web_authn_credentials::credential_id_hash)
.first::<String>(conn)
.optional()
.is_ok_and(|credential| credential.is_some())
}}
}
/// Persist the serialized passkey blob after a successful assertion advances /// Persist the serialized passkey blob after a successful assertion advances
/// its signature counter. Touches only the `credential` column so a concurrent /// its signature counter. Touches only the `credential` column so a concurrent
/// key rotation cannot clobber it (see [`Self::update_keys`]). /// key rotation cannot clobber it (see [`Self::update_keys`]).
@ -148,12 +137,17 @@ impl WebAuthnCredential {
}} }}
} }
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> { /// Surface DB errors so callers can convert them into proper failure
/// responses instead of panicking inside `conn.run`'s blocking closure.
/// In particular, this is reachable from the unauthenticated
/// `webauthn_login` grant in `src/api/identity.rs`, where a transient
/// DB error must not crash the worker.
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Result<Vec<Self>, Error> {
db_run! { conn: { db_run! { conn: {
web_authn_credentials::table web_authn_credentials::table
.filter(web_authn_credentials::user_uuid.eq(user_uuid)) .filter(web_authn_credentials::user_uuid.eq(user_uuid))
.load::<Self>(conn) .load::<Self>(conn)
.expect("Error loading web_authn_credentials") .map_res("Error loading web_authn_credentials")
}} }}
} }
@ -223,9 +217,20 @@ impl WebAuthnLoginChallenge {
}} }}
} }
/// Fetch and delete a pending challenge (single-use). Returns `None` when the /// Fetch and delete a pending challenge (single-use). Three outcomes:
/// token is unknown, has already been consumed, or the challenge has expired. /// - `Ok(Some(_))` — winner of the SELECT+DELETE race; the row has been
pub async fn take(id: &WebAuthnLoginChallengeId, conn: &DbConn) -> Option<Self> { /// removed and the caller may verify the assertion against the state.
/// - `Ok(None)` — token unknown, already consumed by a concurrent caller,
/// or the row was current but past the TTL cutoff (post-transaction
/// filter). All three collapse to a single "stale or invalid challenge"
/// path so the caller can't distinguish them — a small AUTH_FAILED
/// information-leak hardening for the unauthenticated login endpoint.
/// - `Err(_)` — DB degradation (deadlock, conn drop, lock timeout). The
/// surrounding transaction rolled back atomically, so the row is intact
/// rather than silently consumed; propagating via `?` lets the caller
/// surface a 5xx instead of an indistinguishable 4xx stale-token
/// response.
pub async fn take(id: &WebAuthnLoginChallengeId, conn: &DbConn) -> Result<Option<Self>, Error> {
db_run! { conn: { db_run! { conn: {
// Single-use is enforced by the `deleted == 1` row-count guard, not // Single-use is enforced by the `deleted == 1` row-count guard, not
// by isolation: concurrent callers may all see the row in the // by isolation: concurrent callers may all see the row in the
@ -235,7 +240,7 @@ impl WebAuthnLoginChallenge {
// ensures the SELECT+DELETE pair rolls back atomically on a DB // ensures the SELECT+DELETE pair rolls back atomically on a DB
// error, leaving the challenge intact rather than silently // error, leaving the challenge intact rather than silently
// consuming it. // consuming it.
let taken = match conn let taken = conn
.transaction::<Option<WebAuthnLoginChallenge>, diesel::result::Error, _>(|conn| { .transaction::<Option<WebAuthnLoginChallenge>, diesel::result::Error, _>(|conn| {
let challenge = web_authn_login_challenges::table let challenge = web_authn_login_challenges::table
.filter(web_authn_login_challenges::id.eq(id)) .filter(web_authn_login_challenges::id.eq(id))
@ -246,20 +251,11 @@ impl WebAuthnLoginChallenge {
) )
.execute(conn)?; .execute(conn)?;
Ok(challenge.filter(|_| deleted == 1)) Ok(challenge.filter(|_| deleted == 1))
}) { })
Ok(opt) => opt, .map_res("Error taking web_authn_login_challenge")?;
Err(e) => {
// Surface the underlying error so a degrading DB
// (deadlock, conn drop, lock timeout) is operator-
// visible instead of masquerading as a stale-token
// rejection at the caller.
error!("WebAuthnLoginChallenge::take failed for id {id}: {e:#?}");
None
}
};
let cutoff = Utc::now().naive_utc() - TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_TTL_SECONDS); let cutoff = Utc::now().naive_utc() - TimeDelta::seconds(WEBAUTHN_LOGIN_CHALLENGE_TTL_SECONDS);
taken.filter(|c| c.created_at >= cutoff) Ok(taken.filter(|c| c.created_at >= cutoff))
}} }}
} }

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

@ -63,6 +63,19 @@ app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secon
.vw-passkey-login { .vw-passkey-login {
@extend %vw-hide; @extend %vw-hide;
} }
/* Mirror Bitwarden's `app-webauthn-login-settings` template gate
`*ngIf="hasData && !limitReached && !requireSsoPolicyEnabled"` on the
"Turn on" / "New passkey" Add button. Bitwarden suppresses just that
button under Require SSO; the credentials `<table>` and per-row Remove
buttons stay rendered so users can revoke legacy credentials from the
UI. Vaultwarden doesn't surface org policies to the client, so we apply
the same hide via CSS. The Add button is the only direct-child
`button[bitbutton]` of the component (per-row buttons use `bitlink` and
live deeper inside `<table>`). */
app-webauthn-login-settings > button[bitbutton] {
@extend %vw-hide;
}
{{/if}} {{/if}}
/* Hide the or text followed by the two buttons hidden above */ /* Hide the or text followed by the two buttons hidden above */

Loading…
Cancel
Save