use std::collections::HashSet;
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use rand::rngs::OsRng;
use rand::RngCore;
use zeroize::{Zeroize, Zeroizing};
use crate::error::{HsmError, HsmResult};
use crate::store::encrypted_store::derive_key_from_pin;
use crate::store::object::StoredObject;
use crate::store::object_codec;
const BACKUP_MAGIC: &[u8; 4] = b"RHBK";
const BACKUP_VERSION: u32 = 2;
const SALT_LEN: usize = 32;
const NONCE_LEN: usize = 12;
const HEADER_LEN: usize = 4 + 4 + SALT_LEN + NONCE_LEN; const MIN_PASSPHRASE_LEN: usize = 16;
struct BackupPayload {
version: u32,
created: String,
created_epoch: u64,
backup_id: String,
token_serial: String,
object_count: usize,
objects: Vec<StoredObject>,
}
const DEFAULT_MAX_BACKUP_AGE_SECS: u64 = 30 * 24 * 3600;
const MAX_BACKUP_OBJECTS: usize = 10_000;
const MAX_METADATA_BYTES: usize = 4096;
fn encode_payload(payload: &BackupPayload) -> HsmResult<Zeroizing<Vec<u8>>> {
if payload.objects.len() > MAX_BACKUP_OBJECTS || payload.object_count != payload.objects.len() {
return Err(HsmError::DataLenRange);
}
let mut out = Zeroizing::new(Vec::with_capacity(512));
put_u32(&mut out, payload.version);
put_string(&mut out, &payload.created)?;
put_u64(&mut out, payload.created_epoch);
put_string(&mut out, &payload.backup_id)?;
put_string(&mut out, &payload.token_serial)?;
put_u32(&mut out, payload.object_count as u32);
for object in &payload.objects {
let encoded = object_codec::encode(object)?;
if encoded.len() > u32::MAX as usize {
return Err(HsmError::DataLenRange);
}
put_u32(&mut out, encoded.len() as u32);
out.extend_from_slice(&encoded);
}
Ok(out)
}
fn decode_payload(data: &[u8]) -> HsmResult<BackupPayload> {
let mut reader = PayloadReader::new(data);
let version = reader.take_u32()?;
if version != BACKUP_VERSION {
return Err(HsmError::DataInvalid);
}
let created = reader.take_string()?;
let created_epoch = reader.take_u64()?;
let backup_id = reader.take_string()?;
let token_serial = reader.take_string()?;
let object_count = reader.take_u32()? as usize;
if object_count > MAX_BACKUP_OBJECTS {
return Err(HsmError::DataLenRange);
}
let mut objects = Vec::with_capacity(object_count);
for _ in 0..object_count {
let length = reader.take_u32()? as usize;
let object_data = reader.take_exact(length)?;
objects.push(object_codec::decode(object_data)?);
}
if !reader.is_empty() {
return Err(HsmError::DataInvalid);
}
Ok(BackupPayload {
version,
created,
created_epoch,
backup_id,
token_serial,
object_count,
objects,
})
}
fn put_u32(out: &mut Vec<u8>, value: u32) {
out.extend_from_slice(&value.to_le_bytes());
}
fn put_u64(out: &mut Vec<u8>, value: u64) {
out.extend_from_slice(&value.to_le_bytes());
}
fn put_string(out: &mut Vec<u8>, value: &str) -> HsmResult<()> {
if value.len() > MAX_METADATA_BYTES {
return Err(HsmError::DataLenRange);
}
put_u32(out, value.len() as u32);
out.extend_from_slice(value.as_bytes());
Ok(())
}
struct PayloadReader<'a> {
input: &'a [u8],
offset: usize,
}
impl<'a> PayloadReader<'a> {
fn new(input: &'a [u8]) -> Self {
Self { input, offset: 0 }
}
fn is_empty(&self) -> bool {
self.offset == self.input.len()
}
fn take_exact(&mut self, length: usize) -> HsmResult<&'a [u8]> {
let end = self
.offset
.checked_add(length)
.ok_or(HsmError::DataInvalid)?;
let value = self
.input
.get(self.offset..end)
.ok_or(HsmError::DataInvalid)?;
self.offset = end;
Ok(value)
}
fn take_u32(&mut self) -> HsmResult<u32> {
Ok(u32::from_le_bytes(
self.take_exact(4)?
.try_into()
.map_err(|_| HsmError::DataInvalid)?,
))
}
fn take_u64(&mut self) -> HsmResult<u64> {
Ok(u64::from_le_bytes(
self.take_exact(8)?
.try_into()
.map_err(|_| HsmError::DataInvalid)?,
))
}
fn take_string(&mut self) -> HsmResult<String> {
let length = self.take_u32()? as usize;
if length > MAX_METADATA_BYTES {
return Err(HsmError::DataLenRange);
}
let bytes = self.take_exact(length)?;
String::from_utf8(bytes.to_vec()).map_err(|_| HsmError::DataInvalid)
}
}
pub fn create_backup(
objects: &[StoredObject],
passphrase: &str,
token_serial: &str,
pbkdf2_iterations: Option<u32>,
) -> HsmResult<Vec<u8>> {
if passphrase.chars().count() < MIN_PASSPHRASE_LEN {
return Err(HsmError::PinLenRange);
}
if !check_passphrase_complexity(passphrase) {
return Err(HsmError::PinInvalid);
}
let mut uuid_bytes = [0u8; 16];
OsRng.fill_bytes(&mut uuid_bytes);
let backup_id = hex::encode(uuid_bytes);
let now_epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let payload = BackupPayload {
version: BACKUP_VERSION,
created: chrono_timestamp(),
created_epoch: now_epoch,
backup_id,
token_serial: token_serial.to_string(),
object_count: objects.len(),
objects: objects.to_vec(),
};
let plaintext = encode_payload(&payload)?;
let (key, salt) = derive_key_from_pin(passphrase.as_bytes(), None, pbkdf2_iterations);
let mut nonce_bytes = [0u8; NONCE_LEN];
OsRng.fill_bytes(&mut nonce_bytes);
let aes_key = Key::<Aes256Gcm>::from_slice(key.as_ref());
let cipher = Aes256Gcm::new(aes_key);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext.as_ref())
.map_err(|_| HsmError::GeneralError)?;
let mut output = Vec::with_capacity(HEADER_LEN + ciphertext.len());
output.extend_from_slice(BACKUP_MAGIC);
output.extend_from_slice(&BACKUP_VERSION.to_le_bytes());
output.extend_from_slice(&salt);
output.extend_from_slice(&nonce_bytes);
output.extend_from_slice(&ciphertext);
Ok(output)
}
pub fn restore_backup(
data: &[u8],
passphrase: &str,
expected_serial: &str,
max_age_secs: Option<u64>,
pbkdf2_iterations: Option<u32>,
mut consumed_ids: Option<&mut HashSet<String>>,
) -> HsmResult<Vec<StoredObject>> {
if data.len() < HEADER_LEN {
return Err(HsmError::DataInvalid);
}
if &data[0..4] != BACKUP_MAGIC {
return Err(HsmError::DataInvalid);
}
let version = u32::from_le_bytes(data[4..8].try_into().map_err(|_| HsmError::DataInvalid)?);
if version != BACKUP_VERSION {
return Err(HsmError::DataInvalid);
}
let salt = &data[8..8 + SALT_LEN];
let nonce_bytes = &data[8 + SALT_LEN..HEADER_LEN];
let ciphertext = &data[HEADER_LEN..];
let (key, _) = derive_key_from_pin(passphrase.as_bytes(), Some(salt), pbkdf2_iterations);
let aes_key = Key::<Aes256Gcm>::from_slice(key.as_ref());
let cipher = Aes256Gcm::new(aes_key);
let nonce = Nonce::from_slice(nonce_bytes);
let mut plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|_| HsmError::PinIncorrect)?;
let payload = decode_payload(&plaintext)?;
plaintext.zeroize();
if payload.object_count != payload.objects.len() {
tracing::warn!(
"Backup restore rejected: object_count mismatch (header={}, actual={})",
payload.object_count,
payload.objects.len()
);
return Err(HsmError::DataInvalid);
}
if payload.token_serial != expected_serial {
tracing::warn!(
"Backup restore rejected: token serial mismatch (backup={}, expected={})",
payload.token_serial,
expected_serial
);
return Err(HsmError::DataInvalid);
}
match consumed_ids {
Some(ref mut ids) => {
if ids.contains(&payload.backup_id) {
tracing::warn!(
"Backup restore rejected: backup_id {} has already been consumed (replay attack?)",
payload.backup_id
);
return Err(HsmError::DataInvalid);
}
}
None => {
tracing::warn!(
"Backup replay protection disabled (consumed_ids=None). \
This is unsafe for production — duplicate key imports are possible."
);
}
}
let max_age = match max_age_secs {
Some(0) => None, Some(age) => Some(age),
None => Some(DEFAULT_MAX_BACKUP_AGE_SECS),
};
if let Some(max) = max_age {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if payload.created_epoch > now + 300 {
tracing::warn!("Backup restore rejected: timestamp is in the future");
return Err(HsmError::DataInvalid);
}
if now.saturating_sub(payload.created_epoch) > max {
tracing::warn!(
"Backup restore rejected: backup is {} seconds old (max allowed: {})",
now - payload.created_epoch,
max
);
return Err(HsmError::DataInvalid);
}
}
tracing::info!(
"Backup restored: id={}, serial={}, objects={}",
payload.backup_id,
payload.token_serial,
payload.object_count
);
if let Some(ids) = consumed_ids {
ids.insert(payload.backup_id);
}
Ok(payload.objects)
}
pub struct PersistentReplayGuard {
ids: HashSet<String>,
path: Option<std::path::PathBuf>,
}
impl PersistentReplayGuard {
pub fn new(path: std::path::PathBuf) -> Self {
let mut ids = HashSet::new();
match std::fs::read_to_string(&path) {
Ok(content) => {
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() {
ids.insert(trimmed.to_string());
}
}
tracing::info!(
"Loaded {} consumed backup IDs from {}",
ids.len(),
path.display()
);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
}
Err(e) => {
tracing::warn!(
"Could not read replay guard file {}: {} — starting empty",
path.display(),
e
);
}
}
Self {
ids,
path: Some(path),
}
}
pub fn in_memory() -> Self {
Self {
ids: HashSet::new(),
path: None,
}
}
pub fn is_consumed(&self, id: &str) -> bool {
self.ids.contains(id)
}
pub fn record(&mut self, id: String) -> HsmResult<()> {
if let Some(ref path) = self.path {
use fs2::FileExt;
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| {
tracing::error!(
"Failed to open replay guard file {} for append: {}",
path.display(),
e
);
HsmError::GeneralError
})?;
crate::store::encrypted_store::set_restrictive_permissions(path)?;
file.lock_exclusive().map_err(|e| {
tracing::error!("Failed to lock replay guard file {}: {}", path.display(), e);
HsmError::GeneralError
})?;
writeln!(file, "{}", id).map_err(|e| {
tracing::error!(
"Failed to write to replay guard file {}: {}",
path.display(),
e
);
HsmError::GeneralError
})?;
}
self.ids.insert(id);
Ok(())
}
pub fn consumed_ids(&self) -> &HashSet<String> {
&self.ids
}
pub fn consumed_ids_clone(&self) -> HashSet<String> {
self.ids.clone()
}
}
fn check_passphrase_complexity(passphrase: &str) -> bool {
let mut has_lower = false;
let mut has_upper = false;
let mut has_digit = false;
let mut has_symbol = false;
let mut unique_chars = std::collections::HashSet::new();
for ch in passphrase.chars() {
unique_chars.insert(ch);
if ch.is_ascii_lowercase() {
has_lower = true;
} else if ch.is_ascii_uppercase() {
has_upper = true;
} else if ch.is_ascii_digit() {
has_digit = true;
} else {
has_symbol = true;
}
}
let class_count = has_lower as u8 + has_upper as u8 + has_digit as u8 + has_symbol as u8;
let char_count = passphrase.chars().count();
let unique_count = unique_chars.len();
let policy_a = class_count >= 3 && unique_count >= 6;
let policy_b = char_count >= 24 && unique_count >= 12;
policy_a || policy_b
}
fn chrono_timestamp() -> String {
use std::time::SystemTime;
let duration = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
format!("{}s-since-epoch", duration.as_secs())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::object::StoredObject;
fn make_test_object(
handle: crate::pkcs11_abi::types::CK_OBJECT_HANDLE,
label: &str,
) -> StoredObject {
let mut obj = StoredObject::new(handle, 3); obj.label = label.as_bytes().to_vec();
obj.token_object = true;
obj
}
const TEST_SERIAL: &str = "0000000000000001";
#[test]
fn round_trip_backup_restore() {
let objects = vec![
make_test_object(1, "key1"),
make_test_object(2, "key2"),
make_test_object(3, "key3"),
];
let backup = create_backup(&objects, "Test-P@ssphr4se-Long", TEST_SERIAL, None).unwrap();
let restored = restore_backup(
&backup,
"Test-P@ssphr4se-Long",
TEST_SERIAL,
Some(0),
None,
None,
)
.unwrap();
assert_eq!(restored.len(), 3);
assert_eq!(restored[0].label, b"key1");
assert_eq!(restored[1].label, b"key2");
assert_eq!(restored[2].label, b"key3");
}
#[test]
fn wrong_passphrase_fails() {
let objects = vec![make_test_object(1, "key1")];
let backup = create_backup(&objects, "C0rrect-P@ssphrase", TEST_SERIAL, None).unwrap();
let result = restore_backup(
&backup,
"Wr0ng-P@ssphrase",
TEST_SERIAL,
Some(0),
None,
None,
);
assert!(result.is_err());
}
#[test]
fn empty_backup_is_valid() {
let backup = create_backup(&[], "L0ng-P@ssphrase!", TEST_SERIAL, None).unwrap();
let restored = restore_backup(
&backup,
"L0ng-P@ssphrase!",
TEST_SERIAL,
Some(0),
None,
None,
)
.unwrap();
assert!(restored.is_empty());
}
#[test]
fn truncated_blob_fails() {
let result = restore_backup(&[0u8; 10], "pass", TEST_SERIAL, Some(0), None, None);
assert!(result.is_err());
}
#[test]
fn invalid_magic_fails() {
let mut backup = create_backup(&[], "L0ng-P@ssphrase!", TEST_SERIAL, None).unwrap();
backup[0] = b'X'; let result = restore_backup(&backup, "pass", TEST_SERIAL, Some(0), None, None);
assert!(result.is_err());
}
#[test]
fn wrong_serial_rejected() {
let backup = create_backup(&[], "L0ng-P@ssphrase!", "serial-A0000000", None).unwrap();
let result = restore_backup(
&backup,
"L0ng-P@ssphrase!",
"serial-B0000000",
Some(0),
None,
None,
);
assert!(result.is_err(), "Cross-token restore should be rejected");
}
#[test]
fn replay_protection() {
let backup = create_backup(&[], "L0ng-P@ssphrase!", TEST_SERIAL, None).unwrap();
let mut consumed = HashSet::new();
let result = restore_backup(
&backup,
"L0ng-P@ssphrase!",
TEST_SERIAL,
Some(0),
None,
Some(&mut consumed),
);
assert!(result.is_ok(), "First restore should succeed");
assert_eq!(consumed.len(), 1);
let result = restore_backup(
&backup,
"L0ng-P@ssphrase!",
TEST_SERIAL,
Some(0),
None,
Some(&mut consumed),
);
assert!(result.is_err(), "Replay of same backup should be rejected");
}
}