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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 45 additions & 4 deletions crates/sigstore-rekor/src/body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<IntotoSignature>,
}

#[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<String>,
/// 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<DerCertificate, crate::error::Error> {
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<DerPublicKey, crate::error::Error> {
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, crate::error::Error> {
String::from_utf8(self.public_key.as_bytes().to_vec()).map_err(|e| {
crate::error::Error::InvalidResponse(format!("PEM not valid UTF-8: {}", e))
})
}
}

// ============================================================================
Expand Down
21 changes: 13 additions & 8 deletions crates/sigstore-types/src/intoto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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]
Expand Down
45 changes: 31 additions & 14 deletions crates/sigstore-verify/src/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<'_>,
Expand All @@ -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)
Expand All @@ -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(),
));
Expand Down Expand Up @@ -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)
}
Expand Down
12 changes: 8 additions & 4 deletions crates/sigstore-verify/src/verify_impl/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand Down
Loading
Loading