#![forbid(unsafe_code)]
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::error::{HsmError, HsmResult};
use crate::pkcs11_abi::types::CK_SLOT_ID;
use crate::store::encrypted_store::set_restrictive_permissions;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TokenStateData {
pub initialized: bool,
pub user_pin_initialized: bool,
pub label_hex: String,
pub so_pin_hash_hex: Option<String>,
pub user_pin_hash_hex: Option<String>,
pub object_key_salt_hex: Option<String>,
pub object_master_key_wrapped_hex: Option<String>,
}
pub struct RestoredTokenState {
pub initialized: bool,
pub user_pin_initialized: bool,
pub label: Option<[u8; 32]>,
pub so_pin_hash: Option<Zeroizing<Vec<u8>>>,
pub user_pin_hash: Option<Zeroizing<Vec<u8>>>,
pub object_key_salt: Option<Vec<u8>>,
pub object_master_key_wrapped: Option<Vec<u8>>,
}
pub struct TokenStateStore {
path: PathBuf,
}
impl TokenStateStore {
pub fn new(storage_dir: &std::path::Path, slot_id: CK_SLOT_ID) -> Self {
Self {
path: storage_dir.join(format!("token_state_{}.json", slot_id)),
}
}
pub fn load(&self) -> Option<RestoredTokenState> {
let content = match std::fs::read_to_string(&self.path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::debug!("no token state file found, starting fresh");
return None;
}
Err(e) => {
tracing::error!(
"cannot read token state file ({}) — treating token as uninitialized",
e
);
return None;
}
};
let data: TokenStateData = match serde_json::from_str(&content) {
Ok(d) => d,
Err(e) => {
tracing::error!(
"token state file is corrupted ({}) — treating token as uninitialized",
e
);
return None;
}
};
let decode_hash = |field: Option<String>| -> Option<Zeroizing<Vec<u8>>> {
field.and_then(|h| hex::decode(h).ok()).map(Zeroizing::new)
};
let so_pin_hash = decode_hash(data.so_pin_hash_hex);
let user_pin_hash = decode_hash(data.user_pin_hash_hex);
let initialized = data.initialized && so_pin_hash.is_some();
if data.initialized && so_pin_hash.is_none() {
tracing::error!(
"token state claims initialized but has no SO PIN hash — \
treating as uninitialized"
);
}
let user_pin_initialized = data.user_pin_initialized && user_pin_hash.is_some();
let label = match hex::decode(&data.label_hex) {
Ok(bytes) if bytes.len() == 32 => {
let mut l = [0u8; 32];
l.copy_from_slice(&bytes);
Some(l)
}
_ => None,
};
let object_key_salt = data
.object_key_salt_hex
.and_then(|h| hex::decode(h).ok())
.filter(|s| s.len() == 32);
let object_master_key_wrapped = data
.object_master_key_wrapped_hex
.and_then(|h| hex::decode(h).ok());
tracing::info!(
"loaded token state: initialized={}, user_pin_initialized={}",
initialized,
user_pin_initialized,
);
Some(RestoredTokenState {
initialized,
user_pin_initialized,
label,
so_pin_hash,
user_pin_hash,
object_key_salt,
object_master_key_wrapped,
})
}
pub fn save(&self, data: &TokenStateData) -> HsmResult<()> {
let json = serde_json::to_string_pretty(data).map_err(|e| {
tracing::error!("failed to serialize token state: {}", e);
HsmError::GeneralError
})?;
if let Some(parent) = self.path.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
std::fs::create_dir_all(parent).map_err(|e| {
tracing::error!("failed to create token 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 token 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 token 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 token 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 token state file: {}", e);
let _ = std::fs::remove_file(&tmp_path);
return Err(HsmError::GeneralError);
}
set_restrictive_permissions(&self.path)?;
Ok(())
}
#[allow(dead_code)]
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 token state file: {}", e);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmpdir(tag: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir().join(format!(
"craton-tss-{}-{}-{}",
tag,
std::process::id(),
nanos
));
std::fs::create_dir_all(&dir).expect("create tempdir");
dir
}
#[test]
fn load_missing_file_returns_none() {
let dir = tmpdir("missing");
let store = TokenStateStore::new(&dir, 0);
assert!(store.load().is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_then_load_round_trips() {
let dir = tmpdir("roundtrip");
let store = TokenStateStore::new(&dir, 0);
let so_hash = vec![0xABu8; 64];
let user_hash = vec![0xCDu8; 64];
let salt = vec![0x11u8; 32];
let wrapped = vec![0x22u8; 60];
let mut label = [b' '; 32];
label[..5].copy_from_slice(b"hello");
store
.save(&TokenStateData {
initialized: true,
user_pin_initialized: true,
label_hex: hex::encode(label),
so_pin_hash_hex: Some(hex::encode(&so_hash)),
user_pin_hash_hex: Some(hex::encode(&user_hash)),
object_key_salt_hex: Some(hex::encode(&salt)),
object_master_key_wrapped_hex: Some(hex::encode(&wrapped)),
})
.expect("save must succeed");
let restored = store.load().expect("must load");
assert!(restored.initialized);
assert!(restored.user_pin_initialized);
assert_eq!(restored.label, Some(label));
assert_eq!(restored.so_pin_hash.as_deref(), Some(&so_hash));
assert_eq!(restored.user_pin_hash.as_deref(), Some(&user_hash));
assert_eq!(restored.object_key_salt, Some(salt));
assert_eq!(restored.object_master_key_wrapped, Some(wrapped));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn initialized_without_so_hash_is_downgraded() {
let dir = tmpdir("inconsistent");
let store = TokenStateStore::new(&dir, 0);
store
.save(&TokenStateData {
initialized: true,
user_pin_initialized: false,
label_hex: hex::encode([b' '; 32]),
so_pin_hash_hex: None,
user_pin_hash_hex: None,
object_key_salt_hex: None,
object_master_key_wrapped_hex: None,
})
.expect("save");
let restored = store.load().expect("load");
assert!(
!restored.initialized,
"initialized must be downgraded when SO hash is absent"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn corrupt_file_loads_as_none() {
let dir = tmpdir("corrupt");
let store = TokenStateStore::new(&dir, 0);
std::fs::write(dir.join("token_state_0.json"), b"{not valid json").unwrap();
assert!(store.load().is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn save_writes_owner_only_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tmpdir("perms");
let store = TokenStateStore::new(&dir, 0);
store
.save(&TokenStateData::default())
.expect("save must succeed");
let meta = std::fs::metadata(dir.join("token_state_0.json")).expect("stat");
assert_eq!(
meta.permissions().mode() & 0o077,
0,
"token state file must be owner-only (0o600)"
);
let _ = std::fs::remove_dir_all(&dir);
}
}