use crate::types::error::{Result, TypeError};
use serde::{Deserialize, Serialize};
const MAX_AUDIT_FIELD_LEN: usize = 512;
const MAX_STATE_HISTORY: usize = 1024;
const MAX_APPROVERS: usize = 256;
fn validate_audit_field(name: &str, value: &str) -> Result<()> {
if value.is_empty() {
return Err(TypeError::InvalidAuditInput(format!("{} must not be empty", name)));
}
if value.len() > MAX_AUDIT_FIELD_LEN {
return Err(TypeError::InvalidAuditInput(format!(
"{} exceeds {} bytes",
name, MAX_AUDIT_FIELD_LEN
)));
}
if value.chars().any(char::is_control) {
return Err(TypeError::InvalidAuditInput(format!("{} contains control characters", name)));
}
Ok(())
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(kani, derive(kani::Arbitrary))]
pub enum KeyLifecycleState {
Generation,
Active,
Rotating,
Retired,
Destroyed,
}
pub struct KeyStateMachine;
impl KeyStateMachine {
#[must_use]
pub fn is_valid_transition(from: Option<KeyLifecycleState>, to: KeyLifecycleState) -> bool {
match (from, to) {
(None, KeyLifecycleState::Generation) => true,
(Some(KeyLifecycleState::Generation), KeyLifecycleState::Active) => true,
(Some(KeyLifecycleState::Generation), KeyLifecycleState::Destroyed) => true,
(Some(KeyLifecycleState::Active), KeyLifecycleState::Rotating) => true,
(Some(KeyLifecycleState::Rotating), KeyLifecycleState::Retired) => true,
(Some(KeyLifecycleState::Active), KeyLifecycleState::Retired) => true,
(Some(KeyLifecycleState::Retired), KeyLifecycleState::Destroyed) => true,
_ => false,
}
}
#[must_use]
pub fn allowed_next_states(current: KeyLifecycleState) -> Vec<KeyLifecycleState> {
match current {
KeyLifecycleState::Generation => {
vec![KeyLifecycleState::Active, KeyLifecycleState::Destroyed]
}
KeyLifecycleState::Active => {
vec![KeyLifecycleState::Rotating, KeyLifecycleState::Retired]
}
KeyLifecycleState::Rotating => vec![KeyLifecycleState::Retired],
KeyLifecycleState::Retired => vec![KeyLifecycleState::Destroyed],
KeyLifecycleState::Destroyed => vec![],
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyCustodian {
custodian_id: String,
name: String,
role: CustodianRole,
responsibilities: Vec<String>,
approved_until: chrono::DateTime<chrono::Utc>,
}
impl KeyCustodian {
#[must_use]
pub fn new(
custodian_id: String,
name: String,
role: CustodianRole,
responsibilities: Vec<String>,
approved_until: chrono::DateTime<chrono::Utc>,
) -> Self {
Self { custodian_id, name, role, responsibilities, approved_until }
}
#[must_use]
pub fn custodian_id(&self) -> &str {
&self.custodian_id
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn role(&self) -> CustodianRole {
self.role
}
#[must_use]
pub fn responsibilities(&self) -> &[String] {
&self.responsibilities
}
#[must_use]
pub fn approved_until(&self) -> chrono::DateTime<chrono::Utc> {
self.approved_until
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CustodianRole {
KeyGenerator,
KeyApprover,
KeyDestroyer,
KeyAuditor,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(try_from = "KeyLifecycleRecordRaw")]
pub struct KeyLifecycleRecord {
key_id: String,
key_type: String,
security_level: u32,
current_state: KeyLifecycleState,
state_history: Vec<StateTransition>,
generator: Option<String>,
approvers: Vec<String>,
destroyer: Option<String>,
generated_at: chrono::DateTime<chrono::Utc>,
activated_at: Option<chrono::DateTime<chrono::Utc>>,
rotated_at: Option<chrono::DateTime<chrono::Utc>>,
retired_at: Option<chrono::DateTime<chrono::Utc>>,
destroyed_at: Option<chrono::DateTime<chrono::Utc>>,
rotation_interval_days: u32,
overlap_period_days: u32,
}
#[derive(Debug, Clone, Deserialize)]
#[doc(hidden)]
struct KeyLifecycleRecordRaw {
key_id: String,
key_type: String,
security_level: u32,
current_state: KeyLifecycleState,
state_history: Vec<StateTransition>,
generator: Option<String>,
approvers: Vec<String>,
destroyer: Option<String>,
generated_at: chrono::DateTime<chrono::Utc>,
activated_at: Option<chrono::DateTime<chrono::Utc>>,
rotated_at: Option<chrono::DateTime<chrono::Utc>>,
retired_at: Option<chrono::DateTime<chrono::Utc>>,
destroyed_at: Option<chrono::DateTime<chrono::Utc>>,
rotation_interval_days: u32,
overlap_period_days: u32,
}
impl TryFrom<KeyLifecycleRecordRaw> for KeyLifecycleRecord {
type Error = TypeError;
fn try_from(raw: KeyLifecycleRecordRaw) -> Result<Self> {
if raw.state_history.len() > MAX_STATE_HISTORY {
return Err(TypeError::InvalidAuditInput(format!(
"state_history length {} exceeds cap of {}",
raw.state_history.len(),
MAX_STATE_HISTORY
)));
}
if raw.approvers.len() > MAX_APPROVERS {
return Err(TypeError::InvalidAuditInput(format!(
"approvers length {} exceeds cap of {}",
raw.approvers.len(),
MAX_APPROVERS
)));
}
match (raw.current_state, raw.activated_at) {
(KeyLifecycleState::Generation, Some(_)) => {
return Err(TypeError::InvalidAuditInput(
"current_state=Generation with activated_at set".to_string(),
));
}
(
KeyLifecycleState::Active
| KeyLifecycleState::Rotating
| KeyLifecycleState::Retired,
None,
) => {
return Err(TypeError::InvalidAuditInput(format!(
"current_state={:?} requires activated_at",
raw.current_state
)));
}
_ => {}
}
match (raw.current_state, raw.state_history.last()) {
(KeyLifecycleState::Generation, _) => {}
(_, None) => {
return Err(TypeError::InvalidAuditInput(format!(
"current_state={:?} with empty state_history",
raw.current_state
)));
}
(current, Some(t)) if t.to_state != current => {
return Err(TypeError::InvalidAuditInput(format!(
"current_state={:?} but last state_history entry is {:?}",
current, t.to_state
)));
}
_ => {}
}
let entered = |st: KeyLifecycleState| raw.state_history.iter().any(|t| t.to_state == st);
if entered(KeyLifecycleState::Active) != raw.activated_at.is_some() {
return Err(TypeError::InvalidAuditInput(
"activated_at presence disagrees with state_history".to_string(),
));
}
if entered(KeyLifecycleState::Rotating) != raw.rotated_at.is_some() {
return Err(TypeError::InvalidAuditInput(
"rotated_at presence disagrees with state_history".to_string(),
));
}
if entered(KeyLifecycleState::Retired) != raw.retired_at.is_some() {
return Err(TypeError::InvalidAuditInput(
"retired_at presence disagrees with state_history".to_string(),
));
}
if entered(KeyLifecycleState::Destroyed) != raw.destroyed_at.is_some() {
return Err(TypeError::InvalidAuditInput(
"destroyed_at presence disagrees with state_history".to_string(),
));
}
for pair in raw.state_history.windows(2) {
let [prev, curr] = pair else { continue };
if curr.timestamp < prev.timestamp {
return Err(TypeError::InvalidAuditInput(format!(
"state_history timestamps decrease between {:?} ({}) and {:?} ({})",
prev.to_state, prev.timestamp, curr.to_state, curr.timestamp
)));
}
}
let mut seen: std::collections::HashSet<&String> =
std::collections::HashSet::with_capacity(raw.approvers.len());
for approver in &raw.approvers {
if !seen.insert(approver) {
return Err(TypeError::InvalidAuditInput(format!(
"approvers contains duplicate id {:?}",
approver
)));
}
}
for (idx, t) in raw.state_history.iter().enumerate() {
validate_audit_field(&format!("state_history[{idx}].custodian_id"), &t.custodian_id)?;
validate_audit_field(&format!("state_history[{idx}].justification"), &t.justification)?;
if let Some(ref approval_id) = t.approval_id {
validate_audit_field(&format!("state_history[{idx}].approval_id"), approval_id)?;
}
}
type TsPair = (
&'static str,
Option<chrono::DateTime<chrono::Utc>>,
&'static str,
Option<chrono::DateTime<chrono::Utc>>,
);
let pairs: [TsPair; 5] = [
("generated_at", Some(raw.generated_at), "activated_at", raw.activated_at),
("activated_at", raw.activated_at, "rotated_at", raw.rotated_at),
("activated_at", raw.activated_at, "retired_at", raw.retired_at),
("rotated_at", raw.rotated_at, "retired_at", raw.retired_at),
("retired_at", raw.retired_at, "destroyed_at", raw.destroyed_at),
];
for (earlier_name, earlier, later_name, later) in pairs {
if let (Some(e), Some(l)) = (earlier, later)
&& l < e
{
return Err(TypeError::InvalidAuditInput(format!(
"{later_name} ({l}) precedes {earlier_name} ({e}) — state-machine ordering violated"
)));
}
}
validate_security_level(raw.security_level)?;
validate_rotation_interval(raw.rotation_interval_days)?;
Ok(KeyLifecycleRecord {
key_id: raw.key_id,
key_type: raw.key_type,
security_level: raw.security_level,
current_state: raw.current_state,
state_history: raw.state_history,
generator: raw.generator,
approvers: raw.approvers,
destroyer: raw.destroyer,
generated_at: raw.generated_at,
activated_at: raw.activated_at,
rotated_at: raw.rotated_at,
retired_at: raw.retired_at,
destroyed_at: raw.destroyed_at,
rotation_interval_days: raw.rotation_interval_days,
overlap_period_days: raw.overlap_period_days,
})
}
}
fn validate_security_level(security_level: u32) -> Result<()> {
if !(1..=5).contains(&security_level) {
return Err(TypeError::InvalidAuditInput(format!(
"security_level {} outside documented range 1-5",
security_level
)));
}
Ok(())
}
fn validate_rotation_interval(rotation_interval_days: u32) -> Result<()> {
if rotation_interval_days == 0 {
return Err(TypeError::InvalidAuditInput(format!(
"rotation_interval_days {} is zero; would force immediate rotation on activation",
rotation_interval_days
)));
}
Ok(())
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateTransition {
from_state: Option<KeyLifecycleState>,
to_state: KeyLifecycleState,
timestamp: chrono::DateTime<chrono::Utc>,
custodian_id: String,
justification: String,
approval_id: Option<String>,
}
impl StateTransition {
#[must_use]
pub fn new(
from_state: Option<KeyLifecycleState>,
to_state: KeyLifecycleState,
timestamp: chrono::DateTime<chrono::Utc>,
custodian_id: String,
justification: String,
approval_id: Option<String>,
) -> Self {
Self { from_state, to_state, timestamp, custodian_id, justification, approval_id }
}
#[must_use]
pub fn from_state(&self) -> Option<KeyLifecycleState> {
self.from_state
}
#[must_use]
pub fn to_state(&self) -> KeyLifecycleState {
self.to_state
}
#[must_use]
pub fn timestamp(&self) -> chrono::DateTime<chrono::Utc> {
self.timestamp
}
#[must_use]
pub fn custodian_id(&self) -> &str {
&self.custodian_id
}
#[must_use]
pub fn justification(&self) -> &str {
&self.justification
}
#[must_use]
pub fn approval_id(&self) -> Option<&str> {
self.approval_id.as_deref()
}
}
impl KeyLifecycleRecord {
pub fn new(
key_id: String,
key_type: String,
security_level: u32,
rotation_interval_days: u32,
overlap_period_days: u32,
) -> Result<Self> {
validate_security_level(security_level)?;
validate_rotation_interval(rotation_interval_days)?;
Ok(Self {
key_id,
key_type,
security_level,
current_state: KeyLifecycleState::Generation,
state_history: Vec::new(),
generator: None,
approvers: Vec::new(),
destroyer: None,
generated_at: chrono::Utc::now(),
activated_at: None,
rotated_at: None,
retired_at: None,
destroyed_at: None,
rotation_interval_days,
overlap_period_days,
})
}
pub fn transition(
&mut self,
to_state: KeyLifecycleState,
custodian_id: String,
justification: String,
approval_id: Option<String>,
) -> Result<()> {
validate_audit_field("custodian_id", &custodian_id)?;
validate_audit_field("justification", &justification)?;
if let Some(ref approval_id) = approval_id {
validate_audit_field("approval_id", approval_id)?;
}
if !KeyStateMachine::is_valid_transition(Some(self.current_state), to_state) {
return Err(TypeError::InvalidStateTransition {
from: self.current_state,
to: to_state,
});
}
let now = chrono::Utc::now();
let transition = StateTransition::new(
Some(self.current_state),
to_state,
now,
custodian_id.clone(),
justification,
approval_id,
);
if self.state_history.len() >= MAX_STATE_HISTORY {
return Err(TypeError::InvalidAuditInput(format!(
"state_history at cap of {} entries; cannot record further transitions",
MAX_STATE_HISTORY
)));
}
self.state_history.push(transition);
self.current_state = to_state;
match to_state {
KeyLifecycleState::Active => self.activated_at = Some(now),
KeyLifecycleState::Rotating => self.rotated_at = Some(now),
KeyLifecycleState::Retired => self.retired_at = Some(now),
KeyLifecycleState::Destroyed => self.destroyed_at = Some(now),
_ => {}
}
if let Some(last_transition) = self.state_history.last()
&& last_transition.from_state == Some(KeyLifecycleState::Generation)
&& to_state == KeyLifecycleState::Active
{
self.generator = Some(custodian_id.clone());
}
if to_state == KeyLifecycleState::Destroyed {
self.destroyer = Some(custodian_id);
}
Ok(())
}
fn days_since_activation(activated: chrono::DateTime<chrono::Utc>) -> i64 {
chrono::Utc::now().signed_duration_since(activated).num_days()
}
#[must_use]
pub fn requires_rotation(&self) -> bool {
let Some(activated_at) = self.activated_at else { return false };
let age_days_i64 = Self::days_since_activation(activated_at);
if age_days_i64 < 0 {
tracing::warn!(
"Key has negative age ({age_days_i64} days); activation_at in the future"
);
return false;
}
let age_days = u32::try_from(age_days_i64).unwrap_or(u32::MAX);
age_days >= self.rotation_interval_days
}
#[must_use]
pub fn age_days(&self) -> Option<u32> {
self.activated_at.map(|activated| {
u32::try_from(Self::days_since_activation(activated)).unwrap_or(0)
})
}
#[must_use]
pub fn is_valid_for_use(&self) -> bool {
matches!(self.current_state, KeyLifecycleState::Active | KeyLifecycleState::Rotating)
}
#[must_use = "add_approver returns false when the cap is reached; surface that to the caller"]
pub fn add_approver(&mut self, approver_id: impl Into<String>) -> bool {
let approver_id = approver_id.into();
if self.approvers.contains(&approver_id) {
return true;
}
if self.approvers.len() >= MAX_APPROVERS {
return false;
}
self.approvers.push(approver_id);
true
}
#[must_use]
pub fn key_id(&self) -> &str {
&self.key_id
}
#[must_use]
pub fn key_type(&self) -> &str {
&self.key_type
}
#[must_use]
pub fn security_level(&self) -> u32 {
self.security_level
}
#[must_use]
pub fn generated_at(&self) -> chrono::DateTime<chrono::Utc> {
self.generated_at
}
#[must_use]
pub fn rotation_interval_days(&self) -> u32 {
self.rotation_interval_days
}
#[must_use]
pub fn overlap_period_days(&self) -> u32 {
self.overlap_period_days
}
#[must_use]
pub fn current_state(&self) -> KeyLifecycleState {
self.current_state
}
#[must_use]
pub fn state_history(&self) -> &[StateTransition] {
&self.state_history
}
#[must_use]
pub fn generator(&self) -> Option<&str> {
self.generator.as_deref()
}
#[must_use]
pub fn approvers(&self) -> &[String] {
&self.approvers
}
#[must_use]
pub fn destroyer(&self) -> Option<&str> {
self.destroyer.as_deref()
}
#[must_use]
pub fn activated_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
self.activated_at
}
#[must_use]
pub fn rotated_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
self.rotated_at
}
#[must_use]
pub fn retired_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
self.retired_at
}
#[must_use]
pub fn destroyed_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
self.destroyed_at
}
}
#[cfg(kani)]
mod kani_proofs {
use super::*;
#[kani::proof]
fn key_state_machine_destroyed_cannot_transition() {
let to: KeyLifecycleState = kani::any();
let is_valid = KeyStateMachine::is_valid_transition(Some(KeyLifecycleState::Destroyed), to);
kani::assert(!is_valid, "Destroyed keys should not transition");
}
#[kani::proof]
fn key_state_machine_no_backward_to_generation() {
let from: KeyLifecycleState = kani::any();
kani::assume(from != KeyLifecycleState::Generation);
let is_valid =
KeyStateMachine::is_valid_transition(Some(from), KeyLifecycleState::Generation);
kani::assert(!is_valid, "Cannot transition back to Generation");
}
#[kani::proof]
fn key_state_machine_only_generation_from_none() {
let to: KeyLifecycleState = kani::any();
kani::assume(to != KeyLifecycleState::Generation);
let is_valid = KeyStateMachine::is_valid_transition(None, to);
kani::assert(!is_valid, "Only Generation is valid from initial state (None)");
}
#[kani::proof]
fn key_state_machine_transitions_match_spec() {
let from: KeyLifecycleState = kani::any();
let to: KeyLifecycleState = kani::any();
let transition_valid = KeyStateMachine::is_valid_transition(Some(from), to);
let spec_allows = match from {
KeyLifecycleState::Generation => {
to == KeyLifecycleState::Active || to == KeyLifecycleState::Destroyed
}
KeyLifecycleState::Active => {
to == KeyLifecycleState::Rotating || to == KeyLifecycleState::Retired
}
KeyLifecycleState::Rotating => to == KeyLifecycleState::Retired,
KeyLifecycleState::Retired => to == KeyLifecycleState::Destroyed,
KeyLifecycleState::Destroyed => false,
};
kani::assert(
transition_valid == spec_allows,
"is_valid_transition must match SP 800-57 specification",
);
}
#[kani::proof]
fn key_state_machine_retired_only_to_destroyed() {
let to: KeyLifecycleState = kani::any();
kani::assume(to != KeyLifecycleState::Destroyed);
let is_valid = KeyStateMachine::is_valid_transition(Some(KeyLifecycleState::Retired), to);
kani::assert(!is_valid, "Retired keys can only transition to Destroyed");
}
}
#[cfg(test)]
#[expect(clippy::unwrap_used, reason = "test/bench scaffolding: lints suppressed for this module")]
mod tests {
use super::*;
#[test]
fn test_valid_state_transitions_succeeds() {
assert!(KeyStateMachine::is_valid_transition(
Some(KeyLifecycleState::Generation),
KeyLifecycleState::Active
));
assert!(KeyStateMachine::is_valid_transition(
Some(KeyLifecycleState::Generation),
KeyLifecycleState::Destroyed
));
assert!(KeyStateMachine::is_valid_transition(
Some(KeyLifecycleState::Active),
KeyLifecycleState::Rotating
));
assert!(KeyStateMachine::is_valid_transition(
Some(KeyLifecycleState::Rotating),
KeyLifecycleState::Retired
));
assert!(KeyStateMachine::is_valid_transition(
Some(KeyLifecycleState::Retired),
KeyLifecycleState::Destroyed
));
assert!(KeyStateMachine::is_valid_transition(
Some(KeyLifecycleState::Active),
KeyLifecycleState::Retired
));
}
#[test]
fn test_invalid_state_transitions_fails() {
assert!(!KeyStateMachine::is_valid_transition(
Some(KeyLifecycleState::Active),
KeyLifecycleState::Generation
));
assert!(!KeyStateMachine::is_valid_transition(
Some(KeyLifecycleState::Active),
KeyLifecycleState::Destroyed
));
assert!(!KeyStateMachine::is_valid_transition(
Some(KeyLifecycleState::Destroyed),
KeyLifecycleState::Active
));
}
#[test]
fn test_allowed_next_states_succeeds() {
let from_generation = KeyStateMachine::allowed_next_states(KeyLifecycleState::Generation);
assert_eq!(from_generation.len(), 2);
assert!(from_generation.contains(&KeyLifecycleState::Active));
assert!(from_generation.contains(&KeyLifecycleState::Destroyed));
assert_eq!(
KeyStateMachine::allowed_next_states(KeyLifecycleState::Active),
vec![KeyLifecycleState::Rotating, KeyLifecycleState::Retired]
);
assert_eq!(KeyStateMachine::allowed_next_states(KeyLifecycleState::Destroyed), vec![]);
}
#[test]
fn test_generation_to_destroyed_audit_trail_preserves_truth() {
let mut record = KeyLifecycleRecord::new(
"compromised-pre-activation".to_string(),
"ML-KEM-768".to_string(),
3,
365,
30,
)
.unwrap();
assert_eq!(record.current_state(), KeyLifecycleState::Generation);
record
.transition(
KeyLifecycleState::Destroyed,
"incident-responder".to_string(),
"Pre-activation key compromised; emergency destruction per SP 800-57 §8.3.1"
.to_string(),
Some("incident-2026-05-11".to_string()),
)
.unwrap();
assert_eq!(record.current_state(), KeyLifecycleState::Destroyed);
let history = record.state_history();
assert_eq!(history.len(), 1);
let transition = history.first().unwrap();
assert_eq!(transition.from_state(), Some(KeyLifecycleState::Generation));
assert_eq!(transition.to_state(), KeyLifecycleState::Destroyed);
assert!(history.iter().all(|t| t.to_state() != KeyLifecycleState::Active));
assert!(history.iter().all(|t| t.to_state() != KeyLifecycleState::Retired));
}
#[test]
fn test_new_rejects_security_level_below_range_fails() {
let err = KeyLifecycleRecord::new("k".to_string(), "ML-KEM-768".to_string(), 0, 365, 30)
.unwrap_err();
assert!(matches!(err, TypeError::InvalidAuditInput(_)), "got {:?}", err);
assert!(err.to_string().contains("security_level 0"));
}
#[test]
fn test_new_rejects_security_level_above_range_fails() {
let err = KeyLifecycleRecord::new("k".to_string(), "ML-KEM-768".to_string(), 6, 365, 30)
.unwrap_err();
assert!(matches!(err, TypeError::InvalidAuditInput(_)), "got {:?}", err);
assert!(err.to_string().contains("security_level 6"));
}
#[test]
fn test_new_rejects_zero_rotation_interval_fails() {
let err = KeyLifecycleRecord::new("k".to_string(), "ML-KEM-768".to_string(), 3, 0, 30)
.unwrap_err();
assert!(matches!(err, TypeError::InvalidAuditInput(_)), "got {:?}", err);
assert!(err.to_string().contains("rotation_interval_days 0"));
}
fn build_record_json(security_level: u32, rotation_interval_days: u32) -> String {
format!(
r#"{{"key_id":"k","key_type":"ML-KEM-768","security_level":{security_level},"current_state":"Generation","state_history":[],"generator":null,"approvers":[],"destroyer":null,"generated_at":"2026-01-01T00:00:00Z","activated_at":null,"rotated_at":null,"retired_at":null,"destroyed_at":null,"rotation_interval_days":{rotation_interval_days},"overlap_period_days":30}}"#
)
}
#[test]
fn test_deserialize_rejects_security_level_below_range_fails() {
let json = build_record_json(0, 365);
let err = serde_json::from_str::<KeyLifecycleRecord>(&json).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("security_level 0"), "deserializer should echo rejection: got {msg}");
}
#[test]
fn test_deserialize_rejects_security_level_above_range_fails() {
let json = build_record_json(6, 365);
let err = serde_json::from_str::<KeyLifecycleRecord>(&json).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("security_level 6"), "deserializer should echo rejection: got {msg}");
}
#[test]
fn test_deserialize_rejects_zero_rotation_interval_fails() {
let json = build_record_json(3, 0);
let err = serde_json::from_str::<KeyLifecycleRecord>(&json).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("rotation_interval_days 0"),
"deserializer should echo rejection: got {msg}"
);
}
#[test]
fn test_deserialize_rejects_destroyed_with_fabricated_activated_at() {
let json = r#"{
"key_id":"k","key_type":"ML-KEM-768","security_level":3,
"current_state":"Destroyed",
"state_history":[
{"from_state":null,"to_state":"Generation","timestamp":"2026-01-01T00:00:00Z","custodian_id":"alice","justification":"init","approval_id":null},
{"from_state":"Generation","to_state":"Destroyed","timestamp":"2026-01-02T00:00:00Z","custodian_id":"alice","justification":"abort","approval_id":null}
],
"generator":null,"approvers":[],"destroyer":null,
"generated_at":"2026-01-01T00:00:00Z",
"activated_at":"2026-01-01T12:00:00Z",
"rotated_at":null,"retired_at":null,
"destroyed_at":"2026-01-02T00:00:00Z",
"rotation_interval_days":365,"overlap_period_days":30
}"#;
let err = serde_json::from_str::<KeyLifecycleRecord>(json).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("activated_at presence disagrees with state_history"),
"deserializer should reject fabricated activated_at: got {msg}"
);
}
#[test]
fn test_key_lifecycle_record_succeeds() {
let mut record = KeyLifecycleRecord::new(
"test-key-123".to_string(),
"ML-KEM-768".to_string(),
3,
365,
30,
)
.unwrap();
assert_eq!(record.current_state, KeyLifecycleState::Generation);
assert!(!record.is_valid_for_use());
record
.transition(
KeyLifecycleState::Active,
"alice".to_string(),
"Key generation complete".to_string(),
Some("approval-123".to_string()),
)
.unwrap();
assert_eq!(record.current_state, KeyLifecycleState::Active);
assert!(record.is_valid_for_use());
assert_eq!(record.generator, Some("alice".to_string()));
assert!(record.activated_at.is_some());
assert!(!record.requires_rotation());
}
#[test]
fn test_rotation_requirement_succeeds() {
let mut record = KeyLifecycleRecord::new(
"test-key-123".to_string(),
"AES-256".to_string(),
3,
90, 7,
)
.unwrap();
record
.transition(
KeyLifecycleState::Active,
"alice".to_string(),
"test activation".to_string(),
None,
)
.unwrap();
record.activated_at = Some(chrono::Utc::now() - chrono::Duration::days(100));
assert!(record.requires_rotation());
assert_eq!(record.age_days(), Some(100));
}
#[test]
fn test_transition_validation_succeeds() {
let mut record = KeyLifecycleRecord::new(
"test-key-123".to_string(),
"ML-DSA-65".to_string(),
3,
365,
30,
)
.unwrap();
record
.transition(
KeyLifecycleState::Active,
"alice".to_string(),
"activate".to_string(),
None,
)
.unwrap();
let result = record.transition(
KeyLifecycleState::Generation,
"alice".to_string(),
"Invalid transition: cannot go backwards".to_string(),
None,
);
assert!(matches!(result, Err(TypeError::InvalidStateTransition { .. })));
}
#[test]
fn test_add_approver_succeeds() {
let mut record = KeyLifecycleRecord::new(
"test-key-123".to_string(),
"ML-KEM-768".to_string(),
3,
365,
30,
)
.unwrap();
assert!(record.add_approver("alice".to_string()));
assert!(record.add_approver("bob".to_string()));
assert!(record.add_approver("alice".to_string()));
assert_eq!(record.approvers.len(), 2);
assert!(record.approvers.contains(&"alice".to_string()));
assert!(record.approvers.contains(&"bob".to_string()));
}
}