nixfleet_proto/
bootstrap_nonces.rs

1//! `bootstrap-nonces.json` - signed sidecar declaring valid bootstrap-token
2//! nonces. Same trust class as `fleet.resolved.json` and `revocations.json`
3//! (signed by `ciReleaseKey`).
4//!
5//! Closes the replay-after-DB-wipe vector: CP refuses any `/v1/enroll`
6//! whose token nonce is not in the signed allowlist. After a state.db
7//! wipe, CP rebuilds replay protection from the signed artifact.
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12use crate::fleet_resolved::Meta;
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15#[serde(rename_all = "camelCase")]
16pub struct BootstrapNonces {
17    pub schema_version: u32,
18    /// Empty list is the steady state. Strict enforcement: any /v1/enroll
19    /// whose nonce is not in this list is rejected with 401.
20    pub bootstrap_nonces: Vec<BootstrapNonceEntry>,
21    pub meta: Meta,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25#[serde(rename_all = "camelCase")]
26pub struct BootstrapNonceEntry {
27    /// Token claim - matches `BootstrapToken.claims.nonce` exactly.
28    pub nonce: String,
29    /// Token claim must match this. Defends against mis-targeted token
30    /// swap (token A presented as if it were for host B).
31    pub hostname: String,
32    /// Authoritative validity window. May be tighter than the token's own
33    /// `expires_at` claim; cannot extend past it (the token's own claim
34    /// is still checked separately).
35    pub expires_at: DateTime<Utc>,
36    /// Optional audit field.
37    #[serde(default)]
38    pub minted_at: Option<DateTime<Utc>>,
39    /// Optional audit field.
40    #[serde(default)]
41    pub minted_by: Option<String>,
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47
48    fn meta_v1() -> Meta {
49        Meta {
50            schema_version: 1,
51            signed_at: Some("2026-05-13T10:00:00Z".parse().unwrap()),
52            ci_commit: Some("abc12345".into()),
53            signature_algorithm: Some("ecdsa-p256".into()),
54        }
55    }
56
57    #[test]
58    fn optional_audit_fields_default_to_none() {
59        let json = r#"{
60            "nonce": "1ed727e1f9c24e6ab87eb9693ba35e26",
61            "hostname": "agent-01",
62            "expiresAt": "2026-05-13T22:57:45Z"
63        }"#;
64        let entry: BootstrapNonceEntry = serde_json::from_str(json).unwrap();
65        assert_eq!(entry.nonce, "1ed727e1f9c24e6ab87eb9693ba35e26");
66        assert_eq!(entry.hostname, "agent-01");
67        assert_eq!(entry.minted_at, None);
68        assert_eq!(entry.minted_by, None);
69    }
70
71    #[test]
72    fn camelcase_on_wire() {
73        let r = BootstrapNonces {
74            schema_version: 1,
75            bootstrap_nonces: vec![],
76            meta: meta_v1(),
77        };
78        let s = serde_json::to_string(&r).unwrap();
79        assert!(s.contains("\"schemaVersion\""));
80        assert!(s.contains("\"bootstrapNonces\""));
81    }
82}