server/lib/src/attest.rs
//! ATTESTATIONS — the evidence plane, keyed by ARTIFACT DIGEST.
//!
//! An attestation is a signed statement **about specific bytes**. A gate (a CVE
//! scan, a license review, a reproducible-build check, an SBOM generator) runs
//! OUTSIDE holger, emits a statement naming the digest of what it looked at, signs
//! it, and hands it here. holger VERIFIES it on receipt and records it. A later
//! promotion asks this store *"what does anybody with a key I trust claim about
//! these exact bytes?"* — and gets an answer that is true of the bytes, not of a
//! name.
//!
//! ## ★ Why the digest is the key and the coordinate is not
//!
//! `server/lib/src/sbom.rs` stores its documents under `(repo, coordinate)`, and a
//! coordinate is a MUTABLE STRING. `rust-dev/serde/1.0.0` is whatever was last
//! written to `rust-dev/serde/1.0.0`. Evidence filed under a name is evidence for
//! a name, and a promotion gate that trusts it is trusting the naming, not the
//! bytes. The SAME defect is visible one layer down: `security::artifact_from_
//! coords` hands modgunn `blob_sha256: String::new()` while modgunn's
//! `Verdict::cache_key()` is `(blob_sha256, policy_version, advisory_db_rev)` —
//! content-addressed BY DESIGN, fed an empty digest.
//!
//! This store is keyed by `<alg>/<hex>` and nothing else. Re-uploading different
//! bytes under the same coordinate produces a different digest, so it cannot
//! inherit the old bytes' evidence. That is the entire mechanism.
//!
//! ## ★ Reuse, not a seventh envelope (LAW #5)
//!
//! The house already carries five bespoke signature containers — KORPSIG1,
//! `holger.update-bundle/v1`, `gunnar-sshsig-v2`, monetize's signature-inside-the-
//! object and Skidbladnir's `.sig.json` — and each one costs a verifier nobody
//! outside the house can write. This module adds NONE. It speaks:
//!
//! * **DSSE v1** ([`znippy_common::sign::dsse_pae`] / [`dsse_verify`]) — byte-exact
//! PAE including the payload-type-confusion defence, proven against the spec's
//! own vector, producer AND verifier, pure Rust;
//! * **in-toto Statement v1** — the same `_type` / `subject[].digest` /
//! `predicateType` shape `nornir-build-thing::provenance::to_slsa_statement`
//! already emits and `modgunn::provenance::verify_sealed_provenance` already
//! reads.
//!
//! cosign, slsa-verifier and Rekor read this. So does the rest of the suite.
//!
//! ## ★ The check ordering, and why binding comes before cryptography
//!
//! [`AttestStore::accept`] parses, then checks the SUBJECT BINDING, then verifies
//! the signature. That is deliberate and it copies modgunn: `verify_sealed_
//! provenance` recomputes the statement and requires the envelope payload to match
//! BYTE-FOR-BYTE *before* it calls `dsse_verify`. Both gates are mandatory and
//! nothing is written until both pass, so the order costs no security — and it
//! buys two things. First, the binding refusal (`holger-attest-3`) stays reachable
//! in a build without the `sign` feature, so the attack this module exists to stop
//! is covered by a test in EVERY build. Second, an operator whose gate scanned the
//! wrong artifact is told exactly that, instead of being sent to look at their key.
//!
//! ## Multiple attestations, and set-scope statements
//!
//! One artifact carries MANY attestations: an SBOM from one gate, a vulnerability
//! verdict from another, a build provenance from the forge. They are distinguished
//! by `predicateType` and stored side by side — [`AttestStore::query`] answers
//! `(digest, predicateType)` and returns ALL of them, because two gates disagreeing
//! about the same predicate is a fact the promotion boundary must see, not one the
//! store may silently resolve last-wins.
//!
//! An in-toto Statement may name MANY subjects, and "the analysis of the promoted
//! SET as a whole" is exactly that. One such envelope is stored under EVERY subject
//! digest it names, so a query for any member finds it.
//!
//! ## Storage
//!
//! ```text
//! <root>/<alg>/<digest-hex>/<predicate-key>-<envelope-key>.dsse.json
//! ```
//!
//! `<alg>` and `<digest-hex>` come from a digest this module already refused
//! unless it was a lowercase-hex string of the algorithm's exact length, so the
//! key is path-safe BY CONSTRUCTION — no sanitizing step, and therefore no
//! many-to-one sanitizer to collapse two artifacts onto one directory (the
//! `rust/prod` → `rust_prod` hazard `sbom.rs` documents). `<predicate-key>` and
//! `<envelope-key>` are blake3 prefixes over the predicate type and the envelope
//! bytes: two different envelopes for the same predicate get two files, the same
//! envelope submitted twice gets one.
#[cfg(feature = "sign")]
use crate::lock::LockOrRecover as _;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use holger_errcode as ec;
use serde_json::Value;
use sha2::{Digest, Sha256};
// ── The standard type URIs. Spelled here once, consumed everywhere. ───────────
/// The in-toto **Statement v1** `_type`. Anything else is not a statement this
/// store will file, however well signed it is.
pub const IN_TOTO_STATEMENT_TYPE: &str = "https://in-toto.io/Statement/v1";
/// in-toto Statement v0.1: what cosign still writes, so what Chainguard's image
/// attestations carry. Accepted beside v1 (the shape is the same; only the
/// `_type` differs). Found by the bench lane 2026-10-23.
pub const IN_TOTO_STATEMENT_TYPE_V01: &str = "https://in-toto.io/Statement/v0.1";
/// The DSSE `payloadType` for an in-toto statement — what cosign, slsa-verifier
/// and Rekor key on. Identical to `znippy_common::sign::DSSE_PAYLOAD_TYPE_INTOTO`
/// and to `nornir_build_thing::provenance::DSSE_PAYLOAD_TYPE_INTOTO`; restated as
/// a `const` so the non-`sign` build can still name it.
pub const DSSE_PAYLOAD_TYPE_INTOTO: &str = "application/vnd.in-toto+json";
/// SLSA **Provenance v1** — what the forge emits (`to_slsa_statement`).
pub const SLSA_PROVENANCE_V1: &str = "https://slsa.dev/provenance/v1";
/// A **CycloneDX** BOM as an in-toto predicate. CycloneDX is a legitimate
/// predicate type, which is what lets an SBOM stop being a document filed under a
/// name and become a statement bound to a hash (see [`crate::sbom`]).
pub const CYCLONEDX_BOM: &str = "https://cyclonedx.org/bom";
/// The in-toto standard **vulnerability-scan** predicate — the shape a modgunn
/// verdict rides in. A standard predicate, not a holger invention.
pub const INTOTO_VULNS_V01: &str = "https://in-toto.io/attestation/vulns/v0.1";
/// The in-toto standard **test-result** predicate.
pub const INTOTO_TEST_RESULT_V01: &str = "https://in-toto.io/attestation/test-result/v0.1";
/// Holger's own **scan evidence** predicate: one `scanner::Evidence` record,
/// every scanner's typed verdict about these bytes. Emitted by
/// `crate::scanner::attestation`.
pub const HOLGER_SCAN_EVIDENCE_V1: &str = "https://holger.rs/attestation/scan-evidence/v1";
/// SLSA **Verification Summary v1** — the VSA.
///
/// This is the predicate holger EMITS, and the only one it emits. A VSA is the
/// standard shape for "a verifier looked at these bytes, under this policy, and
/// this is what it decided". It is exactly what a promotion produces: the gates
/// ran over a set of digests and returned a `nornir_gate::Decision`.
///
/// It is a STANDARD predicate. holger does not invent an envelope, a payload
/// type or a predicate; the house already carries six envelope formats and there
/// is one DSSE implementation in it, `znippy_common::sign`.
pub const SLSA_VSA_V1: &str = "https://slsa.dev/verification_summary/v1";
/// The digest algorithms this store keys by, with their exact lowercase-hex
/// length. A digest whose length does not match its algorithm is REFUSED: the
/// digest is the storage key, and a key of the wrong shape is either a typo or an
/// attempt to steer the write, never a legitimate hash.
const DIGEST_ALGS: &[(&str, usize)] = &[("sha256", 64), ("sha512", 128), ("blake3", 64)];
// ── Refusals ─────────────────────────────────────────────────────────────────
/// Why an attestation was refused. Every variant carries one of the frozen
/// `holger-attest-<n>` codes, and [`AttestError::code`] is an EXHAUSTIVE match on
/// purpose: adding a refusal without allocating it a code is a compile error, not
/// a runtime surprise. Same rule as `BundleError` and `MarketplaceError`.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AttestError {
/// The DSSE envelope is not readable at all.
#[error("DSSE envelope is malformed: {0}")]
EnvelopeMalformed(String),
/// The payload is not an in-toto Statement v1 with a digested subject.
#[error("payload is not an in-toto Statement v1: {0}")]
StatementMalformed(String),
/// ★ The statement is about DIFFERENT bytes than the ones being attested.
#[error("this statement is about other bytes: it attests {found:?}, not {want} — a valid signature over a DIFFERENT artifact is not evidence for this one")]
SubjectMismatch {
/// The digest the caller asked this statement to be about.
want: String,
/// The subject digests the statement actually names.
found: Vec<String>,
},
/// No enrolled gate certificate for an envelope signature.
#[error("no enrolled gate certificate for this envelope signature: {0}")]
SignerUntrusted(String),
/// The signature did not verify, or the signer does not chain to a root.
#[error("DSSE signature did not verify or the signer does not chain to a trusted root: {0}")]
EnvelopeUnverified(String),
/// A `predicateType` this server does not accept.
#[error("predicateType {0:?} is not one this server accepts")]
PredicateUnsupported(String),
/// A digest that is not lowercase hex of the declared algorithm's length.
#[error("unusable artifact digest: {0}")]
DigestUnsafe(String),
/// The store could not be read or written. Infrastructure, not a verdict.
#[error("attestation store I/O failed: {0}")]
StoreIo(String),
/// ★ A path NAMED in `attestation.trust_roots` is not a usable trust anchor:
/// unreadable, empty, or not a DER certificate. See
/// [`AttestTrust::from_der_root_files`].
#[error("a declared attestation trust root is not a usable trust anchor: {0} — refusing rather than verifying against fewer roots than the operator declared")]
TrustRootUnreadable(String),
/// This build cannot verify DSSE envelopes at all (no `sign` feature).
#[error("this build of holger-server-lib was compiled without the `sign` feature, so a DSSE envelope cannot be verified; refusing to store unverified evidence")]
VerificationUnavailable,
}
impl AttestError {
/// The frozen code for this refusal. Exhaustive by construction.
pub fn code(&self) -> &'static ec::ErrCode {
match self {
AttestError::EnvelopeMalformed(_) => &ec::ATTEST_ENVELOPE_MALFORMED,
AttestError::StatementMalformed(_) => &ec::ATTEST_STATEMENT_MALFORMED,
AttestError::SubjectMismatch { .. } => &ec::ATTEST_SUBJECT_MISMATCH,
AttestError::SignerUntrusted(_) => &ec::ATTEST_SIGNER_UNTRUSTED,
AttestError::EnvelopeUnverified(_) => &ec::ATTEST_ENVELOPE_UNVERIFIED,
AttestError::PredicateUnsupported(_) => &ec::ATTEST_PREDICATE_UNSUPPORTED,
AttestError::DigestUnsafe(_) => &ec::ATTEST_DIGEST_UNSAFE,
AttestError::StoreIo(_) => &ec::ATTEST_STORE_IO,
AttestError::TrustRootUnreadable(_) => &ec::ATTEST_TRUST_ROOT_UNREADABLE,
AttestError::VerificationUnavailable => &ec::ATTEST_VERIFICATION_UNAVAILABLE,
}
}
/// Both channels in one string: `[holger-attest-3] <human sentence>`. The same
/// shape every other holger subsystem emits, so `holger_errcode::parse` finds
/// the code in a log line, an audit row or a gRPC status message alike.
pub fn tagged(&self) -> String {
self.code().tag(self)
}
}
// ── The key: an artifact digest ──────────────────────────────────────────────
/// An artifact digest — `(algorithm, lowercase hex)`. This is the store's ONLY
/// key.
///
/// ★ It is validated at construction and there is no other constructor, so every
/// `ArtifactDigest` in the process is already a path-safe pair of single segments.
/// `sbom.rs` reaches the same containment by SANITIZING a coordinate, and pays for
/// it: `sanitize_repo` is many-to-one, so it must additionally refuse any name
/// that is not already its own storage key or `rust/prod` would land on the real
/// `rust_prod`. A hex digest has no such collapse to defend against.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ArtifactDigest {
alg: String,
hex: String,
}
impl ArtifactDigest {
/// Parse `(alg, hex)`, refusing anything that is not a known algorithm and a
/// lowercase-hex string of exactly that algorithm's length.
///
/// Uppercase hex is refused rather than lowercased: two spellings of one
/// digest would key two directories, and an attestation filed under `ABCD…`
/// would be invisible to a query for `abcd…` — a fail-open that looks like an
/// absent attestation, which is the failure mode hardest to notice.
pub fn parse(alg: &str, hex: &str) -> Result<Self, AttestError> {
let Some((_, want_len)) = DIGEST_ALGS.iter().find(|(a, _)| *a == alg) else {
let known: Vec<&str> = DIGEST_ALGS.iter().map(|(a, _)| *a).collect();
return Err(AttestError::DigestUnsafe(format!(
"algorithm {alg:?} is not one this store keys by (known: {known:?})"
)));
};
if hex.len() != *want_len {
return Err(AttestError::DigestUnsafe(format!(
"{alg} digest must be {want_len} hex characters, got {}",
hex.len()
)));
}
if !hex
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
{
return Err(AttestError::DigestUnsafe(format!(
"{alg} digest is not lowercase hex (uppercase is refused, not folded: two \
spellings of one digest would key two directories)"
)));
}
Ok(Self {
alg: alg.to_string(),
hex: hex.to_string(),
})
}
/// A SHA-256 digest from its lowercase hex — the common case.
pub fn sha256(hex: &str) -> Result<Self, AttestError> {
Self::parse("sha256", hex)
}
/// The SHA-256 digest OF `bytes` — for the caller that holds the artifact.
pub fn of_sha256(bytes: &[u8]) -> Self {
Self {
alg: "sha256".into(),
hex: hex::encode(Sha256::digest(bytes)),
}
}
/// The algorithm label (`sha256`), as it appears in an in-toto `digest` map.
pub fn alg(&self) -> &str {
&self.alg
}
/// The lowercase hex.
pub fn hex(&self) -> &str {
&self.hex
}
}
impl std::fmt::Display for ArtifactDigest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}", self.alg, self.hex)
}
}
// ── The in-toto Statement ────────────────────────────────────────────────────
/// One `subject[]` entry: a name and the digests that identify its bytes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Subject {
/// The subject's name — human context only. It is NEVER part of the key: the
/// whole point of this module is that a name binds nothing.
pub name: String,
/// `algorithm → lowercase hex`, as in-toto spells it.
pub digest: BTreeMap<String, String>,
}
/// An in-toto **Statement v1**, as parsed out of a DSSE payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Statement {
/// Every subject the statement is about. Non-empty by construction — a
/// statement with no digested subject is a signature over an opinion, and
/// [`Statement::parse`] refuses it.
pub subjects: Vec<Subject>,
/// The `predicateType` URI.
pub predicate_type: String,
/// The predicate body, untouched.
pub predicate: Value,
}
impl Statement {
/// Parse the DSSE payload bytes as an in-toto Statement v1 or v0.1.
///
/// Refuses a wrong `_type`, a missing `predicateType`, an empty `subject`, and
/// a subject carrying no digest map. The last one matters: in-toto permits a
/// subject to be named without a digest, and such a subject binds NOTHING.
/// Accepting it here would let a statement claim to be about `serde 1.0.0` in
/// words while naming no bytes at all — the coordinate defect, smuggled back
/// in through a standard format.
pub fn parse(payload: &[u8]) -> Result<Self, AttestError> {
let v: Value = serde_json::from_slice(payload)
.map_err(|e| AttestError::StatementMalformed(format!("payload is not JSON: {e}")))?;
let ty = v.get("_type").and_then(Value::as_str).unwrap_or_default();
if ty != IN_TOTO_STATEMENT_TYPE && ty != IN_TOTO_STATEMENT_TYPE_V01 {
return Err(AttestError::StatementMalformed(format!(
"_type is {ty:?}, expected {IN_TOTO_STATEMENT_TYPE:?} or {IN_TOTO_STATEMENT_TYPE_V01:?}"
)));
}
let predicate_type = v
.get("predicateType")
.and_then(Value::as_str)
.ok_or_else(|| AttestError::StatementMalformed("no predicateType".into()))?
.to_string();
let raw = v
.get("subject")
.and_then(Value::as_array)
.ok_or_else(|| AttestError::StatementMalformed("no subject array".into()))?;
if raw.is_empty() {
return Err(AttestError::StatementMalformed(
"subject array is empty — a statement about nothing is not evidence".into(),
));
}
let mut subjects = Vec::with_capacity(raw.len());
for (i, s) in raw.iter().enumerate() {
let name = s
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let map = s.get("digest").and_then(Value::as_object).ok_or_else(|| {
AttestError::StatementMalformed(format!("subject[{i}] carries no digest map"))
})?;
let mut digest = BTreeMap::new();
for (k, val) in map {
let Some(hexstr) = val.as_str() else {
return Err(AttestError::StatementMalformed(format!(
"subject[{i}].digest.{k} is not a string"
)));
};
digest.insert(k.clone(), hexstr.to_string());
}
if digest.is_empty() {
return Err(AttestError::StatementMalformed(format!(
"subject[{i}].digest is empty — a subject with no digest binds no bytes"
)));
}
subjects.push(Subject { name, digest });
}
Ok(Statement {
subjects,
predicate_type,
predicate: v.get("predicate").cloned().unwrap_or(Value::Null),
})
}
/// ★ Does this statement attest THESE bytes?
///
/// True only when some subject's digest map carries an `alg → hex` pair equal
/// to `want`. The comparison is exact — `want` is already known lowercase hex
/// by construction, and a subject digest that differs only in case is treated
/// as a different digest, for the same reason [`ArtifactDigest::parse`] refuses
/// uppercase: folding would make two spellings interchangeable and the fold
/// would then be the trusted step.
pub fn attests(&self, want: &ArtifactDigest) -> bool {
self.subjects
.iter()
.any(|s| s.digest.get(want.alg()).map(String::as_str) == Some(want.hex()))
}
/// Every subject digest this statement names that this store can key by. A
/// subject digest in an algorithm the store does not know is skipped, not
/// refused: a statement may legitimately carry `sha1` beside `sha256` for an
/// external consumer, and dropping the whole statement over the extra one
/// would refuse good evidence.
pub fn keyable_subjects(&self) -> Vec<ArtifactDigest> {
let mut out: BTreeSet<ArtifactDigest> = BTreeSet::new();
for s in &self.subjects {
for (alg, hexstr) in &s.digest {
if let Ok(d) = ArtifactDigest::parse(alg, hexstr) {
out.insert(d);
}
}
}
out.into_iter().collect()
}
/// The subject digests as `alg:hex` strings, for a refusal message.
fn describe_subjects(&self) -> Vec<String> {
self.subjects
.iter()
.flat_map(|s| s.digest.iter().map(|(a, h)| format!("{a}:{h}")))
.collect()
}
}
// ── Trust ────────────────────────────────────────────────────────────────────
/// Is `der` shaped like a DER-encoded X.509 certificate?
///
/// A structural gate, not a parse: it answers "could this possibly be a trust
/// anchor" so that a path the operator NAMED cannot become an anchor that matches
/// nothing. See [`AttestTrust::from_der_root_files`] for why the full parse is
/// deliberately left to `znippy_common::sign`.
fn check_der_certificate(der: &[u8]) -> Result<(), String> {
if der.is_empty() {
return Err(
"the file is EMPTY. An empty root is not 'no root': it is a declared \
anchor that can never match, which reads in the log as a mounted \
evidence plane and behaves as an unmounted one"
.to_string(),
);
}
if der[0] == b'-' {
return Err(
"the file begins with '-', so it is PEM, not DER. Convert it: \
`openssl x509 -in <file> -outform der -out <file>.der`"
.to_string(),
);
}
if der[0] != 0x30 {
return Err(format!(
"the file does not begin with an ASN.1 SEQUENCE tag (0x30 expected, 0x{:02x} \
found), so it is not a DER certificate",
der[0]
));
}
// DER length: short form (< 0x80) or long form (0x8n followed by n bytes).
let (len, header) = match der.get(1) {
None => return Err("the file is one byte long; a DER certificate is not".to_string()),
Some(&b) if b < 0x80 => (b as usize, 2usize),
Some(&0x80) => {
return Err("the SEQUENCE uses INDEFINITE length, which DER forbids".to_string())
}
Some(&b) => {
let n = (b & 0x7f) as usize;
if n > 4 || der.len() < 2 + n {
return Err("the SEQUENCE length field is truncated or absurd".to_string());
}
let mut v = 0usize;
for byte in &der[2..2 + n] {
v = (v << 8) | *byte as usize;
}
(v, 2 + n)
}
};
if header + len > der.len() {
return Err(format!(
"the DER SEQUENCE declares {len} content bytes but the file holds only {} after \
its {header}-byte header — the certificate is TRUNCATED",
der.len() - header
));
}
Ok(())
}
/// The trust anchors an attestation is verified against.
///
/// TWO layers, and they answer two different questions:
///
/// * `roots` — DER CA certificates the signer must CHAIN to. Loaded exactly the
/// way `repository.rs`'s `ron_trusted_roots` loads them (a DER file per root,
/// read at wiring time, failing closed), so this is the SAME trust-root
/// mechanism the znippy archive path already uses and not a second one.
/// * `enrolled` — the SHA-256 fingerprints of the specific gate certificates this
/// server accepts verdicts from.
///
/// ★ The second layer is not redundant. A corporate CA signs hundreds of leaf
/// certificates; chaining proves the key belongs to the organisation, not that it
/// belongs to the SCANNER. Without enrolment, anybody inside the CA's perimeter
/// could sign "no vulnerabilities" and holger would file it as a gate verdict.
/// Enrolment is what makes `holger-attest-4` a different sentence from
/// `holger-attest-5`, and the two failures want different fixes: enrol the key, or
/// fix the signature.
#[derive(Debug, Clone, Default)]
pub struct AttestTrust {
roots: Vec<Vec<u8>>,
enrolled: BTreeSet<String>,
}
/// **A certificate file's DER**: the file as it is, or the first `CERTIFICATE` of a PEM file
/// (a medium's staged parameters are text, lane HOL-CDS-5). A PEM that does not decode is
/// handed on as it is, so the DER check names it.
pub fn der_of(file: &[u8]) -> Vec<u8> {
use base64::Engine as _;
let Ok(text) = std::str::from_utf8(file) else {
return file.to_vec();
};
let (Some(a), Some(b)) = (
text.find("-----BEGIN CERTIFICATE-----"),
text.find("-----END CERTIFICATE-----"),
) else {
return file.to_vec();
};
let body: String = text[a + "-----BEGIN CERTIFICATE-----".len()..b]
.chars()
.filter(|c| !c.is_whitespace())
.collect();
base64::engine::general_purpose::STANDARD
.decode(body)
.unwrap_or_else(|_| file.to_vec())
}
impl AttestTrust {
/// An empty trust set. With no roots and no enrolled gates, every `accept`
/// refuses — fail-closed, which is the only safe default for an evidence
/// store.
pub fn new() -> Self {
Self::default()
}
/// Load DER root certificates from `paths`, mirroring `ron_trusted_roots`.
///
/// ★ **Every declared path must yield a usable anchor, or NONE of them is
/// loaded.** Until 2026-09-21 this read the file and pushed the bytes, so a
/// zero-byte file, a PEM file somebody forgot to convert, or a path pointing
/// at a directory listing became a "root" that could never match anything.
/// The boot succeeded, the log said the evidence plane was mounted, and the
/// trust set was effectively empty — an estate that believed it was verifying
/// against `n` roots while verifying against `n - k`.
///
/// This is [`crate::attest`]'s copy of the rule the plugin host has enforced
/// since `holger-plugin-8` (`plugin/host/src/verify.rs`): *refusing rather
/// than verifying against fewer roots than the operator declared.* It was
/// written there in prose and in code; here it was written only in prose.
///
/// The check is structural and costs no dependency: a DER certificate is an
/// ASN.1 `SEQUENCE`, so byte 0 is `0x30` and the encoded length must account
/// for the whole file. That refuses empty files, PEM (`-----BEGIN`), text and
/// truncation. It is deliberately NOT a full X.509 parse — the parse that
/// decides trust is `znippy_common::sign`'s, at verification time, and a
/// second half-parse here would be a second place for the rule to be wrong.
pub fn from_der_root_files(paths: &[PathBuf]) -> Result<Self, AttestError> {
let mut t = Self::new();
for p in paths {
let der = std::fs::read(p).map(|b| der_of(&b)).map_err(|e| {
AttestError::TrustRootUnreadable(format!(
"reading trusted root {}: {e}",
p.display()
))
})?;
check_der_certificate(&der).map_err(|why| {
AttestError::TrustRootUnreadable(format!("trusted root {}: {why}", p.display()))
})?;
t.roots.push(der);
}
Ok(t)
}
/// Add a DER root certificate directly (the in-process / test path).
pub fn add_root_der(&mut self, der: Vec<u8>) -> &mut Self {
self.roots.push(der);
self
}
/// Enrol a gate's signer certificate and return its SHA-256 fingerprint hex —
/// the same fingerprint `znippy_common::sign` puts in a DSSE `keyid` and the
/// same one `openssl x509 -fingerprint -sha256` prints.
///
/// Computed here with `sha2` over the DER, not through the `sign` feature:
/// enrolment is bookkeeping and must work in every build, including the one
/// that cannot verify.
pub fn enrol_signer_der(&mut self, der: &[u8]) -> String {
let fp = hex::encode(Sha256::digest(der));
self.enrolled.insert(fp.clone());
fp
}
/// Enrol a gate by fingerprint hex alone (the config path: an operator pastes
/// a fingerprint, not a certificate).
pub fn enrol_fingerprint(&mut self, fp_hex: &str) -> &mut Self {
self.enrolled.insert(fp_hex.to_ascii_lowercase());
self
}
/// Is this fingerprint an enrolled gate?
pub fn is_enrolled(&self, fp_hex: &str) -> bool {
self.enrolled.contains(&fp_hex.to_ascii_lowercase())
}
/// The DER root certificates.
pub fn roots(&self) -> &[Vec<u8>] {
&self.roots
}
/// The enrolled gate fingerprints.
pub fn enrolled(&self) -> &BTreeSet<String> {
&self.enrolled
}
}
/// Which `predicateType`s this server will file.
///
/// ★ An allowlist, never "anything goes". Storing a predicate nothing in this
/// build knows how to evaluate lets a later gate read a claim it cannot check and
/// count it as satisfied — a fail-open with extra steps. The default set is the
/// four STANDARD predicates the suite already produces or consumes; a deployment
/// adds its own with [`AttestPolicy::accept`].
#[derive(Debug, Clone)]
pub struct AttestPolicy {
accepted: BTreeSet<String>,
}
impl Default for AttestPolicy {
fn default() -> Self {
let mut accepted = BTreeSet::new();
// SLSA_VSA_V1 is in the default set because holger EMITS it. A store
// whose default policy refused holger's own verification summary would
// make the evidence path work only after the operator opted into the one
// predicate this server produces.
for pt in [
SLSA_PROVENANCE_V1,
CYCLONEDX_BOM,
INTOTO_VULNS_V01,
INTOTO_TEST_RESULT_V01,
SLSA_VSA_V1,
HOLGER_SCAN_EVIDENCE_V1,
] {
accepted.insert(pt.to_string());
}
Self { accepted }
}
}
impl AttestPolicy {
/// A policy accepting NOTHING — the base an operator builds an explicit set on.
pub fn none() -> Self {
Self {
accepted: BTreeSet::new(),
}
}
/// Also accept `predicate_type`.
pub fn accept(mut self, predicate_type: &str) -> Self {
self.accepted.insert(predicate_type.to_string());
self
}
/// Is this predicate type accepted?
pub fn accepts(&self, predicate_type: &str) -> bool {
self.accepted.contains(predicate_type)
}
}
// ── What a successful accept / query yields ──────────────────────────────────
/// The outcome of filing one verified attestation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Accepted {
/// The envelope's content id (blake3 hex of the stored bytes) — stable, so a
/// re-submission of the same envelope is the same id and the same file.
pub envelope_id: String,
/// The statement's `predicateType`.
pub predicate_type: String,
/// Every subject digest it was filed under. More than one ⇒ a set-scope
/// statement.
pub subjects: Vec<ArtifactDigest>,
/// The verified signers, by certificate fingerprint hex.
pub signers: Vec<String>,
}
/// One attestation read back out of the store.
#[derive(Debug, Clone)]
pub struct StoredAttestation {
/// The envelope content id.
pub envelope_id: String,
/// The DSSE envelope, byte-identical to what was filed.
pub envelope_json: Vec<u8>,
/// Its parsed statement.
pub statement: Statement,
}
// ── The store ────────────────────────────────────────────────────────────────
/// The on-disk attestation store, keyed by artifact digest.
pub struct AttestStore {
root: PathBuf,
trust: AttestTrust,
policy: AttestPolicy,
}
/// **A store's trust, as data** (lane HOL-SANDBOX-TRAIT): the DER roots, the enrolled
/// gate fingerprints and the accepted predicate types, so a verify-bundle job checks
/// the evidence of an airgap import with exactly this side's trust and nothing more.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TrustDoc {
/// Each root certificate, DER, lower-case hex.
pub roots: Vec<String>,
pub enrolled: Vec<String>,
pub accepted: Vec<String>,
}
impl AttestStore {
/// This store's trust as a [`TrustDoc`].
pub fn trust_doc(&self) -> TrustDoc {
TrustDoc {
roots: self.trust.roots.iter().map(hex::encode).collect(),
enrolled: self.trust.enrolled.iter().cloned().collect(),
accepted: self.policy.accepted.iter().cloned().collect(),
}
}
/// A store over `root` that trusts exactly what `doc` says (a job's copy: it
/// verifies, it files nothing).
pub fn from_trust_doc(root: impl Into<PathBuf>, doc: &TrustDoc) -> Result<Self, String> {
let mut roots = Vec::new();
for r in &doc.roots {
roots.push(hex::decode(r).map_err(|e| format!("a root is not hex: {e}"))?);
}
let trust = AttestTrust {
roots,
enrolled: doc.enrolled.iter().cloned().collect(),
};
let policy = AttestPolicy {
accepted: doc.accepted.iter().cloned().collect(),
};
Ok(Self::new(root).with_trust(trust).with_policy(policy))
}
}
impl AttestStore {
/// A store rooted at `root`, with no trust anchors (every `accept` refuses)
/// and the default predicate allowlist.
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
trust: AttestTrust::new(),
policy: AttestPolicy::default(),
}
}
/// The same store with these trust anchors.
pub fn with_trust(mut self, trust: AttestTrust) -> Self {
self.trust = trust;
self
}
/// The same store with this predicate allowlist.
pub fn with_policy(mut self, policy: AttestPolicy) -> Self {
self.policy = policy;
self
}
/// The trust anchors in force.
pub fn trust(&self) -> &AttestTrust {
&self.trust
}
/// The directory holding every attestation about `digest`.
/// The store's root on disk. Public because the ESTATE replication
/// (`crate::replication::replicate_estate`, plan §3 W10) has to be able to
/// say WHICH directory a twin is missing when it holds artifacts and no
/// evidence — `replicate_pairs` moves bytes by coordinate and this tree is
/// the thing it cannot move.
pub fn root(&self) -> &Path {
&self.root
}
/// Where the envelopes about `digest` live. Public for the same reason as
/// [`root`](Self::root): a caller that must PROVE a twin holds the evidence
/// needs to name the place, and a second private copy of this one-line path
/// rule elsewhere is how the two would drift.
pub fn digest_dir(&self, digest: &ArtifactDigest) -> PathBuf {
self.root.join(digest.alg()).join(digest.hex())
}
/// The 16-hex blake3 prefix that keys a predicate type in a filename. A
/// predicate type is a URI full of `/` and `:`; hashing it makes the filename
/// a single safe segment without inventing an escaping scheme whose inverse
/// somebody would then have to maintain. The real predicate type is read back
/// from the STATEMENT, never from the filename, so the hash never has to be
/// undone.
fn predicate_key(predicate_type: &str) -> String {
blake3::hash(predicate_type.as_bytes()).to_hex()[..16].to_string()
}
/// ★ Every check [`accept`](Self::accept) makes, and no write: parse,
/// predicate, subject binding, enrolment, cryptography. Returns the verified
/// statement. This is how a reader verifies an envelope it was handed or read
/// back, and a changed byte anywhere in it fails here.
pub fn verify(
&self,
envelope_json: &[u8],
signer_certs_der: &[Vec<u8>],
required_subject: Option<&ArtifactDigest>,
) -> Result<Statement, AttestError> {
Ok(self
.check(envelope_json, signer_certs_der, required_subject)?
.0)
}
fn check(
&self,
envelope_json: &[u8],
signer_certs_der: &[Vec<u8>],
required_subject: Option<&ArtifactDigest>,
) -> Result<(Statement, Vec<String>), AttestError> {
let (payload, payload_type, keyids) = parse_envelope(envelope_json)?;
if payload_type != DSSE_PAYLOAD_TYPE_INTOTO {
return Err(AttestError::EnvelopeMalformed(format!(
"payloadType is {payload_type:?}, expected {DSSE_PAYLOAD_TYPE_INTOTO:?} — DSSE \
binds the type INTO the signed bytes (PAE), so a mismatch here is a \
payload-type confusion attempt, not a formatting preference"
)));
}
let statement = Statement::parse(&payload)?;
if !self.policy.accepts(&statement.predicate_type) {
return Err(AttestError::PredicateUnsupported(
statement.predicate_type.clone(),
));
}
if let Some(want) = required_subject {
if !statement.attests(want) {
return Err(AttestError::SubjectMismatch {
want: want.to_string(),
found: statement.describe_subjects(),
});
}
}
// Enrolment: every signature must name a gate this server accepts verdicts
// from AND a certificate must actually be on offer for it. An envelope with
// an empty `keyid` is refused here rather than being tried against every
// candidate — DSSE allows the wildcard, but "some key I hold verified this"
// is not an identity, and the audit row must be able to say WHO.
let mut signers = Vec::with_capacity(keyids.len());
for keyid in &keyids {
if keyid.is_empty() {
return Err(AttestError::SignerUntrusted(
"envelope signature carries no keyid; this store files evidence against a \
NAMED gate, never against 'whichever key happened to verify'"
.into(),
));
}
if !self.trust.is_enrolled(keyid) {
return Err(AttestError::SignerUntrusted(format!(
"keyid {keyid} is not an enrolled gate on this server"
)));
}
let offered = signer_certs_der
.iter()
.any(|d| hex::encode(Sha256::digest(d)).eq_ignore_ascii_case(keyid));
if !offered {
return Err(AttestError::SignerUntrusted(format!(
"no certificate was offered for keyid {keyid}"
)));
}
signers.push(keyid.clone());
}
verify_dsse(envelope_json, signer_certs_der, self.trust.roots())?;
Ok((statement, signers))
}
/// ★ Verify an attestation and file it.
///
/// `envelope_json` is a DSSE envelope. `signer_certs_der` are the candidate
/// signer certificates (a gate ships its leaf certificate alongside the
/// envelope, the way `modgunn::provenance::DsseSeal` carries `signer_certs`).
/// `required_subject`, when given, is the artifact this evidence is claimed to
/// be about — and the statement MUST name it.
///
/// The order is parse → predicate → **subject binding** → enrolment →
/// cryptography → write. See the module docs for why binding precedes
/// cryptography; both are mandatory and nothing touches the disk until every
/// one has passed.
pub fn accept(
&self,
envelope_json: &[u8],
signer_certs_der: &[Vec<u8>],
required_subject: Option<&ArtifactDigest>,
) -> Result<Accepted, AttestError> {
let (statement, signers) = self.check(envelope_json, signer_certs_der, required_subject)?;
// Everything passed. File it under EVERY subject digest the statement
// names — a set-scope statement about N artifacts is findable from any of
// them.
let subjects = statement.keyable_subjects();
if subjects.is_empty() {
return Err(AttestError::StatementMalformed(
"no subject digest is in an algorithm this store keys by".into(),
));
}
let envelope_id = blake3::hash(envelope_json).to_hex().to_string();
let file = format!(
"{}-{}.dsse.json",
Self::predicate_key(&statement.predicate_type),
&envelope_id[..16]
);
for d in &subjects {
let dir = self.digest_dir(d);
std::fs::create_dir_all(&dir)
.map_err(|e| AttestError::StoreIo(format!("creating {}: {e}", dir.display())))?;
let path = dir.join(&file);
// Staged then renamed, so a concurrent query never observes a
// half-written envelope — the same discipline `SbomStore::attach` uses.
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, envelope_json)
.map_err(|e| AttestError::StoreIo(format!("writing {}: {e}", tmp.display())))?;
std::fs::rename(&tmp, &path)
.map_err(|e| AttestError::StoreIo(format!("committing {}: {e}", path.display())))?;
}
Ok(Accepted {
envelope_id,
predicate_type: statement.predicate_type,
subjects,
signers,
})
}
/// Every attestation filed about `digest`, newest-first ordering NOT promised —
/// the caller is asking for a SET of claims, and imposing an order would invite
/// somebody to read the first one and stop.
pub fn list(&self, digest: &ArtifactDigest) -> Result<Vec<StoredAttestation>, AttestError> {
let dir = self.digest_dir(digest);
let rd = match std::fs::read_dir(&dir) {
Ok(rd) => rd,
// No directory ⇒ no attestations. Absence is not-found, never an error:
// "nothing has attested these bytes" is the normal state of a fresh
// artifact and a promotion gate must be able to observe it plainly.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => {
return Err(AttestError::StoreIo(format!(
"reading {}: {e}",
dir.display()
)))
}
};
let mut out = Vec::new();
for entry in rd.flatten() {
let path = entry.path();
if !path.to_string_lossy().ends_with(".dsse.json") {
continue; // a crash-leftover `.tmp` is never served
}
let bytes = std::fs::read(&path)
.map_err(|e| AttestError::StoreIo(format!("reading {}: {e}", path.display())))?;
let (payload, _, _) = parse_envelope(&bytes)?;
let statement = Statement::parse(&payload)?;
out.push(StoredAttestation {
envelope_id: blake3::hash(&bytes).to_hex().to_string(),
envelope_json: bytes,
statement,
});
}
Ok(out)
}
/// Every attestation about `digest` with this `predicateType`.
///
/// ★ Returns ALL of them, not one. Two gates that disagree about the same
/// predicate is a FACT the promotion boundary must see; collapsing it here
/// (last-wins, or first-match) would let the store decide a trust question
/// silently — which is exactly the defect `sbom.rs`'s last-wins attach has.
pub fn query(
&self,
digest: &ArtifactDigest,
predicate_type: &str,
) -> Result<Vec<StoredAttestation>, AttestError> {
Ok(self
.list(digest)?
.into_iter()
.filter(|a| a.statement.predicate_type == predicate_type)
.collect())
}
/// Is there at least one attestation of this predicate type about these bytes?
pub fn has(&self, digest: &ArtifactDigest, predicate_type: &str) -> bool {
self.query(digest, predicate_type)
.map(|v| !v.is_empty())
.unwrap_or(false)
}
}
/// A shared handle for wiring into the gRPC state + HTTP gateway, matching
/// [`crate::sbom::SharedSbomStore`].
pub type SharedAttestStore = Arc<AttestStore>;
// ── The DSSE seam ────────────────────────────────────────────────────────────
//
// Two shapes of the same two functions, selected by the `sign` feature, exactly
// the way `repository.rs` carries two shapes of `wire_verified_znippy`. The
// feature-off twin REFUSES; it never degrades to storing unverified evidence,
// because a store that files whatever it is handed when the crypto is missing is
// worse than no store — it produces green audit rows with nothing behind them.
/// Parse a DSSE envelope into `(payload, payloadType, keyids)`.
#[cfg(feature = "sign")]
fn parse_envelope(envelope_json: &[u8]) -> Result<(Vec<u8>, String, Vec<String>), AttestError> {
let text = std::str::from_utf8(envelope_json)
.map_err(|e| AttestError::EnvelopeMalformed(format!("not UTF-8: {e}")))?;
let env = znippy_common::sign::DsseEnvelope::from_json(text)
.map_err(|e| AttestError::EnvelopeMalformed(format!("{e:#}")))?;
let keyids = env
.signatures
.iter()
.map(|s| s.keyid.to_ascii_lowercase())
.collect();
Ok((env.payload, env.payload_type, keyids))
}
/// Verify every envelope signature over `PAE(payloadType, payload)` and chain each
/// signer to `roots`. Delegates WHOLLY to `znippy_common::sign::dsse_verify` — the
/// PAE, the algorithm dispatch off the signer certificate's SPKI and the chain
/// walk are all already there, spec-exact and tested against the DSSE spec's own
/// vector. Re-implementing any of it here would be the sixth envelope in a new
/// costume.
#[cfg(feature = "sign")]
fn verify_dsse(
envelope_json: &[u8],
signer_certs_der: &[Vec<u8>],
roots_der: &[Vec<u8>],
) -> Result<(), AttestError> {
use znippy_common::sign::{dsse_verify, CertStore, DsseEnvelope};
let text = std::str::from_utf8(envelope_json)
.map_err(|e| AttestError::EnvelopeMalformed(format!("not UTF-8: {e}")))?;
let env = DsseEnvelope::from_json(text)
.map_err(|e| AttestError::EnvelopeMalformed(format!("{e:#}")))?;
let store = CertStore::from_der_certs(roots_der).map_err(|e| {
AttestError::EnvelopeUnverified(format!("cannot load trusted roots: {e:#}"))
})?;
let verified = dsse_verify(&env, signer_certs_der, &store)
.map_err(|e| AttestError::EnvelopeUnverified(format!("{e:#}")))?;
if verified.is_empty() {
return Err(AttestError::EnvelopeUnverified(
"envelope produced no verified signer".into(),
));
}
Ok(())
}
/// Fail-closed twin for a build WITHOUT `sign`: an envelope cannot even be read,
/// so it certainly cannot be trusted.
#[cfg(not(feature = "sign"))]
fn parse_envelope(_envelope_json: &[u8]) -> Result<(Vec<u8>, String, Vec<String>), AttestError> {
Err(AttestError::VerificationUnavailable)
}
/// Fail-closed twin for a build WITHOUT `sign`.
#[cfg(not(feature = "sign"))]
fn verify_dsse(
_envelope_json: &[u8],
_signer_certs_der: &[Vec<u8>],
_roots_der: &[Vec<u8>],
) -> Result<(), AttestError> {
Err(AttestError::VerificationUnavailable)
}
/// Build the canonical bytes of an in-toto Statement v1 — the exact payload a DSSE
/// envelope signs.
///
/// Key-sorted and compact, matching
/// `nornir_build_thing::provenance::slsa_statement_bytes`, so a producer over
/// there and a verifier here fold to the same bytes. `serde_json::Value` is a
/// `BTreeMap` under `preserve_order`-off, which is holger's build — hence sorted
/// by construction; the compactness comes from `to_vec` rather than `to_vec_pretty`.
pub fn statement_bytes(
subjects: &[(String, ArtifactDigest)],
predicate_type: &str,
predicate: Value,
) -> Vec<u8> {
let subject: Vec<Value> = subjects
.iter()
.map(|(name, d)| {
serde_json::json!({
"name": name,
"digest": { d.alg(): d.hex() },
})
})
.collect();
let statement = serde_json::json!({
"_type": IN_TOTO_STATEMENT_TYPE,
"subject": subject,
"predicateType": predicate_type,
"predicate": predicate,
});
serde_json::to_vec(&statement).expect("a Value built from literals always serializes")
}
// ── Emitting evidence: the VSA a promotion writes ────────────────────────────
/// Build the in-toto Statement payload for a **Verification Summary** over the
/// whole promoted set.
///
/// One statement, N subjects. `attest.rs` already files a set-scope statement
/// under EVERY member digest (`a_set_scope_statement_is_keyable_from_every_member`),
/// so one signature covers the promotion and is still findable from any single
/// artifact in it.
///
/// `verified_levels` is left empty on purpose: holger does not claim a SLSA
/// build level it did not verify. The fields it fills are the ones it actually
/// knows — who verified, when, against which policy, with what result, and which
/// gates ran.
///
/// Pure: no signing, no I/O. The caller signs it.
pub fn verification_summary_bytes(
subjects: &[(String, ArtifactDigest)],
verifier_id: &str,
result: VerificationResult,
policy: &str,
gates_run: &[String],
timestamp_rfc3339: &str,
) -> Vec<u8> {
let predicate = serde_json::json!({
"verifier": { "id": verifier_id },
"timeVerified": timestamp_rfc3339,
"policy": { "uri": policy },
"verificationResult": result.as_str(),
"verifiedLevels": Vec::<String>::new(),
// Not part of the SLSA schema's required set; carried because the whole
// point of the record is which checks ran. A reader that does not know
// the key ignores it; a reader that does can tell an artifact that
// passed three gates from one that passed none.
"gatesRun": gates_run,
});
statement_bytes(subjects, SLSA_VSA_V1, predicate)
}
/// Format a Unix timestamp (seconds, UTC) as RFC 3339, which is what the SLSA
/// VSA schema's `timeVerified` requires.
///
/// Written here rather than taken as a dependency: holger's server library
/// carries no date/time crate, and the airgap tiers are judged on their
/// dependency closure (`holger_recipe::rules::judge_artifact`). Fifteen lines of
/// civil-date arithmetic is cheaper than a crate, and it is the standard
/// days-from-civil algorithm, not an approximation.
pub fn rfc3339_utc(unix_secs: i64) -> String {
let days = unix_secs.div_euclid(86_400);
let secs = unix_secs.rem_euclid(86_400);
// days-from-civil, inverted (Howard Hinnant's chrono algorithms).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
y,
m,
d,
secs / 3600,
(secs % 3600) / 60,
secs % 60
)
}
/// The two values a VSA's `verificationResult` may take.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerificationResult {
Passed,
Failed,
}
impl VerificationResult {
pub fn as_str(&self) -> &'static str {
match self {
VerificationResult::Passed => "PASSED",
VerificationResult::Failed => "FAILED",
}
}
}
/// holger's own signing identity for the evidence it emits.
///
/// A PKCS#8 private key plus the X.509 certificate that carries its public key.
/// Both are supplied by the operator; holger mints nothing. That is deliberate:
/// `nornir --seal` signs with `ZnippySealer::dev()`, which mints an in-memory CA
/// and DISCARDS the root, so every provenance signature produced that way chains
/// to a CA nobody has. This type has no `dev()` and cannot be constructed without
/// a key and a certificate the operator already owns.
#[cfg(feature = "sign")]
pub struct AttestSigner {
/// `sign_raw` takes `&self`, but the trait object is only `Send`, so the
/// mutex is what makes the signer shareable across the server's threads.
/// Signing a few hundred bytes of PAE is microseconds; there is no
/// contention worth designing around.
inner: std::sync::Mutex<Box<dyn znippy_common::sign::ArchiveSigner + Send>>,
cert_der: Vec<u8>,
}
/// The same name in a build without `sign`, so callers need no `cfg`.
/// [`AttestSigner::from_pkcs8`] then REFUSES rather than silently producing no
/// evidence.
#[cfg(not(feature = "sign"))]
pub struct AttestSigner {
_uninhabited: std::convert::Infallible,
}
impl AttestSigner {
/// Load the signer from a PKCS#8 private key and its certificate, both DER.
///
/// `alg` is `"ed25519"` or `"ecdsa-p256"`, the two
/// `znippy_common::sign::SigAlg` values.
#[cfg(feature = "sign")]
pub fn from_pkcs8(alg: &str, pkcs8_key: &[u8], cert_der: &[u8]) -> Result<Self, AttestError> {
let alg = znippy_common::sign::SigAlg::from_name(alg)
.map_err(|e| AttestError::EnvelopeMalformed(format!("signer algorithm: {e:#}")))?;
let signer = znippy_common::sign::signer_from_pkcs8(alg, pkcs8_key, cert_der)
.map_err(|e| AttestError::EnvelopeMalformed(format!("loading the signer: {e:#}")))?;
Ok(Self {
inner: std::sync::Mutex::new(signer),
cert_der: cert_der.to_vec(),
})
}
/// Wrap an already-constructed znippy signer.
///
/// The seam a key store (or a test's dev PKI) uses when it holds a signer
/// rather than raw PKCS#8. Same shape as
/// `nornir::build::seal::ZnippySealer::new`.
#[cfg(feature = "sign")]
pub fn from_signer(
signer: Box<dyn znippy_common::sign::ArchiveSigner + Send>,
cert_der: Vec<u8>,
) -> Self {
Self {
inner: std::sync::Mutex::new(signer),
cert_der,
}
}
#[cfg(not(feature = "sign"))]
pub fn from_pkcs8(
_alg: &str,
_pkcs8_key: &[u8],
_cert_der: &[u8],
) -> Result<Self, AttestError> {
Err(AttestError::EnvelopeMalformed(
"this build has no `sign` feature, so it carries no DSSE implementation and cannot \
emit a verification summary. Refusing rather than promoting with no evidence written \
and no error, which is indistinguishable from evidence that was written."
.to_string(),
))
}
/// The signer certificate, DER. The verifier needs it to check the envelope.
#[cfg(feature = "sign")]
pub fn cert_der(&self) -> &[u8] {
&self.cert_der
}
}
/// The audit chain's checkpoints are signed by the same signer as every
/// attestation: one signing identity per server, one DSSE implementation.
#[cfg(feature = "sign")]
impl crate::audit::chain::CheckpointSigner for AttestSigner {
fn sign_checkpoint(
&self,
payload_type: &str,
payload: &[u8],
) -> Result<crate::audit::chain::SignedCheckpoint, String> {
let guard = self.inner.lock_or_recover();
let env = znippy_common::sign::dsse_sign(
payload_type,
payload,
&[&**guard as &dyn znippy_common::sign::ArchiveSigner],
)
.map_err(|e| format!("signing the checkpoint: {e:#}"))?;
Ok(crate::audit::chain::SignedCheckpoint {
envelope_json: env
.to_json()
.map_err(|e| format!("rendering the envelope: {e:#}"))?,
cert_der: self.cert_der.clone(),
})
}
}
#[cfg(feature = "sign")]
impl AttestStore {
/// ★ Sign a statement payload and file it through the store's own door.
///
/// The envelope is built by `znippy_common::sign::dsse_sign` — the ONE DSSE
/// implementation in this house — and then handed straight to
/// [`accept`](Self::accept). It is not written by a side door: holger's own
/// evidence is parsed, predicate-checked, subject-bound, enrolment-checked
/// and cryptographically verified on the same path as evidence from anyone
/// else. If holger's signer does not chain to a configured trust root, its
/// own VSA is refused, which is the correct answer: a statement nobody can
/// verify is not evidence.
///
/// `required_subject` is passed through so the caller can bind the write to
/// one artifact; for a set-scope VSA it is `None` and the statement's own
/// subject list decides where it is filed.
pub fn emit_signed(
&self,
payload: &[u8],
signer: &AttestSigner,
required_subject: Option<&ArtifactDigest>,
) -> Result<Accepted, AttestError> {
let json = {
let guard = signer.inner.lock_or_recover();
let env = znippy_common::sign::dsse_sign(
DSSE_PAYLOAD_TYPE_INTOTO,
payload,
&[&**guard as &dyn znippy_common::sign::ArchiveSigner],
)
.map_err(|e| AttestError::EnvelopeMalformed(format!("signing the statement: {e:#}")))?;
env.to_json().map_err(|e| {
AttestError::EnvelopeMalformed(format!("rendering the envelope: {e:#}"))
})?
};
self.accept(
json.as_bytes(),
std::slice::from_ref(&signer.cert_der),
required_subject,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A medium's staged certificate is PEM text (lane HOL-CDS-5): its DER is decoded, and a
/// DER file is taken as it is.
#[test]
fn a_pem_certificate_reads_as_its_der() {
use base64::Engine as _;
let der = vec![0x30u8, 0x82, 0x01, 0x0a, 1, 2, 3, 4, 5];
let b64 = base64::engine::general_purpose::STANDARD.encode(&der);
let pem = format!(
"-----BEGIN CERTIFICATE-----\n{}\n{}\n-----END CERTIFICATE-----\n",
&b64[..4],
&b64[4..]
);
assert_eq!(der_of(pem.as_bytes()), der);
assert_eq!(der_of(&der), der);
}
const D_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const D_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
fn stmt(subjects: &[(&str, &str)], pt: &str) -> Vec<u8> {
let subs: Vec<(String, ArtifactDigest)> = subjects
.iter()
.map(|(n, h)| ((*n).to_string(), ArtifactDigest::sha256(h).unwrap()))
.collect();
statement_bytes(&subs, pt, serde_json::json!({"note": "test"}))
}
// ── The key is a digest, and a digest is validated ───────────────────────
/// A digest that is not lowercase hex of the algorithm's exact length is
/// REFUSED BY NAME. The digest IS the path, so this is the containment
/// argument: there is nothing to sanitize because nothing unsafe is ever
/// constructed.
#[test]
fn a_digest_that_is_not_a_digest_is_refused_not_sanitized() {
for (alg, hexstr) in [
("sha256", "../../../etc/passwd"),
("sha256", "short"),
("sha256", &format!("{D_A}extra")),
// Uppercase: refused, never folded — two spellings would key two dirs.
("sha256", &D_A.to_ascii_uppercase()),
(
"sha256",
"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
),
("md5", D_A),
("sha256/../..", D_A),
] {
let err =
ArtifactDigest::parse(alg, hexstr).expect_err("{alg}:{hexstr} must be refused");
assert_eq!(err.code().code, "holger-attest-7", "{alg}: {err}");
}
assert!(ArtifactDigest::sha256(D_A).is_ok());
}
/// An accepted digest is exactly two path segments below the root, always.
#[test]
fn an_accepted_digest_is_two_segments_below_the_root() {
let tmp = tempfile::tempdir().unwrap();
let store = AttestStore::new(tmp.path());
let p = store.digest_dir(&ArtifactDigest::sha256(D_A).unwrap());
assert!(p.starts_with(tmp.path()));
assert_eq!(p.strip_prefix(tmp.path()).unwrap().components().count(), 2);
}
// ── The statement ───────────────────────────────────────────────────────
/// ★ THE REFUSAL THIS MODULE EXISTS FOR. A statement about digest B is not
/// evidence about digest A — however impeccably it is signed. Asserted on the
/// REFUSAL CODE, not on any content.
#[test]
fn a_statement_about_other_bytes_does_not_attest_these_bytes() {
let s = Statement::parse(&stmt(&[("other-artifact", D_B)], CYCLONEDX_BOM)).unwrap();
let a = ArtifactDigest::sha256(D_A).unwrap();
let b = ArtifactDigest::sha256(D_B).unwrap();
assert!(!s.attests(&a), "a statement about B must NOT attest A");
assert!(s.attests(&b));
}
/// A subject named in words but carrying NO digest binds nothing, and is
/// refused — otherwise the coordinate defect walks back in wearing in-toto.
#[test]
fn a_statement_whose_subject_carries_no_digest_is_refused() {
let payload = serde_json::json!({
"_type": IN_TOTO_STATEMENT_TYPE,
"subject": [{ "name": "serde 1.0.0" }],
"predicateType": CYCLONEDX_BOM,
"predicate": {},
});
let err = Statement::parse(&serde_json::to_vec(&payload).unwrap())
.expect_err("a subject with no digest must be refused");
assert_eq!(err.code().code, "holger-attest-2", "{err}");
}
/// The Chainguard fixture of the intake bench (`cgr.dev/chainguard/python`'s
/// cosign attestation, fetched 2026-10-23): a DSSE envelope whose statement is
/// in-toto v0.1 with a SLSA v1 predicate.
fn chainguard_fixture() -> (Vec<u8>, serde_json::Value) {
let dir = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../bench-scenarios/fixtures/intake/chainguard-python"
);
let env = std::fs::read(format!("{dir}/att-0.payload")).expect("the bench fixture");
let ann = serde_json::from_slice(
&std::fs::read(format!("{dir}/att-0.annotations.json")).unwrap(),
)
.unwrap();
(env, ann)
}
fn base64_decode(s: &str) -> Option<Vec<u8>> {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.decode(s).ok()
}
/// cosign writes in-toto Statement v0.1; Chainguard's attestations are read.
#[test]
fn an_in_toto_v01_statement_is_accepted() {
let (env, _) = chainguard_fixture();
let v: serde_json::Value = serde_json::from_slice(&env).unwrap();
let payload = base64_decode(v["payload"].as_str().unwrap()).unwrap();
let st = Statement::parse(&payload).expect("a v0.1 statement parses");
assert_eq!(st.predicate_type, SLSA_PROVENANCE_V1);
assert_eq!(st.subjects.len(), 1);
assert!(st.subjects[0].digest.contains_key("sha256"));
}
/// The whole Chainguard envelope through the store's own verifier: P-256 over
/// the PAE with the Fulcio leaf, chained through the Sigstore intermediate to
/// the root the attachment carries (znippy-common 0.10.1).
#[cfg(feature = "sign")]
#[test]
fn a_chainguard_envelope_chains_through_fulcio_to_the_sigstore_root() {
let (env, ann) = chainguard_fixture();
let pems = |k: &str| -> Vec<Vec<u8>> {
let text = ann[k].as_str().unwrap_or_default();
let mut out = Vec::new();
for block in text.split("-----END CERTIFICATE-----") {
if let Some(i) = block.find("-----BEGIN CERTIFICATE-----") {
let b64: String = block[i + 27..].split_whitespace().collect();
out.push(base64_decode(&b64).unwrap());
}
}
out
};
let leaf = pems("dev.sigstore.cosign/certificate");
let chain = pems("dev.sigstore.cosign/chain");
assert_eq!((leaf.len(), chain.len()), (1, 2));
verify_dsse(&env, &leaf, &chain).expect("the Chainguard envelope verifies and chains");
let mut bad = env.clone();
let i = String::from_utf8_lossy(&bad)
.find("\"payload\":\"")
.unwrap()
+ 11;
bad[i] ^= 1;
assert!(verify_dsse(&bad, &leaf, &chain).is_err());
assert!(
verify_dsse(&env, &leaf, &chain[1..]).is_err(),
"the root alone: the intermediate is not invented"
);
}
/// Every malformed shape is refused by the SAME named code, and none of them
/// panics or parses half-way.
#[test]
fn malformed_statements_are_refused_by_name() {
let cases: Vec<Vec<u8>> = vec![
b"not json at all".to_vec(),
serde_json::to_vec(&serde_json::json!({
"_type": "https://in-toto.io/Statement/v2",
"subject": [{"name":"x","digest":{"sha256":D_A}}],
"predicateType": CYCLONEDX_BOM,
}))
.unwrap(),
serde_json::to_vec(&serde_json::json!({
"_type": IN_TOTO_STATEMENT_TYPE,
"subject": [],
"predicateType": CYCLONEDX_BOM,
}))
.unwrap(),
serde_json::to_vec(&serde_json::json!({
"_type": IN_TOTO_STATEMENT_TYPE,
"subject": [{"name":"x","digest":{"sha256":D_A}}],
}))
.unwrap(),
serde_json::to_vec(&serde_json::json!({
"_type": IN_TOTO_STATEMENT_TYPE,
"subject": [{"name":"x","digest":{}}],
"predicateType": CYCLONEDX_BOM,
}))
.unwrap(),
];
for (i, payload) in cases.iter().enumerate() {
let err = Statement::parse(payload).expect_err("case {i} must be refused");
assert_eq!(err.code().code, "holger-attest-2", "case {i}: {err}");
}
}
/// A set-scope statement — one signed claim about N artifacts — is keyable from
/// EVERY member. "Analysis of the promoted set as a whole" is precisely this
/// shape, so a store that only understood one subject could not hold it.
#[test]
fn a_set_scope_statement_is_keyable_from_every_member() {
let s = Statement::parse(&stmt(&[("a", D_A), ("b", D_B)], INTOTO_VULNS_V01)).unwrap();
let keys = s.keyable_subjects();
assert_eq!(keys.len(), 2);
assert!(s.attests(&ArtifactDigest::sha256(D_A).unwrap()));
assert!(s.attests(&ArtifactDigest::sha256(D_B).unwrap()));
}
/// A subject digest in an algorithm the store cannot key by is SKIPPED, not
/// fatal — a statement legitimately carries `sha1` beside `sha256` for an
/// external consumer, and refusing the whole thing would drop good evidence.
#[test]
fn an_unkeyable_extra_algorithm_does_not_discard_the_statement() {
let payload = serde_json::json!({
"_type": IN_TOTO_STATEMENT_TYPE,
"subject": [{"name":"x","digest":{"sha256": D_A, "sha1": "deadbeef"}}],
"predicateType": CYCLONEDX_BOM,
"predicate": {},
});
let s = Statement::parse(&serde_json::to_vec(&payload).unwrap()).unwrap();
let keys = s.keyable_subjects();
assert_eq!(keys.len(), 1, "only the sha256 is keyable: {keys:?}");
assert_eq!(keys[0].hex(), D_A);
}
/// The canonical statement bytes really are an in-toto Statement v1 that
/// round-trips through the parser — producer and verifier in one crate.
#[test]
fn statement_bytes_round_trip_through_the_parser() {
let bytes = stmt(&[("serde-1.0.0.crate", D_A)], SLSA_PROVENANCE_V1);
let s = Statement::parse(&bytes).unwrap();
assert_eq!(s.predicate_type, SLSA_PROVENANCE_V1);
assert_eq!(s.subjects[0].name, "serde-1.0.0.crate");
assert!(s.attests(&ArtifactDigest::sha256(D_A).unwrap()));
let v: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["_type"], IN_TOTO_STATEMENT_TYPE);
}
// ── Trust bookkeeping (works in every build) ─────────────────────────────
/// Enrolment is by SHA-256 over the certificate DER — the same fingerprint
/// `znippy_common::sign` writes into a DSSE `keyid`. Anything not enrolled is
/// not enrolled, including a plausible near-miss.
#[test]
fn enrolment_keys_on_the_certificate_fingerprint() {
let mut t = AttestTrust::new();
let fp = t.enrol_signer_der(b"pretend-der-bytes");
assert_eq!(fp, hex::encode(Sha256::digest(b"pretend-der-bytes")));
assert!(t.is_enrolled(&fp));
assert!(
t.is_enrolled(&fp.to_ascii_uppercase()),
"a keyid's case must not matter"
);
assert!(!t.is_enrolled(D_A));
}
/// The default policy accepts the four STANDARD predicates and nothing else —
/// an unknown predicate is refused by name, not filed "just in case".
#[test]
fn the_predicate_allowlist_refuses_what_it_does_not_know() {
let p = AttestPolicy::default();
for pt in [
SLSA_PROVENANCE_V1,
CYCLONEDX_BOM,
INTOTO_VULNS_V01,
INTOTO_TEST_RESULT_V01,
] {
assert!(p.accepts(pt), "{pt} must be accepted by default");
}
assert!(!p.accepts("https://example.invalid/whatever/v1"));
assert!(AttestPolicy::none().accept("x").accepts("x"));
assert!(!AttestPolicy::none().accepts(SLSA_PROVENANCE_V1));
}
/// An artifact nothing has attested lists EMPTY — never an error. "No evidence
/// yet" is the normal state of a fresh artifact, and a gate must be able to
/// observe it plainly rather than through an I/O failure.
#[test]
fn an_unattested_artifact_lists_empty_rather_than_erroring() {
let tmp = tempfile::tempdir().unwrap();
let store = AttestStore::new(tmp.path());
let d = ArtifactDigest::sha256(D_A).unwrap();
assert!(store.list(&d).unwrap().is_empty());
assert!(store.query(&d, CYCLONEDX_BOM).unwrap().is_empty());
assert!(!store.has(&d, CYCLONEDX_BOM));
}
/// Every refusal has a DISTINCT frozen code. Collapsed, they would demand four
/// different operator responses and get one — which is the whole reason the
/// registry exists.
#[test]
fn the_refusals_are_distinguishable_by_code() {
let all = [
AttestError::EnvelopeMalformed("x".into()),
AttestError::StatementMalformed("x".into()),
AttestError::SubjectMismatch {
want: "a".into(),
found: vec!["b".into()],
},
AttestError::SignerUntrusted("x".into()),
AttestError::EnvelopeUnverified("x".into()),
AttestError::PredicateUnsupported("x".into()),
AttestError::DigestUnsafe("x".into()),
AttestError::StoreIo("x".into()),
AttestError::VerificationUnavailable,
];
let codes: BTreeSet<&str> = all.iter().map(|e| e.code().code).collect();
assert_eq!(
codes.len(),
all.len(),
"two refusals share a code: {codes:?}"
);
for e in &all {
let tagged = e.tagged();
assert_eq!(
holger_errcode::parse(&tagged).map(|c| c.code),
Some(e.code().code),
"the code must survive the message: {tagged}"
);
}
}
/// Without the `sign` feature the accept path REFUSES rather than storing
/// unverified evidence. Fail-closed, by name, and nothing reaches the disk.
#[cfg(not(feature = "sign"))]
#[test]
fn a_build_without_sign_refuses_to_store_unverified_evidence() {
let tmp = tempfile::tempdir().unwrap();
let store = AttestStore::new(tmp.path());
let err = store
.accept(b"{}", &[], None)
.expect_err("must fail closed");
assert_eq!(err.code().code, "holger-attest-9", "{err}");
assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
}
/// The VSA's `timeVerified` must be RFC 3339. Three fixed points, including
/// a leap day and the epoch, because the civil-date arithmetic is hand-rolled.
#[test]
fn unix_seconds_render_as_rfc3339_utc() {
assert_eq!(rfc3339_utc(0), "1970-01-01T00:00:00Z");
assert_eq!(rfc3339_utc(951_782_400), "2000-02-29T00:00:00Z");
assert_eq!(rfc3339_utc(1_758_326_400), "2025-09-20T00:00:00Z");
assert_eq!(rfc3339_utc(1_758_326_400 + 3_661), "2025-09-20T01:01:01Z");
}
/// The emitted statement is an in-toto Statement with the SLSA VSA
/// predicate type and one subject per promoted artifact — one signature over
/// the whole set, findable from any member.
#[test]
fn a_verification_summary_names_every_member_of_the_set() {
let a = ArtifactDigest::sha256(D_A).unwrap();
let b = ArtifactDigest::sha256(D_B).unwrap();
let payload = verification_summary_bytes(
&[
("one".to_string(), a.clone()),
("two".to_string(), b.clone()),
],
"holger",
VerificationResult::Passed,
"holger://promotion-gates",
&["allowed by 1 gate(s): osv".to_string()],
&rfc3339_utc(0),
);
let s = Statement::parse(&payload).expect("the emitted payload is a valid Statement");
assert_eq!(s.predicate_type, SLSA_VSA_V1);
assert!(s.attests(&a));
assert!(s.attests(&b));
assert_eq!(s.keyable_subjects().len(), 2);
}
// ── The real cryptographic path ─────────────────────────────────────────
#[cfg(feature = "sign")]
mod signed {
use super::*;
use std::path::Path;
use znippy_common::sign::{dev, dsse_sign, ArchiveSigner, EcdsaP256Signer};
/// One in-memory CA plus a gate signer issued by it — the dev PKI
/// `znippy_common::sign::dev` already ships (`server/repository/
/// znippy-package/src/lib.rs:439` uses the same helpers), so no test
/// fixture files and no second PKI.
struct Gate {
ca_der: Vec<u8>,
signer: EcdsaP256Signer,
}
fn gate(cn: &str) -> Gate {
let (ca_key, ca_der) = dev::mint_ca(&format!("{cn}-ca")).unwrap();
let signer = dev::new_p256_signer(&ca_key, &ca_der, cn).unwrap();
Gate { ca_der, signer }
}
/// A signing identity for holger ITSELF: its own CA, its own leaf, and
/// the leaf wrapped in [`AttestSigner`]. Returns `(ca_der, cert_der,
/// signer)` so a test can decide whether the store trusts it.
fn holger_identity(cn: &str) -> (Vec<u8>, Vec<u8>, AttestSigner) {
let (ca_key, ca_der) = dev::mint_ca(&format!("{cn}-ca")).unwrap();
let leaf = dev::new_p256_signer(&ca_key, &ca_der, cn).unwrap();
let cert = leaf.certificate_der().to_vec();
(
ca_der,
cert.clone(),
AttestSigner::from_signer(Box::new(leaf), cert),
)
}
impl Gate {
fn cert(&self) -> Vec<u8> {
self.signer.certificate_der().to_vec()
}
fn sign(&self, payload: &[u8]) -> Vec<u8> {
dsse_sign(DSSE_PAYLOAD_TYPE_INTOTO, payload, &[&self.signer])
.unwrap()
.to_json()
.unwrap()
.into_bytes()
}
}
fn store_trusting(root: &Path, g: &Gate) -> AttestStore {
let mut trust = AttestTrust::new();
trust.add_root_der(g.ca_der.clone());
trust.enrol_signer_der(&g.cert());
AttestStore::new(root).with_trust(trust)
}
/// The happy path, stated once so the refusals below are known to be
/// refusing something that would otherwise work.
#[test]
fn a_verified_attestation_is_filed_under_its_subject_digest() {
let tmp = tempfile::tempdir().unwrap();
let g = gate("cve-gate");
let store = store_trusting(tmp.path(), &g);
let a = ArtifactDigest::sha256(D_A).unwrap();
let env = g.sign(&stmt(&[("serde-1.0.0.crate", D_A)], INTOTO_VULNS_V01));
let accepted = store.accept(&env, &[g.cert()], Some(&a)).unwrap();
assert_eq!(accepted.predicate_type, INTOTO_VULNS_V01);
assert_eq!(accepted.subjects, vec![a.clone()]);
assert_eq!(store.query(&a, INTOTO_VULNS_V01).unwrap().len(), 1);
assert!(store.has(&a, INTOTO_VULNS_V01));
// …and it is NOT evidence for other bytes.
let b = ArtifactDigest::sha256(D_B).unwrap();
assert!(store.list(&b).unwrap().is_empty());
}
// ── Emitting: the VSA a promotion writes ────────────────────────────
/// holger signs a Verification Summary over the promoted artifact and
/// files it through its OWN door, so the statement it wrote back is
/// findable by the digest a later gate will ask about.
///
/// Before this existed, `attest.rs` had no constructor and no consumer
/// anywhere in the shipped tree: 1458 lines of verification nothing
/// could reach.
#[test]
fn a_promotion_verification_summary_is_signed_filed_and_findable() {
let tmp = tempfile::tempdir().unwrap();
let (ca_der, cert, signer) = holger_identity("holger");
let mut trust = AttestTrust::new();
trust.add_root_der(ca_der);
trust.enrol_signer_der(&cert);
let store = AttestStore::new(tmp.path()).with_trust(trust);
let bytes = b"the promoted artifact";
let digest = ArtifactDigest::of_sha256(bytes);
let payload = verification_summary_bytes(
&[("serde-1.0.0.crate".to_string(), digest.clone())],
"holger",
VerificationResult::Passed,
"holger://promotion-gates",
&["allowed by 1 gate(s): osv".to_string()],
&rfc3339_utc(1_758_326_400),
);
let accepted = store
.emit_signed(&payload, &signer, Some(&digest))
.expect("holger's own evidence must verify through its own door");
assert_eq!(accepted.predicate_type, SLSA_VSA_V1);
assert_eq!(accepted.subjects, vec![digest.clone()]);
assert!(store.has(&digest, SLSA_VSA_V1));
}
/// holger's own statement is NOT privileged. A signer that does not
/// chain to a configured trust root is refused exactly as anyone else's
/// would be, and nothing is written. A statement nobody can verify is
/// not evidence.
#[test]
fn holgers_own_summary_is_refused_when_its_signer_is_not_trusted() {
let tmp = tempfile::tempdir().unwrap();
let trusted = gate("trusted");
let store = store_trusting(tmp.path(), &trusted);
let (_stranger_ca, _stranger_cert, signer) = holger_identity("stranger");
let bytes = b"the promoted artifact";
let digest = ArtifactDigest::of_sha256(bytes);
let payload = verification_summary_bytes(
&[("x".to_string(), digest.clone())],
"holger",
VerificationResult::Passed,
"holger://promotion-gates",
&[],
&rfc3339_utc(0),
);
assert!(store.emit_signed(&payload, &signer, Some(&digest)).is_err());
assert!(
store.list(&digest).unwrap().is_empty(),
"nothing may be written"
);
}
/// ★ THE TEST THIS SLICE IS FOR. A PERFECTLY SIGNED envelope from a
/// PERFECTLY TRUSTED gate, whose statement names DIFFERENT bytes, is
/// REFUSED — and nothing is written. Assert on the refusal.
#[test]
fn an_attestation_about_different_bytes_is_refused_by_name() {
let tmp = tempfile::tempdir().unwrap();
let g = gate("cve-gate");
let store = store_trusting(tmp.path(), &g);
let a = ArtifactDigest::sha256(D_A).unwrap();
// A genuine, verifying envelope — about B.
let env = g.sign(&stmt(&[("some-other-artifact", D_B)], INTOTO_VULNS_V01));
let err = store
.accept(&env, &[g.cert()], Some(&a))
.expect_err("a statement about other bytes must be refused");
assert_eq!(err.code().code, "holger-attest-3", "{err}");
// Nothing was filed — not under A, and not under B either: the
// submission was refused, not re-routed to the bytes it happened to
// name.
assert!(store.list(&a).unwrap().is_empty());
assert!(store
.list(&ArtifactDigest::sha256(D_B).unwrap())
.unwrap()
.is_empty());
}
/// An envelope signed by a key from a DIFFERENT CA is refused: the
/// signature verifies against its own certificate, and the chain walk is
/// what says no.
#[test]
fn an_envelope_signed_by_an_untrusted_key_is_refused() {
let tmp = tempfile::tempdir().unwrap();
let good = gate("cve-gate");
let rogue = gate("rogue");
let a = ArtifactDigest::sha256(D_A).unwrap();
// The rogue's fingerprint IS enrolled (so this is not the enrolment
// refusal) but its CA is not a root — only the chain can refuse it.
let mut trust = AttestTrust::new();
trust.add_root_der(good.ca_der.clone());
trust.enrol_signer_der(&rogue.cert());
let store = AttestStore::new(tmp.path()).with_trust(trust);
let env = rogue.sign(&stmt(&[("serde-1.0.0.crate", D_A)], INTOTO_VULNS_V01));
let err = store
.accept(&env, &[rogue.cert()], Some(&a))
.expect_err("a signer outside the trusted roots must be refused");
assert_eq!(err.code().code, "holger-attest-5", "{err}");
assert!(store.list(&a).unwrap().is_empty());
}
/// A key that chains PERFECTLY to the trusted root but was never enrolled
/// as a gate is still refused — and with a DIFFERENT code, because the fix
/// is different: enrol the key, not fix the signature. This is the layer
/// that stops anybody inside the corporate CA's perimeter from minting
/// scanner verdicts.
#[test]
fn a_chaining_but_unenrolled_signer_is_refused_with_its_own_code() {
let tmp = tempfile::tempdir().unwrap();
let (ca_key, ca_der) = dev::mint_ca("corp-ca").unwrap();
let enrolled = dev::new_p256_signer(&ca_key, &ca_der, "cve-gate").unwrap();
let interloper = dev::new_p256_signer(&ca_key, &ca_der, "somebody-else").unwrap();
let mut trust = AttestTrust::new();
trust.add_root_der(ca_der.clone());
trust.enrol_signer_der(enrolled.certificate_der());
let store = AttestStore::new(tmp.path()).with_trust(trust);
let a = ArtifactDigest::sha256(D_A).unwrap();
let payload = stmt(&[("serde-1.0.0.crate", D_A)], INTOTO_VULNS_V01);
let env = dsse_sign(DSSE_PAYLOAD_TYPE_INTOTO, &payload, &[&interloper])
.unwrap()
.to_json()
.unwrap()
.into_bytes();
let err = store
.accept(&env, &[interloper.certificate_der().to_vec()], Some(&a))
.expect_err("an unenrolled gate must be refused");
assert_eq!(err.code().code, "holger-attest-4", "{err}");
assert!(store.list(&a).unwrap().is_empty());
}
/// A TAMPERED payload breaks the signature, because DSSE signs
/// `PAE(payloadType, payload)` and not the payload alone. Flip one byte of
/// the statement inside a genuine envelope and it stops verifying.
#[test]
fn a_tampered_payload_is_refused() {
let tmp = tempfile::tempdir().unwrap();
let g = gate("cve-gate");
let store = store_trusting(tmp.path(), &g);
let a = ArtifactDigest::sha256(D_A).unwrap();
let genuine = g.sign(&stmt(&[("serde-1.0.0.crate", D_A)], INTOTO_VULNS_V01));
// Re-encode the envelope with a different payload, keeping the signature.
let mut v: Value = serde_json::from_slice(&genuine).unwrap();
v["payload"] = Value::String(znippy_common::sign::base64_encode(&stmt(
&[("serde-1.0.0.crate", D_A)],
INTOTO_TEST_RESULT_V01,
)));
let forged = serde_json::to_vec(&v).unwrap();
let err = store
.accept(&forged, &[g.cert()], Some(&a))
.expect_err("a spliced payload must not verify");
assert_eq!(err.code().code, "holger-attest-5", "{err}");
assert!(store.list(&a).unwrap().is_empty());
}
/// A predicate this server does not accept is refused BEFORE any storage —
/// an unknown claim filed is a claim a later gate may read and count as
/// satisfied.
#[test]
fn an_unsupported_predicate_is_refused() {
let tmp = tempfile::tempdir().unwrap();
let g = gate("cve-gate");
let store = store_trusting(tmp.path(), &g)
.with_policy(AttestPolicy::none().accept(CYCLONEDX_BOM));
let a = ArtifactDigest::sha256(D_A).unwrap();
let env = g.sign(&stmt(&[("serde-1.0.0.crate", D_A)], INTOTO_VULNS_V01));
let err = store
.accept(&env, &[g.cert()], Some(&a))
.expect_err("an unlisted predicate must be refused");
assert_eq!(err.code().code, "holger-attest-6", "{err}");
assert!(store.list(&a).unwrap().is_empty());
}
/// Malformed envelope bytes are refused by their own code, distinct from a
/// malformed STATEMENT — the producer's bug is in a different place.
#[test]
fn a_malformed_envelope_is_refused_by_its_own_code() {
let tmp = tempfile::tempdir().unwrap();
let g = gate("cve-gate");
let store = store_trusting(tmp.path(), &g);
for bad in [
&b"not json"[..],
b"{}",
br#"{"payload":"!!!","payloadType":"x"}"#,
] {
let err = store
.accept(bad, &[g.cert()], None)
.expect_err("must refuse");
assert_eq!(err.code().code, "holger-attest-1", "{err}");
}
// A well-formed envelope carrying the WRONG payloadType is the
// payload-type-confusion case, and it is refused at the same door.
let payload = stmt(&[("x", D_A)], CYCLONEDX_BOM);
let env = dsse_sign(
"application/vnd.something-else+json",
&payload,
&[&g.signer],
)
.unwrap()
.to_json()
.unwrap()
.into_bytes();
let err = store
.accept(&env, &[g.cert()], None)
.expect_err("must refuse");
assert_eq!(err.code().code, "holger-attest-1", "{err}");
}
/// MULTIPLE attestations coexist on one artifact: different predicate types
/// from different gates, and two DIFFERENT claims of the SAME predicate.
/// The store returns all of them — it never resolves a disagreement between
/// gates on the promotion boundary's behalf.
#[test]
fn many_attestations_coexist_and_a_query_returns_all_of_them() {
let tmp = tempfile::tempdir().unwrap();
let g1 = gate("cve-gate");
let g2 = gate("sbom-gate");
let mut trust = AttestTrust::new();
trust.add_root_der(g1.ca_der.clone());
trust.add_root_der(g2.ca_der.clone());
trust.enrol_signer_der(&g1.cert());
trust.enrol_signer_der(&g2.cert());
let store = AttestStore::new(tmp.path()).with_trust(trust);
let a = ArtifactDigest::sha256(D_A).unwrap();
let vulns = g1.sign(&stmt(&[("serde", D_A)], INTOTO_VULNS_V01));
let sbom = g2.sign(&stmt(&[("serde", D_A)], CYCLONEDX_BOM));
// A SECOND vulns claim about the same bytes, from the same gate, with a
// different predicate body — a genuine disagreement.
let subs = vec![("serde".to_string(), a.clone())];
let vulns2 = g1.sign(&statement_bytes(
&subs,
INTOTO_VULNS_V01,
serde_json::json!({"scanResults": [{"id": "CVE-2021-44228"}]}),
));
store.accept(&vulns, &[g1.cert()], Some(&a)).unwrap();
store.accept(&sbom, &[g2.cert()], Some(&a)).unwrap();
store.accept(&vulns2, &[g1.cert()], Some(&a)).unwrap();
assert_eq!(store.list(&a).unwrap().len(), 3, "all three are held");
assert_eq!(
store.query(&a, INTOTO_VULNS_V01).unwrap().len(),
2,
"BOTH vulns claims come back — the store does not pick a winner"
);
assert_eq!(store.query(&a, CYCLONEDX_BOM).unwrap().len(), 1);
assert!(store.query(&a, SLSA_PROVENANCE_V1).unwrap().is_empty());
}
/// Re-submitting the IDENTICAL envelope is idempotent: same content id, one
/// file. An evidence store that grew a duplicate per retry would turn a
/// flaky network into a fake quorum.
#[test]
fn resubmitting_the_same_envelope_is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let g = gate("cve-gate");
let store = store_trusting(tmp.path(), &g);
let a = ArtifactDigest::sha256(D_A).unwrap();
let env = g.sign(&stmt(&[("serde", D_A)], INTOTO_VULNS_V01));
let first = store.accept(&env, &[g.cert()], Some(&a)).unwrap();
let second = store.accept(&env, &[g.cert()], Some(&a)).unwrap();
assert_eq!(first.envelope_id, second.envelope_id);
assert_eq!(store.list(&a).unwrap().len(), 1);
}
/// A SET-SCOPE statement — one signature over N artifacts — is filed under
/// every member and found from any of them. This is what "analysis of the
/// promoted set as a whole" needs, and a store keyed one-subject-deep could
/// not hold it.
#[test]
fn a_set_scope_statement_is_filed_under_every_member() {
let tmp = tempfile::tempdir().unwrap();
let g = gate("set-gate");
let store = store_trusting(tmp.path(), &g);
let a = ArtifactDigest::sha256(D_A).unwrap();
let b = ArtifactDigest::sha256(D_B).unwrap();
let env = g.sign(&stmt(
&[("a.crate", D_A), ("b.crate", D_B)],
INTOTO_VULNS_V01,
));
let accepted = store.accept(&env, &[g.cert()], Some(&a)).unwrap();
assert_eq!(accepted.subjects.len(), 2);
assert_eq!(store.query(&a, INTOTO_VULNS_V01).unwrap().len(), 1);
assert_eq!(store.query(&b, INTOTO_VULNS_V01).unwrap().len(), 1);
// …and the SAME envelope bytes come back from both.
assert_eq!(
store.query(&a, INTOTO_VULNS_V01).unwrap()[0].envelope_json,
store.query(&b, INTOTO_VULNS_V01).unwrap()[0].envelope_json
);
}
/// A crash-leftover `.tmp` sibling is never served — only committed
/// `.dsse.json` files are read back.
#[test]
fn a_leftover_staging_file_is_never_served() {
let tmp = tempfile::tempdir().unwrap();
let g = gate("cve-gate");
let store = store_trusting(tmp.path(), &g);
let a = ArtifactDigest::sha256(D_A).unwrap();
store
.accept(
&g.sign(&stmt(&[("serde", D_A)], INTOTO_VULNS_V01)),
&[g.cert()],
Some(&a),
)
.unwrap();
let dir = store.digest_dir(&a);
std::fs::write(dir.join("half.json.tmp"), b"not an envelope").unwrap();
assert_eq!(
store.list(&a).unwrap().len(),
1,
"the half-written file is ignored"
);
}
/// A signature whose `keyid` names a gate but for which NO certificate was
/// offered is refused as untrusted: a keyid is an unauthenticated hint, and
/// trusting it alone would be trusting the attacker's own label.
#[test]
fn a_keyid_with_no_certificate_on_offer_is_refused() {
let tmp = tempfile::tempdir().unwrap();
let g = gate("cve-gate");
let store = store_trusting(tmp.path(), &g);
let a = ArtifactDigest::sha256(D_A).unwrap();
let env = g.sign(&stmt(&[("serde", D_A)], INTOTO_VULNS_V01));
let err = store
.accept(&env, &[], Some(&a))
.expect_err("no cert on offer");
assert_eq!(err.code().code, "holger-attest-4", "{err}");
}
}
}