- pm-auth::password: Argon2id (m=65536,t=3,p=1) hashing + verification - pm-auth::jwt: EdDSA/Ed25519 JWT issuance + validation (15-min TTL) - pm-auth::refresh: Opaque 256-bit refresh tokens, SHA-256 hashed, 1-hour sliding inactivity timeout, rotation on use, revocable - pm-auth::mfa_totp: TOTP setup/verify (HMAC-SHA1, 6-digit, 30s) with otpauth:// URI generation (Google Authenticator compatible) - pm-auth::mfa_webauthn: Stub (full implementation deferred) - pm-auth::rbac: Axum middleware for JWT auth + IP whitelist + admin/operator role enforcement + FromRequestParts extractor - pm-auth::session: Full login flow (password → MFA → tokens), token refresh, logout, force-logout - pm-web auth routes: POST /api/v1/auth/login|refresh|logout, GET /api/v1/auth/mfa/setup, POST /api/v1/auth/mfa/verify - IP whitelist middleware on all protected connection points - migrations/002_seed_admin.sql: Default admin account seed - Frontend: Auth store (Zustand with persistence), login page with MFA prompt, MFA setup page (stepper), JWT auto-refresh interceptor, route guards (RequireAuth), updated App.tsx routing - cargo check --workspace: zero errors, 1 minor warning Closes M2.
94 lines
2.8 KiB
Rust
94 lines
2.8 KiB
Rust
//! Password hashing and verification using Argon2id.
|
|
//!
|
|
//! Parameters (calibrated per OWASP recommendations):
|
|
//! - Algorithm: Argon2id
|
|
//! - Memory cost: 65536 KiB (64 MiB)
|
|
//! - Time cost: 3 iterations
|
|
//! - Parallelism: 1
|
|
|
|
use argon2::{
|
|
password_hash::{
|
|
rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
|
|
},
|
|
Argon2, Params, Version,
|
|
};
|
|
use thiserror::Error;
|
|
|
|
/// Argon2id parameters per spec.
|
|
const M_COST: u32 = 65536; // 64 MiB
|
|
const T_COST: u32 = 3; // 3 iterations
|
|
const P_COST: u32 = 1; // 1 thread
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum PasswordError {
|
|
#[error("Failed to hash password: {0}")]
|
|
HashError(String),
|
|
#[error("Failed to verify password: {0}")]
|
|
VerifyError(String),
|
|
#[error("Invalid password hash format")]
|
|
InvalidHash,
|
|
}
|
|
|
|
/// Build an Argon2id instance with calibrated parameters.
|
|
fn argon2() -> Result<Argon2<'static>, PasswordError> {
|
|
let params = Params::new(M_COST, T_COST, P_COST, None)
|
|
.map_err(|e| PasswordError::HashError(e.to_string()))?;
|
|
Ok(Argon2::new(argon2::Algorithm::Argon2id, Version::V0x13, params))
|
|
}
|
|
|
|
/// Hash a plaintext password using Argon2id with a random salt.
|
|
///
|
|
/// Returns the PHC string format hash suitable for storage.
|
|
pub fn hash_password(password: &str) -> Result<String, PasswordError> {
|
|
let salt = SaltString::generate(&mut OsRng);
|
|
let argon2 = argon2()?;
|
|
|
|
let hash = argon2
|
|
.hash_password(password.as_bytes(), &salt)
|
|
.map_err(|e| PasswordError::HashError(e.to_string()))?;
|
|
|
|
Ok(hash.to_string())
|
|
}
|
|
|
|
/// Verify a plaintext password against a stored Argon2id PHC hash.
|
|
///
|
|
/// Returns `Ok(true)` if the password matches, `Ok(false)` if not.
|
|
pub fn verify_password(password: &str, hash: &str) -> Result<bool, PasswordError> {
|
|
let parsed_hash =
|
|
PasswordHash::new(hash).map_err(|_| PasswordError::InvalidHash)?;
|
|
|
|
let argon2 = argon2()?;
|
|
|
|
match argon2.verify_password(password.as_bytes(), &parsed_hash) {
|
|
Ok(()) => Ok(true),
|
|
Err(argon2::password_hash::Error::Password) => Ok(false),
|
|
Err(e) => Err(PasswordError::VerifyError(e.to_string())),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn hash_and_verify_roundtrip() {
|
|
let password = "super-secret-password-123!";
|
|
let hash = hash_password(password).unwrap();
|
|
assert!(hash.starts_with("$argon2id$"));
|
|
assert!(verify_password(password, &hash).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_password_fails() {
|
|
let hash = hash_password("correct-horse").unwrap();
|
|
assert!(!verify_password("wrong-password", &hash).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn different_salts_produce_different_hashes() {
|
|
let hash1 = hash_password("same-password").unwrap();
|
|
let hash2 = hash_password("same-password").unwrap();
|
|
assert_ne!(hash1, hash2); // different salts
|
|
}
|
|
}
|