nornir 0.5.3

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! **znippy-sealed** build-provenance (feature `build-provenance`).
//!
//! The real cryptographic seal for a [`BuildProvenance`] attestation. It wires
//! nornir's [`ProvenanceSealer`] seam (defined dep-light in
//! [`nornir_build_thing`]) to znippy's detached-CMS signer
//! (`znippy_common::sign`) — the same provenance-signing machinery holger uses
//! to seal served artifacts. The seal is a detached CMS `SignedData` (RFC 5652)
//! over the 32-byte attestation digest; content bytes never flow through the
//! signer, so the seal can travel separately from (or alongside) the record.
//!
//! The sealed attestation is then **dumped as a znippy archive placed NEXT TO
//! the released artifact** ([`dump_beside_artifact`]): a content-addressed,
//! airgap-portable `<artifact>.build-provenance.znippy` that travels with the
//! binary. This is the last hop of the chain: build → warehouse ledger → seal →
//! znippy archive beside the artifact → offline verify.
//!
//! Enable with `--features build-provenance` (pulls znippy-common's `sign`
//! feature — the pure-Rust RustCrypto stack — plus znippy-compress).

use anyhow::{Context, Result};

use znippy_common::sign::{self, ArchiveSigner, CertStore, SigAlg, SignerId};

use super::provenance::{Attestation, ProvenanceSealer, Seal};

/// A [`ProvenanceSealer`] backed by a znippy CMS signer (Ed25519 or P-256).
pub struct ZnippySealer {
    signer: Box<dyn ArchiveSigner + Send>,
    signer_cn: String,
}

impl ZnippySealer {
    /// Build a sealer from a PKCS#8 (DER) private key + the signer's X.509
    /// certificate (DER). `alg` is `"ed25519"` or `"p256"`.
    pub fn from_pkcs8(
        alg: &str,
        pkcs8_key: &[u8],
        cert_der: &[u8],
        cn: impl Into<String>,
    ) -> Result<Self> {
        let alg = SigAlg::from_name(alg)?;
        let signer = sign::signer_from_pkcs8(alg, pkcs8_key, cert_der)
            .context("load provenance signer from PKCS#8")?;
        Ok(ZnippySealer { signer, signer_cn: cn.into() })
    }

    /// Wrap an already-constructed znippy signer.
    pub fn new(signer: Box<dyn ArchiveSigner + Send>, cn: impl Into<String>) -> Self {
        ZnippySealer { signer, signer_cn: cn.into() }
    }

    /// A dev/bootstrap sealer: mint an in-memory P-256 CA and an Ed25519 signer
    /// leaf under it. Returns the sealer plus the CA cert (DER) to trust when
    /// verifying. For tests, local bootstrap, and airgap dev — not a substitute
    /// for a real key store (use [`ZnippySealer::from_pkcs8`] for that).
    pub fn dev(cn: &str) -> Result<(Self, Vec<u8>)> {
        let (ca_key, ca_der) = sign::dev::mint_ca(&format!("{cn} Dev CA"))?;
        let signer = sign::dev::new_ed25519_signer(&ca_key, &ca_der, cn)?;
        Ok((ZnippySealer::new(Box::new(signer), cn), ca_der))
    }

    fn alg_name(&self) -> &'static str {
        match self.signer.algorithm() {
            SigAlg::Ed25519 => "ed25519",
            SigAlg::EcdsaP256 => "p256",
        }
    }
}

impl ProvenanceSealer for ZnippySealer {
    fn seal(&self, digest: &[u8; 32]) -> Result<Seal> {
        let cms_der = self.signer.sign_digest(digest).context("CMS-sign provenance digest")?;
        Ok(Seal { alg: self.alg_name().to_string(), signer_cn: self.signer_cn.clone(), cms_der })
    }
}

/// Verify a sealed attestation: recompute the record digest, confirm it matches
/// the sealed digest, then verify the detached CMS against `trusted_roots` (DER
/// CA certs). Returns the recovered signer identity.
pub fn verify(att: &Attestation, trusted_roots: &[Vec<u8>]) -> Result<SignerId> {
    anyhow::ensure!(
        att.digest_matches_record().context("recompute attestation digest")?,
        "attestation digest does not match its record (record tampered)"
    );
    let seal = att
        .seal
        .as_ref()
        .ok_or_else(|| anyhow::anyhow!("attestation is not sealed"))?;
    let store = CertStore::from_der_certs(trusted_roots).context("load trusted roots")?;
    sign::verify_digest(&att.digest, &seal.cms_der, &store).context("verify provenance seal")
}

/// Pack a sealed attestation into a **content-addressed znippy archive** at
/// `<out>.znippy`. Returns the archive path.
pub fn pack_znippy(att: &Attestation, out: &std::path::Path) -> Result<std::path::PathBuf> {
    let staging = tempfile::tempdir().context("provenance znippy staging dir")?;
    std::fs::write(staging.path().join("attestation.json"), att.to_json()?)
        .context("stage attestation.json")?;

    let input = staging.path().to_path_buf();
    let output = out.to_path_buf();
    znippy_compress::compress_dir(&input, &output, false, None, None, None)
        .context("znippy compress attestation")?;
    Ok(output.with_extension("znippy"))
}

/// Dump the sealed attestation as a znippy archive **beside the released
/// artifact**: `<artifact>.build-provenance.znippy` in the artifact's directory.
/// This is the file that travels WITH the binary. Returns the archive path.
pub fn dump_beside_artifact(
    att: &Attestation,
    artifact: &std::path::Path,
) -> Result<std::path::PathBuf> {
    let dir = artifact.parent().unwrap_or_else(|| std::path::Path::new("."));
    let name = artifact
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_else(|| "artifact".to_string());
    // `pack_znippy` / `compress_dir` force the `.znippy` extension via
    // `Path::with_extension`, which REPLACES the final dotted component. So hand
    // it a name that ALREADY ends in `.znippy` (with_extension then leaves it
    // intact), yielding `<artifact>.build-provenance.znippy`.
    let out = dir.join(format!("{name}.build-provenance.znippy"));
    pack_znippy(att, &out)
}

#[cfg(test)]
mod tests {
    use super::super::provenance::{BuildProvenance, HostInfo, Outcome, Toolchain};
    use super::super::variant::{BuildVariant, Profile};
    use super::*;

    fn sample_attestation() -> (tempfile::TempDir, std::path::PathBuf, Attestation) {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        std::fs::create_dir_all(root.join("target/release")).unwrap();
        let art = root.join("target/release/holger-full");
        std::fs::write(&art, b"sealed holger-full bytes").unwrap();

        let v = BuildVariant {
            name: "full".to_string(),
            package: Some("holger-full".to_string()),
            bin: Some("holger-full".to_string()),
            features: vec!["full".to_string()],
            profile: Profile::Release,
            ..Default::default()
        };
        let att = BuildProvenance::capture(
            &v,
            "holger",
            root,
            Toolchain::default(),
            HostInfo::default(),
            Outcome::Success,
            &[art.clone()],
        )
        .unwrap()
        .attest()
        .unwrap();
        (dir, art, att)
    }

    #[test]
    fn seal_and_verify_round_trip() {
        let (sealer, ca_der) = ZnippySealer::dev("nornir-build-signer").unwrap();
        let (_d, _art, att) = sample_attestation();
        let att = att.seal_with(&sealer).unwrap();
        assert!(att.is_sealed());
        assert_eq!(att.seal.as_ref().unwrap().alg, "ed25519");

        let id = verify(&att, &[ca_der]).unwrap();
        assert_eq!(id.common_name, "nornir-build-signer");
    }

    #[test]
    fn tampered_record_fails_seal_verification() {
        let (sealer, ca_der) = ZnippySealer::dev("nornir-build-signer").unwrap();
        let (_d, _art, att) = sample_attestation();
        let mut att = att.seal_with(&sealer).unwrap();
        assert!(verify(&att, &[ca_der.clone()]).is_ok());

        att.record.inputs.vcs_rev = "deadbeef".to_string();
        let err = verify(&att, &[ca_der]).unwrap_err();
        assert!(err.to_string().contains("does not match"), "unexpected: {err}");
    }

    #[test]
    fn untrusted_root_is_rejected() {
        let (sealer, _ca_der) = ZnippySealer::dev("nornir-build-signer").unwrap();
        let (_d, _art, att) = sample_attestation();
        let att = att.seal_with(&sealer).unwrap();
        let (_other, other_ca) = ZnippySealer::dev("rogue").unwrap();
        assert!(verify(&att, &[other_ca]).is_err());
    }

    #[test]
    fn sealed_attestation_serializes_with_the_seal() {
        let (sealer, _ca) = ZnippySealer::dev("nornir-build-signer").unwrap();
        let (_d, _art, att) = sample_attestation();
        let att = att.seal_with(&sealer).unwrap();
        let back = Attestation::from_json(&att.to_json().unwrap()).unwrap();
        assert!(back.is_sealed());
        assert_eq!(back.seal.unwrap().cms_der, att.seal.unwrap().cms_der);
    }

    #[test]
    fn dump_beside_artifact_writes_the_archive() {
        let (sealer, _ca) = ZnippySealer::dev("nornir-build-signer").unwrap();
        let (_d, art, att) = sample_attestation();
        let att = att.seal_with(&sealer).unwrap();
        let archive = dump_beside_artifact(&att, &art).unwrap();
        // The archive lands in the artifact's own directory, named after it.
        assert_eq!(archive.parent(), art.parent());
        assert_eq!(
            archive.file_name().unwrap().to_string_lossy(),
            "holger-full.build-provenance.znippy"
        );
        assert!(archive.exists() && std::fs::metadata(&archive).unwrap().len() > 0);
    }
}