Pure formatting pass — no behavior changes. Catches the line-wrapping drift across the new authz / api_keys / middleware / handler edits that piled up during the implementation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
232 lines
8.2 KiB
Rust
232 lines
8.2 KiB
Rust
//! 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<String, argon2::password_hash::Error> {
|
|
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_<base32>`) 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<GeneratedApiKey, argon2::password_hash::Error> {
|
|
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"
|
|
);
|
|
}
|
|
}
|