From d06962c3c01fe204435f1d6fd0475e4df0fe424b Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 06:44:48 +0000 Subject: [PATCH 1/3] Set Console passwords with verified email codes --- console/README.md | 13 +- src/console.rs | 28 ++- src/console/accounts.rs | 60 +++++-- src/console/accounts/codes.rs | 92 ++++++++++ src/console/accounts/tests.rs | 104 +++++++++++ src/console/auth.rs | 9 +- src/console/auth/password.rs | 124 +++++++++++++ src/console/set_password.html | 25 +++ src/console/sign_in.html | 4 + tests/console.rs | 9 +- tests/console/accounts.rs | 3 + tests/console/password_codes.rs | 303 ++++++++++++++++++++++++++++++++ 12 files changed, 749 insertions(+), 25 deletions(-) create mode 100644 src/console/accounts/codes.rs create mode 100644 src/console/auth/password.rs create mode 100644 src/console/set_password.html create mode 100644 tests/console/password_codes.rs diff --git a/console/README.md b/console/README.md index 962417d8..3f8e93b4 100644 --- a/console/README.md +++ b/console/README.md @@ -50,8 +50,17 @@ The server checks current settings on requests and while streams remain open. `thirdshift console log [--json]` and the Console log page show the same account events from `~/.thirdshift/console.log`. They name the account but -never contain passwords, session tokens or page views. Email account creation -and password reset are a later Ticket; ask the owner to use the password command. +never contain passwords, codes, session tokens or page views. + +With a Resend key in `RESEND_API_KEY` or the Credentials, **Set your password** +on the sign-in page creates an account or resets its password by email code. +Only allowed addresses receive mail, from `email.from`, but every address gets +the same answer. Codes are stored as argon2id hashes, last 15 minutes and allow +5 tries. An address may receive one code a minute and 10 in a rolling 24 hours; +these limits survive restarts and password changes. A correct code verifies +the address, sets its password, ends its old sessions and signs it in. +The Console log records codes sent and passwords set. Without a key the flow +is off; ask the owner to use `thirdshift console password` instead. The header names this machine and links to those addresses, without fetching them. The Inbox and its `/events` stream pair with `thirdshift status`. One diff --git a/src/console.rs b/src/console.rs index 67bcea7d..93b8f6d6 100644 --- a/src/console.rs +++ b/src/console.rs @@ -52,9 +52,30 @@ enum View { SignOut, SignInPage, SignIn, + SetPasswordPage, + SendCode, + SetPassword, } const PAGES: &[PageRoute] = &[ + PageRoute { + path: "/set-password", + cli: &["thirdshift console password"], + method: Method::GET, + view: View::SetPasswordPage, + }, + PageRoute { + path: "/set-password/code", + cli: &["thirdshift console password"], + method: Method::POST, + view: View::SendCode, + }, + PageRoute { + path: "/set-password", + cli: &["thirdshift console password"], + method: Method::POST, + view: View::SetPassword, + }, PageRoute { path: "/waterfall", cli: &[ @@ -246,6 +267,9 @@ fn router(no_sign_in: bool, changes: tokio::sync::watch::Receiver) -> Route (View::SignOut, Method::POST) => post(auth::sign_out), (View::SignInPage, Method::GET) => get(auth::sign_in_page), (View::SignIn, Method::POST) => post(auth::sign_in), + (View::SetPasswordPage, Method::GET) => get(auth::set_password_page), + (View::SendCode, Method::POST) => post(auth::code), + (View::SetPassword, Method::POST) => post(auth::set_password), _ => unreachable!("unknown Console page method"), }; router = router.route(page.path, handler); @@ -502,7 +526,7 @@ mod tests { .with_state(changes), ); // Each unique path has one fallback; merging GET and POST at sign-in - // adds only the POST service, not another fallback service. + // and set-password adds only the POST services, not more fallbacks. let fallback = 1; let expected = super::PAGES .iter() @@ -515,7 +539,7 @@ mod tests { }) .sum::() + get_methods - - fallback; + - 2 * fallback; assert_eq!( registered_methods(router), expected, diff --git a/src/console/accounts.rs b/src/console/accounts.rs index 489c731f..ba609b46 100644 --- a/src/console/accounts.rs +++ b/src/console/accounts.rs @@ -11,12 +11,19 @@ use rand_core::{OsRng, RngCore}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +mod codes; +use codes::Codes; + pub(super) const LIFETIME: i64 = 30 * 24 * 60 * 60; #[derive(Default, Deserialize, Serialize)] struct Account { password_hash: String, #[serde(default)] + verified: bool, + #[serde(default)] + codes: Codes, + #[serde(default)] sessions: BTreeMap, #[serde(default)] failed_passwords: u32, @@ -30,6 +37,7 @@ pub(super) enum EventKind { SignIn, FailedSignIn, PasswordSet, + CodeSent, SignOut, BlockedAddress, } @@ -40,6 +48,7 @@ impl std::fmt::Display for EventKind { Self::SignIn => "sign-in", Self::FailedSignIn => "failed-sign-in", Self::PasswordSet => "password-set", + Self::CodeSent => "code-sent", Self::SignOut => "sign-out", Self::BlockedAddress => "blocked-address", }) @@ -100,6 +109,7 @@ impl Accounts { for (email, account) in &mut store.accounts { let before = account.sessions.len(); if !allowed.contains(email) { + changed |= account.codes.clear(); account.sessions.clear(); if before != 0 { blocked.push(email.clone()); @@ -126,18 +136,13 @@ impl Accounts { "address is not allowed in console.allowed_emails" ); ensure!(!password.is_empty(), "password must not be empty"); - let salt = SaltString::generate(&mut OsRng); - let password_hash = Argon2::default() - .hash_password(password.as_bytes(), &salt) - .map_err(|error| anyhow::anyhow!("can't hash password: {error}"))? - .to_string(); - self.accounts.insert( - email.into(), - Account { - password_hash, - ..Account::default() - }, - ); + let password_hash = hash_secret(password)?; + let account = self.accounts.entry(email.into()).or_default(); + account.password_hash = password_hash; + account.sessions.clear(); + account.codes.clear(); + account.failed_passwords = 0; + account.next_attempt = 0; self.save()?; self.record(email, EventKind::PasswordSet) } @@ -155,13 +160,10 @@ impl Accounts { self.record(email, EventKind::FailedSignIn)?; return Ok(SignIn::Slowdown); } - let valid = self.accounts.get(email).is_some_and(|account| { - PasswordHash::new(&account.password_hash).is_ok_and(|hash| { - Argon2::default() - .verify_password(password.as_bytes(), &hash) - .is_ok() - }) - }); + let valid = self + .accounts + .get(email) + .is_some_and(|account| matches_secret(password, &account.password_hash)); if !valid { if let Some(account) = self.accounts.get_mut(email) { account.failed_passwords = account.failed_passwords.saturating_add(1); @@ -173,6 +175,10 @@ impl Accounts { self.record(email, EventKind::FailedSignIn)?; return Ok(SignIn::Refused); } + self.start_session(email) + } + + fn start_session(&mut self, email: &str) -> Result { let token = random_token(); let account = self.accounts.get_mut(email).expect("verified account"); account.failed_passwords = 0; @@ -249,6 +255,22 @@ impl Accounts { } } +fn hash_secret(secret: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(secret.as_bytes(), &salt) + .map(|hash| hash.to_string()) + .map_err(|error| anyhow::anyhow!("can't hash secret: {error}")) +} + +fn matches_secret(secret: &str, hash: &str) -> bool { + PasswordHash::new(hash).is_ok_and(|hash| { + Argon2::default() + .verify_password(secret.as_bytes(), &hash) + .is_ok() + }) +} + fn private_file(path: &Path) -> Result { let mut options = OpenOptions::new(); options.read(true).append(true).create(true); diff --git a/src/console/accounts/codes.rs b/src/console/accounts/codes.rs new file mode 100644 index 00000000..77f6388b --- /dev/null +++ b/src/console/accounts/codes.rs @@ -0,0 +1,92 @@ +//! Email codes and their durable limits share the account store's lock. +use super::*; + +#[derive(Default, Deserialize, Serialize)] +pub(super) struct Codes { + pending: Option, + sent: Vec, +} + +#[derive(Deserialize, Serialize)] +struct Code { + hash: String, + sent_at: i64, + attempts: u8, +} + +impl Codes { + pub(super) fn clear(&mut self) -> bool { + self.pending.take().is_some() + } +} + +impl Accounts { + /// The sender crosses the external email seam. Reserve the limits before + /// calling it so even a failed send cannot be retried without a bound. + pub fn send_code(&mut self, email: &str, send: impl FnOnce(&str) -> Result<()>) -> Result<()> { + if !self.allowed.iter().any(|allowed| allowed == email) { + return Ok(()); + } + let account = self.accounts.entry(email.into()).or_default(); + account + .codes + .sent + .retain(|sent| self.now.saturating_sub(*sent) < 24 * 60 * 60); + if account.codes.sent.len() >= 10 + || account + .codes + .sent + .last() + .is_some_and(|sent| self.now.saturating_sub(*sent) < 60) + { + return Ok(()); + } + // Rejection sampling keeps all six-digit strings equally likely. + let number = loop { + let number = OsRng.next_u32(); + if number < u32::MAX - u32::MAX % 1_000_000 { + break number % 1_000_000; + } + }; + let code = format!("{number:06}"); + account.codes.pending = Some(Code { + hash: hash_secret(&code)?, + sent_at: self.now, + attempts: 0, + }); + account.codes.sent.push(self.now); + self.save()?; + send(&code)?; + self.record(email, EventKind::CodeSent) + } + + pub fn set_password_by_code( + &mut self, + email: &str, + code: &str, + password: &str, + ) -> Result { + if password.is_empty() || !self.allowed.iter().any(|allowed| allowed == email) { + return Ok(SignIn::Refused); + } + let Some(pending) = self + .accounts + .get_mut(email) + .and_then(|account| account.codes.pending.as_mut()) + else { + return Ok(SignIn::Refused); + }; + if self.now.saturating_sub(pending.sent_at) >= 15 * 60 || pending.attempts >= 5 { + return Ok(SignIn::Refused); + } + pending.attempts += 1; + let valid = matches_secret(code, &pending.hash); + self.save()?; + if !valid { + return Ok(SignIn::Refused); + } + self.set_password(email, password)?; + self.accounts.get_mut(email).expect("code account").verified = true; + self.start_session(email) + } +} diff --git a/src/console/accounts/tests.rs b/src/console/accounts/tests.rs index bdf85a52..aca4eb75 100644 --- a/src/console/accounts/tests.rs +++ b/src/console/accounts/tests.rs @@ -8,6 +8,110 @@ fn signed_in(accounts: &mut Accounts) -> String { token } +#[test] +fn email_codes_expire_at_fifteen_minutes_and_allow_exactly_five_tries() { + let allowed = vec!["owner@example.test".into()]; + for (elapsed, wrong_tries, accepted) in [(899, 4, true), (900, 0, false), (1, 5, false)] { + let dir = tempfile::tempdir().unwrap(); + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000).unwrap(); + let mut code = String::new(); + accounts + .send_code("owner@example.test", |sent| { + code = sent.into(); + Ok(()) + }) + .unwrap(); + drop(accounts); + let wrong = if code == "000000" { "111111" } else { "000000" }; + for _ in 0..wrong_tries { + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + elapsed).unwrap(); + assert!(matches!( + accounts + .set_password_by_code("owner@example.test", wrong, "password") + .unwrap(), + SignIn::Refused + )); + } + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + elapsed).unwrap(); + assert_eq!( + matches!( + accounts + .set_password_by_code("owner@example.test", &code, "password") + .unwrap(), + SignIn::SignedIn(_) + ), + accepted + ); + assert!(matches!( + accounts + .set_password_by_code("owner@example.test", &code, "replay") + .unwrap(), + SignIn::Refused + )); + } +} + +#[test] +fn code_send_limits_survive_restarts_and_password_changes_and_are_per_address() { + let dir = tempfile::tempdir().unwrap(); + let allowed = vec!["owner@example.test".into(), "other@example.test".into()]; + let mut sends = 0; + let mut code = String::new(); + for (time, expected) in [(1_000, 1), (1_059, 1), (1_060, 2)] { + let mut accounts = Accounts::open(dir.path(), &allowed, time).unwrap(); + accounts + .send_code("owner@example.test", |sent| { + sends += 1; + code = sent.into(); + Ok(()) + }) + .unwrap(); + assert_eq!(sends, expected); + } + { + let mut accounts = Accounts::open(dir.path(), &allowed, 1_061).unwrap(); + assert!(matches!( + accounts + .set_password_by_code("owner@example.test", &code, "password") + .unwrap(), + SignIn::SignedIn(_) + )); + } + for index in 2..11 { + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + index * 60).unwrap(); + accounts + .send_code("owner@example.test", |_| { + sends += 1; + Ok(()) + }) + .unwrap(); + assert_eq!(sends, (index + 1).min(10)); + } + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + 24 * 60 * 60 - 1).unwrap(); + accounts + .send_code("owner@example.test", |_| panic!("daily limit bypassed")) + .unwrap(); + accounts + .send_code("unknown@example.test", |_| panic!("unknown address sent")) + .unwrap(); + accounts + .send_code("other@example.test", |_| { + sends += 1; + Ok(()) + }) + .unwrap(); + assert_eq!(sends, 11); + drop(accounts); + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + 24 * 60 * 60).unwrap(); + accounts + .send_code("owner@example.test", |_| { + sends += 1; + Ok(()) + }) + .unwrap(); + assert_eq!(sends, 12, "first send ages out of the rolling day"); +} + #[test] fn five_wrong_passwords_slow_the_address_to_one_attempt_per_minute() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/console/auth.rs b/src/console/auth.rs index 8aebbc4e..6dce1305 100644 --- a/src/console/auth.rs +++ b/src/console/auth.rs @@ -13,6 +13,9 @@ use subtle::ConstantTimeEq; use super::accounts::{self, Accounts, SignIn}; use crate::config::UserConfig; +mod password; +pub(super) use password::{code, set_password, set_password_page}; + const SESSION: &str = "__Host-thirdshift-session"; const SIGN_IN: &str = "__Host-thirdshift-sign-in"; @@ -84,7 +87,9 @@ pub(super) async fn guard( next: Next, ) -> Response { let path = request.uri().path(); - if path == "/sign-in" || path.starts_with("/assets/") { + if matches!(path, "/sign-in" | "/set-password" | "/set-password/code") + || path.starts_with("/assets/") + { return next.run(request).await; } let viewer = if access.no_sign_in { @@ -166,6 +171,7 @@ pub(super) async fn headers(request: Request, next: Next) -> Response { struct SignInPage { csrf: String, theme: String, + email_enabled: bool, } pub(super) async fn sign_in_page( @@ -182,6 +188,7 @@ pub(super) async fn sign_in_page( Ok(SignInPage { csrf: form_token, theme: UserConfig::load()?.console.theme, + email_enabled: crate::resend_key::ResendKey::find()?.is_some(), } .render()?) }) diff --git a/src/console/auth/password.rs b/src/console/auth/password.rs new file mode 100644 index 00000000..d96612e3 --- /dev/null +++ b/src/console/auth/password.rs @@ -0,0 +1,124 @@ +//! The same anonymous email-code flow creates accounts and resets passwords. +use super::*; +use crate::{email::Resend, resend_key::ResendKey}; + +const OWNER: &str = "Email password setup is off. Ask the machine's owner to run thirdshift console password --email
."; +const SENT: &str = "If this address is allowed, a code has been sent. It lasts 15 minutes. Enter your code and new password."; + +#[derive(Template)] +#[template(path = "console/set_password.html")] +struct PasswordPage { + csrf: String, + theme: String, + email_enabled: bool, +} + +pub(in crate::console) async fn set_password_page( + Extension(access): Extension, + headers: HeaderMap, +) -> Response { + if access.no_sign_in { + return StatusCode::NOT_FOUND.into_response(); + } + let token = cookie(&headers, SIGN_IN).unwrap_or_else(accounts::random_token); + let form_token = csrf(&token); + let mut response = crate::console::page( + tokio::task::spawn_blocking(move || -> Result { + Ok(PasswordPage { + csrf: form_token, + theme: UserConfig::load()?.console.theme, + email_enabled: ResendKey::find()?.is_some(), + } + .render()?) + }) + .await, + ); + if response.status().is_success() { + set_cookie(&mut response, SIGN_IN, &token, 15 * 60); + } + response +} + +#[derive(Deserialize)] +pub(in crate::console) struct PasswordForm { + email: String, + #[serde(default)] + code: String, + #[serde(default)] + password: String, + #[serde(default)] + csrf: String, +} + +fn authorized(access: &Access, headers: &HeaderMap, form: &PasswordForm) -> bool { + !access.no_sign_in + && cookie(headers, SIGN_IN).is_some_and(|token| valid_csrf(&form.csrf, &csrf(&token))) +} + +pub(in crate::console) async fn code( + Extension(access): Extension, + headers: HeaderMap, + Form(form): Form, +) -> Response { + if access.no_sign_in { + return StatusCode::NOT_FOUND.into_response(); + } + if !authorized(&access, &headers, &form) { + return StatusCode::FORBIDDEN.into_response(); + } + let result = tokio::task::spawn_blocking(move || -> Result { + if ResendKey::find()?.is_none() { return Ok(false); } + let config = UserConfig::load()?; + // Every address gets the same answer, including on send failure. Only + // the owner sees transport errors, never account presence in HTTP. + if let Err(error) = Accounts::load()?.send_code(&form.email, |code| { + Resend::new(Some(form.email.clone()), &config.email)?.send( + "Set your thirdshift Console password", + &format!("Your thirdshift Console code is {code}\n\nIt lasts 15 minutes and allows 5 tries. If you did not request it, ignore this email."), + ) + }) { + eprintln!("thirdshift Console: can't send password code: {error:#}"); + } + Ok(true) + }).await; + match result { + Ok(Ok(true)) => axum::response::Html(SENT).into_response(), + Ok(Ok(false)) => (StatusCode::SERVICE_UNAVAILABLE, OWNER).into_response(), + error => failed(error), + } +} + +pub(in crate::console) async fn set_password( + Extension(access): Extension, + headers: HeaderMap, + Form(form): Form, +) -> Response { + if access.no_sign_in { + return StatusCode::NOT_FOUND.into_response(); + } + if !authorized(&access, &headers, &form) { + return StatusCode::FORBIDDEN.into_response(); + } + let result = tokio::task::spawn_blocking(move || -> Result> { + if ResendKey::find()?.is_none() { + return Ok(None); + } + Ok(Some(Accounts::load()?.set_password_by_code( + &form.email, + &form.code, + &form.password, + )?)) + }) + .await; + match result { + Ok(Ok(Some(SignIn::SignedIn(token)))) => { + let mut response = Redirect::to("/").into_response(); + set_cookie(&mut response, SESSION, &token, accounts::LIFETIME); + response + }, + Ok(Ok(Some(_))) => (StatusCode::UNAUTHORIZED, axum::response::Html("Password not set. Check your code or request another.")).into_response(), + Ok(Ok(None)) => (StatusCode::SERVICE_UNAVAILABLE, OWNER).into_response(), + Ok(Err(error)) => failed(error), + Err(error) => failed(error), + } +} diff --git a/src/console/set_password.html b/src/console/set_password.html new file mode 100644 index 00000000..2ea67a46 --- /dev/null +++ b/src/console/set_password.html @@ -0,0 +1,25 @@ +{% extends "console/account_base.html" %} +{% block title %}Set your password{% endblock %} +{% block navigation %}{% endblock %} +{% block content %} +

Set your password

+{% if email_enabled %} +

Create your account or reset your password with a code sent to your email.

+ +

A code lasts 15 minutes and allows 5 tries.

+ +{% else %} +

Email password setup is off. Ask the machine's owner to run thirdshift console password --email <address>.

+{% endif %} +

Sign in

+{% endblock %} diff --git a/src/console/sign_in.html b/src/console/sign_in.html index 8361d114..062837ef 100644 --- a/src/console/sign_in.html +++ b/src/console/sign_in.html @@ -9,5 +9,9 @@

Sign in

+{% if email_enabled %} +

Set your password to create an account or reset a forgotten password.

+{% else %}

Need a password? Ask the machine's owner to run thirdshift console password --email <address>.

+{% endif %} {% endblock %} diff --git a/tests/console.rs b/tests/console.rs index 308d0341..e2aae349 100644 --- a/tests/console.rs +++ b/tests/console.rs @@ -48,9 +48,16 @@ impl Console { } else { &["console"] }; - let mut child = Command::new(binary) + let mut command = Command::new(binary); + command .args(args) .env("HOME", home) + .env_remove("RESEND_API_KEY"); + Self::start_command(command) + } + + fn start_command(mut command: Command) -> Self { + let mut child = command .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .spawn() diff --git a/tests/console/accounts.rs b/tests/console/accounts.rs index 39f4b5c5..1218d545 100644 --- a/tests/console/accounts.rs +++ b/tests/console/accounts.rs @@ -8,6 +8,9 @@ use std::time::Instant; const EMAIL: &str = "owner@example.test"; const PASSWORD: &str = "correct horse battery staple"; +#[path = "password_codes.rs"] +mod password_codes; + fn configured() -> tempfile::TempDir { let home = tempfile::tempdir().unwrap(); configure(home.path()); diff --git a/tests/console/password_codes.rs b/tests/console/password_codes.rs new file mode 100644 index 00000000..01d6345c --- /dev/null +++ b/tests/console/password_codes.rs @@ -0,0 +1,303 @@ +use super::*; +use crate::support::resend::ResendStandIn; + +fn start(home: &Path, resend: &ResendStandIn, environment_key: bool) -> Console { + let mut command = Command::new(env!("CARGO_BIN_EXE_thirdshift")); + command + .args(["console"]) + .env("HOME", home) + .env("THIRDSHIFT_RESEND_URL", resend.url()) + .env_remove("RESEND_API_KEY"); + if environment_key { + command.env("RESEND_API_KEY", "re_environment"); + } + Console::start_command(command) +} + +fn form(console: &Console) -> (String, String) { + let response = get(console, "/set-password", ""); + assert_eq!(response.status(), 200); + let cookie = cookie(&response); + (cookie, csrf(&response.text().unwrap())) +} + +fn post(console: &Console, path: &str, fields: &[(&str, &str)]) -> reqwest::blocking::Response { + let (cookie, csrf) = form(console); + let mut fields = fields.to_vec(); + fields.push(("csrf", &csrf)); + client() + .post(format!("{}{path}", console.address)) + .header("cookie", cookie) + .form(&fields) + .send() + .unwrap() +} + +fn code(resend: &ResendStandIn) -> String { + let requests = resend.requests(); + let text = requests.last().unwrap().body["text"].as_str().unwrap(); + text.split_whitespace() + .find(|word| word.len() == 6 && word.bytes().all(|b| b.is_ascii_digit())) + .expect("six-digit code in email") + .into() +} + +#[test] +fn email_code_creates_and_resets_an_account_without_revealing_the_allowlist() { + for environment_key in [false, true] { + let home = configured(); + fs::write( + home.path().join(".thirdshift/credentials.toml"), + "[resend]\nkey = 're_credentials'\n", + ) + .unwrap(); + let mut config = fs::read_to_string(home.path().join(".thirdshift/config.toml")).unwrap(); + config.push_str("[email]\nfrom = 'console@example.test'\n"); + fs::write(home.path().join(".thirdshift/config.toml"), config).unwrap(); + let resend = ResendStandIn::replying(200, "{}"); + if environment_key { + assert!( + password(home.path(), None, "old password", "old password") + .0 + .success() + ); + } + let console = start(home.path(), &resend, environment_key); + let old_session = + environment_key.then(|| cookie(&sign_in(&console, EMAIL, "old password"))); + assert!( + get(&console, "/sign-in", "") + .text() + .unwrap() + .contains("/set-password") + ); + let unknown = post( + &console, + "/set-password/code", + &[("email", "unknown@example.test")], + ); + let allowed = post(&console, "/set-password/code", &[("email", EMAIL)]); + assert_eq!(unknown.status(), allowed.status()); + assert_eq!(unknown.text().unwrap(), allowed.text().unwrap()); + let requests = resend.requests(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, "/emails"); + assert_eq!(requests[0].body["to"], EMAIL); + assert_eq!(requests[0].body["from"], "console@example.test"); + assert_eq!( + requests[0].authorization.as_deref(), + Some(if environment_key { + "Bearer re_environment" + } else { + "Bearer re_credentials" + }) + ); + let code = code(&resend); + let stored: Value = serde_json::from_slice( + &fs::read(home.path().join(".thirdshift/console-accounts.json")).unwrap(), + ) + .unwrap(); + assert!( + stored[EMAIL]["codes"]["pending"]["hash"] + .as_str() + .unwrap() + .starts_with("$argon2id$") + ); + assert!( + !serde_json::to_string(&stored) + .unwrap() + .contains(&format!("\"{code}\"")) + ); + let response = post( + &console, + "/set-password", + &[("email", EMAIL), ("code", &code), ("password", PASSWORD)], + ); + assert_eq!(response.status(), 303); + let session = cookie(&response); + assert_eq!(get(&console, "/", &session).status(), 200); + if let Some(old_session) = old_session { + assert_eq!(get(&console, "/", &old_session).status(), 303); + assert_eq!(sign_in(&console, EMAIL, "old password").status(), 401); + } + assert_eq!(sign_in(&console, EMAIL, PASSWORD).status(), 303); + let stored: Value = serde_json::from_slice( + &fs::read(home.path().join(".thirdshift/console-accounts.json")).unwrap(), + ) + .unwrap(); + assert_eq!(stored[EMAIL]["verified"], true); + assert_eq!( + post( + &console, + "/set-password", + &[("email", EMAIL), ("code", &code), ("password", "replay")] + ) + .status(), + 401 + ); + assert!( + password(home.path(), None, "new password", "new password") + .0 + .success() + ); + assert_eq!(get(&console, "/", &session).status(), 303); + let events: Vec = + serde_json::from_slice(&cli(home.path(), &["console", "log", "--json"]).stdout) + .unwrap(); + assert!( + events + .iter() + .any(|event| event["event"] == "code-sent" && event["email"] == EMAIL) + ); + assert!( + events + .iter() + .any(|event| event["event"] == "password-set" && event["email"] == EMAIL) + ); + } +} + +#[test] +fn code_flow_requires_csrf_and_enforces_five_tries_and_send_throttling_over_http() { + let home = configured(); + let resend = ResendStandIn::replying(200, "{}"); + let console = start(home.path(), &resend, true); + for path in ["/set-password/code", "/set-password"] { + assert_eq!( + client() + .post(format!("{}{path}", console.address)) + .form(&[("email", EMAIL), ("code", "123456"), ("password", PASSWORD)]) + .send() + .unwrap() + .status(), + 403 + ); + let (cookie, _) = form(&console); + assert_eq!( + client() + .post(format!("{}{path}", console.address)) + .header("cookie", cookie) + .form(&[ + ("email", EMAIL), + ("code", "123456"), + ("password", PASSWORD), + ("csrf", "wrong") + ]) + .send() + .unwrap() + .status(), + 403 + ); + } + assert!(resend.requests().is_empty()); + let first = post(&console, "/set-password/code", &[("email", EMAIL)]) + .text() + .unwrap(); + let code = code(&resend); + assert_eq!( + post(&console, "/set-password/code", &[("email", EMAIL)]) + .text() + .unwrap(), + first + ); + assert_eq!(resend.requests().len(), 1); + let wrong = if code == "000000" { "111111" } else { "000000" }; + for _ in 0..5 { + assert_eq!( + post( + &console, + "/set-password", + &[("email", EMAIL), ("code", wrong), ("password", PASSWORD)] + ) + .status(), + 401 + ); + } + // The attempt budget is durable across server restarts. + drop(console); + let console = start(home.path(), &resend, true); + assert_eq!( + post( + &console, + "/set-password", + &[("email", EMAIL), ("code", &code), ("password", PASSWORD)] + ) + .status(), + 401 + ); + assert_eq!( + post(&console, "/set-password/code", &[("email", EMAIL)]) + .text() + .unwrap(), + first + ); + assert_eq!(resend.requests().len(), 1); +} + +#[test] +fn email_flow_is_off_without_a_key_and_in_no_sign_in_mode() { + let home = configured(); + let resend = ResendStandIn::replying(200, "{}"); + let console = start(home.path(), &resend, false); + for path in ["/sign-in", "/set-password"] { + let html = get(&console, path, "").text().unwrap(); + assert!(html.contains("thirdshift console password")); + assert!(!html.contains("Send code")); + } + for path in ["/set-password/code", "/set-password"] { + let page = get(&console, "/sign-in", ""); + let cookie = cookie(&page); + let csrf = csrf(&page.text().unwrap()); + let response = client() + .post(format!("{}{path}", console.address)) + .header("cookie", cookie) + .form(&[ + ("email", EMAIL), + ("code", "123456"), + ("password", PASSWORD), + ("csrf", &csrf), + ]) + .send() + .unwrap(); + assert_eq!(response.status(), 503); + assert!( + response + .text() + .unwrap() + .contains("thirdshift console password") + ); + } + assert!(resend.requests().is_empty()); + let console = Console::start(Path::new(env!("CARGO_BIN_EXE_thirdshift")), home.path()); + assert_eq!(get(&console, "/set-password", "").status(), 404); + for path in ["/set-password/code", "/set-password"] { + assert_eq!( + client() + .post(format!("{}{path}", console.address)) + .form(&[("email", EMAIL)]) + .send() + .unwrap() + .status(), + 404 + ); + } +} + +#[test] +fn resend_refusals_do_not_reveal_the_allowlist_or_record_a_code_sent() { + let home = configured(); + let resend = ResendStandIn::replying(503, "unavailable"); + let console = start(home.path(), &resend, true); + let unknown = post( + &console, + "/set-password/code", + &[("email", "unknown@example.test")], + ); + let allowed = post(&console, "/set-password/code", &[("email", EMAIL)]); + assert_eq!(unknown.status(), allowed.status()); + assert_eq!(unknown.text().unwrap(), allowed.text().unwrap()); + assert_eq!(resend.requests().len(), 1); + let events: Vec = + serde_json::from_slice(&cli(home.path(), &["console", "log", "--json"]).stdout).unwrap(); + assert!(!events.iter().any(|event| event["event"] == "code-sent")); +} From a1ad67555e0ba299057689e141fae454833bb0ee Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 06:50:10 +0000 Subject: [PATCH 2/3] Hide code delivery timing and cover expiry and daily limits over HTTP --- console/README.md | 3 +- src/console/auth.rs | 35 +++++- src/console/auth/password.rs | 66 ++++++------ tests/console/password_codes.rs | 186 ++++++++++++++++++++++++++++++++ 4 files changed, 253 insertions(+), 37 deletions(-) diff --git a/console/README.md b/console/README.md index 3f8e93b4..c0421ac9 100644 --- a/console/README.md +++ b/console/README.md @@ -55,7 +55,8 @@ never contain passwords, codes, session tokens or page views. With a Resend key in `RESEND_API_KEY` or the Credentials, **Set your password** on the sign-in page creates an account or resets its password by email code. Only allowed addresses receive mail, from `email.from`, but every address gets -the same answer. Codes are stored as argon2id hashes, last 15 minutes and allow +the same answer, without waiting for address-dependent delivery. Codes are +stored as argon2id hashes, last 15 minutes and allow 5 tries. An address may receive one code a minute and 10 in a rolling 24 hours; these limits survive restarts and password changes. A correct code verifies the address, sets its password, ends its old sessions and signs it in. diff --git a/src/console/auth.rs b/src/console/auth.rs index 6dce1305..c45034b6 100644 --- a/src/console/auth.rs +++ b/src/console/auth.rs @@ -177,6 +177,36 @@ struct SignInPage { pub(super) async fn sign_in_page( Extension(access): Extension, headers: HeaderMap, +) -> Response { + anonymous_page( + access, + headers, + |AnonymousForm { + csrf, + theme, + email_enabled, + }| { + Ok(SignInPage { + csrf, + theme, + email_enabled, + } + .render()?) + }, + ) + .await +} + +struct AnonymousForm { + csrf: String, + theme: String, + email_enabled: bool, +} + +async fn anonymous_page( + access: Access, + headers: HeaderMap, + render: impl FnOnce(AnonymousForm) -> Result + Send + 'static, ) -> Response { if access.no_sign_in { return StatusCode::NOT_FOUND.into_response(); @@ -185,12 +215,11 @@ pub(super) async fn sign_in_page( let form_token = csrf(&token); let mut response = super::page( tokio::task::spawn_blocking(move || -> Result { - Ok(SignInPage { + render(AnonymousForm { csrf: form_token, theme: UserConfig::load()?.console.theme, email_enabled: crate::resend_key::ResendKey::find()?.is_some(), - } - .render()?) + }) }) .await, ); diff --git a/src/console/auth/password.rs b/src/console/auth/password.rs index d96612e3..e956536d 100644 --- a/src/console/auth/password.rs +++ b/src/console/auth/password.rs @@ -17,26 +17,23 @@ pub(in crate::console) async fn set_password_page( Extension(access): Extension, headers: HeaderMap, ) -> Response { - if access.no_sign_in { - return StatusCode::NOT_FOUND.into_response(); - } - let token = cookie(&headers, SIGN_IN).unwrap_or_else(accounts::random_token); - let form_token = csrf(&token); - let mut response = crate::console::page( - tokio::task::spawn_blocking(move || -> Result { + anonymous_page( + access, + headers, + |AnonymousForm { + csrf, + theme, + email_enabled, + }| { Ok(PasswordPage { - csrf: form_token, - theme: UserConfig::load()?.console.theme, - email_enabled: ResendKey::find()?.is_some(), + csrf, + theme, + email_enabled, } .render()?) - }) - .await, - ); - if response.status().is_success() { - set_cookie(&mut response, SIGN_IN, &token, 15 * 60); - } - response + }, + ) + .await } #[derive(Deserialize)] @@ -66,23 +63,26 @@ pub(in crate::console) async fn code( if !authorized(&access, &headers, &form) { return StatusCode::FORBIDDEN.into_response(); } - let result = tokio::task::spawn_blocking(move || -> Result { - if ResendKey::find()?.is_none() { return Ok(false); } - let config = UserConfig::load()?; - // Every address gets the same answer, including on send failure. Only - // the owner sees transport errors, never account presence in HTTP. - if let Err(error) = Accounts::load()?.send_code(&form.email, |code| { - Resend::new(Some(form.email.clone()), &config.email)?.send( - "Set your thirdshift Console password", - &format!("Your thirdshift Console code is {code}\n\nIt lasts 15 minutes and allows 5 tries. If you did not request it, ignore this email."), - ) - }) { - eprintln!("thirdshift Console: can't send password code: {error:#}"); + // Availability is independent of the address. Do not wait for any work + // that depends on allowlist membership, including hashing and delivery. + match tokio::task::spawn_blocking(|| ResendKey::find().map(|key| key.is_some())).await { + Ok(Ok(true)) => { + tokio::task::spawn_blocking(move || { + let result = (|| -> Result<()> { + let config = UserConfig::load()?; + Accounts::load()?.send_code(&form.email, |code| { + Resend::new(Some(form.email.clone()), &config.email)?.send( + "Set your thirdshift Console password", + &format!("Your thirdshift Console code is {code}\n\nIt lasts 15 minutes and allows 5 tries. If you did not request it, ignore this email."), + ) + }) + })(); + if let Err(error) = result { + eprintln!("thirdshift Console: can't send password code: {error:#}"); + } + }); + axum::response::Html(SENT).into_response() } - Ok(true) - }).await; - match result { - Ok(Ok(true)) => axum::response::Html(SENT).into_response(), Ok(Ok(false)) => (StatusCode::SERVICE_UNAVAILABLE, OWNER).into_response(), error => failed(error), } diff --git a/tests/console/password_codes.rs b/tests/console/password_codes.rs index 01d6345c..bfe1a375 100644 --- a/tests/console/password_codes.rs +++ b/tests/console/password_codes.rs @@ -34,6 +34,7 @@ fn post(console: &Console, path: &str, fields: &[(&str, &str)]) -> reqwest::bloc } fn code(resend: &ResendStandIn) -> String { + wait_for_email(resend); let requests = resend.requests(); let text = requests.last().unwrap().body["text"].as_str().unwrap(); text.split_whitespace() @@ -42,6 +43,14 @@ fn code(resend: &ResendStandIn) -> String { .into() } +fn wait_for_email(resend: &ResendStandIn) { + let deadline = Instant::now() + Duration::from_secs(10); + while resend.requests().is_empty() { + assert!(Instant::now() < deadline, "no code reached Resend"); + std::thread::sleep(Duration::from_millis(10)); + } +} + #[test] fn email_code_creates_and_resets_an_account_without_revealing_the_allowlist() { for environment_key in [false, true] { @@ -79,6 +88,7 @@ fn email_code_creates_and_resets_an_account_without_revealing_the_allowlist() { let allowed = post(&console, "/set-password/code", &[("email", EMAIL)]); assert_eq!(unknown.status(), allowed.status()); assert_eq!(unknown.text().unwrap(), allowed.text().unwrap()); + wait_for_email(&resend); let requests = resend.requests(); assert_eq!(requests.len(), 1); assert_eq!(requests[0].path, "/emails"); @@ -296,8 +306,184 @@ fn resend_refusals_do_not_reveal_the_allowlist_or_record_a_code_sent() { let allowed = post(&console, "/set-password/code", &[("email", EMAIL)]); assert_eq!(unknown.status(), allowed.status()); assert_eq!(unknown.text().unwrap(), allowed.text().unwrap()); + wait_for_email(&resend); assert_eq!(resend.requests().len(), 1); let events: Vec = serde_json::from_slice(&cli(home.path(), &["console", "log", "--json"]).stdout).unwrap(); assert!(!events.iter().any(|event| event["event"] == "code-sent")); } + +#[test] +fn code_response_timing_does_not_reveal_the_allowlist() { + use std::io::{BufRead, BufReader, Read, Write}; + use std::net::TcpListener; + use std::time::{Duration, Instant}; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let resend_url = format!("http://{}", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let mut length = 0; + loop { + line.clear(); + reader.read_line(&mut line).unwrap(); + if line.trim().is_empty() { + break; + } + if let Some((name, value)) = line.split_once(':') { + if name.eq_ignore_ascii_case("content-length") { + length = value.trim().parse::().unwrap(); + } + } + } + reader.read_exact(&mut vec![0; length]).unwrap(); + std::thread::sleep(Duration::from_secs(2)); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\ + Connection: close\r\n\r\n{}", + ) + .unwrap(); + }); + + let home = configured(); + let mut command = Command::new(env!("CARGO_BIN_EXE_thirdshift")); + command + .args(["console"]) + .env("HOME", home.path()) + .env("RESEND_API_KEY", "re_test") + .env("THIRDSHIFT_RESEND_URL", resend_url); + let console = Console::start_command(command); + + let started = Instant::now(); + let unknown = post( + &console, + "/set-password/code", + &[("email", "unknown@example.test")], + ); + let unknown_elapsed = started.elapsed(); + let unknown_status = unknown.status(); + let unknown_body = unknown.text().unwrap(); + + let started = Instant::now(); + let allowed = post(&console, "/set-password/code", &[("email", EMAIL)]); + let allowed_elapsed = started.elapsed(); + let allowed_status = allowed.status(); + let allowed_body = allowed.text().unwrap(); + server.join().unwrap(); + + assert_eq!(unknown_status, allowed_status); + assert_eq!(unknown_body, allowed_body); + assert!( + allowed_elapsed.abs_diff(unknown_elapsed) < Duration::from_secs(1), + "allowlist exposed: unknown={unknown_elapsed:?}, allowed={allowed_elapsed:?}", + ); +} + +// Prepare old durable input while the server is stopped, rather than adding +// a test-only clock to the production HTTP interface. +fn stored_codes(home: &Path, edit: impl FnOnce(&mut Value)) { + let path = home.join(".thirdshift/console-accounts.json"); + let mut accounts: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + edit(&mut accounts[EMAIL]["codes"]); + fs::write(path, serde_json::to_vec(&accounts).unwrap()).unwrap(); +} + +#[test] +fn code_expiry_is_enforced_over_http_with_a_delivered_code() { + for (age, expected_status) in [(14 * 60, 303), (15 * 60, 401)] { + let home = configured(); + let resend = ResendStandIn::replying(200, "{}"); + let console = start(home.path(), &resend, true); + assert_eq!( + post(&console, "/set-password/code", &[("email", EMAIL)]).status(), + 200 + ); + let code = code(&resend); + // The log command takes the shared lock, so the delivered send is + // finished before the fixture is closed and its timestamps aged. + assert!(cli(home.path(), &["console", "log"]).status.success()); + drop(console); + stored_codes(home.path(), |codes| { + codes["pending"]["sent_at"] = json!(chrono::Utc::now().timestamp() - age); + }); + let console = start(home.path(), &resend, true); + assert_eq!( + post( + &console, + "/set-password", + &[("email", EMAIL), ("code", &code), ("password", PASSWORD)] + ) + .status(), + expected_status + ); + } +} + +#[test] +fn daily_code_limit_is_enforced_over_http_and_ages_out() { + let home = configured(); + let resend = ResendStandIn::replying(200, "{}"); + let console = start(home.path(), &resend, true); + assert_eq!( + post(&console, "/set-password/code", &[("email", EMAIL)]).status(), + 200 + ); + let _ = code(&resend); + assert!(cli(home.path(), &["console", "log"]).status.success()); + drop(console); + stored_codes(home.path(), |codes| { + let now = chrono::Utc::now().timestamp(); + codes["sent"] = json!( + (1..=10) + .rev() + .map(|minute| now - minute * 60) + .collect::>() + ); + }); + let console = start(home.path(), &resend, true); + let refused = post(&console, "/set-password/code", &[("email", EMAIL)]) + .text() + .unwrap(); + // Delivery is asynchronous to prevent allowlist disclosure. Observe the + // absence of a second email across a bounded delivery window. + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline { + assert_eq!(resend.requests().len(), 1, "eleventh code was sent"); + std::thread::sleep(Duration::from_millis(10)); + } + assert!(cli(home.path(), &["console", "log"]).status.success()); + drop(console); + stored_codes(home.path(), |codes| { + let now = chrono::Utc::now().timestamp(); + codes["sent"] = json!([now - 24 * 60 * 60]); + }); + let console = start(home.path(), &resend, true); + assert_eq!( + post(&console, "/set-password/code", &[("email", EMAIL)]) + .text() + .unwrap(), + refused + ); + let deadline = Instant::now() + Duration::from_secs(10); + while resend.requests().len() < 2 { + assert!(Instant::now() < deadline, "code stayed blocked after a day"); + std::thread::sleep(Duration::from_millis(10)); + } + let fresh = code(&resend); + assert_eq!( + post( + &console, + "/set-password", + &[("email", EMAIL), ("code", &fresh), ("password", PASSWORD)] + ) + .status(), + 303 + ); +} From 61e2f5b78eee68cade838c4f319e2d60d6dd26f9 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 06:50:49 +0000 Subject: [PATCH 3/3] Apply Clippy style to retained timing regression --- tests/console/password_codes.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/console/password_codes.rs b/tests/console/password_codes.rs index bfe1a375..23a22b89 100644 --- a/tests/console/password_codes.rs +++ b/tests/console/password_codes.rs @@ -336,10 +336,10 @@ fn code_response_timing_does_not_reveal_the_allowlist() { if line.trim().is_empty() { break; } - if let Some((name, value)) = line.split_once(':') { - if name.eq_ignore_ascii_case("content-length") { - length = value.trim().parse::().unwrap(); - } + if let Some((name, value)) = line.split_once(':') + && name.eq_ignore_ascii_case("content-length") + { + length = value.trim().parse::().unwrap(); } } reader.read_exact(&mut vec![0; length]).unwrap();