webauthn.rs
| 1 | //! WebAuthn (passkey) support: the relying-party instance and the short-lived |
| 2 | //! state of an in-flight ceremony. |
| 3 | //! |
| 4 | //! Both WebAuthn ceremonies take two round trips. The server issues a |
| 5 | //! challenge, the browser answers it, and the server must still hold the |
| 6 | //! challenge it issued to check the answer. That state lives in [`PENDING`] |
| 7 | //! here, keyed by an opaque handle the client echoes back. |
| 8 | |
| 9 | use std::collections::HashMap; |
| 10 | use std::sync::{Arc, LazyLock}; |
| 11 | use std::time::{Duration, Instant}; |
| 12 | |
| 13 | use axum::extract::FromRequestParts; |
| 14 | use axum::http::request::Parts; |
| 15 | use axum::http::{HeaderMap, StatusCode, Uri, header}; |
| 16 | use webauthn_rs::prelude::*; |
| 17 | |
| 18 | use crate::error::{ApiError, AppState}; |
| 19 | |
| 20 | /// The relying party, as an extractor. |
| 21 | /// |
| 22 | /// Built per request rather than once at startup, because the RP ID is the |
| 23 | /// domain the browser is on, and nothing tells us that at startup unless |
| 24 | /// `--public-url` is set. A reverse proxy that rewrites the host would break |
| 25 | /// this; set `--public-url` there. |
| 26 | pub struct Rp(pub Webauthn); |
| 27 | |
| 28 | impl FromRequestParts<Arc<AppState>> for Rp { |
| 29 | type Rejection = ApiError; |
| 30 | |
| 31 | async fn from_request_parts( |
| 32 | parts: &mut Parts, |
| 33 | state: &Arc<AppState>, |
| 34 | ) -> Result<Self, Self::Rejection> { |
| 35 | relying_party(state, &parts.uri, &parts.headers).map(Rp) |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | pub fn relying_party( |
| 40 | state: &AppState, |
| 41 | uri: &Uri, |
| 42 | headers: &HeaderMap, |
| 43 | ) -> Result<Webauthn, ApiError> { |
| 44 | let origin = origin(state, uri, headers).ok_or_else(misconfigured)?; |
| 45 | check_origin(&origin, headers)?; |
| 46 | // `domain()` is None for a bare IP address, and WebAuthn does not work on |
| 47 | // one at all — the RP ID has to be a registrable domain. |
| 48 | let rp_id = origin.domain().ok_or_else(misconfigured)?; |
| 49 | WebauthnBuilder::new(rp_id, &origin) |
| 50 | .and_then(|b| b.rp_name("dovenest").build()) |
| 51 | .map_err(|e| { |
| 52 | tracing::error!(error = ?e, %origin, "cannot build the WebAuthn relying party"); |
| 53 | misconfigured() |
| 54 | }) |
| 55 | } |
| 56 | |
| 57 | /// The origin the browser will report, as far as the server can tell. |
| 58 | /// |
| 59 | /// Falls back to the request's own host, which is plain HTTP because that is |
| 60 | /// all this process ever speaks. Behind a TLS proxy that guess is wrong and |
| 61 | /// `--public-url` is the only way to correct it. |
| 62 | /// |
| 63 | /// The host arrives in one of two places depending on the protocol version. |
| 64 | /// HTTP/1.1 sends a `Host` header; HTTP/2 sends `:authority`, which hyper |
| 65 | /// puts in the URI and does *not* mirror into a header. Reading only one of |
| 66 | /// them would break passkeys behind an h2 reverse proxy. |
| 67 | fn origin(state: &AppState, uri: &Uri, headers: &HeaderMap) -> Option<Url> { |
| 68 | if let Some(public) = &state.public_url { |
| 69 | return Some(public.clone()); |
| 70 | } |
| 71 | let host = match uri.authority() { |
| 72 | Some(a) => a.as_str().to_string(), |
| 73 | None => headers.get(header::HOST)?.to_str().ok()?.to_string(), |
| 74 | }; |
| 75 | Url::parse(&format!("http://{host}")).ok() |
| 76 | } |
| 77 | |
| 78 | /// Refuse a ceremony the browser could not complete anyway. |
| 79 | /// |
| 80 | /// A challenge built for the wrong origin fails in the browser with a bare |
| 81 | /// `SecurityError`, or at the last step with a mismatch nobody can see. The |
| 82 | /// `Origin` header is what the browser will sign, so comparing it here turns |
| 83 | /// both into one message that names the two addresses. |
| 84 | /// |
| 85 | /// Absent on requests that are not a browser fetch, and then unenforced. |
| 86 | fn check_origin(configured: &Url, headers: &HeaderMap) -> Result<(), ApiError> { |
| 87 | let Some(browser) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) else { |
| 88 | return Ok(()); |
| 89 | }; |
| 90 | let expected = configured.origin().ascii_serialization(); |
| 91 | if browser == expected { |
| 92 | return Ok(()); |
| 93 | } |
| 94 | tracing::error!( |
| 95 | %browser, |
| 96 | %expected, |
| 97 | "passkeys are configured for a different address than the browser is on; \ |
| 98 | set --public-url to the address the browser uses" |
| 99 | ); |
| 100 | Err(ApiError::localized( |
| 101 | StatusCode::INTERNAL_SERVER_ERROR, |
| 102 | "passkeys are set up for a different address", |
| 103 | "err_passkey_origin", |
| 104 | )) |
| 105 | } |
| 106 | |
| 107 | /// The client is told nothing but "not available here". |
| 108 | /// |
| 109 | /// Some of the routes that reach this need no session, so the hint belongs in |
| 110 | /// the log. It names the deployment's configuration, which is the operator's |
| 111 | /// business and not a visitor's. |
| 112 | fn misconfigured() -> ApiError { |
| 113 | tracing::error!("passkeys need a domain name for the relying party; set --public-url"); |
| 114 | ApiError::localized( |
| 115 | StatusCode::INTERNAL_SERVER_ERROR, |
| 116 | "passkeys are not available here", |
| 117 | "err_passkey_unavailable", |
| 118 | ) |
| 119 | } |
| 120 | |
| 121 | // --------------------------------------------------------------------------- |
| 122 | // In-flight ceremonies |
| 123 | // --------------------------------------------------------------------------- |
| 124 | |
| 125 | /// What the second leg of a ceremony needs to know. |
| 126 | pub enum Pending { |
| 127 | /// Registering a passkey for a signed-in user. |
| 128 | Register { |
| 129 | user_id: i64, |
| 130 | state: Box<PasskeyRegistration>, |
| 131 | }, |
| 132 | /// Signing in with a known account: the challenge names that account's |
| 133 | /// credentials, so the answer can only come from one of them. |
| 134 | Authenticate { |
| 135 | user_id: i64, |
| 136 | state: Box<PasskeyAuthentication>, |
| 137 | /// True when the password already passed and this is the second |
| 138 | /// factor. Only then does finishing create a session directly. |
| 139 | second_factor: bool, |
| 140 | }, |
| 141 | /// Signing in without a name. The account is only known once the browser |
| 142 | /// answers, because the answer carries the user handle. |
| 143 | Discoverable { |
| 144 | state: Box<DiscoverableAuthentication>, |
| 145 | /// True when this stands in for a name the server does not know. |
| 146 | /// |
| 147 | /// The ceremony is real so that it cannot be told apart from one for |
| 148 | /// an account that exists. Finishing it must still fail, or answering |
| 149 | /// with any passkey would sign that passkey's owner in and turn the |
| 150 | /// answer into the name oracle the padding exists to prevent. |
| 151 | decoy: bool, |
| 152 | }, |
| 153 | /// A passkey passed, but the account also requires its password. Holds |
| 154 | /// the identified user until `POST /api/auth/login` supplies it. |
| 155 | NeedsPassword { user_id: i64 }, |
| 156 | } |
| 157 | |
| 158 | impl Pending { |
| 159 | /// Whether a stranger could have made this one. |
| 160 | /// |
| 161 | /// Only these count against [`MAX_ANONYMOUS`]. The rest cost a session, a |
| 162 | /// correct password or a real authenticator signature to produce, and that |
| 163 | /// limits them better than a number here could. It also keeps a flood of |
| 164 | /// the cheap kind from evicting a sign-in that is halfway done. |
| 165 | fn anonymous(&self) -> bool { |
| 166 | matches!( |
| 167 | self, |
| 168 | Pending::Discoverable { .. } |
| 169 | | Pending::Authenticate { |
| 170 | second_factor: false, |
| 171 | .. |
| 172 | } |
| 173 | ) |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | /// How long a client has to answer a challenge. |
| 178 | /// |
| 179 | /// The browser's own timeout is shorter, but a conditional-UI challenge sits |
| 180 | /// in an autofill dropdown until the user touches the field. |
| 181 | const TTL: Duration = Duration::from_secs(300); |
| 182 | |
| 183 | /// Upper bound on outstanding ceremonies nobody had to authenticate for. |
| 184 | /// |
| 185 | /// Conditional UI creates one on every load of the login page, most of which |
| 186 | /// are never answered. Without a cap an unauthenticated visitor could grow |
| 187 | /// this map without limit. |
| 188 | /// |
| 189 | /// ponytail: the authenticated kinds are uncapped. Registering needs a |
| 190 | /// session, so an account could loop it; the ceiling is one TTL of requests, |
| 191 | /// tens of megabytes at a realistic rate. Cap them too if that ever bites. |
| 192 | const MAX_ANONYMOUS: usize = 4096; |
| 193 | |
| 194 | /// ponytail: process-wide map, like `auth::LOGIN_FAILURES` and |
| 195 | /// `auth::VERIFIED`. Move it to the DB if the server is ever scaled out — |
| 196 | /// today a challenge issued by one node could not be answered on another. |
| 197 | static PENDING: LazyLock<std::sync::Mutex<HashMap<String, (Pending, Instant)>>> = |
| 198 | LazyLock::new(Default::default); |
| 199 | |
| 200 | /// Store a ceremony and return the handle the client sends back. |
| 201 | pub fn put(pending: Pending) -> String { |
| 202 | let id = crate::auth::random_token(); |
| 203 | let mut map = PENDING.lock().unwrap_or_else(|e| e.into_inner()); |
| 204 | map.retain(|_, (_, at)| at.elapsed() < TTL); |
| 205 | // Still full of live anonymous entries: drop the oldest of those to make |
| 206 | // room. A visitor whose challenge is evicted here just retries, and a |
| 207 | // half-finished sign-in is never the thing that gets dropped. |
| 208 | if pending.anonymous() |
| 209 | && map.values().filter(|(p, _)| p.anonymous()).count() >= MAX_ANONYMOUS |
| 210 | && let Some(oldest) = map |
| 211 | .iter() |
| 212 | .filter(|(_, (p, _))| p.anonymous()) |
| 213 | .min_by_key(|(_, (_, at))| *at) |
| 214 | .map(|(k, _)| k.clone()) |
| 215 | { |
| 216 | map.remove(&oldest); |
| 217 | } |
| 218 | map.insert(id.clone(), (pending, Instant::now())); |
| 219 | id |
| 220 | } |
| 221 | |
| 222 | /// Take a ceremony out of the map. One handle answers one challenge: a replay |
| 223 | /// of the same handle finds nothing. |
| 224 | pub fn take(id: &str) -> Option<Pending> { |
| 225 | let mut map = PENDING.lock().unwrap_or_else(|e| e.into_inner()); |
| 226 | map.retain(|_, (_, at)| at.elapsed() < TTL); |
| 227 | map.remove(id).map(|(p, _)| p) |
| 228 | } |
| 229 | |
| 230 | #[cfg(test)] |
| 231 | mod tests { |
| 232 | use super::*; |
| 233 | |
| 234 | #[test] |
| 235 | fn a_browser_on_another_origin_is_refused() { |
| 236 | let configured = Url::parse("https://files.example.com/").unwrap(); |
| 237 | let header = |v: &str| { |
| 238 | let mut h = HeaderMap::new(); |
| 239 | h.insert(header::ORIGIN, v.parse().unwrap()); |
| 240 | h |
| 241 | }; |
| 242 | |
| 243 | assert!(check_origin(&configured, &HeaderMap::new()).is_ok()); |
| 244 | assert!(check_origin(&configured, &header("https://files.example.com")).is_ok()); |
| 245 | // The three ways --public-url goes wrong. |
| 246 | assert!(check_origin(&configured, &header("http://files.example.com")).is_err()); |
| 247 | assert!(check_origin(&configured, &header("https://other.example.com")).is_err()); |
| 248 | assert!(check_origin(&configured, &header("https://files.example.com:8443")).is_err()); |
| 249 | } |
| 250 | |
| 251 | #[test] |
| 252 | fn a_handle_answers_once() { |
| 253 | let id = put(Pending::NeedsPassword { user_id: 7 }); |
| 254 | assert!(matches!( |
| 255 | take(&id), |
| 256 | Some(Pending::NeedsPassword { user_id: 7 }) |
| 257 | )); |
| 258 | assert!(take(&id).is_none(), "a handle must not be reusable"); |
| 259 | assert!(take("never-issued").is_none()); |
| 260 | } |
| 261 | |
| 262 | /// An anonymous ceremony, the kind `login_begin` hands out to a stranger. |
| 263 | fn anonymous_ceremony() -> Pending { |
| 264 | let url = Url::parse("https://example.com").unwrap(); |
| 265 | let rp = WebauthnBuilder::new("example.com", &url) |
| 266 | .unwrap() |
| 267 | .build() |
| 268 | .unwrap(); |
| 269 | let (_, disc) = rp.start_discoverable_authentication().unwrap(); |
| 270 | Pending::Discoverable { |
| 271 | state: Box::new(disc), |
| 272 | decoy: false, |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | #[test] |
| 277 | fn a_flood_of_strangers_stays_bounded_and_spares_a_sign_in() { |
| 278 | // A sign-in that already passed one factor, parked mid-flight. |
| 279 | let halfway = put(Pending::NeedsPassword { user_id: 1 }); |
| 280 | |
| 281 | for _ in 0..MAX_ANONYMOUS + 50 { |
| 282 | put(anonymous_ceremony()); |
| 283 | } |
| 284 | |
| 285 | let anon = PENDING |
| 286 | .lock() |
| 287 | .unwrap() |
| 288 | .values() |
| 289 | .filter(|(p, _)| p.anonymous()) |
| 290 | .count(); |
| 291 | assert!(anon <= MAX_ANONYMOUS, "{anon} entries outgrew the cap"); |
| 292 | assert!( |
| 293 | take(&halfway).is_some(), |
| 294 | "a flood must not evict a half-finished sign-in" |
| 295 | ); |
| 296 | } |
| 297 | } |
| 298 |