From 47436f884120f3d255af0adf5a5ac2de8e947ea9 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Sat, 5 Sep 2026 18:39:19 +0000 Subject: [PATCH 1/4] feat(verify)!: require an explicit signer authorization policy Signed-off-by: Wolf Vollprecht --- README.md | 11 +-- crates/sigstore-conformance/src/main.rs | 2 +- crates/sigstore-verify/README.md | 17 ++-- .../sigstore-verify/examples/verify_bundle.rs | 21 ++++- .../examples/verify_conda_attestation.rs | 4 +- crates/sigstore-verify/src/lib.rs | 2 +- crates/sigstore-verify/src/verify.rs | 42 +++++++--- .../tests/verification_tests.rs | 81 ++++++++++--------- 8 files changed, 112 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index d9baa820..a397f2c7 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,10 @@ let verifier = Verifier::new(&root); let bundle: sigstore_types::Bundle = serde_json::from_str(&bundle_json)?; // Authorize the expected signer, not just any valid Sigstore identity. -let policy = VerificationPolicy::default() - .require_issuer("https://token.actions.githubusercontent.com") - .require_identity("https://github.com/myorg/myrepo/.github/workflows/release.yml@refs/tags/v1.0.0"); +let policy = VerificationPolicy::new( + "https://github.com/myorg/myrepo/.github/workflows/release.yml@refs/tags/v1.0.0", + "https://token.actions.githubusercontent.com", +); verifier.verify(artifact_bytes, &bundle, &policy)?; ``` @@ -88,7 +89,7 @@ let bundle_json = serde_json::to_string_pretty(&bundle)?; cargo run -p sigstore-sign --features browser --example sign_blob -- README.md -o README.md.sigstore.json # Verify with our tool -cargo run -p sigstore-verify --example verify_bundle -- README.md README.md.sigstore.json +cargo run -p sigstore-verify --example verify_bundle -- --identity "$INSERT_YOUR_EMAIL" --issuer https://github.com/login/oauth README.md README.md.sigstore.json # You can also verify with cosign cosign verify-blob --bundle README.md.sigstore.json \ @@ -108,7 +109,7 @@ curl -LO https://github.com/sigstore/cosign/releases/download/v3.0.2/cosign_chec # 2. Verify the bundle (cryptographic verification without identity policy) cargo run -p sigstore-verify --example verify_bundle -- \ - cosign_checksums.txt cosign_checksums.txt.sigstore.json + --allow-any-identity cosign_checksums.txt cosign_checksums.txt.sigstore.json # 3. Or verify with identity policy (this release was signed with Google's keyless signer) cargo run -p sigstore-verify --example verify_bundle -- \ diff --git a/crates/sigstore-conformance/src/main.rs b/crates/sigstore-conformance/src/main.rs index d87f80ba..dee4e6e1 100644 --- a/crates/sigstore-conformance/src/main.rs +++ b/crates/sigstore-conformance/src/main.rs @@ -292,7 +292,7 @@ fn verify_bundle(args: &[String]) -> Result<(), Box> { let certificate_oidc_issuer = certificate_oidc_issuer.unwrap(); // Create verification policy - let policy = VerificationPolicy::default() + let policy = VerificationPolicy::any_identity() .require_identity(certificate_identity) .require_issuer(certificate_oidc_issuer); diff --git a/crates/sigstore-verify/README.md b/crates/sigstore-verify/README.md index f19fa3e4..fff57c24 100644 --- a/crates/sigstore-verify/README.md +++ b/crates/sigstore-verify/README.md @@ -23,6 +23,13 @@ This crate provides high-level APIs for verifying Sigstore signatures. It handle 5. Verify timestamps if present 6. Check identity against policy (optional) +## Authorization + +`VerificationPolicy` has no default. Prefer `VerificationPolicy::new(identity, issuer)` +to authorize the expected signer. `any_identity()` is an explicit opt-in to +cryptographic verification without signer authorization. The example CLI likewise +requires an identity/issuer restriction or `--allow-any-identity`. + ## Usage ```rust @@ -31,7 +38,7 @@ use sigstore_trust_root::{TrustedRoot, TufConfig}; use sigstore_types::{Artifact, Bundle, Sha256Hash}; let bundle: Bundle = serde_json::from_str(bundle_json)?; -let policy = VerificationPolicy::default(); +let policy = VerificationPolicy::any_identity(); // Actively choose the Sigstore instance and fetch its root through TUF. let root = TrustedRoot::from_tuf(TufConfig::production()).await?; @@ -80,7 +87,7 @@ let artifact_digest = Sha256Hash::from_hex("...")?; // client), or use the embedded copy below for an offline path. // let root = TrustedRoot::from_tuf(sigstore_trust_root::TufConfig::github()).await?; let root = TrustedRoot::from_embedded(SigstoreInstance::GitHub)?; -let policy = VerificationPolicy::default().skip_tlog_unsafe().skip_sct(); +let policy = VerificationPolicy::any_identity().skip_tlog_unsafe().skip_sct(); let result = verify(artifact_digest, &bundle, &policy, &root)?; ``` @@ -91,15 +98,15 @@ let result = verify(artifact_digest, &bundle, &policy, &root)?; use sigstore_verify::VerificationPolicy; // Default policy (verify tlog, timestamps, and certificate chain) -let policy = VerificationPolicy::default(); +let policy = VerificationPolicy::any_identity(); // Require specific identity and issuer -let policy = VerificationPolicy::default() +let policy = VerificationPolicy::any_identity() .require_identity("user@example.com") .require_issuer("https://accounts.google.com"); // Skip certain verifications (for testing only) -let policy = VerificationPolicy::default() +let policy = VerificationPolicy::any_identity() .skip_tlog_unsafe() .skip_certificate_chain(); ``` diff --git a/crates/sigstore-verify/examples/verify_bundle.rs b/crates/sigstore-verify/examples/verify_bundle.rs index 9f2ec53e..f08a8f77 100644 --- a/crates/sigstore-verify/examples/verify_bundle.rs +++ b/crates/sigstore-verify/examples/verify_bundle.rs @@ -6,7 +6,7 @@ //! //! Verify a local bundle: //! ```sh -//! cargo run -p sigstore-verify --example verify_bundle -- artifact.txt artifact.sigstore.json +//! cargo run -p sigstore-verify --example verify_bundle -- --allow-any-identity artifact.txt artifact.sigstore.json //! ``` //! //! Verify with identity requirements: @@ -65,6 +65,7 @@ async fn main() { let mut trusted_root_path: Option = None; let mut tuf_root_path: Option = None; let mut staging = false; + let mut allow_any_identity = false; let mut positional: Vec = Vec::new(); let mut i = 1; @@ -115,6 +116,7 @@ async fn main() { tuf_root_path = Some(value); } } + "--allow-any-identity" => allow_any_identity = true, "--staging" => { staging = true; } @@ -152,6 +154,13 @@ async fn main() { process::exit(2); } + if identity.is_none() && identity_regexp.is_none() && issuer.is_none() && !allow_any_identity { + eprintln!( + "Error: specify an identity/issuer requirement or explicitly pass --allow-any-identity" + ); + process::exit(2); + } + let artifact_or_digest = &positional[0]; let bundle_path = &positional[1]; @@ -223,7 +232,7 @@ async fn main() { }; // Build verification policy - let mut policy = VerificationPolicy::default(); + let mut policy = VerificationPolicy::any_identity(); if let Some(id) = &identity { policy = policy.require_identity(id); } @@ -330,6 +339,9 @@ fn print_usage(program: &str) { eprintln!(" Path to the Sigstore bundle (.sigstore.json)"); eprintln!(); eprintln!("Options:"); + eprintln!( + " --allow-any-identity Verify cryptography without authorizing a signer" + ); eprintln!(" --certificate-identity Required certificate identity (exact match)"); eprintln!(" --certificate-identity-regexp Required certificate identity (regex)"); eprintln!(" --certificate-oidc-issuer Required OIDC issuer"); @@ -344,7 +356,10 @@ fn print_usage(program: &str) { eprintln!(); eprintln!("Examples:"); eprintln!(" # Verify a bundle"); - eprintln!(" {} artifact.txt artifact.sigstore.json", program); + eprintln!( + " {} --allow-any-identity artifact.txt artifact.sigstore.json", + program + ); eprintln!(); eprintln!(" # Verify with identity regex (cosign-compatible)"); eprintln!(" {} --certificate-identity-regexp \".*\" \\", program); diff --git a/crates/sigstore-verify/examples/verify_conda_attestation.rs b/crates/sigstore-verify/examples/verify_conda_attestation.rs index 802f3f4f..2ba0d875 100644 --- a/crates/sigstore-verify/examples/verify_conda_attestation.rs +++ b/crates/sigstore-verify/examples/verify_conda_attestation.rs @@ -137,8 +137,8 @@ async fn main() { // Build verification policy - for GitHub Actions attestations, we expect // the identity to be the workflow file path and issuer to be GitHub - let policy = - VerificationPolicy::default().require_issuer("https://token.actions.githubusercontent.com"); + let policy = VerificationPolicy::any_identity() + .require_issuer("https://token.actions.githubusercontent.com"); // Verify println!(); diff --git a/crates/sigstore-verify/src/lib.rs b/crates/sigstore-verify/src/lib.rs index cccac9b9..208cf9e0 100644 --- a/crates/sigstore-verify/src/lib.rs +++ b/crates/sigstore-verify/src/lib.rs @@ -15,7 +15,7 @@ //! let bundle = Bundle::from_json(&bundle_json)?; //! let artifact = std::fs::read("artifact.txt")?; //! -//! let policy = VerificationPolicy::default() +//! let policy = VerificationPolicy::any_identity() //! .require_identity("user@example.com") //! .require_issuer("https://accounts.google.com"); //! diff --git a/crates/sigstore-verify/src/verify.rs b/crates/sigstore-verify/src/verify.rs index 75b7ecd1..576522d1 100644 --- a/crates/sigstore-verify/src/verify.rs +++ b/crates/sigstore-verify/src/verify.rs @@ -58,6 +58,15 @@ impl PublicKeyVerificationPolicy { } /// Policy for verifying certificate-based signatures. +/// +/// Choose [`Self::new`] to authorize an identity and issuer, or explicitly opt +/// into [`Self::any_identity`] for cryptographic verification without signer +/// authorization. There is deliberately no `Default` policy. +/// +/// ```compile_fail +/// use sigstore_verify::VerificationPolicy; +/// let _: VerificationPolicy = Default::default(); +/// ``` #[derive(Debug, Clone)] pub struct VerificationPolicy { /// Expected identity (email or URI) @@ -78,8 +87,19 @@ pub struct VerificationPolicy { pub certificate: CertificatePolicy, } -impl Default for VerificationPolicy { - fn default() -> Self { +impl VerificationPolicy { + /// Require both an exact certificate identity and its OIDC issuer. + pub fn new(identity: impl Into, issuer: impl Into) -> Self { + Self::any_identity() + .require_identity(identity) + .require_issuer(issuer) + } + + /// Verify cryptography without authorizing a particular signer. + /// + /// Any identity accepted by the configured certificate authorities may + /// verify. Applications must authorize the result separately before use. + pub fn any_identity() -> Self { Self { identity: None, issuer: None, @@ -87,14 +107,12 @@ impl Default for VerificationPolicy { certificate: CertificatePolicy::Verify { verify_sct: true }, } } -} -impl VerificationPolicy { /// Create a policy that requires a specific identity pub fn with_identity(identity: impl Into) -> Self { Self { identity: Some(identity.into()), - ..Default::default() + ..Self::any_identity() } } @@ -102,7 +120,7 @@ impl VerificationPolicy { pub fn with_issuer(issuer: impl Into) -> Self { Self { issuer: Some(issuer.into()), - ..Default::default() + ..Self::any_identity() } } @@ -237,7 +255,7 @@ impl Verifier { /// let trusted_root = TrustedRoot::from_json(SIGSTORE_PRODUCTION_TRUSTED_ROOT)?; /// let verifier = Verifier::new(&trusted_root); /// let bundle: Bundle = todo!(); - /// let policy = VerificationPolicy::default(); + /// let policy = VerificationPolicy::any_identity(); /// /// // Option 1: Verify with raw bytes /// let artifact_bytes = b"hello world"; @@ -826,7 +844,7 @@ fn verify_message_signature_crypto( /// let bundle = Bundle::from_json(&bundle_json)?; /// let artifact = std::fs::read("artifact.txt")?; /// -/// verify(&artifact, &bundle, &sigstore_verify::VerificationPolicy::default(), &trusted_root)?; +/// verify(&artifact, &bundle, &sigstore_verify::VerificationPolicy::any_identity(), &trusted_root)?; /// # Ok(()) /// # } /// ``` @@ -899,7 +917,7 @@ mod tests { #[test] fn test_verification_policy_default() { - let policy = VerificationPolicy::default(); + let policy = VerificationPolicy::any_identity(); assert!(policy.verify_tlog); assert_eq!( policy.certificate, @@ -909,7 +927,7 @@ mod tests { #[test] fn test_verification_policy_builder() { - let policy = VerificationPolicy::default() + let policy = VerificationPolicy::any_identity() .require_identity("test@example.com") .require_issuer("https://accounts.google.com") .skip_tlog_unsafe(); @@ -924,7 +942,7 @@ mod tests { #[test] fn test_skip_sct_keeps_certificate_chain_verification() { - let policy = VerificationPolicy::default().skip_sct(); + let policy = VerificationPolicy::any_identity().skip_sct(); assert_eq!( policy.certificate, @@ -934,7 +952,7 @@ mod tests { #[test] fn test_skip_certificate_chain_preserves_legacy_sct_skip() { - let policy = VerificationPolicy::default().skip_certificate_chain(); + let policy = VerificationPolicy::any_identity().skip_certificate_chain(); assert_eq!(policy.certificate, CertificatePolicy::Skip); } diff --git a/crates/sigstore-verify/tests/verification_tests.rs b/crates/sigstore-verify/tests/verification_tests.rs index aba37708..b7730f12 100644 --- a/crates/sigstore-verify/tests/verification_tests.rs +++ b/crates/sigstore-verify/tests/verification_tests.rs @@ -161,7 +161,7 @@ fn test_tampered_inclusion_proof_fails_verification() { // ...but the verification path must reject the invalid Merkle proof. let artifact_digest = extract_artifact_digest(&bundle).expect("Bundle should have artifact digest"); - let policy = VerificationPolicy::default(); + let policy = VerificationPolicy::any_identity(); let err = verify(artifact_digest, &bundle, &policy, &production_root()) .expect_err("verification must fail with a tampered inclusion proof"); @@ -184,7 +184,7 @@ fn test_tampered_canonicalized_body_fails_verification() { let artifact_digest = extract_artifact_digest(&bundle).expect("Bundle should have artifact digest"); - let policy = VerificationPolicy::default(); + let policy = VerificationPolicy::any_identity(); let result = verify(artifact_digest, &bundle, &policy, &production_root()); assert!( @@ -208,7 +208,7 @@ fn test_verifier_creation() { extract_artifact_digest(&bundle).expect("Bundle should have artifact digest"); // The bundle's certificate predates the current staging CAs - skip chain checks - let policy = VerificationPolicy::default() + let policy = VerificationPolicy::any_identity() .skip_certificate_chain() .skip_tlog_unsafe(); @@ -225,7 +225,7 @@ fn test_verify_with_policy() { extract_artifact_digest(&bundle).expect("Bundle should have artifact digest"); // Test with default policy (requires tlog verification) - let policy = VerificationPolicy::default(); + let policy = VerificationPolicy::any_identity(); let result = verify(artifact_digest, &bundle, &policy, &production_root()); assert!(result.is_ok(), "Verification failed: {:?}", result.err()); @@ -242,7 +242,7 @@ fn test_verify_extracts_integrated_time() { let artifact_digest = extract_artifact_digest(&bundle).expect("Bundle should have artifact digest"); - let policy = VerificationPolicy::default(); + let policy = VerificationPolicy::any_identity(); let result = verify(artifact_digest, &bundle, &policy, &production_root()).unwrap(); @@ -264,7 +264,7 @@ fn test_skip_tlog_verification() { // V03_BUNDLE is from sigstore-python tests (staging) and may not chain to // the current staging Fulcio; its signed time still authenticates against // the staging root's Rekor key. - let policy = VerificationPolicy::default() + let policy = VerificationPolicy::any_identity() .skip_tlog_unsafe() .skip_certificate_chain(); @@ -288,7 +288,7 @@ fn test_backdated_integrated_time_rejected_even_when_tlog_skipped() { let artifact_digest = extract_artifact_digest(&bundle).expect("Bundle should have artifact digest"); - let policy = VerificationPolicy::default().skip_tlog_unsafe(); + let policy = VerificationPolicy::any_identity().skip_tlog_unsafe(); let err = verify(artifact_digest, &bundle, &policy, &production_root()) .expect_err("backdated integratedTime must fail verification"); @@ -302,7 +302,9 @@ fn test_verify_github_bundle_with_explicit_embedded_root() { Sha256Hash::from_hex("76f1fe8593bf227cca2c089e3c16dc95014a8d3e89c5dd220530469ca043c428") .unwrap(); let root = TrustedRoot::from_embedded(SigstoreInstance::GitHub).unwrap(); - let policy = VerificationPolicy::default().skip_tlog_unsafe().skip_sct(); + let policy = VerificationPolicy::any_identity() + .skip_tlog_unsafe() + .skip_sct(); let result = verify(artifact_digest, &bundle, &policy, &root); @@ -317,9 +319,7 @@ fn test_verify_github_bundle_with_explicit_embedded_root() { #[test] fn test_policy_builder() { - let policy = VerificationPolicy::default() - .require_identity("test@example.com") - .require_issuer("https://accounts.google.com") + let policy = VerificationPolicy::new("test@example.com", "https://accounts.google.com") .skip_tlog_unsafe(); assert_eq!(policy.identity, Some("test@example.com".to_string())); @@ -381,7 +381,7 @@ fn test_full_verification_flow() { // Run full verification - extract digest from bundle let artifact_digest = extract_artifact_digest(&bundle).expect("Bundle should have artifact digest"); - let policy = VerificationPolicy::default(); + let policy = VerificationPolicy::any_identity(); let result = verify(artifact_digest, &bundle, &policy, &production_root()).unwrap(); assert_eq!( @@ -424,7 +424,7 @@ fn test_full_verification_flow_happy_path() { // Run full verification - extract digest from bundle let artifact_digest = extract_artifact_digest(&bundle).expect("Bundle should have artifact digest"); - let policy = VerificationPolicy::default(); + let policy = VerificationPolicy::any_identity(); let result = verify(artifact_digest, &bundle, &policy, &production_root()).unwrap(); assert_eq!( @@ -441,7 +441,7 @@ fn test_verification_with_different_bundle_versions() { let v03_msg = Bundle::from_json(V03_BUNDLE).unwrap(); let artifact_digest = extract_artifact_digest(&v03_msg).expect("Bundle should have artifact digest"); - let policy = VerificationPolicy::default() + let policy = VerificationPolicy::any_identity() .skip_certificate_chain() .skip_tlog_unsafe(); @@ -452,7 +452,7 @@ fn test_verification_with_different_bundle_versions() { let v03_dsse = Bundle::from_json(V03_BUNDLE_DSSE).unwrap(); let dsse_artifact_digest = extract_artifact_digest(&v03_dsse).expect("DSSE bundle should have artifact digest"); - let dsse_policy = VerificationPolicy::default(); + let dsse_policy = VerificationPolicy::any_identity(); let result = verify( dsse_artifact_digest, &v03_dsse, @@ -648,7 +648,7 @@ fn test_verify_github_actions_provenance_bundle() { let result = verify( artifact_digest, &bundle, - &VerificationPolicy::default(), + &VerificationPolicy::any_identity(), &production_root(), ); assert!(result.is_ok(), "Verification failed: {:?}", result.err()); @@ -708,7 +708,7 @@ fn test_bundle_no_cert_v1() { // Use extracted digest or dummy - doesn't matter since validation should fail first let artifact_digest = extract_artifact_digest(&bundle).unwrap_or_else(|| Sha256Hash::from_bytes([0u8; 32])); - let policy = VerificationPolicy::default(); + let policy = VerificationPolicy::any_identity(); let result = verify(artifact_digest, &bundle, &policy, &production_root()); assert!( @@ -759,7 +759,7 @@ fn test_bundle_no_log_entry() { // Use extracted digest or dummy - doesn't matter since validation should fail first let artifact_digest = extract_artifact_digest(&bundle).unwrap_or_else(|| Sha256Hash::from_bytes([0u8; 32])); - let policy = VerificationPolicy::default(); + let policy = VerificationPolicy::any_identity(); let result = verify(artifact_digest, &bundle, &policy, &production_root()); assert!( @@ -804,7 +804,7 @@ fn test_bundle_v3_no_signed_time() { // Use extracted digest or dummy - we're testing handling of missing signed time let artifact_digest = extract_artifact_digest(&bundle).unwrap_or_else(|| Sha256Hash::from_bytes([0u8; 32])); - let policy = VerificationPolicy::default(); + let policy = VerificationPolicy::any_identity(); let result = verify(artifact_digest, &bundle, &policy, &production_root()); // Whether this succeeds or fails depends on implementation @@ -933,7 +933,7 @@ fn test_verify_conda_package_attestation() { Bundle::from_json(CONDA_ATTESTATION_BUNDLE).expect("Failed to parse conda attestation"); // Verify with identity requirements for GitHub Actions - let policy = VerificationPolicy::default() + let policy = VerificationPolicy::any_identity() .require_identity("https://github.com/prefix-dev/sigstore-example/.github/workflows/action.yaml@refs/heads/main") .require_issuer("https://token.actions.githubusercontent.com"); @@ -957,7 +957,7 @@ fn test_verify_conda_package_attestation() { } fn conda_attestation_policy() -> VerificationPolicy { - VerificationPolicy::default() + VerificationPolicy::any_identity() .require_identity("https://github.com/prefix-dev/sigstore-example/.github/workflows/action.yaml@refs/heads/main") .require_issuer("https://token.actions.githubusercontent.com") } @@ -1019,7 +1019,7 @@ fn test_verify_conda_package_wrong_identity() { Bundle::from_json(CONDA_ATTESTATION_BUNDLE).expect("Failed to parse conda attestation"); // Use wrong identity - let policy = VerificationPolicy::default() + let policy = VerificationPolicy::any_identity() .require_identity( "https://github.com/wrong-org/wrong-repo/.github/workflows/wrong.yaml@refs/heads/main", ) @@ -1041,8 +1041,8 @@ fn test_verify_conda_package_tampered() { // Use modified package content let tampered_package = b"this is not the original package content"; - let policy = - VerificationPolicy::default().require_issuer("https://token.actions.githubusercontent.com"); + let policy = VerificationPolicy::any_identity() + .require_issuer("https://token.actions.githubusercontent.com"); let result = verify(tampered_package, &bundle, &policy, &production_root()); assert!( @@ -1102,7 +1102,8 @@ fn test_verify_cosign_v3_blob_bundle() { // The artifact content that was signed let artifact = include_bytes!("../test_data/bundles/cosign-v3-blob.txt"); - let policy = VerificationPolicy::default().require_issuer("https://github.com/login/oauth"); + let policy = + VerificationPolicy::any_identity().require_issuer("https://github.com/login/oauth"); let result = verify(artifact, &bundle, &policy, &production_root()); assert!(result.is_ok(), "Verification failed: {:?}", result.err()); @@ -1120,13 +1121,13 @@ async fn invalid_certificate_does_not_consume_readers() { let verifier = Verifier::new(&production_root()); let mut reader = std::io::Cursor::new(b"do not consume"); let error = verifier - .verify_reader(&mut reader, &bundle, &VerificationPolicy::default()) + .verify_reader(&mut reader, &bundle, &VerificationPolicy::any_identity()) .unwrap_err(); assert!(error.to_string().contains("failed to parse certificate")); assert_eq!(reader.position(), 0); let mut reader = futures::io::Cursor::new(b"do not consume"); assert!(verifier - .verify_async_reader(&mut reader, &bundle, &VerificationPolicy::default()) + .verify_async_reader(&mut reader, &bundle, &VerificationPolicy::any_identity()) .await .is_err()); assert_eq!(reader.position(), 0); @@ -1136,7 +1137,8 @@ async fn invalid_certificate_does_not_consume_readers() { fn test_verify_cosign_bundle_from_sync_reader() { let bundle = Bundle::from_json(COSIGN_V3_BLOB_BUNDLE).unwrap(); let artifact = include_bytes!("../test_data/bundles/cosign-v3-blob.txt"); - let policy = VerificationPolicy::default().require_issuer("https://github.com/login/oauth"); + let policy = + VerificationPolicy::any_identity().require_issuer("https://github.com/login/oauth"); Verifier::new(&production_root()) .verify_reader(std::io::Cursor::new(artifact), &bundle, &policy) @@ -1147,7 +1149,8 @@ fn test_verify_cosign_bundle_from_sync_reader() { async fn test_verify_cosign_bundle_from_async_reader() { let bundle = Bundle::from_json(COSIGN_V3_BLOB_BUNDLE).unwrap(); let artifact = include_bytes!("../test_data/bundles/cosign-v3-blob.txt"); - let policy = VerificationPolicy::default().require_issuer("https://github.com/login/oauth"); + let policy = + VerificationPolicy::any_identity().require_issuer("https://github.com/login/oauth"); Verifier::new(&production_root()) .verify_async_reader(futures::io::Cursor::new(artifact), &bundle, &policy) @@ -1191,7 +1194,7 @@ fn test_foreign_tlog_entry_rejected_even_when_tlog_skipped() { // Skip the certificate chain so the foreign entry is rejected on its // contents rather than incidentally on its (much older) timestamp. - let policy = VerificationPolicy::default() + let policy = VerificationPolicy::any_identity() .skip_tlog_unsafe() .skip_certificate_chain(); @@ -1222,7 +1225,7 @@ fn production_root_with_unhintable_tlog_at(index: usize) -> TrustedRoot { fn test_malformed_rekor_log_id_is_rejected_regardless_of_position() { let bundle = Bundle::from_json(COSIGN_V3_BLOB_BUNDLE).unwrap(); let artifact = include_bytes!("../test_data/bundles/cosign-v3-blob.txt"); - let policy = VerificationPolicy::default(); + let policy = VerificationPolicy::any_identity(); let tlog_count = { let root: serde_json::Value = @@ -1311,7 +1314,7 @@ fn test_verify_fails_with_mismatched_hashedrekord_version_for_dsse() { Bundle::from_json(&corrupted_bundle_json).expect("Failed to parse corrupted bundle"); let artifact_digest = extract_artifact_digest(&bundle).expect("Bundle should have artifact digest"); - let policy = VerificationPolicy::default(); + let policy = VerificationPolicy::any_identity(); let result = verify(artifact_digest, &bundle, &policy, &production_root()); assert!(result.is_err()); @@ -1332,7 +1335,7 @@ fn test_verify_dsse_with_hashedrekord_v002() { let artifact = include_bytes!("../test_data/bundles/signed-package-2.1.0-hb0f4dca_0.conda"); - let policy = VerificationPolicy::default(); + let policy = VerificationPolicy::any_identity(); let result = verify(artifact.as_slice(), &bundle, &policy, &staging_root()); assert!( @@ -1353,7 +1356,7 @@ fn verifies_sigstore_python_rekor_v2_message_signature_fixture() { verify( artifact.as_slice(), &bundle, - &VerificationPolicy::default(), + &VerificationPolicy::any_identity(), &staging_root(), ) .unwrap(); @@ -1371,7 +1374,7 @@ fn rekor_v2_does_not_report_unauthenticated_integrated_time() { let result = verify( artifact.as_slice(), &bundle, - &VerificationPolicy::default(), + &VerificationPolicy::any_identity(), &staging_root(), ) .unwrap(); @@ -1392,7 +1395,7 @@ fn verifies_sigstore_python_rekor_v2_dsse_fixture() { verify( subject_digest, &bundle, - &VerificationPolicy::default(), + &VerificationPolicy::any_identity(), &staging_root(), ) .unwrap(); @@ -1432,7 +1435,7 @@ fn rekor_v2_accepts_a_valid_log_signature_after_an_invalid_matching_signature() verify( artifact.as_slice(), &bundle, - &VerificationPolicy::default(), + &VerificationPolicy::any_identity(), &staging_root(), ) .unwrap(); @@ -1454,7 +1457,7 @@ fn rekor_v2_ignores_untrusted_duplicate_inclusion_proof_fields() { verify( artifact.as_slice(), &bundle, - &VerificationPolicy::default(), + &VerificationPolicy::any_identity(), &staging_root(), ) .unwrap(); @@ -1671,7 +1674,7 @@ fn test_certificate_checked_against_every_verified_timestamp() { 1 ); - let policy = VerificationPolicy::default().skip_tlog_unsafe(); + let policy = VerificationPolicy::any_identity().skip_tlog_unsafe(); let err = verify(artifact, &bundle, &policy, &production_root()) .expect_err("a timestamp outside the certificate's validity must fail verification"); From b917638d86ff3ab85468def1a0548384aa737b13 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Sat, 5 Sep 2026 18:43:29 +0000 Subject: [PATCH 2/4] feat(verify)!: expose read-only verification evidence instead of constructible results Signed-off-by: Wolf Vollprecht --- crates/sigstore-verify/README.md | 9 ++ .../sigstore-verify/examples/verify_bundle.rs | 16 ++-- .../examples/verify_conda_attestation.rs | 15 ++-- crates/sigstore-verify/src/verify.rs | 82 +++++++++++++++---- .../tests/verification_tests.rs | 70 +++++++++++++--- 5 files changed, 152 insertions(+), 40 deletions(-) diff --git a/crates/sigstore-verify/README.md b/crates/sigstore-verify/README.md index fff57c24..4acdf7d6 100644 --- a/crates/sigstore-verify/README.md +++ b/crates/sigstore-verify/README.md @@ -23,6 +23,15 @@ This crate provides high-level APIs for verifying Sigstore signatures. It handle 5. Verify timestamps if present 6. Check identity against policy (optional) +## Verification results + +`VerificationResult` is created only by successful verification and exposes +read-only accessors. `identity()` and `issuer()` are certificate claims; +`certificate_verified()`, `sct_verified()`, `tlog_verified()` and +`identity_policy_checked()` describe what was actually checked. +`verified_timestamps()` excludes unsigned time hints. Relaxed policies must not +be treated as equivalent to full certificate and log verification. + ## Authorization `VerificationPolicy` has no default. Prefer `VerificationPolicy::new(identity, issuer)` diff --git a/crates/sigstore-verify/examples/verify_bundle.rs b/crates/sigstore-verify/examples/verify_bundle.rs index f08a8f77..9d80aad3 100644 --- a/crates/sigstore-verify/examples/verify_bundle.rs +++ b/crates/sigstore-verify/examples/verify_bundle.rs @@ -296,7 +296,7 @@ async fn main() { process::exit(1); } }; - if let Some(id) = &result.identity { + if let Some(id) = result.identity() { if !re.is_match(id) { eprintln!("\nVerification: FAILED"); eprintln!(" Identity '{}' does not match regexp '{}'", id, re_str); @@ -310,18 +310,20 @@ async fn main() { } println!("\nVerification: SUCCESS"); - if let Some(id) = &result.identity { + if let Some(id) = result.identity() { println!(" Identity: {}", id); } - if let Some(iss) = &result.issuer { + if let Some(iss) = result.issuer() { println!(" Issuer: {}", iss); } - if let Some(time) = result.integrated_time { + if let Some(time) = result.integrated_time() { println!(" Signed at: {}", time); } - for warning in &result.warnings { - println!(" Warning: {}", warning); - } + println!(" Certificate verified: {}", result.certificate_verified()); + println!( + " Transparency-log inclusion verified: {}", + result.tlog_verified() + ); process::exit(0); } Err(e) => { diff --git a/crates/sigstore-verify/examples/verify_conda_attestation.rs b/crates/sigstore-verify/examples/verify_conda_attestation.rs index 2ba0d875..060836db 100644 --- a/crates/sigstore-verify/examples/verify_conda_attestation.rs +++ b/crates/sigstore-verify/examples/verify_conda_attestation.rs @@ -147,19 +147,20 @@ async fn main() { println!("Verification: SUCCESS"); println!(); println!("Certificate Details:"); - if let Some(id) = &result.identity { + if let Some(id) = result.identity() { println!(" Identity (SAN): {}", id); } - if let Some(iss) = &result.issuer { + if let Some(iss) = result.issuer() { println!(" OIDC Issuer: {}", iss); } - if let Some(time) = result.integrated_time { + if let Some(time) = result.integrated_time() { println!(" Signed at: {}", time); } - for warning in &result.warnings { - println!(); - println!("Warning: {}", warning); - } + println!(" Certificate verified: {}", result.certificate_verified()); + println!( + " Transparency-log inclusion verified: {}", + result.tlog_verified() + ); process::exit(0); } Err(e) => { diff --git a/crates/sigstore-verify/src/verify.rs b/crates/sigstore-verify/src/verify.rs index 576522d1..854c7c5d 100644 --- a/crates/sigstore-verify/src/verify.rs +++ b/crates/sigstore-verify/src/verify.rs @@ -189,35 +189,77 @@ impl VerificationPolicy { /// Result of verification /// /// This is returned only when verification *succeeds* — any failure is reported -/// as an [`Err`]. It carries metadata extracted during verification (identity, -/// issuer, integrated time) plus any non-fatal warnings. +/// as an [`Err`]. Metadata and evidence are read-only. Check the evidence +/// getters when accepting results from callers that may use relaxed policies. +/// Identity/issuer values are certificate claims, not proof of authorization. +/// +/// ```compile_fail +/// let result = sigstore_verify::VerificationResult::new(); +/// ``` +/// ```compile_fail +/// let _: sigstore_verify::VerificationResult = Default::default(); +/// ``` #[derive(Debug)] pub struct VerificationResult { - /// Identity from the certificate - pub identity: Option, - /// Issuer from the certificate - pub issuer: Option, - /// Integrated time from transparency log - pub integrated_time: Option, - /// Any warnings during verification - pub warnings: Vec, + identity: Option, + issuer: Option, + integrated_time: Option, + certificate_verified: bool, + sct_verified: bool, + tlog_verified: bool, + identity_policy_checked: bool, + verified_timestamps: Vec, } impl VerificationResult { /// Create an empty result to be populated as verification proceeds. - pub fn new() -> Self { + fn new() -> Self { Self { identity: None, issuer: None, integrated_time: None, - warnings: Vec::new(), + certificate_verified: false, + sct_verified: false, + tlog_verified: false, + identity_policy_checked: false, + verified_timestamps: Vec::new(), } } -} -impl Default for VerificationResult { - fn default() -> Self { - Self::new() + /// Certificate SAN claim, if present; see [`Self::certificate_verified`]. + pub fn identity(&self) -> Option<&str> { + self.identity.as_deref() + } + /// Certificate OIDC issuer claim, if present. + pub fn issuer(&self) -> Option<&str> { + self.issuer.as_deref() + } + /// An authenticated Rekor v1 integrated time, if inclusion was verified. + pub fn integrated_time(&self) -> Option { + self.integrated_time + } + /// Whether the signing certificate's chain, EKU and validity were checked. + pub fn certificate_verified(&self) -> bool { + self.certificate_verified + } + /// Whether the signing certificate's SCT was verified. + pub fn sct_verified(&self) -> bool { + self.sct_verified + } + /// Whether transparency-log inclusion and checkpoints were verified. + pub fn tlog_verified(&self) -> bool { + self.tlog_verified + } + /// Whether an identity and/or issuer constraint was matched. + /// + /// Matching claims is not authorization unless the certificate was also verified. + pub fn identity_policy_checked(&self) -> bool { + self.identity_policy_checked + } + /// All authenticated times used during verification (not unsigned hints). + /// Managed-key verification does not use TSA tokens for certificate validation. + pub fn verified_timestamps(&self) -> &[jiff::Timestamp] { + &self.verified_timestamps } } @@ -418,8 +460,11 @@ impl Verifier { issuer_spki.as_bytes(), &self.trusted_root, )?; + result.sct_verified = true; } + result.certificate_verified = true; } + result.verified_timestamps = validation_times; // (3): Verify against the given `VerificationPolicy`. @@ -460,6 +505,8 @@ impl Verifier { } } + result.identity_policy_checked = policy.identity.is_some() || policy.issuer.is_some(); + // (4): Verify the inclusion proof and signed checkpoint for the log entry. // (5): Verify the inclusion promise for the log entry, if present. // (6): Verify the timely insertion of the log entry against the validity @@ -472,6 +519,7 @@ impl Verifier { cert_info.not_after, )?; + result.tlog_verified = true; if let Some(time) = integrated_time { result.integrated_time = Some(time); } @@ -643,9 +691,11 @@ impl Verifier { jiff::Timestamp::now(), )?; result.integrated_time = Some(time); + result.verified_timestamps.push(time); } } } + result.tlog_verified = true; } // Verify the signature diff --git a/crates/sigstore-verify/tests/verification_tests.rs b/crates/sigstore-verify/tests/verification_tests.rs index b7730f12..a1d312bd 100644 --- a/crates/sigstore-verify/tests/verification_tests.rs +++ b/crates/sigstore-verify/tests/verification_tests.rs @@ -231,7 +231,7 @@ fn test_verify_with_policy() { assert!(result.is_ok(), "Verification failed: {:?}", result.err()); let verification = result.unwrap(); - assert!(verification.integrated_time.is_some()); + assert!(verification.integrated_time().is_some()); } #[test] @@ -248,7 +248,7 @@ fn test_verify_extracts_integrated_time() { // The integrated time in the bundle is 1738060096 (2025-01-28) assert_eq!( - result.integrated_time, + result.integrated_time(), Some(jiff::Timestamp::from_second(1738060096).unwrap()) ); } @@ -385,7 +385,7 @@ fn test_full_verification_flow() { let result = verify(artifact_digest, &bundle, &policy, &production_root()).unwrap(); assert_eq!( - result.integrated_time, + result.integrated_time(), Some(jiff::Timestamp::from_second(1738060096).unwrap()) ); } @@ -428,7 +428,7 @@ fn test_full_verification_flow_happy_path() { let result = verify(artifact_digest, &bundle, &policy, &production_root()).unwrap(); assert_eq!( - result.integrated_time, + result.integrated_time(), Some(jiff::Timestamp::from_second(1734374576).unwrap()) ); } @@ -946,14 +946,14 @@ fn test_verify_conda_package_attestation() { let verification = result.unwrap(); assert_eq!( - verification.identity.as_deref(), + verification.identity(), Some("https://github.com/prefix-dev/sigstore-example/.github/workflows/action.yaml@refs/heads/main") ); assert_eq!( - verification.issuer.as_deref(), + verification.issuer(), Some("https://token.actions.githubusercontent.com") ); - assert!(verification.integrated_time.is_some()); + assert!(verification.integrated_time().is_some()); } fn conda_attestation_policy() -> VerificationPolicy { @@ -976,7 +976,7 @@ fn test_verify_conda_package_attestation_from_sync_reader() { &conda_attestation_policy(), ) .unwrap(); - assert!(verification.integrated_time.is_some()); + assert!(verification.integrated_time().is_some()); } #[tokio::test] @@ -1133,6 +1133,51 @@ async fn invalid_certificate_does_not_consume_readers() { assert_eq!(reader.position(), 0); } +#[test] +fn verification_results_report_only_checked_evidence() { + let bundle = Bundle::from_json(COSIGN_V3_BLOB_BUNDLE).unwrap(); + let bytes = include_bytes!("../test_data/bundles/cosign-v3-blob.txt"); + let verifier = Verifier::new(&production_root()); + for (policy, chain, sct, tlog) in [ + (VerificationPolicy::any_identity(), true, true, true), + ( + VerificationPolicy::any_identity().skip_sct(), + true, + false, + true, + ), + ( + VerificationPolicy::any_identity().skip_certificate_chain(), + false, + false, + true, + ), + ( + VerificationPolicy::any_identity().skip_tlog_unsafe(), + true, + true, + false, + ), + ] { + let result = verifier.verify(bytes, &bundle, &policy).unwrap(); + assert_eq!(result.certificate_verified(), chain); + assert_eq!(result.sct_verified(), sct); + assert_eq!(result.tlog_verified(), tlog); + assert_eq!(result.integrated_time().is_some(), tlog); + assert!(!result.identity_policy_checked()); + assert_eq!(result.verified_timestamps().len(), 2); + let authorized = + VerificationPolicy::new(result.identity().unwrap(), result.issuer().unwrap()); + assert!(verifier + .verify(bytes, &bundle, &authorized) + .unwrap() + .identity_policy_checked()); + assert!(verifier + .verify(bytes, &bundle, &authorized.require_identity("wrong signer")) + .is_err()); + } +} + #[test] fn test_verify_cosign_bundle_from_sync_reader() { let bundle = Bundle::from_json(COSIGN_V3_BLOB_BUNDLE).unwrap(); @@ -1379,7 +1424,7 @@ fn rekor_v2_does_not_report_unauthenticated_integrated_time() { ) .unwrap(); - assert_eq!(result.integrated_time, None); + assert_eq!(result.integrated_time(), None); } #[test] @@ -1570,7 +1615,12 @@ fn test_verifier_with_key_accepts_digest_and_reports_integrated_time() { ) .unwrap(); - assert_eq!(result.integrated_time, expected_time); + assert_eq!(result.integrated_time(), expected_time); + assert!(result.tlog_verified()); + assert!(!result.certificate_verified()); + assert!(!result.sct_verified()); + assert!(!result.identity_policy_checked()); + assert_eq!(result.verified_timestamps(), &[expected_time.unwrap()]); } #[test] From 9bd73404aadea5e93bc2ff7ca45f57747007ff91 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Sat, 5 Sep 2026 19:15:37 +0000 Subject: [PATCH 3/4] fix(verify): report integrated times only when authenticated by a SET Signed-off-by: Wolf Vollprecht --- .../sigstore-verify/src/verify_impl/tlog.rs | 6 +++--- .../tests/verification_tests.rs | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/sigstore-verify/src/verify_impl/tlog.rs b/crates/sigstore-verify/src/verify_impl/tlog.rs index c548fdc3..5ff2919d 100644 --- a/crates/sigstore-verify/src/verify_impl/tlog.rs +++ b/crates/sigstore-verify/src/verify_impl/tlog.rs @@ -41,10 +41,10 @@ pub fn verify_tlog_entries( // Verify Merkle inclusion proof, checkpoint signature and SET verify_entry_inclusion(entry, trusted_root)?; - // Only Rekor v1 authenticates integratedTime via the SET. Rekor v2 - // uses RFC 3161 timestamps; ignore an unauthenticated top-level value. + // Only a Rekor v1 SET authenticates integratedTime. An inclusion proof + // authenticates the body, not this separate timestamp field. let is_rekor_v2 = entry.kind_version == KindVersion::HashedRekordV002; - if !is_rekor_v2 { + if !is_rekor_v2 && entry.inclusion_promise.is_some() { if let Some(time) = entry.integrated_time { validate_integrated_time(time, jiff::Timestamp::now(), not_before, not_after)?; integrated_time_result = Some(time); diff --git a/crates/sigstore-verify/tests/verification_tests.rs b/crates/sigstore-verify/tests/verification_tests.rs index a1d312bd..b9601ade 100644 --- a/crates/sigstore-verify/tests/verification_tests.rs +++ b/crates/sigstore-verify/tests/verification_tests.rs @@ -1133,6 +1133,26 @@ async fn invalid_certificate_does_not_consume_readers() { assert_eq!(reader.position(), 0); } +#[test] +fn inclusion_proof_alone_does_not_authenticate_integrated_time() { + let mut bundle = Bundle::from_json(COSIGN_V3_BLOB_BUNDLE).unwrap(); + let entry = &mut bundle.verification_material.tlog_entries[0]; + entry.inclusion_promise = None; + entry.integrated_time = + Some(entry.integrated_time.unwrap() + jiff::SignedDuration::from_secs(1)); + // The independent TSA timestamp still authenticates the signing time. + let result = verify( + include_bytes!("../test_data/bundles/cosign-v3-blob.txt"), + &bundle, + &VerificationPolicy::any_identity(), + &production_root(), + ) + .unwrap(); + assert_eq!(result.integrated_time(), None); + assert_eq!(result.verified_timestamps().len(), 1); + assert!(result.tlog_verified()); +} + #[test] fn verification_results_report_only_checked_evidence() { let bundle = Bundle::from_json(COSIGN_V3_BLOB_BUNDLE).unwrap(); From d143e3720d0ad42e9f12183a5e7f15e6aaf854a6 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Sat, 5 Sep 2026 19:03:40 +0000 Subject: [PATCH 4/4] feat(verify)!: validate and prepare static trust in a fallible constructor Signed-off-by: Wolf Vollprecht --- README.md | 2 +- crates/sigstore-verify/README.md | 10 +- .../sigstore-verify/examples/verify_bundle.rs | 5 +- crates/sigstore-verify/src/error.rs | 8 ++ crates/sigstore-verify/src/verify.rs | 110 ++++++++++++++++-- .../src/verify_impl/helpers.rs | 56 ++++----- crates/sigstore-verify/src/verify_impl/sct.rs | 12 +- .../sigstore-verify/src/verify_impl/tlog.rs | 29 ++--- .../tests/verification_tests.rs | 41 ++++++- 9 files changed, 204 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index a397f2c7..bfa5d90b 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ use sigstore_trust_root::TrustedRoot; // Load the trusted root via TUF (recommended - ensures up-to-date trust material) let root = TrustedRoot::production().await?; -let verifier = Verifier::new(&root); +let verifier = Verifier::new(&root)?; // Parse the bundle (contains signature, certificate, transparency log entry) let bundle: sigstore_types::Bundle = serde_json::from_str(&bundle_json)?; diff --git a/crates/sigstore-verify/README.md b/crates/sigstore-verify/README.md index 4acdf7d6..c3e7d9f6 100644 --- a/crates/sigstore-verify/README.md +++ b/crates/sigstore-verify/README.md @@ -23,6 +23,14 @@ This crate provides high-level APIs for verifying Sigstore signatures. It handle 5. Verify timestamps if present 6. Check identity against policy (optional) +## Verifier construction + +`Verifier::new(&root)?` prepares Rekor/CT keyrings and Fulcio trust anchors before +reading artifacts. Invalid certificates, keys, duplicate log IDs and reversed +validity windows are errors at construction. Unsupported configured keys must be +removed explicitly rather than silently ignored. Key/authority activation times +are still checked when verifying, so long-lived verifiers do not freeze time. + ## Verification results `VerificationResult` is created only by successful verification and exposes @@ -61,7 +69,7 @@ let digest = Sha256Hash::from_hex("b94d27b9...")?; let result = verify(digest, &bundle, &policy, &root)?; // Or use a Verifier directly; it also offers the same inputs -let verifier = Verifier::new(&root); +let verifier = Verifier::new(&root)?; let result = verifier.verify(artifact_bytes.as_slice(), &bundle, &policy)?; // Stream a large artifact in constant memory diff --git a/crates/sigstore-verify/examples/verify_bundle.rs b/crates/sigstore-verify/examples/verify_bundle.rs index 9d80aad3..7b69712f 100644 --- a/crates/sigstore-verify/examples/verify_bundle.rs +++ b/crates/sigstore-verify/examples/verify_bundle.rs @@ -261,7 +261,10 @@ async fn main() { } // Verify - let verifier = Verifier::new(&trusted_root); + let verifier = Verifier::new(&trusted_root).unwrap_or_else(|e| { + eprintln!("Error preparing trusted root: {e}"); + process::exit(2); + }); let result = if is_digest { // Parse digest (sha256:hex...) let hex_digest = artifact_or_digest.strip_prefix("sha256:").unwrap(); diff --git a/crates/sigstore-verify/src/error.rs b/crates/sigstore-verify/src/error.rs index d0ff230f..73245b48 100644 --- a/crates/sigstore-verify/src/error.rs +++ b/crates/sigstore-verify/src/error.rs @@ -21,6 +21,14 @@ pub enum Error { #[error("Bundle error: {0}")] Bundle(#[from] sigstore_bundle::Error), + /// Invalid trust-root configuration. + #[error("Trust root error: {0}")] + TrustRoot(#[from] sigstore_trust_root::Error), + + /// A configured authority certificate could not be parsed. + #[error("invalid trusted certificate: {0}")] + TrustedCertificate(#[source] webpki::Error), + /// Failed to read artifact input. #[error("failed to read artifact: {0}")] ArtifactRead(#[source] std::io::Error), diff --git a/crates/sigstore-verify/src/verify.rs b/crates/sigstore-verify/src/verify.rs index 854c7c5d..599236f0 100644 --- a/crates/sigstore-verify/src/verify.rs +++ b/crates/sigstore-verify/src/verify.rs @@ -267,6 +267,9 @@ impl VerificationResult { pub struct Verifier { /// Trusted root containing verification material trusted_root: TrustedRoot, + rekor_keys: sigstore_crypto::Keyring, + pub(crate) ct_keys: Vec<(SigningScheme, sigstore_crypto::Keyring)>, + pub(crate) fulcio_anchors: Vec, } impl Verifier { @@ -274,10 +277,85 @@ impl Verifier { /// /// The trusted root is required and contains all cryptographic material /// needed for verification (Fulcio CA certs, Rekor keys, TSA certs, etc.) - pub fn new(trusted_root: &TrustedRoot) -> Self { - Self { - trusted_root: trusted_root.clone(), + /// + /// Prepares configured keys and certificates, rejecting malformed, + /// unsupported, duplicate or invalid-window trust material before artifact + /// I/O. Empty authority lists are allowed for managed-key or relaxed policies. + pub fn new(trusted_root: &TrustedRoot) -> Result { + use sigstore_crypto::{Keyring, VerificationKey}; + use sigstore_types::Sha256Hash; + let mut ids = std::collections::HashSet::new(); + let logs = trusted_root + .tlogs + .iter() + .map(|log| (true, &log.log_id, &log.public_key)) + .chain( + trusted_root + .ctlogs + .iter() + .map(|log| (false, &log.log_id, &log.public_key)), + ); + for (is_rekor, id, public_key) in logs { + validate_trust_window(public_key.valid_for)?; + let id = Sha256Hash::try_from_slice(&id.key_id.decode()?)?; + if !ids.insert((is_rekor, id)) { + return Err(Error::Verification(format!( + "duplicate trusted log ID: {}", + id.to_hex() + ))); + } + if is_rekor { + VerificationKey::from_spki(&public_key.raw_bytes)?; + } else { + VerificationKey::from_spki(&public_key.raw_bytes).or_else(|_| { + VerificationKey::from_der(&public_key.raw_bytes, SigningScheme::RsaPkcs1Sha256) + })?; + } + } + let rekor_keys = trusted_root.rekor_keys()?; + let ct_keys: Vec<(SigningScheme, Keyring)> = [ + SigningScheme::EcdsaP256Sha256, + SigningScheme::EcdsaP384Sha384, + SigningScheme::RsaPkcs1Sha256, + SigningScheme::RsaPkcs1Sha384, + SigningScheme::RsaPkcs1Sha512, + ] + .into_iter() + .map(|scheme| Ok((scheme, trusted_root.ctfe_keys(scheme)?))) + .collect::>()?; + let mut fulcio_anchors = Vec::new(); + let authorities = trusted_root + .certificate_authorities + .iter() + .map(|ca| (true, &ca.cert_chain, ca.valid_for)) + .chain( + trusted_root + .timestamp_authorities + .iter() + .map(|tsa| (false, &tsa.cert_chain, tsa.valid_for)), + ); + for (is_fulcio, chain, window) in authorities { + validate_trust_window(window)?; + if chain.certificates.is_empty() { + return Err(Error::Verification( + "trusted authority has an empty certificate chain".into(), + )); + } + for cert in &chain.certificates { + let der = rustls_pki_types::CertificateDer::from(cert.raw_bytes.as_bytes()); + let anchor = + webpki::anchor_from_trusted_cert(&der).map_err(Error::TrustedCertificate)?; + if is_fulcio { + fulcio_anchors.push((anchor.to_owned(), window)); + } + } } + Ok(Self { + trusted_root: trusted_root.clone(), + rekor_keys, + ct_keys, + fulcio_anchors, + }) } /// Verify an artifact against a bundle @@ -295,7 +373,7 @@ impl Verifier { /// /// # async fn example() -> Result<(), Box> { /// let trusted_root = TrustedRoot::from_json(SIGSTORE_PRODUCTION_TRUSTED_ROOT)?; - /// let verifier = Verifier::new(&trusted_root); + /// let verifier = Verifier::new(&trusted_root)?; /// let bundle: Bundle = todo!(); /// let policy = VerificationPolicy::any_identity(); /// @@ -419,6 +497,7 @@ impl Verifier { bundle, &signature, &self.trusted_root, + &self.rekor_keys, )?; // (1): Verify that the signing certificate chains to the root of trust, @@ -439,7 +518,7 @@ impl Verifier { issuer_spki = Some(crate::verify_impl::helpers::verify_certificate_chain( &bundle.verification_material.content, validation_time, - &self.trusted_root, + &self.fulcio_anchors, )?); // Also verify the certificate is within its validity period @@ -458,7 +537,7 @@ impl Verifier { crate::verify_impl::sct::verify_sct( cert.as_bytes(), issuer_spki.as_bytes(), - &self.trusted_root, + &self.ct_keys, )?; result.sct_verified = true; } @@ -514,7 +593,7 @@ impl Verifier { if policy.verify_tlog { let integrated_time = crate::verify_impl::tlog::verify_tlog_entries( bundle, - &self.trusted_root, + &self.rekor_keys, cert_info.not_before, cert_info.not_after, )?; @@ -584,7 +663,7 @@ impl Verifier { /// /// # fn example() -> Result<(), Box> { /// let trusted_root = TrustedRoot::from_file("trusted_root.json")?; - /// let verifier = Verifier::new(&trusted_root); + /// let verifier = Verifier::new(&trusted_root)?; /// let bundle = Bundle::from_json(&std::fs::read_to_string("artifact.sigstore.json")?)?; /// let public_key = DerPublicKey::from_pem(&std::fs::read_to_string("key.pub")?)?; /// let artifact = std::fs::read("artifact.txt")?; @@ -678,7 +757,7 @@ impl Verifier { // SETs) without certificate time validation. if policy.verify_tlog { for entry in &bundle.verification_material.tlog_entries { - crate::verify_impl::tlog::verify_entry_inclusion(entry, &self.trusted_root)?; + crate::verify_impl::tlog::verify_entry_inclusion(entry, &self.rekor_keys)?; let is_rekor_v1 = matches!( entry.kind_version, @@ -733,6 +812,15 @@ impl Verifier { } } +fn validate_trust_window(window: Option) -> Result<()> { + if window.is_some_and(|range| range.end.is_some_and(|end| end < range.start)) { + return Err(Error::Verification( + "trusted validity window ends before it starts".into(), + )); + } + Ok(()) +} + /// Check that a `MessageSignature`'s declared `messageDigest`, if any, matches /// the artifact. fn verify_message_digest_binding( @@ -904,7 +992,7 @@ pub fn verify<'a>( policy: &VerificationPolicy, trusted_root: &TrustedRoot, ) -> Result { - Verifier::new(trusted_root).verify(artifact, bundle, policy) + Verifier::new(trusted_root)?.verify(artifact, bundle, policy) } /// Convenience function to verify a managed-key bundle with a caller-supplied @@ -947,7 +1035,7 @@ pub fn verify_with_key<'a>( policy: &PublicKeyVerificationPolicy, trusted_root: &TrustedRoot, ) -> Result { - Verifier::new(trusted_root).verify_with_key(artifact, bundle, public_key, policy) + Verifier::new(trusted_root)?.verify_with_key(artifact, bundle, public_key, policy) } #[cfg(test)] diff --git a/crates/sigstore-verify/src/verify_impl/helpers.rs b/crates/sigstore-verify/src/verify_impl/helpers.rs index 52f302ec..8dd77e36 100644 --- a/crates/sigstore-verify/src/verify_impl/helpers.rs +++ b/crates/sigstore-verify/src/verify_impl/helpers.rs @@ -10,7 +10,12 @@ use sigstore_crypto::CertificateInfo; use sigstore_trust_root::{TrustedRoot, TsaAuthority}; use sigstore_types::bundle::VerificationMaterialContent; use sigstore_types::{Bundle, DerPublicKey, KindVersion, SignatureBytes, SignatureContent}; -use webpki::{anchor_from_trusted_cert, EndEntityCert, KeyUsage, ALL_VERIFICATION_ALGS}; +use webpki::{EndEntityCert, KeyUsage, ALL_VERIFICATION_ALGS}; + +pub(crate) type FulcioAnchor = ( + rustls_pki_types::TrustAnchor<'static>, + Option, +); /// Extract signature from bundle content (needed for TSA verification). /// @@ -131,7 +136,7 @@ pub fn has_v2_tlog_entries(bundle: &Bundle) -> bool { /// Returns every authenticated integrated time. fn extract_v1_integrated_times_with_promise( bundle: &Bundle, - trusted_root: &TrustedRoot, + rekor_keys: &sigstore_crypto::Keyring, ) -> Result> { let mut times = Vec::new(); @@ -147,7 +152,7 @@ fn extract_v1_integrated_times_with_promise( } if let Some(time) = entry.integrated_time { - crate::verify_impl::tlog::verify_set(entry, trusted_root)?; + crate::verify_impl::tlog::verify_set(entry, rekor_keys)?; times.push(time); } } @@ -186,11 +191,11 @@ pub fn determine_validation_times( bundle: &Bundle, signature: &SignatureBytes, trusted_root: &TrustedRoot, + rekor_keys: &sigstore_crypto::Keyring, ) -> Result> { let mut times = extract_tsa_timestamps(bundle, signature.as_bytes(), trusted_root)?; times.extend(extract_v1_integrated_times_with_promise( - bundle, - trusted_root, + bundle, rekor_keys, )?); if !times.is_empty() { @@ -252,7 +257,7 @@ pub fn validate_certificate_time( pub fn verify_certificate_chain( verification_material: &VerificationMaterialContent, validation_time: jiff::Timestamp, - trusted_root: &TrustedRoot, + fulcio_anchors: &[FulcioAnchor], ) -> Result { // Extract the end-entity certificate and any intermediates from the bundle let (ee_cert_der, intermediate_ders) = match verification_material { @@ -277,24 +282,13 @@ pub fn verify_certificate_chain( } }; - // Get Fulcio certificates from trusted root to use as trust anchors - let fulcio_certs = trusted_root.fulcio_certs(); - - if fulcio_certs.is_empty() { - return Err(Error::Verification( - "no Fulcio certificates in trusted root".to_string(), - )); - } - - // Build trust anchors from Fulcio root certificates - let trust_anchors: Vec<_> = fulcio_certs + // Keep window selection dynamic: a long-lived verifier may cross the + // activation time of a future authority after construction. + let now = jiff::Timestamp::now(); + let trust_anchors: Vec<_> = fulcio_anchors .iter() - .filter_map(|cert_der| { - let cert = CertificateDer::from(&cert_der[..]); - anchor_from_trusted_cert(&cert) - .map(|anchor| anchor.to_owned()) - .ok() - }) + .filter(|(_, window)| window.is_none_or(|range| range.has_started_by(now))) + .map(|(anchor, _)| anchor.clone()) .collect(); if trust_anchors.is_empty() { @@ -385,7 +379,13 @@ mod tests { .unwrap(); let signature = extract_signature(&bundle.content); - let times = determine_validation_times(&bundle, &signature, &trusted_root).unwrap(); + let times = determine_validation_times( + &bundle, + &signature, + &trusted_root, + &trusted_root.rekor_keys().unwrap(), + ) + .unwrap(); assert_eq!( times.len(), @@ -450,8 +450,10 @@ mod tests { // The canonical flow: the issuer comes from the verified chain, then SCT // verification uses it. Before the fix, SCT verification returned // Err("SCT signature verification failed: ... signature invalid"). - let issuer_spki = verify_certificate_chain(material, validation_time, &trusted_root) - .expect("certificate chain should verify against the staging root"); + let verifier = crate::Verifier::new(&trusted_root).unwrap(); + let issuer_spki = + verify_certificate_chain(material, validation_time, &verifier.fulcio_anchors) + .expect("certificate chain should verify against the staging root"); let cert = match material { VerificationMaterialContent::Certificate(cert) => &cert.raw_bytes, VerificationMaterialContent::X509CertificateChain { certificates } => { @@ -459,7 +461,7 @@ mod tests { } _ => panic!("fixture must have a signing certificate"), }; - super::super::sct::verify_sct(cert.as_bytes(), issuer_spki.as_bytes(), &trusted_root) + super::super::sct::verify_sct(cert.as_bytes(), issuer_spki.as_bytes(), &verifier.ct_keys) .expect("SCT verification should succeed once the correct issuer is selected"); } diff --git a/crates/sigstore-verify/src/verify_impl/sct.rs b/crates/sigstore-verify/src/verify_impl/sct.rs index 6664543b..3657cbbf 100644 --- a/crates/sigstore-verify/src/verify_impl/sct.rs +++ b/crates/sigstore-verify/src/verify_impl/sct.rs @@ -6,8 +6,8 @@ use crate::error::{Error, Result}; use const_oid::db::rfc6962::CT_PRECERT_SCTS; +use sigstore_crypto::Keyring; use sigstore_crypto::SigningScheme; -use sigstore_trust_root::TrustedRoot; use sigstore_types::{Sha256Hash, SignatureBytes}; use tls_codec::{SerializeBytes, TlsByteVecU16, TlsByteVecU24, TlsSerializeBytes, TlsSize}; use x509_cert::{ @@ -191,7 +191,7 @@ pub fn extract_sct( pub fn verify_sct( cert_der: &[u8], issuer_spki_der: &[u8], - trusted_root: &TrustedRoot, + ct_keys: &[(SigningScheme, Keyring)], ) -> Result<()> { // Parse the certificate let cert = Certificate::from_der(cert_der) @@ -212,9 +212,11 @@ pub fn verify_sct( // TrustedRoot constructs the keyring and preserves each CT log's declared // key ID and validity window. RFC 6962 timestamps are milliseconds since // the Unix epoch, so select the key that was valid when the SCT was issued. - let keyring = trusted_root - .ctfe_keys(scheme) - .map_err(|e| Error::Verification(format!("failed to build CT keyring: {e}")))?; + let keyring = &ct_keys + .iter() + .find(|(candidate, _)| *candidate == scheme) + .ok_or_else(|| Error::Verification("unsupported SCT signing scheme".into()))? + .1; if keyring.is_empty() { return Err(Error::Verification( "no CT log keys in trusted root".to_string(), diff --git a/crates/sigstore-verify/src/verify_impl/tlog.rs b/crates/sigstore-verify/src/verify_impl/tlog.rs index 5ff2919d..8e95b006 100644 --- a/crates/sigstore-verify/src/verify_impl/tlog.rs +++ b/crates/sigstore-verify/src/verify_impl/tlog.rs @@ -7,7 +7,7 @@ use crate::error::{Error, Result}; use base64::Engine; use serde::Serialize; use sigstore_crypto::Checkpoint; -use sigstore_trust_root::TrustedRoot; +use sigstore_crypto::Keyring; use sigstore_types::bundle::InclusionProof; use sigstore_types::{Bundle, KindVersion, Sha256Hash, SignatureBytes, TransparencyLogEntry}; @@ -26,12 +26,12 @@ use sigstore_types::{Bundle, KindVersion, Sha256Hash, SignatureBytes, Transparen /// /// # Arguments /// * `bundle` - The bundle containing transparency log entries -/// * `trusted_root` - Trusted root for cryptographic verification +/// * `rekor_keys` - Trusted root for cryptographic verification /// * `not_before` - Certificate validity start time (Unix timestamp) /// * `not_after` - Certificate validity end time (Unix timestamp) pub fn verify_tlog_entries( bundle: &Bundle, - trusted_root: &TrustedRoot, + rekor_keys: &Keyring, not_before: jiff::Timestamp, not_after: jiff::Timestamp, ) -> Result> { @@ -39,7 +39,7 @@ pub fn verify_tlog_entries( for entry in &bundle.verification_material.tlog_entries { // Verify Merkle inclusion proof, checkpoint signature and SET - verify_entry_inclusion(entry, trusted_root)?; + verify_entry_inclusion(entry, rekor_keys)?; // Only a Rekor v1 SET authenticates integratedTime. An inclusion proof // authenticates the body, not this separate timestamp field. @@ -110,22 +110,19 @@ pub(crate) fn validate_integrated_time_not_in_future( /// /// Time-related checks (integrated time vs. certificate validity) are not /// performed here; see [`verify_tlog_entries`]. -pub fn verify_entry_inclusion( - entry: &TransparencyLogEntry, - trusted_root: &TrustedRoot, -) -> Result<()> { +pub fn verify_entry_inclusion(entry: &TransparencyLogEntry, rekor_keys: &Keyring) -> Result<()> { if let Some(ref inclusion_proof) = entry.inclusion_proof { verify_merkle_inclusion(entry, inclusion_proof)?; verify_checkpoint( inclusion_proof.checkpoint.envelope(), inclusion_proof, is_rekor_v2(entry), - trusted_root, + rekor_keys, )?; } if entry.inclusion_promise.is_some() { - verify_set(entry, trusted_root)?; + verify_set(entry, rekor_keys)?; } Ok(()) @@ -168,7 +165,7 @@ pub fn verify_checkpoint( checkpoint_envelope: &str, inclusion_proof: &InclusionProof, is_v2: bool, - trusted_root: &TrustedRoot, + rekor_keys: &Keyring, ) -> Result<()> { // Parse the checkpoint (signed note) let checkpoint = Checkpoint::from_text(checkpoint_envelope) @@ -191,9 +188,7 @@ pub fn verify_checkpoint( // validity metadata alongside each parsed verification key. Four-byte // checkpoint hints may collide, so try every matching key and do not let // an invalid matching signature suppress a later valid log signature. - let rekor_keys = trusted_root - .rekor_keys() - .map_err(|e| Error::Verification(format!("failed to build Rekor keyring: {e}")))?; + let message = checkpoint.signed_data(); let now = jiff::Timestamp::now(); let mut found_matching_key = false; @@ -230,7 +225,7 @@ struct RekorPayload { } /// Verify SET (Signed Entry Timestamp) -pub fn verify_set(entry: &TransparencyLogEntry, trusted_root: &TrustedRoot) -> Result<()> { +pub fn verify_set(entry: &TransparencyLogEntry, rekor_keys: &Keyring) -> Result<()> { let promise = entry .inclusion_promise .as_ref() @@ -246,9 +241,7 @@ pub fn verify_set(entry: &TransparencyLogEntry, trusted_root: &TrustedRoot) -> R .map_err(|e| Error::Verification(format!("invalid Rekor log ID: {e}")))?; let key_id = Sha256Hash::try_from_slice(&decoded_key_id) .map_err(|e| Error::Verification(format!("invalid Rekor log ID: {e}")))?; - let keyring = trusted_root - .rekor_keys() - .map_err(|e| Error::Verification(format!("failed to build Rekor keyring: {e}")))?; + let keyring = rekor_keys; let log_key = if let Some(integrated_ts) = entry.integrated_time { keyring.get_key_at(&key_id, integrated_ts).ok_or_else(|| { Error::Verification(format!( diff --git a/crates/sigstore-verify/tests/verification_tests.rs b/crates/sigstore-verify/tests/verification_tests.rs index b9601ade..43cf68a2 100644 --- a/crates/sigstore-verify/tests/verification_tests.rs +++ b/crates/sigstore-verify/tests/verification_tests.rs @@ -200,7 +200,7 @@ fn test_verifier_creation() { // V03_BUNDLE is from sigstore-python tests, so it verifies against the // staging root (whose expired Rekor key authenticates the SET / signed time). let root = staging_root(); - let verifier = Verifier::new(&root); + let verifier = Verifier::new(&root).unwrap(); let bundle = Bundle::from_json(V03_BUNDLE).unwrap(); // Extract expected digest from the bundle @@ -970,6 +970,7 @@ fn test_verify_conda_package_attestation_from_sync_reader() { let bundle = Bundle::from_json(CONDA_ATTESTATION_BUNDLE).unwrap(); let verification = Verifier::new(&production_root()) + .unwrap() .verify_reader( std::io::Cursor::new(CONDA_PACKAGE), &bundle, @@ -984,6 +985,7 @@ async fn test_verify_conda_package_attestation_from_async_reader() { let bundle = Bundle::from_json(CONDA_ATTESTATION_BUNDLE).unwrap(); Verifier::new(&production_root()) + .unwrap() .verify_async_reader( futures::io::Cursor::new(CONDA_PACKAGE), &bundle, @@ -1000,6 +1002,7 @@ fn test_verify_conda_package_tampered_from_reader() { let bundle = Bundle::from_json(CONDA_ATTESTATION_BUNDLE).unwrap(); let err = Verifier::new(&production_root()) + .unwrap() .verify_reader( std::io::Cursor::new(b"this is not the original package content"), &bundle, @@ -1118,7 +1121,7 @@ async fn invalid_certificate_does_not_consume_readers() { raw_bytes: sigstore_types::DerCertificate::new(vec![0]), }, ); - let verifier = Verifier::new(&production_root()); + let verifier = Verifier::new(&production_root()).unwrap(); let mut reader = std::io::Cursor::new(b"do not consume"); let error = verifier .verify_reader(&mut reader, &bundle, &VerificationPolicy::any_identity()) @@ -1153,11 +1156,35 @@ fn inclusion_proof_alone_does_not_authenticate_integrated_time() { assert!(result.tlog_verified()); } +#[test] +fn verifier_constructor_rejects_invalid_static_trust() { + let root = production_root(); + assert!(Verifier::new(&root).is_ok()); + let mut bad_key = root.clone(); + bad_key.tlogs[0].public_key.raw_bytes = sigstore_types::DerPublicKey::new(vec![0]); + let mut bad_ca = root.clone(); + bad_ca.certificate_authorities[0].cert_chain.certificates[0].raw_bytes = + sigstore_types::DerCertificate::new(vec![0]); + let mut bad_tsa = root.clone(); + bad_tsa.timestamp_authorities[0].cert_chain.certificates[0].raw_bytes = + sigstore_types::DerCertificate::new(vec![0]); + let mut duplicate_ct = root.clone(); + duplicate_ct.ctlogs.push(duplicate_ct.ctlogs[0].clone()); + let mut reversed_window = root; + reversed_window.tlogs[0].public_key.valid_for = Some(sigstore_types::TimeRange::new( + jiff::Timestamp::MAX, + Some(jiff::Timestamp::MIN), + )); + for invalid in [bad_key, bad_ca, bad_tsa, duplicate_ct, reversed_window] { + assert!(Verifier::new(&invalid).is_err()); + } +} + #[test] fn verification_results_report_only_checked_evidence() { let bundle = Bundle::from_json(COSIGN_V3_BLOB_BUNDLE).unwrap(); let bytes = include_bytes!("../test_data/bundles/cosign-v3-blob.txt"); - let verifier = Verifier::new(&production_root()); + let verifier = Verifier::new(&production_root()).unwrap(); for (policy, chain, sct, tlog) in [ (VerificationPolicy::any_identity(), true, true, true), ( @@ -1206,6 +1233,7 @@ fn test_verify_cosign_bundle_from_sync_reader() { VerificationPolicy::any_identity().require_issuer("https://github.com/login/oauth"); Verifier::new(&production_root()) + .unwrap() .verify_reader(std::io::Cursor::new(artifact), &bundle, &policy) .unwrap(); } @@ -1218,6 +1246,7 @@ async fn test_verify_cosign_bundle_from_async_reader() { VerificationPolicy::any_identity().require_issuer("https://github.com/login/oauth"); Verifier::new(&production_root()) + .unwrap() .verify_async_reader(futures::io::Cursor::new(artifact), &bundle, &policy) .await .unwrap(); @@ -1580,7 +1609,7 @@ fn managed_dsse_verifier_is_bound_even_when_tlog_verification_is_skipped() { inclusion_proof: None, canonicalized_body: CanonicalizedBody::new(serde_json::to_vec(&body).unwrap()), }]; - let result = Verifier::new(&production_root()).verify_with_key( + let result = Verifier::new(&production_root()).unwrap().verify_with_key( b"artifact", &bundle, &public_key, @@ -1624,7 +1653,7 @@ fn test_verify_with_key_treats_public_key_hint_as_opaque() { fn test_verifier_with_key_accepts_digest_and_reports_integrated_time() { let bundle = Bundle::from_json(MANAGED_KEY_BUNDLE).unwrap(); let expected_time = bundle.verification_material.tlog_entries[0].integrated_time; - let verifier = Verifier::new(&production_root()); + let verifier = Verifier::new(&production_root()).unwrap(); let result = verifier .verify_with_key( @@ -1648,6 +1677,7 @@ fn test_verifier_with_key_from_sync_reader() { let bundle = Bundle::from_json(MANAGED_KEY_BUNDLE).unwrap(); Verifier::new(&production_root()) + .unwrap() .verify_with_key_reader( std::io::Cursor::new(MANAGED_KEY_ARTIFACT), &bundle, @@ -1662,6 +1692,7 @@ async fn test_verifier_with_key_from_async_reader() { let bundle = Bundle::from_json(MANAGED_KEY_BUNDLE).unwrap(); Verifier::new(&production_root()) + .unwrap() .verify_with_key_async_reader( futures::io::Cursor::new(MANAGED_KEY_ARTIFACT), &bundle,