use argon2::Argon2;
use argon2::password_hash::rand_core::OsRng as PhcOsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use rand::RngExt;
use vti_common::error::AppError;
const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const CLAIM_SECRET_LEN: usize = 10;
pub fn generate() -> String {
let mut rng = rand::rng();
(0..CLAIM_SECRET_LEN)
.map(|_| {
let idx = rng.random_range(0..ALPHABET.len());
ALPHABET[idx] as char
})
.collect()
}
pub fn hash(secret: &str) -> Result<String, AppError> {
let salt = SaltString::generate(&mut PhcOsRng);
Argon2::default()
.hash_password(secret.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| AppError::Internal(format!("claim secret hash failed: {e}")))
}
pub fn verify(secret: &str, stored_hash: &str) -> Result<bool, AppError> {
let parsed = PasswordHash::new(stored_hash)
.map_err(|e| AppError::Internal(format!("malformed claim secret hash: {e}")))?;
match Argon2::default().verify_password(secret.as_bytes(), &parsed) {
Ok(()) => Ok(true),
Err(argon2::password_hash::Error::Password) => Ok(false),
Err(e) => Err(AppError::Internal(format!(
"claim secret verify failed: {e}"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_uses_unambiguous_alphabet_and_correct_length() {
for _ in 0..100 {
let code = generate();
assert_eq!(code.len(), CLAIM_SECRET_LEN);
for c in code.chars() {
assert!(
ALPHABET.contains(&(c as u8)),
"char {c:?} not in unambiguous alphabet"
);
}
}
}
#[test]
fn generate_produces_distinct_codes() {
let a = generate();
let b = generate();
assert_ne!(a, b);
}
#[test]
fn hash_and_verify_round_trip() {
let secret = generate();
let stored = hash(&secret).unwrap();
assert!(stored.starts_with("$argon2id$"));
assert!(verify(&secret, &stored).unwrap());
}
#[test]
fn verify_rejects_wrong_secret() {
let stored = hash("CORRECT-SECRET").unwrap();
assert!(!verify("WRONG-SECRET", &stored).unwrap());
}
#[test]
fn verify_errors_on_malformed_hash() {
let err = verify("anything", "not a phc string").unwrap_err();
assert!(
format!("{err}").contains("malformed claim secret hash"),
"expected malformed-hash error, got: {err}"
);
}
#[test]
fn two_hashes_of_same_secret_differ_due_to_salt() {
let secret = "SAME-SECRET";
let h1 = hash(secret).unwrap();
let h2 = hash(secret).unwrap();
assert_ne!(h1, h2, "salts must differ");
assert!(verify(secret, &h1).unwrap());
assert!(verify(secret, &h2).unwrap());
}
}