anvilsign in

collin/anvil · 2f49cc06

Test the passkey ceremonies with a software authenticator

Collin Richards · 2026-08-18 09:52 UTC · 2f49cc0603a1ffe5c98c86899af40f689285de88 · parent bdd0fcba · browse files

modifiedCargo.lock+65 −3
⋯ 222 unchanged lines
223223 "axum",
224224 "axum-extra",
225225 "base64",
226+ "ciborium",
226227 "lru",
227228 "maud",
229+ "p256 0.13.2",
228230 "pulldown-cmark",
229231 "serde",
230232 "serde_json",
233+ "sha2 0.10.9",
231234 "similar",
232235 "ssh-key",
233236 "syntect",
⋯ 1 unchanged line
235238 "time",
236239 "tokio",
237240 "tokio-util",
241+ "tower",
238242 "tower-http",
239243 "tracing",
240244 "webauthn_rp",
⋯ 413 unchanged lines
654658 ]
655659
656660 [[package]]
661+name = "ciborium"
662+version = "0.2.2"
663+source = "registry+https://github.com/rust-lang/crates.io-index"
664+checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
665+dependencies = [
666+ "ciborium-io",
667+ "ciborium-ll",
668+ "serde",
669+]
670+
671+[[package]]
672+name = "ciborium-io"
673+version = "0.2.2"
674+source = "registry+https://github.com/rust-lang/crates.io-index"
675+checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
676+
677+[[package]]
678+name = "ciborium-ll"
679+version = "0.2.2"
680+source = "registry+https://github.com/rust-lang/crates.io-index"
681+checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
682+dependencies = [
683+ "ciborium-io",
684+ "half",
685+]
686+
687+[[package]]
657688 name = "cipher"
658689 version = "0.5.2"
659690 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 154 unchanged lines
814845 checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
815846
816847 [[package]]
848+name = "crunchy"
849+version = "0.2.4"
850+source = "registry+https://github.com/rust-lang/crates.io-index"
851+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
852+
853+[[package]]
817854 name = "crypto-bigint"
818855 version = "0.5.5"
819856 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 173 unchanged lines
9931030 checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
9941031 dependencies = [
9951032 "const-oid 0.9.6",
1033+ "pem-rfc7468 0.7.0",
9961034 "zeroize",
9971035 ]
9981036
⋯ 4 unchanged lines
10031041 checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b"
10041042 dependencies = [
10051043 "const-oid 0.10.2",
1006- "pem-rfc7468",
1044+ "pem-rfc7468 1.0.0",
10071045 "zeroize",
10081046 ]
10091047
⋯ 74 unchanged lines
10841122 "elliptic-curve 0.13.8",
10851123 "rfc6979 0.4.0",
10861124 "signature 2.2.0",
1125+ "spki 0.7.3",
10871126 ]
10881127
10891128 [[package]]
⋯ 70 unchanged lines
11601199 "ff 0.13.1",
11611200 "generic-array 0.14.7",
11621201 "group 0.13.0",
1202+ "pem-rfc7468 0.7.0",
1203+ "pkcs8 0.10.2",
11631204 "rand_core 0.6.4",
11641205 "sec1 0.7.3",
11651206 "subtle",
⋯ 15 unchanged lines
11811222 "hkdf",
11821223 "hybrid-array",
11831224 "once_cell",
1184- "pem-rfc7468",
1225+ "pem-rfc7468 1.0.0",
11851226 "pkcs8 0.11.0",
11861227 "rand_core 0.10.1",
11871228 "sec1 0.8.1",
⋯ 1077 unchanged lines
22652306 ]
22662307
22672308 [[package]]
2309+name = "half"
2310+version = "2.7.1"
2311+source = "registry+https://github.com/rust-lang/crates.io-index"
2312+checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
2313+dependencies = [
2314+ "cfg-if",
2315+ "crunchy",
2316+ "zerocopy",
2317+]
2318+
2319+[[package]]
22682320 name = "hash32"
22692321 version = "0.3.1"
22702322 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 1053 unchanged lines
33243376
33253377 [[package]]
33263378 name = "pem-rfc7468"
3379+version = "0.7.0"
3380+source = "registry+https://github.com/rust-lang/crates.io-index"
3381+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
3382+dependencies = [
3383+ "base64ct",
3384+]
3385+
3386+[[package]]
3387+name = "pem-rfc7468"
33273388 version = "1.0.0"
33283389 source = "registry+https://github.com/rust-lang/crates.io-index"
33293390 checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9"
⋯ 878 unchanged lines
42084269 "base16ct 0.2.0",
42094270 "der 0.7.10",
42104271 "generic-array 0.14.7",
4272+ "pkcs8 0.10.2",
42114273 "subtle",
42124274 "zeroize",
42134275 ]
⋯ 387 unchanged lines
46014663 "crypto-bigint 0.7.3",
46024664 "ctutils",
46034665 "digest 0.11.3",
4604- "pem-rfc7468",
4666+ "pem-rfc7468 1.0.0",
46054667 "zeroize",
46064668 ]
46074669
⋯ 1287 unchanged lines
modifiedcrates/anvil-web/Cargo.toml+7 −0
⋯ 31 unchanged lines
3232 serde_json.workspace = true
3333 argon2.workspace = true
3434 ssh-key = { workspace = true, features = ["ed25519"] }
35+tokio = { workspace = true }
36+tower = { workspace = true, features = ["util"] }
37+# A software authenticator for the passkey tests: CBOR for attestation
38+# objects and COSE keys, P-256 for the signatures a security key would make.
39+ciborium = "0.2"
40+p256 = "0.13"
41+sha2.workspace = true
modifiedcrates/anvil-web/src/passkeys.rs+22 −9
⋯ 49 unchanged lines
5050 };
5151 use webauthn_rp::{
5252 AuthenticatedCredential,
53- DiscoverableAuthentication64,
5453 DiscoverableCredentialRequestOptions,
5554 PublicKeyCredentialCreationOptions,
56- Registration,
5755 bin::{
5856 Decode,
5957 Encode,
⋯ 12 unchanged lines
7270 response::{
7371 AuthTransports,
7472 CredentialId,
73+ auth::ser_relaxed::AuthenticationRelaxed,
7574 register::{
7675 CompressedPubKey,
7776 DynamicState,
7877 StaticState,
78+ ser_relaxed::RegistrationRelaxed,
7979 },
8080 },
8181 };
⋯ 141 unchanged lines
223223 Ok(rp) => rp,
224224 Err(e) => return server_error(e),
225225 };
226- let registration: Registration = match serde_json::from_value(body.credential) {
227- Ok(reg) => reg,
226+ // The *relaxed* deserializer on purpose: the strict one additionally
227+ // requires `authenticatorData`, `publicKey` and `publicKeyAlgorithm`, which
228+ // only browsers implementing the newer WebAuthn-JSON serialization emit.
229+ // Nothing security-relevant rides on them — they are conveniences derived
230+ // from the attestation object, which is verified either way.
231+ let registration = match serde_json::from_value::<RegistrationRelaxed>(body.credential) {
232+ Ok(reg) => reg.0,
228233 Err(e) => return bad_request(format!("malformed credential: {e}")),
229234 };
230235
⋯ 110 unchanged lines
341346 Ok(rp) => rp,
342347 Err(e) => return server_error(e),
343348 };
344- let authentication: DiscoverableAuthentication64 = match serde_json::from_value(body.credential)
345- {
346- Ok(auth) => auth,
347- Err(e) => return bad_request(format!("malformed assertion: {e}")),
348- };
349+ let authentication =
350+ match serde_json::from_value::<AuthenticationRelaxed<64, true>>(body.credential) {
351+ Ok(auth) => auth.0,
352+ Err(e) => return bad_request(format!("malformed assertion: {e}")),
353+ };
349354
350355 let credential_id = b64url().encode(authentication.raw_id().as_ref());
351356 let stored = match passkeys::find_by_credential_id(&app.db, &credential_id).await {
⋯ 236 unchanged lines
588593 clientDataJSON: encode(credential.response.clientDataJSON),
589594 attestationObject: encode(credential.response.attestationObject),
590595 transports: credential.response.getTransports ? credential.response.getTransports() : [],
596+ // Derived views of the attestation object. The server verifies the
597+ // object itself, so these are optional — sent when the browser can.
598+ authenticatorData: credential.response.getAuthenticatorData
599+ ? encode(credential.response.getAuthenticatorData()) : undefined,
600+ publicKey: credential.response.getPublicKey && credential.response.getPublicKey()
601+ ? encode(credential.response.getPublicKey()) : undefined,
602+ publicKeyAlgorithm: credential.response.getPublicKeyAlgorithm
603+ ? credential.response.getPublicKeyAlgorithm() : undefined,
591604 },
592605 };
593606 },
⋯ 126 unchanged lines
addedcrates/anvil-web/tests/passkey_flow.rs+517 −0
1+//! End-to-end passkey ceremonies, driven by a software authenticator.
2+//!
3+//! A real passkey needs hardware and a human fingerprint, which no test can
4+//! supply — so this file *is* the authenticator: it holds a P-256 key, builds
5+//! the `authenticatorData` and `clientDataJSON` the spec describes, and signs
6+//! exactly what a security key would. Everything on the other side of the wire
7+//! is the real thing: the actual router, the actual handlers, the actual
8+//! verification.
9+//!
10+//! That makes it a genuine test of the flow — register a credential, then sign
11+//! in with it and get a session — plus the failures that matter: a forged
12+//! signature, a replayed challenge, someone else's credential.
13+
14+use anvil_core::{
15+ App,
16+ Config,
17+ sessions,
18+ users,
19+};
20+use axum::{
21+ Router,
22+ body::Body,
23+ http::{
24+ Request,
25+ StatusCode,
26+ header,
27+ },
28+};
29+use base64::Engine;
30+use p256::ecdsa::{
31+ Signature,
32+ SigningKey,
33+ signature::Signer,
34+};
35+use sha2::{
36+ Digest,
37+ Sha256,
38+};
39+use tower::ServiceExt;
40+
41+const ORIGIN: &str = "https://anvil.localhost";
42+const RP_ID: &str = "anvil.localhost";
43+
44+// --- the authenticator -----------------------------------------------------
45+
46+/// A software stand-in for a security key: one credential, one P-256 key.
47+struct Authenticator {
48+ key: SigningKey,
49+ credential_id: Vec<u8>,
50+ sign_count: u32,
51+}
52+
53+impl Authenticator {
54+ fn new() -> Self {
55+ let mut seed = [0u8; 32];
56+ getrandom(&mut seed);
57+ let mut credential_id = vec![0u8; 32];
58+ getrandom(&mut credential_id);
59+ Self {
60+ key: SigningKey::from_bytes(&seed.into()).expect("random scalar is a valid key"),
61+ credential_id,
62+ sign_count: 0,
63+ }
64+ }
65+
66+ /// `navigator.credentials.create()`: a `none`-attestation registration
67+ /// response carrying the new credential's public key.
68+ fn register(&self, challenge: &str) -> serde_json::Value {
69+ let client_data = client_data("webauthn.create", challenge);
70+ let auth_data = self.auth_data(true);
71+ let attestation = cbor_map(vec![
72+ (
73+ ciborium::Value::Text("fmt".into()),
74+ ciborium::Value::Text("none".into()),
75+ ),
76+ (
77+ ciborium::Value::Text("attStmt".into()),
78+ ciborium::Value::Map(vec![]),
79+ ),
80+ (
81+ ciborium::Value::Text("authData".into()),
82+ ciborium::Value::Bytes(auth_data),
83+ ),
84+ ]);
85+ serde_json::json!({
86+ "id": b64url(&self.credential_id),
87+ "rawId": b64url(&self.credential_id),
88+ "type": "public-key",
89+ "clientExtensionResults": {},
90+ "response": {
91+ "clientDataJSON": b64url(client_data.as_bytes()),
92+ "attestationObject": b64url(&attestation),
93+ "transports": ["internal"],
94+ },
95+ })
96+ }
97+
98+ /// `navigator.credentials.get()`: an assertion over this challenge.
99+ fn assert(&mut self, challenge: &str, user_handle: &[u8]) -> serde_json::Value {
100+ self.sign_count += 1;
101+ let client_data = client_data("webauthn.get", challenge);
102+ let auth_data = self.auth_data(false);
103+
104+ // What the authenticator actually signs: its own data, then the hash
105+ // of what the browser told it about this request.
106+ let mut signed = auth_data.clone();
107+ signed.extend_from_slice(&Sha256::digest(client_data.as_bytes()));
108+ let signature: Signature = self.key.sign(&signed);
109+
110+ serde_json::json!({
111+ "id": b64url(&self.credential_id),
112+ "rawId": b64url(&self.credential_id),
113+ "type": "public-key",
114+ "clientExtensionResults": {},
115+ "response": {
116+ "clientDataJSON": b64url(client_data.as_bytes()),
117+ "authenticatorData": b64url(&auth_data),
118+ "signature": b64url(signature.to_der().as_bytes()),
119+ "userHandle": b64url(user_handle),
120+ },
121+ })
122+ }
123+
124+ /// `authenticatorData`: rpIdHash ‖ flags ‖ signCount, plus the attested
125+ /// credential (and the credProtect extension anvil asks for) at
126+ /// registration time.
127+ fn auth_data(&self, registering: bool) -> Vec<u8> {
128+ // UP (touched) | UV (verified) — anvil requires both.
129+ let mut flags = 0x01 | 0x04;
130+ if registering {
131+ flags |= 0x40; // AT: attested credential data present
132+ flags |= 0x80; // ED: extension data present
133+ }
134+ let mut data = Sha256::digest(RP_ID.as_bytes()).to_vec();
135+ data.push(flags);
136+ data.extend_from_slice(&self.sign_count.to_be_bytes());
137+ if registering {
138+ data.extend_from_slice(&[0u8; 16]); // AAGUID: zeroes, as privacy-preserving authenticators report
139+ data.extend_from_slice(&(self.credential_id.len() as u16).to_be_bytes());
140+ data.extend_from_slice(&self.credential_id);
141+ data.extend_from_slice(&self.cose_key());
142+ data.extend_from_slice(&cbor_map(vec![(
143+ ciborium::Value::Text("credProtect".into()),
144+ ciborium::Value::Integer(3.into()), // userVerificationRequired
145+ )]));
146+ }
147+ data
148+ }
149+
150+ /// The public key as a COSE_Key: EC2 / P-256 / ES256.
151+ fn cose_key(&self) -> Vec<u8> {
152+ let point = self.key.verifying_key().to_encoded_point(false);
153+ cbor_map(vec![
154+ (
155+ ciborium::Value::Integer(1.into()), // kty
156+ ciborium::Value::Integer(2.into()), // EC2
157+ ),
158+ (
159+ ciborium::Value::Integer(3.into()), // alg
160+ ciborium::Value::Integer((-7).into()), // ES256
161+ ),
162+ (
163+ ciborium::Value::Integer((-1).into()), // crv
164+ ciborium::Value::Integer(1.into()), // P-256
165+ ),
166+ (
167+ ciborium::Value::Integer((-2).into()),
168+ ciborium::Value::Bytes(point.x().expect("uncompressed point has x").to_vec()),
169+ ),
170+ (
171+ ciborium::Value::Integer((-3).into()),
172+ ciborium::Value::Bytes(point.y().expect("uncompressed point has y").to_vec()),
173+ ),
174+ ])
175+ }
176+}
177+
178+fn client_data(ceremony: &str, challenge: &str) -> String {
179+ serde_json::json!({
180+ "type": ceremony,
181+ "challenge": challenge,
182+ "origin": ORIGIN,
183+ "crossOrigin": false,
184+ })
185+ .to_string()
186+}
187+
188+fn cbor_map(entries: Vec<(ciborium::Value, ciborium::Value)>) -> Vec<u8> {
189+ let mut out = Vec::new();
190+ ciborium::into_writer(&ciborium::Value::Map(entries), &mut out).expect("CBOR encoding");
191+ out
192+}
193+
194+fn b64url(bytes: &[u8]) -> String {
195+ base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
196+}
197+
198+fn getrandom(buf: &mut [u8]) {
199+ use argon2::password_hash::rand_core::{
200+ OsRng,
201+ RngCore,
202+ };
203+ OsRng.fill_bytes(buf);
204+}
205+
206+// --- harness ---------------------------------------------------------------
207+
208+struct Harness {
209+ router: Router,
210+ app: App,
211+ cookie: String,
212+ csrf: String,
213+ user_id: i64,
214+ _dir: tempfile::TempDir,
215+}
216+
217+async fn harness() -> Harness {
218+ let dir = tempfile::tempdir().unwrap();
219+ let mut config = Config::default();
220+ config.data_dir = dir.path().to_path_buf();
221+ config.http.base_url = ORIGIN.to_string();
222+ let app = App::bootstrap(config).await.unwrap();
223+ let user = users::create(&app.db, "collin", "", "password", true)
224+ .await
225+ .unwrap();
226+ let session = sessions::create(&app.db, user.id).await.unwrap();
227+ let csrf = app.csrf_token(&session.token);
228+ Harness {
229+ router: anvil_web::router(app.clone()),
230+ app,
231+ cookie: format!("anvil_session={}", session.token),
232+ csrf,
233+ user_id: user.id,
234+ _dir: dir,
235+ }
236+}
237+
238+impl Harness {
239+ /// POST JSON as the signed-in user (cookie + CSRF header).
240+ async fn post_json(&self, path: &str, body: serde_json::Value) -> (StatusCode, String) {
241+ self.send(
242+ Request::post(path)
243+ .header(header::COOKIE, &self.cookie)
244+ .header("X-CSRF-Token", &self.csrf)
245+ .header(header::CONTENT_TYPE, "application/json")
246+ .body(Body::from(body.to_string()))
247+ .unwrap(),
248+ )
249+ .await
250+ }
251+
252+ /// POST JSON with no session at all, the way the login page does.
253+ async fn post_anonymous(&self, path: &str, body: serde_json::Value) -> (StatusCode, String) {
254+ self.send(
255+ Request::post(path)
256+ .header(header::CONTENT_TYPE, "application/json")
257+ .body(Body::from(body.to_string()))
258+ .unwrap(),
259+ )
260+ .await
261+ }
262+
263+ async fn send(&self, request: Request<Body>) -> (StatusCode, String) {
264+ let response = self.router.clone().oneshot(request).await.unwrap();
265+ let status = response.status();
266+ let body = axum::body::to_bytes(response.into_body(), 1 << 20)
267+ .await
268+ .unwrap();
269+ (status, String::from_utf8_lossy(&body).into_owned())
270+ }
271+
272+ /// The login ceremony, returning the raw response so cookies can be read.
273+ async fn login(&self, body: serde_json::Value) -> axum::response::Response {
274+ self.router
275+ .clone()
276+ .oneshot(
277+ Request::post("/-/login/passkey/finish")
278+ .header(header::CONTENT_TYPE, "application/json")
279+ .body(Body::from(body.to_string()))
280+ .unwrap(),
281+ )
282+ .await
283+ .unwrap()
284+ }
285+
286+ /// Begin registration, returning (ceremony id, handle, challenge).
287+ async fn begin_registration(&self) -> (String, String, String) {
288+ let (status, body) = self
289+ .post_json("/-/settings/passkeys/begin", serde_json::json!({}))
290+ .await;
291+ assert_eq!(status, StatusCode::OK, "begin failed: {body}");
292+ let json: serde_json::Value = serde_json::from_str(&body).unwrap();
293+ (
294+ json["ceremony"].as_str().unwrap().to_string(),
295+ json["handle"].as_str().unwrap().to_string(),
296+ json["options"]["challenge"].as_str().unwrap().to_string(),
297+ )
298+ }
299+
300+ /// Begin sign-in, returning (ceremony id, challenge).
301+ async fn begin_login(&self) -> (String, String) {
302+ let (status, body) = self
303+ .post_anonymous("/-/login/passkey/begin", serde_json::json!({}))
304+ .await;
305+ assert_eq!(status, StatusCode::OK, "begin failed: {body}");
306+ let json: serde_json::Value = serde_json::from_str(&body).unwrap();
307+ (
308+ json["ceremony"].as_str().unwrap().to_string(),
309+ json["options"]["challenge"].as_str().unwrap().to_string(),
310+ )
311+ }
312+
313+ /// Register `authenticator` and return the account's WebAuthn handle.
314+ async fn register(&self, authenticator: &Authenticator, name: &str) -> Vec<u8> {
315+ let (ceremony, handle, challenge) = self.begin_registration().await;
316+ let (status, body) = self
317+ .post_json(
318+ "/-/settings/passkeys/finish",
319+ serde_json::json!({
320+ "ceremony": ceremony,
321+ "handle": handle,
322+ "name": name,
323+ "credential": authenticator.register(&challenge),
324+ }),
325+ )
326+ .await;
327+ assert_eq!(
328+ status,
329+ StatusCode::NO_CONTENT,
330+ "registration failed: {body}"
331+ );
332+ base64::engine::general_purpose::STANDARD
333+ .decode(&handle)
334+ .unwrap()
335+ }
336+}
337+
338+// --- the tests -------------------------------------------------------------
339+
340+#[tokio::test]
341+async fn a_registered_passkey_signs_in() {
342+ let harness = harness().await;
343+ let mut authenticator = Authenticator::new();
344+ let handle = harness.register(&authenticator, "MacBook Touch ID").await;
345+
346+ // The credential is stored against the account, with the label we gave it.
347+ let stored = anvil_core::passkeys::list(&harness.app.db, harness.user_id)
348+ .await
349+ .unwrap();
350+ assert_eq!(stored.len(), 1);
351+ assert_eq!(stored[0].name, "MacBook Touch ID");
352+ assert_eq!(stored[0].last_used_at, 0, "not used yet");
353+
354+ // Sign in with it: no username anywhere in this exchange.
355+ let (ceremony, challenge) = harness.begin_login().await;
356+ let response = harness
357+ .login(serde_json::json!({
358+ "ceremony": ceremony,
359+ "credential": authenticator.assert(&challenge, &handle),
360+ }))
361+ .await;
362+ assert_eq!(response.status(), StatusCode::OK);
363+
364+ // A session cookie comes back, and it belongs to the right account.
365+ let cookie = response
366+ .headers()
367+ .get(header::SET_COOKIE)
368+ .expect("session cookie")
369+ .to_str()
370+ .unwrap()
371+ .to_string();
372+ let token = cookie
373+ .split(';')
374+ .next()
375+ .unwrap()
376+ .trim_start_matches("anvil_session=")
377+ .to_string();
378+ let signed_in = sessions::lookup_user(&harness.app.db, &token)
379+ .await
380+ .unwrap()
381+ .expect("the cookie names a live session");
382+ assert_eq!(signed_in.id, harness.user_id);
383+
384+ // The sign-in is recorded against the credential.
385+ let stored = anvil_core::passkeys::list(&harness.app.db, harness.user_id)
386+ .await
387+ .unwrap();
388+ assert!(stored[0].last_used_at > 0, "last use should be stamped");
389+}
390+
391+#[tokio::test]
392+async fn a_second_passkey_shares_the_account_handle_and_is_excluded() {
393+ let harness = harness().await;
394+ let first = Authenticator::new();
395+ let handle = harness.register(&first, "laptop").await;
396+
397+ // Registering another authenticator reuses the same user handle, so the
398+ // account does not fork into two identities.
399+ let (_, second_handle, _) = harness.begin_registration().await;
400+ assert_eq!(
401+ base64::engine::general_purpose::STANDARD
402+ .decode(&second_handle)
403+ .unwrap(),
404+ handle
405+ );
406+
407+ // …and the browser is told to refuse the already-registered credential.
408+ let (status, body) = harness
409+ .post_json("/-/settings/passkeys/begin", serde_json::json!({}))
410+ .await;
411+ assert_eq!(status, StatusCode::OK);
412+ let json: serde_json::Value = serde_json::from_str(&body).unwrap();
413+ let excluded = json["options"]["excludeCredentials"].as_array().unwrap();
414+ assert_eq!(excluded.len(), 1);
415+ assert_eq!(
416+ excluded[0]["id"].as_str().unwrap(),
417+ b64url(&first.credential_id)
418+ );
419+}
420+
421+#[tokio::test]
422+async fn a_forged_signature_is_refused() {
423+ let harness = harness().await;
424+ let mut authenticator = Authenticator::new();
425+ let handle = harness.register(&authenticator, "laptop").await;
426+
427+ // Same credential id, a different key: what a stolen database plus a
428+ // home-made authenticator would produce.
429+ let (ceremony, challenge) = harness.begin_login().await;
430+ let mut impostor = Authenticator::new();
431+ impostor.credential_id = authenticator.credential_id.clone();
432+ let response = harness
433+ .login(serde_json::json!({
434+ "ceremony": ceremony,
435+ "credential": impostor.assert(&challenge, &handle),
436+ }))
437+ .await;
438+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
439+ assert!(
440+ response.headers().get(header::SET_COOKIE).is_none(),
441+ "a rejected sign-in must not set a session"
442+ );
443+
444+ // The real authenticator still works afterwards.
445+ let (ceremony, challenge) = harness.begin_login().await;
446+ let response = harness
447+ .login(serde_json::json!({
448+ "ceremony": ceremony,
449+ "credential": authenticator.assert(&challenge, &handle),
450+ }))
451+ .await;
452+ assert_eq!(response.status(), StatusCode::OK);
453+}
454+
455+#[tokio::test]
456+async fn a_captured_assertion_cannot_be_replayed() {
457+ let harness = harness().await;
458+ let mut authenticator = Authenticator::new();
459+ let handle = harness.register(&authenticator, "laptop").await;
460+
461+ let (ceremony, challenge) = harness.begin_login().await;
462+ let assertion = authenticator.assert(&challenge, &handle);
463+ let first = harness
464+ .login(serde_json::json!({ "ceremony": ceremony.clone(), "credential": assertion.clone() }))
465+ .await;
466+ assert_eq!(first.status(), StatusCode::OK);
467+
468+ // Replaying the identical exchange fails: the challenge is spent.
469+ let second = harness
470+ .login(serde_json::json!({ "ceremony": ceremony, "credential": assertion }))
471+ .await;
472+ assert_eq!(second.status(), StatusCode::BAD_REQUEST);
473+}
474+
475+#[tokio::test]
476+async fn an_unregistered_passkey_cannot_sign_in() {
477+ let harness = harness().await;
478+ let mut stranger = Authenticator::new();
479+ let (ceremony, challenge) = harness.begin_login().await;
480+ let response = harness
481+ .login(serde_json::json!({
482+ "ceremony": ceremony,
483+ "credential": stranger.assert(&challenge, &[7u8; 64]),
484+ }))
485+ .await;
486+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
487+}
488+
489+#[tokio::test]
490+async fn removing_a_passkey_revokes_it() {
491+ let harness = harness().await;
492+ let mut authenticator = Authenticator::new();
493+ let handle = harness.register(&authenticator, "laptop").await;
494+ let stored = anvil_core::passkeys::list(&harness.app.db, harness.user_id)
495+ .await
496+ .unwrap();
497+
498+ let (status, _) = harness
499+ .send(
500+ Request::post(format!("/-/settings/passkeys/{}/delete", stored[0].id))
501+ .header(header::COOKIE, &harness.cookie)
502+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
503+ .body(Body::from(format!("csrf={}", harness.csrf)))
504+ .unwrap(),
505+ )
506+ .await;
507+ assert_eq!(status, StatusCode::SEE_OTHER);
508+
509+ let (ceremony, challenge) = harness.begin_login().await;
510+ let response = harness
511+ .login(serde_json::json!({
512+ "ceremony": ceremony,
513+ "credential": authenticator.assert(&challenge, &handle),
514+ }))
515+ .await;
516+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
517+}