anvilsign in

collin/anvil

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
14use anvil_core::{
15 App,
16 Config,
17 sessions,
18 users,
19};
20use axum::{
21 Router,
22 body::Body,
23 http::{
24 Request,
25 StatusCode,
26 header,
27 },
28};
29use base64::Engine;
30use p256::ecdsa::{
31 Signature,
32 SigningKey,
33 signature::Signer,
34};
35use sha2::{
36 Digest,
37 Sha256,
38};
39use tower::ServiceExt;
40
41const ORIGIN: &str = "https://anvil.localhost";
42const 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.
47struct Authenticator {
48 key: SigningKey,
49 credential_id: Vec<u8>,
50 sign_count: u32,
51}
52
53impl 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
178fn 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
188fn 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
194fn b64url(bytes: &[u8]) -> String {
195 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
196}
197
198fn 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
208struct Harness {
209 router: Router,
210 app: App,
211 cookie: String,
212 csrf: String,
213 user_id: i64,
214 _dir: tempfile::TempDir,
215}
216
217async 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
238impl 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]
341async 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]
392async 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]
422async 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]
456async 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]
476async 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]
490async 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}