| 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 | |
| 18 | use 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 | |
| 31 | use 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. |
| 42 | pub 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. |
| 46 | const 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. |
| 50 | const 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). |
| 57 | pub 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. |
| 85 | pub 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. |
| 90 | pub 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)] |
| 109 | pub struct Ceremonies { |
| 110 | inner: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<String, Pending>>>, |
| 111 | } |
| 112 | |
| 113 | struct Pending { |
| 114 | ceremony: Ceremony, |
| 115 | expires_at: i64, |
| 116 | } |
| 117 | |
| 118 | impl 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 | |
| 166 | fn 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. |
| 177 | pub fn new_user_handle() -> UserHandle64 { |
| 178 | UserHandle64::new() |
| 179 | } |
| 180 | |
| 181 | // --- persistence ----------------------------------------------------------- |
| 182 | |
| 183 | /// List a user's passkeys, newest first. |
| 184 | pub 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. |
| 194 | pub 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)] |
| 209 | pub 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. |
| 241 | pub 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. |
| 255 | pub 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. |
| 272 | pub 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. |
| 281 | fn 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)] |
| 291 | mod 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 | } |