The load tests (#133) sent forty clients' wrong passwords to /api/login, 54 attempts a second, which takes no account. Everyone else's requests took 4s (median 3.96s for /api/feeds, about 20ms otherwise) and `ipx status`, the healthcheck, 1.3s (#137): each attempt was an Argon2id check, tens of milliseconds of CPU, run inside the handler on one of the runtime's workers, so a handful at once held every worker the rest of the server answers on. And a wrong password for an account's name was refused a median 31ms later than one for a made-up name (#138), since only a name with a hash was checked: the answer read the same, the time said which names are accounts. auth::check_password runs the check on the blocking pool, at most half the cores at once, so a flood waits on itself, and checks a name with no account, or no password, against a fixed decoy hash, false in the same time. Under the same flood the rest of the site answers at p95 57ms, `ipx status` at most 90ms, the gap is 0.2ms, and twice as many attempts are answered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
160 lines
6.4 KiB
Rust
160 lines
6.4 KiB
Rust
//! Who is asking. Sign-in is either a local password or a header set by whatever fronts
|
|
//! this -- Cloudflare Zero Trust on `ipodderx.sdf1.net`, which puts the authenticated
|
|
//! address in `Cf-Access-Authenticated-User-Email`.
|
|
|
|
use anyhow::{Result, bail};
|
|
use argon2::Argon2;
|
|
use argon2::password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash};
|
|
|
|
/// Argon2id with the crate's defaults, which are the OWASP-recommended parameters. The
|
|
/// salt is generated per password by the hasher itself.
|
|
pub fn hash_password(password: &str) -> Result<String> {
|
|
if password.len() < 8 {
|
|
bail!("password must be at least 8 characters");
|
|
}
|
|
Argon2::default()
|
|
.hash_password(password.as_bytes())
|
|
.map(|h| h.to_string())
|
|
.map_err(|e| anyhow::anyhow!("could not hash the password: {e}"))
|
|
}
|
|
|
|
/// False for a wrong password *and* for a stored hash this build cannot parse; either way
|
|
/// the answer is no.
|
|
pub fn verify_password(password: &str, stored: &str) -> bool {
|
|
let Ok(parsed) = PasswordHash::new(stored) else {
|
|
tracing::warn!("stored password hash is unreadable; refusing the sign-in");
|
|
return false;
|
|
};
|
|
Argon2::default()
|
|
.verify_password(password.as_bytes(), &parsed)
|
|
.is_ok()
|
|
}
|
|
|
|
/// How many password checks run at once: each is tens of milliseconds of CPU, and forty wrong
|
|
/// passwords at a time, run on the async workers, made every other request wait 4s (#137).
|
|
/// Half the cores, so a flood of sign-ins waits on itself and the rest of the server has the rest.
|
|
static CHECKS: std::sync::LazyLock<tokio::sync::Semaphore> = std::sync::LazyLock::new(|| {
|
|
tokio::sync::Semaphore::new(std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).max(1)))
|
|
});
|
|
|
|
/// What a name that is not an account is checked against, so that refusing it takes as long as
|
|
/// refusing a wrong password does. Refused without a check, it came back 31ms sooner, and the
|
|
/// time told anyone which names are accounts here (#138).
|
|
static DECOY: std::sync::LazyLock<String> =
|
|
std::sync::LazyLock::new(|| hash_password("no account here has this password").expect("hashing a fixed password"));
|
|
|
|
/// A sign-in's password against the account's hash, or against `DECOY` when there is no account
|
|
/// or it has no password: false either way, in the same time. Off the async workers, and a few at
|
|
/// a time; see `CHECKS`.
|
|
pub async fn check_password(password: String, stored: Option<String>) -> bool {
|
|
let Ok(_turn) = CHECKS.acquire().await else { return false };
|
|
tokio::task::spawn_blocking(move || match stored {
|
|
Some(h) => verify_password(&password, &h),
|
|
None => {
|
|
verify_password(&password, &DECOY);
|
|
false
|
|
}
|
|
})
|
|
.await
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// A session id: 256 bits of urandom, hex. Long enough that guessing is not a strategy.
|
|
pub fn new_session_token() -> String {
|
|
let mut bytes = [0u8; 32];
|
|
if getrandom(&mut bytes).is_err() {
|
|
// Falling back to the clock would be a predictable session id. Better to fail.
|
|
panic!("no source of randomness for a session token");
|
|
}
|
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
|
}
|
|
|
|
/// A new API token: `ipx_` and a session's 256 random bits, the prefix so one found in a log
|
|
/// or a file says what it opens (#123).
|
|
pub fn new_api_token() -> String {
|
|
format!("ipx_{}", new_session_token())
|
|
}
|
|
|
|
/// What is kept of an API token. A fast hash is enough: the token is 256 random bits, not a
|
|
/// password, so there is nothing to guess from a stolen hash.
|
|
pub fn api_token_hash(token: &str) -> String {
|
|
use sha2::Digest;
|
|
sha2::Sha256::digest(token.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
|
|
}
|
|
|
|
fn getrandom(buf: &mut [u8]) -> std::io::Result<()> {
|
|
use std::io::Read;
|
|
std::fs::File::open("/dev/urandom")?.read_exact(buf)
|
|
}
|
|
|
|
/// A username taken from a proxy header. Cloudflare sends an email address; the local part
|
|
/// is what a person recognises, and the whole thing stays unique enough for one household.
|
|
pub fn name_from_header(raw: &str) -> Option<String> {
|
|
let name = raw.trim();
|
|
if name.is_empty() || name.len() > 190 {
|
|
return None;
|
|
}
|
|
// Anything that could confuse a lookup or a log line is not a name.
|
|
if name.chars().any(|c| c.is_control() || c == ',' || c == ';') {
|
|
return None;
|
|
}
|
|
Some(name.to_ascii_lowercase())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn a_sign_in_without_an_account_is_checked_all_the_same() {
|
|
let h = hash_password("correct horse battery").unwrap();
|
|
assert!(check_password("correct horse battery".into(), Some(h.clone())).await);
|
|
assert!(!check_password("wrong".into(), Some(h)).await);
|
|
assert!(!check_password("correct horse battery".into(), None).await);
|
|
// A decoy that does not parse is refused before any hashing, and the time says so (#138).
|
|
assert!(PasswordHash::new(&DECOY).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn a_password_verifies_only_against_itself() {
|
|
let h = hash_password("correct horse battery").unwrap();
|
|
assert!(verify_password("correct horse battery", &h));
|
|
assert!(!verify_password("Correct horse battery", &h));
|
|
assert!(!verify_password("", &h));
|
|
// A hash from a different scheme, or a truncated one, must not authenticate.
|
|
assert!(!verify_password("correct horse battery", "not-a-hash"));
|
|
assert!(hash_password("short").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn api_tokens_are_prefixed_and_hash_to_hex() {
|
|
let t = new_api_token();
|
|
assert!(t.starts_with("ipx_") && t.len() == 68);
|
|
let h = api_token_hash(&t);
|
|
assert_eq!(h.len(), 64);
|
|
assert_eq!(h, api_token_hash(&t), "the same token, the same hash");
|
|
assert_ne!(h, api_token_hash(&new_api_token()));
|
|
assert_eq!(
|
|
api_token_hash("abc"),
|
|
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
|
"SHA-256"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn session_tokens_are_long_and_distinct() {
|
|
let a = new_session_token();
|
|
let b = new_session_token();
|
|
assert_eq!(a.len(), 64);
|
|
assert_ne!(a, b);
|
|
}
|
|
|
|
#[test]
|
|
fn a_header_name_is_cleaned_or_refused() {
|
|
assert_eq!(name_from_header(" Ray@Example.COM "), Some("ray@example.com".into()));
|
|
assert_eq!(name_from_header(""), None);
|
|
assert_eq!(name_from_header("ray\nadmin"), None);
|
|
assert_eq!(name_from_header("ray;admin"), None);
|
|
}
|
|
}
|