From b326f1f0c7612f304b1df6fd7759f9987bde02b3 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Tue, 4 Aug 2026 12:37:35 +0200 Subject: [PATCH 1/5] fix(verify): handle canonical Rekor intoto entries Signed-off-by: Wolf Vollprecht --- crates/sigstore-rekor/src/body.rs | 49 ++++- crates/sigstore-verify/src/verify.rs | 44 +++-- .../src/verify_impl/helpers.rs | 12 +- .../sigstore-verify/src/verify_impl/rekor.rs | 170 +++++++++++++++--- .../tests/verification_tests.rs | 22 ++- 5 files changed, 252 insertions(+), 45 deletions(-) diff --git a/crates/sigstore-rekor/src/body.rs b/crates/sigstore-rekor/src/body.rs index 3105a7f1..c3400f01 100644 --- a/crates/sigstore-rekor/src/body.rs +++ b/crates/sigstore-rekor/src/body.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use sigstore_types::encoding::base64_bytes; use sigstore_types::{ - DerCertificate, DerPublicKey, HashAlgorithm, HexHash, PayloadBytes, PemContent, SignatureBytes, + DerCertificate, DerPublicKey, HashAlgorithm, HexHash, PemContent, SignatureBytes, }; /// Parsed Rekor entry body @@ -251,21 +251,62 @@ pub struct IntotoV002Spec { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct IntotoV002Content { pub envelope: IntotoEnvelope, + /// Hash of the complete submitted DSSE envelope. + pub hash: HashValue, + /// Hash of the decoded DSSE payload. + pub payload_hash: HashValue, } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct IntotoEnvelope { - /// Payload bytes (actually double-encoded in Rekor) - pub payload: PayloadBytes, + /// DSSE payload type. Canonical log entries omit the proposed payload. + pub payload_type: String, pub signatures: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct IntotoSignature { - /// Signature bytes (double-encoded in Rekor) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keyid: Option, + /// Signature bytes (double-encoded in Rekor). pub sig: SignatureBytes, + /// PEM certificate or public key associated with the signature. + pub public_key: PemContent, +} + +impl IntotoSignature { + /// Parse the PEM verifier as an X.509 certificate. + pub fn to_certificate(&self) -> Result { + let pem_str = self.verifier_pem()?; + DerCertificate::from_pem(&pem_str).map_err(|e| { + crate::error::Error::InvalidResponse(format!( + "failed to parse intoto signature certificate PEM: {}", + e + )) + }) + } + + /// Parse the PEM verifier as a SubjectPublicKeyInfo public key. + pub fn to_public_key(&self) -> Result { + let pem_str = self.verifier_pem()?; + DerPublicKey::from_pem(&pem_str).map_err(|e| { + crate::error::Error::InvalidResponse(format!( + "failed to parse intoto signature public key PEM: {}", + e + )) + }) + } + + fn verifier_pem(&self) -> Result { + String::from_utf8(self.public_key.as_bytes().to_vec()).map_err(|e| { + crate::error::Error::InvalidResponse(format!("PEM not valid UTF-8: {}", e)) + }) + } } // ============================================================================ diff --git a/crates/sigstore-verify/src/verify.rs b/crates/sigstore-verify/src/verify.rs index d43915e7..0dd20a55 100644 --- a/crates/sigstore-verify/src/verify.rs +++ b/crates/sigstore-verify/src/verify.rs @@ -472,11 +472,8 @@ fn verify_dsse_envelope_signature( /// Only in-toto statements are supported: any other payload type has no /// defined relationship to the artifact, so verification fails closed rather /// than accepting an arbitrary artifact alongside a validly-signed envelope. -/// The artifact's SHA-256 digest must match at least one subject of the -/// statement, and the statement must have at least one subject. -/// -/// Note: in-toto supports multiple digest algorithms (e.g. sha512), but -/// Sigstore currently mandates SHA-256 for attestation subjects. +/// A supported artifact digest (SHA-256 or SHA-512) must match at least one +/// subject of the statement, and the statement must have at least one subject. fn verify_dsse_artifact_binding( envelope: &sigstore_types::DsseEnvelope, artifact: &Artifact<'_>, @@ -488,11 +485,6 @@ fn verify_dsse_artifact_binding( ))); } - let artifact_hash = compute_artifact_digest_algo(artifact, HashAlgorithm::Sha2256)?; - let artifact_hash_hex = sigstore_types::Sha256Hash::try_from_slice(&artifact_hash) - .map_err(|_| Error::Verification("invalid SHA-256 hash length".to_string()))? - .to_hex(); - let payload_str = std::str::from_utf8(envelope.payload.as_bytes()) .map_err(|e| Error::Verification(format!("payload is not valid UTF-8: {}", e)))?; let statement: Statement = serde_json::from_str(payload_str) @@ -503,7 +495,37 @@ fn verify_dsse_artifact_binding( "in-toto statement has no subjects: cannot bind artifact to attestation".to_string(), )); } - if !statement.matches_sha256(&artifact_hash_hex) { + let matches = match artifact { + Artifact::Bytes(bytes) => { + let sha256 = hex::encode(sigstore_crypto::sha256(bytes)); + let sha512 = hex::encode(sigstore_crypto::sha512(bytes)); + statement.subject.iter().any(|subject| { + subject.digest.sha256.as_deref() == Some(sha256.as_str()) + || subject.digest.sha512.as_deref() == Some(sha512.as_str()) + }) + } + Artifact::Digest(hash) => match hash.len() { + 32 => { + let digest = hex::encode(hash); + statement.matches_sha256(&digest) + } + 64 => { + let digest = hex::encode(hash); + statement + .subject + .iter() + .any(|subject| subject.digest.sha512.as_deref() == Some(digest.as_str())) + } + length => { + return Err(Error::Verification(format!( + "unsupported pre-computed artifact digest length: {}", + length + ))) + } + }, + }; + + if !matches { return Err(Error::Verification( "artifact hash does not match any subject in attestation".to_string(), )); diff --git a/crates/sigstore-verify/src/verify_impl/helpers.rs b/crates/sigstore-verify/src/verify_impl/helpers.rs index 52b2cb8c..eb9f9913 100644 --- a/crates/sigstore-verify/src/verify_impl/helpers.rs +++ b/crates/sigstore-verify/src/verify_impl/helpers.rs @@ -96,7 +96,10 @@ pub fn has_v2_tlog_entries(bundle: &Bundle) -> bool { .verification_material .tlog_entries .iter() - .any(|entry| entry.kind_version.version == "0.0.2") + .any(|entry| { + entry.kind_version.version == "0.0.2" + && matches!(entry.kind_version.kind.as_str(), "hashedrekord" | "dsse") + }) } /// Extract integrated time from V1 tlog entries that have inclusion promises. @@ -121,9 +124,10 @@ fn extract_v1_integrated_times_with_promise( let mut times = Vec::new(); for entry in &bundle.verification_material.tlog_entries { - // Only V1 entries (0.0.1) with inclusion promises are valid timestamp sources - let is_v1 = entry.kind_version.version == "0.0.1" - && (entry.kind_version.kind == "hashedrekord" || entry.kind_version.kind == "dsse"); + // Rekor v1 uses 0.0.1 for hashedrekord/dsse and 0.0.2 for intoto. + let is_v1 = (entry.kind_version.version == "0.0.1" + && matches!(entry.kind_version.kind.as_str(), "hashedrekord" | "dsse")) + || (entry.kind_version.kind == "intoto" && entry.kind_version.version == "0.0.2"); if !is_v1 || entry.inclusion_promise.is_none() { continue; diff --git a/crates/sigstore-verify/src/verify_impl/rekor.rs b/crates/sigstore-verify/src/verify_impl/rekor.rs index ff0edeb4..e81191b2 100644 --- a/crates/sigstore-verify/src/verify_impl/rekor.rs +++ b/crates/sigstore-verify/src/verify_impl/rekor.rs @@ -7,7 +7,8 @@ use crate::error::{Error, Result}; use base64::Engine; use sigstore_rekor::body::RekorEntryBody; use sigstore_types::bundle::VerificationMaterialContent; -use sigstore_types::{Bundle, SignatureContent, TransparencyLogEntry}; +use sigstore_types::{Bundle, HashAlgorithm, SignatureContent, TransparencyLogEntry}; +use x509_cert::der::{Decode, Encode}; /// Verify that all log entries are consistent with the bundle's content and artifact pub fn verify_tlog_consistency( @@ -41,7 +42,7 @@ pub fn verify_tlog_consistency( } }, "intoto" => match entry.kind_version.version.as_str() { - "0.0.2" => verify_intoto_v002(entry, envelope)?, + "0.0.2" => verify_intoto_v002(entry, envelope, bundle)?, version => { return Err(Error::Verification(format!( "unsupported intoto entry version: {}", @@ -175,6 +176,7 @@ fn verify_dsse_v001( fn verify_intoto_v002( entry: &TransparencyLogEntry, envelope: &sigstore_types::DsseEnvelope, + bundle: &Bundle, ) -> Result<()> { let body = RekorEntryBody::from_base64_json( &entry.canonicalized_body.to_base64(), @@ -183,10 +185,10 @@ fn verify_intoto_v002( ) .map_err(|e| Error::Verification(format!("failed to parse Rekor body: {}", e)))?; - let (rekor_payload_b64, rekor_signatures) = match &body { + let (rekor_envelope, payload_hash) = match &body { RekorEntryBody::IntotoV002(intoto_body) => ( - &intoto_body.spec.content.envelope.payload, - &intoto_body.spec.content.envelope.signatures, + &intoto_body.spec.content.envelope, + &intoto_body.spec.content.payload_hash, ), _ => { return Err(Error::Verification( @@ -195,37 +197,155 @@ fn verify_intoto_v002( } }; - // The Rekor entry has the payload double-base64-encoded, decode it once - let rekor_payload_bytes = base64::engine::general_purpose::STANDARD - .decode(rekor_payload_b64.as_bytes()) - .map_err(|e| Error::Verification(format!("failed to decode Rekor payload: {}", e)))?; - - // Compare with bundle payload bytes - if envelope.payload.as_bytes() != rekor_payload_bytes.as_slice() { + if payload_hash.algorithm != HashAlgorithm::Sha2256 { + return Err(Error::Verification(format!( + "unsupported intoto payload hash algorithm: {}", + payload_hash.algorithm + ))); + } + let expected_payload_hash = payload_hash + .value + .decode() + .map_err(|e| Error::Verification(format!("invalid intoto payload hash: {}", e)))?; + let actual_payload_hash = sigstore_crypto::sha256(envelope.payload.as_bytes()); + if actual_payload_hash.as_bytes() != expected_payload_hash.as_slice() { return Err(Error::Verification( - "DSSE payload in bundle does not match intoto Rekor entry".to_string(), + "DSSE payload hash does not match intoto Rekor entry".to_string(), )); } - // Validate that the bundle's single signature is present in the Rekor entry - let mut found_match = false; - for rekor_sig in rekor_signatures { - // The Rekor signature is also double-base64-encoded, decode it once - let rekor_sig_decoded = base64::engine::general_purpose::STANDARD - .decode(rekor_sig.sig.as_bytes()) - .map_err(|e| Error::Verification(format!("failed to decode Rekor signature: {}", e)))?; + if envelope.payload_type != rekor_envelope.payload_type { + return Err(Error::Verification(format!( + "DSSE payload type mismatch: bundle has {:?}, Rekor entry has {:?}", + envelope.payload_type, rekor_envelope.payload_type + ))); + } - if envelope.signature.sig.as_bytes() == rekor_sig_decoded.as_slice() { - found_match = true; - break; + let bundle_cert = match &bundle.verification_material.content { + VerificationMaterialContent::X509CertificateChain { certificates } => { + certificates.first().map(|cert| &cert.raw_bytes) } + VerificationMaterialContent::Certificate(cert) => Some(&cert.raw_bytes), + VerificationMaterialContent::PublicKey { .. } => None, } + .ok_or_else(|| { + Error::Verification( + "intoto Rekor signature cannot be bound to a bundle signing certificate".to_string(), + ) + })?; - if !found_match { + let [rekor_sig] = rekor_envelope.signatures.as_slice() else { + return Err(Error::Verification(format!( + "DSSE signature count mismatch: bundle has 1, Rekor entry has {}", + rekor_envelope.signatures.len() + ))); + }; + + // Rekor's signature field contains an additional base64 layer in canonical + // intoto/0.0.2 entries. + let rekor_sig_decoded = base64::engine::general_purpose::STANDARD + .decode(rekor_sig.sig.as_bytes()) + .map_err(|e| Error::Verification(format!("failed to decode Rekor signature: {e}")))?; + if envelope.signature.sig.as_bytes() != rekor_sig_decoded.as_slice() { return Err(Error::Verification( - "DSSE signature in bundle does not match intoto Rekor entry".to_string(), + "DSSE signature in bundle does not match the intoto Rekor signature".to_string(), + )); + } + + let verifier_matches = match rekor_sig.to_certificate() { + Ok(rekor_cert) => bundle_cert.as_bytes() == rekor_cert.as_bytes(), + Err(_) => { + let rekor_key = rekor_sig + .to_public_key() + .map_err(|e| Error::Verification(e.to_string()))?; + let cert = x509_cert::Certificate::from_der(bundle_cert.as_bytes()).map_err(|e| { + Error::Verification(format!("failed to parse bundle certificate: {e}")) + })?; + let cert_key = cert + .tbs_certificate + .subject_public_key_info + .to_der() + .map_err(|e| { + Error::Verification(format!( + "failed to encode bundle certificate public key: {e}" + )) + })?; + rekor_key.as_bytes() == cert_key + } + }; + if !verifier_matches { + return Err(Error::Verification( + "DSSE signing certificate does not match the intoto Rekor verifier".to_string(), )); } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use sigstore_types::{Artifact, Bundle, CanonicalizedBody}; + + const CANONICAL_INTOTO_BUNDLE: &str = + include_str!("../../test_data/bundles/sigstore.js@2.0.0-provenance.sigstore.json"); + + fn bundle() -> Bundle { + Bundle::from_json(CANONICAL_INTOTO_BUNDLE).unwrap() + } + + fn mutate_body(bundle: &mut Bundle, mutation: impl FnOnce(&mut serde_json::Value)) { + let entry = &mut bundle.verification_material.tlog_entries[0]; + let mut body: serde_json::Value = + serde_json::from_slice(entry.canonicalized_body.as_bytes()).unwrap(); + mutation(&mut body); + entry.canonicalized_body = CanonicalizedBody::new(serde_json::to_vec(&body).unwrap()); + } + + fn verify_consistency(bundle: &Bundle) -> Result<()> { + let digest = [0u8; 32]; + verify_tlog_consistency(bundle, &Artifact::from_digest(&digest)) + } + + #[test] + fn canonical_intoto_entry_matches_bundle() { + verify_consistency(&bundle()).unwrap(); + } + + #[test] + fn canonical_intoto_entry_binds_payload_hash() { + let mut bundle = bundle(); + mutate_body(&mut bundle, |body| { + body["spec"]["content"]["payloadHash"]["value"] = "00".repeat(32).into(); + }); + assert!(verify_consistency(&bundle).is_err()); + } + + #[test] + fn canonical_intoto_entry_binds_payload_type() { + let mut bundle = bundle(); + mutate_body(&mut bundle, |body| { + body["spec"]["content"]["envelope"]["payloadType"] = + "application/vnd.example+json".into(); + }); + assert!(verify_consistency(&bundle).is_err()); + } + + #[test] + fn canonical_intoto_entry_binds_signature() { + let mut bundle = bundle(); + mutate_body(&mut bundle, |body| { + body["spec"]["content"]["envelope"]["signatures"][0]["sig"] = "YmFk".into(); + }); + assert!(verify_consistency(&bundle).is_err()); + } + + #[test] + fn canonical_intoto_entry_binds_signing_certificate() { + let mut bundle = bundle(); + mutate_body(&mut bundle, |body| { + body["spec"]["content"]["envelope"]["signatures"][0]["publicKey"] = "YmFk".into(); + }); + assert!(verify_consistency(&bundle).is_err()); + } +} diff --git a/crates/sigstore-verify/tests/verification_tests.rs b/crates/sigstore-verify/tests/verification_tests.rs index f90d993e..052251ba 100644 --- a/crates/sigstore-verify/tests/verification_tests.rs +++ b/crates/sigstore-verify/tests/verification_tests.rs @@ -3,7 +3,7 @@ //! These tests validate the complete verification flow using real bundles. use sigstore_trust_root::{SigstoreInstance, TrustedRoot, SIGSTORE_PRODUCTION_TRUSTED_ROOT}; -use sigstore_types::{LogIndex, Sha256Hash}; +use sigstore_types::{DigestBytes, LogIndex, Sha256Hash}; use sigstore_verify::bundle::{validate_bundle, validate_bundle_with_options, ValidationOptions}; use sigstore_verify::types::Bundle; use sigstore_verify::{verify, VerificationPolicy, Verifier}; @@ -641,6 +641,26 @@ fn test_github_actions_provenance_bundle() { ); } +/// Exercise complete verification of the canonical Rekor intoto/0.0.2 body, +/// which stores payloadHash and payloadType rather than the proposed payload. +#[test] +fn test_verify_github_actions_provenance_bundle() { + let bundle = + Bundle::from_json(SIGSTORE_JS_PROVENANCE).expect("Failed to parse provenance bundle"); + let artifact_digest = DigestBytes::from_bytes( + hex::decode("46d4e2f74c4877316640000a6fdf8a8b59f1e0847667973e9859f774dd31b8f1e0937813b777fb66a2ac67d50540fe34640966eee9fc2ccca387082b4c85cd3c") + .unwrap(), + ); + + let result = verify( + artifact_digest, + &bundle, + &VerificationPolicy::default(), + &production_root(), + ); + assert!(result.is_ok(), "Verification failed: {:?}", result.err()); +} + /// Test bundle with OtherName SAN type /// Equivalent to sigstore-go's TestEntityWithOthernameSan #[test] From 79e16770a7658ea9dcefe44c5df45608d25d4574 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Thu, 13 Aug 2026 11:30:07 +0200 Subject: [PATCH 2/5] refactor(types): add Statement SHA-512 matching Signed-off-by: Wolf Vollprecht --- crates/sigstore-types/src/intoto.rs | 21 +++++++++++++-------- crates/sigstore-verify/src/verify.rs | 10 ++-------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/crates/sigstore-types/src/intoto.rs b/crates/sigstore-types/src/intoto.rs index 0998b752..10d5f92a 100644 --- a/crates/sigstore-types/src/intoto.rs +++ b/crates/sigstore-types/src/intoto.rs @@ -58,13 +58,16 @@ pub struct Digest { impl Statement { /// Check if any subject in the statement matches the given SHA-256 hash pub fn matches_sha256(&self, hash_hex: &str) -> bool { - self.subject.iter().any(|subject| { - subject - .digest - .sha256 - .as_ref() - .is_some_and(|h| h == hash_hex) - }) + self.subject + .iter() + .any(|subject| subject.digest.sha256.as_deref() == Some(hash_hex)) + } + + /// Check if any subject in the statement matches the given SHA-512 hash + pub fn matches_sha512(&self, hash_hex: &str) -> bool { + self.subject + .iter() + .any(|subject| subject.digest.sha512.as_deref() == Some(hash_hex)) } } @@ -107,7 +110,7 @@ mod tests { name: "file1.txt".to_string(), digest: Digest { sha256: Some("hash1".to_string()), - sha512: None, + sha512: Some("long-hash1".to_string()), }, }, Subject { @@ -125,6 +128,8 @@ mod tests { assert!(statement.matches_sha256("hash1")); assert!(statement.matches_sha256("hash2")); assert!(!statement.matches_sha256("hash3")); + assert!(statement.matches_sha512("long-hash1")); + assert!(!statement.matches_sha512("long-hash2")); } #[test] diff --git a/crates/sigstore-verify/src/verify.rs b/crates/sigstore-verify/src/verify.rs index 0dd20a55..053aa0af 100644 --- a/crates/sigstore-verify/src/verify.rs +++ b/crates/sigstore-verify/src/verify.rs @@ -499,10 +499,7 @@ fn verify_dsse_artifact_binding( Artifact::Bytes(bytes) => { let sha256 = hex::encode(sigstore_crypto::sha256(bytes)); let sha512 = hex::encode(sigstore_crypto::sha512(bytes)); - statement.subject.iter().any(|subject| { - subject.digest.sha256.as_deref() == Some(sha256.as_str()) - || subject.digest.sha512.as_deref() == Some(sha512.as_str()) - }) + statement.matches_sha256(&sha256) || statement.matches_sha512(&sha512) } Artifact::Digest(hash) => match hash.len() { 32 => { @@ -511,10 +508,7 @@ fn verify_dsse_artifact_binding( } 64 => { let digest = hex::encode(hash); - statement - .subject - .iter() - .any(|subject| subject.digest.sha512.as_deref() == Some(digest.as_str())) + statement.matches_sha512(&digest) } length => { return Err(Error::Verification(format!( From 6ba142c542202dc2efcdf000e9116912a2cf2fb3 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Thu, 13 Aug 2026 13:42:08 +0200 Subject: [PATCH 3/5] fix(verify): bind intoto entries to managed keys Pass the caller-supplied public key into Rekor consistency checks so canonical intoto entries remain safely verifiable for managed-key bundles. Signed-off-by: Wolf Vollprecht --- crates/sigstore-verify/src/verify.rs | 10 +- .../sigstore-verify/src/verify_impl/rekor.rs | 127 ++++++++++++++---- 2 files changed, 107 insertions(+), 30 deletions(-) diff --git a/crates/sigstore-verify/src/verify.rs b/crates/sigstore-verify/src/verify.rs index 053aa0af..e7665ff6 100644 --- a/crates/sigstore-verify/src/verify.rs +++ b/crates/sigstore-verify/src/verify.rs @@ -791,9 +791,13 @@ pub fn verify_with_key<'a>( // Verify the transparency log entries' consistency against the bundle's // other materials and the artifact (CVE-2022-36056 class), mirroring - // step 8 of `Verifier::verify`. Without this, a log entry whose body - // (hash, signature, verifier) disagrees with the bundle passes silently. - crate::verify_impl::verify_tlog_consistency(bundle, &artifact)?; + // step 8 of `Verifier::verify`. Pass the caller-supplied key so legacy + // intoto entries can bind their logged verifier in managed-key bundles. + crate::verify_impl::rekor::verify_tlog_consistency_with_key( + bundle, + &artifact, + Some(public_key), + )?; Ok(result) } diff --git a/crates/sigstore-verify/src/verify_impl/rekor.rs b/crates/sigstore-verify/src/verify_impl/rekor.rs index e81191b2..3758be8b 100644 --- a/crates/sigstore-verify/src/verify_impl/rekor.rs +++ b/crates/sigstore-verify/src/verify_impl/rekor.rs @@ -7,13 +7,25 @@ use crate::error::{Error, Result}; use base64::Engine; use sigstore_rekor::body::RekorEntryBody; use sigstore_types::bundle::VerificationMaterialContent; -use sigstore_types::{Bundle, HashAlgorithm, SignatureContent, TransparencyLogEntry}; +use sigstore_types::{ + Bundle, DerCertificate, DerPublicKey, HashAlgorithm, SignatureContent, TransparencyLogEntry, +}; use x509_cert::der::{Decode, Encode}; /// Verify that all log entries are consistent with the bundle's content and artifact pub fn verify_tlog_consistency( bundle: &Bundle, artifact: &sigstore_types::Artifact<'_>, +) -> Result<()> { + verify_tlog_consistency_with_key(bundle, artifact, None) +} + +/// Verify log-entry consistency when managed-key verification supplies the +/// public key that the bundle references only by hint. +pub(crate) fn verify_tlog_consistency_with_key( + bundle: &Bundle, + artifact: &sigstore_types::Artifact<'_>, + managed_key: Option<&DerPublicKey>, ) -> Result<()> { for entry in &bundle.verification_material.tlog_entries { match &bundle.content { @@ -42,7 +54,7 @@ pub fn verify_tlog_consistency( } }, "intoto" => match entry.kind_version.version.as_str() { - "0.0.2" => verify_intoto_v002(entry, envelope, bundle)?, + "0.0.2" => verify_intoto_v002(entry, envelope, bundle, managed_key)?, version => { return Err(Error::Verification(format!( "unsupported intoto entry version: {}", @@ -177,6 +189,7 @@ fn verify_intoto_v002( entry: &TransparencyLogEntry, envelope: &sigstore_types::DsseEnvelope, bundle: &Bundle, + managed_key: Option<&DerPublicKey>, ) -> Result<()> { let body = RekorEntryBody::from_base64_json( &entry.canonicalized_body.to_base64(), @@ -221,18 +234,28 @@ fn verify_intoto_v002( ))); } - let bundle_cert = match &bundle.verification_material.content { - VerificationMaterialContent::X509CertificateChain { certificates } => { - certificates.first().map(|cert| &cert.raw_bytes) - } - VerificationMaterialContent::Certificate(cert) => Some(&cert.raw_bytes), - VerificationMaterialContent::PublicKey { .. } => None, + enum ExpectedVerifier<'a> { + Certificate(&'a DerCertificate), + PublicKey(&'a DerPublicKey), } - .ok_or_else(|| { - Error::Verification( - "intoto Rekor signature cannot be bound to a bundle signing certificate".to_string(), - ) - })?; + + let expected_verifier = match &bundle.verification_material.content { + VerificationMaterialContent::X509CertificateChain { certificates } => certificates + .first() + .map(|cert| ExpectedVerifier::Certificate(&cert.raw_bytes)) + .ok_or_else(|| Error::Verification("bundle certificate chain is empty".to_string()))?, + VerificationMaterialContent::Certificate(cert) => { + ExpectedVerifier::Certificate(&cert.raw_bytes) + } + VerificationMaterialContent::PublicKey { .. } => { + ExpectedVerifier::PublicKey(managed_key.ok_or_else(|| { + Error::Verification( + "intoto Rekor signature cannot be bound without the managed public key" + .to_string(), + ) + })?) + } + }; let [rekor_sig] = rekor_envelope.signatures.as_slice() else { return Err(Error::Verification(format!( @@ -253,24 +276,26 @@ fn verify_intoto_v002( } let verifier_matches = match rekor_sig.to_certificate() { - Ok(rekor_cert) => bundle_cert.as_bytes() == rekor_cert.as_bytes(), + Ok(rekor_cert) => match expected_verifier { + ExpectedVerifier::Certificate(bundle_cert) => { + bundle_cert.as_bytes() == rekor_cert.as_bytes() + } + ExpectedVerifier::PublicKey(bundle_key) => { + certificate_public_key(&rekor_cert)?.as_bytes() == bundle_key.as_bytes() + } + }, Err(_) => { let rekor_key = rekor_sig .to_public_key() .map_err(|e| Error::Verification(e.to_string()))?; - let cert = x509_cert::Certificate::from_der(bundle_cert.as_bytes()).map_err(|e| { - Error::Verification(format!("failed to parse bundle certificate: {e}")) - })?; - let cert_key = cert - .tbs_certificate - .subject_public_key_info - .to_der() - .map_err(|e| { - Error::Verification(format!( - "failed to encode bundle certificate public key: {e}" - )) - })?; - rekor_key.as_bytes() == cert_key + match expected_verifier { + ExpectedVerifier::Certificate(bundle_cert) => { + rekor_key.as_bytes() == certificate_public_key(bundle_cert)?.as_bytes() + } + ExpectedVerifier::PublicKey(bundle_key) => { + rekor_key.as_bytes() == bundle_key.as_bytes() + } + } } }; if !verifier_matches { @@ -282,6 +307,21 @@ fn verify_intoto_v002( Ok(()) } +fn certificate_public_key(certificate: &DerCertificate) -> Result { + let certificate = x509_cert::Certificate::from_der(certificate.as_bytes()) + .map_err(|e| Error::Verification(format!("failed to parse signing certificate: {e}")))?; + let spki = certificate + .tbs_certificate + .subject_public_key_info + .to_der() + .map_err(|e| { + Error::Verification(format!( + "failed to encode signing certificate public key: {e}" + )) + })?; + Ok(DerPublicKey::new(spki)) +} + #[cfg(test)] mod tests { use super::*; @@ -312,6 +352,39 @@ mod tests { verify_consistency(&bundle()).unwrap(); } + #[test] + fn canonical_intoto_entry_binds_managed_public_key() { + let mut bundle = bundle(); + let certificate = match &bundle.verification_material.content { + VerificationMaterialContent::Certificate(cert) => cert.raw_bytes.clone(), + VerificationMaterialContent::X509CertificateChain { certificates } => { + certificates[0].raw_bytes.clone() + } + VerificationMaterialContent::PublicKey { .. } => { + panic!("fixture must use a certificate") + } + }; + let public_key = certificate_public_key(&certificate).unwrap(); + bundle.verification_material.content = VerificationMaterialContent::PublicKey { + hint: sigstore_crypto::sha256(public_key.as_bytes()).to_base64(), + }; + let digest = [0u8; 32]; + verify_tlog_consistency_with_key( + &bundle, + &Artifact::from_digest(&digest), + Some(&public_key), + ) + .unwrap(); + + let wrong_key = DerPublicKey::new(vec![1, 2, 3]); + assert!(verify_tlog_consistency_with_key( + &bundle, + &Artifact::from_digest(&digest), + Some(&wrong_key), + ) + .is_err()); + } + #[test] fn canonical_intoto_entry_binds_payload_hash() { let mut bundle = bundle(); From c8f834455584d079db32c382a2fde1a967fda156 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Wed, 19 Aug 2026 08:05:01 +0200 Subject: [PATCH 4/5] refactor(verify): simplify intoto verifier matching Signed-off-by: Wolf Vollprecht --- .../sigstore-verify/src/verify_impl/rekor.rs | 61 ++++++------------- 1 file changed, 18 insertions(+), 43 deletions(-) diff --git a/crates/sigstore-verify/src/verify_impl/rekor.rs b/crates/sigstore-verify/src/verify_impl/rekor.rs index 3758be8b..14693613 100644 --- a/crates/sigstore-verify/src/verify_impl/rekor.rs +++ b/crates/sigstore-verify/src/verify_impl/rekor.rs @@ -234,27 +234,19 @@ fn verify_intoto_v002( ))); } - enum ExpectedVerifier<'a> { - Certificate(&'a DerCertificate), - PublicKey(&'a DerPublicKey), - } - - let expected_verifier = match &bundle.verification_material.content { - VerificationMaterialContent::X509CertificateChain { certificates } => certificates - .first() - .map(|cert| ExpectedVerifier::Certificate(&cert.raw_bytes)) - .ok_or_else(|| Error::Verification("bundle certificate chain is empty".to_string()))?, - VerificationMaterialContent::Certificate(cert) => { - ExpectedVerifier::Certificate(&cert.raw_bytes) - } - VerificationMaterialContent::PublicKey { .. } => { - ExpectedVerifier::PublicKey(managed_key.ok_or_else(|| { - Error::Verification( - "intoto Rekor signature cannot be bound without the managed public key" - .to_string(), - ) - })?) + let expected_public_key = match &bundle.verification_material.content { + VerificationMaterialContent::X509CertificateChain { certificates } => { + let certificate = certificates.first().ok_or_else(|| { + Error::Verification("bundle certificate chain is empty".to_string()) + })?; + certificate_public_key(&certificate.raw_bytes)? } + VerificationMaterialContent::Certificate(cert) => certificate_public_key(&cert.raw_bytes)?, + VerificationMaterialContent::PublicKey { .. } => managed_key.cloned().ok_or_else(|| { + Error::Verification( + "intoto Rekor signature cannot be bound without the managed public key".to_string(), + ) + })?, }; let [rekor_sig] = rekor_envelope.signatures.as_slice() else { @@ -275,30 +267,13 @@ fn verify_intoto_v002( )); } - let verifier_matches = match rekor_sig.to_certificate() { - Ok(rekor_cert) => match expected_verifier { - ExpectedVerifier::Certificate(bundle_cert) => { - bundle_cert.as_bytes() == rekor_cert.as_bytes() - } - ExpectedVerifier::PublicKey(bundle_key) => { - certificate_public_key(&rekor_cert)?.as_bytes() == bundle_key.as_bytes() - } - }, - Err(_) => { - let rekor_key = rekor_sig - .to_public_key() - .map_err(|e| Error::Verification(e.to_string()))?; - match expected_verifier { - ExpectedVerifier::Certificate(bundle_cert) => { - rekor_key.as_bytes() == certificate_public_key(bundle_cert)?.as_bytes() - } - ExpectedVerifier::PublicKey(bundle_key) => { - rekor_key.as_bytes() == bundle_key.as_bytes() - } - } - } + let rekor_public_key = match rekor_sig.to_certificate() { + Ok(certificate) => certificate_public_key(&certificate)?, + Err(_) => rekor_sig + .to_public_key() + .map_err(|e| Error::Verification(e.to_string()))?, }; - if !verifier_matches { + if rekor_public_key.as_bytes() != expected_public_key.as_bytes() { return Err(Error::Verification( "DSSE signing certificate does not match the intoto Rekor verifier".to_string(), )); From 9541ae4f1b5dd9ee0490d14defcf4c55dee39121 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Tue, 18 Aug 2026 12:30:05 +0200 Subject: [PATCH 5/5] fix: adapt Rekor verification to typed artifact digests Signed-off-by: Wolf Vollprecht --- crates/sigstore-verify/src/verify.rs | 29 +++++++++---------- .../sigstore-verify/src/verify_impl/rekor.rs | 8 ++--- .../tests/verification_tests.rs | 14 +++++---- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/crates/sigstore-verify/src/verify.rs b/crates/sigstore-verify/src/verify.rs index e7665ff6..fde525e7 100644 --- a/crates/sigstore-verify/src/verify.rs +++ b/crates/sigstore-verify/src/verify.rs @@ -496,27 +496,24 @@ fn verify_dsse_artifact_binding( )); } let matches = match artifact { - Artifact::Bytes(bytes) => { + Artifact::Blob(bytes) => { let sha256 = hex::encode(sigstore_crypto::sha256(bytes)); let sha512 = hex::encode(sigstore_crypto::sha512(bytes)); statement.matches_sha256(&sha256) || statement.matches_sha512(&sha512) } - Artifact::Digest(hash) => match hash.len() { - 32 => { - let digest = hex::encode(hash); - statement.matches_sha256(&digest) - } - 64 => { - let digest = hex::encode(hash); - statement.matches_sha512(&digest) - } - length => { - return Err(Error::Verification(format!( - "unsupported pre-computed artifact digest length: {}", - length - ))) + Artifact::Digest(digest) => { + let value = hex::encode(digest.as_bytes()); + match digest.algorithm() { + HashAlgorithm::Sha2256 => statement.matches_sha256(&value), + HashAlgorithm::Sha2512 => statement.matches_sha512(&value), + algorithm => { + return Err(Error::Verification(format!( + "unsupported pre-computed artifact digest algorithm: {}", + algorithm + ))) + } } - }, + } }; if !matches { diff --git a/crates/sigstore-verify/src/verify_impl/rekor.rs b/crates/sigstore-verify/src/verify_impl/rekor.rs index 14693613..a6af00f2 100644 --- a/crates/sigstore-verify/src/verify_impl/rekor.rs +++ b/crates/sigstore-verify/src/verify_impl/rekor.rs @@ -300,7 +300,7 @@ fn certificate_public_key(certificate: &DerCertificate) -> Result #[cfg(test)] mod tests { use super::*; - use sigstore_types::{Artifact, Bundle, CanonicalizedBody}; + use sigstore_types::{Artifact, Bundle, CanonicalizedBody, Sha256Hash}; const CANONICAL_INTOTO_BUNDLE: &str = include_str!("../../test_data/bundles/sigstore.js@2.0.0-provenance.sigstore.json"); @@ -319,7 +319,7 @@ mod tests { fn verify_consistency(bundle: &Bundle) -> Result<()> { let digest = [0u8; 32]; - verify_tlog_consistency(bundle, &Artifact::from_digest(&digest)) + verify_tlog_consistency(bundle, &Artifact::from(Sha256Hash::from_bytes(digest))) } #[test] @@ -346,7 +346,7 @@ mod tests { let digest = [0u8; 32]; verify_tlog_consistency_with_key( &bundle, - &Artifact::from_digest(&digest), + &Artifact::from(Sha256Hash::from_bytes(digest)), Some(&public_key), ) .unwrap(); @@ -354,7 +354,7 @@ mod tests { let wrong_key = DerPublicKey::new(vec![1, 2, 3]); assert!(verify_tlog_consistency_with_key( &bundle, - &Artifact::from_digest(&digest), + &Artifact::from(Sha256Hash::from_bytes(digest)), Some(&wrong_key), ) .is_err()); diff --git a/crates/sigstore-verify/tests/verification_tests.rs b/crates/sigstore-verify/tests/verification_tests.rs index 052251ba..b5a8db09 100644 --- a/crates/sigstore-verify/tests/verification_tests.rs +++ b/crates/sigstore-verify/tests/verification_tests.rs @@ -3,7 +3,7 @@ //! These tests validate the complete verification flow using real bundles. use sigstore_trust_root::{SigstoreInstance, TrustedRoot, SIGSTORE_PRODUCTION_TRUSTED_ROOT}; -use sigstore_types::{DigestBytes, LogIndex, Sha256Hash}; +use sigstore_types::{ArtifactDigest, DigestBytes, HashAlgorithm, LogIndex, Sha256Hash}; use sigstore_verify::bundle::{validate_bundle, validate_bundle_with_options, ValidationOptions}; use sigstore_verify::types::Bundle; use sigstore_verify::{verify, VerificationPolicy, Verifier}; @@ -647,10 +647,14 @@ fn test_github_actions_provenance_bundle() { fn test_verify_github_actions_provenance_bundle() { let bundle = Bundle::from_json(SIGSTORE_JS_PROVENANCE).expect("Failed to parse provenance bundle"); - let artifact_digest = DigestBytes::from_bytes( - hex::decode("46d4e2f74c4877316640000a6fdf8a8b59f1e0847667973e9859f774dd31b8f1e0937813b777fb66a2ac67d50540fe34640966eee9fc2ccca387082b4c85cd3c") - .unwrap(), - ); + let artifact_digest = ArtifactDigest::new( + HashAlgorithm::Sha2512, + DigestBytes::from_bytes( + hex::decode("46d4e2f74c4877316640000a6fdf8a8b59f1e0847667973e9859f774dd31b8f1e0937813b777fb66a2ac67d50540fe34640966eee9fc2ccca387082b4c85cd3c") + .unwrap(), + ), + ) + .unwrap(); let result = verify( artifact_digest,