# 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 |
```rust
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).
```text
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:
```rust
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:
```rust
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`).
```rust
#[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.
```rust
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:
```rust
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.
```rust
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](https://github.com/rust-fuzz/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](https://symposium.dev) 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](LICENSE-APACHE) or
[MIT license](LICENSE-MIT) 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.