nixfleet_reconciler/
trust_rotation.rs

1//! Declarative key rotation. Emits `Action::RotateTrustRoot` when a slot's
2//! `retire_at` has passed AND a `successor` is declared. Informational only -
3//! the CP NEVER self-mutates trust roots; the operator's tooling promotes
4//! `current -> previous, successor -> current` in the next fleet commit.
5
6use chrono::{DateTime, Utc};
7use nixfleet_proto::trust::{KeySlot, TrustConfig};
8
9/// Operator-visible "rotate this slot now" hint. The CP never self-mutates
10/// trust roots — this struct is informational telemetry only. The legacy
11/// `Action::RotateTrustRoot` (in the deleted reconciler `action.rs`) was
12/// the same shape; this is the moved-in-place definition.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct RotateTrustRoot {
15    pub which: String,
16    pub retire_at: DateTime<Utc>,
17}
18
19/// One `Action::RotateTrustRoot` per slot with `retire_at <= now` AND
20/// `successor.is_some()`. Idempotent: re-emitted every tick until the operator
21/// rotates the slot (after which `successor` clears and the predicate stops).
22/// Pure arithmetic - safe in the reconcile hot path.
23pub 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
42/// `Some(retire_at)` when this slot's rotation is due. Same field-pair as
43/// `active_keys_at`, opposite sense.
44fn 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    /// Nix-side assertion makes this unreachable from the operator path;
118    /// this test pins runtime behaviour for malformed trust.json.
119    #[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        // `now >= retire_at`: equality is the moment of rotation.
154        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}