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

nordisk/holger

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