Browse Source

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<Option<TwoFactor>, 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
pull/7297/head
Zaid Marji 2 weeks ago
parent
commit
019a1adf98
  1. 4
      README.md
  2. 2
      migrations/mysql/2026-02-12-000000_add_web_authn_credentials/up.sql
  3. 2
      migrations/postgresql/2026-02-12-000000_add_web_authn_credentials/up.sql
  4. 2
      migrations/sqlite/2026-02-12-000000_add_web_authn_credentials/up.sql
  5. 46
      playwright/tests/passkey.spec.ts
  6. 147
      src/api/core/accounts.rs
  7. 10
      src/api/core/ciphers.rs
  8. 721
      src/api/core/mod.rs
  9. 953
      src/api/core/passkeys.rs
  10. 45
      src/api/core/two_factor/webauthn.rs
  11. 649
      src/api/identity.rs
  12. 9
      src/config.rs
  13. 75
      src/db/models/two_factor.rs
  14. 35
      src/db/models/user.rs
  15. 334
      src/db/models/web_authn_credential.rs
  16. 7
      src/static/templates/scss/vaultwarden.scss.hbs
  17. 12
      src/util.rs

4
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)

2
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);

2
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);

2
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);

46
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<str
id: 'AAAA',
rawId: 'AAAA',
type: 'public-key',
// Optional: `PublicKeyCredentialCopy.extensions` is
// `#[serde(default)]`, so the server also accepts clients that
// omit it. Included to mirror the modern browser shape; the
// payload still parses and reaches the pre-verification user
// lookup (`identify_discoverable_authentication`) these tests
// are intended to exercise.
extensions: {},
response: {
authenticatorData: 'AAAA',
clientDataJson: 'AAAA',
signature: 'AAAA',
userHandle: base64url(userUuid),
userHandle: uuidToUserHandle(userUuid),
},
}),
token,
@ -449,7 +478,8 @@ test.describe('Passkey grant is rejected when SSO_ONLY is on', () => {
// 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

147
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<Vec<WebAuthnCredential>> {
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 = "<data>")]
async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
// TODO: See if we can wrap everything within a SQL Transaction. If something fails it should revert everything.
@ -888,20 +909,16 @@ async fn post_rotatekey(data: Json<KeyData>, 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::<HashSet<WebAuthnCredentialId>>();
let current_prf_ids = WebAuthnCredential::find_by_user(user_id, &conn)
.await?
.into_iter()
.filter(WebAuthnCredential::has_prf_keyset)
.map(|c| c.uuid)
.collect::<HashSet<WebAuthnCredentialId>>();
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<KeyData>, 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::<HashSet<WebAuthnCredentialId>>();
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<KeyData>, 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 = "<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)];

10
src/api/core/ciphers.rs

@ -191,12 +191,12 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
// 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
// upstream `SyncResponseModel.UserDecryption.WebAuthnPrfOptions`.
// Bitwarden disallows account passkeys under Require SSO; expose the
// canonical empty array so clients keep the unlock affordance hidden.
let webauthn_prf_options = if CONFIG.sso_enabled() && CONFIG.sso_only() {
Vec::new()
} else {
// Expose the canonical empty array whenever account passkeys are not
// usable, so clients keep the lock-screen affordance hidden.
let webauthn_prf_options = if api::core::account_passkeys_allowed() {
api::identity::build_webauthn_prf_options(&WebAuthnCredential::find_by_user(&headers.user.uuid, &conn).await?)
} else {
Vec::new()
};
Ok(Json(json!({

721
src/api/core/mod.rs

@ -6,6 +6,7 @@ mod emergency_access;
mod events;
mod folders;
mod organizations;
mod passkeys;
mod public;
mod sends;
@ -13,52 +14,31 @@ pub use accounts::purge_auth_requests;
pub use ciphers::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciphers};
pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job};
pub use events::{event_cleanup_job, log_event, log_user_event};
pub(crate) use passkeys::{account_passkeys_allowed, check_passkey_endpoint_preconditions, passkey_credential_id_hash};
pub use sends::purge_sends;
use chrono::Utc;
use reqwest::Method;
use rocket::{Catcher, Route, http::Status, serde::json::Json, serde::json::Value};
use webauthn_rs::prelude::{Passkey, PasskeyAuthentication, PasskeyRegistration};
use webauthn_rs_proto::UserVerificationPolicy;
use rocket::{Catcher, Route, serde::json::Json, serde::json::Value};
use crate::{
CONFIG,
api::{
ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
core::two_factor::webauthn::{PublicKeyCredentialCopy, RegisterPublicKeyCredentialCopy, WEBAUTHN},
},
api::{EmptyResult, JsonResult, Notify, UpdateType},
auth::Headers,
crypto,
db::{
DbConn,
models::{
Membership, MembershipStatus, OrgPolicy, Organization, TwoFactor, TwoFactorType, User, WebAuthnCredential,
WebAuthnCredentialId,
},
models::{Membership, MembershipStatus, OrgPolicy, Organization, User},
},
error::Error,
http_client::make_http_request,
mail,
util::{FeatureFlagFilter, get_uuid, parse_experimental_client_feature_flags},
util::{FeatureFlagFilter, parse_experimental_client_feature_flags},
};
const WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS: i64 = 300;
pub fn routes() -> Vec<Route> {
let mut eq_domains_routes = routes![get_settings_domains, post_settings_domains, put_settings_domains];
let mut hibp_routes = routes![hibp_breach];
let mut meta_routes = routes![
alive,
now,
version,
config,
get_api_webauthn,
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<Route> {
routes.append(&mut two_factor::routes());
routes.append(&mut sends::routes());
routes.append(&mut public::routes());
routes.append(&mut passkeys::routes());
routes.append(&mut eq_domains_routes);
routes.append(&mut hibp_routes);
routes.append(&mut meta_routes);
@ -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<Value> = 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<PasskeyRegistration> {
// 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::<WebAuthnPasskeyRegistrationChallenge>(data) else {
err!("Invalid registration challenge. Please try again.")
};
if !token.is_some_and(|t| crypto::ct_eq(t, &saved.token)) {
err!("Invalid registration challenge. Please try again.")
}
if !passkey_management_challenge_is_fresh(saved.created_at) {
err!("Invalid registration challenge. Please try again.")
}
if !crypto::ct_eq(user_security_stamp, &saved.user_security_stamp) {
err!("Invalid registration challenge. Please try again.")
}
Ok(saved.state)
}
fn passkey_assertion_challenge_state(
data: &str,
token: &str,
user_security_stamp: &str,
) -> ApiResult<PasskeyAuthentication> {
// 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::<WebAuthnPasskeyAssertionChallenge>(data) else {
err!("Invalid assertion challenge. Please try again.")
};
if !crypto::ct_eq(token, &saved.token) {
err!("Invalid assertion challenge. Please try again.")
}
if !passkey_management_challenge_is_fresh(saved.created_at) {
err!("Invalid assertion challenge. Please try again.")
}
if !crypto::ct_eq(user_security_stamp, &saved.user_security_stamp) {
err!("Invalid assertion challenge. Please try again.")
}
Ok(saved.state)
}
fn passkey_credential_id_hash(passkey: &Passkey) -> String {
crypto::sha256_hex(passkey.cred_id().as_slice())
}
#[post("/webauthn/attestation-options", data = "<data>")]
async fn post_api_webauthn_attestation_options(
data: Json<PasswordOrOtpData>,
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 = "<data>")]
async fn post_api_webauthn_assertion_options(
data: Json<PasswordOrOtpData>,
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<Passkey> = 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<String>,
supports_prf: bool,
encrypted_user_key: Option<String>,
encrypted_public_key: Option<String>,
encrypted_private_key: Option<String>,
}
#[post("/webauthn", data = "<data>")]
async fn post_api_webauthn(
data: Json<WebAuthnLoginCredentialCreateRequest>,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> ApiResult<Status> {
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(), &current_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, &current_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<String>,
encrypted_public_key: Option<String>,
encrypted_private_key: Option<String>,
}
#[put("/webauthn", data = "<data>")]
async fn put_api_webauthn(
data: Json<WebAuthnLoginCredentialUpdateRequest>,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> ApiResult<Status> {
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, &current_user.security_stamp)?;
let credential_response = data.device_response.into();
let mut parsed_credentials: Vec<(WebAuthnCredential, Passkey)> =
WebAuthnCredential::find_by_user(&current_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, &current_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/<uuid>/delete", data = "<data>")]
async fn post_api_webauthn_delete(
data: Json<PasswordOrOtpData>,
uuid: WebAuthnCredentialId,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> ApiResult<Status> {
crate::ratelimit::check_limit_login(&headers.ip.ip)?;
let data: PasswordOrOtpData = data.into_inner();
let mut user = headers.user;
data.validate(&user, true, &conn).await?;
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(&registration_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<Value> {
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

953
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<Route> {
routes![
get_api_webauthn,
post_api_webauthn,
put_api_webauthn,
post_api_webauthn_assertion_options,
post_api_webauthn_attestation_options,
post_api_webauthn_delete,
]
}
#[get("/webauthn")]
async fn get_api_webauthn(headers: Headers, conn: DbConn) -> JsonResult {
let user = headers.user;
let data: Vec<Value> = WebAuthnCredential::find_by_user(&user.uuid, &conn)
.await?
.into_iter()
.map(|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<PasskeyRegistration> {
// 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::<WebAuthnPasskeyRegistrationChallenge>(data) else {
err!("Invalid registration challenge. Please try again.")
};
if !token.is_some_and(|t| crypto::ct_eq(t, &saved.token)) {
err!("Invalid registration challenge. Please try again.")
}
if !passkey_management_challenge_is_fresh(saved.created_at) {
err!("Invalid registration challenge. Please try again.")
}
if !crypto::ct_eq(user_security_stamp, &saved.user_security_stamp) {
err!("Invalid registration challenge. Please try again.")
}
Ok(saved.state)
}
fn passkey_assertion_challenge_state(
data: &str,
token: &str,
user_security_stamp: &str,
) -> ApiResult<PasskeyAuthentication> {
// 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::<WebAuthnPasskeyAssertionChallenge>(data) else {
err!("Invalid assertion challenge. Please try again.")
};
if !crypto::ct_eq(token, &saved.token) {
err!("Invalid assertion challenge. Please try again.")
}
if !passkey_management_challenge_is_fresh(saved.created_at) {
err!("Invalid assertion challenge. Please try again.")
}
if !crypto::ct_eq(user_security_stamp, &saved.user_security_stamp) {
err!("Invalid assertion challenge. Please try again.")
}
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<String>, Option<String>, Option<String>);
fn passkey_registration_prf_data(
client_supports_prf: bool,
encrypted_user_key: Option<String>,
encrypted_public_key: Option<String>,
encrypted_private_key: Option<String>,
server_supports_prf: bool,
) -> ApiResult<PasskeyRegistrationPrfData> {
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 = "<data>")]
async fn post_api_webauthn_attestation_options(
data: Json<PasswordOrOtpData>,
headers: Headers,
conn: DbConn,
) -> JsonResult {
check_passkey_endpoint_preconditions(&headers.ip.ip, "created")?;
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user;
data.validate(&user, true, &conn).await?;
let all_creds = WebAuthnCredential::find_by_user(&user.uuid, &conn).await?;
if passkey_count_limit_reached(all_creds.len()) {
err!("Maximum number of passkeys reached")
}
let existing_cred_ids: Vec<_> = all_creds
.into_iter()
.filter_map(|wac| {
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 = "<data>")]
async fn post_api_webauthn_assertion_options(
data: Json<PasswordOrOtpData>,
headers: Headers,
conn: DbConn,
) -> JsonResult {
check_passkey_endpoint_preconditions(&headers.ip.ip, "updated")?;
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user;
data.validate(&user, true, &conn).await?;
let credentials: Vec<Passkey> = WebAuthnCredential::find_by_user(&user.uuid, &conn)
.await?
.into_iter()
.filter(|wac| wac.supports_prf)
.filter_map(|wac| 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<String>,
supports_prf: bool,
encrypted_user_key: Option<String>,
encrypted_public_key: Option<String>,
encrypted_private_key: Option<String>,
}
#[post("/webauthn", data = "<data>")]
async fn post_api_webauthn(
data: Json<WebAuthnLoginCredentialCreateRequest>,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> ApiResult<Status> {
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(&current_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(), &current_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, &current_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<String>,
encrypted_public_key: Option<String>,
encrypted_private_key: Option<String>,
}
#[put("/webauthn", data = "<data>")]
async fn put_api_webauthn(
data: Json<WebAuthnLoginCredentialUpdateRequest>,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> ApiResult<Status> {
check_passkey_endpoint_preconditions(&headers.ip.ip, "updated")?;
let data: WebAuthnLoginCredentialUpdateRequest = data.into_inner();
let user = headers.user;
let Some(encrypted_user_key) = data.encrypted_user_key else {
err!("Encrypted user key is required")
};
let Some(encrypted_public_key) = data.encrypted_public_key else {
err!("Encrypted public key is required")
};
let Some(encrypted_private_key) = data.encrypted_private_key else {
err!("Encrypted private key is required")
};
// 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, &current_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(&current_user.uuid, &credential_id_hash, &conn).await?
else {
err!("Verified credential is not registered")
};
if !matched_wac.supports_prf {
err!("Passkey does not support PRF")
}
let 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, &current_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/<uuid>/delete", data = "<data>")]
async fn post_api_webauthn_delete(
data: Json<PasswordOrOtpData>,
uuid: WebAuthnCredentialId,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> ApiResult<Status> {
crate::ratelimit::check_limit_login(&headers.ip.ip)?;
let data: PasswordOrOtpData = data.into_inner();
let mut user = headers.user;
data.validate(&user, true, &conn).await?;
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<bool>) -> 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(&registration_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());
}
}

45
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<RegisterPublicKeyCredentialCopy> 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::<RegisterPublicKeyCredentialCopy>(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::<PublicKeyCredentialCopy>(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");
}
}

649
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<Device> {
// 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<Option<String>> {
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<RegisterData>, conn: DbConn) -> JsonResult {
register(data, true, conn).await
}
fn passkey_credential_id(passkey: &Passkey) -> ApiResult<String> {
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<String> {
// 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::<Vec<_>>())
.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<String> {
/// 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<Value> {
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<String>,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for FetchMetadata {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
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<UserId>, 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::<DiscoverableAuthentication>(&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::<PublicKeyCredentialCopy>(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::<PublicKeyCredentialCopy>(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<DiscoverableKey> =
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<String>,
}
fn connect_device_type(data: &ConnectData) -> i32 {
util::try_parse_string(data.device_type.as_ref()).unwrap_or(14)
}
fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult {
if value.is_none() {
if value.is_some() {
Ok(())
} else {
err!(msg)
}
Ok(())
}
fn check_is_nonempty<T: ToString>(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::<String>(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::<String>(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`)

9
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.

75
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 <TwoFactorType as num_traits::FromPrimitive>::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<Vec<Self>, 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::<Self>(conn)
.map_res("Error loading twofactor")
})
.await
}
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| {
twofactor::table
@ -318,3 +363,33 @@ impl From<WebauthnRegistrationV3> 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());
}
}

35
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::<Self>(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<Self> {
conn.run(move |conn| users::table.filter(users::uuid.eq(uuid)).first::<Self>(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<Option<Self>, Error> {
conn.run(move |conn| {
users::table.filter(users::uuid.eq(uuid)).first::<Self>(conn).optional().map_res("Error loading user")
})
.await
}
pub async fn find_by_device_for_email2fa(device_uuid: &DeviceId, conn: &DbConn) -> Option<Self> {

334
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::<i64>(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<usize, Error> {
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::<i64>(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::<Option<String>>(None),
web_authn_credentials::encrypted_public_key.eq::<Option<String>>(None),
web_authn_credentials::encrypted_private_key.eq::<Option<String>>(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<Self> {
/// 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<Option<Self>, 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::<Self>(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<Option<Self>, 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::<Option<WebAuthnLoginChallenge>, 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,
));
}
}

7
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)}}

12
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<T, D>(created_at: T, now: T, ttl: D, skew: D) -> bool
where
T: Copy + PartialOrd + std::ops::Sub<D, Output = T> + std::ops::Add<D, Output = T>,
{
created_at >= now - ttl && created_at <= now + skew
}
//
// String util methods
//

Loading…
Cancel
Save