latchkey 0.4.0

Sans-IO sign-in building blocks and policy: Argon2id passwords, TOTP, recovery codes, passkeys, tokens, sealing, cookies, lockout, rate limits and sign-in flows as pure state machines
Documentation

latchkey

Sign-in building blocks and sign-in policy for Rust, without I/O.

latchkey hashes passwords, checks TOTP and recovery codes, runs passkey ceremonies, issues tokens, seals secrets, builds cookies, and decides what a sign-in flow does next. It does not talk to a database, send mail, serve HTTP, read the clock or draw randomness behind your back. Every function takes values (including now and an explicit CryptoRng) and returns values; your app stores what it is told to store, sends what it is told to send and answers its clients.

It was extracted from an axum sign-in service (latchkey 0.2) and keeps its behaviour and stored formats: Argon2id (64 MiB, t=3, p=2), TOTP (SHA-1; 6 digits, 30 s, ±1 step by default) with a replay guard, single-use recovery codes, passkeys with user verification, __Host- cookies, Origin and CSRF checks, a doubling lockout, a wrong-password throttle, new-device mail and an audit trail, now as pure functions and state machines. Since 0.4 an emailed link or code stands in for the password only: the second factor is always asked for.

What is in it

Module What it does
password PasswordHasher: Argon2id hash/verify, needs_rehash for hashes made with other parameters, PasswordHash::from_legacy_argon2 for imports, a dummy hash so unknown accounts cost the same, check_policy(password, length) for new passwords
totp TotpSecret (from_base32 to import), TotpParams (digits, step, skew), enrolment (base32 + otpauth:// URL), verify(secret, code, last_used_step, now, params) -> Result<TotpStep, TotpRefusal>, qr_svg/qr_png
recovery generate(count, rng) -> RecoveryCodes { codes, hashes }, verify(code, unused_hashes) -> Option<RecoveryCodeHash>
passkey RelyingParty with start_*/finish_* for registration, authentication, allow-list and discoverable sign-in; Ceremony<S> bound (Ceremony::binding) to a session and user, or before sign-in to the browser by a one-off token; Config::passkey_ceremony_ttl
token issue(rng) -> IssuedToken { token, hash }, TokenHash::of(presented), TokenHash::of_foreign for tokens another system issued, constant-time compare
seal DataKey: AES-256-GCM with associated data per owner and kind, compatible with 0.2's stored values
http SetCookie (__Host-, HttpOnly, Secure, SameSite=Lax; a development mode for plain-HTTP localhost), find_cookie, RequestHeaders::check_origin / check_session_request, client_ip, IpTagKey
account Account, Email, Factors (last-factor rules), LockoutPolicy and Lockout
limits RateLimiter (fixed windows), WrongPasswordThrottle (sliding window), and their per-key states
session Session records (with their step-up failure count), Stage, SignInMethod, Session::authorize(active, Access, step_up_window, now)
flow sign_in, email_link (link and numeric code), setup, invite, reset (the owner resets a member's sign-in), step_up as advance(state, event, now, rng) -> Result<Transition, Refusal>; issue_session, sign_out
mail Mail with subject() and text() for setup, sign-in link and code, invite, sign-in reset and new-device mail
audit AuditEvent and AuditKind

Features

Feature Default Adds Pulls in
os-rng yes latchkey::os_rng(): a ChaCha12 generator seeded from the OS rand
serde no Serialize/Deserialize on ids, hashes, accounts, sessions, links, mail and audit events, limiter states serde
passkeys no the passkey module, DataKey::seal_passkey/open_passkey; implies serde webauthn-rs (OpenSSL), serde_json
qr no totp::qr_svg qrcodegen
qr-png no totp::qr_png qrcodegen-image, image

Without passkeys, nothing links OpenSSL. The flows still accept a passkey result (verified: bool) from any WebAuthn library you prefer.

Public dependencies

These crates' types appear in latchkey's API, so a breaking release of one of them means a breaking release of latchkey:

Crate Where
time 0.3 OffsetDateTime and Duration everywhere (now, expiry, lifetimes)
rand_core 0.10 CryptoRng, re-exported as latchkey::CryptoRng, for every rng
uuid 1 UserId, SessionId, PasskeyId, CeremonyId convert from and to Uuid
webauthn-rs 0.5 the passkey module's challenges, credentials and Passkey (feature passkeys); re-exported as latchkey::passkey::webauthn_rs so you use the same version

Everything else (url, rand, argon2, totp-rs, aes-gcm and the rest) stays private: URLs are &str, os_rng() returns impl CryptoRng.

Configuration

latchkey provides the mechanisms; your app chooses the numbers. Every one of them is a Config::builder(owner_email, origin) setting with a default, and build() refuses values outside the bounds below (Error::Config with a ConfigReason naming the setting). Read them back from the built Config (config.link_ttl(), config.totp_params(), ...) and pass them to the primitives that take them; the flows read them themselves.

Setting Default Bounds
rp_id the origin's host must match the origin (checked by RelyingParty::new)
rp_name the relying party id any
owner_name the owner's address any
cookie_name __Host-latchkey __Host- prefix, valid cookie name, all four cookie names distinct
device_cookie_name __Host-latchkey_device as above
ceremony_cookie_name __Host-latchkey_ceremony as above
email_cookie_name __Host-latchkey_email as above
setup_path /setup must join onto the origin
sign_in_link_path /sign-in/link as above
invite_path /invite as above
sessions_path /settings/sessions as above
device_ttl 400 days any Duration
session_ttl 30 days any Duration
pending_ttl (second-factor sessions) 10 minutes any Duration
setup_ttl (setup sessions) 1 hour any Duration
link_ttl (email sign-in links and codes) 5 minutes any Duration; keep it short
setup_link_ttl (owner setup links) 1 hour any Duration
invite_ttl (invite and reset links) 24 hours any Duration
resend_cooldown 1 minute any Duration
step_up_window 5 minutes any Duration
passkey_ceremony_ttl 5 minutes any Duration
lockout (LockoutPolicy::new(threshold, base, max)) 5 failures lock 5 minutes, doubling to 1 hour threshold 0 turns it off
step_up_failures StepUpFailures::AccountLockout PerSession { limit } with limit ≥ 1
password_only PasswordOnly::Refused
rate_limits (RateLimits::new(per_ip, per_account, wrong_passwords, window)) 30 per IP and 10 per account per minute; 10 wrong passwords per 15 minutes wrong passwords 0 turns the throttle off
password_params (PasswordParams::new(memory_kib, t, p)) 64 MiB, t=3, p=2 what Argon2 accepts
password_min_len (characters) 12 8 to 128, at most password_max_len
password_max_len (characters) 1024 64 to 1024
totp_digits 6 6 or 8
totp_step (seconds) 30 15 to 120
totp_skew (steps either way) 1 0 to 3
email_code_digits 6 6 to 10
email_code_attempts 5 1 to 20
recovery_code_count 10 4 to 32
display_name_max_len (characters, invites) 100 1 to 1000
danger_insecure_localhost_cookies() off plain-HTTP localhost origins only
use latchkey::Config;
use time::Duration;

let config = Config::builder("owner@example.com", "https://home.example.com")
    .link_ttl(Duration::minutes(2))
    .email_code_digits(8)
    .email_code_attempts(3)
    .recovery_code_count(16)
    .build()?;
assert_eq!(config.email_code_digits(), 8);
assert!(Config::builder("owner@example.com", "https://home.example.com")
    .email_code_attempts(0)
    .build()
    .is_err());
# Ok::<(), latchkey::Error>(())

TOTP digits and step are part of every enrolment. Authenticator apps store them with the secret, and latchkey stores nothing per enrolment: an account enrolled under one setting keeps working only while the setting stays the same. Change totp_digits or totp_step and every account must enrol TOTP again (for the step, also clear its stored last-used step, which counts steps of the old length). totp::enrolment puts digits and period in the otpauth:// URL, and so in its QR code, whenever they differ from 6 and 30; if you show only the base32 secret, tell users the values. totp_skew can change at any time.

The other settings change only what happens next: new passwords, new codes, new mails, new sessions. Pending email codes of another length stop matching, recovery-code sets keep their size until regenerated, and stored passwords keep verifying.

Constants that stay are formats and security floors, not preferences: token::TOKEN_BYTES (256-bit tokens, how latchkey recognises its own), token::FOREIGN_MIN_LEN/FOREIGN_MAX_LEN (the shape of imported tokens: at least 128 bits), totp::SECRET_BYTES (160-bit secrets, RFC 4226's recommendation) and MIN_SECRET_BYTES (128 bits, its floor), http::CSRF_HEADER (a wire name) and http::MAX_USER_AGENT (what truncate_user_agent keeps; Client::user_agent is yours to shorten differently), plus the recovery-code format and the hash and seal layouts that stored values depend on. The DEFAULT_* constants (email_link::DEFAULT_CODE_DIGITS, totp::DEFAULT_STEP_SECS, ...) are the settings' defaults.

How the flows work

Each flow is an enum of states. advance consumes the state, takes one event carrying the facts your app loaded (the account, its factor material, the presented device cookie, a verification result) and now, and returns either

  • Ok(Transition { state, effects }): the next state and what to do, or
  • Err(Refusal { error, effects }): the error to answer with and what must still be done (count the failure, lock the account, write the audit event).
sign_in:    Start -Password-> VerifyPassword -PasswordChecked-> SecondFactor -Code|Passkey-> SignedIn
            Start -PasskeyOnly-> SignedIn
email_link: Start -Request-> Requested            Start -Redeem|RedeemCode-> SecondFactor, then sign_in's SecondFactor
setup:      Start -RequestLink-> LinkRequested    Start -Redeem-> InSetup -PasswordSet-> InSetup -Finish-> Complete
invite:     Start -Send-> Sent
reset:      Start -Reset-> Done                   then setup: Start -Redeem { kind: Invite }-> ...
step_up:    Start -Password-> VerifyPassword -PasswordChecked-> Done      Start -Passkey-> Done

Password steps are split in two so the throttle and lockout are decided before the expensive hash, and an unknown address still costs a full (dummy) hash.

Effects are values: StoreSession { session, replaces }, DeleteSession, SetCookie, RecordFailure, ClearFailures, RecordWrongPassword, UseTotpStep, UseRecoveryCode, StoreRecoveryCodes, StoreLinkToken, SendMail, Audit, RememberDevice, CreateAccount, StorePassword, ActivateAccount, MarkStepUp, with per-session step-up failures RecordStepUpFailure and ClearStepUpFailures, for email codes StoreEmailCode, ConsumeEmailCode, DeleteEmailCode and DeleteEmailSignIn, and for a sign-in reset RevokeSessions, RemoveFactors, DeactivateAccount and DeletePendingLinks. Carry out every one, in order: storage effects ideally in one transaction, then mail. UseTotpStep and UseRecoveryCode are compare-and-set: if the stored step is already as new, or the code already used, the request lost a race and must be refused. RecordFailure must be one atomic statement that increments the stored count (UPDATE users SET failures = failures + 1 WHERE id = ? RETURNING failures), never a value computed from the Account snapshot, so failures that race are all counted; then store config.lockout().locked_until(n, at) and append config.lockout().lockout_event(user, n, at, ip_tag) when they are Some. ClearFailures resets the count and the lock.

Where wrong step-up guesses count

By default (StepUpFailures::AccountLockout) a wrong password or code at step-up counts like a sign-in failure: towards the account lockout and the wrong-password throttle, and a locked account cannot step up. That lets anyone holding a stolen session cookie lock the real owner out, and lets anonymous sign-in guesses stop a signed-in owner from re-verifying. To count them per session instead:

use latchkey::{Config, Error, StepUpFailures};

fn config() -> Result<Config, Error> {
    Config::builder("owner@example.com", "https://home.example.com")
        .step_up_failures(StepUpFailures::PerSession { limit: 5 })
        .build()
}
assert!(config().is_ok());

Then step-up neither consults nor feeds the lockout and throttle. Each wrong guess emits RecordStepUpFailure { token_hash }: increment sessions.step_up_failures atomically in the same transaction (UPDATE sessions SET step_up_failures = step_up_failures + 1 WHERE token_hash = ? RETURNING step_up_failures). Load that column into Session::step_up_failures when you rebuild the session. The guess that reaches limit also emits DeleteSession, a cookie removal and a session_revoked audit event (detail step_up_failures), and the refusal is Error::SessionEnded (session_ended, 401): sign in again. A session whose stored count already reached the limit (guesses that raced) is refused and ended the same way on its next step-up; to end it at once, check config.step_up_failures().ends_session(n) on the count the increment returned. A successful step-up emits ClearStepUpFailures and still ClearFailures, so a full proof lifts an account lock. A passkey that fails to verify is not counted in either mode.

Accounts with only a password

latchkey asks for a second factor after every password, and setup does not finish without one. Accounts imported from another system with only a password, or apps that let users enrol TOTP or a passkey later, can opt in:

use latchkey::{Config, Error, PasswordOnly};

fn config() -> Result<Config, Error> {
    Config::builder("owner@example.com", "https://home.example.com")
        .password_only(PasswordOnly::AllowedUntilEnrolled)
        .build()
}
assert!(config().is_ok());

Then an account with a password and no second factor signs in with the password alone (SignIn::VerifyPassword goes straight to SignedIn, method password_only), steps up with it, finishes setup and gets issue_session. As soon as the account has TOTP or a passkey, that factor is required again; Factors::password_suffices(config.password_only()) says which case applies.

Passkey sign-in before a session exists

Registration, a passkey second factor and step-up run inside a session, and their ceremonies are bound to it (Binding::Session, finished with into_state(session.id, user, now)). A passkey sign-in has no session yet: RelyingParty::start_sign_in(user, passkeys, now, rng) (the user typed their address; the challenge lists their passkeys) and start_discoverable(now, rng) return a SignInStarted whose binding_token is a one-off token for the browser's cookie. The ceremony keeps only its hash (Binding::Browser).

#[cfg(feature = "passkeys")]
mod passkey_sign_in {
    use latchkey::http::{SetCookie, find_cookie};
    use latchkey::passkey::webauthn_rs::prelude::{
        AuthenticationResult, Passkey, PasskeyAuthentication, PublicKeyCredential,
    };
    use latchkey::passkey::{Ceremony, RelyingParty, SignInStarted};
    use latchkey::{Config, CryptoRng, Error, UserId};
    use time::OffsetDateTime;

    /// POST /sign-in/passkey/start {email}: store `started.ceremony` under its id, answer
    /// with `started.challenge` and that id, and send the cookie.
    pub fn start(
        config: &Config,
        rp: &RelyingParty,
        user: UserId,
        passkeys: &[Passkey],
        now: OffsetDateTime,
        rng: &mut impl CryptoRng,
    ) -> Result<(SignInStarted<PasskeyAuthentication>, SetCookie), Error> {
        let started = rp.start_sign_in(user, passkeys, now, rng)?;
        let name = config.ceremony_cookie_name().clone();
        // As long as the ceremony: config.passkey_ceremony_ttl(), via RelyingParty::new.
        let cookie = SetCookie::new(name, &started.binding_token, rp.ceremony_ttl());
        Ok((started, cookie))
    }

    /// POST /sign-in/passkey/finish {ceremony_id, credential}: `ceremony` is the one the app
    /// removed from storage by the posted id. Then `passkey::record_use` on the passkeys of
    /// the returned user, and `SignInEvent::PasskeyOnly { account, verified, client }`.
    pub fn finish(
        config: &Config,
        rp: &RelyingParty,
        ceremony: Ceremony<PasskeyAuthentication>,
        cookie_headers: &[&str],
        credential: &PublicKeyCredential,
        now: OffsetDateTime,
    ) -> Result<(UserId, AuthenticationResult), Error> {
        let user = ceremony.binding().user().ok_or(Error::SignInFailed)?;
        let name = config.ceremony_cookie_name().as_str();
        let binding = find_cookie(cookie_headers.iter().copied(), name).unwrap_or_default();
        let state = ceremony.into_browser_state(binding, now)?;
        Ok((user, rp.finish_authentication(credential, &state)?))
    }
}

Only the browser holding the cookie can finish the ceremony it started.

Email sign-in: a link and a code

EmailLinkEvent::Request mails one message with a single-use link and a code of config.email_code_digits() digits (6 by default), both valid for config.link_ttl() (5 minutes by default). The link works on any device; the code only on the device that asked, which gets a binding cookie (config.email_cookie_name()) whether or not the address exists. Redeeming either one deletes both, and neither signs in on its own: they replace the password, and the flow continues to the second factor exactly as after a password (EmailLink::SecondFactor, then SignIn::second_factor(session, account) with SignInEvent::Code or Passkey).

A 6-digit code is only safe because it is bound to one pending request (its hash is keyed with the binding cookie, which is never stored), allows few guesses (config.email_code_attempts(), 5 by default, each counted before it is checked, and also towards the account lockout), lives minutes (link_ttl), and still needs the second factor. Keep all four; the settings let you make the code longer and the budget smaller.

use latchkey::flow::email_link::{EmailCodeRequest, EmailLink, EmailLinkEvent};
use latchkey::flow::{Client, Effect, Refusal};
use latchkey::token::SecretToken;
use latchkey::{Account, Config, CryptoRng};
use time::OffsetDateTime;

/// POST /sign-in/email {email}: carry out the effects and answer 202 either way. They set
/// the binding cookie and, for an active account outside the resend cooldown, store the link
/// (`StoreLinkToken`) and the code (`StoreEmailCode`, under the cookie's hash) and mail both.
fn request(
    config: &Config,
    account: Option<Account>,
    last_sent_at: Option<OffsetDateTime>,
    cookie: Option<&str>, // config.email_cookie_name()
    client: Client,
    now: OffsetDateTime,
    rng: &mut impl CryptoRng,
) -> Result<Vec<Effect>, Refusal> {
    let binding = cookie.map(SecretToken::from_presented);
    let event = EmailLinkEvent::Request { account, last_sent_at, binding, client };
    Ok(EmailLink::Start.advance(config, event, now, rng)?.effects)
}

/// POST /sign-in/email/code {code}, with the binding cookie. `counted` is the row the app
/// read with the attempt already counted, in one statement:
/// `UPDATE email_codes SET attempts = attempts + 1 WHERE binding_hash = ? RETURNING ...`.
/// On success, `ConsumeEmailCode` deletes the request and its link (both must still exist)
/// and the client gets a second-factor session.
fn redeem_code(
    config: &Config,
    cookie: Option<&str>,
    code: String,
    counted: Option<EmailCodeRequest>,
    account: Option<Account>,
    client: Client,
    now: OffsetDateTime,
    rng: &mut impl CryptoRng,
) -> Result<(EmailLink, Vec<Effect>), Refusal> {
    let binding = cookie.map(SecretToken::from_presented);
    let event = EmailLinkEvent::RedeemCode { binding, code, request: counted, account, client };
    let step = EmailLink::Start.advance(config, event, now, rng)?;
    Ok((step.state, step.effects)) // EmailLink::SecondFactor: ask for TOTP or a passkey next
}

# fn main() -> Result<(), Box<dyn std::error::Error>> {
# let config = Config::builder("owner@example.com", "https://home.example.com").build()?;
# let mut rng = latchkey::os_rng()?;
# let now = OffsetDateTime::from_unix_timestamp(1_800_000_000)?;
let effects = request(&config, None, None, None, Client::default(), now, &mut rng)?;
assert!(matches!(effects.as_slice(), [Effect::SetCookie(_)]), "unknown address: cookie only");
let refused = redeem_code(&config, None, "123456".into(), None, None, Client::default(), now, &mut rng);
assert_eq!(refused.err().map(|refusal| refusal.error), Some(latchkey::Error::SignInFailed));
# Ok(())
# }

For the link, delete it by TokenHash::of(token) (DELETE ... RETURNING) and send EmailLinkEvent::Redeem { token_hash, link, account, client }; its DeleteEmailCode kills the code. examples/email_link.rs runs the whole path, second factor included.

Resetting a member who lost their second factor

flow::reset gives the owner a "Reset sign-in" button: from the owner's recent session, Reset::Start with ResetEvent::Reset { admin, session, member, remove_password, client } revokes the member's sessions (RevokeSessions), removes their TOTP, passkeys and recovery codes and optionally their password (RemoveFactors), deactivates the account and clears its failures, deletes their older links and codes (DeletePendingLinks), and mails a one-time setup link valid for config.invite_ttl() (24 hours) as MailKind::SignInReset, with an AuditKind::SignInReset event (detail by:<owner id>). The link is an invite link: the member finishes it through Setup with Redeem { kind: LinkKind::Invite }, enrolling a new second factor (and a password if theirs was removed) before Finish.

Plain-HTTP development on localhost

Cookies are __Host- and Secure. Chrome and Firefox keep such cookies on http://localhost, Safari does not. For local development over plain HTTP:

use latchkey::{Config, Error};

fn development_config() -> Result<Config, Error> {
    Config::builder("owner@example.com", "http://localhost:3000")
        .danger_insecure_localhost_cookies()
        .build()
}
let config = development_config();
assert_eq!(config.map(|c| c.cookie_name().to_string()), Ok("latchkey".into())); // no __Host-

build refuses this for any origin other than http://localhost, http://127.0.0.1 or http://[::1], and a production origin must be https, so it cannot reach production by accident. Read cookies by config.cookie_name().as_str(), not a hard-coded name.

Example: password + TOTP with your own storage

A condensed version of examples/password_totp.rs (cargo run --example password_totp). Storage is a HashMap here; use whatever you like.

use std::collections::HashMap;

use latchkey::account::{AccountKey, Lockout};
use latchkey::flow::sign_in::{SignIn, SignInEvent};
use latchkey::flow::{Client, CodeMaterial, Effect, TotpMaterial};
use latchkey::limits::WrongPasswordThrottle;
use latchkey::password::{PasswordHash, PasswordHasher};
use latchkey::recovery::RecoveryCodeHash;
use latchkey::seal::DataKey;
use latchkey::session::Session;
use latchkey::token::TokenHash;
use latchkey::totp::TotpStep;
use latchkey::{Account, Config, CryptoRng, Email, Error, UserId};
use time::OffsetDateTime;

/// An account as the app stores it: a database row in a real app.
struct Row {
    account: Account,
    password: PasswordHash,
    sealed_totp: Vec<u8>,
    last_step: Option<TotpStep>,
    recovery: Vec<(RecoveryCodeHash, bool)>,
}

/// Built once at start-up.
struct App {
    config: Config,                  // Config::builder(owner, origin).build()?
    hasher: PasswordHasher,          // PasswordHasher::new(config.password_params(), &mut rng)?
    data_key: DataKey,               // DataKey::new(&keys.data), kept out of the database
    throttle: WrongPasswordThrottle, // WrongPasswordThrottle::new(config.rate_limits())
    users: HashMap<UserId, Row>,
    sessions: HashMap<TokenHash, Session>,
}

impl App {
    /// POST /sign-in {email, password}
    fn sign_in(
        &mut self,
        email: &str,
        password: &str,
        client: Client,
        now: OffsetDateTime,
        rng: &mut impl CryptoRng,
    ) -> Result<(), Error> {
        let address = Email::parse(email).ok();
        let row = self.users.values().find(|row| Some(&row.account.email) == address.as_ref());
        let event = SignInEvent::Password {
            login: email.to_owned(),
            account: row.map(|row| row.account.clone()),
            wrong_passwords: self.throttle.window(&AccountKey::from_login(email)),
        };
        // Throttled or locked: refused before any hashing.
        let step = SignIn::Start
            .advance(&self.config, event, now, rng)
            .map_err(|refusal| refusal.error)?;
        // On a blocking thread in a server. An unknown address (None) still costs a hash.
        let matched = self.hasher.verify(password, row.map(|row| &row.password));
        let event = SignInEvent::PasswordChecked { matched, client };
        match step.state.advance(&self.config, event, now, rng) {
            Ok(step) => self.apply(step.effects, now), // reply with the new session's CSRF token
            Err(refusal) => {
                self.apply(refusal.effects, now)?;
                Err(refusal.error)
            }
        }
    }

    /// POST /sign-in/code {code}, with the second-factor session's cookie
    fn code(
        &mut self,
        cookie: &str,
        code: String,
        client: Client,
        now: OffsetDateTime,
        rng: &mut impl CryptoRng,
    ) -> Result<(), Error> {
        let hash = TokenHash::of(cookie).ok_or(Error::Unauthorized)?;
        let session = self.sessions.get(&hash).cloned().ok_or(Error::Unauthorized)?;
        let row = self.users.get(&session.user_id).ok_or(Error::Unauthorized)?;
        let totp = TotpMaterial::new(
            self.data_key.open_totp(row.account.id, &row.sealed_totp)?,
            row.last_step,
        );
        let unused = row.recovery.iter().filter(|(_, used)| !used).map(|(h, _)| *h).collect();
        let material = CodeMaterial::new(Some(totp), unused);
        let event = SignInEvent::Code { code, material, client };
        match SignIn::second_factor(session, row.account.clone()).advance(&self.config, event, now, rng) {
            Ok(step) => self.apply(step.effects, now), // a full session replaces the pending one
            Err(refusal) => {
                self.apply(refusal.effects, now)?;
                Err(refusal.error)
            }
        }
    }

    /// Carries out every effect, in order: storage in one transaction, then mail.
    fn apply(&mut self, effects: Vec<Effect>, now: OffsetDateTime) -> Result<(), Error> {
        for effect in effects {
            match effect {
                Effect::StoreSession { session, replaces } => {
                    if let Some(old) = replaces {
                        self.sessions.remove(&old);
                    }
                    self.sessions.insert(session.token_hash, session);
                }
                Effect::SetCookie(cookie) => {
                    let _header = cookie.header_value(); // add to the response
                }
                Effect::RecordFailure { user_id, at, ip_tag } => {
                    // In SQL, one statement:
                    // UPDATE users SET failures = failures + 1 WHERE id = ? RETURNING failures
                    let row = self.users.get_mut(&user_id).ok_or(Error::NotFound)?;
                    let n = row.account.lockout.failures.saturating_add(1);
                    row.account.lockout.failures = n;
                    if let Some(until) = self.config.lockout().locked_until(n, at) {
                        row.account.lockout.locked_until = Some(until);
                    }
                    let _lock = self.config.lockout().lockout_event(user_id, n, at, ip_tag); // append if Some
                }
                Effect::ClearFailures { user_id } => {
                    let row = self.users.get_mut(&user_id).ok_or(Error::NotFound)?;
                    row.account.lockout = Lockout::default();
                }
                Effect::RecordWrongPassword { key } => self.throttle.record(&key, now),
                Effect::UseTotpStep { user_id, step } => {
                    // Compare-and-set: UPDATE ... SET last_step = ?
                    //   WHERE user_id = ? AND (last_step IS NULL OR last_step < ?)
                    let row = self.users.get_mut(&user_id).ok_or(Error::SignInFailed)?;
                    if row.last_step.is_some_and(|last| last >= step) {
                        return Err(Error::SignInFailed); // the code raced another request
                    }
                    row.last_step = Some(step);
                }
                Effect::UseRecoveryCode { user_id, hash } => {
                    let row = self.users.get_mut(&user_id).ok_or(Error::SignInFailed)?;
                    let code = row.recovery.iter_mut().find(|(h, used)| *h == hash && !used);
                    code.ok_or(Error::SignInFailed)?.1 = true;
                }
                Effect::SendMail(_mail) => { /* after commit: mail.to, mail.subject(), mail.text() */ }
                Effect::Audit(_event) => { /* append */ }
                // RememberDevice, RecordStepUpFailure and the effects of the other flows: see
                // the documentation of `Effect`.
                _ => {}
            }
        }
        Ok(())
    }
}

examples/email_link.rs and examples/passkey_registration.rs (feature passkeys) show the other two common paths.

Migrating from 0.3.0

0.4 changes email sign-in: a link no longer signs in on its own, the same mail carries a code, and links and invites live shorter. Apps on 0.3.0 change these lines:

0.3.0 0.4.0
EmailLinkEvent::Request { account, last_sent_at, client } add binding: find_cookie(.., config.email_cookie_name().as_str()).map(SecretToken::from_presented), and carry out the SetCookie it returns, for unknown addresses too
EmailLinkEvent::Redeem { link, account, client } add token_hash: TokenHash::of(token) (the hash the link was deleted by)
EmailLink::SignedIn after redeeming a link EmailLink::SecondFactor { account, session }: the client has a second-factor session; continue with SignIn::second_factor(session, account) and SignInEvent::Code / Passkey, as after a password. SignedIn is left only for accounts a password alone signs in (PasswordOnly::AllowedUntilEnrolled, no second factor)
(nothing) store pending codes: an email_codes table keyed by the binding hash with EmailCodeRequest's fields; handle StoreEmailCode, ConsumeEmailCode, DeleteEmailCode, DeleteEmailSignIn; add a code endpoint sending EmailLinkEvent::RedeemCode; show mail.code in your own mail templates; purge expired codes with expired links
(nothing) for "Reset sign-in": flow::reset and the effects RevokeSessions, RemoveFactors, DeactivateAccount, DeletePendingLinks; MailKind::SignInReset in your templates
link_ttl 15 minutes for sign-in and setup links, invite_ttl 3 days by default sign-in links and codes link_ttl 5 minutes, owner setup links the new setup_link_ttl 1 hour, invites and reset links invite_ttl 24 hours; set them to keep other values (keep link_ttl short: it is also the code's life)
full sessions with method email_link still parse; new sign-ins record email+totp, email+recovery_code or email+passkey, and second-factor sessions email_link or email_code
an app method or cookie named email_code or __Host-latchkey_email pick another name: both are latchkey's now
a match on Effect, MailKind, AuditKind or SignInMethod handle the new variants (they are #[non_exhaustive], so a _ arm compiles, but the new effects must be carried out)
totp::verify(secret, code, last, now), totp::enrolment(secret, issuer, account), totp::looks_like_code(code), secret.code_at(now) add config.totp_params() as the last argument
totp::DIGITS, STEP_SECS, SKEW config.totp_params() (digits(), step_secs(), skew()); the defaults are totp::DEFAULT_DIGITS, DEFAULT_STEP_SECS, DEFAULT_SKEW (now a u8)
recovery::generate(rng), recovery::COUNT recovery::generate(config.recovery_code_count(), rng); recovery::DEFAULT_COUNT
password::check_policy(pw), hasher.hash_new_password(pw, rng), password::MIN_LEN / MAX_LEN check_policy(pw, config.password_length()), hash_new_password(pw, config.password_length(), rng); password::DEFAULT_MIN_LEN / DEFAULT_MAX_LEN
passkey::CEREMONY_TTL config.passkey_ceremony_ttl(), or rp.ceremony_ttl() on a RelyingParty built with RelyingParty::new(&config) (from_parts uses 5 minutes; with_ceremony_ttl changes it)
flow::check_code(code, material, now), flow::regenerate_recovery_codes(user, client, now, rng) add &config as the first argument
flow::invite::MAX_NAME_LEN config.display_name_max_len(); invite::DEFAULT_MAX_NAME_LEN

Stored data is unchanged; rows written by 0.3.0 keep working.

Migrating from 0.3.0-dev

Apps on latchkey 0.3.0-dev (from crates.io or git, such as Home and the portfolio) change these lines; the compiler finds each one.

0.3.0-dev 0.3.0
Account { id, email, .. }, Factors { .. }, Session { .. }, LinkToken { .. }, Client { .. }, CodeMaterial { .. }, TotpMaterial { .. }, RequestHeaders { .. }, Mail { .. }, NewAccount { .. }, Lockout { .. } Account::new(id, email, role) then assign display_name, active, factors, lockout; Factors::new(password, totp, passkeys); Session::new(id, token_hash, user_id, stage, csrf_token, created_at, expires_at) then assign method, authenticated_at, ip_tag, user_agent, step_up_failures; LinkToken::new(..); Client::new(ip_tag, user_agent).with_device_cookie(cookie, known); CodeMaterial::new(totp, recovery_codes); TotpMaterial::new(secret, last_used_step); RequestHeaders::new(method, origin, sec_fetch_site, csrf_token); Mail::new(..); NewAccount::new(..); Lockout::new(failures, locked_until)
LockoutPolicy { threshold: 0, ..LockoutPolicy::default() }, RateLimits { x, ..RateLimits::default() } LockoutPolicy::new(threshold, base, max), RateLimits::new(per_ip, per_account, wrong_passwords, window), or a default() with fields assigned
match state { SignIn::SignedIn { .. } => .., SignIn::Start => .. } add a _ arm: flow states are #[non_exhaustive]
Error::InvalidRequest("no_passkey"), Error::Conflict("last_factor"), Error::Config(msg), Error::Crypto(msg) Error::InvalidRequest(InvalidRequestReason::NoPasskey), Error::Conflict(ConflictReason::LastFactor), Error::Config(ConfigReason::..), Error::Crypto(CryptoReason::..) from latchkey::error; error.detail() still gives "no_passkey"
SignInEvent::DiscoverablePasskey { .. } SignInEvent::PasskeyOnly { .. } (same fields)
.password(params), config.password() .password_params(params), config.password_params()
config.origin(), config.setup_url() etc. as &Url; mail.link as Url &str; mail.link is a String (Url::parse it if you need one)
let mut rng: StdRng = latchkey::os_rng()? let mut rng = latchkey::os_rng()? (an impl CryptoRng + Send + Sync; box it as Box<dyn CryptoRng + Send> to store it)
rp.start_registration(user, name, display, &existing, session, now, rng) returning a tuple rp.start_registration(RegistrationRequest::new(user, name, display, session).excluding(&existing), now, rng) (or RegistrationRequest::for_account(&account, session)) returning Started { challenge, ceremony }
let (challenge, ceremony) = rp.start_authentication(..)? let Started { challenge, ceremony, .. } = rp.start_authentication(..)?
let (challenge, ceremony) = rp.start_discoverable(now, rng)? and into_state(None, None, now) let SignInStarted { challenge, ceremony, binding_token, .. } = rp.start_discoverable(now, rng)?; send binding_token as the config.ceremony_cookie_name() cookie; finish with ceremony.into_browser_state(cookie_value, now)
ceremony.into_state(Some(session), Some(user), now) ceremony.into_state(session, user, now)
RelyingParty::from_parts(rp_id, rp_name, &url) RelyingParty::from_parts(rp_id, rp_name, "https://..")
TotpSecret::from_bytes(bytes) TotpSecret::from_bytes(bytes)?: refuses secrets under 16 bytes
PasswordHash::try_from(phc) for any PHC string Argon2id (v=19) only; PasswordHash::from_legacy_argon2(phc) for argon2i, argon2d or older versions, then rehash on sign-in (needs_rehash)
latchkey::token::ct_eq, flow::invite::optional_name, Lockout::after_failure gone: subtle::ConstantTimeEq (or TokenHash::verify / SecretToken::matches), the invite flow validates names itself, Effect::RecordFailure as an atomic increment
feature fuzzing _fuzzing (only for fuzz/)
a cookie named __Host-latchkey_ceremony, or an app method password_only pick another name: both are latchkey's now

Stored data is unchanged except passkey ceremonies, which no longer deserialize across the upgrade; they live 5 minutes, so drop pending ones when you deploy.

Porting from 0.2

latchkey 0.2 was a service: an axum router, extractors, a SQLite store, a mailer and in-memory limiters. 0.3 keeps the decisions and leaves the I/O to the app.

0.2 What the app does with 0.3
Latchkey::new(config, store, mailer) Build a Config with Config::builder, a PasswordHasher, a DataKey and an IpTagKey; create the owner with NewAccount::owner if your table has none
Config public fields, Config::new Config::builder(owner_email, origin) with setters; keys left Config (DataKey::new, IpTagKey::new, seal::check_keys)
Latchkey::router() and its endpoints Your own routes; each calls a flow's advance (table below)
Store trait, SqliteStore, migrations Your schema. 0.2's tables map one to one: users (Account + password hash), sessions (Session), tokens (LinkToken under TokenHash), totp (sealed secret, pending secret, TotpStep), passkeys (sealed JSON), recovery codes (RecoveryCodeHash + used flag), devices (TokenHash + last seen), audit (AuditEvent). Stored values (Argon2 PHC strings, SHA-256 hashes, lk1 sealed values, stage/method/kind names, IP HMACs) are unchanged
Atomic store methods (consume_token, use_totp_step, use_recovery_code, replace_session, add_failure) DELETE ... RETURNING for links; compare-and-set for UseTotpStep/UseRecoveryCode; one transaction for StoreSession { replaces }; RecordFailure as one atomic failures = failures + 1 statement, then LockoutPolicy::locked_until on the returned count
Mailer trait, TestMailer, background sending Effect::SendMail(Mail); Mail::subject()/text() are unchanged; send after commit, retry if you like; Mail::link_token() for tests
CurrentUser, RecentAuth extractors Look the session up by TokenHash::of(find_cookie(...)), then session.authorize(account.active, Access::SignedIn / Access::Recent, config.step_up_window(), now) and, for unsafe methods, RequestHeaders::check_session_request(&config.origin_string(), &session.csrf_token)
Router-wide Origin check RequestHeaders::check_origin on every unsafe request
CSRF_HEADER http::CSRF_HEADER
Latchkey::issue_session flow::issue_session(config, account, AppMethod::new("...")?, client, now, rng)
Latchkey::session_from_cookie, current_user_from_headers TokenHash::of + your session lookup + Session::authorize
IssueSession::from_headers http::client_ip, http::truncate_user_agent, http::find_cookie into a flow::Client
In-memory rate limiter and wrong-password log limits::RateLimiter and limits::WrongPasswordThrottle, owned by the app (a Mutex), or their per-key states persisted
In-memory passkey ceremonies passkey::Ceremony<S> is Serialize: store it under ceremony.id(), remove it on finish, then into_state(session, user, now), or for a sign-in into_browser_state(binding_cookie, now)
Clock, ManualClock, SystemClock Pass now (OffsetDateTime::now_utc() in production, any value in tests)
Latchkey::encrypt_plaintext_secrets seal::classify_bytes/classify_text find 0.1 plaintext; reseal with DataKey::seal_bytes/seal_text and Aad::totp/Aad::passkey
Start-up check of sealed values Open each stored value with DataKey::open_totp/open_text at start if you want the same refusal
Latchkey::reset_owner Delete the owner's password, factors and sessions, set it inactive, append AuditKind::OwnerReset
Latchkey::purge_expired Delete sessions, links and email codes past expires_at, devices unseen for config.device_ttl()
Error with IntoResponse Error::code(), Error::http_status(), Error::detail(), Error::retry_after_secs(); build the JSON body yourself
async_trait, tokio, axum, sqlx, tracing dependencies Gone. Run PasswordHasher on a blocking thread with a semaphore of one, as 0.2 did

Endpoints to flow calls:

0.2 endpoint 0.3
POST /setup/start, /setup/redeem, /setup/password, /setup/complete Setup: RequestLink, Redeem { kind: Setup }, PasswordSet, Finish
POST /invites, /invites/redeem Invite::Send, then Setup with Redeem { kind: Invite }
POST /sign-in/password, /sign-in/code, /sign-in/second-factor/passkey/*, /sign-in/passkey/* SignIn: Password + PasswordChecked, Code, Passkey, PasskeyOnly
POST /sign-in/email, /sign-in/email/redeem EmailLink: Request, Redeem (then SignIn's second factor; new: RedeemCode)
POST /step-up/password, /step-up/passkey/* StepUp: Password + PasswordChecked, Passkey
POST /sign-out flow::sign_out
POST /password, /recovery-codes flow::password_changed, flow::regenerate_recovery_codes (after Access::Recent)
POST /totp/enrol, /totp/confirm, DELETE /totp TotpSecret::random + totp::enrolment + DataKey::seal_totp; totp::verify(pending, code, None, now, config.totp_params()); Factors::may_remove_totp (after Access::Enrolling/Recent)
/passkeys/register/*, DELETE /passkeys/{id} RelyingParty::start_registration/finish_registration; Factors::may_remove_passkey
GET /me, /sessions, DELETE /sessions/{id}, GET /audit Your queries; Session::step_up_until for step_up_until

Behavioural changes: email links (and the new codes) no longer sign in on their own but lead to the second factor, and live 5 minutes instead of 15 (owner setup links 1 hour); step_up joins the reserved method names for app-issued sessions; RelyingParty::finish_discoverable takes the claimed user and refuses an assertion whose user handle differs; the device cookie is still set on every completed sign-in.

Security notes

  • Passwords: Argon2id; an unknown account verifies against a dummy hash and fails the same way as a wrong password. Passwords over 4096 bytes are not hashed as given.
  • Tokens (sessions, links, devices, email-code bindings, CSRF) are 32 random bytes; only SHA-256 hashes are stored, except the session's CSRF token, which the client needs again. Comparisons of presented secrets are constant-time.
  • A TOTP step is accepted once (last_used_step); recovery codes, links, email codes and passkey ceremonies are single use; an emailed link or code replaces only the password, never the second factor. An email code has 6 to 10 digits (6 by default), so it is bound to the requesting device's cookie (its hash is keyed with it), allows 1 to 20 counted guesses (5 by default; they also count towards the lockout), and lives link_ttl (5 minutes by default); using the link or the code deletes both; passkey ceremonies are bound to the session and account that started them (a sign-in, which has no session yet, to the browser by a one-off cookie) and expire after passkey_ceremony_ttl (5 minutes by default); counter regressions are rejected by webauthn-rs.
  • Every stage change issues a new session token (StoreSession { replaces }).
  • The wrong-password throttle (by default 10 per 15 minutes per address) counts unknown addresses too and is checked before hashing; the lockout (by default 5 failures: 5 minutes, doubling to an hour) is per account. Step-up guesses count towards both unless Config::step_up_failures is PerSession { limit }, which bounds each session to limit wrong guesses and then ends it.
  • Sealed values use a random 96-bit nonce and associated data latchkey-totp:<user> or latchkey-passkey:<user>:<passkey>; Aad::app(context) is for the app's own secrets.
  • Secret types (SecretToken, EmailCode, TotpSecret, RecoveryCode, PasswordHash, SetCookie, DataKey, IpTagKey, Mail links and codes) redact Debug, have no Display, and zero their memory on drop where they own it.
  • No unsafe; unwrap, expect, panic! and indexing are denied outside tests.
  • Randomness comes from the CryptoRng you pass, except webauthn-rs challenges, which it draws from OpenSSL.

Known trade-offs carried over from 0.2: a locked account answers too_many_attempts, which tells someone who already made 5 wrong guesses that the address exists; the new-device check is per browser profile; anyone with both the database and the data key can read factors.

Testing

cargo test --all-features runs unit tests, property tests (proptest: token and cookie parsing, the limiter against a fixed-window model, the throttle against a sliding-window model, the lockout, TOTP replay, sealing, email codes) and integration tests that drive every flow through an in-memory app (tests/common), including a software passkey.

fuzz/ has a cargo-fuzz target, untrusted_inputs, that feeds arbitrary bytes to every parser of client input. It builds on stable (cargo build --manifest-path fuzz/Cargo.toml); running it needs nightly: cargo +nightly fuzz run untrusted_inputs.

The minimum supported Rust version is 1.88.

For coding agents

The crate ships Symposium skills in skills/: latchkey-basics (how to use it correctly) and latchkey-porting (moving from 0.2). With the cargo agents CLI, cargo agents use latchkey in a project that depends on latchkey installs them.

License

Licensed under either of Apache License, Version 2.0 or MIT license at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.