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

nordisk/nornir

src/build/seal.rs

//! **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, DsseEnvelope, SigAlg, SignerId};

use super::provenance::{Attestation, DsseSeal, DsseSealer, 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 })
    }
}

impl DsseSealer for ZnippySealer {
    /// Sign `payload` (the in-toto SLSA statement) as a **DSSE** envelope with the
    /// SAME znippy key the CMS path uses, and carry the signer cert so an airgap
    /// verifier can recover the public key. This is the SLSA-interop seal.
    fn seal_dsse(&self, payload_type: &str, payload: &[u8]) -> Result<DsseSeal> {
        let signer_ref: &dyn ArchiveSigner = &*self.signer;
        let env = sign::dsse_sign(payload_type, payload, &[signer_ref])
            .context("DSSE-sign provenance statement")?;
        let envelope_json = env.to_json().context("serialize DSSE envelope")?;
        Ok(DsseSeal {
            alg: self.alg_name().to_string(),
            signer_cn: self.signer_cn.clone(),
            payload_type: payload_type.to_string(),
            envelope_json,
            signer_certs: vec![signer_ref.certificate_der().to_vec()],
        })
    }
}

/// Which provenance signature shape to produce — the `--provenance cms|dsse` choice
/// on the seal path. **`Cms` is the default** (today's airgap seal, unchanged);
/// `Dsse` emits the SLSA-interop envelope beside it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProvenanceMode {
    /// znippy detached CMS `SignedData` over the record digest — the airgap default.
    #[default]
    Cms,
    /// DSSE envelope over an in-toto SLSA statement — cosign/slsa-verifier interop.
    Dsse,
}

impl ProvenanceMode {
    /// Parse the `--provenance` CLI value.
    pub fn from_name(name: &str) -> Result<Self> {
        match name.trim().to_ascii_lowercase().as_str() {
            "cms" | "airgap" => Ok(ProvenanceMode::Cms),
            "dsse" | "slsa" | "in-toto" | "intoto" => Ok(ProvenanceMode::Dsse),
            other => anyhow::bail!("unknown --provenance mode '{other}' (expected cms|dsse)"),
        }
    }
}

/// Seal `att` in the chosen [`ProvenanceMode`] with `sealer`. This is the ONE
/// dispatch the seal path calls, so the `--provenance` flag routes to exactly one
/// of the two shapes and the CMS default stays the untouched airgap seal.
pub fn seal_attestation(
    att: Attestation,
    sealer: &ZnippySealer,
    mode: ProvenanceMode,
) -> Result<Attestation> {
    match mode {
        ProvenanceMode::Cms => att.seal_with(sealer),
        ProvenanceMode::Dsse => att.seal_dsse_with(sealer),
    }
}

/// 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 carries no CMS seal (DSSE? use verify_dsse)"))?;
    let store = CertStore::from_der_certs(trusted_roots).context("load trusted roots")?;
    // `.id`: znippy's `verify_digest` returns a `VerifiedSigner { id, attrs }`, having
    // been widened from a bare `SignerId` so a policy layer can read the complete,
    // verified `signedAttrs` SET. This caller was never recompiled after that change —
    // `build-provenance` is in no default feature set and no CI command builds it — so
    // the E0308 sat on origin/main unseen. Measured 2026-08-01.
    //
    // The documented contract here stays `SignerId` (this function answers "who
    // signed"); `attrs` is deliberately dropped rather than widening nornir's API
    // speculatively. A policy layer that needs the attributes should call
    // `sign::verify_digest` directly.
    sign::verify_digest(&att.digest, &seal.cms_der, &store)
        .context("verify provenance seal")
        .map(|v| v.id)
}

/// Verify a **DSSE**-sealed attestation: rebuild the in-toto SLSA statement from
/// the record, confirm the envelope's payload IS that statement (record-tamper
/// check), then verify the DSSE signature against `trusted_roots` (DER CA certs)
/// using the carried signer cert. Returns the recovered signer identity.
///
/// This is the producer-side counterpart of [`verify`] for the SLSA-interop seal.
/// A standard SLSA verifier (cosign / slsa-verifier) would instead verify the
/// envelope JSON directly against the signer's public key + trust root; this
/// function additionally binds the envelope to nornir's own record.
pub fn verify_dsse(att: &Attestation, trusted_roots: &[Vec<u8>]) -> Result<SignerId> {
    let dsse = att
        .dsse
        .as_ref()
        .ok_or_else(|| anyhow::anyhow!("attestation carries no DSSE seal (CMS? use verify)"))?;

    let env = DsseEnvelope::from_json(&dsse.envelope_json).context("parse DSSE envelope")?;
    let expected = att
        .record
        .slsa_statement_bytes()
        .context("recompute SLSA statement")?;
    anyhow::ensure!(
        env.payload == expected,
        "DSSE payload does not match the record's SLSA statement (record tampered)"
    );

    let store = CertStore::from_der_certs(trusted_roots).context("load trusted roots")?;
    let verified = sign::dsse_verify(&env, &dsse.signer_certs, &store)
        .context("verify DSSE envelope")?;
    let v = verified
        .into_iter()
        .next()
        .ok_or_else(|| anyhow::anyhow!("DSSE envelope produced no verified signer"))?;
    Ok(v.id)
}

/// 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)
}

/// Seal the accumulated §f3 build-info rows at rest as **znippy over Arrow-IPC**
/// (build-info flight-stream design §f4): `<artifact>.build-info.znippy`, the
/// sibling of [`dump_beside_artifact`]'s `<artifact>.build-provenance.znippy`.
///
/// The rows are columnized (`build_info::build_info_record_batch`) into the §f3
/// Arrow schema, streamed to an Arrow-IPC container, then znippy-sealed — the
/// same schema live, at rest, and in the `build_info` warehouse table (no second
/// format). Returns the archive path. The manifest rows are ALSO landed into the
/// warehouse via [`crate::warehouse::build_info::append_build_info`] (that is the
/// query ledger, §f6); this is the airgap-portable copy that travels WITH the
/// artifact.
pub fn seal_build_info_beside_artifact(
    rows: &[nornir_build_thing::build_info::BuildInfoRow],
    artifact: &std::path::Path,
) -> Result<std::path::PathBuf> {
    use arrow::ipc::writer::StreamWriter;

    let schema = crate::warehouse::build_info::build_info_arrow_schema();
    let batch = crate::warehouse::build_info::build_info_record_batch(schema.clone(), rows)
        .context("columnize build-info rows")?;

    // Arrow-IPC stream bytes (the container znippy seals).
    let mut ipc: Vec<u8> = Vec::new();
    {
        let mut w = StreamWriter::try_new(&mut ipc, &schema).context("open Arrow-IPC stream")?;
        w.write(&batch).context("write build-info batch")?;
        w.finish().context("finish Arrow-IPC stream")?;
    }

    let staging = tempfile::tempdir().context("build-info znippy staging dir")?;
    std::fs::write(staging.path().join("build-info.arrow"), &ipc)
        .context("stage build-info.arrow")?;
    let staging_dir = staging.path().to_path_buf();

    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());
    // `compress_dir` forces the `.znippy` extension via `with_extension` (replaces
    // the final dotted component), so hand it a name already ending in `.znippy`.
    let out = dir.join(format!("{name}.build-info.znippy"));
    znippy_compress::compress_dir(&staging_dir, &out, false, None, None, None)
        .context("znippy compress build-info")?;
    Ok(out.with_extension("znippy"))
}

#[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 dsse_seal_and_verify_round_trip() {
        let (sealer, ca_der) = ZnippySealer::dev("nornir-slsa-signer").unwrap();
        let (_d, _art, att) = sample_attestation();

        // Seal via the mode dispatcher (the `--provenance dsse` path).
        let att = seal_attestation(att, &sealer, ProvenanceMode::Dsse).unwrap();
        assert!(att.is_sealed());
        let dsse = att.dsse.as_ref().expect("a DSSE seal");
        assert_eq!(dsse.payload_type, "application/vnd.in-toto+json");
        // The envelope really carries the SLSA statement as its payload.
        let env = DsseEnvelope::from_json(&dsse.envelope_json).unwrap();
        assert_eq!(env.payload, att.record.slsa_statement_bytes().unwrap());
        // ...and that payload is the in-toto statement.
        assert!(
            String::from_utf8_lossy(&env.payload).contains("https://slsa.dev/provenance/v1")
        );

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

        // A CMS-only verify must refuse a DSSE seal (and vice-versa), so the two
        // shapes never silently cross.
        assert!(verify(&att, &[]).is_err());
    }

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

        // RED 1 — mutate the record: the recomputed SLSA statement no longer equals
        // the envelope's signed payload.
        att.record.inputs.vcs_rev = "deadbeef".to_string();
        let err = verify_dsse(&att, &[ca_der]).unwrap_err();
        assert!(err.to_string().contains("does not match"), "unexpected: {err}");

        // RED 2 — an untrusted root cannot chain the signer.
        let (_d2, _a2, att2) = sample_attestation();
        let att2 = att2.seal_dsse_with(&sealer).unwrap();
        let (_other, other_ca) = ZnippySealer::dev("rogue").unwrap();
        assert!(verify_dsse(&att2, &[other_ca]).is_err());
    }

    #[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 seal_build_info_writes_znippy_beside_artifact() {
        use nornir_build_thing::build_info::{BuildInfoKind, BuildInfoRow, manifest_rows};
        let (_d, art, att) = sample_attestation();
        let mut rows = manifest_rows(&att.record, 100);
        // A live-feed log row WITH an inline blob — exercises the nullable column.
        rows.push(
            BuildInfoRow::new(&att.record.id, 101, BuildInfoKind::Log).blob(b"compiling".to_vec()),
        );

        let archive = seal_build_info_beside_artifact(&rows, &art).unwrap();
        // Named after the artifact, in its directory (sibling of build-provenance).
        assert_eq!(archive.parent(), art.parent());
        assert_eq!(
            archive.file_name().unwrap().to_string_lossy(),
            "holger-full.build-info.znippy"
        );
        // RED-when-broken: a real, non-empty sealed archive exists.
        assert!(archive.exists() && std::fs::metadata(&archive).unwrap().len() > 0);
    }

    #[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);
    }
}