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