server/lib/src/scanner/attestation.rs
//! Scan evidence as signed attestations.
//!
//! An [`Evidence`] record becomes an in-toto Statement v1 about the artifact's
//! SHA-256, with predicate type [`HOLGER_SCAN_EVIDENCE_V1`] and the record as the
//! predicate. It is signed into a DSSE envelope by the existing
//! [`AttestSigner`] and filed through [`AttestStore::emit_signed`], the same
//! door every other attestation goes through. An SBOM a scanner produced is
//! filed beside it as a standard CycloneDX statement, so the existing
//! `AttestedGate` and any cosign-style reader find it.
//!
//! [`verify_evidence`] goes the other way: it verifies an envelope against the
//! store's trust anchors and returns the evidence inside. A changed byte in the
//! envelope fails the DSSE signature check.
use crate::attest::{
statement_bytes, Accepted, ArtifactDigest, AttestError, AttestSigner, AttestStore,
CYCLONEDX_BOM, HOLGER_SCAN_EVIDENCE_V1,
};
use super::{Evidence, ScannerKind};
fn subject_of(evidence: &Evidence) -> Result<(String, ArtifactDigest), AttestError> {
Ok((
evidence.subject.file_name.clone(),
ArtifactDigest::sha256(evidence.sha256())?,
))
}
/// The in-toto Statement bytes for one evidence record.
pub fn evidence_statement_bytes(evidence: &Evidence) -> Result<Vec<u8>, AttestError> {
let predicate = serde_json::to_value(evidence).map_err(|e| {
AttestError::StatementMalformed(format!("evidence does not serialize: {e}"))
})?;
Ok(statement_bytes(
&[subject_of(evidence)?],
HOLGER_SCAN_EVIDENCE_V1,
predicate,
))
}
/// ★ Sign one evidence record, and its SBOM when it has one, into `store`.
///
/// Returns the scan-evidence attestation first, then the SBOM's. In a build
/// without the `sign` feature this refuses
/// ([`AttestError::VerificationUnavailable`]); it never skips the signature.
pub fn attest_evidence(
store: &AttestStore,
signer: &AttestSigner,
evidence: &Evidence,
) -> Result<Vec<Accepted>, AttestError> {
attest_evidence_about(store, signer, evidence, &[])
}
/// [`attest_evidence`], with `also` named as further subjects of both statements.
///
/// An image's layout (`.oci.tar`) is one artifact on the chain, but a registry client
/// asks about the image by its MANIFEST digest: each of the layout's image digests
/// ([`crate::referrers::image_subjects`]) is named beside the layout's own, so the
/// store files Holger's verdict under the digest `cosign verify-attestation` checks
/// (lane HOL-REFERRERS). The layout's own digest stays the first subject.
pub fn attest_evidence_about(
store: &AttestStore,
signer: &AttestSigner,
evidence: &Evidence,
also: &[(String, ArtifactDigest)],
) -> Result<Vec<Accepted>, AttestError> {
let (name, digest) = subject_of(evidence)?;
let mut subjects = vec![(name, digest.clone())];
subjects.extend(also.iter().filter(|(_, d)| *d != digest).cloned());
let predicate = serde_json::to_value(evidence).map_err(|e| {
AttestError::StatementMalformed(format!("evidence does not serialize: {e}"))
})?;
let mut statements = vec![statement_bytes(
&subjects,
HOLGER_SCAN_EVIDENCE_V1,
predicate,
)];
if let Some(doc) = evidence.document_of(ScannerKind::Sbom) {
statements.push(statement_bytes(&subjects, CYCLONEDX_BOM, doc.clone()));
}
statements
.iter()
.map(|s| emit(store, signer, s, &digest))
.collect()
}
#[cfg(feature = "sign")]
fn emit(
store: &AttestStore,
signer: &AttestSigner,
payload: &[u8],
digest: &ArtifactDigest,
) -> Result<Accepted, AttestError> {
store.emit_signed(payload, signer, Some(digest))
}
#[cfg(not(feature = "sign"))]
fn emit(
_store: &AttestStore,
_signer: &AttestSigner,
_payload: &[u8],
_digest: &ArtifactDigest,
) -> Result<Accepted, AttestError> {
Err(AttestError::VerificationUnavailable)
}
/// ★ Verify a scan-evidence envelope about `artifact` and return the evidence.
///
/// Every check of [`AttestStore::accept`] runs (predicate, subject binding,
/// enrolment, DSSE signature, chain to a trust root), then the predicate is read
/// back as [`Evidence`] and must name the same artifact.
pub fn verify_evidence(
store: &AttestStore,
envelope_json: &[u8],
signer_certs_der: &[Vec<u8>],
artifact: &ArtifactDigest,
) -> Result<Evidence, AttestError> {
let statement = store.verify(envelope_json, signer_certs_der, Some(artifact))?;
if statement.predicate_type != HOLGER_SCAN_EVIDENCE_V1 {
return Err(AttestError::PredicateUnsupported(format!(
"{} is not scan evidence ({HOLGER_SCAN_EVIDENCE_V1})",
statement.predicate_type
)));
}
let evidence: Evidence = serde_json::from_value(statement.predicate.clone()).map_err(|e| {
AttestError::StatementMalformed(format!("the predicate is not scan evidence: {e}"))
})?;
if evidence.sha256() != artifact.hex() || artifact.alg() != "sha256" {
return Err(AttestError::SubjectMismatch {
want: artifact.to_string(),
found: vec![format!("sha256:{}", evidence.sha256())],
});
}
Ok(evidence)
}
/// Every verified scan-evidence record the store holds about `artifact`.
pub fn stored_evidence(
store: &AttestStore,
artifact: &ArtifactDigest,
) -> Result<Vec<Evidence>, AttestError> {
store
.query(artifact, HOLGER_SCAN_EVIDENCE_V1)?
.into_iter()
.map(|a| {
serde_json::from_value(a.statement.predicate.clone()).map_err(|e| {
AttestError::StatementMalformed(format!("the predicate is not scan evidence: {e}"))
})
})
.collect()
}