use anyhow::{Context, Result};
use znippy_common::sign::{self, ArchiveSigner, CertStore, SigAlg, SignerId};
use super::provenance::{Attestation, ProvenanceSealer, Seal};
pub struct ZnippySealer {
signer: Box<dyn ArchiveSigner + Send>,
signer_cn: String,
}
impl ZnippySealer {
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() })
}
pub fn new(signer: Box<dyn ArchiveSigner + Send>, cn: impl Into<String>) -> Self {
ZnippySealer { signer, signer_cn: cn.into() }
}
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 })
}
}
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")
}
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"))
}
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());
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();
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);
}
}