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-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 d43915e7..fde525e7 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,28 @@ 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::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(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 { return Err(Error::Verification( "artifact hash does not match any subject in attestation".to_string(), )); @@ -775,9 +788,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/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..a6af00f2 100644 --- a/crates/sigstore-verify/src/verify_impl/rekor.rs +++ b/crates/sigstore-verify/src/verify_impl/rekor.rs @@ -7,12 +7,25 @@ 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, 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 { @@ -41,7 +54,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, managed_key)?, version => { return Err(Error::Verification(format!( "unsupported intoto entry version: {}", @@ -175,6 +188,8 @@ fn verify_dsse_v001( 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(), @@ -183,10 +198,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 +210,190 @@ 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 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 { + 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 the intoto Rekor signature".to_string(), + )); } - if !found_match { + 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 rekor_public_key.as_bytes() != expected_public_key.as_bytes() { return Err(Error::Verification( - "DSSE signature in bundle does not match intoto Rekor entry".to_string(), + "DSSE signing certificate does not match the intoto Rekor verifier".to_string(), )); } 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::*; + 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"); + + 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(Sha256Hash::from_bytes(digest))) + } + + #[test] + fn canonical_intoto_entry_matches_bundle() { + 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(Sha256Hash::from_bytes(digest)), + Some(&public_key), + ) + .unwrap(); + + let wrong_key = DerPublicKey::new(vec![1, 2, 3]); + assert!(verify_tlog_consistency_with_key( + &bundle, + &Artifact::from(Sha256Hash::from_bytes(digest)), + Some(&wrong_key), + ) + .is_err()); + } + + #[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..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::{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}; @@ -641,6 +641,30 @@ 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 = ArtifactDigest::new( + HashAlgorithm::Sha2512, + DigestBytes::from_bytes( + hex::decode("46d4e2f74c4877316640000a6fdf8a8b59f1e0847667973e9859f774dd31b8f1e0937813b777fb66a2ac67d50540fe34640966eee9fc2ccca387082b4c85cd3c") + .unwrap(), + ), + ) + .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]