//! Self-service credentials: the password, passkeys, and which of the two an //! account needs to sign in. //! //! Every route here except the two `login_*` ones needs a session. The two //! that do not are the passkey half of signing in, which by definition runs //! before there is one. use std::sync::Arc; use api_types::{ AuthMode, ChangePassword, LoginResp, OkResp, PasskeyChallenge, PasskeyInfo, PasskeyLoginBegin, PasskeyLoginFinish, PasskeyRegisterFinish, PasswordStep, SetAuthMode, }; use axum::Json; use axum::extract::{Path as AxumPath, State}; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Response}; use webauthn_rs::prelude::*; use webauthn_rs_proto::{AllowCredentials, ResidentKeyRequirement}; use crate::api::common::{SessionUser, credential_label, hash_password, validate_password}; use crate::auth::{self, parse_session_cookie, session_cookie}; use crate::db::{PASSKEY_LIMIT, PasskeyDeleted, PasskeyRow}; use crate::error::{ApiError, AppState}; use crate::webauthn::{Pending, Rp}; // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- fn challenge_expired() -> ApiError { ApiError::localized( StatusCode::BAD_REQUEST, "that took too long, please try again", "err_challenge_expired", ) } /// One message for every way a WebAuthn ceremony can fail. /// /// The detail goes to the log, never to the client: a bad signature, a /// mismatched origin and an unknown credential are all "it did not work" to /// the person at the keyboard, and telling them apart only helps an attacker. fn webauthn_failed(e: WebauthnError) -> ApiError { tracing::warn!(error = ?e, "webauthn ceremony failed"); ApiError::localized( StatusCode::UNAUTHORIZED, "that passkey could not be used", "err_passkey_failed", ) } /// The database refused a change that would have left the account with no way /// to sign in. /// /// Each handler checks its own rule first and says which one, so this is only /// reached when two changes race: a count taken before the write was already /// stale. Rare enough that one message covers it. fn locked_out() -> ApiError { ApiError::localized( StatusCode::BAD_REQUEST, "that would leave the account with no way to sign in", "err_locked_out", ) } fn too_many_passkeys() -> ApiError { ApiError::localized( StatusCode::BAD_REQUEST, "this account already holds as many passkeys as it may", "err_passkey_limit", ) } fn bad_credential() -> ApiError { ApiError::localized( StatusCode::BAD_REQUEST, "the browser sent an unreadable credential", "err_passkey_malformed", ) } // --------------------------------------------------------------------------- // Shared checks // --------------------------------------------------------------------------- /// Apply the fallout of any credential change: every other session of this /// user is dropped, and cached WebDAV credentials are forgotten. /// /// This carries more weight than it looks. Nothing on these routes asks for /// the current password — a passkey-only account has none — so the session is /// the only thing standing behind a credential change. Dropping the others /// keeps a session stolen before the change from outliving it. async fn invalidate_elsewhere( state: &AppState, user_id: i64, headers: &HeaderMap, ) -> Result<(), ApiError> { let current = parse_session_cookie(headers).unwrap_or_default(); state.db.delete_other_sessions(user_id, ¤t).await?; auth::forget_verified_for(user_id); Ok(()) } fn info(row: &PasskeyRow) -> PasskeyInfo { PasskeyInfo { id: row.id, name: row.name.clone(), created_at: row.created_at.clone(), last_used_at: row.last_used_at.clone(), discoverable: row.discoverable, } } /// The stored credentials of one account, ready for `webauthn-rs`. /// /// A row that will not deserialize is skipped rather than fatal. It can only /// come from a `webauthn-rs` format change, and one unreadable passkey must /// not lock an account out of the others. pub(crate) async fn load_passkeys( state: &AppState, user_id: i64, ) -> Result, ApiError> { Ok(state .db .user_passkeys(user_id) .await? .into_iter() .filter_map(|r| match serde_json::from_str::(&r.passkey) { Ok(k) => Some((r.id, k)), Err(e) => { tracing::error!(passkey_id = r.id, error = %e, "stored passkey is unreadable"); None } }) .collect()) } // --------------------------------------------------------------------------- // Password // --------------------------------------------------------------------------- /// POST `{AUTH_PASSWORD}` — set or change the password. pub async fn change_password( State(state): State>, SessionUser { user, .. }: SessionUser, headers: HeaderMap, Json(body): Json, ) -> Result, ApiError> { validate_password(&body.new_password)?; let hash = hash_password(&body.new_password).await?; state .db .set_password_keeping_sessions(user.id, &hash) .await?; invalidate_elsewhere(&state, user.id, &headers).await?; Ok(Json(OkResp {})) } /// DELETE `{AUTH_PASSWORD}` — leave the account on passkeys alone. pub async fn delete_password( State(state): State>, SessionUser { user, .. }: SessionUser, headers: HeaderMap, ) -> Result, ApiError> { if !user.has_password { return Ok(Json(OkResp {})); } // The account must keep at least one way in, and `Both` needs a password // by definition. if state.db.count_passkeys(user.id).await? == 0 { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "add a passkey before removing your password", "err_password_last_credential", )); } if user.auth_mode == AuthMode::Both { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "this account requires a password and a passkey", "err_required_by_mode", )); } if !state.db.clear_user_password(user.id).await? { return Err(locked_out()); } invalidate_elsewhere(&state, user.id, &headers).await?; Ok(Json(OkResp {})) } // --------------------------------------------------------------------------- // Sign-in requirement // --------------------------------------------------------------------------- /// PUT `{AUTH_MODE}`. pub async fn set_mode( State(state): State>, SessionUser { user, .. }: SessionUser, headers: HeaderMap, Json(body): Json, ) -> Result, ApiError> { if body.mode == AuthMode::Both { if !user.has_password { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "set a password before requiring both", "err_mode_needs_password", )); } if state.db.count_passkeys(user.id).await? == 0 { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "add a passkey before requiring both", "err_mode_needs_passkey", )); } } if !state.db.set_user_auth_mode(user.id, body.mode).await? { return Err(locked_out()); } // WebDAV speaks HTTP Basic, which carries a password and nothing else. An // account that requires both can no longer authenticate a mount, so any // cached Basic credential has to go. invalidate_elsewhere(&state, user.id, &headers).await?; Ok(Json(OkResp {})) } // --------------------------------------------------------------------------- // Managing passkeys // --------------------------------------------------------------------------- /// GET `{AUTH_PASSKEYS}`. pub async fn list( State(state): State>, SessionUser { user, .. }: SessionUser, ) -> Result>, ApiError> { let rows = state.db.user_passkeys(user.id).await?; Ok(Json(rows.iter().map(info).collect())) } /// DELETE `{AUTH_PASSKEYS}/{id}`. pub async fn delete( State(state): State>, SessionUser { user, .. }: SessionUser, headers: HeaderMap, AxumPath(id): AxumPath, ) -> Result, ApiError> { // The database decides, inside one transaction, whether the account would // still have a way in. Asking it first means an id that does not exist is // a plain 404, not a complaint about a rule it never reached. match state.db.delete_passkey(id, user.id).await? { PasskeyDeleted::Gone => {} PasskeyDeleted::NotFound => { return Err(ApiError::localized( StatusCode::NOT_FOUND, "no such passkey", "err_passkey_not_found", )); } // Say which of the two rules stopped it. PasskeyDeleted::LastCredential if user.auth_mode == AuthMode::Both => { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "this account requires a password and a passkey", "err_required_by_mode", )); } PasskeyDeleted::LastCredential => { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "set a password before removing your last passkey", "err_passkey_last_credential", )); } } invalidate_elsewhere(&state, user.id, &headers).await?; Ok(Json(OkResp {})) } /// POST `{AUTH_PASSKEYS_REGISTER}` — first leg of registration. pub async fn register_begin( State(state): State>, SessionUser { user, .. }: SessionUser, Rp(rp): Rp, ) -> Result, ApiError> { // Checked again inside `add_passkey`, which is where it actually holds. // This one only spares the user a ceremony that could not be stored. if state.db.count_passkeys(user.id).await? as usize >= PASSKEY_LIMIT { return Err(too_many_passkeys()); } let wid = state.db.user_webauthn_id(user.id).await?; // Excluding what is already registered makes the authenticator refuse a // second credential for this account, instead of silently creating one // the user then has to tell apart from the first. let existing: Vec = load_passkeys(&state, user.id) .await? .iter() .map(|(_, k)| k.cred_id().clone()) .collect(); let (mut options, reg) = rp .start_passkey_registration(wid, &user.name, &user.name, Some(existing)) .map_err(webauthn_failed)?; ask_for_discoverable(&mut options); Ok(Json(challenge( Pending::Register { user_id: user.id, state: Box::new(reg), }, &options, )?)) } /// POST `{AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}`. pub async fn register_finish( State(state): State>, SessionUser { user, .. }: SessionUser, Rp(rp): Rp, headers: HeaderMap, Json(body): Json, ) -> Result, ApiError> { let Some(Pending::Register { user_id, state: reg, }) = crate::webauthn::take(&body.state_id) else { return Err(challenge_expired()); }; // The handle is opaque and single-use, so this can only be a client that // mixed two ceremonies up. Refuse rather than register to the wrong // account. if user_id != user.id { return Err(challenge_expired()); } let cred: RegisterPublicKeyCredential = serde_json::from_str(&body.credential).map_err(|_| bad_credential())?; // Whether the browser thinks it stored a discoverable credential. Unsigned // and optional, so it is a UI hint only — never a security decision. let discoverable = cred.extensions.cred_props.as_ref().and_then(|c| c.rk); let passkey = rp .finish_passkey_registration(&cred, ®) .map_err(webauthn_failed)?; let encoded = serde_json::to_string(&passkey).map_err(|e| { ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, format!("cannot store passkey: {e}"), ) })?; let Some(row) = state .db .add_passkey( user.id, passkey.cred_id().as_ref(), &encoded, &credential_label(&body.name, "Passkey"), discoverable, ) .await .map_err(|e| match e { // `passkeys.cred_id` is UNIQUE across the whole table, so this // also fires when the credential belongs to another account. rusqlite::Error::SqliteFailure(f, _) if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE => { ApiError::localized( StatusCode::CONFLICT, "that passkey is already registered", "err_passkey_duplicate", ) } other => other.into(), })? else { return Err(too_many_passkeys()); }; invalidate_elsewhere(&state, user.id, &headers).await?; Ok(Json(info(&row))) } // --------------------------------------------------------------------------- // Signing in with a passkey // --------------------------------------------------------------------------- /// Key material for one decoy, in counter mode so any id length is reachable. /// /// `tag` separates the two things derived per decoy, its length and its bytes, /// so neither can be read off the other. fn decoy_bytes(secret: &str, name: &str, index: u32, tag: u8, len: usize) -> Vec { use sha2::{Digest, Sha256}; let mut out = Vec::with_capacity(len + 32); let mut block = 0u32; while out.len() < len { let mut h = Sha256::new(); h.update(secret.as_bytes()); h.update([tag]); // Length-prefixed, so two names cannot run together into one input. h.update((name.len() as u64).to_le_bytes()); h.update(name.as_bytes()); h.update(index.to_le_bytes()); h.update(block.to_le_bytes()); out.extend_from_slice(&h.finalize()); block += 1; } out.truncate(len); out } /// One fake `allowCredentials` entry, stable across requests. /// /// A real account lists the same credential ids every time. A decoy derived /// from a per-install secret does too, so probing one name twice gives an /// attacker nothing to compare. /// /// The name is ASCII-folded first, because `users.name` is `COLLATE NOCASE`. /// Without that, "admin" and "ADMIN" would return the same real credential /// with different decoys around it, and comparing the two spellings would say /// which entries were real. fn decoy(secret: &str, name: &str, index: u32, lengths: &[usize]) -> AllowCredentials { let name = &name.to_ascii_lowercase(); let pick = decoy_bytes(secret, name, index, 1, 1); let len = lengths[usize::from(pick[0]) % lengths.len()]; AllowCredentials { type_: "public-key".to_string(), id: decoy_bytes(secret, name, index, 0, len).into(), transports: None, } } /// POST `{AUTH_PASSKEY_LOGIN}` — first leg of a passkey sign-in. /// /// An empty name gets a discoverable challenge, which any passkey the browser /// holds for this site can answer. A name gets a challenge listing credentials, /// which is the only form a non-discoverable credential can answer. /// /// This route needs no session, so a named challenge must not say whether the /// name exists. It does not: an unknown name gets a list of decoys, and the /// two starters' other differences are flattened below. The account behind a /// real name still decides nothing here, because the assertion has to verify /// before anyone is signed in. pub async fn login_begin( State(state): State>, Rp(rp): Rp, Json(body): Json, ) -> Result, ApiError> { // Autofill carries no name and its challenge is the same for everyone, so // it needs none of the padding below. let name = match body .name .as_deref() .map(str::trim) .filter(|n| !n.is_empty()) { Some(name) if !body.conditional => name, _ => { let (mut options, disc) = rp .start_discoverable_authentication() .map_err(webauthn_failed)?; // `start_discoverable_authentication` always asks for conditional // mediation, which parks the request in the autofill dropdown. The // button wants the modal picker instead. if !body.conditional { options.mediation = None; } return Ok(Json(challenge( Pending::Discoverable { state: Box::new(disc), decoy: false, }, &options, )?)); } }; let found = match state.db.find_user_by_name(name).await?.filter(|u| u.active) { Some(u) => { let keys: Vec = load_passkeys(&state, u.id) .await? .into_iter() .map(|(_, k)| k) .collect(); (!keys.is_empty()).then_some((u.id, keys)) } None => None, }; // An unknown name still gets a working ceremony, not a fake one: a passkey // the browser holds for this site can answer it. Only the credential list // is invented. let (mut options, pending) = match found { Some((user_id, keys)) => { let (options, auth) = rp .start_passkey_authentication(&keys) .map_err(webauthn_failed)?; ( options, Pending::Authenticate { user_id, state: Box::new(auth), second_factor: false, }, ) } None => { let (options, disc) = rp .start_discoverable_authentication() .map_err(webauthn_failed)?; ( options, Pending::Discoverable { state: Box::new(disc), decoy: true, }, ) } }; // The two starters disagree on more than the credential list. // `start_discoverable_authentication` asks for the `uvm` extension and // conditional mediation; `start_passkey_authentication` asks for neither. // Left alone those two fields would answer the question the decoys are // here to hide. Neither is checked when the assertion comes back, so // clearing them costs nothing. options.public_key.extensions = None; options.mediation = None; // Transports vary per authenticator and a decoy has none to copy, so they // come off the real entries too. They are a hint to the browser about // where to look, never a requirement. for cred in &mut options.public_key.allow_credentials { cred.transports = None; } let secret = state.db.decoy_secret().await?; let mut lengths = state.db.cred_id_lengths().await?; if lengths.is_empty() { lengths.push(32); } // Always exactly `PASSKEY_LIMIT` entries. No account may hold more, so the // list never has to grow past the padding and its length says nothing. for index in options.public_key.allow_credentials.len()..PASSKEY_LIMIT { options .public_key .allow_credentials .push(decoy(&secret, name, index as u32, &lengths)); } Ok(Json(challenge(pending, &options)?)) } /// POST `{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}`. /// /// Either signs the user in, or — for an account that needs both factors and /// started with the passkey — asks for the password next. pub async fn login_finish( State(state): State>, Rp(rp): Rp, Json(body): Json, ) -> Result { let Some(pending) = crate::webauthn::take(&body.state_id) else { return Err(challenge_expired()); }; let cred: PublicKeyCredential = serde_json::from_str(&body.credential).map_err(|_| bad_credential())?; let (user_id, second_factor, result) = match pending { Pending::Authenticate { user_id, state: auth_state, second_factor, } => { let res = rp .finish_passkey_authentication(&cred, &auth_state) .map_err(webauthn_failed)?; (user_id, second_factor, res) } Pending::Discoverable { state: disc, decoy } => { // The user handle comes from the credential, so it is only a // claim until `finish_discoverable_authentication` checks the // signature against that account's own keys below. let (wid, _) = rp .identify_discoverable_authentication(&cred) .map_err(webauthn_failed)?; let user = state .db .find_user_by_webauthn_id(&wid) .await? .filter(|u| u.active) .ok_or_else(|| webauthn_failed(WebauthnError::CredentialNotFound))?; let keys: Vec = load_passkeys(&state, user.id) .await? .iter() .map(|(_, k)| k.into()) .collect(); let res = rp .finish_discoverable_authentication(&cred, *disc, &keys) .map_err(webauthn_failed)?; // The name this challenge was issued for does not exist. The // ceremony was real so that it could not be told apart from a // real one, and it is verified before being refused for the same // reason. Signing this passkey's owner in instead would answer // the question the whole padding is there to swallow. if decoy { return Err(webauthn_failed(WebauthnError::CredentialNotFound)); } (user.id, false, res) } // Any other handle names a different ceremony. Refusing keeps a // registration challenge from being answered as a sign-in. _ => return Err(challenge_expired()), }; record_use(&state, user_id, &result).await?; let user = state .db .find_user_by_id(user_id) .await? .filter(|u| u.active) .ok_or_else(|| webauthn_failed(WebauthnError::CredentialNotFound))?; if user.auth_mode == AuthMode::Both && !second_factor { let state_id = crate::webauthn::put(Pending::NeedsPassword { user_id }); return Ok(Json(LoginResp { ok: false, password_required: Some(PasswordStep { name: user.name, state_id, }), ..Default::default() }) .into_response()); } sign_in(&state, user_id).await } /// Persist what the assertion changed: the signature counter and backup /// flags move, and the settings list shows when a passkey was last used. async fn record_use( state: &AppState, user_id: i64, result: &AuthenticationResult, ) -> Result<(), ApiError> { let Some((id, mut key)) = load_passkeys(state, user_id) .await? .into_iter() .find(|(_, k)| k.cred_id() == result.cred_id()) else { return Ok(()); }; key.update_credential(result); let encoded = serde_json::to_string(&key).unwrap_or_default(); if !encoded.is_empty() { state.db.passkey_used(id, &encoded).await?; } Ok(()) } /// Create the session and send its cookie. pub(crate) async fn sign_in(state: &AppState, user_id: i64) -> Result { let token = auth::random_token(); state.db.create_session(user_id, &token).await?; let mut res = Json(LoginResp { ok: true, ..Default::default() }) .into_response(); res.headers_mut().insert( header::SET_COOKIE, session_cookie(&token, state.https).parse().unwrap(), ); Ok(res) } /// Ask the authenticator to store the credential itself. /// /// `start_passkey_registration` sends `residentKey: "discouraged"`, which /// tells a password manager *not* to make a discoverable passkey — and they /// obey it, so every credential would then need the account name typed in to /// be found again. There is no builder switch for this on the passkey API, /// hence the patch. /// /// Only the request changes, not what is accepted: an authenticator with no /// room for a resident key still registers, and the `credProps` extension /// reports what actually happened. Enforcing it would lock out the older /// security keys this server deliberately still supports. fn ask_for_discoverable(options: &mut CreationChallengeResponse) { match options.public_key.authenticator_selection.as_mut() { Some(sel) => { sel.resident_key = Some(ResidentKeyRequirement::Required); // `require_resident_key` is the CTAP1-era boolean, consulted only // when `residentKey` is absent. Some older keys fail outright on // it, so it stays false. } // `webauthn-rs` always sends this block today. If a future version // stops, every new passkey silently goes back to needing a typed name. None => tracing::warn!("no authenticatorSelection to ask for a discoverable credential"), } } /// Park a ceremony's state and pair its handle with the browser's options. pub(crate) fn challenge( pending: Pending, options: &T, ) -> Result { let options = serde_json::to_string(options).map_err(|e| { ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, format!("cannot encode the challenge: {e}"), ) })?; Ok(PasskeyChallenge { state_id: crate::webauthn::put(pending), options, }) }