//! Pure auth helpers: password hashing, session-token generation, and //! token-to-hash conversion. No DB, no HTTP — repos and middleware live //! in their own modules. Keeping this surface pure also keeps the unit //! tests fast (no Postgres needed). //! //! Hash algorithm is Argon2id with the OWASP default parameters //! (`Argon2::default()`). Tokens are 32 cryptographically random bytes //! base64-url-encoded for the wire; their SHA-256 (hex) is what hits the //! sessions table. use argon2::password_hash::rand_core::OsRng as ArgonRng; use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; use argon2::Argon2; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine as _; use data_encoding::BASE32_NOPAD; use rand::rngs::OsRng; use rand::RngCore; use sha2::{Digest, Sha256}; /// Returned when the supplied password hash string isn't a valid PHC /// Argon2id encoding. Only surfaces at bootstrap time when the operator /// passes `PICLOUD_ADMIN_PASSWORD_HASH`. #[derive(Debug, thiserror::Error)] #[error("invalid Argon2id PHC hash")] pub struct InvalidPasswordHash; /// Hash a raw password into an Argon2id PHC-formatted string suitable /// for `admin_users.password_hash`. The output already encodes the salt /// and parameters; nothing else needs to be persisted alongside it. pub fn hash_password(raw: &str) -> Result { let salt = SaltString::generate(&mut ArgonRng); let hash = Argon2::default().hash_password(raw.as_bytes(), &salt)?; Ok(hash.to_string()) } /// Constant-ish-time verify of a raw password against a PHC hash. /// Returns `false` for any error (including malformed stored hash) — /// callers should treat that case identically to "wrong password" so /// nothing leaks about why auth failed. #[must_use] pub fn verify_password(stored_hash: &str, raw: &str) -> bool { let Ok(parsed) = PasswordHash::new(stored_hash) else { return false; }; Argon2::default() .verify_password(raw.as_bytes(), &parsed) .is_ok() } /// Validate that a string parses as a PHC Argon2id hash — used at /// bootstrap to fail fast on malformed `PICLOUD_ADMIN_PASSWORD_HASH` /// rather than write garbage into the DB and discover it at first login. pub fn validate_password_hash(stored_hash: &str) -> Result<(), InvalidPasswordHash> { PasswordHash::new(stored_hash).map_err(|_| InvalidPasswordHash)?; Ok(()) } /// Newly minted session token: `raw` goes to the client (cookie + JSON /// response), `hash` is what gets stored. Raw is unrecoverable from hash /// even if the DB leaks. pub struct GeneratedToken { pub raw: String, pub hash: String, } /// Generate a fresh session token (32 random bytes base64-url-encoded). /// Always succeeds — `OsRng::fill_bytes` panics on entropy failure /// instead of returning, but that's a non-recoverable system condition. #[must_use] pub fn generate_session_token() -> GeneratedToken { let mut bytes = [0u8; 32]; OsRng.fill_bytes(&mut bytes); let raw = URL_SAFE_NO_PAD.encode(bytes); let hash = hash_token(&raw); GeneratedToken { raw, hash } } /// SHA-256(raw) as lower-case hex. Stable lookup key for /// `admin_sessions.token_hash`. #[must_use] pub fn hash_token(raw: &str) -> String { let digest = Sha256::digest(raw.as_bytes()); hex(&digest) } fn hex(bytes: &[u8]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let mut out = String::with_capacity(bytes.len() * 2); for &b in bytes { out.push(HEX[(b >> 4) as usize] as char); out.push(HEX[(b & 0x0f) as usize] as char); } out } // ---------------------------------------------------------------------------- // API key generation (Phase 3.5) // ---------------------------------------------------------------------------- /// Wire-format prefix that marks a Bearer value as an API key (vs. a /// session token). Mirrors `auth_middleware::API_KEY_PREFIX` so the /// generator and the verifier agree. pub const API_KEY_WIRE_PREFIX: &str = "pic_"; /// Length of the indexed prefix portion (the first 8 chars of the /// `pic_`-stripped body). Mirrors `auth_middleware::API_KEY_PREFIX_LEN`. pub const API_KEY_INDEX_PREFIX_LEN: usize = 8; /// Newly minted API key — returned exactly once by `POST /api/v1/admin/api-keys`. /// /// * `raw` is the full wire-format token (`pic_`) shown to the /// caller in the response body and never persisted. /// * `prefix` is the indexed 8-char slice persisted to /// `api_keys.prefix` for lookup. /// * `hash` is the Argon2id PHC string persisted to `api_keys.hash`; /// covers the body after `pic_` (i.e., `raw[4..]`). pub struct GeneratedApiKey { pub raw: String, pub prefix: String, pub hash: String, } /// Generate a fresh API key. 32 random bytes → unpadded base32, then /// `pic_` prefix on the wire. The first 8 base32 chars are the index /// key; everything after `pic_` is what the verifier hashes. /// /// # Errors /// /// Returns `argon2::password_hash::Error` if the Argon2 hash step /// fails (which it shouldn't under normal conditions). pub fn generate_api_key() -> Result { let mut bytes = [0u8; 32]; OsRng.fill_bytes(&mut bytes); let body = BASE32_NOPAD.encode(&bytes); debug_assert!( body.len() >= API_KEY_INDEX_PREFIX_LEN, "32 bytes base32 must exceed the 8-char prefix length" ); let prefix = body[..API_KEY_INDEX_PREFIX_LEN].to_string(); let salt = SaltString::generate(&mut ArgonRng); let hash = Argon2::default() .hash_password(body.as_bytes(), &salt)? .to_string(); let raw = format!("{API_KEY_WIRE_PREFIX}{body}"); Ok(GeneratedApiKey { raw, prefix, hash }) } /// Verify a wire-format token body (the portion *after* `pic_`) /// against a stored Argon2id hash. Convenience wrapper around /// `verify_password` named to reflect its caller. #[must_use] pub fn verify_api_key(stored_hash: &str, presented_body: &str) -> bool { verify_password(stored_hash, presented_body) } #[cfg(test)] mod tests { use super::*; #[test] fn hash_verify_roundtrip() { let h = hash_password("correct horse battery staple").unwrap(); assert!(verify_password(&h, "correct horse battery staple")); assert!(!verify_password(&h, "wrong")); } #[test] fn verify_returns_false_on_malformed_hash() { assert!(!verify_password("not-a-phc-string", "anything")); } #[test] fn validate_password_hash_accepts_phc() { let h = hash_password("pw").unwrap(); assert!(validate_password_hash(&h).is_ok()); } #[test] fn validate_password_hash_rejects_garbage() { assert!(validate_password_hash("not a hash").is_err()); } #[test] fn generate_token_unique_and_hash_stable() { let a = generate_session_token(); let b = generate_session_token(); assert_ne!(a.raw, b.raw, "tokens must be unique"); assert_ne!(a.hash, b.hash, "hashes must differ"); assert_eq!(a.hash, hash_token(&a.raw), "hash must be reproducible"); assert_eq!(a.hash.len(), 64, "sha256-hex is 64 chars"); } #[test] fn generate_api_key_round_trip() { let key = generate_api_key().expect("mint"); assert!( key.raw.starts_with(API_KEY_WIRE_PREFIX), "raw must carry the pic_ prefix" ); let body = key .raw .strip_prefix(API_KEY_WIRE_PREFIX) .expect("starts with prefix"); assert_eq!( &body[..API_KEY_INDEX_PREFIX_LEN], key.prefix, "stored prefix matches the first 8 chars of the body" ); assert!( verify_api_key(&key.hash, body), "Argon2 verify must accept the original body" ); assert!( !verify_api_key(&key.hash, "wrong-body-entirely"), "Argon2 verify must reject anything else" ); } #[test] fn generate_api_key_unique() { let a = generate_api_key().expect("mint a"); let b = generate_api_key().expect("mint b"); assert_ne!(a.raw, b.raw); assert_ne!(a.hash, b.hash); assert_ne!( a.prefix, b.prefix, "32 random bytes → prefix collision is negligible" ); } }