nixfleet_control_plane/auth/
issuance.rs

1//! Cert issuance for `/v1/enroll` and `/v1/agent/renew`.
2
3use 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
16/// 30 days; agents self-pace renewal at 50% via `/v1/agent/renew`.
17pub const AGENT_CERT_VALIDITY: Duration = Duration::from_secs(30 * 24 * 60 * 60);
18
19/// Example suffix for canonical agent CNs (`agent-<machineId>.<suffix>`).
20/// Used as a stand-in by `ServerState::default()` / `ServeFlags::default()`
21/// in tests; NOT a production fallback. Production gets the suffix from
22/// `--agent-cn-suffix` via clap, which is a required argument with no
23/// default - the binary refuses to start if it's unset.
24pub const DEFAULT_AGENT_CN_SUFFIX: &str = "fleet.example.com";
25
26/// Build the canonical CN for an agent cert: `agent-<machineId>.<suffix>`.
27pub fn canonical_agent_cn(machine_id: &str, suffix: &str) -> String {
28    format!("agent-{machine_id}.{suffix}")
29}
30
31/// Idempotent: passes through bare CNs unchanged, strips canonical wrapper.
32pub 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/// Stage-typed error so axum handlers map each phase to the right StatusCode.
49#[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    /// No current/previous orgRootKey candidate verified the signature.
61    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
84/// Re-reads trust.json per call (key rotations apply without restart).
85/// ed25519-only. `now` enables the `successor` overlap window.
86pub 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
129/// Cryptographic signature only; caller handles replay/hostname/fingerprint/expiry.
130pub 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    // JCS canonical bytes match what the operator-side mint tool signed.
146    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
155/// Validates expiry, hostname-vs-CN, and pubkey fingerprint; caller verifies signature/replay.
156pub 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
180/// Base64(SHA-256(bytes)).
181pub fn fingerprint(pubkey_bytes: &[u8]) -> String {
182    let digest = Sha256::digest(pubkey_bytes);
183    base64::engine::general_purpose::STANDARD.encode(digest)
184}
185
186/// Extract the first private-key PEM block. Strips OpenSSL preambles
187/// (`EC PARAMETERS`) that rcgen's `KeyPair::from_pem` can't read.
188pub 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
228/// Bind agent identity to the host's declared SSH host pubkey. Fail-closed:
229/// no `pubkey` in fleet.nix ⇒ enrollment refused (declarative-enrollment
230/// policy, no permissive fallback).
231pub 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
254/// CA signer abstraction over file-backed + TPM-backed paths. `issuer()`
255/// caches the cert at construction; `make_key_pair()` produces a fresh signer
256/// per issuance so operator key rotations apply without restart.
257pub trait CaSigner: Send + Sync {
258    fn issuer(&self) -> &rcgen::Certificate;
259    fn make_key_pair(&self) -> Result<KeyPair>;
260}
261
262/// Builds a `CaSigner` from a flag triple. TPM (`pub_raw + wrapper`) wins
263/// over file (`fleet_ca_key`); both absent -> `None` (enroll/renew will 500).
264pub 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
312/// File-backed CA signer (current default; agenix-encrypted PEM on disk).
313pub 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        // FOOTGUN: rcgen's `from_pem` reads only the first block. OpenSSL
325        // EC keys ship `EC PARAMETERS` first and `EC PRIVATE KEY` second  -
326        // strip the parameters block before handing to rcgen.
327        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
354/// TPM-backed CA. Holds uncompressed SEC1 P-256 pubkey (`0x04 || X || Y`,
355/// 65 bytes - rcgen's ECDSA shape) + the `tpm-sign-<keyname>` wrapper path.
356/// Issuer cert is self-signed via TPM once at construction; the real CA
357/// signature (by the offline fleet root) lives on disk and rcgen never
358/// reads it (only DN + pubkey), so the re-self-sign is sound.
359pub struct TpmCaSigner {
360    pubkey_uncompressed: Vec<u8>,
361    sign_wrapper_path: PathBuf,
362    issuer_cert: rcgen::Certificate,
363}
364
365impl TpmCaSigner {
366    /// `tpm_pubkey_raw_path` = 64-byte X||Y (no leading 0x04).
367    /// `sign_wrapper_path` = `tpm-sign-<keyname>` binary.
368    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        // One TPM sign at startup to produce the in-memory issuer Cert.
389        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
421/// Shells out to the keyslot's `tpm-sign-<keyname>` (file in, raw R||S out);
422/// `der_encode_ecdsa_p256_sig` rewraps to the DER form rcgen expects.
423struct 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
447/// Write `msg` to a tempfile, invoke the tpm-sign wrapper, return stdout.
448fn 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
468/// Convert raw `R || S` (64 bytes for P-256) to DER `Ecdsa-Sig-Value
469/// SEQUENCE { r INTEGER, s INTEGER }` - what rcgen expects from a
470/// `RemoteKeyPair::sign` for ECDSA P-256.
471fn 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    // SEQUENCE for P-256 worst case: 2*(2+33) = 70 body, +2 header = 72; <128
479    // so always single-byte length.
480    let mut out = Vec::with_capacity(2 + body_len);
481    out.push(0x30); // SEQUENCE
482    out.push(body_len as u8);
483    out.extend(r);
484    out.extend(s);
485    Ok(out)
486}
487
488/// Big-endian unsigned int -> DER INTEGER. Strips leading zeros + pads if MSB set.
489fn 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); // INTEGER
498    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
506/// Issues an agent cert: clientAuth EKU + canonical CN `agent-<machineId>.<suffix>`
507/// + SAN `dNSName=<CN>` (rustls/webpki rejects CN-only certs). CSR CN is read as
508/// the bare machineId. Caller validates CSR-pubkey ↔ host-pubkey binding upstream.
509pub 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    // CSR CN = bare machineId; we rewrite to canonical FQDN for the D14
539    // name constraint. `extract_machine_id` is idempotent on canonical input.
540    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    // Preserve non-CN attributes (O, OU, C) the CSR carried.
547    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    // FOOTGUN: rustls/webpki rejects CN-only certs - SAN dNSName=CN is required for mTLS to work.
557    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
577/// Best-effort append; write failure warns but doesn't fail issuance.
578pub 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        // Pre-C.3 cert: CN=<machineId>, no FQDN. Must pass through
639        // unchanged so the renew handler's fleet.hosts lookup still
640        // works during the migration window.
641        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        // CN under an unexpected suffix -> treat as opaque, return as-is.
650        // Defensive: prevents accidental machineId collisions across
651        // suffixes (operator running two fleets with overlapping IDs).
652        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        // (label, input, expected)
678        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            // DER INTEGER 0 must be a single 0x00 byte.
691            (
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        // Mid-range r and s without MSB set - clean encoding, no padding.
716        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        // SEQUENCE
727        assert_eq!(der[0], 0x30);
728        // body length: 2 (INTEGER tag+len) + 32 + 2 + 32 = 68
729        assert_eq!(der[1], 68);
730        // first INTEGER
731        assert_eq!(der[2], 0x02);
732        assert_eq!(der[3], 32);
733        // second INTEGER
734        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        // MSB set in both r and s -> both pad to 33 bytes.
741        let mut raw = [0u8; 64];
742        raw[0] = 0x80; // r MSB set
743        raw[32] = 0x80; // s MSB set
744        let der = der_encode_ecdsa_p256_sig(&raw).expect("encode");
745        // body: 2 + 33 + 2 + 33 = 70
746        assert_eq!(der[1], 70);
747        // first INTEGER length 33
748        assert_eq!(der[3], 33);
749        // padding byte
750        assert_eq!(der[4], 0x00);
751        assert_eq!(der[5], 0x80);
752        // second INTEGER length 33
753        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    /// FileCaSigner round-trip: build, issue an agent cert, verify the
764    /// resulting PEM parses as an X.509 cert with the expected subject DN.
765    #[test]
766    fn file_ca_signer_round_trips() {
767        // Mint a throwaway P-256 CA for the test.
768        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        // Build a CSR for an agent with a fresh keypair. Agents emit
783        // CSRs with CN = bare machineId; the CP rewrites to canonical.
784        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        // Runtime-plumbed validity (no longer a hardcoded const) must
806        // produce not_after == now + validity. Guards short-cycle
807        // overrides used for hardware testing.
808        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        // Verify the issued PEM parses as an X.509 cert + CN was
815        // canonicalised to `agent-<machineId>.<suffix>` (D14).
816        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    /// Build a valid OpenSSH ed25519 pubkey line wrapping `raw`.
844    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        // The error chain mentions the parse failure context.
885        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        // Body is opaque to the extractor - it just needs the labels.
896        // PKCS#8 = rcgen / mkcert / openssl pkcs8 -topk8; SEC1 = openssl ecparam -genkey.
897        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        // Shape of `openssl ecparam -genkey -name prime256v1` output:
916        // EC PARAMETERS first (curve OID), then EC PRIVATE KEY.
917        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        // All non-key inputs must surface the same error mentioning the
937        // accepted PEM labels - the operator-facing hint.
938        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        // Defensive: file with two PRIVATE KEY blocks (would be unusual
964        // but possible). Take the first.
965        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        // Sanity: keys produced by rcgen still parse after going
981        // through the extractor (round-trip).
982        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    /// LOADBEARING: an actual OpenSSL-generated `prime256v1` SEC1 PEM
990    /// (multi-block: EC PARAMETERS + EC PRIVATE KEY) must extract to a
991    /// single-block SEC1 PEM that rcgen accepts. This is the shape the operator's
992    /// `fleet-ca-key.age` had - pre-fix, rcgen's `from_pem` (on the
993    /// default `ring` feature) rejected it. The fix combines: extract
994    /// the SEC1 block here + rcgen built with `aws_lc_rs` feature
995    /// (Cargo.toml) so SEC1 is accepted natively.
996    ///
997    /// Key bytes are throwaway test material (no secrets) - generated
998    /// with `openssl ecparam -genkey -name prime256v1` for this test.
999    /// Wrapped with the `EC PARAMETERS` block to match the shape
1000    /// `-text` mode emits - same as the operator's pre-fix `fleet-ca-key.age`.
1001    #[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        // Must extract just the EC PRIVATE KEY block.
1017        assert!(extracted.starts_with("-----BEGIN EC PRIVATE KEY-----"));
1018        assert!(!extracted.contains("EC PARAMETERS"));
1019        // And rcgen (on aws_lc_rs feature) must parse the result.
1020        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}