Skip to main content

sigstore_verification/
api.rs

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