nixfleet_cli/
operator_cert.rs

1//! Operator-cert mint. Offline crypto only - never opens a socket. Generates
2//! an ECDSA-P-256 keypair, signs a clientAuth-EKU child with the fleet root
3//! cert + key, atomic-writes both PEMs.
4
5use std::path::PathBuf;
6
7use anyhow::{Context, Result, bail};
8use chrono::{DateTime, Utc};
9use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose};
10
11pub struct MintOperatorCertArgs {
12    pub root_cert_path: PathBuf,
13    pub root_key_path: PathBuf,
14    pub cn: String,
15    pub output_cert_path: PathBuf,
16    pub output_key_path: PathBuf,
17    pub validity_days: u32,
18    pub overwrite: bool,
19}
20
21#[derive(Debug)]
22pub struct MintOutcome {
23    pub cn: String,
24    pub not_after: DateTime<Utc>,
25    pub cert_path: PathBuf,
26    pub key_path: PathBuf,
27}
28
29pub fn mint_operator_cert(args: MintOperatorCertArgs) -> Result<MintOutcome> {
30    if args.cn.is_empty() {
31        bail!("operator CN is empty");
32    }
33    if args.validity_days == 0 {
34        bail!("validity must be at least 1 day; got 0");
35    }
36    for path in [&args.output_cert_path, &args.output_key_path] {
37        if path.exists() && !args.overwrite {
38            bail!(
39                "{} already exists; pass --force to overwrite",
40                path.display()
41            );
42        }
43    }
44
45    let ca_cert_pem = std::fs::read_to_string(&args.root_cert_path)
46        .with_context(|| format!("read fleet root cert {}", args.root_cert_path.display()))?;
47    let ca_key_pem = std::fs::read_to_string(&args.root_key_path)
48        .with_context(|| format!("read fleet root key {}", args.root_key_path.display()))?;
49
50    let ca_key = KeyPair::from_pem(&ca_key_pem).context("parse fleet root key PEM")?;
51    // Reject non-ECDSA-P-256 roots: trust chain is P-256 (issuance CA + agent
52    // certs); off-algorithm roots won't chain at the CP's mTLS layer.
53    let algo = ca_key.algorithm();
54    if algo != &rcgen::PKCS_ECDSA_P256_SHA256 {
55        bail!(
56            "fleet root key must be ECDSA-P-256 (matches issuance CA chain); got {:?}",
57            algo,
58        );
59    }
60    let ca_params =
61        CertificateParams::from_ca_cert_pem(&ca_cert_pem).context("parse fleet root cert PEM")?;
62    let ca_cert = ca_params
63        .self_signed(&ca_key)
64        .context("rebuild fleet root CA from PEM")?;
65
66    let child_key = KeyPair::generate().context("generate operator keypair")?;
67    let now = Utc::now();
68    let not_before = now - chrono::Duration::minutes(5);
69    let not_after = now + chrono::Duration::days(i64::from(args.validity_days));
70    // rcgen wants `time::OffsetDateTime`; bridge through `SystemTime`.
71    let not_before_sys = std::time::SystemTime::UNIX_EPOCH
72        + std::time::Duration::from_secs(not_before.timestamp().max(0) as u64);
73    let not_after_sys = std::time::SystemTime::UNIX_EPOCH
74        + std::time::Duration::from_secs(not_after.timestamp().max(0) as u64);
75
76    let mut child_params = CertificateParams::default();
77    child_params
78        .distinguished_name
79        .push(DnType::CommonName, args.cn.clone());
80    child_params
81        .distinguished_name
82        .push(DnType::OrganizationName, "arcanesys");
83    child_params
84        .distinguished_name
85        .push(DnType::OrganizationalUnitName, "fleet");
86    child_params.is_ca = IsCa::ExplicitNoCa;
87    child_params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
88    // CN-only is fine for clientAuth (webpki's dNSName check is server-side).
89    // Operator certs never serve, so no SAN.
90    child_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth];
91    child_params.not_before = not_before_sys.into();
92    child_params.not_after = not_after_sys.into();
93
94    let child_cert = child_params
95        .signed_by(&child_key, &ca_cert, &ca_key)
96        .context("sign operator cert with fleet root")?;
97
98    write_atomic_with_mode(&args.output_cert_path, child_cert.pem().as_bytes(), 0o644)?;
99    write_atomic_with_mode(
100        &args.output_key_path,
101        child_key.serialize_pem().as_bytes(),
102        0o600,
103    )?;
104
105    Ok(MintOutcome {
106        cn: args.cn,
107        not_after,
108        cert_path: args.output_cert_path,
109        key_path: args.output_key_path,
110    })
111}
112
113fn write_atomic_with_mode(path: &std::path::Path, body: &[u8], mode: u32) -> Result<()> {
114    if let Some(parent) = path.parent() {
115        std::fs::create_dir_all(parent)
116            .with_context(|| format!("create dir {}", parent.display()))?;
117    }
118    let tmp = path.with_extension(format!("tmp.{}", rand::random::<u32>()));
119    #[cfg(unix)]
120    {
121        use std::io::Write;
122        use std::os::unix::fs::OpenOptionsExt;
123        let mut f = std::fs::OpenOptions::new()
124            .create(true)
125            .write(true)
126            .truncate(true)
127            .mode(mode)
128            .open(&tmp)
129            .with_context(|| format!("create temp {}", tmp.display()))?;
130        f.write_all(body)
131            .with_context(|| format!("write temp {}", tmp.display()))?;
132        f.sync_all()
133            .with_context(|| format!("fsync {}", tmp.display()))?;
134    }
135    #[cfg(not(unix))]
136    {
137        let _ = mode;
138        std::fs::write(&tmp, body).with_context(|| format!("write temp {}", tmp.display()))?;
139    }
140    std::fs::rename(&tmp, path)
141        .with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
142    Ok(())
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use rcgen::{BasicConstraints, KeyUsagePurpose};
149    use tempfile::TempDir;
150
151    /// Mint a fresh self-signed ECDSA-P-256 CA into `dir` and return paths.
152    fn fresh_root_pki(dir: &TempDir) -> (PathBuf, PathBuf) {
153        let mut params = CertificateParams::default();
154        params
155            .distinguished_name
156            .push(DnType::CommonName, "Test Fleet Root CA");
157        params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
158        params.key_usages = vec![
159            KeyUsagePurpose::KeyCertSign,
160            KeyUsagePurpose::DigitalSignature,
161        ];
162        let key = KeyPair::generate().unwrap();
163        let cert = params.self_signed(&key).unwrap();
164        let cert_path = dir.path().join("root.cert.pem");
165        let key_path = dir.path().join("root.key.pem");
166        std::fs::write(&cert_path, cert.pem()).unwrap();
167        std::fs::write(&key_path, key.serialize_pem()).unwrap();
168        (cert_path, key_path)
169    }
170
171    fn mint_args(dir: &TempDir, root_cert: PathBuf, root_key: PathBuf) -> MintOperatorCertArgs {
172        MintOperatorCertArgs {
173            root_cert_path: root_cert,
174            root_key_path: root_key,
175            cn: "operator-test@host".into(),
176            output_cert_path: dir.path().join("operator.pem"),
177            output_key_path: dir.path().join("operator.key"),
178            validity_days: 365,
179            overwrite: false,
180        }
181    }
182
183    #[test]
184    fn mints_cert_signed_by_provided_root() {
185        let dir = TempDir::new().unwrap();
186        let (root_cert, root_key) = fresh_root_pki(&dir);
187        let outcome = mint_operator_cert(mint_args(&dir, root_cert.clone(), root_key)).unwrap();
188        assert_eq!(outcome.cn, "operator-test@host");
189        assert!(outcome.cert_path.exists());
190        assert!(outcome.key_path.exists());
191
192        let leaf_pem = std::fs::read_to_string(&outcome.cert_path).unwrap();
193        let root_pem = std::fs::read_to_string(&root_cert).unwrap();
194        let (_, leaf) = x509_parser::pem::parse_x509_pem(leaf_pem.as_bytes()).unwrap();
195        let (_, root) = x509_parser::pem::parse_x509_pem(root_pem.as_bytes()).unwrap();
196        let leaf_cert = leaf.parse_x509().unwrap();
197        let root_cert_parsed = root.parse_x509().unwrap();
198        assert_eq!(
199            leaf_cert.issuer().to_string(),
200            root_cert_parsed.subject().to_string(),
201            "leaf issuer should match root subject",
202        );
203        leaf_cert
204            .verify_signature(Some(root_cert_parsed.public_key()))
205            .expect("leaf signature must verify against root pubkey");
206    }
207
208    #[test]
209    fn output_cert_has_correct_cn_and_eku() {
210        let dir = TempDir::new().unwrap();
211        let (root_cert, root_key) = fresh_root_pki(&dir);
212        let outcome = mint_operator_cert(mint_args(&dir, root_cert, root_key)).unwrap();
213
214        let pem = std::fs::read_to_string(&outcome.cert_path).unwrap();
215        let (_, parsed) = x509_parser::pem::parse_x509_pem(pem.as_bytes()).unwrap();
216        let cert = parsed.parse_x509().unwrap();
217        assert!(
218            cert.subject().to_string().contains("CN=operator-test@host"),
219            "subject must include CN: got {}",
220            cert.subject(),
221        );
222        assert!(cert.subject().to_string().contains("O=arcanesys"));
223        let eku = cert
224            .extended_key_usage()
225            .unwrap()
226            .expect("EKU extension")
227            .value;
228        assert!(eku.client_auth, "EKU must include clientAuth");
229        let bc = cert
230            .basic_constraints()
231            .unwrap()
232            .expect("BC extension")
233            .value;
234        assert!(!bc.ca, "BasicConstraints.cA must be false");
235    }
236
237    #[test]
238    fn validity_window_matches_days() {
239        let dir = TempDir::new().unwrap();
240        let (root_cert, root_key) = fresh_root_pki(&dir);
241        let mut args = mint_args(&dir, root_cert, root_key);
242        args.validity_days = 30;
243        let now = Utc::now();
244        let outcome = mint_operator_cert(args).unwrap();
245        let delta = outcome.not_after - now;
246        assert!(
247            delta.num_days() >= 29 && delta.num_days() <= 30,
248            "expected ~30 days, got {} days",
249            delta.num_days(),
250        );
251    }
252
253    #[test]
254    fn refuses_overwrite_without_force() {
255        let dir = TempDir::new().unwrap();
256        let (root_cert, root_key) = fresh_root_pki(&dir);
257        let args = mint_args(&dir, root_cert.clone(), root_key.clone());
258        let cert_path = args.output_cert_path.clone();
259        mint_operator_cert(args).unwrap();
260
261        let original = std::fs::read(&cert_path).unwrap();
262        let args2 = mint_args(&dir, root_cert, root_key);
263        let err = mint_operator_cert(args2).unwrap_err();
264        assert!(
265            err.to_string().contains("already exists"),
266            "expected refusal, got: {err}",
267        );
268        let after = std::fs::read(&cert_path).unwrap();
269        assert_eq!(original, after, "output must be untouched on refusal");
270    }
271
272    #[test]
273    fn overwrites_with_force() {
274        let dir = TempDir::new().unwrap();
275        let (root_cert, root_key) = fresh_root_pki(&dir);
276        let args1 = mint_args(&dir, root_cert.clone(), root_key.clone());
277        mint_operator_cert(args1).unwrap();
278        let mut args2 = mint_args(&dir, root_cert, root_key);
279        args2.cn = "operator-replaced@host".into();
280        args2.overwrite = true;
281        mint_operator_cert(args2).unwrap();
282
283        let pem = std::fs::read_to_string(dir.path().join("operator.pem")).unwrap();
284        let (_, parsed) = x509_parser::pem::parse_x509_pem(pem.as_bytes()).unwrap();
285        let cert = parsed.parse_x509().unwrap();
286        assert!(
287            cert.subject()
288                .to_string()
289                .contains("CN=operator-replaced@host"),
290            "force overwrite should produce new CN: got {}",
291            cert.subject(),
292        );
293    }
294
295    #[test]
296    fn rejects_non_ecdsa_root_key() {
297        let dir = TempDir::new().unwrap();
298        let mut params = CertificateParams::default();
299        params
300            .distinguished_name
301            .push(DnType::CommonName, "Ed25519 Root");
302        params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
303        let ed_key = KeyPair::generate_for(&rcgen::PKCS_ED25519).unwrap();
304        let cert = params.self_signed(&ed_key).unwrap();
305        let cert_path = dir.path().join("root.cert.pem");
306        let key_path = dir.path().join("root.key.pem");
307        std::fs::write(&cert_path, cert.pem()).unwrap();
308        std::fs::write(&key_path, ed_key.serialize_pem()).unwrap();
309
310        let err = mint_operator_cert(mint_args(&dir, cert_path, key_path)).unwrap_err();
311        assert!(
312            err.to_string().contains("ECDSA-P-256"),
313            "expected ECDSA-P-256 rejection, got: {err}",
314        );
315    }
316
317    #[cfg(unix)]
318    #[test]
319    fn output_modes_are_0644_cert_0600_key_on_unix() {
320        use std::os::unix::fs::PermissionsExt;
321        let dir = TempDir::new().unwrap();
322        let (root_cert, root_key) = fresh_root_pki(&dir);
323        let outcome = mint_operator_cert(mint_args(&dir, root_cert, root_key)).unwrap();
324        let cert_mode = std::fs::metadata(&outcome.cert_path)
325            .unwrap()
326            .permissions()
327            .mode()
328            & 0o777;
329        let key_mode = std::fs::metadata(&outcome.key_path)
330            .unwrap()
331            .permissions()
332            .mode()
333            & 0o777;
334        assert_eq!(cert_mode, 0o644, "cert mode");
335        assert_eq!(key_mode, 0o600, "key mode");
336    }
337
338    /// Smoke check on PEM well-formedness. Cryptographic pairing is covered
339    /// transitively by `mints_cert_signed_by_provided_root`.
340    #[test]
341    fn output_pems_decode_without_error() {
342        let dir = TempDir::new().unwrap();
343        let (root_cert, root_key) = fresh_root_pki(&dir);
344        let outcome = mint_operator_cert(mint_args(&dir, root_cert, root_key)).unwrap();
345
346        let cert_pem = std::fs::read_to_string(&outcome.cert_path).unwrap();
347        let key_pem = std::fs::read_to_string(&outcome.key_path).unwrap();
348        KeyPair::from_pem(&key_pem).expect("operator key parses");
349        CertificateParams::from_ca_cert_pem(&cert_pem).expect("operator cert parses");
350    }
351}