#![forbid(unsafe_code)]
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::error::{HsmError, HsmResult};
use crate::store::encrypted_store::set_restrictive_permissions;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LockoutData {
pub failed_user_logins: u32,
pub failed_so_logins: u32,
pub failed_init_token_logins: u32,
pub user_pin_locked: bool,
pub so_pin_locked: bool,
}
pub struct LockoutStore {
path: PathBuf,
}
impl LockoutStore {
pub fn new(storage_dir: &std::path::Path) -> Self {
Self {
path: storage_dir.join("lockout_state.json"),
}
}
pub fn load(&self) -> LockoutData {
match std::fs::read_to_string(&self.path) {
Ok(content) => match serde_json::from_str::<LockoutData>(&content) {
Ok(data) => {
tracing::info!(
"loaded lockout state: user_failures={}, so_failures={}, \
user_locked={}, so_locked={}",
data.failed_user_logins,
data.failed_so_logins,
data.user_pin_locked,
data.so_pin_locked,
);
data
}
Err(e) => {
tracing::error!(
"lockout state file is corrupted ({}), \
defaulting to locked state as safety precaution",
e
);
LockoutData {
user_pin_locked: true,
so_pin_locked: true,
failed_user_logins: u32::MAX,
failed_so_logins: u32::MAX,
failed_init_token_logins: u32::MAX,
}
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::debug!("no lockout state file found, starting fresh");
LockoutData::default()
}
Err(e) => {
tracing::error!(
"cannot read lockout state file ({}), \
defaulting to locked state as safety precaution",
e
);
LockoutData {
user_pin_locked: true,
so_pin_locked: true,
failed_user_logins: u32::MAX,
failed_so_logins: u32::MAX,
failed_init_token_logins: u32::MAX,
}
}
}
}
pub fn save(&self, data: &LockoutData) -> HsmResult<()> {
let json = serde_json::to_string_pretty(data).map_err(|e| {
tracing::error!("failed to serialize lockout state: {}", e);
HsmError::GeneralError
})?;
if let Some(parent) = self.path.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent).map_err(|e| {
tracing::error!("failed to create lockout state directory: {}", e);
HsmError::GeneralError
})?;
}
}
let tmp_path = self.path.with_extension("json.tmp");
{
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts.open(&tmp_path).map_err(|e| {
tracing::error!("failed to open lockout state temp file: {}", e);
HsmError::GeneralError
})?;
if let Err(e) = set_restrictive_permissions(&tmp_path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(e);
}
use std::io::Write;
if let Err(e) = file.write_all(json.as_bytes()) {
tracing::error!("failed to write lockout state temp file: {}", e);
let _ = std::fs::remove_file(&tmp_path);
return Err(HsmError::GeneralError);
}
if let Err(e) = file.sync_all() {
tracing::error!("failed to fsync lockout state temp file: {}", e);
let _ = std::fs::remove_file(&tmp_path);
return Err(HsmError::GeneralError);
}
}
if let Err(e) = std::fs::rename(&tmp_path, &self.path) {
tracing::error!("failed to rename lockout state file: {}", e);
let _ = std::fs::remove_file(&tmp_path);
return Err(HsmError::GeneralError);
}
set_restrictive_permissions(&self.path)?;
Ok(())
}
pub fn clear(&self) {
if let Err(e) = std::fs::remove_file(&self.path) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!("failed to remove lockout state file: {}", e);
}
}
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn save_writes_owner_only_permissions() {
let dir = tempfile::tempdir().expect("create tempdir");
let store = LockoutStore::new(dir.path());
store
.save(&LockoutData::default())
.expect("save must succeed");
let meta =
std::fs::metadata(dir.path().join("lockout_state.json")).expect("stat lockout file");
let mode = meta.permissions().mode();
assert_eq!(
mode & 0o077,
0,
"lockout file mode {:o} grants group/world access — must be 0o600",
mode & 0o777,
);
}
}