Rust Argon 2 Password Hashing with Multi-Scheme Pattern (E04)
Overview
This chapter explores how to build a production-grade, multi-scheme password hashing architecture from scratch in Rust. When corporate security mandates upgrading hashing algorithms (e.g., transitioning from legacy HMAC-SHA512 to Argon2) or rotating secrets, invalidating all existing user passwords is unacceptable.
This guide implements a modular routing infrastructure that validates legacy hashes, automatically upgrades user passwords to the latest scheme upon successful login, and optimizes performance using compile-time static dispatch via the enum_dispatch crate.
1. Why Do We Need Multi-Scheme Password Hashing?
In real-world enterprise applications, security requirements change over time.
The Problem:
- Algorithm Aging: Cryptographic standards evolve. An algorithm considered secure today (e.g., HMAC-SHA512) may become vulnerable or deprecated tomorrow.
- Secret Rotation: Corporate security policies often require rotating pepper/secret keys periodically.
- User Experience vs. Invalidation: If you force a hard migration where all user hashes are dropped or invalidated, users cannot log in until they reset their passwords, causing massive user drop-off and support overhead.
The Solution:
Implement a Multi-Scheme Routing Architecture. By embedding a small prefix identifier into hashed passwords from day one (e.g., 01 for HMAC-SHA512, 02 for Argon2), the application inspects the stored hash string at login time, routes it to the appropriate legacy or modern validator, and seamlessly upgrades the password hash to the newest scheme using the plaintext password available during the login flow.
2. Refactoring Strategy: Isolating the Password Module
When performing major architectural refactors in production codebases, changing files in-place risks breaking dependent modules during intermediate compilation states.
Best Practice Refactoring Workflow:
- Rename the Legacy Module: Rename
pwdtopwd_legacyinlib.rsand update imports so the rest of the application continues compiling against the old implementation. - Create a Clean Sandbox: Create a new
mod pwdmodule from scratch. This allows writing unit tests, defining new error structures, and building out the multi-scheme router independently. - Gradual Cutover: Once the new multi-scheme module is fully tested and verified against the test suite, perform a global search-and-replace (or module swap) to wire the new
pwdmodule into the web services layer.
3. Designing Error Handling and Types
In this production architecture, sub-modules (like pwd and token) maintain their own self-contained error enums rather than bubbling everything up into a monolithic root crate error.
Error Module Structure (mod error)
- Errors derive standard traits like
Debug,Display, andserde::Serialize(required for structured JSON logging in production request logs). - The
derive_more::Frommacro is utilized to automatically implement conversions from sub-component errors (like scheme-specific errors) into parent password errors.
// Conceptual Error Architecture
use serde::Serialize;
#[derive(Debug, Serialize, derive_more::From)]
pub enum PwdError {
#[from]
SchemeError(SchemeError),
#[from]
PasswordWithSchemeFailed(String),
KeyError(String),
SaltError(String),
}
pub type Result<T> = std::result::Result<T, PwdError>;
Core Content Types (mod types)
The content to be hashed consists of cleartext data coupled with a unique salt generated via UUIDs.
pub struct ContentToHash<'a> {
pub content: &'a str,
pub salt: &'a uuid::Uuid,
}
4. Implementing the Scheme Trait (Dynamic vs. Static Dispatch)
To support multiple algorithms interchangeably, define a common Scheme trait. Every hashing algorithm (HMAC-SHA512, Argon2) implements this trait.
The Scheme Trait Definition
pub trait Scheme {
fn hash(&self, to_hash: &ContentToHash) -> Result<String>;
fn validate(&self, to_hash: &ContentToHash, pwd_ref: &str) -> Result<()>;
}
The SchemeStatus Enum
When validating a password, the hashing module does not know about the database state. Instead, it returns a SchemeStatus indicator to let the caller (the web login controller) know if an upgrade is required:
#[derive(Debug, PartialEq)]
pub enum SchemeStatus {
Ok, // Password is using the latest scheme
Outdated, // Password is using an older scheme; triggers auto-upgrade
}
5. Scheme 01: Legacy HMAC-SHA512
Scheme 01 represents the legacy implementation using HMAC-SHA512. It is retained strictly for backward compatibility so existing user credentials can be validated.
Implementation Details:
- Stateless Configuration: Takes a pre-shared key (pepper) and salt.
- Storage Format: Prepends the scheme identifier to the resulting hash string (e.g., 01hash).
pub struct Scheme01 {
key: Vec<u8>,
}
impl Scheme for Scheme01 {
fn hash(&self, to_hash: &ContentToHash) -> Result<String> {
// Internal HMAC-SHA512 computation logic
// Returns formatted string: "salt:hashed_output"
}
fn validate(&self, to_hash: &ContentToHash, pwd_ref: &str) -> Result<()> {
// Re-hashes incoming content with stored salt and verifies against pwd_ref
}
}
6. Scheme 02: Modern Argon2 Hashing
Argon2 is the winner of the Password Hashing Competition and provides robust memory-hard protection against GPU-accelerated cracking attacks.
Key Architectural Differences from Scheme 01:
- Self-Contained Metadata: Unlike HMAC-SHA512 (where salt and hash must be separately stored and managed), Argon2 encodes its complete configuration, parameters, version, and salt directly into the final encoded string.
- Asymmetric Validation Workflow: During Argon2 validation, the hashed string (
pwd_ref) is parsed into an Argon2 structure containing its embedded salt, eliminating the need to supply the salt separately during verification.
Setting up Argon2 with OnceLock (std::sync::OnceLock)
To avoid re-instantiating the Argon2 hashing context on every request, use OnceLock (introduced in Rust 1.72) to initialize a static, thread-safe instance once at runtime.
use std::sync::OnceLock;
use argon2::{Argon2, PasswordHasher, PasswordVerifier};
use argon2::password_hash::SaltString;
fn get_argon2() -> &'static Argon2<'static> {
static INSTANCE: OnceLock<Argon2> = OnceLock::new();
INSTANCE.get_or_init(|| {
let key = env_config::get_password_key();
// Initialize Argon2 with secret pepper key
Argon2::new(
argon2::Algorithm::Argon2id,
argon2::Version::V0x13,
argon2::Params::default(),
)
})
}
Implementing Scheme 02 Hash & Validate
pub struct Scheme02;
impl Scheme for Scheme02 {
fn hash(&self, to_hash: &ContentToHash) -> Result<String> {
let argon2 = get_argon2();
let salt = SaltString::generate(&mut rand::rngs::OsRng);
// Hash password bytes with base64 encoded salt
let password_hash = argon2
.hash_password(to_hash.content.as_bytes(), &salt)
.map_err(|e| PwdError::KeyError(e.to_string()))?
.to_string();
Ok(password_hash)
}
fn validate(&self, to_hash: &ContentToHash, pwd_ref: &str) -> Result<()> {
let argon2 = get_argon2();
let parsed_hash = argon2::PasswordHash::new(pwd_ref)
.map_err(|e| PwdError::KeyError(e.to_string()))?;
argon2
.verify_password(to_hash.content.as_bytes(), &parsed_hash)
.map_err(|_| PwdError::PasswordWithSchemeFailed("Verification failed".into()))
}
}
7. Multi-Scheme Dispatch Routing: From Dynamic to Static Dispatch
The router must inspect incoming password strings, parse their scheme prefix, and dispatch execution to the correct algorithm handler.
Step A: Dynamic Dispatch (Trait Objects)
Initially, schemes can be returned as trait objects boxed on the heap (Box<dyn Scheme>):
pub fn get_scheme(scheme_name: &str) -> Result<Box<dyn Scheme>> {
match scheme_name {
"01" => Ok(Box::new(Scheme01::new()?)),
"02" => Ok(Box::new(Scheme02)),
_ => Err(PwdError::KeyError(format!("Scheme not found: {}", scheme_name))),
}
}
- Pros: Highly extensible at runtime.
- Cons: Incurs small heap-allocation overhead (
Box) and dynamic vtable dispatch penalties.
Step B: High-Performance Static Dispatch with enum_dispatch
Since password hashing schemes rarely change (adding or updating an algorithm happens perhaps once a year), this is an ideal candidate for Static Dispatch using an enum wrapper.
Rather than manually writing boilerplate match arms for every trait method, the external crate enum_dispatch automates this cleanly via procedural macros.
use enum_dispatch::enum_dispatch;
#[enum_dispatch]
pub trait Scheme {
fn hash(&self, to_hash: &ContentToHash) -> Result<String>;
fn validate(&self, to_hash: &ContentToHash, pwd_ref: &str) -> Result<()>;
}
// The macro automatically implements the Scheme trait for this enum
#[enum_dispatch(Scheme)]
pub enum SchemeDispatcher {
Scheme01(Scheme01),
Scheme02(Scheme02),
}
pub fn get_scheme(scheme_name: &str) -> Result<SchemeDispatcher> {
match scheme_name {
"01" => Ok(SchemeDispatcher::Scheme01(Scheme01::new()?)),
"02" => Ok(SchemeDispatcher::Scheme02(Scheme02)),
_ => Err(PwdError::KeyError(format!("Scheme not found: {}", scheme_name))),
}
}
8. Password Parsing and Web Layer Integration
When a user logs in, the stored password string in the database looks like 02m=...hash. A regular expression (powered by lazy_regex) parses this string into components: the scheme identifier and the hashed payload.
Parsing Logic
use lazy_regex::regex_captures;
pub struct PwdParts {
pub scheme_name: String,
pub hashed: String,
}
impl PwdParts {
pub fn parse(pwd_ref: &str) -> Result<Self> {
let (_, scheme, hashed) = regex_captures!(r"^([0-9]{2})\$(.+)$", pwd_ref)
.ok_or_else(|| PwdError::PasswordWithSchemeFailed("Invalid format".into()))?;
Ok(Self {
scheme_name: scheme.to_string(),
hashed: hashed.to_string(),
})
}
}
Seamless Auto-Upgrade in the Web Login Controller
When a user authenticates successfully, check the returned SchemeStatus. If the scheme is marked as Outdated, automatically re-hash the password in cleartext using the current default scheme (02) and update the database record asynchronously.
// Inside web server login handler (e.g., auth_service.rs)
pub async fn login(
State(mc): State<ModelManager>,
Json(payload): Json<LoginPayload>,
) -> Result<impl IntoResponse> {
// 1. Fetch user from database
let user = UserBmc::first_by_username(&mc, &payload.username).await?;
// 2. Validate password and get scheme status
let scheme_status = pwd::validate(
&ContentToHash {
content: &payload.password,
salt: &user.salt,
},
&user.pwd_hash,
)?;
// 3. Seamless Auto-Upgrade Trigger
if matches!(scheme_status, SchemeStatus::Outdated) {
// User logged in with legacy hash; upgrade to latest default scheme
UserBmc::update_password(&mc, user.id, &payload.password).await?;
}
// 4. Issue authentication token and complete login
let token = token::generate(&user.username)?;
Ok(Json(token))
}
Summary and Key Takeaways
- Multi-Scheme Resilience: Designing for multiple hashing schemes from day one prevents catastrophic password invalidations when security standards or corporate policies mandate cryptographic upgrades.
- Prefix Identifier Strategy: Embedding a two-digit scheme identifier prefix (e.g.,
01,02) directly into stored database password strings enables deterministic routing during login validation. - Backward Compatibility: Retaining legacy scheme implementations (like HMAC-SHA512) alongside modern algorithms (like Argon2) ensures smooth validation of historical records without forcing password resets.
- Asymmetric Validation Realities: Modern memory-hard algorithms like Argon2 store salt, version, and parameters directly within the generated hash string, whereas legacy schemes require separate salt tracking.
- Static Dispatch via
enum_dispatch: Transitioning from heap-allocated trait objects (Box<dyn Scheme>) to zero-cost static dispatch enums using procedural macros provides maximum performance and clean ergonomics. - Seamless Just-In-Time Upgrades: Leveraging the cleartext password available during a successful login allows background automatic rehashing and database updates when an
Outdatedscheme status is detected. - Isolated Module Sandboxing: Renaming legacy modules (
pwd_legacy) while building clean concurrent test suites (mod pwd) minimizes breaking changes during major production refactorings. - Framework-Less Production Design: Utilizing native Rust features (
OnceLock, custom error enums withderive_more, andlazy_regex) maintains a lean, predictable, and highly performant backend stack.