Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion crates/mise-sigstore/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ rustls-webpki = { version = "0.103", default-features = false, features = [
rustls-pki-types = "1"

[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
tokio = { version = "1", features = [
"macros",
"rt-multi-thread",
"net",
"sync",
] }

[features]
default = ["rustls"]
Expand Down
52 changes: 44 additions & 8 deletions crates/mise-sigstore/src/bundle.rs
Original file line number Diff line number Diff line change
@@ -1,33 +1,61 @@
use super::*;
use crate::github::{log_attestation_method_failure, warn_if_trust_root_unreachable};

pub(crate) async fn verify_attestation_bundles(
attestations: &[Attestation],
artifact: &[u8],
signer_workflow: Option<&str>,
trust_roots: &mut TrustRoots,
) -> Result<bool> {
verify_attestation_bundles_for_artifact(
Artifact::from(artifact),
attestations,
signer_workflow,
trust_roots,
)
.await
}

pub(crate) async fn verify_attestation_bundles_for_artifact(
artifact: Artifact<'_>,
attestations: &[Attestation],
signer_workflow: Option<&str>,
trust_roots: &mut TrustRoots,
) -> Result<bool> {
let mut errors = Vec::new();
let mut trust_root_failures = 0;
for attestation in attestations {
let Some(bundle_value) = &attestation.bundle else {
continue;
};
let bundle = match serde_json::from_value::<Bundle>(bundle_value.clone()) {
Ok(bundle) => bundle,
Err(e) => {
errors.push(e.to_string());
let error_message = e.to_string();
log_attestation_method_failure(&AttestationError::Json(e));
errors.push(error_message);
continue;
}
};
match verify_bundle_with_trust_roots(
Artifact::from(artifact),
artifact.clone(),
&bundle,
signer_workflow,
trust_roots,
)
.await
{
Ok(()) => return Ok(true),
Err(e) => errors.push(e.to_string()),
Ok(()) => {
warn_if_trust_root_unreachable(trust_root_failures);
return Ok(true);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
}
Err(e) => {
log_attestation_method_failure(&e);
if matches!(e, AttestationError::TrustRoot(_)) {
trust_root_failures += 1;
}
errors.push(e.to_string());
}
}
}

Expand Down Expand Up @@ -137,10 +165,18 @@ where
Err(embedded_err) => match verify_with_tuf().await {
Ok(()) => Ok(()),
Err(tuf_err) if is_signer_workflow_mismatch(&tuf_err) => Err(tuf_err),
Err(tuf_err) => Err(AttestationError::Verification(format!(
"GitHub attestation verification failed with embedded trusted root: \
{embedded_err}; GitHub TUF trusted root retry also failed: {tuf_err}"
))),
Err(tuf_err) => {
let message = format!(
"GitHub attestation verification failed with embedded trusted root: \
{embedded_err}; GitHub TUF trusted root retry also failed: {tuf_err}"
);
// Keep the category: a trust root that could not be loaded is
// not a signature failure, and callers count it to warn.
Err(match tuf_err {
AttestationError::TrustRoot(_) => AttestationError::TrustRoot(message),
_ => AttestationError::Verification(message),
})
}
},
}
}
Expand Down
20 changes: 18 additions & 2 deletions crates/mise-sigstore/src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ pub(crate) async fn verify_github_attestation_sources_for_artifact(
let mut trust_roots = TrustRoots::default();
let mut sources = Vec::new();
let mut errors = Vec::new();
let mut trust_root_failures = 0;
for attestation in attestations {
let Some(bundle_value) = &attestation.bundle else {
continue;
Expand Down Expand Up @@ -135,13 +136,16 @@ pub(crate) async fn verify_github_attestation_sources_for_artifact(
None => {
const ERROR: &str = "verified attestation names no source repository";
log::debug!(
"cached GitHub attestation verification method failed; continuing with other attestations: {ERROR}"
"GitHub attestation verification method failed; continuing with other attestations: {ERROR}"
);
errors.push(ERROR.to_string());
}
},
Err(error) => {
log_attestation_method_failure(&error);
if matches!(error, AttestationError::TrustRoot(_)) {
trust_root_failures += 1;
}
errors.push(error.to_string());
}
}
Expand All @@ -162,12 +166,24 @@ pub(crate) async fn verify_github_attestation_sources_for_artifact(
errors.len()
);
}
warn_if_trust_root_unreachable(trust_root_failures);
Ok(sources)
}

/// Verification passes when any one attestation verifies, so attestations that
/// were skipped because their trust root could not be loaded would otherwise
/// go unnoticed. Warn once rather than per attestation.
pub(crate) fn warn_if_trust_root_unreachable(skipped: usize) {
if skipped > 0 {
log::warn!(
"{skipped} GitHub attestation(s) were not checked because a TUF trust root could not be loaded; verification passed on the remaining attestations. This usually means the Sigstore or GitHub TUF repository (tuf-repo-cdn.sigstore.dev and tuf-repo.github.com by default) is unreachable, or a configured mirror served invalid metadata; run with -v for details"
);
}
}

pub(crate) fn log_attestation_method_failure(error: &AttestationError) {
log::debug!(
"cached GitHub attestation verification method failed; continuing with other attestations: {}",
"GitHub attestation verification method failed; continuing with other attestations: {}",
error.diagnostic_summary()
);
}
Expand Down
9 changes: 9 additions & 0 deletions crates/mise-sigstore/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ pub enum AttestationError {
Json(#[from] serde_json::Error),
#[error("Sigstore error: {0}")]
Sigstore(String),
/// A Sigstore or GitHub TUF trust root could not be loaded, so the bundle
/// could not be checked against it. Usually the TUF repository was
/// unreachable, but invalid or expired TUF metadata (from a mirror, say)
/// lands here too. Distinct from a bundle that failed to verify.
#[error("Trust root error: {0}")]
TrustRoot(String),
}

impl From<sigstore_verify::Error> for AttestationError {
Expand Down Expand Up @@ -61,6 +67,9 @@ impl AttestationError {
Self::NoAttestations => "no attestations found",
Self::Io(_) => "artifact I/O failed",
Self::Http(_) => "network request failed",
Self::TrustRoot(_) => {
"could not load the TUF trust root needed to verify the signature"
}
Self::Json(_) => "invalid attestation JSON",
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/mise-sigstore/src/tests/certificate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ fn github_release_attestation_names_its_repository() {

#[tokio::test]
async fn fixture_bundles_verify_against_their_artifact() {
let _tuf_root = TUF_ROOT_LOCK.lock().await;
// mise-v2026.9.12-linux-x64.tar.gz, which both fixtures attest.
let digest =
Sha256Hash::from_hex("b4058dece685259910d3aba5782445996eea79dbdb3cf952a6eb81aadf0373ff")
Expand Down
1 change: 1 addition & 0 deletions crates/mise-sigstore/src/tests/cosign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ fn trivial_identity_patterns_do_not_pin_the_signer() {

#[tokio::test]
async fn keyless_bundle_verification_enforces_signer_identity() {
let _tuf_root = TUF_ROOT_LOCK.lock().await;
// mise-v2026.9.12-linux-x64.tar.gz, attested by the fixture bundle.
let digest =
Sha256Hash::from_hex("b4058dece685259910d3aba5782445996eea79dbdb3cf952a6eb81aadf0373ff")
Expand Down
78 changes: 75 additions & 3 deletions crates/mise-sigstore/src/tests/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ async fn github_attestation_sources_succeeds_when_one_method_fails() {
&[
invalid_attestation(),
fixture_attestation(include_str!(
"../../tests/fixtures/github_build_provenance_jdx_mise.json"
"../../tests/fixtures/github_release_attestation_jdx_mise.json"
)),
],
None,
Expand All @@ -108,7 +108,7 @@ async fn github_attestation_sources_succeeds_when_one_method_fails() {
assert_eq!(sources, ["jdx/mise"]);
let messages = captured_messages(&capture);
assert!(messages.iter().any(|message| message.contains(
"cached GitHub attestation verification method failed; continuing with other attestations: invalid attestation JSON"
"GitHub attestation verification method failed; continuing with other attestations: invalid attestation JSON"
)));
assert!(messages.iter().any(|message| message.contains(
"cached GitHub attestation verification succeeded overall despite 1 failed attestation method(s)"
Expand All @@ -130,7 +130,7 @@ async fn github_attestation_sources_fails_when_all_methods_fail() {
assert!(matches!(error, AttestationError::Verification(_)));
let messages = captured_messages(&capture);
assert!(messages.iter().any(|message| message.contains(
"cached GitHub attestation verification method failed; continuing with other attestations: invalid attestation JSON"
"GitHub attestation verification method failed; continuing with other attestations: invalid attestation JSON"
)));
assert!(messages.iter().any(|message| message
== "cached GitHub attestation verification failed overall: no attestation verified"));
Expand Down Expand Up @@ -161,3 +161,75 @@ fn attestation_diagnostic_logs_do_not_leak_secrets() {
}
assert!(message.contains("attestation signature verification failed"));
}

/// A bundle with no signing certificate cannot be GitHub-internal, so it needs
/// the Sigstore public-good trust root.
fn public_good_attestation() -> Attestation {
let mut bundle: serde_json::Value = serde_json::from_str(include_str!(
"../../tests/fixtures/github_build_provenance_jdx_mise.json"
))
.unwrap();
let material = bundle["verificationMaterial"].as_object_mut().unwrap();
material.remove("certificate");
material.insert("publicKey".to_string(), serde_json::json!({"hint": "key"}));
serde_json::from_value(serde_json::json!({ "bundle": bundle })).unwrap()
}

// One test, because the TUF URL override is process-global.
#[tokio::test]
async fn unreachable_tuf_repository_is_a_trust_root_error_not_a_signature_failure() {
let _tuf_root = TUF_ROOT_LOCK.lock().await;
// Port 1 refuses connections, standing in for a sandbox that blocks the
// Sigstore TUF repository.
crate::set_tuf_url(Some("http://127.0.0.1:1".to_string()));

let error = crate::trust::production_trusted_root()
.await
.expect_err("unreachable TUF repository must fail");
assert!(matches!(error, AttestationError::TrustRoot(_)), "{error}");
assert_eq!(
error.diagnostic_summary(),
"could not load the TUF trust root needed to verify the signature"
);

// A skipped public-good bundle followed by a valid GitHub one still passes,
// and the aggregator warns exactly once with the skipped count.
let capture = capture_logs();
let digest = fixture_digest();
let verified = crate::bundle::verify_attestation_bundles_for_artifact(
Artifact::from(&digest),
&[
public_good_attestation(),
fixture_attestation(include_str!(
"../../tests/fixtures/github_release_attestation_jdx_mise.json"
)),
],
None,
&mut TrustRoots::default(),
)
.await;
crate::set_tuf_url(None);

assert!(verified.unwrap());
let warnings: Vec<_> = captured_messages(&capture)
.into_iter()
.filter(|message| message.contains("were not checked because a TUF trust root"))
.collect();
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].starts_with("1 GitHub attestation(s) were not checked"));
}

#[test]
fn skipped_trust_root_attestations_warn_once() {
let capture = capture_logs();
crate::github::warn_if_trust_root_unreachable(0);
assert!(captured_messages(&capture).is_empty());

crate::github::warn_if_trust_root_unreachable(2);
let messages = captured_messages(&capture);
assert_eq!(messages.len(), 1);
assert!(messages[0].starts_with("2 GitHub attestation(s) were not checked"));
assert!(messages[0].contains("a configured mirror served invalid metadata"));
assert!(messages[0].contains("tuf-repo-cdn.sigstore.dev"));
assert!(messages[0].contains("tuf-repo.github.com"));
}
5 changes: 5 additions & 0 deletions crates/mise-sigstore/src/tests/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
use super::*;

/// Tests that load the Sigstore public-good TUF root take this lock, and the
/// test that points the process-global TUF URL at an unreachable host holds
/// it, so the override can't make another test's fetch fail.
pub(crate) static TUF_ROOT_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());

fn embedded_sigstore_root() -> TrustedRoot {
TrustedRoot::from_json(sigstore_verify::trust_root::SIGSTORE_PRODUCTION_TRUSTED_ROOT)
.expect("embedded production trusted_root.json parses")
Expand Down
1 change: 1 addition & 0 deletions crates/mise-sigstore/src/tests/slsa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ fn intoto_envelope_requires_exact_signer_identity_and_issuer() {

#[tokio::test]
async fn bundle_requires_exact_signer_identity_and_issuer() {
let _tuf_root = TUF_ROOT_LOCK.lock().await;
let bundle = Bundle::from_json(include_str!(
"../../tests/fixtures/github_build_provenance_jdx_mise.json"
))
Expand Down
34 changes: 34 additions & 0 deletions crates/mise-sigstore/src/tests/trust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,37 @@ async fn github_tuf_retry_preserves_workflow_mismatch() {

assert!(matches!(result, Err(AttestationError::WorkflowMismatch(_))));
}

#[tokio::test]
async fn github_tuf_retry_preserves_trust_root_fetch_failure() {
let result = verify_github_bundle_with_tuf_retry_after_embedded_result(
Err(AttestationError::Verification(
"embedded root rejected bundle".to_string(),
)),
|| async {
Err(AttestationError::TrustRoot(
"connection refused".to_string(),
))
},
)
.await;

let Err(AttestationError::TrustRoot(message)) = result else {
panic!("expected a TrustRoot error, got {result:?}");
};
assert!(message.contains("embedded root rejected bundle"));
assert!(message.contains("connection refused"));
}

#[tokio::test]
async fn github_tuf_retry_keeps_other_failures_as_verification_errors() {
let result = verify_github_bundle_with_tuf_retry_after_embedded_result(
Err(AttestationError::Verification(
"embedded root rejected bundle".to_string(),
)),
|| async { Err(AttestationError::Verification("bad signature".to_string())) },
)
.await;

assert!(matches!(result, Err(AttestationError::Verification(_))));
}
8 changes: 6 additions & 2 deletions crates/mise-sigstore/src/trust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,19 @@ pub(crate) async fn production_trusted_root() -> Result<TrustedRoot> {
.read()
.unwrap_or_else(|e| e.into_inner())
.clone();
Ok(TrustedRoot::from_tuf(select_tuf_config(override_url)).await?)
TrustedRoot::from_tuf(select_tuf_config(override_url))
.await
.map_err(|e| AttestationError::TrustRoot(e.to_string()))
}

pub(crate) fn github_embedded_trusted_root() -> Result<TrustedRoot> {
Ok(TrustedRoot::from_embedded(SigstoreInstance::GitHub)?)
}

pub(crate) async fn github_tuf_trusted_root() -> Result<TrustedRoot> {
Ok(TrustedRoot::from_tuf(TufConfig::github()).await?)
TrustedRoot::from_tuf(TufConfig::github())
.await
.map_err(|e| AttestationError::TrustRoot(e.to_string()))
Comment thread
jdx marked this conversation as resolved.
Resolved
}

/// Per-process cache so we only fetch the Sigstore TUF root or parse the
Expand Down
Loading