nixfleet_reconciler/
trust_rotation.rs1use chrono::{DateTime, Utc};
7use nixfleet_proto::trust::{KeySlot, TrustConfig};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct RotateTrustRoot {
15 pub which: String,
16 pub retire_at: DateTime<Utc>,
17}
18
19pub fn check_trust_rotations(trust: &TrustConfig, now: DateTime<Utc>) -> Vec<RotateTrustRoot> {
24 let mut out = Vec::new();
25 if let Some(retire_at) = is_rotation_due(&trust.ci_release_key, now) {
26 out.push(RotateTrustRoot {
27 which: "ciReleaseKey".to_string(),
28 retire_at,
29 });
30 }
31 if let Some(org_root) = trust.org_root_key.as_ref()
32 && let Some(retire_at) = is_rotation_due(org_root, now)
33 {
34 out.push(RotateTrustRoot {
35 which: "orgRootKey".to_string(),
36 retire_at,
37 });
38 }
39 out
40}
41
42fn is_rotation_due(slot: &KeySlot, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
45 let retire_at = slot.retire_at?;
46 slot.successor.as_ref()?;
47 if now < retire_at {
48 return None;
49 }
50 Some(retire_at)
51}
52
53#[cfg(test)]
54mod tests {
55 use super::*;
56 use nixfleet_proto::trust::TrustedPubkey;
57
58 fn key(public: &str) -> TrustedPubkey {
59 TrustedPubkey {
60 algorithm: "ed25519".into(),
61 public: public.into(),
62 }
63 }
64
65 fn slot_with(successor: Option<TrustedPubkey>, retire_at: Option<DateTime<Utc>>) -> KeySlot {
66 KeySlot {
67 current: Some(key("AAAA")),
68 previous: None,
69 reject_before: None,
70 successor,
71 retire_at,
72 }
73 }
74
75 fn trust_with(ci: KeySlot, org: Option<KeySlot>) -> TrustConfig {
76 TrustConfig {
77 schema_version: TrustConfig::CURRENT_SCHEMA_VERSION,
78 ci_release_key: ci,
79 cache_keys: vec![],
80 org_root_key: org,
81 root_ca_pem: None,
82 issuance_ca_pems: vec![],
83 }
84 }
85
86 #[test]
87 fn pre_announce_window_emits_nothing() {
88 let now = Utc::now();
89 let slot = slot_with(Some(key("CCCC")), Some(now + chrono::Duration::days(7)));
90 let actions = check_trust_rotations(&trust_with(slot, None), now);
91 assert!(actions.is_empty());
92 }
93
94 #[test]
95 fn post_retire_with_successor_emits_rotate_for_ci_release_key() {
96 let now = Utc::now();
97 let retire_at = now - chrono::Duration::hours(1);
98 let slot = slot_with(Some(key("CCCC")), Some(retire_at));
99 let actions = check_trust_rotations(&trust_with(slot, None), now);
100 assert_eq!(actions.len(), 1);
101 let RotateTrustRoot {
102 which,
103 retire_at: r,
104 } = &actions[0];
105 assert_eq!(which, "ciReleaseKey");
106 assert_eq!(*r, retire_at);
107 }
108
109 #[test]
110 fn post_retire_without_successor_emits_nothing() {
111 let now = Utc::now();
112 let slot = slot_with(None, Some(now - chrono::Duration::days(1)));
113 let actions = check_trust_rotations(&trust_with(slot, None), now);
114 assert!(actions.is_empty());
115 }
116
117 #[test]
120 fn successor_without_retire_at_emits_nothing() {
121 let now = Utc::now();
122 let slot = slot_with(Some(key("CCCC")), None);
123 let actions = check_trust_rotations(&trust_with(slot, None), now);
124 assert!(actions.is_empty());
125 }
126
127 #[test]
128 fn org_root_key_rotation_also_signaled() {
129 let now = Utc::now();
130 let retire_at = now - chrono::Duration::minutes(30);
131 let ci_slot = slot_with(None, None);
132 let org_slot = slot_with(Some(key("DDDD")), Some(retire_at));
133 let actions = check_trust_rotations(&trust_with(ci_slot, Some(org_slot)), now);
134 assert_eq!(actions.len(), 1);
135 assert_eq!(actions[0].which, "orgRootKey");
136 }
137
138 #[test]
139 fn both_slots_due_simultaneously_emits_two_actions() {
140 let now = Utc::now();
141 let retire_at = now - chrono::Duration::hours(1);
142 let ci_slot = slot_with(Some(key("CCCC")), Some(retire_at));
143 let org_slot = slot_with(Some(key("DDDD")), Some(retire_at));
144 let actions = check_trust_rotations(&trust_with(ci_slot, Some(org_slot)), now);
145 assert_eq!(actions.len(), 2);
146 let whiches: Vec<&str> = actions.iter().map(|a| a.which.as_str()).collect();
147 assert!(whiches.contains(&"ciReleaseKey"));
148 assert!(whiches.contains(&"orgRootKey"));
149 }
150
151 #[test]
152 fn exactly_at_deadline_is_rotation_due() {
153 let now = Utc::now();
155 let slot = slot_with(Some(key("CCCC")), Some(now));
156 let actions = check_trust_rotations(&trust_with(slot, None), now);
157 assert_eq!(actions.len(), 1);
158 }
159}