use std::path::Path;
use async_trait::async_trait;
use base64::Engine;
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sigstore_verify::VerificationPolicy;
use sigstore_verify::trust_root::{SigstoreInstance, TrustedRoot};
use sigstore_verify::types::bundle::VerificationMaterialContent;
use sigstore_verify::types::{
Artifact, Bundle, DerCertificate, DerPublicKey, HashAlgorithm, Sha256Hash, SignatureBytes,
SignatureContent,
};
use thiserror::Error;
use tokio::io::AsyncReadExt;
const GITHUB_API_URL: &str = "https://api.github.com";
const USER_AGENT_VALUE: &str = "mise-sigstore/0.1.0";
#[derive(Debug, Error)]
pub enum AttestationError {
#[error("API error: {0}")]
Api(String),
#[error("Verification failed: {0}")]
Verification(String),
#[error("SLSA subject mismatch: {0}")]
SubjectMismatch(String),
#[error("Unsupported attestation format: {0}")]
UnsupportedFormat(String),
#[error("No attestations found")]
NoAttestations,
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Sigstore error: {0}")]
Sigstore(String),
}
impl From<sigstore_verify::Error> for AttestationError {
fn from(err: sigstore_verify::Error) -> Self {
AttestationError::Sigstore(err.to_string())
}
}
impl From<sigstore_verify::types::Error> for AttestationError {
fn from(err: sigstore_verify::types::Error) -> Self {
AttestationError::Sigstore(err.to_string())
}
}
impl From<sigstore_verify::trust_root::Error> for AttestationError {
fn from(err: sigstore_verify::trust_root::Error) -> Self {
AttestationError::Sigstore(err.to_string())
}
}
pub type Result<T> = std::result::Result<T, AttestationError>;
#[derive(Debug, Clone)]
pub struct SlsaArtifact {
pub name: String,
pub sha256: String,
}
impl SlsaArtifact {
pub fn from_bytes(name: String, bytes: &[u8]) -> Self {
Self {
name,
sha256: hex::encode(Sha256::digest(bytes)),
}
}
}
#[derive(Debug, Clone)]
pub struct ArtifactRef {
digest: String,
}
impl ArtifactRef {
pub fn from_digest(digest: &str) -> Self {
if digest.contains(':') {
Self {
digest: digest.to_string(),
}
} else {
Self {
digest: format!("sha256:{digest}"),
}
}
}
}
#[async_trait]
pub trait AttestationSource {
async fn fetch_attestations(&self, artifact: &ArtifactRef) -> Result<Vec<Attestation>>;
}
pub mod sources {
pub use crate::{ArtifactRef, AttestationSource};
pub mod github {
pub use crate::GitHubSource;
}
}
#[derive(Debug, Clone)]
pub struct GitHubSource {
client: AttestationClient,
owner: String,
repo: String,
}
impl GitHubSource {
pub fn new(owner: &str, repo: &str, token: Option<&str>) -> Result<Self> {
let mut builder = AttestationClient::builder();
if let Some(token) = token {
builder = builder.github_token(token);
}
Ok(Self {
client: builder.build()?,
owner: owner.to_string(),
repo: repo.to_string(),
})
}
pub fn with_base_url(
owner: &str,
repo: &str,
token: Option<&str>,
base_url: &str,
) -> Result<Self> {
let mut builder = AttestationClient::builder().base_url(base_url);
if let Some(token) = token {
builder = builder.github_token(token);
}
Ok(Self {
client: builder.build()?,
owner: owner.to_string(),
repo: repo.to_string(),
})
}
}
#[async_trait]
impl AttestationSource for GitHubSource {
async fn fetch_attestations(&self, artifact: &ArtifactRef) -> Result<Vec<Attestation>> {
self.client
.fetch_attestations(FetchParams {
owner: self.owner.clone(),
repo: Some(format!("{}/{}", self.owner, self.repo)),
digest: artifact.digest.clone(),
limit: 30,
predicate_type: None,
})
.await
}
}
#[derive(Debug, Clone)]
pub struct AttestationClient {
client: reqwest::Client,
base_url: String,
github_token: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct AttestationClientBuilder {
base_url: Option<String>,
github_token: Option<String>,
}
impl AttestationClientBuilder {
pub fn base_url(mut self, url: &str) -> Self {
self.base_url = Some(url.trim_end_matches('/').to_string());
self
}
pub fn github_token(mut self, token: &str) -> Self {
self.github_token = Some(token.to_string());
self
}
pub fn build(self) -> Result<AttestationClient> {
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static(USER_AGENT_VALUE));
let client = reqwest::Client::builder()
.default_headers(headers)
.build()?;
Ok(AttestationClient {
client,
base_url: self.base_url.unwrap_or_else(|| GITHUB_API_URL.to_string()),
github_token: self.github_token,
})
}
}
#[derive(Debug, Serialize)]
pub struct FetchParams {
pub owner: String,
pub repo: Option<String>,
pub digest: String,
pub limit: usize,
pub predicate_type: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AttestationsResponse {
attestations: Vec<Attestation>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Attestation {
bundle: Option<serde_json::Value>,
bundle_url: Option<String>,
}
impl AttestationClient {
pub fn builder() -> AttestationClientBuilder {
AttestationClientBuilder::default()
}
fn github_headers(&self, url: &str) -> Result<HeaderMap> {
let mut headers = HeaderMap::new();
let base_with_slash = format!("{}/", self.base_url);
if url == self.base_url || url.starts_with(&base_with_slash) {
if let Some(token) = &self.github_token {
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {token}"))
.map_err(|e| AttestationError::Api(e.to_string()))?,
);
}
headers.insert(
"x-github-api-version",
HeaderValue::from_static("2022-11-28"),
);
}
Ok(headers)
}
pub async fn fetch_attestations(&self, params: FetchParams) -> Result<Vec<Attestation>> {
let url = if let Some(repo) = ¶ms.repo {
format!(
"{}/repos/{repo}/attestations/{}",
self.base_url, params.digest
)
} else {
format!(
"{}/orgs/{}/attestations/{}",
self.base_url, params.owner, params.digest
)
};
let mut query_params = vec![("per_page", params.limit.to_string())];
if let Some(predicate_type) = ¶ms.predicate_type {
query_params.push(("predicate_type", predicate_type.clone()));
}
let url = reqwest::Url::parse_with_params(&url, query_params)
.map_err(|e| AttestationError::Api(format!("Invalid GitHub attestations URL: {e}")))?;
let response = self
.client
.get(url.clone())
.headers(self.github_headers(url.as_str())?)
.send()
.await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(vec![]);
}
if !response.status().is_success() {
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(AttestationError::Api(format!(
"GitHub API returned {status}: {body}"
)));
}
let response: AttestationsResponse = response.json().await?;
let mut attestations = Vec::new();
for attestation in response.attestations {
if attestation.bundle.is_some() {
attestations.push(attestation);
} else if let Some(bundle_url) = &attestation.bundle_url {
let bundle = self.fetch_bundle_url(bundle_url).await?;
attestations.push(Attestation {
bundle: Some(bundle),
bundle_url: Some(bundle_url.clone()),
});
}
}
Ok(attestations)
}
async fn fetch_bundle_url(&self, bundle_url: &str) -> Result<serde_json::Value> {
let response = self
.client
.get(bundle_url)
.headers(self.github_headers(bundle_url)?)
.send()
.await?;
if !response.status().is_success() {
return Err(AttestationError::Api(format!(
"bundle URL returned {}",
response.status()
)));
}
if is_snappy_content_type(&response) {
let bytes = response.bytes().await?;
let decompressed = snap::raw::Decoder::new()
.decompress_vec(&bytes)
.map_err(|e| AttestationError::Api(format!("Snappy decompression failed: {e}")))?;
serde_json::from_slice(&decompressed).map_err(AttestationError::Json)
} else {
response.json().await.map_err(AttestationError::Http)
}
}
}
pub async fn verify_github_attestation(
artifact_path: &Path,
owner: &str,
repo: &str,
token: Option<&str>,
signer_workflow: Option<&str>,
) -> Result<bool> {
verify_github_attestation_inner(artifact_path, owner, repo, token, signer_workflow, None).await
}
pub async fn verify_github_attestation_with_base_url(
artifact_path: &Path,
owner: &str,
repo: &str,
token: Option<&str>,
signer_workflow: Option<&str>,
base_url: &str,
) -> Result<bool> {
verify_github_attestation_inner(
artifact_path,
owner,
repo,
token,
signer_workflow,
Some(base_url),
)
.await
}
async fn verify_github_attestation_inner(
artifact_path: &Path,
owner: &str,
repo: &str,
token: Option<&str>,
signer_workflow: Option<&str>,
base_url: Option<&str>,
) -> Result<bool> {
let mut builder = AttestationClient::builder();
if let Some(token) = token {
builder = builder.github_token(token);
}
if let Some(base_url) = base_url {
builder = builder.base_url(base_url);
}
let client = builder.build()?;
let digest = calculate_file_digest_async(artifact_path).await?;
let attestations = client
.fetch_attestations(FetchParams {
owner: owner.to_string(),
repo: Some(format!("{owner}/{repo}")),
digest: format!("sha256:{digest}"),
limit: 30,
predicate_type: None,
})
.await?;
if attestations.is_empty() {
return Err(AttestationError::NoAttestations);
}
let artifact = tokio::fs::read(artifact_path).await?;
let mut trust_roots = TrustRoots::default();
verify_attestation_bundles(&attestations, &artifact, signer_workflow, &mut trust_roots).await
}
pub async fn verify_cosign_signature(
artifact_path: &Path,
sig_or_bundle_path: &Path,
) -> Result<bool> {
let content = tokio::fs::read_to_string(sig_or_bundle_path).await?;
let artifact = tokio::fs::read(artifact_path).await?;
let mut trust_roots = TrustRoots::default();
if let Ok(bundle) = Bundle::from_json(&content) {
let trusted_root = trust_roots.for_bundle(&bundle).await?;
verify_bundle(&artifact, &bundle, None, trusted_root)?;
return Ok(true);
}
let trusted_root = trust_roots.sigstore_root().await?;
verify_legacy_cosign_bundle(&artifact, &content, trusted_root)?;
Ok(true)
}
pub async fn verify_cosign_signature_with_key(
artifact_path: &Path,
sig_or_bundle_path: &Path,
public_key_path: &Path,
) -> Result<bool> {
let key_pem = tokio::fs::read_to_string(public_key_path).await?;
let public_key = DerPublicKey::from_pem(&key_pem)?;
let raw_bytes = tokio::fs::read(sig_or_bundle_path).await?;
let bundle = std::str::from_utf8(&raw_bytes)
.ok()
.and_then(|content| Bundle::from_json(content).ok());
if let Some(bundle) = bundle {
if matches!(
&bundle.verification_material.content,
VerificationMaterialContent::PublicKey { .. }
) {
let artifact = tokio::fs::read(artifact_path).await?;
verify_public_key_bundle(&artifact, &bundle, &public_key)?;
return Ok(true);
}
let trusted_root = production_trusted_root().await?;
let artifact = tokio::fs::read(artifact_path).await?;
let result = sigstore_verify::verify_with_key(
artifact.as_slice(),
&bundle,
&public_key,
&trusted_root,
)?;
if !result.success {
return Err(AttestationError::Verification(
"sigstore verification returned false".to_string(),
));
}
return Ok(true);
}
let artifact = tokio::fs::read(artifact_path).await?;
let signature = decode_cosign_signature(&raw_bytes);
verify_raw_signature(&artifact, &signature, &public_key)?;
Ok(true)
}
fn verify_public_key_bundle(
artifact: &[u8],
bundle: &Bundle,
public_key: &DerPublicKey,
) -> Result<()> {
use sigstore_verify::bundle::{ValidationOptions, validate_bundle_with_options};
use sigstore_verify::crypto::{
KeyType, SigningScheme, detect_key_type, verify_signature, verify_signature_prehashed,
};
validate_bundle_with_options(
bundle,
&ValidationOptions {
require_inclusion_proof: true,
require_timestamp: false,
},
)
.map_err(|e| AttestationError::Verification(format!("bundle validation failed: {e}")))?;
let scheme = match detect_key_type(public_key) {
KeyType::Ed25519 => SigningScheme::Ed25519,
KeyType::EcdsaP256 => SigningScheme::EcdsaP256Sha256,
KeyType::Unknown => {
return Err(AttestationError::Verification(
"unsupported or unrecognized public key type".to_string(),
));
}
};
match &bundle.content {
SignatureContent::MessageSignature(msg_sig) => {
let artifact_hash = Sha256Hash::try_from_slice(&Sha256::digest(artifact))?;
if let Some(digest) = &msg_sig.message_digest {
if digest.algorithm != HashAlgorithm::Sha2256 {
return Err(AttestationError::Verification(format!(
"unsupported message digest algorithm {}",
digest.algorithm
)));
}
if digest.digest != artifact_hash {
return Err(AttestationError::Verification(
"message digest in bundle does not match artifact hash".to_string(),
));
}
}
if scheme.uses_sha256() && scheme.supports_prehashed() {
verify_signature_prehashed(public_key, &artifact_hash, &msg_sig.signature, scheme)
} else {
verify_signature(public_key, artifact, &msg_sig.signature, scheme)
}
.map_err(|e| {
AttestationError::Verification(format!("signature verification failed: {e}"))
})?;
}
SignatureContent::DsseEnvelope(envelope) => {
let payload = envelope.decode_payload();
let pae = sigstore_verify::types::pae(&envelope.payload_type, &payload);
if !envelope
.signatures
.iter()
.any(|sig| verify_signature(public_key, &pae, &sig.sig, scheme).is_ok())
{
return Err(AttestationError::Verification(
"DSSE signature verification failed: no valid signatures found".to_string(),
));
}
}
}
Ok(())
}
pub async fn verify_slsa_provenance(
artifact_path: &Path,
provenance_path: &Path,
min_level: u8,
) -> Result<bool> {
let artifact = tokio::fs::read(artifact_path).await?;
verify_slsa_provenance_artifacts(
provenance_path,
&[SlsaArtifact::from_bytes(String::new(), &artifact)],
min_level,
)
.await
}
pub async fn verify_slsa_provenance_artifacts(
provenance_path: &Path,
artifacts: &[SlsaArtifact],
min_level: u8,
) -> Result<bool> {
if artifacts.is_empty() {
return Err(AttestationError::SubjectMismatch(
"no artifacts supplied for SLSA subject verification".to_string(),
));
}
let content = tokio::fs::read_to_string(provenance_path).await?;
let mut errors = Vec::new();
let mut trust_roots = TrustRoots::default();
let mut candidates: Vec<&str> = content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect();
let trimmed = content.trim();
if !trimmed.is_empty() && !candidates.contains(&trimmed) {
candidates.push(trimmed);
}
for candidate in candidates {
if let Ok(bundle) = Bundle::from_json(candidate) {
let result = match trust_roots.for_bundle(&bundle).await {
Ok(root) => verify_bundle_for_any_artifact(artifacts, &bundle, root)
.and_then(|_| verify_bundle_slsa_subjects(&bundle, artifacts, min_level)),
Err(e) => Err(e),
};
match result {
Ok(()) => return Ok(true),
Err(e) => errors.push(e),
}
continue;
}
let result = match trust_roots.sigstore_root().await {
Ok(root) => verify_intoto_envelope_subjects(candidate, artifacts, min_level, root),
Err(e) => Err(e),
};
match result {
Ok(()) => return Ok(true),
Err(e) => errors.push(e),
}
}
collapse_slsa_errors(errors, || {
"File does not contain valid attestations or SLSA provenance".to_string()
})
}
#[cfg(test)]
fn verify_intoto_envelope(
line: &str,
artifact: &[u8],
min_level: u8,
trusted_root: &TrustedRoot,
) -> Result<()> {
verify_intoto_envelope_subjects(
line,
&[SlsaArtifact::from_bytes(String::new(), artifact)],
min_level,
trusted_root,
)
}
fn verify_intoto_envelope_subjects(
line: &str,
artifacts: &[SlsaArtifact],
min_level: u8,
trusted_root: &TrustedRoot,
) -> Result<()> {
let envelope: serde_json::Value = serde_json::from_str(line).map_err(|e| {
AttestationError::UnsupportedFormat(format!("not a JSON DSSE envelope: {e}"))
})?;
let payload_type = envelope
.get("payloadType")
.and_then(|v| v.as_str())
.unwrap_or_default();
if payload_type != "application/vnd.in-toto+json" {
return Err(AttestationError::UnsupportedFormat(format!(
"unsupported DSSE payloadType: {payload_type}"
)));
}
let payload_b64 = envelope
.get("payload")
.and_then(|v| v.as_str())
.ok_or_else(|| {
AttestationError::UnsupportedFormat("DSSE envelope missing payload".to_string())
})?;
let payload = base64::engine::general_purpose::STANDARD
.decode(payload_b64.as_bytes())
.map_err(|e| AttestationError::Verification(format!("invalid base64 payload: {e}")))?;
let signatures = envelope
.get("signatures")
.and_then(|v| v.as_array())
.ok_or_else(|| {
AttestationError::Verification("DSSE envelope missing signatures".to_string())
})?;
if signatures.is_empty() {
return Err(AttestationError::Verification(
"DSSE envelope has no signatures".to_string(),
));
}
let pae = sigstore_verify::types::pae(payload_type, &payload);
let mut sig_errors = Vec::new();
let mut verified = false;
for sig in signatures {
match verify_dsse_signature(sig, &pae, trusted_root) {
Ok(()) => {
verified = true;
break;
}
Err(e) => sig_errors.push(e.to_string()),
}
}
if !verified {
return Err(AttestationError::Verification(format!(
"no valid DSSE signature: {}",
join_error_strings(sig_errors, || "no signatures could be verified".to_string())
)));
}
verify_intoto_payload_subjects(&payload, artifacts, min_level)
}
fn verify_legacy_cosign_bundle(
artifact: &[u8],
bundle_json: &str,
trusted_root: &TrustedRoot,
) -> Result<()> {
let value: serde_json::Value = serde_json::from_str(bundle_json).map_err(|e| {
AttestationError::UnsupportedFormat(format!("not a sigstore or cosign bundle: {e}"))
})?;
let cert_b64 = value.get("cert").and_then(|v| v.as_str()).ok_or_else(|| {
AttestationError::UnsupportedFormat("legacy cosign bundle missing cert".to_string())
})?;
let sig_b64 = value
.get("base64Signature")
.and_then(|v| v.as_str())
.ok_or_else(|| {
AttestationError::UnsupportedFormat(
"legacy cosign bundle missing base64Signature".to_string(),
)
})?;
let cert_pem_bytes = base64::engine::general_purpose::STANDARD
.decode(cert_b64.as_bytes())
.map_err(|e| {
AttestationError::Verification(format!("invalid base64 cert in legacy bundle: {e}"))
})?;
let cert_pem = std::str::from_utf8(&cert_pem_bytes).map_err(|e| {
AttestationError::Verification(format!("legacy cosign cert is not UTF-8 PEM: {e}"))
})?;
let cert = DerCertificate::from_pem(cert_pem)?;
verify_cert_chain(cert.as_bytes(), trusted_root)?;
let sig_bytes = base64::engine::general_purpose::STANDARD
.decode(sig_b64.as_bytes())
.map_err(|e| AttestationError::Verification(format!("invalid base64 signature: {e}")))?;
let spki_der = extract_spki_der(cert.as_bytes())?;
let public_key = DerPublicKey::new(spki_der);
verify_raw_signature(artifact, &sig_bytes, &public_key)
}
fn verify_dsse_signature(
sig: &serde_json::Value,
pae: &[u8],
trusted_root: &TrustedRoot,
) -> Result<()> {
let cert_pem = sig.get("cert").and_then(|v| v.as_str()).ok_or_else(|| {
AttestationError::Verification("DSSE signature missing cert field".to_string())
})?;
let sig_b64 = sig.get("sig").and_then(|v| v.as_str()).ok_or_else(|| {
AttestationError::Verification("DSSE signature missing sig field".to_string())
})?;
let sig_bytes = base64::engine::general_purpose::STANDARD
.decode(sig_b64.as_bytes())
.map_err(|e| AttestationError::Verification(format!("invalid base64 signature: {e}")))?;
let cert = DerCertificate::from_pem(cert_pem)?;
verify_cert_chain(cert.as_bytes(), trusted_root)?;
let spki_der = extract_spki_der(cert.as_bytes())?;
let public_key = DerPublicKey::new(spki_der);
verify_raw_signature(pae, &sig_bytes, &public_key)
}
fn verify_intoto_payload_subjects(
payload: &[u8],
artifacts: &[SlsaArtifact],
min_level: u8,
) -> Result<()> {
let statement: serde_json::Value = serde_json::from_slice(payload).map_err(|e| {
AttestationError::Verification(format!("Failed to parse SLSA payload: {e}"))
})?;
let predicate_type = statement
.get("predicateType")
.and_then(|v| v.as_str())
.unwrap_or_default();
if !predicate_type.starts_with("https://slsa.dev/provenance/") {
return Err(AttestationError::UnsupportedFormat(format!(
"Not an SLSA provenance predicate: {predicate_type}"
)));
}
if min_level > 1 {
return Err(AttestationError::Verification(format!(
"SLSA level {min_level} verification is not supported by the native adapter"
)));
}
let subjects = statement
.get("subject")
.and_then(|v| v.as_array())
.ok_or_else(|| {
AttestationError::Verification("SLSA statement missing subject array".to_string())
})?;
let subject_digests = subjects
.iter()
.filter_map(|subject| {
subject
.get("digest")
.and_then(|d| d.get("sha256"))
.and_then(|v| v.as_str())
.map(|sha| sha.to_ascii_lowercase())
})
.collect::<std::collections::HashSet<_>>();
let named_subjects = subjects
.iter()
.filter_map(|subject| {
let name = subject.get("name")?.as_str()?;
let sha = subject
.get("digest")
.and_then(|d| d.get("sha256"))
.and_then(|v| v.as_str())?;
Some((name.to_string(), sha.to_ascii_lowercase()))
})
.collect::<std::collections::HashSet<_>>();
let mut missing = Vec::new();
for artifact in artifacts {
let artifact_digest = artifact.sha256.to_ascii_lowercase();
let matches_subject = if artifact.name.is_empty() {
subject_digests.contains(&artifact_digest)
} else {
named_subjects.contains(&(artifact.name.clone(), artifact_digest.clone()))
};
if !matches_subject {
if artifact.name.is_empty() {
missing.push(artifact_digest);
} else {
missing.push(format!("{} ({artifact_digest})", artifact.name));
}
}
}
if !missing.is_empty() {
return Err(AttestationError::SubjectMismatch(format!(
"artifact subjects not found in SLSA statement subjects: {}",
missing.join(", ")
)));
}
Ok(())
}
fn collapse_slsa_errors(
errors: Vec<AttestationError>,
default: impl FnOnce() -> String,
) -> Result<bool> {
let unsupported_format = errors
.iter()
.all(|error| matches!(error, AttestationError::UnsupportedFormat(_)));
let subject_mismatch = errors.iter().any(is_slsa_subject_mismatch);
let message = join_error_strings(
errors.into_iter().map(|error| error.to_string()).collect(),
default,
);
Err(if unsupported_format {
AttestationError::UnsupportedFormat(message)
} else if subject_mismatch {
AttestationError::SubjectMismatch(message)
} else {
AttestationError::Verification(message)
})
}
pub fn is_slsa_subject_mismatch(error: &AttestationError) -> bool {
match error {
AttestationError::SubjectMismatch(_) => true,
AttestationError::Verification(msg) | AttestationError::Sigstore(msg) => {
is_subject_mismatch_message(msg)
}
_ => false,
}
}
fn is_subject_mismatch_message(message: &str) -> bool {
message.contains("artifact hash does not match any subject in attestation")
|| message.contains("not found in SLSA statement subjects")
|| message.contains("artifact subjects not found in SLSA statement subjects")
}
fn join_error_strings(errors: Vec<String>, default: impl FnOnce() -> String) -> String {
let mut errors = errors
.into_iter()
.filter(|error| !error.trim().is_empty())
.collect::<Vec<_>>();
errors.dedup();
if errors.is_empty() {
default()
} else {
errors.join("; ")
}
}
async fn verify_attestation_bundles(
attestations: &[Attestation],
artifact: &[u8],
signer_workflow: Option<&str>,
trust_roots: &mut TrustRoots,
) -> Result<bool> {
let mut errors = Vec::new();
for attestation in attestations {
let Some(bundle_value) = &attestation.bundle else {
continue;
};
let bundle = match serde_json::from_value::<Bundle>(bundle_value.clone()) {
Ok(bundle) => bundle,
Err(e) => {
errors.push(e.to_string());
continue;
}
};
let trusted_root = match trust_roots.for_bundle(&bundle).await {
Ok(root) => root,
Err(e) => {
errors.push(e.to_string());
continue;
}
};
match verify_bundle(artifact, &bundle, signer_workflow, trusted_root) {
Ok(()) => return Ok(true),
Err(e) => errors.push(e.to_string()),
}
}
Err(AttestationError::Verification(join_error_strings(
errors,
|| "No valid attestations found".to_string(),
)))
}
fn is_snappy_content_type(response: &reqwest::Response) -> bool {
response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.and_then(|content_type| content_type.split(';').next())
.is_some_and(|content_type| content_type.trim() == "application/x-snappy")
}
fn verify_bundle<'a>(
artifact: impl Into<Artifact<'a>>,
bundle: &Bundle,
signer_workflow: Option<&str>,
trusted_root: &TrustedRoot,
) -> Result<()> {
let mut policy = VerificationPolicy::default();
if !bundle.has_inclusion_proof() {
policy = policy.skip_tlog();
}
if is_github_internal_certificate(bundle) {
policy = policy.skip_sct();
}
let result = sigstore_verify::verify(artifact, bundle, &policy, trusted_root)?;
if !result.success {
return Err(AttestationError::Verification(
"sigstore verification returned false".to_string(),
));
}
verify_signer_workflow_identity(result.identity.as_deref(), signer_workflow)?;
Ok(())
}
fn is_github_internal_certificate(bundle: &Bundle) -> bool {
bundle
.signing_certificate()
.map(|cert| cert_issuer_organization(cert.as_bytes()).as_deref() == Some("GitHub, Inc."))
.unwrap_or(false)
}
fn verify_cert_chain(leaf_der: &[u8], trusted_root: &TrustedRoot) -> Result<()> {
use rustls_pki_types::{CertificateDer, UnixTime};
use webpki::{ALL_VERIFICATION_ALGS, EndEntityCert, KeyUsage, anchor_from_trusted_cert};
use x509_cert::Certificate;
use x509_cert::der::Decode;
let leaf = Certificate::from_der(leaf_der).map_err(|e| {
AttestationError::Verification(format!("failed to parse leaf certificate: {e}"))
})?;
let not_after = leaf
.tbs_certificate
.validity
.not_after
.to_unix_duration()
.as_secs();
let validation_time = UnixTime::since_unix_epoch(std::time::Duration::from_secs(not_after));
let all_certs = trusted_root.fulcio_certs().map_err(|e| {
AttestationError::Verification(format!("failed to load CA certs from trust root: {e}"))
})?;
if all_certs.is_empty() {
return Err(AttestationError::Verification(
"trust root contains no CA certificates".to_string(),
));
}
let trust_anchors: Vec<_> = all_certs
.iter()
.filter_map(|der| {
anchor_from_trusted_cert(&CertificateDer::from(der.as_ref()))
.map(|a| a.to_owned())
.ok()
})
.collect();
if trust_anchors.is_empty() {
return Err(AttestationError::Verification(
"trust root CA certs are unparseable".to_string(),
));
}
let intermediate_certs: Vec<CertificateDer<'static>> = all_certs
.iter()
.map(|der| CertificateDer::from(der.as_ref()).into_owned())
.collect();
let leaf_der_ref = CertificateDer::from(leaf_der);
let leaf_cert = EndEntityCert::try_from(&leaf_der_ref).map_err(|e| {
AttestationError::Verification(format!("failed to parse leaf for chain check: {e}"))
})?;
const ID_KP_CODE_SIGNING: &[u8] = &[0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x03];
leaf_cert
.verify_for_usage(
ALL_VERIFICATION_ALGS,
&trust_anchors,
&intermediate_certs,
validation_time,
KeyUsage::required(ID_KP_CODE_SIGNING),
None,
None,
)
.map_err(|e| {
AttestationError::Verification(format!("certificate chain validation failed: {e}"))
})?;
Ok(())
}
fn cert_issuer_organization(cert_der: &[u8]) -> Option<String> {
use x509_cert::Certificate;
use x509_cert::der::Decode;
let cert = Certificate::from_der(cert_der).ok()?;
for rdn in cert.tbs_certificate.issuer.0.iter() {
for atv in rdn.0.iter() {
if atv.oid.to_string() == "2.5.4.10" {
if let Ok(s) = atv.value.decode_as::<String>() {
return Some(s);
}
if let Ok(s) = atv
.value
.decode_as::<x509_cert::der::asn1::PrintableStringRef>()
{
return Some(s.as_str().to_string());
}
if let Ok(s) = atv.value.decode_as::<x509_cert::der::asn1::Utf8StringRef>() {
return Some(s.as_str().to_string());
}
}
}
}
None
}
fn extract_spki_der(cert_der: &[u8]) -> Result<Vec<u8>> {
use x509_cert::Certificate;
use x509_cert::der::{Decode, Encode};
let cert = Certificate::from_der(cert_der)
.map_err(|e| AttestationError::Verification(format!("failed to parse certificate: {e}")))?;
cert.tbs_certificate
.subject_public_key_info
.to_der()
.map_err(|e| {
AttestationError::Verification(format!("failed to encode SubjectPublicKeyInfo: {e}"))
})
}
async fn production_trusted_root() -> Result<TrustedRoot> {
Ok(TrustedRoot::production().await?)
}
fn github_trusted_root() -> Result<TrustedRoot> {
Ok(TrustedRoot::from_embedded(SigstoreInstance::GitHub)?)
}
#[derive(Default)]
struct TrustRoots {
sigstore: Option<TrustedRoot>,
github: Option<TrustedRoot>,
}
impl TrustRoots {
async fn for_bundle(&mut self, bundle: &Bundle) -> Result<&TrustedRoot> {
if is_github_internal_certificate(bundle) {
self.github_root()
} else {
self.sigstore_root().await
}
}
async fn sigstore_root(&mut self) -> Result<&TrustedRoot> {
if self.sigstore.is_none() {
self.sigstore = Some(production_trusted_root().await?);
}
Ok(self.sigstore.as_ref().unwrap())
}
fn github_root(&mut self) -> Result<&TrustedRoot> {
if self.github.is_none() {
self.github = Some(github_trusted_root()?);
}
Ok(self.github.as_ref().unwrap())
}
}
fn verify_signer_workflow_identity(
identity: Option<&str>,
signer_workflow: Option<&str>,
) -> Result<()> {
let Some(expected) = signer_workflow else {
return Ok(());
};
let Some(identity) = identity.filter(|identity| !identity.is_empty()) else {
return Err(AttestationError::Verification(format!(
"Workflow verification failed: expected '{expected}', found no certificate identity"
)));
};
if !identity.contains(expected) {
return Err(AttestationError::Verification(format!(
"Workflow verification failed: expected '{expected}', found certificate identity: {identity:?}"
)));
}
Ok(())
}
fn verify_bundle_for_any_artifact(
artifacts: &[SlsaArtifact],
bundle: &Bundle,
root: &TrustedRoot,
) -> Result<()> {
let artifact = artifacts.first().ok_or_else(|| {
AttestationError::SubjectMismatch(
"no artifacts supplied for SLSA subject verification".to_string(),
)
})?;
let digest = Sha256Hash::from_hex(&artifact.sha256).map_err(|e| {
AttestationError::Verification(format!("invalid artifact sha256 digest: {e}"))
})?;
match verify_bundle(Artifact::from_digest(digest), bundle, None, root) {
Ok(()) => Ok(()),
Err(e) if is_slsa_subject_mismatch(&e) => {
Err(AttestationError::SubjectMismatch(e.to_string()))
}
Err(e) => Err(e),
}
}
fn verify_bundle_slsa_subjects(
bundle: &Bundle,
artifacts: &[SlsaArtifact],
min_level: u8,
) -> Result<()> {
let payload = match &bundle.content {
sigstore_verify::types::SignatureContent::DsseEnvelope(envelope) => {
envelope.decode_payload()
}
_ => {
return Err(AttestationError::UnsupportedFormat(
"SLSA provenance must be a DSSE envelope".to_string(),
));
}
};
verify_intoto_payload_subjects(&payload, artifacts, min_level)
}
fn decode_cosign_signature(bytes: &[u8]) -> Vec<u8> {
let trimmed = String::from_utf8_lossy(bytes).trim().to_string();
if let Some(decoded) = base64::engine::general_purpose::STANDARD
.decode(trimmed.as_bytes())
.ok()
.filter(|_| !trimmed.is_empty())
{
return decoded;
}
bytes.to_vec()
}
fn verify_raw_signature(
artifact: &[u8],
signature: &[u8],
public_key: &DerPublicKey,
) -> Result<()> {
use sigstore_verify::crypto::{KeyType, SigningScheme, detect_key_type, verify_signature};
let scheme = match detect_key_type(public_key) {
KeyType::Ed25519 => SigningScheme::Ed25519,
KeyType::EcdsaP256 => SigningScheme::EcdsaP256Sha256,
KeyType::Unknown => {
return Err(AttestationError::Verification(
"unsupported or unrecognized public key type".to_string(),
));
}
};
let signature = SignatureBytes::from_bytes(signature);
verify_signature(public_key, artifact, &signature, scheme)
.map_err(|e| AttestationError::Verification(format!("signature verification failed: {e}")))
}
async fn calculate_file_digest_async(path: &Path) -> Result<String> {
let mut file = tokio::fs::File::open(path).await?;
let mut hasher = Sha256::new();
let mut buffer = [0; 8192];
loop {
let read = file.read(&mut buffer).await?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(hex::encode(hasher.finalize()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn signer_workflow_requires_identity() {
let err = verify_signer_workflow_identity(None, Some(".github/workflows/release.yml"))
.unwrap_err()
.to_string();
assert!(err.contains("found no certificate identity"));
}
#[test]
fn signer_workflow_rejects_mismatch() {
let err = verify_signer_workflow_identity(
Some("https://github.com/jdx/mise/.github/workflows/ci.yml@refs/tags/v1.0.0"),
Some(".github/workflows/release.yml"),
)
.unwrap_err()
.to_string();
assert!(err.contains("Workflow verification failed"));
}
#[test]
fn signer_workflow_accepts_match() {
verify_signer_workflow_identity(
Some("https://github.com/jdx/mise/.github/workflows/release.yml@refs/tags/v1.0.0"),
Some(".github/workflows/release.yml"),
)
.unwrap();
}
#[test]
fn signer_workflow_rejects_expected_containing_identity() {
let err = verify_signer_workflow_identity(
Some(".github/workflows/release.yml"),
Some("https://github.com/jdx/mise/.github/workflows/release.yml@refs/tags/v1.0.0"),
)
.unwrap_err()
.to_string();
assert!(err.contains("Workflow verification failed"));
}
const GENUINE_INTOTO_ENVELOPE: &str =
include_str!("../tests/fixtures/sops_v3_9_0.intoto.jsonl");
fn embedded_sigstore_root() -> TrustedRoot {
TrustedRoot::from_json(sigstore_verify::trust_root::SIGSTORE_PRODUCTION_TRUSTED_ROOT)
.expect("embedded production trusted_root.json parses")
}
fn slsa_statement(subjects: serde_json::Value) -> Vec<u8> {
serde_json::json!({
"predicateType": "https://slsa.dev/provenance/v1",
"subject": subjects,
})
.to_string()
.into_bytes()
}
#[test]
fn intoto_payload_accepts_complete_content_subjects() {
let artifact = SlsaArtifact::from_bytes("pixi".to_string(), b"binary");
let payload = slsa_statement(serde_json::json!([
{"name": "pixi", "digest": {"sha256": artifact.sha256.clone()}},
]));
verify_intoto_payload_subjects(&payload, &[artifact], 1).unwrap();
}
#[test]
fn intoto_payload_rejects_partial_content_subjects() {
let covered = SlsaArtifact::from_bytes("bin/tool".to_string(), b"tool");
let uncovered = SlsaArtifact::from_bytes("README.md".to_string(), b"docs");
let payload = slsa_statement(serde_json::json!([
{"name": "bin/tool", "digest": {"sha256": covered.sha256.clone()}},
]));
let err = verify_intoto_payload_subjects(&payload, &[covered, uncovered], 1)
.unwrap_err()
.to_string();
assert!(err.contains("README.md"));
assert!(err.contains("not found in SLSA statement subjects"));
}
#[test]
fn intoto_envelope_rejects_tampered_signature() {
let root = embedded_sigstore_root();
let mut env: serde_json::Value =
serde_json::from_str(GENUINE_INTOTO_ENVELOPE.trim()).unwrap();
env["signatures"][0]["sig"] =
serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(b"forged"));
let tampered = serde_json::to_string(&env).unwrap();
let err = verify_intoto_envelope(&tampered, b"any artifact bytes", 1, &root)
.unwrap_err()
.to_string();
assert!(
err.contains("DSSE signature") || err.contains("signature verification failed"),
"expected signature failure, got {err}"
);
}
#[test]
fn intoto_envelope_rejects_missing_signatures() {
let root = embedded_sigstore_root();
let mut env: serde_json::Value =
serde_json::from_str(GENUINE_INTOTO_ENVELOPE.trim()).unwrap();
env["signatures"] = serde_json::json!([]);
let stripped = serde_json::to_string(&env).unwrap();
let err = verify_intoto_envelope(&stripped, b"any artifact bytes", 1, &root)
.unwrap_err()
.to_string();
assert!(err.contains("no signatures"), "got {err}");
}
#[test]
fn intoto_envelope_rejects_unknown_artifact() {
let root = embedded_sigstore_root();
let err = verify_intoto_envelope(
GENUINE_INTOTO_ENVELOPE.trim(),
b"different artifact contents",
1,
&root,
)
.unwrap_err()
.to_string();
assert!(
err.contains("not found in SLSA statement subjects"),
"expected subject mismatch, got {err}"
);
}
#[test]
fn intoto_envelope_rejects_self_signed_cert() {
let root = embedded_sigstore_root();
let mut env: serde_json::Value =
serde_json::from_str(GENUINE_INTOTO_ENVELOPE.trim()).unwrap();
const SELF_SIGNED: &str = "-----BEGIN CERTIFICATE-----\n\
MIIBhTCCASugAwIBAgIUExample0AAAAAAAAAAAAAAAAAAAAwCgYIKoZIzj0EAwIw\n\
EzERMA8GA1UEAwwIc2VsZi1jYTAeFw0yNTAxMDEwMDAwMDBaFw0zNTAxMDEwMDAw\n\
MDBaMBMxETAPBgNVBAMMCHNlbGYtY2EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC\n\
AAQX9YJlbpFy0FmCXn7gC8m/qAh3wZw9w0CIxample/Random/dataABCDEFGHIJ\n\
KLMNOPQRSTUVWXYZabcdefghijklmnopo1MwUTAdBgNVHQ4EFgQUExampleHandle\n\
00000000000000000000003wHwYDVR0jBBgwFoAUExampleHandle00000000000\n\
00000000003wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNJADBGAiEAExam\n\
pleSignature1234567890123456789012345678901234567890CIQDExampleS\n\
ignature1234567890123456789012345678901234567890Aa==\n\
-----END CERTIFICATE-----\n";
env["signatures"][0]["cert"] = serde_json::Value::String(SELF_SIGNED.to_string());
let forged = serde_json::to_string(&env).unwrap();
let err = verify_intoto_envelope(&forged, b"any artifact bytes", 1, &root)
.unwrap_err()
.to_string();
assert!(
err.to_lowercase().contains("chain")
|| err.to_lowercase().contains("trust")
|| err.to_lowercase().contains("invalid"),
"expected chain validation failure, got {err}"
);
}
}