use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, PasswordVerifier, SaltString},
Argon2, PasswordHash,
};
use rand::Rng;
use crate::Error;
const ALPHABET: &[u8] = b"23456789abcdefghjkmnpqrstuvwxyz";
const CHARS_PER_CODE: usize = 10;
const GROUP_AT: usize = 5;
pub fn generate(n: usize) -> Vec<String> {
let mut rng = rand::thread_rng();
(0..n).map(|_| generate_one(&mut rng)).collect()
}
fn generate_one<R: Rng>(rng: &mut R) -> String {
let mut out = String::with_capacity(CHARS_PER_CODE + 1);
for i in 0..CHARS_PER_CODE {
if i > 0 && i % GROUP_AT == 0 {
out.push('-');
}
let idx = rng.gen_range(0..ALPHABET.len());
out.push(ALPHABET[idx] as char);
}
out
}
pub fn hash_all(codes: &[String]) -> Result<Vec<String>, Error> {
codes.iter().map(|c| hash_one(c)).collect()
}
pub fn hash_one(code: &str) -> Result<String, Error> {
let normalized = normalize(code);
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(normalized.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| Error::Internal(format!("recovery-code hash failed: {e}")))
}
pub fn verify_and_consume(
provided: &str,
stored_hashes: &[String],
) -> Result<Option<String>, Error> {
let normalized = normalize(provided);
if normalized.is_empty() {
return Ok(None);
}
for hash in stored_hashes {
if verify_against(&normalized, hash) {
return Ok(Some(hash.clone()));
}
}
Ok(None)
}
fn normalize(code: &str) -> String {
code.chars()
.filter(|c| !c.is_whitespace() && *c != '-')
.flat_map(|c| c.to_lowercase())
.collect()
}
fn verify_against(normalized: &str, encoded_hash: &str) -> bool {
let Ok(parsed) = PasswordHash::new(encoded_hash) else {
return false;
};
Argon2::default()
.verify_password(normalized.as_bytes(), &parsed)
.is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_yields_distinct_codes_of_expected_shape() {
let codes = generate(8);
assert_eq!(codes.len(), 8);
for code in &codes {
assert_eq!(code.len(), CHARS_PER_CODE + 1); assert!(code.chars().nth(GROUP_AT) == Some('-'), "code: {code}");
assert!(code
.chars()
.all(|c| c == '-' || ALPHABET.contains(&(c as u8))));
}
let unique: std::collections::HashSet<_> = codes.iter().collect();
assert_eq!(unique.len(), 8, "duplicate code generated");
}
#[test]
fn hash_and_verify_roundtrip() {
let codes = generate(3);
let hashes = hash_all(&codes).unwrap();
assert_eq!(hashes.len(), 3);
for code in &codes {
let consumed = verify_and_consume(code, &hashes).unwrap();
assert!(consumed.is_some(), "code `{code}` should match a hash");
}
}
#[test]
fn verify_returns_none_for_wrong_code() {
let codes = generate(3);
let hashes = hash_all(&codes).unwrap();
let result = verify_and_consume("totally-wrong", &hashes).unwrap();
assert!(result.is_none());
}
#[test]
fn verify_normalizes_case_whitespace_and_hyphens() {
let code = "abcde-fghij".to_string();
let hashes = hash_all(std::slice::from_ref(&code)).unwrap();
assert!(verify_and_consume("abcde-fghij", &hashes)
.unwrap()
.is_some());
assert!(verify_and_consume("ABCDE-FGHIJ", &hashes)
.unwrap()
.is_some());
assert!(verify_and_consume("abcdefghij", &hashes).unwrap().is_some());
assert!(verify_and_consume(" abcde fghij ", &hashes)
.unwrap()
.is_some());
}
#[test]
fn verify_returns_the_matched_hash_for_deletion() {
let codes = generate(3);
let hashes = hash_all(&codes).unwrap();
let consumed = verify_and_consume(&codes[1], &hashes).unwrap().unwrap();
let parsed = PasswordHash::new(&consumed).unwrap();
assert!(Argon2::default()
.verify_password(normalize(&codes[1]).as_bytes(), &parsed)
.is_ok());
}
#[test]
fn verify_rejects_empty_and_whitespace_only_input() {
let codes = generate(2);
let hashes = hash_all(&codes).unwrap();
assert!(verify_and_consume("", &hashes).unwrap().is_none());
assert!(verify_and_consume(" ", &hashes).unwrap().is_none());
assert!(verify_and_consume(" - - ", &hashes).unwrap().is_none());
}
}