collin/anvil · c4cfe9ad
Stop enforcing credProtect, which broke registration on real authenticators
Collin Richards · 2026-08-18 10:21 UTC · c4cfe9ad070a818f501f58672c7df51a575af6d1 · parent 8887ef61 · browse files
modifiedcrates/anvil-web/src/passkeys.rs+17 −5
| ⋯ 56 unchanged lines | |||
| 57 | 57 | Encode, | |
| 58 | 58 | }, | |
| 59 | 59 | request::{ | |
| 60 | + | ExtensionInfo, | |
| 60 | 61 | PublicKeyCredentialDescriptor, | |
| 61 | 62 | auth::AuthenticationVerificationOptions, | |
| 62 | 63 | register::{ | |
| 64 | + | CredProtect, | |
| 63 | 65 | Nickname, | |
| 64 | 66 | PublicKeyCredentialUserEntity, | |
| 65 | 67 | RegistrationVerificationOptions, | |
| ⋯ 100 unchanged lines | |||
| 166 | 168 | display_name, | |
| 167 | 169 | }; | |
| 168 | 170 | ||
| 169 | - | let (server_state, client_state) = | |
| 170 | - | match PublicKeyCredentialCreationOptions::passkey(&rp, entity, exclude).start_ceremony() { | |
| 171 | - | Ok(pair) => pair, | |
| 172 | - | Err(e) => return server_error(format!("building registration options: {e}")), | |
| 173 | - | }; | |
| 171 | + | let mut options = PublicKeyCredentialCreationOptions::passkey(&rp, entity, exclude); | |
| 172 | + | // Ask for credProtect, but never *enforce* it. The crate's passkey preset | |
| 173 | + | // enforces the policy, and an authenticator that does not implement the | |
| 174 | + | // extension — a phone over hybrid, many security keys — then fails the whole | |
| 175 | + | // ceremony rather than ignoring it ("Something went wrong" in Chrome's | |
| 176 | + | // dialog, with nothing reaching the server). Enforcement buys nothing here: | |
| 177 | + | // both ceremonies already require user verification, and the UV flag is | |
| 178 | + | // checked on every assertion, so a UV-less credential could not sign in | |
| 179 | + | // anyway. | |
| 180 | + | options.extensions.cred_protect = | |
| 181 | + | CredProtect::UserVerificationRequired(ExtensionInfo::AllowDontEnforceValue); | |
| 182 | + | let (server_state, client_state) = match options.start_ceremony() { | |
| 183 | + | Ok(pair) => pair, | |
| 184 | + | Err(e) => return server_error(format!("building registration options: {e}")), | |
| 185 | + | }; | |
| 174 | 186 | let options = match serde_json::to_value(&client_state) { | |
| 175 | 187 | Ok(value) => value, | |
| 176 | 188 | Err(e) => return server_error(e), | |
| ⋯ 556 unchanged lines | |||
modifiedcrates/anvil-web/tests/passkey_flow.rs+29 −0
| ⋯ 417 unchanged lines | |||
| 418 | 418 | ); | |
| 419 | 419 | } | |
| 420 | 420 | ||
| 421 | + | /// Registration options must stay compatible with authenticators that do not | |
| 422 | + | /// implement credProtect — phones over hybrid, plenty of security keys. With | |
| 423 | + | /// enforcement on, those fail the whole ceremony inside the browser, before | |
| 424 | + | /// anything reaches the server. User verification is still required, which is | |
| 425 | + | /// what actually gates a sign-in. | |
| 426 | + | #[tokio::test] | |
| 427 | + | async fn registration_asks_for_credprotect_without_enforcing_it() { | |
| 428 | + | let harness = harness().await; | |
| 429 | + | let (status, body) = harness | |
| 430 | + | .post_json("/-/settings/passkeys/begin", serde_json::json!({})) | |
| 431 | + | .await; | |
| 432 | + | assert_eq!(status, StatusCode::OK); | |
| 433 | + | let options: serde_json::Value = serde_json::from_str(&body).unwrap(); | |
| 434 | + | let options = &options["options"]; | |
| 435 | + | ||
| 436 | + | assert_eq!( | |
| 437 | + | options["extensions"]["enforceCredentialProtectionPolicy"], | |
| 438 | + | serde_json::json!(false), | |
| 439 | + | ); | |
| 440 | + | assert_eq!( | |
| 441 | + | options["authenticatorSelection"]["userVerification"], | |
| 442 | + | serde_json::json!("required"), | |
| 443 | + | ); | |
| 444 | + | assert_eq!( | |
| 445 | + | options["authenticatorSelection"]["residentKey"], | |
| 446 | + | serde_json::json!("required"), | |
| 447 | + | ); | |
| 448 | + | } | |
| 449 | + | ||
| 421 | 450 | #[tokio::test] | |
| 422 | 451 | async fn a_forged_signature_is_refused() { | |
| 423 | 452 | let harness = harness().await; | |
| ⋯ 94 unchanged lines | |||