1use 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 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 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 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 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 #[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}