plugins/native/znippy-plugin-elf/src/provenance.rs
//! One signed statement per artifact: source commit → recipe → output digest.
//!
//! **Nothing here is a new signing scheme, a new envelope or a new predicate.**
//! All four already exist and are reused verbatim:
//!
//! | piece | where it already lives |
//! |---|---|
//! | the DSSE envelope, PAE, sign and verify | `znippy_common::sign::{DsseEnvelope, dsse_sign, dsse_verify}` |
//! | `payloadType` `application/vnd.in-toto+json` | `znippy_common::sign::DSSE_PAYLOAD_TYPE_INTOTO` |
//! | `predicateType` `https://slsa.dev/provenance/v1` | already on holger's accept allowlist (`AttestStore`), so no policy change |
//! | the store, trust roots and subject binding | `holger_server_lib::attest::AttestStore::{accept, emit_signed}` |
//!
//! What is genuinely new is the **binding**, and it is deliberately thin.
//!
//! # The statement REFERENCES the build-thing artifact, it does not restate it
//!
//! A build coordinate copied into a statement is a second record that can
//! disagree with the first. So the predicate carries *pointers by digest*
//! (`resolvedDependencies`) and only the minimum needed to find them
//! (`externalParameters`). A verifier fetches the referenced record and
//! **re-derives**; it never trusts a transcription.
//!
//! Two references are available today and both are stable, content-addressed
//! identities:
//!
//! * the recipe — `nornir_build_thing::contract::Coordinate`, `krate:version:hash`,
//! where `hash` is SHA-256 over the recipe crate's `src/**/*.rs`. This one the
//! binary carries **itself**, in its `.nordisk.buildthing` receipt, so an
//! airgapped verifier needs nothing but the file.
//! * the build record — `nornir_build_thing::provenance::Attestation::digest`,
//! SHA-256 over the canonical `BuildProvenance` bytes, which is the digest that
//! is actually **signed** today (CMS or DSSE, via nornir's `ZnippySealer`).
//!
//! # Verification looks at the disk
//!
//! "The payload is signed" proves a statement exists. It proves nothing about the
//! file lying next to it. [`verify_against_disk`] recomputes the subject digest
//! from the bytes on disk and compares; a signed statement about a *different*
//! artifact is [`ProvenanceRefusal::SubjectIsNotThisFile`], which is a louder
//! failure than an unsigned one.
use std::path::Path;
use sha2::{Digest, Sha256};
use znippy_common::sign::{
ArchiveSigner, CertStore, DSSE_PAYLOAD_TYPE_INTOTO, DsseEnvelope, dsse_sign, dsse_verify,
};
use crate::facts::{ElfFacts, Recipe};
/// The in-toto Statement type URI. Same constant as
/// `nornir_build_thing::provenance::IN_TOTO_STATEMENT_TYPE` and
/// `holger_server_lib::attest::IN_TOTO_STATEMENT_TYPE` — spelled here so this
/// crate does not take a dependency on either, never to differ from them.
pub const IN_TOTO_STATEMENT_TYPE: &str = "https://in-toto.io/Statement/v1";
/// The SLSA Provenance v1 predicate type. Already on holger's accept allowlist.
pub const SLSA_PREDICATE_TYPE: &str = "https://slsa.dev/provenance/v1";
/// What a caller knows that the binary cannot know about itself: which build
/// record vouches for it, and who the forge was.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BuildRecordRef {
/// `BuildProvenance.id` — the record's own UUID.
pub id: Option<String>,
/// `Attestation::digest_hex()` — the SIGNED digest of the canonical record.
pub digest_sha256: Option<String>,
/// The repo the build was cut from, for the `git+nordisk/<repo>` material URI.
pub repo: Option<String>,
/// `runDetails.builder.id`.
pub builder: Option<String>,
}
/// Why a statement was refused. Each is a sentence that names both sides.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProvenanceRefusal {
/// The statement is signed, and it is about some OTHER file.
SubjectIsNotThisFile { on_disk: String, in_statement: String },
/// The statement names no subject digest at all.
SubjectMissing,
/// The payload is not an in-toto Statement / SLSA provenance.
NotSlsaProvenance { payload_type: String, predicate_type: String },
/// The signature did not verify or did not chain to a trusted root.
NotSigned { why: String },
}
impl std::fmt::Display for ProvenanceRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "REFUSED [elf-provenance] ")?;
match self {
ProvenanceRefusal::SubjectIsNotThisFile { on_disk, in_statement } => write!(
f,
"the file on disk hashes to {on_disk} and the signed statement is about \
{in_statement} — the signature is valid and it vouches for a DIFFERENT \
artifact; fetch the artifact this statement names, or the statement for \
the artifact you have"
),
ProvenanceRefusal::SubjectMissing => write!(
f,
"the statement names no subject digest, so it binds to nothing — a \
provenance statement that does not say WHAT it is about cannot vouch \
for anything"
),
ProvenanceRefusal::NotSlsaProvenance { payload_type, predicate_type } => write!(
f,
"the envelope carries payloadType {payload_type} / predicateType \
{predicate_type}, not an in-toto SLSA provenance — payload-type \
confusion is refused by name, never guessed at"
),
ProvenanceRefusal::NotSigned { why } => {
write!(f, "the envelope did not verify against the trusted roots: {why}")
}
}
}
}
/// Lowercase-hex SHA-256 of `bytes`.
pub fn sha256_hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(64);
for b in Sha256::digest(bytes) {
s.push(char::from_digit(u32::from(b >> 4), 16).unwrap_or('0'));
s.push(char::from_digit(u32::from(b & 0xf), 16).unwrap_or('0'));
}
s
}
/// Build the in-toto Statement for one artifact.
///
/// `subject_name` is how the artifact is filed (its `relative_path` in the
/// archive); `data` is the artifact's bytes — the digest is taken from THEM, not
/// from anything a caller asserts.
///
/// The canonical bytes are compact and **key-sorted**, because `serde_json`'s
/// `Map` is a `BTreeMap`: producer and verifier therefore fold to the same bytes,
/// the same property `nornir_build_thing::provenance::slsa_statement_bytes`
/// relies on.
pub fn statement(
subject_name: &str,
data: &[u8],
facts: &ElfFacts,
record: &BuildRecordRef,
) -> serde_json::Value {
use serde_json::json;
let r: &Recipe = &facts.recipe;
let mut digest = serde_json::Map::new();
digest.insert("sha256".into(), json!(sha256_hex(data)));
// The build-id is not a digest of the file, so it does NOT go in `digest`
// (in-toto digests are content hashes). It rides as a byproduct-grade
// annotation on the subject instead, because it is the key debuginfod and
// the index both use.
let mut subject = serde_json::Map::new();
subject.insert("name".into(), json!(subject_name));
subject.insert("digest".into(), serde_json::Value::Object(digest));
if let Some(b) = &facts.build_id {
subject.insert("annotations".into(), json!({ "buildId": b }));
}
// ── resolvedDependencies: POINTERS, never copies ──────────────────────────
let mut resolved: Vec<serde_json::Value> = Vec::new();
if let Some(coord) = &r.coordinate {
// `krate:version:hash` → a URI that names the recipe and a digest that
// pins its CODE. The binary carries this itself, so it works airgapped.
let (uri, sha) = split_coordinate(coord);
let mut e = serde_json::Map::new();
e.insert("name".into(), json!("build-thing-recipe"));
e.insert("uri".into(), json!(uri));
if let Some(sha) = sha {
e.insert("digest".into(), json!({ "sha256": sha }));
}
resolved.push(serde_json::Value::Object(e));
}
if let Some(d) = &record.digest_sha256 {
let uri = match &record.id {
Some(id) => format!("nornir.build-provenance/v1/{id}"),
None => "nornir.build-provenance/v1".to_string(),
};
resolved.push(json!({
"name": "build-thing-provenance",
"uri": uri,
"digest": { "sha256": d },
}));
}
if let Some(commit) = &r.source_commit {
let repo = record.repo.as_deref().unwrap_or("unknown");
resolved.push(json!({
"name": "source",
"uri": format!("git+nordisk/{repo}"),
"digest": { "gitCommit": commit },
}));
}
if let Some(image) = &r.toolchain_image {
let mut e = serde_json::Map::new();
e.insert("name".into(), json!("toolchain-image"));
e.insert("uri".into(), json!(image));
if let Some(d) = &r.toolchain_image_sha256 {
e.insert("digest".into(), json!({ "sha256": d }));
}
resolved.push(serde_json::Value::Object(e));
}
// ── externalParameters: the MINIMUM needed to find the record ─────────────
let mut external = serde_json::Map::new();
if let Some(v) = &r.variant {
external.insert("variant".into(), json!(v));
}
if let Some(t) = &r.target {
external.insert("target".into(), json!(t));
}
let build_type = format!(
"https://nordisk.dev/nornir/build-thing/variant/{}",
r.variant.as_deref().unwrap_or("unknown")
);
json!({
"_type": IN_TOTO_STATEMENT_TYPE,
"subject": [serde_json::Value::Object(subject)],
"predicateType": SLSA_PREDICATE_TYPE,
"predicate": {
"buildDefinition": {
"buildType": build_type,
"externalParameters": serde_json::Value::Object(external),
// What the ARTIFACT is, measured — never a claim about it.
"internalParameters": {
"elfMachine": facts.machine,
"elfClass": facts.class,
"elfType": facts.etype,
"elfAbiLevel": facts.abi_level,
},
"resolvedDependencies": resolved,
},
"runDetails": {
"builder": {
"id": format!(
"nornir-build-thing://{}",
record.builder.as_deref().unwrap_or("unknown")
),
"builderDependencies": [],
},
"byproducts": byproducts(facts),
},
},
})
}
/// What the build left behind that a consumer can use without the artifact:
/// today, the embedded dependency tree's root.
fn byproducts(facts: &ElfFacts) -> Vec<serde_json::Value> {
let mut v = Vec::new();
if let Some(root) = &facts.auditable_root {
v.push(serde_json::json!({ "name": "dep-v0-root", "annotations": { "root": root } }));
}
v
}
/// `krate:version:hash` → (`nornir+buildthing://krate@version`, `hash`).
fn split_coordinate(coord: &str) -> (String, Option<String>) {
let parts: Vec<&str> = coord.split(':').collect();
match parts.as_slice() {
[k, v, h] => (format!("nornir+buildthing://{k}@{v}"), Some((*h).to_string())),
[k, v] => (format!("nornir+buildthing://{k}@{v}"), None),
_ => (format!("nornir+buildthing://{coord}"), None),
}
}
/// The exact bytes a DSSE envelope signs: compact, key-sorted JSON.
pub fn statement_bytes(
subject_name: &str,
data: &[u8],
facts: &ElfFacts,
record: &BuildRecordRef,
) -> anyhow::Result<Vec<u8>> {
Ok(serde_json::to_vec(&statement(subject_name, data, facts, record))?)
}
/// Sign the statement as a DSSE envelope — a one-line delegation to znippy's own
/// `dsse_sign`, which is also what holger's `AttestStore::emit_signed` calls.
/// There is exactly one signing path in the estate and this is it.
pub fn seal(
subject_name: &str,
data: &[u8],
facts: &ElfFacts,
record: &BuildRecordRef,
signer: &dyn ArchiveSigner,
) -> anyhow::Result<DsseEnvelope> {
let payload = statement_bytes(subject_name, data, facts, record)?;
dsse_sign(DSSE_PAYLOAD_TYPE_INTOTO, &payload, &[signer])
}
/// What a verification found. Both halves are reported, always: a caller must be
/// able to tell "signed but about another file" from "not signed at all".
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Verified {
/// The digest recomputed FROM THE BYTES ON DISK.
pub on_disk_sha256: String,
/// The subject digest the statement named.
pub statement_sha256: Option<String>,
/// The recipe the statement points at, for the `elf-recipe` comparison.
pub statement_recipe: Recipe,
}
/// Verify an envelope **against the file on disk**.
///
/// Order, and it is the order holger's `accept` already enforces: parse, then
/// payload type, then **subject binding**, then cryptography. Nothing is
/// accepted because it was signed; it is accepted because it was signed AND it
/// is about this file.
pub fn verify_against_disk(
artifact: &Path,
envelope_json: &[u8],
signer_certs_der: &[Vec<u8>],
roots: &CertStore,
) -> Result<Verified, ProvenanceRefusal> {
let data = std::fs::read(artifact).map_err(|e| ProvenanceRefusal::NotSigned {
why: format!("cannot read {}: {e}", artifact.display()),
})?;
verify_bytes_against(&data, envelope_json, signer_certs_der, roots)
}
/// [`verify_against_disk`] for bytes already in hand (an archive member).
pub fn verify_bytes_against(
data: &[u8],
envelope_json: &[u8],
signer_certs_der: &[Vec<u8>],
roots: &CertStore,
) -> Result<Verified, ProvenanceRefusal> {
let text = std::str::from_utf8(envelope_json).map_err(|e| ProvenanceRefusal::NotSigned {
why: format!("envelope is not UTF-8: {e}"),
})?;
let env = DsseEnvelope::from_json(text)
.map_err(|e| ProvenanceRefusal::NotSigned { why: e.to_string() })?;
let payload: serde_json::Value = serde_json::from_slice(&env.payload).map_err(|e| {
ProvenanceRefusal::NotSlsaProvenance {
payload_type: env.payload_type.clone(),
predicate_type: format!("<unparsable: {e}>"),
}
})?;
let predicate_type =
payload.get("predicateType").and_then(|v| v.as_str()).unwrap_or("").to_string();
if env.payload_type != DSSE_PAYLOAD_TYPE_INTOTO
|| payload.get("_type").and_then(|v| v.as_str()) != Some(IN_TOTO_STATEMENT_TYPE)
|| predicate_type != SLSA_PREDICATE_TYPE
{
return Err(ProvenanceRefusal::NotSlsaProvenance {
payload_type: env.payload_type.clone(),
predicate_type,
});
}
// ── the subject binding, BEFORE the cryptography ──────────────────────────
let on_disk = sha256_hex(data);
let statement_sha = payload
.get("subject")
.and_then(|s| s.as_array())
.and_then(|a| a.first())
.and_then(|s| s.get("digest"))
.and_then(|d| d.get("sha256"))
.and_then(|v| v.as_str())
.map(str::to_owned);
match &statement_sha {
None => return Err(ProvenanceRefusal::SubjectMissing),
Some(s) if *s != on_disk => {
return Err(ProvenanceRefusal::SubjectIsNotThisFile {
on_disk,
in_statement: s.clone(),
});
}
Some(_) => {}
}
dsse_verify(&env, signer_certs_der, roots)
.map_err(|e| ProvenanceRefusal::NotSigned { why: e.to_string() })?;
Ok(Verified {
on_disk_sha256: on_disk,
statement_sha256: statement_sha,
statement_recipe: recipe_from_statement(&payload),
})
}
/// Recover the recipe the statement POINTS AT, so the caller can compare it with
/// what the binary says about itself (`elf-recipe`).
pub fn recipe_from_statement(payload: &serde_json::Value) -> Recipe {
let mut r = Recipe::default();
let bd = payload.get("predicate").and_then(|p| p.get("buildDefinition"));
if let Some(ext) = bd.and_then(|b| b.get("externalParameters")) {
r.variant = ext.get("variant").and_then(|v| v.as_str()).map(str::to_owned);
r.target = ext.get("target").and_then(|v| v.as_str()).map(str::to_owned);
}
if let Some(deps) = bd.and_then(|b| b.get("resolvedDependencies")).and_then(|d| d.as_array()) {
for d in deps {
let name = d.get("name").and_then(|v| v.as_str()).unwrap_or("");
let uri = d.get("uri").and_then(|v| v.as_str()).unwrap_or("");
let sha = d.get("digest").and_then(|x| x.get("sha256")).and_then(|v| v.as_str());
match name {
"build-thing-recipe" => {
// `nornir+buildthing://krate@version` + digest → krate:version:hash
let bare = uri.strip_prefix("nornir+buildthing://").unwrap_or(uri);
let (k, v) = bare.split_once('@').unwrap_or((bare, ""));
r.coordinate = Some(match sha {
Some(h) => format!("{k}:{v}:{h}"),
None => format!("{k}:{v}"),
});
}
"source" => {
r.source_commit = d
.get("digest")
.and_then(|x| x.get("gitCommit"))
.and_then(|v| v.as_str())
.map(str::to_owned);
}
"toolchain-image" => {
r.toolchain_image = Some(uri.to_string());
r.toolchain_image_sha256 = sha.map(str::to_owned);
}
_ => {}
}
}
}
r
}
#[cfg(test)]
mod tests {
use super::*;
use crate::facts;
use crate::fixture::*;
fn a_binary_with_a_receipt() -> Vec<u8> {
let receipt = br#"{"receipt_version":1,
"build_thing":{"krate":"gunnar-build-thing","version":"0.3.1","hash":"abc123"},
"atom":"gunnar-installer-metered","target":"iso-installer",
"settings":{"containers":"on"},
"inputs":{"commit":"451288a0000000000000000000000000000000aa",
"toolchain_image":"nordisk-cargo-toolchain",
"toolchain_image_sha256":"3d5115baa07d03ca51eb3e44ebb86b4e64e679bd88f38e156ef9da12c35d2143"}}"#;
Builder::default()
.etype(ET_EXEC)
.text(64)
.note(".note.gnu.build-id", b"GNU", 3, &[0xaa, 0xbb, 0xcc])
.section(Section::new(".nordisk.buildthing", SHT_PROGBITS, receipt.to_vec()))
.build()
}
fn a_record() -> BuildRecordRef {
BuildRecordRef {
id: Some("11111111-2222-3333-4444-555555555555".into()),
digest_sha256: Some("ff".repeat(32)),
repo: Some("gunnar".into()),
builder: Some("alice@t14s".into()),
}
}
fn signer() -> (znippy_common::sign::Ed25519Signer, Vec<u8>) {
let (ca_key, ca_der) = znippy_common::sign::dev::mint_ca("elf test CA").unwrap();
let s =
znippy_common::sign::dev::new_ed25519_signer(&ca_key, &ca_der, "elf test signer")
.unwrap();
(s, ca_der)
}
fn roots_of(ca_der: &[u8]) -> CertStore {
CertStore::from_der_certs(&[ca_der.to_vec()]).unwrap()
}
#[test]
fn the_subject_digest_is_the_hash_of_the_bytes_and_of_nothing_else() {
let data = a_binary_with_a_receipt();
let f = facts::read(&data).unwrap();
let st = statement("bin/gunnar", &data, &f, &a_record());
let got = st["subject"][0]["digest"]["sha256"].as_str().unwrap();
assert_eq!(got, sha256_hex(&data));
assert_eq!(st["subject"][0]["name"].as_str().unwrap(), "bin/gunnar");
// the build-id rides as an annotation, NOT as a digest — it is not one
assert_eq!(st["subject"][0]["annotations"]["buildId"].as_str().unwrap(), "aabbcc");
}
#[test]
fn the_statement_points_at_the_build_thing_artifacts_instead_of_copying_them() {
let data = a_binary_with_a_receipt();
let f = facts::read(&data).unwrap();
let st = statement("bin/gunnar", &data, &f, &a_record());
let deps = st["predicate"]["buildDefinition"]["resolvedDependencies"].as_array().unwrap();
let recipe = deps.iter().find(|d| d["name"] == "build-thing-recipe").expect("recipe ref");
assert_eq!(recipe["uri"], "nornir+buildthing://gunnar-build-thing@0.3.1");
assert_eq!(recipe["digest"]["sha256"], "abc123");
let record = deps.iter().find(|d| d["name"] == "build-thing-provenance").expect("record");
assert_eq!(record["uri"], "nornir.build-provenance/v1/11111111-2222-3333-4444-555555555555");
assert_eq!(record["digest"]["sha256"], "ff".repeat(32));
let src = deps.iter().find(|d| d["name"] == "source").expect("source ref");
assert_eq!(src["digest"]["gitCommit"], "451288a0000000000000000000000000000000aa");
let tc = deps.iter().find(|d| d["name"] == "toolchain-image").expect("toolchain ref");
assert_eq!(tc["uri"], "nordisk-cargo-toolchain");
assert_eq!(
tc["digest"]["sha256"],
"3d5115baa07d03ca51eb3e44ebb86b4e64e679bd88f38e156ef9da12c35d2143"
);
// externalParameters holds the MINIMUM to find the record, not a copy of it
let ext = &st["predicate"]["buildDefinition"]["externalParameters"];
assert_eq!(ext["variant"], "gunnar-installer-metered");
assert_eq!(ext["target"], "iso-installer");
assert!(ext.get("settings").is_none(), "the recipe's contents must NOT be restated");
assert!(ext.get("features").is_none(), "the recipe's contents must NOT be restated");
}
#[test]
fn the_canonical_bytes_are_key_sorted_and_stable() {
let data = a_binary_with_a_receipt();
let f = facts::read(&data).unwrap();
let a = statement_bytes("bin/gunnar", &data, &f, &a_record()).unwrap();
let b = statement_bytes("bin/gunnar", &data, &f, &a_record()).unwrap();
assert_eq!(a, b, "producer and verifier must fold the same bytes");
let text = String::from_utf8(a).unwrap();
assert!(text.starts_with(r#"{"_type":"https://in-toto.io/Statement/v1","predicate":"#), "{text}");
assert!(!text.contains("\n "), "canonical bytes are compact");
}
#[test]
fn a_sealed_statement_verifies_against_the_file_it_is_about() {
let data = a_binary_with_a_receipt();
let f = facts::read(&data).unwrap();
let (s, ca) = signer();
let env = seal("bin/gunnar", &data, &f, &a_record(), &s).unwrap();
let json = env.to_json().unwrap();
let v = verify_bytes_against(&data, json.as_bytes(), &[s.certificate_der().to_vec()], &roots_of(&ca))
.expect("must verify");
assert_eq!(v.on_disk_sha256, sha256_hex(&data));
assert_eq!(v.statement_sha256.as_deref(), Some(v.on_disk_sha256.as_str()));
}
#[test]
fn a_perfectly_signed_statement_about_another_file_is_refused() {
// THE point: "the payload is signed" proves a statement exists. It proves
// nothing about the file next to it.
let signed_about = a_binary_with_a_receipt();
let on_disk = Builder::default().etype(ET_EXEC).text(128).build();
assert_ne!(signed_about, on_disk);
let f = facts::read(&signed_about).unwrap();
let (s, ca) = signer();
let env = seal("bin/gunnar", &signed_about, &f, &a_record(), &s).unwrap();
let json = env.to_json().unwrap();
let err = verify_bytes_against(
&on_disk,
json.as_bytes(),
&[s.certificate_der().to_vec()],
&roots_of(&ca),
)
.expect_err("a valid signature over ANOTHER artifact must be refused");
match &err {
ProvenanceRefusal::SubjectIsNotThisFile { on_disk: d, in_statement } => {
assert_eq!(*d, sha256_hex(&on_disk));
assert_eq!(*in_statement, sha256_hex(&signed_about));
}
other => panic!("wrong refusal: {other}"),
}
assert!(err.to_string().contains("vouches for a DIFFERENT artifact"), "{err}");
}
#[test]
fn one_flipped_byte_on_disk_breaks_the_binding() {
let data = a_binary_with_a_receipt();
let f = facts::read(&data).unwrap();
let (s, ca) = signer();
let json = seal("bin/gunnar", &data, &f, &a_record(), &s).unwrap().to_json().unwrap();
let mut tampered = data.clone();
let last = tampered.len() - 1;
tampered[last] ^= 0xff;
assert!(matches!(
verify_bytes_against(&tampered, json.as_bytes(), &[s.certificate_der().to_vec()], &roots_of(&ca)),
Err(ProvenanceRefusal::SubjectIsNotThisFile { .. })
));
}
#[test]
fn an_envelope_signed_by_an_untrusted_key_is_refused() {
let data = a_binary_with_a_receipt();
let f = facts::read(&data).unwrap();
let (s, _ca) = signer();
let (_other, other_ca) = signer(); // a DIFFERENT CA
let json = seal("bin/gunnar", &data, &f, &a_record(), &s).unwrap().to_json().unwrap();
let err = verify_bytes_against(
&data,
json.as_bytes(),
&[s.certificate_der().to_vec()],
&roots_of(&other_ca),
)
.expect_err("a signer that does not chain must be refused");
assert!(matches!(err, ProvenanceRefusal::NotSigned { .. }), "{err}");
}
#[test]
fn a_payload_that_is_not_slsa_provenance_is_refused_by_name() {
let data = a_binary_with_a_receipt();
let f = facts::read(&data).unwrap();
let (s, ca) = signer();
let mut st = statement("bin/gunnar", &data, &f, &a_record());
st["predicateType"] = serde_json::json!("https://cyclonedx.org/bom");
let payload = serde_json::to_vec(&st).unwrap();
let env = dsse_sign(DSSE_PAYLOAD_TYPE_INTOTO, &payload, &[&s]).unwrap();
let err = verify_bytes_against(
&data,
env.to_json().unwrap().as_bytes(),
&[s.certificate_der().to_vec()],
&roots_of(&ca),
)
.expect_err("payload-type confusion must be refused, never guessed at");
assert!(matches!(err, ProvenanceRefusal::NotSlsaProvenance { .. }), "{err}");
}
#[test]
fn a_statement_with_no_subject_digest_binds_to_nothing_and_is_refused() {
let data = a_binary_with_a_receipt();
let f = facts::read(&data).unwrap();
let (s, ca) = signer();
let mut st = statement("bin/gunnar", &data, &f, &a_record());
st["subject"] = serde_json::json!([{ "name": "bin/gunnar" }]);
let payload = serde_json::to_vec(&st).unwrap();
let env = dsse_sign(DSSE_PAYLOAD_TYPE_INTOTO, &payload, &[&s]).unwrap();
let err = verify_bytes_against(
&data,
env.to_json().unwrap().as_bytes(),
&[s.certificate_der().to_vec()],
&roots_of(&ca),
)
.expect_err("a statement that does not say what it is about cannot vouch");
assert!(matches!(err, ProvenanceRefusal::SubjectMissing), "{err}");
}
#[test]
fn the_pointers_round_trip_so_elf_recipe_can_compare_them() {
let data = a_binary_with_a_receipt();
let f = facts::read(&data).unwrap();
let st = statement("bin/gunnar", &data, &f, &a_record());
let recovered = recipe_from_statement(&st);
// Exactly the fields the elf-recipe refusal compares.
assert_eq!(recovered.coordinate, f.recipe.coordinate);
assert_eq!(recovered.source_commit, f.recipe.source_commit);
assert_eq!(recovered.toolchain_image_sha256, f.recipe.toolchain_image_sha256);
assert_eq!(recovered.variant, f.recipe.variant);
// And a statement that points at a DIFFERENT toolchain image is caught by
// the refusal that already exists, not by a second rule here.
let coord = crate::refusal::Coordinate {
path: "bin/gunnar".into(),
expect_toolchain_image_sha256: Some("f29e517c".into()),
..Default::default()
};
let host = crate::refusal::HostFacts {
machine: f.machine.clone(),
abi_level: None,
interpreters: vec![],
};
assert!(crate::refusal::check(&f, &host, &coord).iter().any(|r| r.name() == "elf-recipe"));
}
#[test]
fn verification_reads_the_file_on_disk_not_a_caller_supplied_digest() {
let dir = std::env::temp_dir().join(format!("elf-prov-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("gunnar");
let data = a_binary_with_a_receipt();
std::fs::write(&path, &data).unwrap();
let f = facts::read(&data).unwrap();
let (s, ca) = signer();
let json = seal("bin/gunnar", &data, &f, &a_record(), &s).unwrap().to_json().unwrap();
let certs = [s.certificate_der().to_vec()];
let roots = roots_of(&ca);
assert!(verify_against_disk(&path, json.as_bytes(), &certs, &roots).is_ok());
// Change the file on disk. The envelope is untouched and still valid.
std::fs::write(&path, b"\x7fELF replaced").unwrap();
assert!(matches!(
verify_against_disk(&path, json.as_bytes(), &certs, &roots),
Err(ProvenanceRefusal::SubjectIsNotThisFile { .. })
));
let _ = std::fs::remove_dir_all(&dir);
}
}