sigstore-verification 0.2.7

Sigstore, Cosign, and SLSA attestation verification library
Documentation
use crate::{AttestationError, Result};
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT};
use serde::{Deserialize, Serialize};

const GITHUB_API_URL: &str = "https://api.github.com";
const USER_AGENT_VALUE: &str = "mise-attestation/0.1.0";

#[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)]
pub struct AttestationsResponse {
    pub attestations: Vec<Attestation>,
}

#[derive(Debug, Deserialize, Clone)]
pub struct Attestation {
    pub bundle: Option<SigstoreBundle>,
    pub bundle_url: Option<String>,
}

#[derive(Debug, Deserialize, Clone)]
pub struct SigstoreBundle {
    #[serde(rename = "mediaType")]
    pub media_type: String,
    #[serde(rename = "dsseEnvelope")]
    pub dsse_envelope: Option<DsseEnvelope>,
    #[serde(rename = "verificationMaterial")]
    pub verification_material: Option<serde_json::Value>,
    /// Message signature for direct blob signing (cosign v3 format)
    #[serde(rename = "messageSignature")]
    pub message_signature: Option<MessageSignature>,
}

#[derive(Debug, Deserialize, Clone)]
pub struct MessageSignature {
    #[serde(rename = "messageDigest")]
    pub message_digest: MessageDigest,
    pub signature: String,
}

#[derive(Debug, Deserialize, Clone)]
pub struct MessageDigest {
    pub algorithm: String,
    pub digest: String,
}

#[derive(Debug, Deserialize, Clone)]
pub struct DsseEnvelope {
    pub payload: String,
    #[serde(rename = "payloadType")]
    pub payload_type: String,
    pub signatures: Vec<Signature>,
}

#[derive(Debug, Deserialize, Clone)]
pub struct Signature {
    pub sig: String,
    pub keyid: Option<String>,
}

impl AttestationClient {
    pub fn new(github_token: Option<&str>) -> Result<Self> {
        let mut builder = Self::builder();
        if let Some(token) = github_token {
            builder = builder.github_token(token);
        }
        builder.build()
    }

    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.starts_with(&base_with_slash) || url == self.base_url {
            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) = &params.repo {
            format!(
                "{}/repos/{}/attestations/{}",
                self.base_url, repo, 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) = &params.predicate_type {
            query_params.push(("predicate_type", predicate_type.clone()));
        }

        let response = self
            .client
            .get(&url)
            .headers(self.github_headers(&url)?)
            .query(&query_params)
            .send()
            .await?;

        if !response.status().is_success() {
            let status = response.status();

            // 404 means no attestations exist for this artifact
            if status == reqwest::StatusCode::NOT_FOUND {
                return Ok(Vec::new());
            }

            let body = response
                .text()
                .await
                .unwrap_or_else(|_| "Unknown error".to_string());
            return Err(AttestationError::Api(format!(
                "GitHub API returned {}: {}",
                status, body
            )));
        }

        let attestations_response: AttestationsResponse = response.json().await?;

        // Download bundles if only URLs are provided
        let mut attestations = Vec::new();
        for att in attestations_response.attestations {
            if att.bundle.is_some() {
                attestations.push(att);
            } else if let Some(bundle_url) = &att.bundle_url {
                // Download the bundle
                let bundle_response = self
                    .client
                    .get(bundle_url)
                    .headers(self.github_headers(bundle_url)?)
                    .send()
                    .await?;
                if bundle_response.status().is_success() {
                    let bundle: SigstoreBundle = if bundle_response
                        .headers()
                        .get(reqwest::header::CONTENT_TYPE)
                        .and_then(|v| v.to_str().ok())
                        == Some("application/x-snappy")
                    {
                        let bytes = bundle_response.bytes().await?;
                        let decompressed = decompress_snappy(&bytes)?;
                        serde_json::from_slice(&decompressed)?
                    } else {
                        bundle_response.json().await?
                    };

                    attestations.push(Attestation {
                        bundle: Some(bundle),
                        bundle_url: att.bundle_url.clone(),
                    });
                }
            }
        }

        Ok(attestations)
    }
}

fn decompress_snappy(bytes: &[u8]) -> Result<Vec<u8>> {
    let mut decoder = snap::raw::Decoder::new();
    decoder
        .decompress_vec(bytes)
        .map_err(|e| AttestationError::Api(format!("Snappy decompression failed: {}", e)))
}