slave-framework 0.1.0

Runtime for slave apps: Axum server lifecycle, config, logging, Turso database, migrations and password/token auth
//! Building blocks for authentication: password hashing and bearer tokens.
//!
//! Passwords are hashed with Argon2id (default parameters) into PHC strings.
//! Bearer tokens are 256 random bits, hex-encoded; only their SHA-256 digest
//! should be stored, so a leaked database does not leak usable tokens.

use argon2::Argon2;
use argon2::password_hash::phc::PasswordHash;
use argon2::password_hash::{PasswordHasher, PasswordVerifier};
use sha2::{Digest, Sha256};

use crate::{Error, Result};

/// A valid Argon2id hash of a random throwaway password, used by
/// [`verify_password_or_dummy`] to keep timing similar for unknown accounts.
const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$vYr3jd8Ylw+62+VMY1gp6w$e6xmLuAnUlkoGiWLEsmz/NNQ/+5MSk8XXiCPZlRw73Q";

/// Hashes `password` with Argon2id and a random salt, returning a PHC string.
pub fn hash_password(password: &str) -> Result<String> {
    Argon2::default()
        .hash_password(password.as_bytes())
        .map(|hash| hash.to_string())
        .map_err(|err| Error::Auth(format!("failed to hash password: {err}")))
}

/// Checks `password` against a PHC string produced by [`hash_password`].
///
/// A malformed stored hash is treated as a mismatch rather than an error.
pub fn verify_password(password: &str, hash: &str) -> bool {
    PasswordHash::new(hash).is_ok_and(|parsed| {
        Argon2::default()
            .verify_password(password.as_bytes(), &parsed)
            .is_ok()
    })
}

/// Like [`verify_password`], but when there is no stored hash (unknown account)
/// it still performs a full verification so response times do not reveal
/// which accounts exist. Always `false` in that case.
pub fn verify_password_or_dummy(password: &str, hash: Option<&str>) -> bool {
    match hash {
        Some(hash) => verify_password(password, hash),
        None => {
            let _ = verify_password(password, DUMMY_HASH);
            false
        }
    }
}

/// Generates a new random bearer token (64 hex characters).
pub fn generate_token() -> Result<String> {
    let mut bytes = [0u8; 32];
    getrandom::fill(&mut bytes)
        .map_err(|err| Error::Auth(format!("failed to generate token: {err}")))?;
    Ok(hex(&bytes))
}

/// The SHA-256 digest of `token`, hex-encoded: the form tokens are stored in.
pub fn hash_token(token: &str) -> String {
    hex(&Sha256::digest(token.as_bytes()))
}

fn hex(bytes: &[u8]) -> String {
    use std::fmt::Write as _;
    bytes
        .iter()
        .fold(String::with_capacity(bytes.len() * 2), |mut out, b| {
            let _ = write!(out, "{b:02x}");
            out
        })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn hashes_and_verifies_passwords() {
        let hash = hash_password("correct horse").unwrap();
        assert!(hash.starts_with("$argon2id$"), "{hash}");
        assert!(verify_password("correct horse", &hash));
        assert!(!verify_password("wrong horse", &hash));
        assert_ne!(
            hash,
            hash_password("correct horse").unwrap(),
            "salts differ"
        );
    }

    #[test]
    fn malformed_hashes_never_verify() {
        assert!(!verify_password("x", "not a hash"));
        assert!(!verify_password_or_dummy("x", None));
    }

    #[test]
    fn dummy_hash_is_well_formed() {
        assert!(PasswordHash::new(DUMMY_HASH).is_ok());
    }

    #[test]
    fn tokens_are_random_and_hash_stably() {
        let (a, b) = (generate_token().unwrap(), generate_token().unwrap());
        assert_eq!(a.len(), 64);
        assert_ne!(a, b);
        assert_eq!(hash_token(&a), hash_token(&a));
        assert_ne!(hash_token(&a), a);
        assert_eq!(
            hash_token("abc"),
            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
        );
    }
}