Skip to main content

sigstore_verification/
api.rs

1use crate::{AttestationError, Result};
2use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT};
3use serde::{Deserialize, Serialize};
4
5const GITHUB_API_URL: &str = "https://api.github.com";
6const USER_AGENT_VALUE: &str = "mise-attestation/0.1.0";
7
8#[derive(Debug, Clone)]
9pub struct AttestationClient {
10    client: reqwest::Client,
11    base_url: String,
12    github_token: Option<String>,
13}
14
15#[derive(Debug, Clone, Default)]
16pub struct AttestationClientBuilder {
17    base_url: Option<String>,
18    github_token: Option<String>,
19}
20
21impl AttestationClientBuilder {
22    pub fn base_url(mut self, url: &str) -> Self {
23        self.base_url = Some(url.trim_end_matches('/').to_string());
24        self
25    }
26
27    pub fn github_token(mut self, token: &str) -> Self {
28        self.github_token = Some(token.to_string());
29        self
30    }
31
32    pub fn build(self) -> Result<AttestationClient> {
33        let mut headers = HeaderMap::new();
34        headers.insert(USER_AGENT, HeaderValue::from_static(USER_AGENT_VALUE));
35
36        let client = reqwest::Client::builder()
37            .default_headers(headers)
38            .build()?;
39
40        Ok(AttestationClient {
41            client,
42            base_url: self.base_url.unwrap_or_else(|| GITHUB_API_URL.to_string()),
43            github_token: self.github_token,
44        })
45    }
46}
47
48#[derive(Debug, Serialize)]
49pub struct FetchParams {
50    pub owner: String,
51    pub repo: Option<String>,
52    pub digest: String,
53    pub limit: usize,
54    pub predicate_type: Option<String>,
55}
56
57#[derive(Debug, Deserialize)]
58pub struct AttestationsResponse {
59    pub attestations: Vec<Attestation>,
60}
61
62#[derive(Debug, Deserialize, Clone)]
63pub struct Attestation {
64    pub bundle: Option<SigstoreBundle>,
65    pub bundle_url: Option<String>,
66}
67
68#[derive(Debug, Deserialize, Clone)]
69pub struct SigstoreBundle {
70    #[serde(rename = "mediaType")]
71    pub media_type: String,
72    #[serde(rename = "dsseEnvelope")]
73    pub dsse_envelope: Option<DsseEnvelope>,
74    #[serde(rename = "verificationMaterial")]
75    pub verification_material: Option<serde_json::Value>,
76    /// Message signature for direct blob signing (cosign v3 format)
77    #[serde(rename = "messageSignature")]
78    pub message_signature: Option<MessageSignature>,
79}
80
81#[derive(Debug, Deserialize, Clone)]
82pub struct MessageSignature {
83    #[serde(rename = "messageDigest")]
84    pub message_digest: MessageDigest,
85    pub signature: String,
86}
87
88#[derive(Debug, Deserialize, Clone)]
89pub struct MessageDigest {
90    pub algorithm: String,
91    pub digest: String,
92}
93
94#[derive(Debug, Deserialize, Clone)]
95pub struct DsseEnvelope {
96    pub payload: String,
97    #[serde(rename = "payloadType")]
98    pub payload_type: String,
99    pub signatures: Vec<Signature>,
100}
101
102#[derive(Debug, Deserialize, Clone)]
103pub struct Signature {
104    pub sig: String,
105    pub keyid: Option<String>,
106}
107
108impl AttestationClient {
109    pub fn new(github_token: Option<&str>) -> Result<Self> {
110        let mut builder = Self::builder();
111        if let Some(token) = github_token {
112            builder = builder.github_token(token);
113        }
114        builder.build()
115    }
116
117    pub fn builder() -> AttestationClientBuilder {
118        AttestationClientBuilder::default()
119    }
120
121    fn github_headers(&self, url: &str) -> Result<HeaderMap> {
122        let mut headers = HeaderMap::new();
123        let base_with_slash = format!("{}/", self.base_url);
124        if url.starts_with(&base_with_slash) || url == self.base_url {
125            if let Some(token) = &self.github_token {
126                headers.insert(
127                    AUTHORIZATION,
128                    HeaderValue::from_str(&format!("Bearer {}", token))
129                        .map_err(|e| AttestationError::Api(e.to_string()))?,
130                );
131            }
132            headers.insert(
133                "x-github-api-version",
134                HeaderValue::from_static("2022-11-28"),
135            );
136        }
137        Ok(headers)
138    }
139
140    pub async fn fetch_attestations(&self, params: FetchParams) -> Result<Vec<Attestation>> {
141        let url = if let Some(repo) = &params.repo {
142            format!(
143                "{}/repos/{}/attestations/{}",
144                self.base_url, repo, params.digest
145            )
146        } else {
147            format!(
148                "{}/orgs/{}/attestations/{}",
149                self.base_url, params.owner, params.digest
150            )
151        };
152
153        let mut query_params = vec![("per_page", params.limit.to_string())];
154        if let Some(predicate_type) = &params.predicate_type {
155            query_params.push(("predicate_type", predicate_type.clone()));
156        }
157
158        let response = self
159            .client
160            .get(&url)
161            .headers(self.github_headers(&url)?)
162            .query(&query_params)
163            .send()
164            .await?;
165
166        if !response.status().is_success() {
167            let status = response.status();
168
169            // 404 means no attestations exist for this artifact
170            if status == reqwest::StatusCode::NOT_FOUND {
171                return Ok(Vec::new());
172            }
173
174            let body = response
175                .text()
176                .await
177                .unwrap_or_else(|_| "Unknown error".to_string());
178            return Err(AttestationError::Api(format!(
179                "GitHub API returned {}: {}",
180                status, body
181            )));
182        }
183
184        let attestations_response: AttestationsResponse = response.json().await?;
185
186        // Download bundles if only URLs are provided
187        let mut attestations = Vec::new();
188        for att in attestations_response.attestations {
189            if att.bundle.is_some() {
190                attestations.push(att);
191            } else if let Some(bundle_url) = &att.bundle_url {
192                // Download the bundle
193                let bundle_response = self
194                    .client
195                    .get(bundle_url)
196                    .headers(self.github_headers(bundle_url)?)
197                    .send()
198                    .await?;
199                if bundle_response.status().is_success() {
200                    let bundle: SigstoreBundle = if bundle_response
201                        .headers()
202                        .get(reqwest::header::CONTENT_TYPE)
203                        .and_then(|v| v.to_str().ok())
204                        == Some("application/x-snappy")
205                    {
206                        let bytes = bundle_response.bytes().await?;
207                        let decompressed = decompress_snappy(&bytes)?;
208                        serde_json::from_slice(&decompressed)?
209                    } else {
210                        bundle_response.json().await?
211                    };
212
213                    attestations.push(Attestation {
214                        bundle: Some(bundle),
215                        bundle_url: att.bundle_url.clone(),
216                    });
217                }
218            }
219        }
220
221        Ok(attestations)
222    }
223}
224
225fn decompress_snappy(bytes: &[u8]) -> Result<Vec<u8>> {
226    let mut decoder = snap::raw::Decoder::new();
227    decoder
228        .decompress_vec(bytes)
229        .map_err(|e| AttestationError::Api(format!("Snappy decompression failed: {}", e)))
230}