Browse Source
- 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
17 changed files with 1980 additions and 1073 deletions
@ -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(¤t_user.uuid, &conn).await?) { |
||||
|
err!("Maximum number of passkeys reached") |
||||
|
} |
||||
|
|
||||
|
let type_ = TwoFactorType::WebauthnPasskeyRegisterChallenge as i32; |
||||
|
let Some(tf) = TwoFactor::take_by_user_and_type(&user.uuid, type_, &conn).await? else { |
||||
|
err!("No registration challenge found. Please try again.") |
||||
|
}; |
||||
|
let state = passkey_registration_challenge_state(&tf.data, data.token.as_deref(), ¤t_user.security_stamp)?; |
||||
|
let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?; |
||||
|
let credential_id_hash = passkey_credential_id_hash(credential.cred_id().as_slice()); |
||||
|
let (supports_prf, encrypted_user_key, encrypted_public_key, encrypted_private_key) = |
||||
|
passkey_registration_prf_data( |
||||
|
data.supports_prf, |
||||
|
data.encrypted_user_key, |
||||
|
data.encrypted_public_key, |
||||
|
data.encrypted_private_key, |
||||
|
passkey_supports_prf(&credential), |
||||
|
)?; |
||||
|
|
||||
|
// Duplicate detection rests on the UNIQUE `(user_uuid, credential_id_hash)`
|
||||
|
// index: `save_with_user_limit` below maps the `UniqueViolation` to
|
||||
|
// "Passkey is already registered". Scoping it per-user means a cross-account
|
||||
|
// hash collision (trivial if an attacker echoes an observed cred_id) inserts
|
||||
|
// cleanly without signalling that another account holds that hash.
|
||||
|
|
||||
|
WebAuthnCredential::new( |
||||
|
current_user.uuid.clone(), |
||||
|
data.name, |
||||
|
serde_json::to_string(&credential)?, |
||||
|
credential_id_hash, |
||||
|
supports_prf, |
||||
|
encrypted_user_key, |
||||
|
encrypted_public_key, |
||||
|
encrypted_private_key, |
||||
|
) |
||||
|
.save_with_user_limit(MAX_WEBAUTHN_CREDENTIALS, &conn) |
||||
|
.await?; |
||||
|
|
||||
|
current_user.update_revision(&conn).await?; |
||||
|
nt.send_user_update(UpdateType::SyncVault, ¤t_user, headers.device.push_uuid.as_ref(), &conn).await; |
||||
|
|
||||
|
Ok(Status::Ok) |
||||
|
} |
||||
|
|
||||
|
#[derive(Debug, Deserialize)] |
||||
|
#[serde(rename_all = "camelCase")] |
||||
|
struct WebAuthnLoginCredentialUpdateRequest { |
||||
|
device_response: PublicKeyCredentialCopy, |
||||
|
token: String, |
||||
|
encrypted_user_key: Option<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, ¤t_user.security_stamp)?; |
||||
|
|
||||
|
let credential_response = data.device_response.into(); |
||||
|
|
||||
|
// Verify the assertion against the saved challenge state. `state`
|
||||
|
// already carries the credential set the challenge was issued against,
|
||||
|
// so we don't need to pass credentials again here. After verification
|
||||
|
// we know the exact cred_id and can index directly into the
|
||||
|
// credential table via the per-user UNIQUE on credential_id_hash —
|
||||
|
// avoiding the full passkey-set scan + N JSON parses the previous
|
||||
|
// shape did.
|
||||
|
let authentication_result = WEBAUTHN.finish_passkey_authentication(&credential_response, &state)?; |
||||
|
let credential_id_hash = passkey_credential_id_hash(authentication_result.cred_id().as_slice()); |
||||
|
let Some(mut matched_wac) = |
||||
|
WebAuthnCredential::find_by_user_and_credential_id_hash(¤t_user.uuid, &credential_id_hash, &conn).await? |
||||
|
else { |
||||
|
err!("Verified credential is not registered") |
||||
|
}; |
||||
|
|
||||
|
if !matched_wac.supports_prf { |
||||
|
err!("Passkey does not support PRF") |
||||
|
} |
||||
|
|
||||
|
let mut passkey: Passkey = serde_json::from_str(&matched_wac.credential)?; |
||||
|
|
||||
|
// Persist the (optional) signature-counter advance and the PRF keyset
|
||||
|
// together. The assertion challenge was atomically consumed via
|
||||
|
// `take_by_user_and_type` above, so a half-applied state would block
|
||||
|
// any retry — the helper folds both writes into a single UPDATE.
|
||||
|
//
|
||||
|
// `advanced_counter` gates the `credential` column write. Passing `false`
|
||||
|
// when the counter did not advance avoids clobbering a counter blob a
|
||||
|
// parallel replica may have just persisted via `webauthn_login`'s
|
||||
|
// counter advance (the per-process DashMap lock does not serialise
|
||||
|
// across replicas). The helper surfaces 0-rows as a Simple error so a
|
||||
|
// concurrent DELETE doesn't yield a misleading 200 OK with no row.
|
||||
|
let advanced_counter = passkey.update_credential(&authentication_result) == Some(true); |
||||
|
if advanced_counter { |
||||
|
matched_wac.credential = serde_json::to_string(&passkey)?; |
||||
|
} |
||||
|
matched_wac.encrypted_user_key = Some(encrypted_user_key); |
||||
|
matched_wac.encrypted_public_key = Some(encrypted_public_key); |
||||
|
matched_wac.encrypted_private_key = Some(encrypted_private_key); |
||||
|
matched_wac.update_credential_and_prf_keyset(advanced_counter, &conn).await?; |
||||
|
|
||||
|
current_user.update_revision(&conn).await?; |
||||
|
nt.send_user_update(UpdateType::SyncVault, ¤t_user, headers.device.push_uuid.as_ref(), &conn).await; |
||||
|
|
||||
|
Ok(Status::Ok) |
||||
|
} |
||||
|
|
||||
|
// Intentionally NOT gated on SSO_ONLY or DOMAIN-misconfigured: delete
|
||||
|
// narrows capability (revokes, never grants), the session is still
|
||||
|
// SSO-authenticated when this handler runs, and delete never touches the
|
||||
|
// `WEBAUTHN` LazyLock so DOMAIN parseability is irrelevant. Lets users
|
||||
|
// clean up credentials regardless of later deployment-config changes.
|
||||
|
#[post("/webauthn/<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(®istration_state()).unwrap(); |
||||
|
|
||||
|
assert!(passkey_registration_challenge_state(&data, None, "stamp").is_err()); |
||||
|
assert!(passkey_registration_challenge_state(&data, Some("any-token"), "stamp").is_err()); |
||||
|
assert!(passkey_registration_challenge_state(&data, Some(""), "stamp").is_err()); |
||||
|
} |
||||
|
|
||||
|
#[test] |
||||
|
fn assertion_challenge_rejects_mismatched_token() { |
||||
|
let (_response, state) = webauthn().start_passkey_authentication(&[passkey()]).unwrap(); |
||||
|
let saved = WebAuthnPasskeyAssertionChallenge { |
||||
|
token: String::from("token"), |
||||
|
created_at: Utc::now().timestamp(), |
||||
|
user_security_stamp: String::from("stamp"), |
||||
|
state, |
||||
|
}; |
||||
|
let data = serde_json::to_string(&saved).unwrap(); |
||||
|
|
||||
|
assert!(passkey_assertion_challenge_state(&data, "token", "stamp").is_ok()); |
||||
|
assert!(passkey_assertion_challenge_state(&data, "wrong", "stamp").is_err()); |
||||
|
} |
||||
|
|
||||
|
#[test] |
||||
|
fn assertion_challenge_rejects_expired_state() { |
||||
|
let (_response, state) = webauthn().start_passkey_authentication(&[passkey()]).unwrap(); |
||||
|
let saved = WebAuthnPasskeyAssertionChallenge { |
||||
|
token: String::from("token"), |
||||
|
created_at: Utc::now().timestamp() - WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS - 1, |
||||
|
user_security_stamp: String::from("stamp"), |
||||
|
state, |
||||
|
}; |
||||
|
let data = serde_json::to_string(&saved).unwrap(); |
||||
|
|
||||
|
assert!(passkey_assertion_challenge_state(&data, "token", "stamp").is_err()); |
||||
|
} |
||||
|
|
||||
|
#[test] |
||||
|
fn assertion_challenge_rejects_stale_account_revision() { |
||||
|
let (_response, state) = webauthn().start_passkey_authentication(&[passkey()]).unwrap(); |
||||
|
let saved = WebAuthnPasskeyAssertionChallenge { |
||||
|
token: String::from("token"), |
||||
|
created_at: Utc::now().timestamp(), |
||||
|
user_security_stamp: String::from("old-stamp"), |
||||
|
state, |
||||
|
}; |
||||
|
let data = serde_json::to_string(&saved).unwrap(); |
||||
|
|
||||
|
assert!(passkey_assertion_challenge_state(&data, "token", "new-stamp").is_err()); |
||||
|
} |
||||
|
|
||||
|
#[test] |
||||
|
fn passkey_credential_id_hash_uses_raw_credential_id_bytes() { |
||||
|
assert_eq!( |
||||
|
passkey_credential_id_hash(passkey().cred_id().as_slice()), |
||||
|
"9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a" |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
#[test] |
||||
|
fn passkey_credential_id_hash_is_deterministic() { |
||||
|
let cred_id: &[u8] = &[10, 20, 30, 40, 50]; |
||||
|
assert_eq!(passkey_credential_id_hash(cred_id), passkey_credential_id_hash(cred_id)); |
||||
|
} |
||||
|
|
||||
|
#[test] |
||||
|
fn passkey_credential_id_hash_distinguishes_different_credentials() { |
||||
|
let a = passkey_credential_id_hash(&[1, 2, 3, 4]); |
||||
|
let b = passkey_credential_id_hash(&[4, 3, 2, 1]); |
||||
|
let c = passkey_credential_id_hash(&[1, 2, 3]); |
||||
|
assert_ne!(a, b, "different bytes must produce different hashes"); |
||||
|
assert_ne!(a, c, "different lengths must produce different hashes"); |
||||
|
assert_ne!(b, c); |
||||
|
} |
||||
|
|
||||
|
#[test] |
||||
|
fn passkey_management_challenge_freshness_allows_current_window() { |
||||
|
let now = Utc::now().timestamp(); |
||||
|
|
||||
|
assert!(passkey_management_challenge_is_fresh(now)); |
||||
|
assert!(passkey_management_challenge_is_fresh(now - WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS + 5)); |
||||
|
assert!(passkey_management_challenge_is_fresh(now + WEBAUTHN_PASSKEY_CHALLENGE_CLOCK_SKEW_SECONDS - 5)); |
||||
|
} |
||||
|
|
||||
|
#[test] |
||||
|
fn passkey_management_challenge_freshness_rejects_old_or_far_future_rows() { |
||||
|
let now = Utc::now().timestamp(); |
||||
|
|
||||
|
assert!(!passkey_management_challenge_is_fresh(now - WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS - 1)); |
||||
|
assert!(!passkey_management_challenge_is_fresh(now + WEBAUTHN_PASSKEY_CHALLENGE_CLOCK_SKEW_SECONDS + 1)); |
||||
|
} |
||||
|
|
||||
|
/// Exact-boundary coverage. The production wrapper reads `Utc::now()`
|
||||
|
/// inside the function, so a test against `now - TTL` would race the
|
||||
|
/// internal clock read and assert FALSE for the row that should be
|
||||
|
/// inclusive. `_is_fresh_at` takes `now` as a parameter so the inclusive
|
||||
|
/// `>=` / `<=` boundaries are exercised deterministically.
|
||||
|
#[test] |
||||
|
fn passkey_management_challenge_freshness_inclusive_at_both_boundaries() { |
||||
|
let now = Utc::now().timestamp(); |
||||
|
|
||||
|
assert!( |
||||
|
passkey_management_challenge_is_fresh_at(now - WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS, now), |
||||
|
"created_at exactly TTL old must remain fresh (`>=` is inclusive)" |
||||
|
); |
||||
|
assert!( |
||||
|
passkey_management_challenge_is_fresh_at(now + WEBAUTHN_PASSKEY_CHALLENGE_CLOCK_SKEW_SECONDS, now), |
||||
|
"created_at exactly skew seconds ahead must remain fresh (`<=` is inclusive)" |
||||
|
); |
||||
|
assert!( |
||||
|
!passkey_management_challenge_is_fresh_at(now - WEBAUTHN_PASSKEY_CHALLENGE_TTL_SECONDS - 1, now), |
||||
|
"one second past TTL must reject" |
||||
|
); |
||||
|
assert!( |
||||
|
!passkey_management_challenge_is_fresh_at(now + WEBAUTHN_PASSKEY_CHALLENGE_CLOCK_SKEW_SECONDS + 1, now), |
||||
|
"one second past skew must reject" |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// `passkey_assertion_challenge_state` has no legacy unwrapped fallback —
|
||||
|
/// the assertion-options endpoint was introduced together with the
|
||||
|
/// wrapping struct, so any persisted state must carry the binding token.
|
||||
|
#[test] |
||||
|
fn assertion_challenge_rejects_unwrapped_legacy_state() { |
||||
|
let (_response, state) = webauthn().start_passkey_authentication(&[passkey()]).unwrap(); |
||||
|
let bare = serde_json::to_string(&state).unwrap(); |
||||
|
|
||||
|
assert!(passkey_assertion_challenge_state(&bare, "any-token", "stamp").is_err()); |
||||
|
assert!(passkey_assertion_challenge_state(&bare, "", "stamp").is_err()); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue