Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

src/pki.rs

//! AIRGAP4 — per-machine PKI.
//!
//! Design (`.nornir/airgap-install.md` §2, `install-system.md` Phase 2): an
//! air-gapped machine needs its own PKI. We support three sources via
//! [`PkiSource`](crate::pki::PkiSource):
//!
//! - [`PkiSource::SelfGenerated`](crate::pki::PkiSource::SelfGenerated) — **implemented, pure-Rust** (no network, no
//!   Vault): a real **ed25519** machine CA keypair + per-service leaf identities,
//!   issued as genuine **X.509** certificates via [`rcgen`]. Because the target
//!   has no NTP, validity is monotonic-only (we record a wide window but
//!   verification is signature-based, not clock-based — the airgap constraint
//!   table).
//! - [`PkiSource::ExistingVault`](crate::pki::PkiSource::ExistingVault) / [`PkiSource::TempVault`](crate::pki::PkiSource::TempVault) — the [`VaultClient`](crate::pki::VaultClient)
//!   trait, implemented for real behind the `vault` feature: [`HttpVaultClient`]
//!   makes the genuine `POST /v1/pki/issue/<role>` call against a Vault PKI engine
//!   (tested end-to-end against a mock Vault), and [`TempVaultClient`] spins a
//!   dev-mode Vault on the staging host. With the feature off, the trait is the
//!   honest seam — the lean offline core never pulls `reqwest`.
//!
//! The real cryptographic object is [`MachineCa`]: it holds a genuine ed25519
//! signing key and a self-signed X.509 CA certificate, can **issue** leaf certs
//! ([`LeafCert`](crate::pki::LeafCert)) — real X.509 with SANs, signed by the CA — and the leaf
//! **verifies** against the CA's public key and **loads into a rustls config**,
//! which is exactly what the test asserts (a real cert, not a placeholder).

use anyhow::Result;
use serde::{Deserialize, Serialize};

/// Where the PKI comes from (design §2).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PkiSource {
    /// Call an existing Vault at this address. SEAM — see [`VaultClient`].
    ExistingVault(String),
    /// Generate the PKI ourselves in pure Rust (ed25519). IMPLEMENTED.
    SelfGenerated,
    /// Spin a temporary Vault, generate, tear down. SEAM — see [`VaultClient`].
    TempVault,
}

/// A leaf identity issued by a [`MachineCa`]: a genuine **X.509** certificate
/// (PEM + DER) for the subject, signed by the CA and carrying the requested SANs.
/// Self-contained and verifiable offline; the DER/PEM load directly into rustls.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LeafCert {
    /// Subject common name (e.g. `holger.<hostname>.x14.local`).
    pub subject_cn: String,
    /// Subject alternative names (service DNS names / IPs).
    pub sans: Vec<String>,
    /// Issuing CA common name.
    pub issuer_cn: String,
    /// Validity, seconds since epoch (wide window — no NTP on target).
    pub not_before: i64,
    pub not_after: i64,
    /// The issued X.509 certificate, PEM-encoded (`-----BEGIN CERTIFICATE-----`).
    pub cert_pem: String,
    /// The same X.509 certificate, DER-encoded (parses straight into rustls /
    /// x509-parser without a PEM round-trip).
    pub cert_der: Vec<u8>,
}

#[cfg(feature = "pki")]
mod real {
    use super::*;
    use ed25519_dalek::{SigningKey, VerifyingKey};
    use rcgen::{
        BasicConstraints, CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose,
        IsCa, KeyPair, KeyUsagePurpose, PKCS_ED25519, SanType,
    };
    use rustls_pki_types::PrivatePkcs8KeyDer;
    use time::OffsetDateTime;

    /// A real ed25519 machine CA — the root of the per-machine PKI. Holds the
    /// dalek signing key (identity / [`verifying_key`](Self::verifying_key)) and
    /// the matching self-signed X.509 CA certificate used to sign leaves.
    pub struct MachineCa {
        common_name: String,
        signing: SigningKey,
        /// The CA's rcgen keypair (same ed25519 key as `signing`) + self-signed
        /// X.509 cert — used to sign issued leaves.
        ca: rcgen::Issuer<'static, KeyPair>,
        ca_cert: rcgen::Certificate,
        /// Wide validity window (no NTP on target → monotonic-only checks).
        not_before: i64,
        not_after: i64,
    }

    impl MachineCa {
        /// Generate a fresh machine CA for `hostname` (CN = `<hostname>.x14.local`).
        /// 10-year window (the airgap "no NTP → wide validity" guidance).
        pub fn generate(hostname: &str) -> Result<Self> {
            let now = chrono::Utc::now().timestamp();
            Self::build(
                format!("{hostname}.x14.local"),
                os_seed()?,
                now,
                now + 10 * 365 * 24 * 3600,
            )
        }

        /// Deterministic generation from a 32-byte seed — used by tests so the
        /// assertion is reproducible (no RNG flakiness), still a real keypair.
        pub fn from_seed(hostname: &str, seed: [u8; 32]) -> Self {
            Self::build(
                format!("{hostname}.x14.local"),
                seed,
                0,
                10 * 365 * 24 * 3600,
            )
            .expect("deterministic CA from fixed seed is infallible")
        }

        /// Construct the CA: derive the dalek key from `seed`, rebuild the same
        /// ed25519 key as an rcgen [`KeyPair`], and self-sign an X.509 CA cert.
        fn build(
            common_name: String,
            seed: [u8; 32],
            not_before: i64,
            not_after: i64,
        ) -> Result<Self> {
            let signing = SigningKey::from_bytes(&seed);
            let pkcs8 = pkcs8_v1_ed25519(&seed);
            let ca_key = KeyPair::from_pkcs8_der_and_sign_algo(
                &PrivatePkcs8KeyDer::from(pkcs8.as_slice()),
                &PKCS_ED25519,
            )
            .map_err(|e| anyhow::anyhow!("rcgen CA keypair from seed: {e}"))?;

            let mut params = CertificateParams::new(Vec::<String>::new())
                .map_err(|e| anyhow::anyhow!("rcgen CA params: {e}"))?;
            params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
            params.distinguished_name = DistinguishedName::new();
            params
                .distinguished_name
                .push(DnType::CommonName, common_name.clone());
            params.key_usages.push(KeyUsagePurpose::DigitalSignature);
            params.key_usages.push(KeyUsagePurpose::KeyCertSign);
            params.key_usages.push(KeyUsagePurpose::CrlSign);
            params.not_before = offset(not_before)?;
            params.not_after = offset(not_after)?;
            let ca_cert = params
                .self_signed(&ca_key)
                .map_err(|e| anyhow::anyhow!("rcgen self-sign CA: {e}"))?;
            let ca = rcgen::Issuer::new(params, ca_key);

            Ok(MachineCa {
                common_name,
                signing,
                ca,
                ca_cert,
                not_before,
                not_after,
            })
        }

        /// The CA's verifying (public) key.
        pub fn verifying_key(&self) -> VerifyingKey {
            self.signing.verifying_key()
        }

        /// Sign an arbitrary message with the CA's ed25519 signing key, returning
        /// the raw 64-byte signature. Used to attest airgap **bundle** authenticity
        /// (the packer signs the manifest `root_sha256`; see
        /// [`crate::pki::sign_bundle`]).
        pub fn sign(&self, msg: &[u8]) -> [u8; 64] {
            use ed25519_dalek::Signer;
            self.signing.sign(msg).to_bytes()
        }

        /// CA common name.
        pub fn common_name(&self) -> &str {
            &self.common_name
        }

        /// The self-signed X.509 CA certificate, PEM-encoded (the chain root that
        /// a verifier trusts to validate issued leaves).
        pub fn ca_cert_pem(&self) -> String {
            self.ca_cert.pem()
        }

        /// Issue a leaf identity for `service_cn` with `sans`, signed by this CA.
        /// Generates a fresh subject ed25519 keypair, mints a real X.509 leaf cert
        /// (ServerAuth EKU + SANs) signed by the CA, and returns the public
        /// [`LeafCert`](crate::pki::LeafCert) plus the subject's PKCS#8 private key (PEM) so the
        /// installer can write `<svc>.key`.
        pub fn issue_leaf(&self, service_cn: &str, sans: &[String]) -> Result<(LeafCert, String)> {
            let leaf_key = KeyPair::generate_for(&PKCS_ED25519)
                .map_err(|e| anyhow::anyhow!("rcgen leaf keypair: {e}"))?;

            let mut params = CertificateParams::new(Vec::<String>::new())
                .map_err(|e| anyhow::anyhow!("rcgen leaf params: {e}"))?;
            params.distinguished_name = DistinguishedName::new();
            params
                .distinguished_name
                .push(DnType::CommonName, service_cn);
            for san in sans {
                params.subject_alt_names.push(san_from_str(san)?);
            }
            params.use_authority_key_identifier_extension = true;
            params.key_usages.push(KeyUsagePurpose::DigitalSignature);
            params
                .extended_key_usages
                .push(ExtendedKeyUsagePurpose::ServerAuth);
            params.not_before = offset(self.not_before)?;
            params.not_after = offset(self.not_after)?;

            let cert = match params.signed_by(&leaf_key, &self.ca) {
                Ok(c) => c,
                Err(e) => {
                    // SURFACE: pki — the mint itself. RED if rcgen cannot sign the
                    // leaf under this CA (the airgap can't issue a machine cert).
                    crate::surface_status!(
                        "skidbladnir/pki",
                        "issue_leaf",
                        false,
                        "rcgen sign leaf `{service_cn}` under CA {} failed: {e}",
                        self.common_name
                    );
                    return Err(anyhow::anyhow!("rcgen sign leaf under CA: {e}"));
                }
            };

            let leaf = LeafCert {
                subject_cn: service_cn.to_string(),
                sans: sans.to_vec(),
                issuer_cn: self.common_name.clone(),
                not_before: self.not_before,
                not_after: self.not_after,
                cert_pem: cert.pem(),
                cert_der: cert.der().to_vec(),
            };
            // SURFACE: pki — green: a real X.509 leaf minted + signed by the CA.
            crate::surface_status!(
                "skidbladnir/pki",
                "issue_leaf",
                true,
                "minted leaf cn={service_cn} sans={} der={}B under CA {}",
                sans.len(),
                leaf.cert_der.len(),
                self.common_name
            );
            Ok((leaf, leaf_key.serialize_pem()))
        }
    }

    /// 32 bytes from the OS CSPRNG (getrandom) — the keypair seed.
    fn os_seed() -> Result<[u8; 32]> {
        let mut seed = [0u8; 32];
        getrandom::fill(&mut seed)
            .map_err(|e| anyhow::anyhow!("getrandom for PKI keypair seed: {e}"))?;
        Ok(seed)
    }

    /// Convert epoch seconds into the `time::OffsetDateTime` rcgen validity wants.
    fn offset(secs: i64) -> Result<OffsetDateTime> {
        OffsetDateTime::from_unix_timestamp(secs)
            .map_err(|e| anyhow::anyhow!("invalid PKI validity timestamp {secs}: {e}"))
    }

    /// A SAN string → rcgen [`SanType`]: an IP literal becomes `IpAddress`,
    /// anything else a `DnsName`.
    fn san_from_str(s: &str) -> Result<SanType> {
        if let Ok(ip) = s.parse::<std::net::IpAddr>() {
            Ok(SanType::IpAddress(ip))
        } else {
            Ok(SanType::DnsName(s.try_into().map_err(|e| {
                anyhow::anyhow!("invalid DNS SAN {s:?}: {e}")
            })?))
        }
    }

    /// Wrap a 32-byte ed25519 seed in a PKCS#8 v1 (`OneAsymmetricKey`) DER blob —
    /// the form rcgen/ring's `from_pkcs8_maybe_unchecked` accepts. The 16-byte
    /// prefix is the fixed Ed25519 PKCS#8 header (RFC 8410).
    fn pkcs8_v1_ed25519(seed: &[u8; 32]) -> Vec<u8> {
        let mut v = Vec::with_capacity(48);
        v.extend_from_slice(&[
            0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22,
            0x04, 0x20,
        ]);
        v.extend_from_slice(seed);
        v
    }

    /// Wrap a 32-byte ed25519 public key in a `SubjectPublicKeyInfo` DER blob
    /// (RFC 8410) so x509-parser can use it as the issuer key for verification.
    fn spki_ed25519(pubkey: &[u8; 32]) -> Vec<u8> {
        let mut v = Vec::with_capacity(44);
        v.extend_from_slice(&[
            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
        ]);
        v.extend_from_slice(pubkey);
        v
    }

    /// Verify a raw ed25519 signature with **`verify_strict`** (rejects
    /// small-order / non-canonical `R` — the strict check the airgap authenticity
    /// gate requires). Returns `false` on any decode/verify failure.
    pub fn verify_signature(pubkey: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> bool {
        let vk = match VerifyingKey::from_bytes(pubkey) {
            Ok(vk) => vk,
            Err(_) => return false,
        };
        let signature = ed25519_dalek::Signature::from_bytes(sig);
        vk.verify_strict(msg, &signature).is_ok()
    }

    /// Verify a [`LeafCert`](crate::pki::LeafCert) against an issuing CA verifying key — the real
    /// cryptographic check the test asserts on: parse the issued X.509 and verify
    /// its signature with `ca`'s ed25519 public key.
    pub fn verify_leaf(cert: &LeafCert, ca: &VerifyingKey) -> bool {
        use x509_parser::prelude::{FromDer, SubjectPublicKeyInfo, X509Certificate};
        let leaf = match X509Certificate::from_der(&cert.cert_der) {
            Ok((_, c)) => c,
            Err(_) => return false,
        };
        let spki_der = spki_ed25519(ca.as_bytes());
        let spki = match SubjectPublicKeyInfo::from_der(&spki_der) {
            Ok((_, s)) => s,
            Err(_) => return false,
        };
        leaf.verify_signature(Some(&spki)).is_ok()
    }
}

#[cfg(feature = "pki")]
pub use real::{MachineCa, verify_leaf, verify_signature};

/// Sign an airgap **bundle** for authenticity: load its manifest, ed25519-sign the
/// `root_sha256` with `ca`, and write the detached [`crate::archive::BundleSignature`]
/// sidecar (`<bundle>.sig.json`). This is the PACK-time authenticity step — the
/// packer's CA is the identity a target pins (out-of-band) via
/// [`crate::archive::TrustRoot`]. Feature `pki`.
#[cfg(feature = "pki")]
pub fn sign_bundle(
    bundle: &std::path::Path,
    ca: &MachineCa,
) -> Result<crate::archive::BundleSignature> {
    use crate::archive::{BundleSignature, load_manifest, signature_path};
    let manifest = load_manifest(bundle)?;
    let sig = ca.sign(manifest.root_sha256.as_bytes());
    let bs = BundleSignature {
        root_sha256: manifest.root_sha256.clone(),
        signature_hex: hex::encode(sig),
        signer_pubkey_hex: hex::encode(ca.verifying_key().to_bytes()),
        ca_cert_pem: Some(ca.ca_cert_pem()),
    };
    std::fs::write(signature_path(bundle), serde_json::to_vec_pretty(&bs)?)
        .map_err(|e| anyhow::anyhow!("write bundle signature sidecar: {e}"))?;
    Ok(bs)
}

/// Verify a bundle's [`crate::archive::BundleSignature`] against a **pinned**
/// [`crate::archive::TrustRoot`] using `verify_strict`. The signature is checked
/// under the PINNED key (never the bundle's embedded key — that would be circular);
/// callers that also require the embedded signer to match the pin do so separately
/// (see [`crate::fetch`]). Feature `pki`.
#[cfg(feature = "pki")]
pub fn verify_bundle_signature(
    sig: &crate::archive::BundleSignature,
    trust_root: &crate::archive::TrustRoot,
) -> bool {
    let pk_bytes = match hex::decode(&trust_root.ca_pubkey_hex) {
        Ok(b) => b,
        Err(_) => return false,
    };
    let sig_bytes = match hex::decode(&sig.signature_hex) {
        Ok(b) => b,
        Err(_) => return false,
    };
    let pk: [u8; 32] = match pk_bytes.try_into() {
        Ok(a) => a,
        Err(_) => return false,
    };
    let s: [u8; 64] = match sig_bytes.try_into() {
        Ok(a) => a,
        Err(_) => return false,
    };
    verify_signature(&pk, sig.root_sha256.as_bytes(), &s)
}

/// A leaf cert **issued by Vault's PKI engine** — a real X.509 chain returned by
/// `POST /v1/pki/issue/<role>` (PEM `certificate` + `private_key` + `issuing_ca`),
/// plus the parsed subject CN / SANs / serial. Distinct from [`LeafCert`] (our
/// pure-Rust ed25519 self-signed shape): Vault issues RSA/ECDSA X.509, so we keep
/// the PEM verbatim rather than forcing it into the ed25519 struct.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VaultIssuedCert {
    /// Subject common name requested.
    pub subject_cn: String,
    /// Subject alternative names requested.
    pub sans: Vec<String>,
    /// PEM-encoded leaf certificate (`-----BEGIN CERTIFICATE-----`).
    pub certificate_pem: String,
    /// PEM-encoded private key for the leaf.
    pub private_key_pem: String,
    /// PEM-encoded issuing CA certificate (the chain root for this leaf).
    pub issuing_ca_pem: String,
    /// Vault's serial number for the issued cert (colon-hex).
    pub serial_number: String,
}

impl VaultIssuedCert {
    /// Whether the PEM material looks like a real, parseable cert + key (the
    /// receiver-side sanity check; full chain verification is left to the TLS
    /// stack at connect time).
    pub fn is_well_formed(&self) -> bool {
        self.certificate_pem.contains("-----BEGIN CERTIFICATE-----")
            && self.certificate_pem.contains("-----END CERTIFICATE-----")
            && self.private_key_pem.contains("-----BEGIN")
            && !self.serial_number.is_empty()
    }
}

/// An existing/temporary Vault PKI backend. With the `vault` feature on,
/// [`HttpVaultClient`] makes the **real** `POST /v1/pki/issue/<role>` call.
pub trait VaultClient {
    /// Issue a leaf cert for `cn`/`sans` from the Vault PKI engine.
    fn issue(&self, cn: &str, sans: &[String]) -> Result<VaultIssuedCert>;
}

/// The Vault PKI HTTP integration (feature `vault`).
#[cfg(feature = "vault")]
pub mod vault {
    use super::*;
    use std::time::Duration;

    /// A client for an **existing** Vault, authenticated by a token. Talks to the
    /// PKI secrets engine mounted at `pki` (override via [`HttpVaultClient::mount`])
    /// and issues leaves against `role`. Its REST call goes through the shared
    /// [`crate::vault_http::VaultHttp`] transport (reuse-law L5, twinned with the
    /// openbao provisioner); this type keeps the error-surface + parse response
    /// handling.
    pub struct HttpVaultClient {
        mount: String,
        role: String,
        ttl: Option<String>,
        http: crate::vault_http::VaultHttp,
    }

    impl HttpVaultClient {
        /// Connect to an existing Vault at `addr` (e.g. `https://vault:8200`) with
        /// `token` (the `X-Vault-Token` header), issuing against PKI `role`.
        pub fn new(
            addr: impl Into<String>,
            token: impl Into<String>,
            role: impl Into<String>,
        ) -> Result<Self> {
            let http = crate::vault_http::VaultHttp::new(addr, token, Duration::from_secs(30))?;
            Ok(HttpVaultClient {
                mount: "pki".to_string(),
                role: role.into(),
                ttl: None,
                http,
            })
        }

        /// Override the PKI mount path (default `pki`).
        pub fn mount(mut self, mount: impl Into<String>) -> Self {
            self.mount = mount.into();
            self
        }

        /// Request a specific TTL for the issued cert (e.g. `"720h"`).
        pub fn ttl(mut self, ttl: impl Into<String>) -> Self {
            self.ttl = Some(ttl.into());
            self
        }

        /// The Vault path (after `/v1/`) the issue call POSTs to: `<mount>/issue/<role>`.
        fn issue_path(&self) -> String {
            format!("{}/issue/{}", self.mount, self.role)
        }

        /// The issue endpoint URL: `<addr>/v1/<mount>/issue/<role>`.
        pub fn issue_url(&self) -> String {
            self.http.url(&self.issue_path())
        }

        /// The JSON request body Vault's PKI `issue` endpoint expects.
        pub fn issue_request_body(
            cn: &str,
            sans: &[String],
            ttl: Option<&str>,
        ) -> serde_json::Value {
            let mut body = serde_json::json!({
                "common_name": cn,
                "format": "pem",
            });
            if !sans.is_empty() {
                body["alt_names"] = serde_json::Value::String(sans.join(","));
            }
            if let Some(ttl) = ttl {
                body["ttl"] = serde_json::Value::String(ttl.to_string());
            }
            body
        }
    }

    impl VaultClient for HttpVaultClient {
        fn issue(&self, cn: &str, sans: &[String]) -> Result<VaultIssuedCert> {
            let body = Self::issue_request_body(cn, sans, self.ttl.as_deref());
            // Shared transport does the token-authenticated POST; this wrapper keeps
            // pki's error-surface + parse response handling.
            let (status, text) = self.http.post_json(&self.issue_path(), &body)?;
            if !status.is_success() {
                anyhow::bail!("Vault issue returned HTTP {status}: {text}");
            }
            parse_issue_response(cn, sans, &text)
        }
    }

    /// Parse Vault's `POST /v1/pki/issue/<role>` JSON response into a
    /// [`VaultIssuedCert`]. Pure → unit-testable against a captured response shape.
    pub fn parse_issue_response(cn: &str, sans: &[String], json: &str) -> Result<VaultIssuedCert> {
        let v: serde_json::Value = serde_json::from_str(json)
            .map_err(|e| anyhow::anyhow!("parse Vault issue JSON: {e}"))?;
        let data = v
            .get("data")
            .ok_or_else(|| anyhow::anyhow!("Vault issue response missing `data`"))?;
        let get = |k: &str| -> Result<String> {
            data.get(k)
                .and_then(|x| x.as_str())
                .map(str::to_string)
                .ok_or_else(|| anyhow::anyhow!("Vault issue response missing `data.{k}`"))
        };
        let cert = VaultIssuedCert {
            subject_cn: cn.to_string(),
            sans: sans.to_vec(),
            certificate_pem: get("certificate")?,
            private_key_pem: get("private_key")?,
            issuing_ca_pem: get("issuing_ca")?,
            serial_number: get("serial_number")?,
        };
        if !cert.is_well_formed() {
            anyhow::bail!("Vault issued a malformed cert (PEM/serial check failed)");
        }
        Ok(cert)
    }

    /// A **temporary, dev-mode Vault** spun up on the build/staging host: launch
    /// `vault server -dev`, enable + configure the PKI engine, then issue from it.
    /// This is the `TempVault` path — it needs the `vault` binary present (it is a
    /// staging-host tool, not an airgap-target tool), so it errors honestly when
    /// the binary is absent rather than faking a Vault.
    pub struct TempVaultClient {
        child: std::process::Child,
        inner: HttpVaultClient,
    }

    impl TempVaultClient {
        /// Spawn `vault server -dev` with a fixed root token, configure a PKI
        /// engine + role, and return a client bound to it. Tears the server down
        /// on [`Drop`].
        pub fn spawn(role: &str) -> Result<Self> {
            // `vault` is a staging-host convenience; absent on an airgap target.
            let present = std::process::Command::new("vault")
                .arg("--version")
                .output()
                .map(|o| o.status.success())
                .unwrap_or(false);
            if !present {
                anyhow::bail!(
                    "TempVault requires the `vault` binary (staging-host tool); not found in PATH"
                );
            }
            let token = "nornir-temp-root";
            let addr = "http://127.0.0.1:8200";
            let child = std::process::Command::new("vault")
                .args([
                    "server",
                    "-dev",
                    "-dev-root-token-id",
                    token,
                    "-dev-listen-address",
                    "127.0.0.1:8200",
                ])
                .stdout(std::process::Stdio::null())
                .stderr(std::process::Stdio::null())
                .spawn()
                .map_err(|e| anyhow::anyhow!("spawn `vault server -dev`: {e}"))?;

            // Give dev-mode Vault a moment to bind + unseal, then enable PKI.
            std::thread::sleep(Duration::from_millis(800));
            let http = crate::http_tls::tls_roots_blocking(reqwest::blocking::Client::builder())
                .timeout(Duration::from_secs(15))
                .build()?;
            // Mount the PKI engine.
            http.post(format!("{addr}/v1/sys/mounts/pki"))
                .header("X-Vault-Token", token)
                .json(&serde_json::json!({ "type": "pki" }))
                .send()
                .map_err(|e| anyhow::anyhow!("enable PKI engine on temp Vault: {e}"))?;
            // Generate an internal root CA.
            http.post(format!("{addr}/v1/pki/root/generate/internal"))
                .header("X-Vault-Token", token)
                .json(&serde_json::json!({ "common_name": "nornir-temp-ca", "ttl": "87600h" }))
                .send()
                .map_err(|e| anyhow::anyhow!("generate temp Vault root CA: {e}"))?;
            // Create the issuing role (allow any CN so the installer can pick).
            http.post(format!("{addr}/v1/pki/roles/{role}"))
                .header("X-Vault-Token", token)
                .json(&serde_json::json!({
                    "allow_any_name": true, "max_ttl": "8760h", "key_type": "rsa", "key_bits": 2048
                }))
                .send()
                .map_err(|e| anyhow::anyhow!("create temp Vault PKI role: {e}"))?;

            let inner = HttpVaultClient::new(addr, token, role)?;
            Ok(TempVaultClient { child, inner })
        }
    }

    impl VaultClient for TempVaultClient {
        fn issue(&self, cn: &str, sans: &[String]) -> Result<VaultIssuedCert> {
            self.inner.issue(cn, sans)
        }
    }

    impl Drop for TempVaultClient {
        fn drop(&mut self) {
            let _ = self.child.kill();
            let _ = self.child.wait();
        }
    }
}

#[cfg(feature = "vault")]
pub use vault::{HttpVaultClient, TempVaultClient};

/// Materialize the PKI per the chosen source.
///
/// `SelfGenerated` is fully implemented (pure-Rust ed25519). The Vault variants
/// require a [`VaultClient`] impl — passing `None` for them is an explicit,
/// honest error rather than a silent stub.
pub fn provision(src: &PkiSource, vault: Option<&dyn VaultClient>) -> Result<()> {
    match src {
        PkiSource::SelfGenerated => {
            #[cfg(feature = "pki")]
            {
                // Smoke the real path: generate a CA + a leaf so a caller without
                // a service list still proves the engine works.
                let ca = MachineCa::generate(&hostname())?;
                let _ = ca.issue_leaf("default", &[])?;
                Ok(())
            }
            #[cfg(not(feature = "pki"))]
            {
                anyhow::bail!("SelfGenerated PKI requires the `pki` cargo feature")
            }
        }
        // ExistingVault / TempVault: with a `VaultClient` we make the real
        // `POST /v1/pki/issue/<role>` call (see [`vault::HttpVaultClient`]).
        // Without one, this is an honest error rather than a silent fake.
        PkiSource::ExistingVault(_) | PkiSource::TempVault => match vault {
            Some(client) => {
                // Smoke the real path: issue a default leaf so a caller without a
                // service list still proves the engine is reachable + issuing.
                let cert = client.issue(&format!("default.{}", hostname()), &[])?;
                anyhow::ensure!(cert.is_well_formed(), "Vault issued a malformed cert");
                Ok(())
            }
            None => anyhow::bail!(
                "Vault PKI source needs a VaultClient impl — \
                 use `HttpVaultClient::new(addr, token, role)` (feature `vault`)"
            ),
        },
    }
}

fn hostname() -> String {
    std::env::var("HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
}

#[cfg(all(test, feature = "pki"))]
mod tests {
    use super::*;

    /// LAW 1 (inject-assert): SelfGenerated PKI produces a REAL X.509 cert — it
    /// parses as valid X.509 (right subject + SANs), its signature **verifies**
    /// against the CA's ed25519 key (a different CA is rejected), and crucially it
    /// **loads into a real rustls `ServerConfig`** — the whole point of moving off
    /// the bespoke non-X.509 shape.
    #[test]
    fn self_generated_leaf_is_real_x509_and_loads_into_rustls() {
        use x509_parser::prelude::*;

        let ca = MachineCa::from_seed("oden", [7u8; 32]);
        let (cert, key_pem) = ca
            .issue_leaf(
                "holger.oden.x14.local",
                &["holger.oden.x14.local".into(), "10.0.0.5".into()],
            )
            .unwrap();

        assert_eq!(cert.issuer_cn, "oden.x14.local");
        assert_eq!(cert.sans, vec!["holger.oden.x14.local", "10.0.0.5"]);
        assert!(cert.not_after > cert.not_before, "wide validity window");
        assert!(cert.cert_pem.contains("-----BEGIN CERTIFICATE-----"));
        assert!(key_pem.contains("-----BEGIN PRIVATE KEY-----"));

        // Parses as genuine X.509 with the requested subject + SANs.
        let (_, x509) = X509Certificate::from_der(&cert.cert_der).expect("valid X.509 DER");
        assert!(x509.subject().to_string().contains("holger.oden.x14.local"));
        let san = x509
            .subject_alternative_name()
            .expect("SAN ext parses")
            .expect("leaf has a SAN extension");
        assert!(
            san.value
                .general_names
                .iter()
                .any(|g| matches!(g, GeneralName::DNSName("holger.oden.x14.local"))),
            "DNS SAN present"
        );
        assert!(
            san.value
                .general_names
                .iter()
                .any(|g| matches!(g, GeneralName::IPAddress(_))),
            "IP SAN present"
        );

        // The leaf's signature VERIFIES against the CA — a real chain, not a stub.
        assert!(
            verify_leaf(&cert, &ca.verifying_key()),
            "leaf verifies under CA"
        );
        let other = MachineCa::from_seed("loki", [9u8; 32]);
        assert!(
            !verify_leaf(&cert, &other.verifying_key()),
            "wrong CA rejected"
        );

        // GOLD: rustls actually loads the rcgen-issued leaf cert + key.
        let certs = rustls_pemfile::certs(&mut cert.cert_pem.as_bytes())
            .collect::<std::result::Result<Vec<_>, _>>()
            .expect("parse leaf cert PEM");
        let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
            .expect("read key PEM")
            .expect("leaf key present");
        let provider = std::sync::Arc::new(rustls::crypto::ring::default_provider());
        rustls::ServerConfig::builder_with_provider(provider)
            .with_safe_default_protocol_versions()
            .unwrap()
            .with_no_client_auth()
            .with_single_cert(certs, key)
            .expect("rustls loads the rcgen-generated X.509 leaf + key");
    }

    /// `provision(SelfGenerated)` exercises the real engine end to end.
    #[test]
    fn provision_self_generated_ok() {
        assert!(provision(&PkiSource::SelfGenerated, None).is_ok());
    }

    /// The Vault sources error WITHOUT a client (honest, no silent fake), but
    /// succeed WITH a real-shape client (covered by the `vault`-gated tests).
    #[test]
    fn vault_sources_require_a_client() {
        assert!(provision(&PkiSource::ExistingVault("https://vault:8200".into()), None).is_err());
        assert!(provision(&PkiSource::TempVault, None).is_err());
    }

    /// `from_seed` is DETERMINISTIC: same hostname+seed → the identical ed25519 CA
    /// identity, proven cryptographically (a leaf issued by CA `a` verifies under
    /// CA `b`'s public key because they are the same seed-derived key), while a
    /// different seed yields a different CA that rejects the leaf.
    #[test]
    fn from_seed_is_deterministic() {
        let a = MachineCa::from_seed("oden", [3u8; 32]);
        let b = MachineCa::from_seed("oden", [3u8; 32]);
        assert_eq!(
            a.verifying_key().to_bytes(),
            b.verifying_key().to_bytes(),
            "same seed → identical CA public key"
        );
        assert_eq!(a.common_name(), b.common_name());
        assert_eq!(a.common_name(), "oden.x14.local");

        let (leaf, _key) = a
            .issue_leaf("svc.oden.x14.local", &["svc.oden.x14.local".into()])
            .unwrap();
        assert!(
            verify_leaf(&leaf, &b.verifying_key()),
            "reproduced CA key verifies a's leaf"
        );

        let c = MachineCa::from_seed("oden", [4u8; 32]);
        assert_ne!(a.verifying_key().to_bytes(), c.verifying_key().to_bytes());
        assert!(
            !verify_leaf(&leaf, &c.verifying_key()),
            "different-seed CA rejects the leaf"
        );
    }

    /// A leaf carrying MULTIPLE DNS SANs plus multiple IP SANs parses as X.509 with
    /// every name present (and in order), and verifies under its CA.
    #[test]
    fn multiple_sans_are_all_present_in_leaf() {
        use x509_parser::prelude::*;

        let ca = MachineCa::from_seed("oden", [11u8; 32]);
        let sans: Vec<String> = vec![
            "a.oden.x14.local".into(),
            "b.oden.x14.local".into(),
            "c.oden.x14.local".into(),
            "10.0.0.7".into(),
            "192.168.1.1".into(),
        ];
        let (leaf, _key) = ca.issue_leaf("multi.oden.x14.local", &sans).unwrap();
        assert_eq!(leaf.sans, sans);

        let (_, x509) = X509Certificate::from_der(&leaf.cert_der).expect("valid X.509");
        let ext = x509
            .subject_alternative_name()
            .expect("SAN ext parses")
            .expect("leaf has SANs");
        let dns: Vec<&str> = ext
            .value
            .general_names
            .iter()
            .filter_map(|g| match g {
                GeneralName::DNSName(d) => Some(*d),
                _ => None,
            })
            .collect();
        assert_eq!(
            dns,
            vec!["a.oden.x14.local", "b.oden.x14.local", "c.oden.x14.local"],
            "all DNS SANs present, in order"
        );
        let ip_count = ext
            .value
            .general_names
            .iter()
            .filter(|g| matches!(g, GeneralName::IPAddress(_)))
            .count();
        assert_eq!(ip_count, 2, "both IP SANs present");
        assert!(verify_leaf(&leaf, &ca.verifying_key()));
    }

    /// The ed25519 **bundle-signing** primitives: `MachineCa::sign` + the standalone
    /// `verify_signature` (verify_strict) round-trip, a wrong key / tampered message /
    /// tampered signature are rejected, and the `sign_bundle`→`verify_bundle_signature`
    /// pair round-trips against a pinned `TrustRoot`.
    #[test]
    fn bundle_signing_roundtrip_and_rejections() {
        use crate::archive::{BundleSignature, TrustRoot};

        let ca = MachineCa::from_seed("packer", [77u8; 32]);
        let pk = ca.verifying_key().to_bytes();
        let msg = b"root-sha256-hex-value";
        let sig = ca.sign(msg);

        // Genuine signature verifies under the CA's key (verify_strict).
        assert!(verify_signature(&pk, msg, &sig));
        // Wrong key, tampered message, tampered signature → all rejected.
        let other = MachineCa::from_seed("other", [88u8; 32])
            .verifying_key()
            .to_bytes();
        assert!(!verify_signature(&other, msg, &sig));
        assert!(!verify_signature(&pk, b"different-message", &sig));
        let mut bad = sig;
        bad[0] ^= 0xFF;
        assert!(!verify_signature(&pk, msg, &bad));

        // The BundleSignature-level check against a pinned TrustRoot.
        let bs = BundleSignature {
            root_sha256: String::from_utf8(msg.to_vec()).unwrap(),
            signature_hex: hex::encode(sig),
            signer_pubkey_hex: hex::encode(pk),
            ca_cert_pem: Some(ca.ca_cert_pem()),
        };
        let trust = TrustRoot::from_pubkey_bytes(&pk);
        assert!(
            super::verify_bundle_signature(&bs, &trust),
            "pinned key verifies"
        );
        let wrong_pin = TrustRoot::from_pubkey_bytes(&other);
        assert!(
            !super::verify_bundle_signature(&bs, &wrong_pin),
            "wrong pin rejected"
        );
    }

    /// A leaf whose DER is mutated by a single byte FAILS verification under its
    /// own CA (signature/parse no longer holds) — tamper detection on the wire.
    #[test]
    fn tampered_leaf_cert_is_rejected() {
        let ca = MachineCa::from_seed("oden", [23u8; 32]);
        let (leaf, _key) = ca
            .issue_leaf("svc.oden.x14.local", &["svc.oden.x14.local".into()])
            .unwrap();
        assert!(
            verify_leaf(&leaf, &ca.verifying_key()),
            "pristine leaf verifies"
        );

        let mut tampered = leaf.clone();
        let mid = tampered.cert_der.len() / 2;
        tampered.cert_der[mid] ^= 0xFF;
        assert!(
            !verify_leaf(&tampered, &ca.verifying_key()),
            "single-byte tamper rejected"
        );
    }
}

#[cfg(all(test, feature = "vault"))]
mod vault_tests {
    use super::vault::*;
    use super::*;
    use crate::mockhttp::MockServer;

    /// A representative `POST /v1/pki/issue/<role>` response body from Vault.
    const VAULT_ISSUE_RESP: &str = r#"{
      "request_id": "abc",
      "lease_duration": 2592000,
      "data": {
        "certificate": "-----BEGIN CERTIFICATE-----\nMIIBfake\n-----END CERTIFICATE-----",
        "issuing_ca": "-----BEGIN CERTIFICATE-----\nMIIBca\n-----END CERTIFICATE-----",
        "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIBkey\n-----END RSA PRIVATE KEY-----",
        "private_key_type": "rsa",
        "serial_number": "3f:1a:2b:4c:5d"
      }
    }"#;

    /// LAW 1 (inject-assert): the Vault issue REQUEST shape is exactly what the
    /// PKI engine expects — right URL (`/v1/pki/issue/<role>`), token header,
    /// JSON body with `common_name` + comma-joined `alt_names` + `format=pem` —
    /// and the returned cert PARSES into a well-formed [`VaultIssuedCert`].
    #[test]
    fn issue_request_shape_and_response_parse() {
        let server = MockServer::start(1, 200, VAULT_ISSUE_RESP);
        let client = HttpVaultClient::new(server.base_url(), "s.token-123", "server")
            .unwrap()
            .ttl("720h");

        // Make the real HTTP call on a worker; the mock captures the request.
        let url = client.issue_url();
        let cert = std::thread::scope(|s| {
            let h = s.spawn(|| {
                client.issue(
                    "holger.oden.x14.local",
                    &["holger".into(), "10.0.0.5".into()],
                )
            });
            let req = server.next_request();
            // Request line.
            assert_eq!(req.method, "POST");
            assert_eq!(req.path, "/v1/pki/issue/server");
            assert!(url.ends_with("/v1/pki/issue/server"));
            // Auth header (the Vault token).
            assert_eq!(req.header("X-Vault-Token"), Some("s.token-123"));
            // Body shape.
            let body: serde_json::Value = serde_json::from_str(&req.body).unwrap();
            assert_eq!(body["common_name"], "holger.oden.x14.local");
            assert_eq!(body["alt_names"], "holger,10.0.0.5");
            assert_eq!(body["format"], "pem");
            assert_eq!(body["ttl"], "720h");
            h.join().unwrap()
        })
        .expect("issue ok");

        // Response parsed into a real cert.
        assert_eq!(cert.subject_cn, "holger.oden.x14.local");
        assert_eq!(cert.sans, vec!["holger", "10.0.0.5"]);
        assert!(cert.certificate_pem.contains("BEGIN CERTIFICATE"));
        assert!(cert.private_key_pem.contains("BEGIN RSA PRIVATE KEY"));
        assert!(cert.issuing_ca_pem.contains("BEGIN CERTIFICATE"));
        assert_eq!(cert.serial_number, "3f:1a:2b:4c:5d");
        assert!(cert.is_well_formed());
    }

    /// The pure response parser rejects a malformed (missing-field) body.
    #[test]
    fn parse_rejects_missing_fields() {
        assert!(parse_issue_response("cn", &[], r#"{"data":{"certificate":"x"}}"#).is_err());
        assert!(parse_issue_response("cn", &[], r#"{}"#).is_err());
    }

    /// A non-2xx Vault response is surfaced as an error (with the body).
    #[test]
    fn http_error_is_surfaced() {
        let server = MockServer::start(1, 403, r#"{"errors":["permission denied"]}"#);
        let client = HttpVaultClient::new(server.base_url(), "bad", "server").unwrap();
        let res = std::thread::scope(|s| {
            let h = s.spawn(|| client.issue("cn", &[]));
            let _ = server.next_request();
            h.join().unwrap()
        });
        let err = res.unwrap_err().to_string();
        assert!(err.contains("403"), "surfaces HTTP status: {err}");
    }

    /// `provision(ExistingVault, Some(client))` drives the real issue call.
    #[test]
    fn provision_existing_vault_with_client() {
        let server = MockServer::start(1, 200, VAULT_ISSUE_RESP);
        let client = HttpVaultClient::new(server.base_url(), "tok", "server").unwrap();
        let res = std::thread::scope(|s| {
            let h = s.spawn(|| provision(&PkiSource::ExistingVault(String::new()), Some(&client)));
            let _ = server.next_request();
            h.join().unwrap()
        });
        assert!(res.is_ok(), "provision via Vault client succeeds: {res:?}");
    }

    /// `TempVault::spawn` is honest when the `vault` binary is absent.
    #[test]
    fn temp_vault_needs_the_binary() {
        if std::process::Command::new("vault")
            .arg("--version")
            .output()
            .map(|o| o.status.success())
            .unwrap_or(false)
        {
            return; // staging host has vault — skip the absence assertion
        }
        assert!(TempVaultClient::spawn("server").is_err());
    }
}

/// Known-answer test for the machine CA (2026-10-02, W3 crypto bump).
///
/// The fixtures in `testdata/pki-kat/` were written by the PRE-bump stack
/// (ed25519-dalek 2 / rcgen 0.13). A seeded CA with a fixed validity window is
/// fully deterministic (Ed25519 is RFC 8032, the window is pinned), so every
/// later stack must re-produce the same public key, the same raw signature and
/// the same CA certificate PEM — airgap bundles signed today stay verifiable and
/// a re-built CA is the SAME CA. Regenerate ONLY on purpose:
/// `SKIDBLADNIR_PKI_KAT_REGEN=1 cargo test --features pki pki_kat`.
#[cfg(all(test, feature = "pki"))]
mod pki_kat {
    use super::*;
    use std::path::PathBuf;

    const SEED: [u8; 32] = [7u8; 32];
    const MSG: &[u8] = b"skidbladnir pki kat: root_sha256 stand-in";

    fn dir() -> PathBuf {
        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/pki-kat")
    }
    fn get(name: &str) -> Vec<u8> {
        std::fs::read(dir().join(name)).unwrap_or_else(|e| panic!("fixture {name}: {e}"))
    }

    #[test]
    fn pki_kat_seeded_ca_is_byte_identical() {
        let ca = MachineCa::from_seed("kat", SEED);
        let pk = ca.verifying_key().to_bytes().to_vec();
        let sig = ca.sign(MSG).to_vec();
        let pem = ca.ca_cert_pem().into_bytes();
        if std::env::var_os("SKIDBLADNIR_PKI_KAT_REGEN").is_some() {
            std::fs::create_dir_all(dir()).unwrap();
            std::fs::write(dir().join("ca.pub"), &pk).unwrap();
            std::fs::write(dir().join("msg.sig"), &sig).unwrap();
            std::fs::write(dir().join("ca.pem"), &pem).unwrap();
        }
        // Deterministic within one stack, too (a non-deterministic field would make the pin meaningless).
        assert_eq!(MachineCa::from_seed("kat", SEED).ca_cert_pem().into_bytes(), pem);
        assert_eq!(pk, get("ca.pub"), "CA public key changed");
        assert_eq!(sig, get("msg.sig"), "raw Ed25519 signature changed");
        assert_eq!(pem, get("ca.pem"), "seeded CA certificate changed");
        let old_pk: [u8; 32] = get("ca.pub").try_into().unwrap();
        let old_sig: [u8; 64] = get("msg.sig").try_into().unwrap();
        assert!(verify_signature(&old_pk, MSG, &old_sig), "an old signature no longer verifies");
    }
}