1use std::path::{Path, PathBuf};
4use std::time::{Duration, SystemTime};
5
6use anyhow::{Context, Result};
7use base64::Engine;
8use chrono::{DateTime, Utc};
9use ed25519_dalek::{Signature, Verifier, VerifyingKey};
10use nixfleet_proto::enroll_wire::{BootstrapToken, TokenClaims};
11use rcgen::{
12 CertificateParams, CertificateSigningRequestParams, DnType, ExtendedKeyUsagePurpose, KeyPair,
13};
14use sha2::{Digest, Sha256};
15
16pub const AGENT_CERT_VALIDITY: Duration = Duration::from_secs(30 * 24 * 60 * 60);
18
19pub const DEFAULT_AGENT_CN_SUFFIX: &str = "fleet.example.com";
25
26pub fn canonical_agent_cn(machine_id: &str, suffix: &str) -> String {
28 format!("agent-{machine_id}.{suffix}")
29}
30
31pub fn extract_machine_id(cn: &str, suffix: &str) -> String {
33 let trailer = format!(".{suffix}");
34 if let Some(rest) = cn.strip_prefix("agent-")
35 && let Some(machine_id) = rest.strip_suffix(&trailer)
36 {
37 return machine_id.to_string();
38 }
39 cn.to_string()
40}
41
42#[derive(Debug, Clone)]
43pub enum AuditContext {
44 Enroll { token_nonce: String },
45 Renew { previous_cert_serial: String },
46}
47
48#[derive(Debug)]
50pub enum TrustVerifyError {
51 TrustFileRead {
52 path: PathBuf,
53 source: std::io::Error,
54 },
55 TrustFileParse {
56 source: serde_json::Error,
57 },
58 NoOrgRootKey,
59 NoActiveKeys,
60 SignatureMismatch,
62}
63
64impl std::fmt::Display for TrustVerifyError {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 match self {
67 Self::TrustFileRead { path, source } => {
68 write!(f, "read trust.json at {}: {source}", path.display())
69 }
70 Self::TrustFileParse { source } => write!(f, "parse trust.json: {source}"),
71 Self::NoOrgRootKey => write!(
72 f,
73 "trust.json has no orgRootKey - set nixfleet.trust.orgRootKey.current"
74 ),
75 Self::NoActiveKeys => write!(f, "orgRootKey has no current/previous keys"),
76 Self::SignatureMismatch => write!(
77 f,
78 "token signature did not verify against any orgRootKey candidate"
79 ),
80 }
81 }
82}
83
84pub fn verify_bootstrap_token_against_trust(
87 trust_path: &Path,
88 token: &BootstrapToken,
89 now: DateTime<Utc>,
90) -> Result<(), TrustVerifyError> {
91 let trust_raw =
92 std::fs::read_to_string(trust_path).map_err(|source| TrustVerifyError::TrustFileRead {
93 path: trust_path.to_path_buf(),
94 source,
95 })?;
96 let trust: nixfleet_proto::TrustConfig = serde_json::from_str(&trust_raw)
97 .map_err(|source| TrustVerifyError::TrustFileParse { source })?;
98 let org_root = trust
99 .org_root_key
100 .as_ref()
101 .ok_or(TrustVerifyError::NoOrgRootKey)?;
102 let candidates = org_root.active_keys_at(now);
103 if candidates.is_empty() {
104 return Err(TrustVerifyError::NoActiveKeys);
105 }
106
107 for pubkey in &candidates {
108 if pubkey.algorithm != "ed25519" {
109 tracing::warn!(
110 algorithm = %pubkey.algorithm,
111 "skipping non-ed25519 orgRootKey candidate (only ed25519 supported)",
112 );
113 continue;
114 }
115 let pubkey_bytes = match base64::engine::general_purpose::STANDARD.decode(&pubkey.public) {
116 Ok(b) => b,
117 Err(err) => {
118 tracing::warn!(error = %err, "orgRootKey base64 decode failed; skipping candidate");
119 continue;
120 }
121 };
122 if verify_token_signature(token, &pubkey_bytes).is_ok() {
123 return Ok(());
124 }
125 }
126 Err(TrustVerifyError::SignatureMismatch)
127}
128
129pub fn verify_token_signature(token: &BootstrapToken, org_root_pubkey: &[u8]) -> Result<()> {
131 if token.version != 1 {
132 anyhow::bail!("unsupported token version: {}", token.version);
133 }
134 let pubkey = VerifyingKey::from_bytes(
135 org_root_pubkey
136 .try_into()
137 .context("orgRootKey is not 32 bytes")?,
138 )
139 .context("parse orgRootKey")?;
140 let sig_bytes = base64::engine::general_purpose::STANDARD
141 .decode(&token.signature)
142 .context("decode token signature base64")?;
143 let signature = Signature::from_slice(&sig_bytes).context("parse ed25519 signature")?;
144
145 let claims_json = serde_json::to_string(&token.claims).context("serialize claims")?;
147 let canonical =
148 nixfleet_canonicalize::canonicalize(&claims_json).context("canonicalize claims")?;
149 pubkey
150 .verify(canonical.as_bytes(), &signature)
151 .context("verify token signature")?;
152 Ok(())
153}
154
155pub fn validate_token_claims(
157 claims: &TokenClaims,
158 csr_cn: &str,
159 csr_pubkey_fingerprint: &str,
160 now: DateTime<Utc>,
161) -> Result<()> {
162 if now < claims.issued_at {
163 anyhow::bail!("token issued in the future");
164 }
165 if now >= claims.expires_at {
166 anyhow::bail!("token expired");
167 }
168 if csr_cn != claims.hostname {
169 anyhow::bail!(
170 "CSR CN ({csr_cn}) does not match token hostname ({})",
171 claims.hostname
172 );
173 }
174 if csr_pubkey_fingerprint != claims.expected_pubkey_fingerprint {
175 anyhow::bail!("CSR pubkey fingerprint does not match token expected_pubkey_fingerprint");
176 }
177 Ok(())
178}
179
180pub fn fingerprint(pubkey_bytes: &[u8]) -> String {
182 let digest = Sha256::digest(pubkey_bytes);
183 base64::engine::general_purpose::STANDARD.encode(digest)
184}
185
186pub fn extract_private_key_pem_block(pem_text: &str) -> Result<String> {
189 const ACCEPTED: &[&str] = &["PRIVATE KEY", "EC PRIVATE KEY", "RSA PRIVATE KEY"];
190
191 let mut current_label: Option<String> = None;
192 let mut current_body = String::new();
193
194 for line in pem_text.lines() {
195 let trimmed = line.trim();
196 if let Some(rest) = trimmed
197 .strip_prefix("-----BEGIN ")
198 .and_then(|s| s.strip_suffix("-----"))
199 {
200 current_label = Some(rest.to_string());
201 current_body.clear();
202 } else if let Some(rest) = trimmed
203 .strip_prefix("-----END ")
204 .and_then(|s| s.strip_suffix("-----"))
205 {
206 if let Some(start) = current_label.take()
207 && start == rest
208 && ACCEPTED.iter().any(|&l| l == start)
209 {
210 return Ok(format!(
211 "-----BEGIN {start}-----\n{body}-----END {start}-----\n",
212 body = current_body,
213 ));
214 }
215 current_body.clear();
216 } else if current_label.is_some() {
217 current_body.push_str(line);
218 current_body.push('\n');
219 }
220 }
221
222 anyhow::bail!(
223 "no PEM block matching {ACCEPTED:?} found - supply a PKCS#8 \
224 (`BEGIN PRIVATE KEY`) or SEC1 (`BEGIN EC PRIVATE KEY`) key",
225 )
226}
227
228pub fn validate_csr_against_fleet_host(
232 csr_pubkey_raw: &[u8],
233 declared_openssh_pubkey: Option<&str>,
234) -> Result<()> {
235 let openssh = declared_openssh_pubkey.ok_or_else(|| {
236 anyhow::anyhow!(
237 "host has no `pubkey` declared in fleet.nix - \
238 enrollment refused (declarative-enrollment policy)"
239 )
240 })?;
241 let declared_raw = nixfleet_proto::host_key::ed25519_pubkey_raw_from_openssh(openssh)
242 .with_context(|| format!("parse declared OpenSSH pubkey for fleet host: {openssh}"))?;
243 if csr_pubkey_raw != declared_raw {
244 anyhow::bail!(
245 "CSR pubkey does not match host's declared SSH host pubkey \
246 (CSR fingerprint: {}, declared fingerprint: {})",
247 fingerprint(csr_pubkey_raw),
248 fingerprint(&declared_raw),
249 );
250 }
251 Ok(())
252}
253
254pub trait CaSigner: Send + Sync {
258 fn issuer(&self) -> &rcgen::Certificate;
259 fn make_key_pair(&self) -> Result<KeyPair>;
260}
261
262pub fn build_signer_from_args(
265 cert_path: &Path,
266 tpm_pubkey_raw: Option<&Path>,
267 tpm_sign_wrapper: Option<&Path>,
268 fleet_ca_key: Option<&Path>,
269) -> Option<std::sync::Arc<dyn CaSigner>> {
270 match (tpm_pubkey_raw, tpm_sign_wrapper, fleet_ca_key) {
271 (Some(pub_raw), Some(wrapper), _) => {
272 match TpmCaSigner::from_paths(cert_path, pub_raw, wrapper) {
273 Ok(s) => {
274 tracing::info!(
275 cert = %cert_path.display(),
276 pubkey_raw = %pub_raw.display(),
277 wrapper = %wrapper.display(),
278 "issuance CA signer: TPM-backed",
279 );
280 Some(std::sync::Arc::new(s))
281 }
282 Err(err) => {
283 tracing::error!(error = %err, "build TPM CA signer; enroll/renew will 500");
284 None
285 }
286 }
287 }
288 (None, None, Some(key_path)) => match FileCaSigner::from_paths(cert_path, key_path) {
289 Ok(s) => {
290 tracing::info!(
291 cert = %cert_path.display(),
292 key = %key_path.display(),
293 "issuance CA signer: file-backed",
294 );
295 Some(std::sync::Arc::new(s))
296 }
297 Err(err) => {
298 tracing::error!(error = %err, "build file CA signer; enroll/renew will 500");
299 None
300 }
301 },
302 _ => {
303 tracing::warn!(
304 "no CA signer flags satisfied (need --fleet-ca-key or --tpm-ca-pubkey-raw + \
305 --tpm-ca-sign-wrapper); enroll/renew will 500"
306 );
307 None
308 }
309 }
310}
311
312pub struct FileCaSigner {
314 key_path: PathBuf,
315 issuer_cert: rcgen::Certificate,
316}
317
318impl FileCaSigner {
319 pub fn from_paths(ca_cert_path: &Path, ca_key_path: &Path) -> Result<Self> {
320 let ca_cert_pem = std::fs::read_to_string(ca_cert_path)
321 .with_context(|| format!("read fleet CA cert {}", ca_cert_path.display()))?;
322 let ca_key_pem_raw = std::fs::read_to_string(ca_key_path)
323 .with_context(|| format!("read fleet CA key {}", ca_key_path.display()))?;
324 let ca_key_pem = extract_private_key_pem_block(&ca_key_pem_raw)
328 .context("extract private-key block from fleet CA key PEM")?;
329 let ca_key = KeyPair::from_pem(&ca_key_pem).context("parse fleet CA key PEM")?;
330 let ca_params =
331 CertificateParams::from_ca_cert_pem(&ca_cert_pem).context("parse fleet CA cert PEM")?;
332 let issuer_cert = ca_params
333 .self_signed(&ca_key)
334 .context("rebuild fleet CA from PEM (rcgen quirk)")?;
335 Ok(Self {
336 key_path: ca_key_path.to_path_buf(),
337 issuer_cert,
338 })
339 }
340}
341
342impl CaSigner for FileCaSigner {
343 fn issuer(&self) -> &rcgen::Certificate {
344 &self.issuer_cert
345 }
346 fn make_key_pair(&self) -> Result<KeyPair> {
347 let raw = std::fs::read_to_string(&self.key_path)
348 .with_context(|| format!("read fleet CA key {}", self.key_path.display()))?;
349 let pem = extract_private_key_pem_block(&raw).context("extract CA key PEM block")?;
350 KeyPair::from_pem(&pem).context("parse fleet CA key PEM")
351 }
352}
353
354pub struct TpmCaSigner {
360 pubkey_uncompressed: Vec<u8>,
361 sign_wrapper_path: PathBuf,
362 issuer_cert: rcgen::Certificate,
363}
364
365impl TpmCaSigner {
366 pub fn from_paths(
369 ca_cert_path: &Path,
370 tpm_pubkey_raw_path: &Path,
371 sign_wrapper_path: &Path,
372 ) -> Result<Self> {
373 let pubkey_raw = std::fs::read(tpm_pubkey_raw_path)
374 .with_context(|| format!("read TPM pubkey {}", tpm_pubkey_raw_path.display()))?;
375 if pubkey_raw.len() != 64 {
376 anyhow::bail!(
377 "TPM pubkey expected 64 bytes (raw P-256 X||Y), got {}",
378 pubkey_raw.len(),
379 );
380 }
381 let mut pubkey_uncompressed = Vec::with_capacity(65);
382 pubkey_uncompressed.push(0x04);
383 pubkey_uncompressed.extend_from_slice(&pubkey_raw);
384
385 let ca_cert_pem = std::fs::read_to_string(ca_cert_path)
386 .with_context(|| format!("read issuance CA cert {}", ca_cert_path.display()))?;
387
388 let signer = TpmRemoteSigner {
390 pubkey_uncompressed: pubkey_uncompressed.clone(),
391 sign_wrapper_path: sign_wrapper_path.to_path_buf(),
392 };
393 let key_pair = KeyPair::from_remote(Box::new(signer)).context("rcgen from_remote (TPM)")?;
394 let ca_params = CertificateParams::from_ca_cert_pem(&ca_cert_pem)
395 .context("parse issuance CA cert PEM")?;
396 let issuer_cert = ca_params
397 .self_signed(&key_pair)
398 .context("self-sign issuer via TPM at startup")?;
399
400 Ok(Self {
401 pubkey_uncompressed,
402 sign_wrapper_path: sign_wrapper_path.to_path_buf(),
403 issuer_cert,
404 })
405 }
406}
407
408impl CaSigner for TpmCaSigner {
409 fn issuer(&self) -> &rcgen::Certificate {
410 &self.issuer_cert
411 }
412 fn make_key_pair(&self) -> Result<KeyPair> {
413 let signer = TpmRemoteSigner {
414 pubkey_uncompressed: self.pubkey_uncompressed.clone(),
415 sign_wrapper_path: self.sign_wrapper_path.clone(),
416 };
417 KeyPair::from_remote(Box::new(signer)).context("rcgen from_remote (TPM)")
418 }
419}
420
421struct TpmRemoteSigner {
424 pubkey_uncompressed: Vec<u8>,
425 sign_wrapper_path: PathBuf,
426}
427
428impl rcgen::RemoteKeyPair for TpmRemoteSigner {
429 fn public_key(&self) -> &[u8] {
430 &self.pubkey_uncompressed
431 }
432 fn sign(&self, msg: &[u8]) -> std::result::Result<Vec<u8>, rcgen::Error> {
433 let raw = invoke_tpm_sign(&self.sign_wrapper_path, msg).map_err(|err| {
434 tracing::error!(error = %err, "tpm-sign invocation failed");
435 rcgen::Error::RingUnspecified
436 })?;
437 der_encode_ecdsa_p256_sig(&raw).map_err(|err| {
438 tracing::error!(error = %err, raw_len = raw.len(), "DER-encoding TPM signature failed");
439 rcgen::Error::RingUnspecified
440 })
441 }
442 fn algorithm(&self) -> &'static rcgen::SignatureAlgorithm {
443 &rcgen::PKCS_ECDSA_P256_SHA256
444 }
445}
446
447fn invoke_tpm_sign(wrapper: &Path, msg: &[u8]) -> Result<Vec<u8>> {
449 use std::io::Write;
450 let mut tmp = tempfile::NamedTempFile::new().context("create tpm-sign tempfile")?;
451 tmp.write_all(msg).context("write tpm-sign tempfile")?;
452 tmp.flush().ok();
453 let output = std::process::Command::new(wrapper)
454 .arg(tmp.path())
455 .output()
456 .with_context(|| format!("invoke tpm-sign wrapper {}", wrapper.display()))?;
457 if !output.status.success() {
458 anyhow::bail!(
459 "tpm-sign wrapper {} exited {}: stderr={}",
460 wrapper.display(),
461 output.status,
462 String::from_utf8_lossy(&output.stderr),
463 );
464 }
465 Ok(output.stdout)
466}
467
468fn der_encode_ecdsa_p256_sig(raw: &[u8]) -> Result<Vec<u8>> {
472 if raw.len() != 64 {
473 anyhow::bail!("expected 64 raw P-256 sig bytes, got {}", raw.len());
474 }
475 let r = der_encode_int(&raw[..32]);
476 let s = der_encode_int(&raw[32..]);
477 let body_len = r.len() + s.len();
478 let mut out = Vec::with_capacity(2 + body_len);
481 out.push(0x30); out.push(body_len as u8);
483 out.extend(r);
484 out.extend(s);
485 Ok(out)
486}
487
488fn der_encode_int(bytes: &[u8]) -> Vec<u8> {
490 let mut start = 0;
491 while start + 1 < bytes.len() && bytes[start] == 0 {
492 start += 1;
493 }
494 let needs_pad = (bytes[start] & 0x80) != 0;
495 let len = bytes.len() - start + usize::from(needs_pad);
496 let mut out = Vec::with_capacity(2 + len);
497 out.push(0x02); out.push(len as u8);
499 if needs_pad {
500 out.push(0x00);
501 }
502 out.extend_from_slice(&bytes[start..]);
503 out
504}
505
506pub fn issue_cert(
510 csr_pem: &str,
511 signer: &dyn CaSigner,
512 validity: Duration,
513 now: DateTime<Utc>,
514 agent_cn_suffix: &str,
515) -> Result<(String, DateTime<Utc>)> {
516 let ca_key = signer.make_key_pair()?;
517 let ca = signer.issuer();
518
519 let csr_params = CertificateSigningRequestParams::from_pem(csr_pem).context("parse CSR PEM")?;
520 let csr_cn = csr_params
521 .params
522 .distinguished_name
523 .iter()
524 .find_map(|(t, v): (&DnType, &rcgen::DnValue)| {
525 if matches!(t, DnType::CommonName) {
526 Some(v.clone())
527 } else {
528 None
529 }
530 })
531 .context("CSR has no CN")?;
532
533 let csr_cn_str = match &csr_cn {
534 rcgen::DnValue::PrintableString(s) => s.to_string(),
535 rcgen::DnValue::Utf8String(s) => s.to_string(),
536 _ => format!("{:?}", csr_cn),
537 };
538 let machine_id = extract_machine_id(&csr_cn_str, agent_cn_suffix);
541 let canonical_cn = canonical_agent_cn(&machine_id, agent_cn_suffix);
542
543 let mut params = csr_params.params;
544 params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth];
545
546 let mut new_dn = rcgen::DistinguishedName::new();
548 for (t, v) in params.distinguished_name.iter() {
549 if !matches!(t, DnType::CommonName) {
550 new_dn.push(t.clone(), v.clone());
551 }
552 }
553 new_dn.push(DnType::CommonName, &*canonical_cn);
554 params.distinguished_name = new_dn;
555
556 params.subject_alt_names = vec![rcgen::SanType::DnsName(
558 canonical_cn
559 .clone()
560 .try_into()
561 .context("canonical CN is not a valid dNSName")?,
562 )];
563
564 let not_before_sys = SystemTime::UNIX_EPOCH + Duration::from_secs(now.timestamp() as u64);
565 let not_after_sys = not_before_sys + validity;
566 params.not_before = not_before_sys.into();
567 params.not_after = not_after_sys.into();
568
569 let cert = params
570 .signed_by(&csr_params.public_key, ca, &ca_key)
571 .context("sign cert with fleet CA")?;
572
573 let not_after = chrono::DateTime::<Utc>::from(not_after_sys);
574 Ok((cert.pem(), not_after))
575}
576
577pub fn audit_log(
579 path: &Path,
580 now: DateTime<Utc>,
581 requester_cn: &str,
582 issued_cn: &str,
583 not_after: DateTime<Utc>,
584 context: &AuditContext,
585) {
586 let context_str = match context {
587 AuditContext::Enroll { token_nonce } => format!("enroll/nonce:{token_nonce}"),
588 AuditContext::Renew {
589 previous_cert_serial,
590 } => format!("renew/prev:{previous_cert_serial}"),
591 };
592 let validity_secs = (not_after - now).num_seconds();
593 let record = serde_json::json!({
594 "at": now.to_rfc3339(),
595 "requester_cn": requester_cn,
596 "issued_cn": issued_cn,
597 "not_after": not_after.to_rfc3339(),
598 "validity_secs": validity_secs,
599 "context": context_str,
600 });
601 let line = serde_json::to_string(&record)
602 .expect("serde_json::to_string on a json!() Value is infallible");
603 if let Err(err) = std::fs::OpenOptions::new()
604 .create(true)
605 .append(true)
606 .open(path)
607 .and_then(|mut f| {
608 use std::io::Write;
609 writeln!(f, "{line}")
610 })
611 {
612 tracing::warn!(error = %err, path = %path.display(), "failed to append audit log");
613 }
614}
615
616#[cfg(test)]
617mod cn_helpers_tests {
618 use super::*;
619
620 #[test]
621 fn canonicalises_bare_machine_id() {
622 assert_eq!(
623 canonical_agent_cn("host-01", "fleet.example.com"),
624 "agent-host-01.fleet.example.com"
625 );
626 }
627
628 #[test]
629 fn extracts_machine_id_from_canonical_cn() {
630 assert_eq!(
631 extract_machine_id("agent-host-01.fleet.example.com", "fleet.example.com"),
632 "host-01"
633 );
634 }
635
636 #[test]
637 fn extract_passes_through_legacy_bare_cn() {
638 assert_eq!(
642 extract_machine_id("host-01", "fleet.example.com"),
643 "host-01"
644 );
645 }
646
647 #[test]
648 fn extract_passes_through_when_suffix_does_not_match() {
649 assert_eq!(
653 extract_machine_id("agent-host-01.other.example", "fleet.example.com"),
654 "agent-host-01.other.example"
655 );
656 }
657
658 #[test]
659 fn canonicalisation_roundtrips_through_extract() {
660 for id in ["host-01", "host-02", "host-04", "machine-with-dashes"] {
661 let canonical = canonical_agent_cn(id, "fleet.example.com");
662 assert_eq!(
663 extract_machine_id(&canonical, "fleet.example.com"),
664 id,
665 "round-trip failed for {id}"
666 );
667 }
668 }
669}
670
671#[cfg(test)]
672mod der_encode_tests {
673 use super::*;
674
675 #[test]
676 fn der_encode_int_handles_padding_and_stripping() {
677 let cases: &[(&str, &[u8], &[u8])] = &[
679 (
680 "minimal positive: no pad/strip",
681 &[0x01],
682 &[0x02, 0x01, 0x01],
683 ),
684 ("MSB set: prepend 0x00", &[0x80], &[0x02, 0x02, 0x00, 0x80]),
685 (
686 "strip leading zeros",
687 &[0x00, 0x00, 0x42],
688 &[0x02, 0x01, 0x42],
689 ),
690 (
692 "zero stays as 0x00",
693 &[0x00, 0x00, 0x00],
694 &[0x02, 0x01, 0x00],
695 ),
696 (
697 "strip then pad when MSB still set",
698 &[0x00, 0x80, 0x01],
699 &[0x02, 0x03, 0x00, 0x80, 0x01],
700 ),
701 ];
702 for (label, input, expected) in cases {
703 assert_eq!(der_encode_int(input).as_slice(), *expected, "case: {label}");
704 }
705 }
706
707 #[test]
708 fn rejects_wrong_length_p256_sig() {
709 let err = der_encode_ecdsa_p256_sig(&[0u8; 32]).unwrap_err();
710 assert!(format!("{err}").contains("64"));
711 }
712
713 #[test]
714 fn encodes_p256_sig_typical_shape() {
715 let mut raw = [0u8; 64];
717 raw[..32]
718 .iter_mut()
719 .enumerate()
720 .for_each(|(i, b)| *b = 1 + i as u8);
721 raw[32..]
722 .iter_mut()
723 .enumerate()
724 .for_each(|(i, b)| *b = 0x40 + i as u8);
725 let der = der_encode_ecdsa_p256_sig(&raw).expect("encode");
726 assert_eq!(der[0], 0x30);
728 assert_eq!(der[1], 68);
730 assert_eq!(der[2], 0x02);
732 assert_eq!(der[3], 32);
733 assert_eq!(der[2 + 2 + 32], 0x02);
735 assert_eq!(der[2 + 2 + 32 + 1], 32);
736 }
737
738 #[test]
739 fn encodes_p256_sig_max_padding_both_components() {
740 let mut raw = [0u8; 64];
742 raw[0] = 0x80; raw[32] = 0x80; let der = der_encode_ecdsa_p256_sig(&raw).expect("encode");
745 assert_eq!(der[1], 70);
747 assert_eq!(der[3], 33);
749 assert_eq!(der[4], 0x00);
751 assert_eq!(der[5], 0x80);
752 assert_eq!(der[2 + 2 + 33], 0x02);
754 assert_eq!(der[2 + 2 + 33 + 1], 33);
755 }
756}
757
758#[cfg(test)]
759mod ca_signer_tests {
760 use super::*;
761 use rcgen::{CertificateParams, KeyPair as RcgenKeyPair};
762
763 #[test]
766 fn file_ca_signer_round_trips() {
767 let ca_key = RcgenKeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)
769 .expect("rcgen P-256 keypair");
770 let mut ca_params = CertificateParams::new(vec!["test-ca".to_string()]).unwrap();
771 ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
772 let ca_cert = ca_params.self_signed(&ca_key).expect("self-sign");
773
774 let tmp = tempfile::tempdir().unwrap();
775 let cert_path = tmp.path().join("ca.pem");
776 let key_path = tmp.path().join("ca.key");
777 std::fs::write(&cert_path, ca_cert.pem()).unwrap();
778 std::fs::write(&key_path, ca_key.serialize_pem()).unwrap();
779
780 let signer = FileCaSigner::from_paths(&cert_path, &key_path).expect("build FileCaSigner");
781
782 let agent_key =
785 RcgenKeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).expect("agent keypair");
786 let mut agent_params = CertificateParams::new(vec!["host-01".to_string()]).unwrap();
787 agent_params
788 .distinguished_name
789 .push(rcgen::DnType::CommonName, "host-01");
790 let csr = agent_params
791 .serialize_request(&agent_key)
792 .expect("serialize CSR");
793
794 let now = chrono::Utc::now();
795 let validity = std::time::Duration::from_secs(3600);
796 let (cert_pem, not_after) = issue_cert(
797 &csr.pem().unwrap(),
798 &signer,
799 validity,
800 now,
801 "fleet.example.com",
802 )
803 .expect("issue agent cert");
804
805 let diff = (not_after - now).num_seconds();
809 assert!(
810 (3599..=3601).contains(&diff),
811 "not_after - now must equal validity, got {diff}s",
812 );
813
814 let parsed =
817 rcgen::CertificateParams::from_ca_cert_pem(&cert_pem).expect("parse issued cert");
818 let cn = parsed
819 .distinguished_name
820 .iter()
821 .find_map(|(t, v)| {
822 if matches!(t, rcgen::DnType::CommonName) {
823 Some(v.clone())
824 } else {
825 None
826 }
827 })
828 .expect("cert has CN");
829 let cn_str = match cn {
830 rcgen::DnValue::PrintableString(s) => s.to_string(),
831 rcgen::DnValue::Utf8String(s) => s.to_string(),
832 _ => panic!("unexpected CN type"),
833 };
834 assert_eq!(cn_str, "agent-host-01.fleet.example.com");
835 }
836}
837
838#[cfg(test)]
839mod validate_csr_tests {
840 use super::*;
841 use base64::Engine;
842
843 fn openssh_line(raw: &[u8; 32]) -> String {
845 let mut blob = Vec::new();
846 blob.extend_from_slice(&(b"ssh-ed25519".len() as u32).to_be_bytes());
847 blob.extend_from_slice(b"ssh-ed25519");
848 blob.extend_from_slice(&(raw.len() as u32).to_be_bytes());
849 blob.extend_from_slice(raw);
850 let b64 = base64::engine::general_purpose::STANDARD.encode(&blob);
851 format!("ssh-ed25519 {b64} test@host")
852 }
853
854 #[test]
855 fn accepts_when_csr_pubkey_matches_declared() {
856 let raw = [0x42u8; 32];
857 let declared = openssh_line(&raw);
858 validate_csr_against_fleet_host(&raw, Some(&declared)).expect("should accept match");
859 }
860
861 #[test]
862 fn rejects_when_csr_pubkey_differs() {
863 let csr_raw = [0x42u8; 32];
864 let declared_raw = [0x43u8; 32];
865 let declared = openssh_line(&declared_raw);
866 let err = validate_csr_against_fleet_host(&csr_raw, Some(&declared)).unwrap_err();
867 let msg = format!("{err}");
868 assert!(msg.contains("does not match"), "msg = {msg}");
869 }
870
871 #[test]
872 fn rejects_when_no_pubkey_declared() {
873 let csr_raw = [0x42u8; 32];
874 let err = validate_csr_against_fleet_host(&csr_raw, None).unwrap_err();
875 let msg = format!("{err}");
876 assert!(msg.contains("declarative-enrollment policy"), "msg = {msg}",);
877 }
878
879 #[test]
880 fn rejects_when_declared_pubkey_unparseable() {
881 let csr_raw = [0x42u8; 32];
882 let err = validate_csr_against_fleet_host(&csr_raw, Some("ssh-rsa garbage")).unwrap_err();
883 let msg = format!("{err}");
884 assert!(msg.contains("parse declared"), "msg = {msg}");
886 }
887}
888
889#[cfg(test)]
890mod extract_pem_tests {
891 use super::*;
892
893 #[test]
894 fn accepts_single_block_key_pem() {
895 let cases = [
898 (
899 "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n",
900 "-----BEGIN PRIVATE KEY-----",
901 ),
902 (
903 "-----BEGIN EC PRIVATE KEY-----\nBBBB\n-----END EC PRIVATE KEY-----\n",
904 "-----BEGIN EC PRIVATE KEY-----",
905 ),
906 ];
907 for (input, expected_label) in cases {
908 let got = extract_private_key_pem_block(input).expect("single block");
909 assert!(got.starts_with(expected_label), "got: {got}");
910 }
911 }
912
913 #[test]
914 fn extracts_key_block_from_multi_block_openssl_ec() {
915 let input = "\
918-----BEGIN EC PARAMETERS-----
919BggqhkjOPQMBBw==
920-----END EC PARAMETERS-----
921-----BEGIN EC PRIVATE KEY-----
922MHcCAQEEIBoldKey...
923-----END EC PRIVATE KEY-----
924";
925 let got = extract_private_key_pem_block(input).expect("multi-block extract");
926 assert!(got.starts_with("-----BEGIN EC PRIVATE KEY-----"));
927 assert!(
928 !got.contains("EC PARAMETERS"),
929 "must drop the parameters block"
930 );
931 assert!(got.contains("MHcCAQEEIBoldKey"));
932 }
933
934 #[test]
935 fn rejects_input_without_key_block() {
936 let cases: &[(&str, &str)] = &[
939 (
940 "no key block (only EC PARAMETERS)",
941 "-----BEGIN EC PARAMETERS-----\nBggqhkjOPQMBBw==\n-----END EC PARAMETERS-----\n",
942 ),
943 ("garbage (not PEM at all)", "not a pem file at all"),
944 (
945 "non-key block (CERTIFICATE)",
946 "-----BEGIN CERTIFICATE-----\nZHVtbXk=\n-----END CERTIFICATE-----\n",
947 ),
948 ];
949 for (label, input) in cases {
950 let err = extract_private_key_pem_block(input)
951 .err()
952 .unwrap_or_else(|| panic!("expected error for: {label}"));
953 let msg = format!("{err}");
954 assert!(
955 msg.contains("PRIVATE KEY"),
956 "case '{label}': msg should mention accepted labels: {msg}",
957 );
958 }
959 }
960
961 #[test]
962 fn returns_first_matching_block_when_multiple_keys_present() {
963 let input = "\
966-----BEGIN PRIVATE KEY-----
967FIRST
968-----END PRIVATE KEY-----
969-----BEGIN PRIVATE KEY-----
970SECOND
971-----END PRIVATE KEY-----
972";
973 let got = extract_private_key_pem_block(input).expect("first key");
974 assert!(got.contains("FIRST"));
975 assert!(!got.contains("SECOND"));
976 }
977
978 #[test]
979 fn round_trips_rcgen_generated_pkcs8() {
980 use rcgen::KeyPair;
983 let key = KeyPair::generate().expect("rcgen generate");
984 let pem = key.serialize_pem();
985 let extracted = extract_private_key_pem_block(&pem).expect("rcgen pem");
986 let _reparsed = KeyPair::from_pem(&extracted).expect("rcgen round-trip");
987 }
988
989 #[test]
1002 fn handles_openssl_generated_multi_block_sec1() {
1003 use rcgen::KeyPair;
1004 let openssl_pem = "\
1005-----BEGIN EC PARAMETERS-----
1006BggqhkjOPQMBBw==
1007-----END EC PARAMETERS-----
1008-----BEGIN EC PRIVATE KEY-----
1009MHcCAQEEIKVWabY7MNGUf1iYmLXO8Jf8Z2Dyt3wqIGzKzr+VPvvjoAoGCCqGSM49
1010AwEHoUQDQgAEm9EgwijVZ1xORnA9p5crCZ60IGnjUJ4LZIXzk2hlxYeiifsnGk7H
1011QzkM5XocGuChmeKIaGD20dCxzEIuW+HP4Q==
1012-----END EC PRIVATE KEY-----
1013";
1014 let extracted =
1015 extract_private_key_pem_block(openssl_pem).expect("multi-block SEC1 extraction");
1016 assert!(extracted.starts_with("-----BEGIN EC PRIVATE KEY-----"));
1018 assert!(!extracted.contains("EC PARAMETERS"));
1019 let _key = KeyPair::from_pem(&extracted).expect(
1021 "rcgen on aws_lc_rs must accept SEC1 - if this fails, \
1022 check the rcgen feature flags in Cargo.toml",
1023 );
1024 }
1025}