use std::time::{Duration, Instant};
use aes_gcm::aead::consts::U12;
use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use argon2::{Algorithm, Argon2, Params, Version};
use secrecy::{ExposeSecret, SecretBox};
use uuid::Uuid;
use zeroize::{Zeroize, Zeroizing};
use crate::errors::AppError;
use crate::services::crypto_service::{AES_256_KEY_LEN, KDF_SALT_LEN, NONCE_LEN};
pub const PIN_MAX_FAILED_ATTEMPTS: u8 = 3;
pub const PIN_MIN_LEN: usize = 4;
pub const PIN_MAX_LEN: usize = 8;
const PIN_KDF_MEMORY_KIB: u32 = 8 * 1024; const PIN_KDF_TIME: u32 = 3;
const PIN_KDF_PARALLELISM: u32 = 1;
pub struct PinCache {
encrypted_master_key: Vec<u8>,
salt: [u8; KDF_SALT_LEN],
nonce: [u8; NONCE_LEN],
created_at: Instant,
failed_attempts: u8,
user_id: Uuid,
}
impl Drop for PinCache {
fn drop(&mut self) {
self.encrypted_master_key.zeroize();
self.salt.zeroize();
self.nonce.zeroize();
self.failed_attempts.zeroize();
}
}
impl PinCache {
pub fn wrap(
master_key: &SecretBox<Vec<u8>>,
pin: &str,
user_id: Uuid,
) -> Result<Self, AppError> {
validate_pin(pin)?;
let mut salt = [0u8; KDF_SALT_LEN];
getrandom::fill(&mut salt)
.map_err(|e| AppError::Crypto(format!("pin salt generation failed: {e}")))?;
let wrapping_key = derive_pin_key(pin, &salt)?;
let mut nonce = [0u8; NONCE_LEN];
getrandom::fill(&mut nonce)
.map_err(|e| AppError::Crypto(format!("pin nonce generation failed: {e}")))?;
let cipher = Aes256Gcm::new_from_slice(wrapping_key.as_slice())
.map_err(|e| AppError::Crypto(format!("pin aes init failed: {e}")))?;
let nonce_ga: Nonce<U12> = nonce.into();
let encrypted_master_key = cipher
.encrypt(&nonce_ga, master_key.expose_secret().as_slice())
.map_err(|_| AppError::Crypto("pin master key encryption failed".to_string()))?;
Ok(Self {
encrypted_master_key,
salt,
nonce,
created_at: Instant::now(),
failed_attempts: 0,
user_id,
})
}
pub fn try_unwrap(&mut self, pin: &str) -> Result<Zeroizing<Vec<u8>>, PinUnlockError> {
if self.is_exhausted() {
return Err(PinUnlockError::Exhausted);
}
match self.decrypt_master_key(pin) {
Ok(key) => Ok(key),
Err(_) => {
self.failed_attempts = self.failed_attempts.saturating_add(1);
if self.is_exhausted() {
Err(PinUnlockError::Exhausted)
} else {
Err(PinUnlockError::WrongPin {
remaining: PIN_MAX_FAILED_ATTEMPTS - self.failed_attempts,
})
}
}
}
}
pub fn is_exhausted(&self) -> bool {
self.failed_attempts >= PIN_MAX_FAILED_ATTEMPTS
}
pub fn is_expired(&self, hard_timeout: Duration) -> bool {
self.created_at.elapsed() >= hard_timeout
}
pub fn remaining(&self, hard_timeout: Duration) -> Duration {
hard_timeout.saturating_sub(self.created_at.elapsed())
}
pub fn user_id(&self) -> Uuid {
self.user_id
}
fn decrypt_master_key(&self, pin: &str) -> Result<Zeroizing<Vec<u8>>, AppError> {
validate_pin(pin)?;
let wrapping_key = derive_pin_key(pin, &self.salt)?;
let cipher = Aes256Gcm::new_from_slice(wrapping_key.as_slice())
.map_err(|e| AppError::Crypto(format!("pin aes init failed: {e}")))?;
let nonce_ga: Nonce<U12> = self.nonce.into();
let plaintext = cipher
.decrypt(&nonce_ga, self.encrypted_master_key.as_slice())
.map_err(|_| AppError::Crypto("pin decryption failed (wrong PIN)".to_string()))?;
Ok(Zeroizing::new(plaintext))
}
}
pub fn validate_pin(pin: &str) -> Result<(), AppError> {
if pin.len() < PIN_MIN_LEN || pin.len() > PIN_MAX_LEN {
return Err(AppError::Crypto(format!(
"PIN must be {PIN_MIN_LEN}–{PIN_MAX_LEN} digits"
)));
}
if !pin.chars().all(|c| c.is_ascii_digit()) {
return Err(AppError::Crypto("PIN must contain only digits".to_string()));
}
Ok(())
}
fn derive_pin_key(pin: &str, salt: &[u8]) -> Result<Zeroizing<Vec<u8>>, AppError> {
let params = Params::new(
PIN_KDF_MEMORY_KIB,
PIN_KDF_TIME,
PIN_KDF_PARALLELISM,
Some(AES_256_KEY_LEN),
)
.map_err(|e| AppError::Crypto(format!("invalid pin argon2 params: {e}")))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut key = Zeroizing::new(vec![0u8; AES_256_KEY_LEN]);
argon2
.hash_password_into(pin.as_bytes(), salt, key.as_mut_slice())
.map_err(|e| AppError::Crypto(format!("pin argon2id derivation failed: {e}")))?;
Ok(key)
}
#[derive(Debug, PartialEq, Eq)]
pub enum PinUnlockError {
WrongPin { remaining: u8 },
Exhausted,
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
use secrecy::SecretBox;
fn dummy_master_key() -> SecretBox<Vec<u8>> {
SecretBox::new(Box::new(vec![0xAB_u8; 32]))
}
fn dummy_uuid() -> Uuid {
Uuid::new_v4()
}
#[test]
fn wrap_then_unwrap_succeeds() {
let uid = dummy_uuid();
let mk = dummy_master_key();
let mut cache = PinCache::wrap(&mk, "123456", uid).expect("wrap failed");
let recovered = cache.try_unwrap("123456").expect("unwrap failed");
assert_eq!(recovered.as_slice(), mk.expose_secret().as_slice());
}
#[test]
fn wrong_pin_increments_attempts() {
let uid = dummy_uuid();
let mk = dummy_master_key();
let mut cache = PinCache::wrap(&mk, "1234", uid).expect("wrap failed");
let err = cache.try_unwrap("9999").unwrap_err();
assert_eq!(err, PinUnlockError::WrongPin { remaining: 2 });
assert_eq!(cache.failed_attempts, 1);
}
#[test]
fn three_failures_exhaust_cache() {
let uid = dummy_uuid();
let mk = dummy_master_key();
let mut cache = PinCache::wrap(&mk, "1234", uid).expect("wrap failed");
for _ in 0..2 {
let _ = cache.try_unwrap("0000");
}
let err = cache.try_unwrap("0000").unwrap_err();
assert_eq!(err, PinUnlockError::Exhausted);
assert!(cache.is_exhausted());
}
#[test]
fn wrong_pin_does_not_decode() {
let uid = dummy_uuid();
let mk = dummy_master_key();
let mut cache = PinCache::wrap(&mk, "1234", uid).expect("wrap failed");
assert!(cache.try_unwrap("4321").is_err());
}
#[test]
fn validate_pin_rejects_short() {
assert!(validate_pin("123").is_err());
}
#[test]
fn validate_pin_rejects_non_digits() {
assert!(validate_pin("12ab").is_err());
}
#[test]
fn validate_pin_accepts_boundary_lengths() {
assert!(validate_pin("1234").is_ok());
assert!(validate_pin("12345678").is_ok());
assert!(validate_pin("123456789").is_err());
}
#[test]
fn hard_timeout_detects_expiry() {
let uid = dummy_uuid();
let mk = dummy_master_key();
let cache = PinCache::wrap(&mk, "1234", uid).expect("wrap failed");
assert!(!cache.is_expired(Duration::from_secs(3600)));
assert!(cache.is_expired(Duration::from_nanos(0)));
}
#[test]
fn user_id_binding_preserved() {
let uid = dummy_uuid();
let mk = dummy_master_key();
let cache = PinCache::wrap(&mk, "1234", uid).expect("wrap failed");
assert_eq!(cache.user_id(), uid);
}
}