anvilsign in

collin/anvil

1//! Passkeys: WebAuthn sign-in, as an alternative to the account password.
2//!
3//! anvil is the relying party. A passkey's private half never leaves the
4//! authenticator (Touch ID, Windows Hello, a security key, a phone); all we
5//! store is the credential id and its public key, and all a login proves is a
6//! signature over a challenge we issued. Nothing here can be replayed against
7//! another site: the authenticator binds every signature to our RP id.
8//!
9//! The ceremony protocol runs in two round trips — *begin* hands the browser a
10//! challenge, *finish* verifies what the authenticator signed — so the server
11//! has to remember the challenge in between. [`Ceremonies`] holds those, in
12//! memory, briefly. Verification itself lives in `anvil-web`, next to the JSON.
13//!
14//! Only passkeys (discoverable, user-verifying credentials) are supported, so
15//! signing in needs no username: the authenticator tells us which credential it
16//! used, and that identifies the account.
17
18use webauthn_rp::{
19 DiscoverableAuthenticationServerState,
20 RegistrationServerState,
21 request::{
22 AsciiDomain,
23 RpId,
24 register::{
25 USER_HANDLE_MAX_LEN,
26 UserHandle64,
27 },
28 },
29};
30
31use crate::{
32 error::{
33 Error,
34 Result,
35 },
36 models::Passkey,
37};
38
39/// Length of the WebAuthn user handle, in bytes. The crate's maximum, and an
40/// opaque random value — deliberately *not* the account id, since the handle is
41/// visible to the authenticator and syncs to the user's password manager.
42pub const USER_HANDLE_LEN: usize = USER_HANDLE_MAX_LEN;
43
44/// How long a browser has to complete a ceremony before its challenge is
45/// forgotten. Matches the five-minute timeout sent to the authenticator.
46const CEREMONY_TTL_SECS: i64 = 300;
47
48/// Cap on outstanding ceremonies, so an unauthenticated endpoint that mints
49/// challenges cannot grow the map without bound.
50const MAX_CEREMONIES: usize = 512;
51
52/// The relying-party id for this deployment: the base URL's host.
53///
54/// WebAuthn scopes a credential to exactly this string, so it must be stable —
55/// change the host and existing passkeys stop working (they are not lost, they
56/// simply belong to a different site now).
57pub fn rp_id(base_url: &str) -> Result<RpId> {
58 let host = base_url
59 .split_once("://")
60 .map_or(base_url, |(_, rest)| rest)
61 .split('/')
62 .next()
63 .unwrap_or_default()
64 .split(':')
65 .next()
66 .unwrap_or_default()
67 .to_ascii_lowercase();
68 if host.is_empty() {
69 return Err(Error::Config(format!(
70 "cannot derive a WebAuthn relying-party id from base_url `{base_url}`"
71 )));
72 }
73 AsciiDomain::try_from(host.clone())
74 .map(RpId::Domain)
75 .map_err(|_| {
76 Error::Config(format!(
77 "base_url host `{host}` is not a domain WebAuthn accepts"
78 ))
79 })
80}
81
82/// The exact origin browsers must report, i.e. scheme + host + any explicit
83/// port. Compared verbatim during verification, which is what stops a
84/// look-alike site from replaying a ceremony.
85pub fn origin(base_url: &str) -> String {
86 base_url.trim_end_matches('/').to_string()
87}
88
89/// A ceremony in flight, keyed by an opaque id the browser echoes back.
90pub enum Ceremony {
91 /// Registering a new passkey for an already signed-in user.
92 Register {
93 state: Box<RegistrationServerState<USER_HANDLE_LEN>>,
94 user_id: i64,
95 },
96 /// Signing in with an existing passkey. No user is known yet — the
97 /// authenticator's response is what identifies the account.
98 Authenticate {
99 state: Box<DiscoverableAuthenticationServerState>,
100 },
101}
102
103/// Challenges issued but not yet completed.
104///
105/// In memory only, and deliberately so: a challenge is single-use and expires
106/// in minutes, so persisting it would buy nothing but a table to clean up. A
107/// restart invalidates ceremonies in flight, which costs a user one retry.
108#[derive(Clone, Default)]
109pub struct Ceremonies {
110 inner: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<String, Pending>>>,
111}
112
113struct Pending {
114 ceremony: Ceremony,
115 expires_at: i64,
116}
117
118impl Ceremonies {
119 /// Store `ceremony` and return the id the browser must send back.
120 pub fn insert(&self, ceremony: Ceremony) -> String {
121 let id = random_id();
122 let mut map = self.inner.lock().expect("ceremony mutex");
123 let now = crate::now();
124 map.retain(|_, pending| pending.expires_at > now);
125 // Under flood, drop the oldest rather than refuse new sign-ins.
126 while map.len() >= MAX_CEREMONIES {
127 let oldest = map
128 .iter()
129 .min_by_key(|(_, pending)| pending.expires_at)
130 .map(|(key, _)| key.clone());
131 match oldest {
132 Some(key) => {
133 map.remove(&key);
134 }
135 None => break,
136 }
137 }
138 map.insert(
139 id.clone(),
140 Pending {
141 ceremony,
142 expires_at: now + CEREMONY_TTL_SECS,
143 },
144 );
145 id
146 }
147
148 /// Consume a ceremony. Single-use: a challenge answered twice is answered
149 /// once, which is what makes replaying a captured assertion useless.
150 pub fn take(&self, id: &str) -> Option<Ceremony> {
151 let mut map = self.inner.lock().expect("ceremony mutex");
152 let pending = map.remove(id)?;
153 (pending.expires_at > crate::now()).then_some(pending.ceremony)
154 }
155
156 /// Drop expired entries (called from the periodic sweep).
157 pub fn sweep(&self) {
158 let now = crate::now();
159 self.inner
160 .lock()
161 .expect("ceremony mutex")
162 .retain(|_, pending| pending.expires_at > now);
163 }
164}
165
166fn random_id() -> String {
167 use argon2::password_hash::rand_core::{
168 OsRng,
169 RngCore,
170 };
171 let mut bytes = [0u8; 32];
172 OsRng.fill_bytes(&mut bytes);
173 bytes.iter().map(|b| format!("{b:02x}")).collect()
174}
175
176/// Generate a fresh WebAuthn user handle.
177pub fn new_user_handle() -> UserHandle64 {
178 UserHandle64::new()
179}
180
181// --- persistence -----------------------------------------------------------
182
183/// List a user's passkeys, newest first.
184pub async fn list(db: &toasty::Db, user_id: i64) -> Result<Vec<Passkey>> {
185 let mut conn = db.clone();
186 let mut keys = Passkey::filter(Passkey::fields().user_id().eq(user_id))
187 .exec(&mut conn)
188 .await?;
189 keys.sort_by_key(|k| std::cmp::Reverse(k.created_at));
190 Ok(keys)
191}
192
193/// Look a credential up by its id (base64url), as presented at sign-in.
194pub async fn find_by_credential_id(
195 db: &toasty::Db,
196 credential_id: &str,
197) -> Result<Option<Passkey>> {
198 let mut conn = db.clone();
199 Ok(
200 Passkey::filter(Passkey::fields().credential_id().eq(credential_id))
201 .first()
202 .exec(&mut conn)
203 .await?,
204 )
205}
206
207/// Record a newly registered passkey.
208#[allow(clippy::too_many_arguments)]
209pub async fn add(
210 db: &toasty::Db,
211 user_id: i64,
212 name: &str,
213 credential_id: &str,
214 user_handle: &str,
215 static_state: &str,
216 dynamic_state: &str,
217 transports: i64,
218) -> Result<Passkey> {
219 if find_by_credential_id(db, credential_id).await?.is_some() {
220 return Err(Error::AlreadyExists("passkey".into()));
221 }
222 let now = crate::now();
223 let mut conn = db.clone();
224 Ok(toasty::create!(Passkey {
225 user_id: user_id,
226 name: display_name(name),
227 credential_id: credential_id,
228 user_handle: user_handle,
229 static_state: static_state,
230 dynamic_state: dynamic_state,
231 transports: transports,
232 created_at: now,
233 last_used_at: 0,
234 })
235 .exec(&mut conn)
236 .await?)
237}
238
239/// Persist the credential's post-authentication state (the signature counter
240/// and flags) and stamp its last use.
241pub async fn record_use(db: &toasty::Db, passkey: Passkey, dynamic_state: &str) -> Result<()> {
242 let mut conn = db.clone();
243 let mut passkey = passkey;
244 passkey
245 .update()
246 .dynamic_state(dynamic_state)
247 .last_used_at(crate::now())
248 .exec(&mut conn)
249 .await?;
250 Ok(())
251}
252
253/// Delete one of `user_id`'s passkeys. No-op if it is missing or someone
254/// else's.
255pub async fn delete(db: &toasty::Db, id: i64, user_id: i64) -> Result<()> {
256 let mut conn = db.clone();
257 if let Some(passkey) = Passkey::filter(Passkey::fields().id().eq(id))
258 .first()
259 .exec(&mut conn)
260 .await?
261 && passkey.user_id == user_id
262 {
263 let mut conn = db.clone();
264 passkey.delete().exec(&mut conn).await?;
265 }
266 Ok(())
267}
268
269/// A user's stable WebAuthn handle, shared by all of their passkeys: reusing it
270/// lets an authenticator recognize a second registration as the same account
271/// rather than a second one.
272pub async fn handle_for_user(db: &toasty::Db, user_id: i64) -> Result<Option<String>> {
273 Ok(list(db, user_id)
274 .await?
275 .into_iter()
276 .next()
277 .map(|k| k.user_handle))
278}
279
280/// Trim and bound a user-supplied label, falling back to something useful.
281fn display_name(name: &str) -> String {
282 let name = name.trim();
283 if name.is_empty() {
284 "passkey".to_string()
285 } else {
286 name.chars().take(64).collect()
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn derives_the_rp_id_from_the_base_url() {
296 let id = |url: &str| rp_id(url).map(|id| id.as_ref().to_string());
297 assert_eq!(id("https://anvil.localhost").unwrap(), "anvil.localhost");
298 assert_eq!(id("http://localhost:3000").unwrap(), "localhost");
299 assert_eq!(
300 id("https://anvil.richardscollin.com/").unwrap(),
301 "anvil.richardscollin.com"
302 );
303 // Case is normalized: browsers report the host lowercased.
304 assert_eq!(id("https://Anvil.LOCALHOST").unwrap(), "anvil.localhost");
305 assert!(id("").is_err());
306 }
307
308 #[test]
309 fn the_origin_keeps_scheme_and_port() {
310 assert_eq!(origin("http://localhost:3000/"), "http://localhost:3000");
311 assert_eq!(origin("https://anvil.localhost"), "https://anvil.localhost");
312 }
313
314 #[test]
315 fn ceremonies_are_single_use_and_expire() {
316 let ceremonies = Ceremonies::default();
317 let id = ceremonies.insert(Ceremony::Authenticate {
318 state: Box::new(fake_auth_state()),
319 });
320 assert!(ceremonies.take(&id).is_some());
321 assert!(
322 ceremonies.take(&id).is_none(),
323 "a challenge must not be answerable twice"
324 );
325 }
326
327 /// A real ceremony state, built the way the server builds one.
328 fn fake_auth_state() -> DiscoverableAuthenticationServerState {
329 let rp = rp_id("https://anvil.localhost").unwrap();
330 webauthn_rp::DiscoverableCredentialRequestOptions::passkey(&rp)
331 .start_ceremony()
332 .expect("default passkey options are valid")
333 .0
334 }
335
336 #[test]
337 fn labels_are_trimmed_and_defaulted() {
338 assert_eq!(display_name(" MacBook "), "MacBook");
339 assert_eq!(display_name(""), "passkey");
340 assert_eq!(display_name(&"x".repeat(100)).len(), 64);
341 }
342}