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");
}
}