use serde::Deserialize;
use std::fmt;
use std::path::{Path, PathBuf};
use crate::error::HsmError;
#[cfg(debug_assertions)]
const MIN_PBKDF2_ITERATIONS: u32 = 1;
#[cfg(not(debug_assertions))]
const MIN_PBKDF2_ITERATIONS: u32 = 100_000;
const MAX_PBKDF2_ITERATIONS: u32 = 10_000_000;
const MIN_PIN_LENGTH: usize = 4;
const MIN_FAILED_LOGINS: u32 = 3;
const MAX_FAILED_LOGINS: u32 = 100;
const MAX_PIN_LENGTH: usize = 256;
const MAX_SESSIONS: u64 = 10_000;
const MAX_SLOT_COUNT: usize = 256;
const MAX_LABEL_LENGTH: usize = 32;
const VALID_BACKENDS: &[&str] = &["rustcrypto", "awslc"];
const VALID_LOG_LEVELS: &[&str] = &["all", "crypto", "auth", "admin", "none"];
const SENSITIVE_PATH_PREFIXES: &[&str] = &[".git", ".ssh", ".gnupg", ".aws", ".config", ".env"];
#[derive(Debug, Deserialize, Clone, Default)]
pub struct HsmConfig {
#[serde(default)]
pub token: TokenConfig,
#[serde(default)]
pub security: SecurityConfig,
#[serde(default)]
pub audit: AuditConfig,
#[serde(default)]
pub algorithms: AlgorithmConfig,
}
#[derive(Debug, Deserialize, Clone)]
pub struct TokenConfig {
#[serde(default = "default_label")]
pub label: String,
#[serde(default = "default_storage_path")]
pub storage_path: PathBuf,
#[serde(default = "default_max_sessions")]
pub max_sessions: u64,
#[serde(default = "default_max_rw_sessions")]
pub max_rw_sessions: u64,
#[serde(default)]
pub persist_objects: bool,
#[serde(default = "default_slot_count")]
pub slot_count: usize,
#[serde(default = "default_serial_number")]
pub serial_number: String,
}
#[derive(Deserialize, Clone)]
pub struct SecurityConfig {
#[serde(default = "default_pin_min")]
pub pin_min_length: usize,
#[serde(default = "default_pin_max")]
pub pin_max_length: usize,
#[serde(default = "default_max_failed")]
pub max_failed_logins: u32,
#[serde(default = "default_pbkdf2_iterations")]
pub pbkdf2_iterations: u32,
}
impl fmt::Debug for SecurityConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SecurityConfig")
.field("pin_min_length", &"[REDACTED]")
.field("pin_max_length", &"[REDACTED]")
.field("max_failed_logins", &"[REDACTED]")
.field("pbkdf2_iterations", &"[REDACTED]")
.finish()
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct AuditConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_audit_path")]
pub log_path: PathBuf,
#[serde(default = "default_log_level")]
pub log_level: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct AlgorithmConfig {
#[serde(default)]
pub allow_weak_rsa: bool,
#[serde(default)]
pub allow_sha1_signing: bool,
#[serde(default = "default_true")]
pub enable_pqc: bool,
#[serde(default = "default_crypto_backend")]
pub crypto_backend: String,
#[serde(default)]
pub fips_approved_only: bool,
#[serde(default)]
pub derived_keys_extractable_by_default: bool,
}
impl AlgorithmConfig {
pub fn derived_keys_default_extractable(&self) -> bool {
self.derived_keys_extractable_by_default && !self.fips_approved_only
}
}
impl Default for TokenConfig {
fn default() -> Self {
Self {
label: default_label(),
storage_path: default_storage_path(),
max_sessions: default_max_sessions(),
max_rw_sessions: default_max_rw_sessions(),
persist_objects: false,
slot_count: default_slot_count(),
serial_number: default_serial_number(),
}
}
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
pin_min_length: default_pin_min(),
pin_max_length: default_pin_max(),
max_failed_logins: default_max_failed(),
pbkdf2_iterations: default_pbkdf2_iterations(),
}
}
}
impl Default for AuditConfig {
fn default() -> Self {
Self {
enabled: true,
log_path: default_audit_path(),
log_level: default_log_level(),
}
}
}
impl Default for AlgorithmConfig {
fn default() -> Self {
Self {
allow_weak_rsa: false,
allow_sha1_signing: false,
enable_pqc: true,
crypto_backend: default_crypto_backend(),
fips_approved_only: false,
derived_keys_extractable_by_default: false,
}
}
}
fn default_label() -> String {
"Craton HSM Token 0".to_string()
}
const RELATIVE_STORAGE_FALLBACK: &str = "craton_hsm_store";
fn default_storage_path() -> PathBuf {
if let Some(system_default) = platform_default_storage_dir() {
let parent_exists = system_default.parent().map(|p| p.is_dir()).unwrap_or(false);
if parent_exists {
return system_default;
}
tracing::warn!(
"default storage parent directory '{}' does not exist — falling back to relative '{}' \
(a process-CWD-relative store is a privesc foothold; configure storage_path \
explicitly or provision the system directory)",
system_default.display(),
RELATIVE_STORAGE_FALLBACK
);
}
PathBuf::from(RELATIVE_STORAGE_FALLBACK)
}
fn canonicalize_deepest_existing(path: &Path) -> PathBuf {
for ancestor in path.ancestors() {
if ancestor.as_os_str().is_empty() {
continue;
}
if let Ok(canon) = std::fs::canonicalize(ancestor) {
if let Ok(suffix) = path.strip_prefix(ancestor) {
let mut out = canon;
out.push(suffix);
return out;
}
return canon;
}
}
path.to_path_buf()
}
fn platform_default_storage_dir() -> Option<PathBuf> {
#[cfg(unix)]
{
Some(PathBuf::from("/var/lib/craton-hsm/store"))
}
#[cfg(windows)]
{
std::env::var_os("PROGRAMDATA").map(|pd| {
let mut p = PathBuf::from(pd);
p.push("craton-hsm");
p.push("store");
p
})
}
#[cfg(not(any(unix, windows)))]
{
None
}
}
fn default_max_sessions() -> u64 {
100
}
fn default_max_rw_sessions() -> u64 {
10
}
fn default_pin_min() -> usize {
8
}
fn default_pin_max() -> usize {
64
}
fn default_max_failed() -> u32 {
10
}
fn default_pbkdf2_iterations() -> u32 {
if cfg!(debug_assertions) {
1
} else {
600_000
}
}
fn default_true() -> bool {
true
}
fn default_audit_path() -> PathBuf {
PathBuf::from("craton_hsm_audit.jsonl")
}
fn default_log_level() -> String {
"all".to_string()
}
fn default_crypto_backend() -> String {
"rustcrypto".to_string()
}
fn default_slot_count() -> usize {
1
}
fn default_serial_number() -> String {
"0000000000000001".to_string()
}
impl HsmConfig {
pub fn load() -> Result<Self, HsmError> {
#[cfg(feature = "fips")]
{
tracing::warn!(
"FIPS mode active (compile-time, `--features fips`) — using hardcoded secure \
defaults and IGNORING craton_hsm.toml entirely, including persist_objects, \
storage_path, and all other settings. If you need persistence or other \
non-default settings in FIPS mode, build without the `fips` feature (note that \
`--all-features` includes it)."
);
if std::path::Path::new("craton_hsm.toml").exists() {
tracing::warn!("craton_hsm.toml exists but is being ignored in FIPS mode");
}
return Ok(Self::fips_defaults());
}
#[cfg(not(feature = "fips"))]
{
if std::env::var("CRATON_HSM_FIPS").as_deref() == Ok("1") {
tracing::warn!(
"FIPS mode activated via CRATON_HSM_FIPS env var. \
SECURITY: env-var FIPS is ADVISORY ONLY — an attacker who can modify \
the process environment can bypass this entirely. For certified FIPS \
compliance, compile with `--features fips`."
);
if std::path::Path::new("craton_hsm.toml").exists() {
tracing::warn!("craton_hsm.toml exists but is being ignored in FIPS mode");
}
return Ok(Self::fips_defaults());
}
let config_path = std::env::var("CRATON_HSM_CONFIG")
.unwrap_or_else(|_| "craton_hsm.toml".to_string());
Self::validate_config_path(&config_path)?;
let config = Self::load_from_path(&config_path)?;
config.validate()?;
Ok(config)
}
}
fn fips_defaults() -> Self {
let mut config = Self::default();
config.algorithms.fips_approved_only = true;
config.algorithms.allow_weak_rsa = false;
config.algorithms.allow_sha1_signing = false;
config.algorithms.enable_pqc = false;
config.audit.enabled = true;
config
}
fn validate_path_safety(path: &Path, label: &str) -> Result<(), Vec<String>> {
let mut errors: Vec<String> = Vec::new();
let path_str = path.to_string_lossy();
if path_str.starts_with("\\\\") || path_str.starts_with("//") {
errors.push(format!(
"{} '{}' is a UNC path — not permitted",
label, path_str
));
}
for component in path.components() {
if let std::path::Component::ParentDir = component {
errors.push(format!(
"{} '{}' contains '..' traversal component",
label, path_str
));
break;
}
}
if path_str.contains('\0') {
errors.push(format!("{} contains null byte", label));
}
if path.is_relative() {
if let Some(first) = path.components().next() {
let first_str = first.as_os_str().to_string_lossy();
for prefix in SENSITIVE_PATH_PREFIXES {
if first_str.eq_ignore_ascii_case(prefix) {
errors.push(format!(
"{} '{}' targets sensitive directory '{}'",
label, path_str, prefix
));
break;
}
}
}
}
if let Err(sym_errs) = Self::reject_symlinks_on_path(path, label) {
errors.extend(sym_errs);
}
let canonical = canonicalize_deepest_existing(path);
for component in canonical.components() {
if let std::path::Component::ParentDir = component {
errors.push(format!(
"{} '{}' canonicalizes to a path containing '..'",
label, path_str
));
break;
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
fn reject_symlinks_on_path(path: &Path, label: &str) -> Result<(), Vec<String>> {
let mut errors: Vec<String> = Vec::new();
let mut acc = PathBuf::new();
for component in path.components() {
acc.push(component.as_os_str());
match std::fs::symlink_metadata(&acc) {
Ok(meta) if meta.file_type().is_symlink() => {
errors.push(format!(
"{} '{}' contains a symlink component at '{}' — not permitted",
label,
path.to_string_lossy(),
acc.display()
));
return Err(errors);
}
Ok(_) => {}
Err(_) => {
break;
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
fn validate_config_path(path: &str) -> Result<(), HsmError> {
let p = std::path::Path::new(path);
if let Err(errs) = Self::validate_path_safety(p, "config path") {
let msg = errs.join("; ");
tracing::error!("Config path validation failed: {}", msg);
return Err(HsmError::ConfigError(msg));
}
Ok(())
}
fn check_file_permissions(file: &std::fs::File, path: &str) -> Result<(), HsmError> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let meta = file.metadata().map_err(|e| {
HsmError::ConfigError(format!("cannot stat config file '{}': {}", path, e))
})?;
let mode = meta.mode();
if mode & 0o022 != 0 {
tracing::error!(
"Config file '{}' has insecure permissions {:o} — \
must not be group- or world-writable",
path,
mode & 0o777
);
return Err(HsmError::ConfigError(format!(
"config file has insecure permissions ({:o}); \
must not be group- or world-writable",
mode & 0o777
)));
}
}
#[cfg(windows)]
{
Self::check_windows_permissions(file, path)?;
}
#[cfg(not(any(unix, windows)))]
{
let _ = (file, path);
}
Ok(())
}
#[cfg(windows)]
fn check_windows_permissions(_file: &std::fs::File, path: &str) -> Result<(), HsmError> {
let canonical = match std::fs::canonicalize(path) {
Ok(p) => p,
Err(_) => {
return Ok(());
}
};
let output = std::process::Command::new("icacls")
.arg(&canonical)
.output();
if let Ok(output) = output {
let stdout = String::from_utf8_lossy(&output.stdout);
let stdout_upper = stdout.to_uppercase();
let dangerous_principals =
["EVERYONE", "USERS", "AUTHENTICATED USERS", "BUILTIN\\USERS"];
let dangerous_perms = ["(F)", "(M)", "(W)"];
for line in stdout_upper.lines() {
for principal in &dangerous_principals {
if line.contains(principal) {
for perm in &dangerous_perms {
if line.contains(perm) {
tracing::error!(
"Config file '{}' has insecure ACL — {} has write access",
path,
principal
);
return Err(HsmError::ConfigError(format!(
"config file has insecure permissions; \
{} has write access — restrict to owner only",
principal
)));
}
}
}
}
}
}
Ok(())
}
fn check_file_not_symlink(file: &std::fs::File, path: &str) -> Result<(), HsmError> {
let meta = file.metadata().map_err(|e| {
HsmError::ConfigError(format!("cannot stat config file '{}': {}", path, e))
})?;
#[cfg(unix)]
{
if let Ok(link_meta) = std::fs::symlink_metadata(path) {
if link_meta.file_type().is_symlink() {
tracing::error!(
"Config file '{}' is a symlink — not permitted for security files",
path
);
return Err(HsmError::ConfigError(
"config file is a symlink — not permitted".to_string(),
));
}
}
}
let _ = meta;
Ok(())
}
pub fn load_from_path(path: &str) -> Result<Self, HsmError> {
use std::io::Read;
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::info!("No config file at '{}' — using secure defaults", path);
return Ok(Self::default());
}
Err(e) => {
tracing::error!("Cannot read config file '{}': {}", path, e);
return Err(HsmError::ConfigError(
"cannot read config file — check logs for details".to_string(),
));
}
};
Self::check_file_not_symlink(&file, path)?;
Self::check_file_permissions(&file, path)?;
let mut content = String::new();
let mut reader = std::io::BufReader::new(file);
reader.read_to_string(&mut content).map_err(|e| {
tracing::error!("Cannot read config file '{}': {}", path, e);
HsmError::ConfigError("cannot read config file — check logs for details".to_string())
})?;
toml::from_str(&content).map_err(|e| {
tracing::error!(
"Failed to parse config file '{}': {} — refusing to start with potentially \
misconfigured security parameters",
path,
e
);
HsmError::ConfigError(
"failed to parse config file — check logs for details".to_string(),
)
})
}
pub fn validate(&self) -> Result<(), HsmError> {
let mut errors: Vec<String> = Vec::new();
if let Err(path_errs) = Self::validate_path_safety(&self.token.storage_path, "storage_path")
{
errors.extend(path_errs);
}
if let Err(path_errs) = Self::validate_path_safety(&self.audit.log_path, "audit log_path") {
errors.extend(path_errs);
}
if self.token.label.is_empty() {
errors.push("token label must not be empty".to_string());
}
if self.token.label.len() > MAX_LABEL_LENGTH {
errors.push(format!(
"token label '{}' exceeds {} bytes (PKCS#11 CK_TOKEN_INFO.label limit)",
self.token.label, MAX_LABEL_LENGTH
));
}
if !self
.token
.label
.bytes()
.all(|b| b.is_ascii_graphic() || b == b' ')
{
errors.push("token label contains non-printable or non-ASCII characters".to_string());
}
if self.security.pin_min_length < MIN_PIN_LENGTH {
errors.push(format!(
"pin_min_length ({}) is below the minimum of {}",
self.security.pin_min_length, MIN_PIN_LENGTH
));
}
if self.security.pin_max_length < self.security.pin_min_length {
errors.push(format!(
"pin_max_length ({}) is less than pin_min_length ({})",
self.security.pin_max_length, self.security.pin_min_length
));
}
if self.security.pin_max_length > MAX_PIN_LENGTH {
errors.push(format!(
"pin_max_length ({}) exceeds the maximum of {} (risk of memory exhaustion during PIN hashing)",
self.security.pin_max_length, MAX_PIN_LENGTH
));
}
if self.security.pbkdf2_iterations < MIN_PBKDF2_ITERATIONS {
errors.push(format!(
"pbkdf2_iterations ({}) is below the minimum of {}",
self.security.pbkdf2_iterations, MIN_PBKDF2_ITERATIONS
));
}
if self.security.pbkdf2_iterations > MAX_PBKDF2_ITERATIONS {
errors.push(format!(
"pbkdf2_iterations ({}) exceeds the maximum of {} (risk of algorithmic DoS)",
self.security.pbkdf2_iterations, MAX_PBKDF2_ITERATIONS
));
}
if self.security.max_failed_logins < MIN_FAILED_LOGINS {
errors.push(format!(
"max_failed_logins ({}) is below the minimum of {} (risk of accidental lockout)",
self.security.max_failed_logins, MIN_FAILED_LOGINS
));
}
if self.security.max_failed_logins > MAX_FAILED_LOGINS {
errors.push(format!(
"max_failed_logins ({}) exceeds the maximum of {} (risk of brute-force)",
self.security.max_failed_logins, MAX_FAILED_LOGINS
));
}
if self.token.serial_number.is_empty() {
errors.push("serial_number must not be empty".to_string());
}
if self.token.serial_number.len() > 16 {
errors.push(format!(
"serial_number '{}' exceeds 16 characters",
self.token.serial_number
));
}
if !self
.token
.serial_number
.bytes()
.all(|b| b.is_ascii_graphic() || b == b' ')
{
errors.push("serial_number contains non-printable or non-ASCII characters".to_string());
}
if self.token.max_sessions == 0 {
errors.push("max_sessions must be at least 1".to_string());
}
if self.token.max_sessions > MAX_SESSIONS {
errors.push(format!(
"max_sessions ({}) exceeds the maximum of {} (risk of resource exhaustion)",
self.token.max_sessions, MAX_SESSIONS
));
}
if self.token.max_rw_sessions == 0 {
errors.push("max_rw_sessions must be at least 1".to_string());
}
if self.token.max_rw_sessions > self.token.max_sessions {
errors.push(format!(
"max_rw_sessions ({}) exceeds max_sessions ({})",
self.token.max_rw_sessions, self.token.max_sessions
));
}
if self.token.slot_count == 0 {
errors.push("slot_count must be at least 1".to_string());
}
if self.token.slot_count > MAX_SLOT_COUNT {
errors.push(format!(
"slot_count ({}) exceeds the maximum of {} (risk of resource exhaustion)",
self.token.slot_count, MAX_SLOT_COUNT
));
}
if !VALID_BACKENDS.contains(&self.algorithms.crypto_backend.as_str()) {
errors.push(format!(
"crypto_backend '{}' is not recognized (valid: {:?})",
self.algorithms.crypto_backend, VALID_BACKENDS
));
}
if !VALID_LOG_LEVELS.contains(&self.audit.log_level.as_str()) {
errors.push(format!(
"audit log_level '{}' is not recognized (valid: {:?})",
self.audit.log_level, VALID_LOG_LEVELS
));
}
if self.algorithms.fips_approved_only {
if self.algorithms.allow_weak_rsa {
errors.push(
"allow_weak_rsa cannot be true when fips_approved_only is enabled".to_string(),
);
}
if self.algorithms.allow_sha1_signing {
errors.push(
"allow_sha1_signing cannot be true when fips_approved_only is enabled"
.to_string(),
);
}
if !self.audit.enabled {
errors.push("audit must be enabled when fips_approved_only is active".to_string());
}
if self.algorithms.derived_keys_extractable_by_default {
errors.push(
"derived_keys_extractable_by_default cannot be true when fips_approved_only is enabled"
.to_string(),
);
}
}
if errors.is_empty() {
Ok(())
} else {
let msg = errors.join("; ");
tracing::error!("Configuration validation failed: {}", msg);
Err(HsmError::ConfigError(msg))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn derived_keys_extractable_by_default_rejected_in_fips_mode() {
let mut config = HsmConfig::default();
config.algorithms.derived_keys_extractable_by_default = true;
assert!(config.validate().is_ok());
assert!(config.algorithms.derived_keys_default_extractable());
config.algorithms.fips_approved_only = true;
assert!(config.validate().is_err());
assert!(!config.algorithms.derived_keys_default_extractable());
}
fn unique_tempdir(tag: &str) -> PathBuf {
#[cfg(unix)]
let base = std::fs::canonicalize(std::env::temp_dir())
.expect("temp_dir must exist and be canonicalizable");
#[cfg(not(unix))]
let base = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = base.join(format!(
"craton-hsm-cfg-{}-{}-{}",
tag,
std::process::id(),
nanos
));
std::fs::create_dir_all(&dir).expect("can create tempdir");
dir
}
#[test]
fn absolute_path_with_no_symlinks_is_accepted() {
let dir = unique_tempdir("abs-ok");
let leaf = dir.join("store");
let res = HsmConfig::validate_path_safety(&leaf, "storage_path");
assert!(
res.is_ok(),
"expected absolute path to be accepted: {:?}",
res
);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn absolute_path_with_symlink_component_is_rejected() {
use std::os::unix::fs::symlink;
let dir = unique_tempdir("abs-symlink");
let real = dir.join("real");
std::fs::create_dir(&real).expect("create real");
let link = dir.join("link");
symlink(&real, &link).expect("create symlink");
let candidate = link.join("store");
let res = HsmConfig::validate_path_safety(&candidate, "storage_path");
assert!(
res.is_err(),
"expected symlink-containing path to be rejected: {:?}",
res
);
let errs = res.unwrap_err();
assert!(
errs.iter().any(|e| e.contains("symlink")),
"expected a symlink-related error, got: {:?}",
errs
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn relative_path_is_still_accepted() {
let res =
HsmConfig::validate_path_safety(Path::new(RELATIVE_STORAGE_FALLBACK), "storage_path");
assert!(res.is_ok(), "expected relative default to pass: {:?}", res);
}
#[test]
fn parent_dir_component_is_rejected() {
let res = HsmConfig::validate_path_safety(Path::new("../../etc/shadow"), "storage_path");
assert!(res.is_err(), "expected '..' traversal to be rejected");
let errs = res.unwrap_err();
assert!(
errs.iter().any(|e| e.contains("'..'")),
"expected a traversal-related error, got: {:?}",
errs
);
}
#[cfg(unix)]
#[test]
fn absolute_path_with_parent_dir_is_rejected() {
let res = HsmConfig::validate_path_safety(
Path::new("/var/lib/craton-hsm/../passwd"),
"storage_path",
);
assert!(
res.is_err(),
"expected absolute '..' traversal to be rejected"
);
}
#[test]
fn canonicalize_deepest_existing_handles_missing_leaf() {
let dir = unique_tempdir("canon");
let missing = dir.join("nope").join("deeper");
let canon = canonicalize_deepest_existing(&missing);
assert!(canon.ends_with("nope/deeper") || canon.ends_with("nope\\deeper"));
let _ = std::fs::remove_dir_all(&dir);
}
}