1use std::path::Path;
2
3use async_trait::async_trait;
4use base64::Engine;
5use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use sigstore_verify::VerificationPolicy;
9use sigstore_verify::trust_root::{SigstoreInstance, TrustedRoot};
10use sigstore_verify::types::{
11 Artifact, Bundle, DerCertificate, DerPublicKey, Sha256Hash, SignatureBytes,
12};
13use thiserror::Error;
14use tokio::io::AsyncReadExt;
15
16const GITHUB_API_URL: &str = "https://api.github.com";
17const USER_AGENT_VALUE: &str = "mise-sigstore/0.1.0";
18
19#[derive(Debug, Error)]
20pub enum AttestationError {
21 #[error("API error: {0}")]
22 Api(String),
23 #[error("Verification failed: {0}")]
24 Verification(String),
25 #[error("SLSA subject mismatch: {0}")]
26 SubjectMismatch(String),
27 #[error("Unsupported attestation format: {0}")]
28 UnsupportedFormat(String),
29 #[error("No attestations found")]
30 NoAttestations,
31 #[error("IO error: {0}")]
32 Io(#[from] std::io::Error),
33 #[error("HTTP error: {0}")]
34 Http(#[from] reqwest::Error),
35 #[error("JSON error: {0}")]
36 Json(#[from] serde_json::Error),
37 #[error("Sigstore error: {0}")]
38 Sigstore(String),
39}
40
41impl From<sigstore_verify::Error> for AttestationError {
42 fn from(err: sigstore_verify::Error) -> Self {
43 AttestationError::Sigstore(err.to_string())
44 }
45}
46
47impl From<sigstore_verify::types::Error> for AttestationError {
48 fn from(err: sigstore_verify::types::Error) -> Self {
49 AttestationError::Sigstore(err.to_string())
50 }
51}
52
53impl From<sigstore_verify::trust_root::Error> for AttestationError {
54 fn from(err: sigstore_verify::trust_root::Error) -> Self {
55 AttestationError::Sigstore(err.to_string())
56 }
57}
58
59pub type Result<T> = std::result::Result<T, AttestationError>;
60
61#[derive(Debug, Clone)]
62pub struct SlsaArtifact {
63 pub name: String,
64 pub sha256: String,
65}
66
67impl SlsaArtifact {
68 pub fn from_bytes(name: String, bytes: &[u8]) -> Self {
69 Self {
70 name,
71 sha256: hex::encode(Sha256::digest(bytes)),
72 }
73 }
74}
75
76#[derive(Debug, Clone)]
77pub struct ArtifactRef {
78 digest: String,
79}
80
81impl ArtifactRef {
82 pub fn from_digest(digest: &str) -> Self {
83 if digest.contains(':') {
84 Self {
85 digest: digest.to_string(),
86 }
87 } else {
88 Self {
89 digest: format!("sha256:{digest}"),
90 }
91 }
92 }
93}
94
95#[async_trait]
96pub trait AttestationSource {
97 async fn fetch_attestations(&self, artifact: &ArtifactRef) -> Result<Vec<Attestation>>;
98}
99
100pub mod sources {
101 pub use crate::{ArtifactRef, AttestationSource};
102
103 pub mod github {
104 pub use crate::GitHubSource;
105 }
106}
107
108#[derive(Debug, Clone)]
109pub struct GitHubSource {
110 client: AttestationClient,
111 owner: String,
112 repo: String,
113}
114
115impl GitHubSource {
116 pub fn new(owner: &str, repo: &str, token: Option<&str>) -> Result<Self> {
117 let mut builder = AttestationClient::builder();
118 if let Some(token) = token {
119 builder = builder.github_token(token);
120 }
121 Ok(Self {
122 client: builder.build()?,
123 owner: owner.to_string(),
124 repo: repo.to_string(),
125 })
126 }
127
128 pub fn with_base_url(
129 owner: &str,
130 repo: &str,
131 token: Option<&str>,
132 base_url: &str,
133 ) -> Result<Self> {
134 let mut builder = AttestationClient::builder().base_url(base_url);
135 if let Some(token) = token {
136 builder = builder.github_token(token);
137 }
138 Ok(Self {
139 client: builder.build()?,
140 owner: owner.to_string(),
141 repo: repo.to_string(),
142 })
143 }
144}
145
146#[async_trait]
147impl AttestationSource for GitHubSource {
148 async fn fetch_attestations(&self, artifact: &ArtifactRef) -> Result<Vec<Attestation>> {
149 self.client
150 .fetch_attestations(FetchParams {
151 owner: self.owner.clone(),
152 repo: Some(format!("{}/{}", self.owner, self.repo)),
153 digest: artifact.digest.clone(),
154 limit: 30,
155 predicate_type: None,
156 })
157 .await
158 }
159}
160
161#[derive(Debug, Clone)]
162pub struct AttestationClient {
163 client: reqwest::Client,
164 base_url: String,
165 github_token: Option<String>,
166}
167
168#[derive(Debug, Clone, Default)]
169pub struct AttestationClientBuilder {
170 base_url: Option<String>,
171 github_token: Option<String>,
172}
173
174impl AttestationClientBuilder {
175 pub fn base_url(mut self, url: &str) -> Self {
176 self.base_url = Some(url.trim_end_matches('/').to_string());
177 self
178 }
179
180 pub fn github_token(mut self, token: &str) -> Self {
181 self.github_token = Some(token.to_string());
182 self
183 }
184
185 pub fn build(self) -> Result<AttestationClient> {
186 let mut headers = HeaderMap::new();
187 headers.insert(USER_AGENT, HeaderValue::from_static(USER_AGENT_VALUE));
188 let client = reqwest::Client::builder()
189 .default_headers(headers)
190 .build()?;
191
192 Ok(AttestationClient {
193 client,
194 base_url: self.base_url.unwrap_or_else(|| GITHUB_API_URL.to_string()),
195 github_token: self.github_token,
196 })
197 }
198}
199
200#[derive(Debug, Serialize)]
201pub struct FetchParams {
202 pub owner: String,
203 pub repo: Option<String>,
204 pub digest: String,
205 pub limit: usize,
206 pub predicate_type: Option<String>,
207}
208
209#[derive(Debug, Deserialize)]
210struct AttestationsResponse {
211 attestations: Vec<Attestation>,
212}
213
214#[derive(Debug, Clone, Deserialize)]
215pub struct Attestation {
216 bundle: Option<serde_json::Value>,
217 bundle_url: Option<String>,
218}
219
220impl AttestationClient {
221 pub fn builder() -> AttestationClientBuilder {
222 AttestationClientBuilder::default()
223 }
224
225 fn github_headers(&self, url: &str) -> Result<HeaderMap> {
226 let mut headers = HeaderMap::new();
227 let base_with_slash = format!("{}/", self.base_url);
228 if url == self.base_url || url.starts_with(&base_with_slash) {
229 if let Some(token) = &self.github_token {
230 headers.insert(
231 AUTHORIZATION,
232 HeaderValue::from_str(&format!("Bearer {token}"))
233 .map_err(|e| AttestationError::Api(e.to_string()))?,
234 );
235 }
236 headers.insert(
237 "x-github-api-version",
238 HeaderValue::from_static("2022-11-28"),
239 );
240 }
241 Ok(headers)
242 }
243
244 pub async fn fetch_attestations(&self, params: FetchParams) -> Result<Vec<Attestation>> {
245 let url = if let Some(repo) = ¶ms.repo {
246 format!(
247 "{}/repos/{repo}/attestations/{}",
248 self.base_url, params.digest
249 )
250 } else {
251 format!(
252 "{}/orgs/{}/attestations/{}",
253 self.base_url, params.owner, params.digest
254 )
255 };
256
257 let mut query_params = vec![("per_page", params.limit.to_string())];
258 if let Some(predicate_type) = ¶ms.predicate_type {
259 query_params.push(("predicate_type", predicate_type.clone()));
260 }
261 let url = reqwest::Url::parse_with_params(&url, query_params)
262 .map_err(|e| AttestationError::Api(format!("Invalid GitHub attestations URL: {e}")))?;
263
264 let response = self
265 .client
266 .get(url.clone())
267 .headers(self.github_headers(url.as_str())?)
268 .send()
269 .await?;
270
271 if response.status() == reqwest::StatusCode::NOT_FOUND {
272 return Ok(vec![]);
273 }
274 if !response.status().is_success() {
275 let status = response.status();
276 let body = response
277 .text()
278 .await
279 .unwrap_or_else(|_| "Unknown error".to_string());
280 return Err(AttestationError::Api(format!(
281 "GitHub API returned {status}: {body}"
282 )));
283 }
284
285 let response: AttestationsResponse = response.json().await?;
286 let mut attestations = Vec::new();
287 for attestation in response.attestations {
288 if attestation.bundle.is_some() {
289 attestations.push(attestation);
290 } else if let Some(bundle_url) = &attestation.bundle_url {
291 let bundle = self.fetch_bundle_url(bundle_url).await?;
292 attestations.push(Attestation {
293 bundle: Some(bundle),
294 bundle_url: Some(bundle_url.clone()),
295 });
296 }
297 }
298 Ok(attestations)
299 }
300
301 async fn fetch_bundle_url(&self, bundle_url: &str) -> Result<serde_json::Value> {
302 let response = self
303 .client
304 .get(bundle_url)
305 .headers(self.github_headers(bundle_url)?)
306 .send()
307 .await?;
308 if !response.status().is_success() {
309 return Err(AttestationError::Api(format!(
310 "bundle URL returned {}",
311 response.status()
312 )));
313 }
314 if is_snappy_content_type(&response) {
315 let bytes = response.bytes().await?;
316 let decompressed = snap::raw::Decoder::new()
317 .decompress_vec(&bytes)
318 .map_err(|e| AttestationError::Api(format!("Snappy decompression failed: {e}")))?;
319 serde_json::from_slice(&decompressed).map_err(AttestationError::Json)
320 } else {
321 response.json().await.map_err(AttestationError::Http)
322 }
323 }
324}
325
326pub async fn verify_github_attestation(
327 artifact_path: &Path,
328 owner: &str,
329 repo: &str,
330 token: Option<&str>,
331 signer_workflow: Option<&str>,
332) -> Result<bool> {
333 verify_github_attestation_inner(artifact_path, owner, repo, token, signer_workflow, None).await
334}
335
336pub async fn verify_github_attestation_with_base_url(
337 artifact_path: &Path,
338 owner: &str,
339 repo: &str,
340 token: Option<&str>,
341 signer_workflow: Option<&str>,
342 base_url: &str,
343) -> Result<bool> {
344 verify_github_attestation_inner(
345 artifact_path,
346 owner,
347 repo,
348 token,
349 signer_workflow,
350 Some(base_url),
351 )
352 .await
353}
354
355async fn verify_github_attestation_inner(
356 artifact_path: &Path,
357 owner: &str,
358 repo: &str,
359 token: Option<&str>,
360 signer_workflow: Option<&str>,
361 base_url: Option<&str>,
362) -> Result<bool> {
363 let mut builder = AttestationClient::builder();
364 if let Some(token) = token {
365 builder = builder.github_token(token);
366 }
367 if let Some(base_url) = base_url {
368 builder = builder.base_url(base_url);
369 }
370 let client = builder.build()?;
371 let digest = calculate_file_digest_async(artifact_path).await?;
372 let attestations = client
373 .fetch_attestations(FetchParams {
374 owner: owner.to_string(),
375 repo: Some(format!("{owner}/{repo}")),
376 digest: format!("sha256:{digest}"),
377 limit: 30,
378 predicate_type: None,
379 })
380 .await?;
381
382 if attestations.is_empty() {
383 return Err(AttestationError::NoAttestations);
384 }
385
386 let artifact = tokio::fs::read(artifact_path).await?;
387 let mut trust_roots = TrustRoots::default();
388 verify_attestation_bundles(&attestations, &artifact, signer_workflow, &mut trust_roots).await
389}
390
391pub async fn verify_cosign_signature(
392 artifact_path: &Path,
393 sig_or_bundle_path: &Path,
394) -> Result<bool> {
395 let content = tokio::fs::read_to_string(sig_or_bundle_path).await?;
396 let artifact = tokio::fs::read(artifact_path).await?;
397 let mut trust_roots = TrustRoots::default();
398 if let Ok(bundle) = Bundle::from_json(&content) {
399 let trusted_root = trust_roots.for_bundle(&bundle).await?;
400 verify_bundle(&artifact, &bundle, None, trusted_root)?;
401 return Ok(true);
402 }
403 let trusted_root = trust_roots.sigstore_root().await?;
408 verify_legacy_cosign_bundle(&artifact, &content, trusted_root)?;
409 Ok(true)
410}
411
412pub async fn verify_cosign_signature_with_key(
413 artifact_path: &Path,
414 sig_or_bundle_path: &Path,
415 public_key_path: &Path,
416) -> Result<bool> {
417 let key_pem = tokio::fs::read_to_string(public_key_path).await?;
418 let public_key = DerPublicKey::from_pem(&key_pem)?;
419
420 let raw_bytes = tokio::fs::read(sig_or_bundle_path).await?;
423 let bundle = std::str::from_utf8(&raw_bytes)
424 .ok()
425 .and_then(|content| Bundle::from_json(content).ok());
426 if let Some(bundle) = bundle {
427 let trusted_root = production_trusted_root().await?;
429 let artifact = tokio::fs::read(artifact_path).await?;
430 let result = sigstore_verify::verify_with_key(
431 artifact.as_slice(),
432 &bundle,
433 &public_key,
434 &trusted_root,
435 )?;
436 if !result.success {
437 return Err(AttestationError::Verification(
438 "sigstore verification returned false".to_string(),
439 ));
440 }
441 return Ok(true);
442 }
443
444 let artifact = tokio::fs::read(artifact_path).await?;
446 let signature = decode_cosign_signature(&raw_bytes);
447 verify_raw_signature(&artifact, &signature, &public_key)?;
448 Ok(true)
449}
450
451pub async fn verify_slsa_provenance(
452 artifact_path: &Path,
453 provenance_path: &Path,
454 min_level: u8,
455) -> Result<bool> {
456 let artifact = tokio::fs::read(artifact_path).await?;
457 verify_slsa_provenance_artifacts(
458 provenance_path,
459 &[SlsaArtifact::from_bytes(String::new(), &artifact)],
460 min_level,
461 )
462 .await
463}
464
465pub async fn verify_slsa_provenance_artifacts(
466 provenance_path: &Path,
467 artifacts: &[SlsaArtifact],
468 min_level: u8,
469) -> Result<bool> {
470 if artifacts.is_empty() {
471 return Err(AttestationError::SubjectMismatch(
472 "no artifacts supplied for SLSA subject verification".to_string(),
473 ));
474 }
475
476 let content = tokio::fs::read_to_string(provenance_path).await?;
477 let mut errors = Vec::new();
478 let mut trust_roots = TrustRoots::default();
479
480 let mut candidates: Vec<&str> = content
481 .lines()
482 .map(str::trim)
483 .filter(|line| !line.is_empty())
484 .collect();
485 let trimmed = content.trim();
486 if !trimmed.is_empty() && !candidates.contains(&trimmed) {
487 candidates.push(trimmed);
488 }
489
490 for candidate in candidates {
491 if let Ok(bundle) = Bundle::from_json(candidate) {
493 let result = match trust_roots.for_bundle(&bundle).await {
494 Ok(root) => verify_bundle_for_any_artifact(artifacts, &bundle, root)
495 .and_then(|_| verify_bundle_slsa_subjects(&bundle, artifacts, min_level)),
496 Err(e) => Err(e),
497 };
498 match result {
499 Ok(()) => return Ok(true),
500 Err(e) => errors.push(e),
501 }
502 continue;
503 }
504 let result = match trust_roots.sigstore_root().await {
512 Ok(root) => verify_intoto_envelope_subjects(candidate, artifacts, min_level, root),
513 Err(e) => Err(e),
514 };
515 match result {
516 Ok(()) => return Ok(true),
517 Err(e) => errors.push(e),
518 }
519 }
520
521 collapse_slsa_errors(errors, || {
522 "File does not contain valid attestations or SLSA provenance".to_string()
523 })
524}
525
526#[cfg(test)]
527fn verify_intoto_envelope(
528 line: &str,
529 artifact: &[u8],
530 min_level: u8,
531 trusted_root: &TrustedRoot,
532) -> Result<()> {
533 verify_intoto_envelope_subjects(
534 line,
535 &[SlsaArtifact::from_bytes(String::new(), artifact)],
536 min_level,
537 trusted_root,
538 )
539}
540
541fn verify_intoto_envelope_subjects(
542 line: &str,
543 artifacts: &[SlsaArtifact],
544 min_level: u8,
545 trusted_root: &TrustedRoot,
546) -> Result<()> {
547 let envelope: serde_json::Value = serde_json::from_str(line).map_err(|e| {
548 AttestationError::UnsupportedFormat(format!("not a JSON DSSE envelope: {e}"))
549 })?;
550 let payload_type = envelope
551 .get("payloadType")
552 .and_then(|v| v.as_str())
553 .unwrap_or_default();
554 if payload_type != "application/vnd.in-toto+json" {
555 return Err(AttestationError::UnsupportedFormat(format!(
556 "unsupported DSSE payloadType: {payload_type}"
557 )));
558 }
559 let payload_b64 = envelope
560 .get("payload")
561 .and_then(|v| v.as_str())
562 .ok_or_else(|| {
563 AttestationError::UnsupportedFormat("DSSE envelope missing payload".to_string())
564 })?;
565 let payload = base64::engine::general_purpose::STANDARD
566 .decode(payload_b64.as_bytes())
567 .map_err(|e| AttestationError::Verification(format!("invalid base64 payload: {e}")))?;
568
569 let signatures = envelope
582 .get("signatures")
583 .and_then(|v| v.as_array())
584 .ok_or_else(|| {
585 AttestationError::Verification("DSSE envelope missing signatures".to_string())
586 })?;
587 if signatures.is_empty() {
588 return Err(AttestationError::Verification(
589 "DSSE envelope has no signatures".to_string(),
590 ));
591 }
592 let pae = sigstore_verify::types::pae(payload_type, &payload);
593 let mut sig_errors = Vec::new();
594 let mut verified = false;
595 for sig in signatures {
596 match verify_dsse_signature(sig, &pae, trusted_root) {
597 Ok(()) => {
598 verified = true;
599 break;
600 }
601 Err(e) => sig_errors.push(e.to_string()),
602 }
603 }
604 if !verified {
605 return Err(AttestationError::Verification(format!(
606 "no valid DSSE signature: {}",
607 join_error_strings(sig_errors, || "no signatures could be verified".to_string())
608 )));
609 }
610
611 verify_intoto_payload_subjects(&payload, artifacts, min_level)
612}
613
614fn verify_legacy_cosign_bundle(
630 artifact: &[u8],
631 bundle_json: &str,
632 trusted_root: &TrustedRoot,
633) -> Result<()> {
634 let value: serde_json::Value = serde_json::from_str(bundle_json).map_err(|e| {
635 AttestationError::UnsupportedFormat(format!("not a sigstore or cosign bundle: {e}"))
636 })?;
637 let cert_b64 = value.get("cert").and_then(|v| v.as_str()).ok_or_else(|| {
638 AttestationError::UnsupportedFormat("legacy cosign bundle missing cert".to_string())
639 })?;
640 let sig_b64 = value
641 .get("base64Signature")
642 .and_then(|v| v.as_str())
643 .ok_or_else(|| {
644 AttestationError::UnsupportedFormat(
645 "legacy cosign bundle missing base64Signature".to_string(),
646 )
647 })?;
648
649 let cert_pem_bytes = base64::engine::general_purpose::STANDARD
650 .decode(cert_b64.as_bytes())
651 .map_err(|e| {
652 AttestationError::Verification(format!("invalid base64 cert in legacy bundle: {e}"))
653 })?;
654 let cert_pem = std::str::from_utf8(&cert_pem_bytes).map_err(|e| {
655 AttestationError::Verification(format!("legacy cosign cert is not UTF-8 PEM: {e}"))
656 })?;
657 let cert = DerCertificate::from_pem(cert_pem)?;
658 verify_cert_chain(cert.as_bytes(), trusted_root)?;
659
660 let sig_bytes = base64::engine::general_purpose::STANDARD
661 .decode(sig_b64.as_bytes())
662 .map_err(|e| AttestationError::Verification(format!("invalid base64 signature: {e}")))?;
663 let spki_der = extract_spki_der(cert.as_bytes())?;
664 let public_key = DerPublicKey::new(spki_der);
665 verify_raw_signature(artifact, &sig_bytes, &public_key)
666}
667
668fn verify_dsse_signature(
669 sig: &serde_json::Value,
670 pae: &[u8],
671 trusted_root: &TrustedRoot,
672) -> Result<()> {
673 let cert_pem = sig.get("cert").and_then(|v| v.as_str()).ok_or_else(|| {
674 AttestationError::Verification("DSSE signature missing cert field".to_string())
675 })?;
676 let sig_b64 = sig.get("sig").and_then(|v| v.as_str()).ok_or_else(|| {
677 AttestationError::Verification("DSSE signature missing sig field".to_string())
678 })?;
679 let sig_bytes = base64::engine::general_purpose::STANDARD
680 .decode(sig_b64.as_bytes())
681 .map_err(|e| AttestationError::Verification(format!("invalid base64 signature: {e}")))?;
682 let cert = DerCertificate::from_pem(cert_pem)?;
683 verify_cert_chain(cert.as_bytes(), trusted_root)?;
685 let spki_der = extract_spki_der(cert.as_bytes())?;
686 let public_key = DerPublicKey::new(spki_der);
687 verify_raw_signature(pae, &sig_bytes, &public_key)
688}
689
690fn verify_intoto_payload_subjects(
691 payload: &[u8],
692 artifacts: &[SlsaArtifact],
693 min_level: u8,
694) -> Result<()> {
695 let statement: serde_json::Value = serde_json::from_slice(payload).map_err(|e| {
696 AttestationError::Verification(format!("Failed to parse SLSA payload: {e}"))
697 })?;
698 let predicate_type = statement
699 .get("predicateType")
700 .and_then(|v| v.as_str())
701 .unwrap_or_default();
702 if !predicate_type.starts_with("https://slsa.dev/provenance/") {
703 return Err(AttestationError::UnsupportedFormat(format!(
704 "Not an SLSA provenance predicate: {predicate_type}"
705 )));
706 }
707 if min_level > 1 {
708 return Err(AttestationError::Verification(format!(
709 "SLSA level {min_level} verification is not supported by the native adapter"
710 )));
711 }
712 let subjects = statement
713 .get("subject")
714 .and_then(|v| v.as_array())
715 .ok_or_else(|| {
716 AttestationError::Verification("SLSA statement missing subject array".to_string())
717 })?;
718
719 let subject_digests = subjects
720 .iter()
721 .filter_map(|subject| {
722 subject
723 .get("digest")
724 .and_then(|d| d.get("sha256"))
725 .and_then(|v| v.as_str())
726 .map(|sha| sha.to_ascii_lowercase())
727 })
728 .collect::<std::collections::HashSet<_>>();
729 let named_subjects = subjects
730 .iter()
731 .filter_map(|subject| {
732 let name = subject.get("name")?.as_str()?;
733 let sha = subject
734 .get("digest")
735 .and_then(|d| d.get("sha256"))
736 .and_then(|v| v.as_str())?;
737 Some((name.to_string(), sha.to_ascii_lowercase()))
738 })
739 .collect::<std::collections::HashSet<_>>();
740
741 let mut missing = Vec::new();
742 for artifact in artifacts {
743 let artifact_digest = artifact.sha256.to_ascii_lowercase();
744 let matches_subject = if artifact.name.is_empty() {
745 subject_digests.contains(&artifact_digest)
746 } else {
747 named_subjects.contains(&(artifact.name.clone(), artifact_digest.clone()))
748 };
749 if !matches_subject {
750 if artifact.name.is_empty() {
751 missing.push(artifact_digest);
752 } else {
753 missing.push(format!("{} ({artifact_digest})", artifact.name));
754 }
755 }
756 }
757 if !missing.is_empty() {
758 return Err(AttestationError::SubjectMismatch(format!(
759 "artifact subjects not found in SLSA statement subjects: {}",
760 missing.join(", ")
761 )));
762 }
763 Ok(())
764}
765
766fn collapse_slsa_errors(
767 errors: Vec<AttestationError>,
768 default: impl FnOnce() -> String,
769) -> Result<bool> {
770 let unsupported_format = errors
771 .iter()
772 .all(|error| matches!(error, AttestationError::UnsupportedFormat(_)));
773 let subject_mismatch = errors.iter().any(is_slsa_subject_mismatch);
774 let message = join_error_strings(
775 errors.into_iter().map(|error| error.to_string()).collect(),
776 default,
777 );
778 Err(if unsupported_format {
779 AttestationError::UnsupportedFormat(message)
780 } else if subject_mismatch {
781 AttestationError::SubjectMismatch(message)
782 } else {
783 AttestationError::Verification(message)
784 })
785}
786
787pub fn is_slsa_subject_mismatch(error: &AttestationError) -> bool {
788 match error {
789 AttestationError::SubjectMismatch(_) => true,
790 AttestationError::Verification(msg) | AttestationError::Sigstore(msg) => {
791 is_subject_mismatch_message(msg)
792 }
793 _ => false,
794 }
795}
796
797fn is_subject_mismatch_message(message: &str) -> bool {
798 message.contains("artifact hash does not match any subject in attestation")
799 || message.contains("not found in SLSA statement subjects")
800 || message.contains("artifact subjects not found in SLSA statement subjects")
801}
802
803fn join_error_strings(errors: Vec<String>, default: impl FnOnce() -> String) -> String {
804 let mut errors = errors
805 .into_iter()
806 .filter(|error| !error.trim().is_empty())
807 .collect::<Vec<_>>();
808 errors.dedup();
809 if errors.is_empty() {
810 default()
811 } else {
812 errors.join("; ")
813 }
814}
815
816async fn verify_attestation_bundles(
817 attestations: &[Attestation],
818 artifact: &[u8],
819 signer_workflow: Option<&str>,
820 trust_roots: &mut TrustRoots,
821) -> Result<bool> {
822 let mut errors = Vec::new();
823 for attestation in attestations {
824 let Some(bundle_value) = &attestation.bundle else {
825 continue;
826 };
827 let bundle = match serde_json::from_value::<Bundle>(bundle_value.clone()) {
828 Ok(bundle) => bundle,
829 Err(e) => {
830 errors.push(e.to_string());
831 continue;
832 }
833 };
834 let trusted_root = match trust_roots.for_bundle(&bundle).await {
835 Ok(root) => root,
836 Err(e) => {
837 errors.push(e.to_string());
838 continue;
839 }
840 };
841 match verify_bundle(artifact, &bundle, signer_workflow, trusted_root) {
842 Ok(()) => return Ok(true),
843 Err(e) => errors.push(e.to_string()),
844 }
845 }
846
847 Err(AttestationError::Verification(join_error_strings(
848 errors,
849 || "No valid attestations found".to_string(),
850 )))
851}
852
853fn is_snappy_content_type(response: &reqwest::Response) -> bool {
854 response
855 .headers()
856 .get(reqwest::header::CONTENT_TYPE)
857 .and_then(|v| v.to_str().ok())
858 .and_then(|content_type| content_type.split(';').next())
859 .is_some_and(|content_type| content_type.trim() == "application/x-snappy")
860}
861
862fn verify_bundle<'a>(
863 artifact: impl Into<Artifact<'a>>,
864 bundle: &Bundle,
865 signer_workflow: Option<&str>,
866 trusted_root: &TrustedRoot,
867) -> Result<()> {
868 let mut policy = VerificationPolicy::default();
869 if !bundle.has_inclusion_proof() {
876 policy = policy.skip_tlog();
877 }
878 if is_github_internal_certificate(bundle) {
883 policy = policy.skip_sct();
884 }
885 let result = sigstore_verify::verify(artifact, bundle, &policy, trusted_root)?;
886 if !result.success {
887 return Err(AttestationError::Verification(
888 "sigstore verification returned false".to_string(),
889 ));
890 }
891
892 verify_signer_workflow_identity(result.identity.as_deref(), signer_workflow)?;
893
894 Ok(())
895}
896
897fn is_github_internal_certificate(bundle: &Bundle) -> bool {
898 bundle
899 .signing_certificate()
900 .map(|cert| cert_issuer_organization(cert.as_bytes()).as_deref() == Some("GitHub, Inc."))
901 .unwrap_or(false)
902}
903
904fn verify_cert_chain(leaf_der: &[u8], trusted_root: &TrustedRoot) -> Result<()> {
920 use rustls_pki_types::{CertificateDer, UnixTime};
921 use webpki::{ALL_VERIFICATION_ALGS, EndEntityCert, KeyUsage, anchor_from_trusted_cert};
922 use x509_cert::Certificate;
923 use x509_cert::der::Decode;
924
925 let leaf = Certificate::from_der(leaf_der).map_err(|e| {
926 AttestationError::Verification(format!("failed to parse leaf certificate: {e}"))
927 })?;
928 let not_after = leaf
929 .tbs_certificate
930 .validity
931 .not_after
932 .to_unix_duration()
933 .as_secs();
934 let validation_time = UnixTime::since_unix_epoch(std::time::Duration::from_secs(not_after));
935
936 let all_certs = trusted_root.fulcio_certs().map_err(|e| {
937 AttestationError::Verification(format!("failed to load CA certs from trust root: {e}"))
938 })?;
939 if all_certs.is_empty() {
940 return Err(AttestationError::Verification(
941 "trust root contains no CA certificates".to_string(),
942 ));
943 }
944 let trust_anchors: Vec<_> = all_certs
952 .iter()
953 .filter_map(|der| {
954 anchor_from_trusted_cert(&CertificateDer::from(der.as_ref()))
955 .map(|a| a.to_owned())
956 .ok()
957 })
958 .collect();
959 if trust_anchors.is_empty() {
960 return Err(AttestationError::Verification(
961 "trust root CA certs are unparseable".to_string(),
962 ));
963 }
964 let intermediate_certs: Vec<CertificateDer<'static>> = all_certs
965 .iter()
966 .map(|der| CertificateDer::from(der.as_ref()).into_owned())
967 .collect();
968
969 let leaf_der_ref = CertificateDer::from(leaf_der);
970 let leaf_cert = EndEntityCert::try_from(&leaf_der_ref).map_err(|e| {
971 AttestationError::Verification(format!("failed to parse leaf for chain check: {e}"))
972 })?;
973
974 const ID_KP_CODE_SIGNING: &[u8] = &[0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x03];
976
977 leaf_cert
978 .verify_for_usage(
979 ALL_VERIFICATION_ALGS,
980 &trust_anchors,
981 &intermediate_certs,
982 validation_time,
983 KeyUsage::required(ID_KP_CODE_SIGNING),
984 None,
985 None,
986 )
987 .map_err(|e| {
988 AttestationError::Verification(format!("certificate chain validation failed: {e}"))
989 })?;
990 Ok(())
991}
992
993fn cert_issuer_organization(cert_der: &[u8]) -> Option<String> {
1001 use x509_cert::Certificate;
1002 use x509_cert::der::Decode;
1003 let cert = Certificate::from_der(cert_der).ok()?;
1004 for rdn in cert.tbs_certificate.issuer.0.iter() {
1005 for atv in rdn.0.iter() {
1006 if atv.oid.to_string() == "2.5.4.10" {
1008 if let Ok(s) = atv.value.decode_as::<String>() {
1009 return Some(s);
1010 }
1011 if let Ok(s) = atv
1012 .value
1013 .decode_as::<x509_cert::der::asn1::PrintableStringRef>()
1014 {
1015 return Some(s.as_str().to_string());
1016 }
1017 if let Ok(s) = atv.value.decode_as::<x509_cert::der::asn1::Utf8StringRef>() {
1018 return Some(s.as_str().to_string());
1019 }
1020 }
1021 }
1022 }
1023 None
1024}
1025
1026fn extract_spki_der(cert_der: &[u8]) -> Result<Vec<u8>> {
1028 use x509_cert::Certificate;
1029 use x509_cert::der::{Decode, Encode};
1030 let cert = Certificate::from_der(cert_der)
1031 .map_err(|e| AttestationError::Verification(format!("failed to parse certificate: {e}")))?;
1032 cert.tbs_certificate
1033 .subject_public_key_info
1034 .to_der()
1035 .map_err(|e| {
1036 AttestationError::Verification(format!("failed to encode SubjectPublicKeyInfo: {e}"))
1037 })
1038}
1039
1040async fn production_trusted_root() -> Result<TrustedRoot> {
1041 Ok(TrustedRoot::production().await?)
1042}
1043
1044fn github_trusted_root() -> Result<TrustedRoot> {
1045 Ok(TrustedRoot::from_embedded(SigstoreInstance::GitHub)?)
1046}
1047
1048#[derive(Default)]
1053struct TrustRoots {
1054 sigstore: Option<TrustedRoot>,
1055 github: Option<TrustedRoot>,
1056}
1057
1058impl TrustRoots {
1059 async fn for_bundle(&mut self, bundle: &Bundle) -> Result<&TrustedRoot> {
1060 if is_github_internal_certificate(bundle) {
1061 self.github_root()
1062 } else {
1063 self.sigstore_root().await
1064 }
1065 }
1066
1067 async fn sigstore_root(&mut self) -> Result<&TrustedRoot> {
1068 if self.sigstore.is_none() {
1069 self.sigstore = Some(production_trusted_root().await?);
1070 }
1071 Ok(self.sigstore.as_ref().unwrap())
1072 }
1073
1074 fn github_root(&mut self) -> Result<&TrustedRoot> {
1075 if self.github.is_none() {
1076 self.github = Some(github_trusted_root()?);
1077 }
1078 Ok(self.github.as_ref().unwrap())
1079 }
1080}
1081
1082fn verify_signer_workflow_identity(
1083 identity: Option<&str>,
1084 signer_workflow: Option<&str>,
1085) -> Result<()> {
1086 let Some(expected) = signer_workflow else {
1087 return Ok(());
1088 };
1089 let Some(identity) = identity.filter(|identity| !identity.is_empty()) else {
1090 return Err(AttestationError::Verification(format!(
1091 "Workflow verification failed: expected '{expected}', found no certificate identity"
1092 )));
1093 };
1094 if !identity.contains(expected) {
1095 return Err(AttestationError::Verification(format!(
1096 "Workflow verification failed: expected '{expected}', found certificate identity: {identity:?}"
1097 )));
1098 }
1099 Ok(())
1100}
1101
1102fn verify_bundle_for_any_artifact(
1108 artifacts: &[SlsaArtifact],
1109 bundle: &Bundle,
1110 root: &TrustedRoot,
1111) -> Result<()> {
1112 let artifact = artifacts.first().ok_or_else(|| {
1113 AttestationError::SubjectMismatch(
1114 "no artifacts supplied for SLSA subject verification".to_string(),
1115 )
1116 })?;
1117 let digest = Sha256Hash::from_hex(&artifact.sha256).map_err(|e| {
1118 AttestationError::Verification(format!("invalid artifact sha256 digest: {e}"))
1119 })?;
1120 match verify_bundle(Artifact::from_digest(digest), bundle, None, root) {
1121 Ok(()) => Ok(()),
1122 Err(e) if is_slsa_subject_mismatch(&e) => {
1123 Err(AttestationError::SubjectMismatch(e.to_string()))
1124 }
1125 Err(e) => Err(e),
1126 }
1127}
1128
1129fn verify_bundle_slsa_subjects(
1130 bundle: &Bundle,
1131 artifacts: &[SlsaArtifact],
1132 min_level: u8,
1133) -> Result<()> {
1134 let payload = match &bundle.content {
1135 sigstore_verify::types::SignatureContent::DsseEnvelope(envelope) => {
1136 envelope.decode_payload()
1137 }
1138 _ => {
1139 return Err(AttestationError::UnsupportedFormat(
1140 "SLSA provenance must be a DSSE envelope".to_string(),
1141 ));
1142 }
1143 };
1144 verify_intoto_payload_subjects(&payload, artifacts, min_level)
1145}
1146
1147fn decode_cosign_signature(bytes: &[u8]) -> Vec<u8> {
1148 let trimmed = String::from_utf8_lossy(bytes).trim().to_string();
1149 if let Some(decoded) = base64::engine::general_purpose::STANDARD
1150 .decode(trimmed.as_bytes())
1151 .ok()
1152 .filter(|_| !trimmed.is_empty())
1153 {
1154 return decoded;
1155 }
1156 bytes.to_vec()
1157}
1158
1159fn verify_raw_signature(
1160 artifact: &[u8],
1161 signature: &[u8],
1162 public_key: &DerPublicKey,
1163) -> Result<()> {
1164 use sigstore_verify::crypto::{KeyType, SigningScheme, detect_key_type, verify_signature};
1165
1166 let scheme = match detect_key_type(public_key) {
1167 KeyType::Ed25519 => SigningScheme::Ed25519,
1168 KeyType::EcdsaP256 => SigningScheme::EcdsaP256Sha256,
1169 KeyType::Unknown => {
1170 return Err(AttestationError::Verification(
1171 "unsupported or unrecognized public key type".to_string(),
1172 ));
1173 }
1174 };
1175 let signature = SignatureBytes::from_bytes(signature);
1176 verify_signature(public_key, artifact, &signature, scheme)
1177 .map_err(|e| AttestationError::Verification(format!("signature verification failed: {e}")))
1178}
1179
1180async fn calculate_file_digest_async(path: &Path) -> Result<String> {
1181 let mut file = tokio::fs::File::open(path).await?;
1182 let mut hasher = Sha256::new();
1183 let mut buffer = [0; 8192];
1184 loop {
1185 let read = file.read(&mut buffer).await?;
1186 if read == 0 {
1187 break;
1188 }
1189 hasher.update(&buffer[..read]);
1190 }
1191 Ok(hex::encode(hasher.finalize()))
1192}
1193#[cfg(test)]
1194mod tests {
1195 use super::*;
1196
1197 #[test]
1198 fn signer_workflow_requires_identity() {
1199 let err = verify_signer_workflow_identity(None, Some(".github/workflows/release.yml"))
1200 .unwrap_err()
1201 .to_string();
1202
1203 assert!(err.contains("found no certificate identity"));
1204 }
1205
1206 #[test]
1207 fn signer_workflow_rejects_mismatch() {
1208 let err = verify_signer_workflow_identity(
1209 Some("https://github.com/jdx/mise/.github/workflows/ci.yml@refs/tags/v1.0.0"),
1210 Some(".github/workflows/release.yml"),
1211 )
1212 .unwrap_err()
1213 .to_string();
1214
1215 assert!(err.contains("Workflow verification failed"));
1216 }
1217
1218 #[test]
1219 fn signer_workflow_accepts_match() {
1220 verify_signer_workflow_identity(
1221 Some("https://github.com/jdx/mise/.github/workflows/release.yml@refs/tags/v1.0.0"),
1222 Some(".github/workflows/release.yml"),
1223 )
1224 .unwrap();
1225 }
1226
1227 #[test]
1228 fn signer_workflow_rejects_expected_containing_identity() {
1229 let err = verify_signer_workflow_identity(
1230 Some(".github/workflows/release.yml"),
1231 Some("https://github.com/jdx/mise/.github/workflows/release.yml@refs/tags/v1.0.0"),
1232 )
1233 .unwrap_err()
1234 .to_string();
1235
1236 assert!(err.contains("Workflow verification failed"));
1237 }
1238
1239 const GENUINE_INTOTO_ENVELOPE: &str =
1243 include_str!("../tests/fixtures/sops_v3_9_0.intoto.jsonl");
1244
1245 fn embedded_sigstore_root() -> TrustedRoot {
1246 TrustedRoot::from_json(sigstore_verify::trust_root::SIGSTORE_PRODUCTION_TRUSTED_ROOT)
1247 .expect("embedded production trusted_root.json parses")
1248 }
1249
1250 fn slsa_statement(subjects: serde_json::Value) -> Vec<u8> {
1251 serde_json::json!({
1252 "predicateType": "https://slsa.dev/provenance/v1",
1253 "subject": subjects,
1254 })
1255 .to_string()
1256 .into_bytes()
1257 }
1258
1259 #[test]
1260 fn intoto_payload_accepts_complete_content_subjects() {
1261 let artifact = SlsaArtifact::from_bytes("pixi".to_string(), b"binary");
1262 let payload = slsa_statement(serde_json::json!([
1263 {"name": "pixi", "digest": {"sha256": artifact.sha256.clone()}},
1264 ]));
1265
1266 verify_intoto_payload_subjects(&payload, &[artifact], 1).unwrap();
1267 }
1268
1269 #[test]
1270 fn intoto_payload_rejects_partial_content_subjects() {
1271 let covered = SlsaArtifact::from_bytes("bin/tool".to_string(), b"tool");
1272 let uncovered = SlsaArtifact::from_bytes("README.md".to_string(), b"docs");
1273 let payload = slsa_statement(serde_json::json!([
1274 {"name": "bin/tool", "digest": {"sha256": covered.sha256.clone()}},
1275 ]));
1276
1277 let err = verify_intoto_payload_subjects(&payload, &[covered, uncovered], 1)
1278 .unwrap_err()
1279 .to_string();
1280 assert!(err.contains("README.md"));
1281 assert!(err.contains("not found in SLSA statement subjects"));
1282 }
1283
1284 #[test]
1285 fn intoto_envelope_rejects_tampered_signature() {
1286 let root = embedded_sigstore_root();
1287 let mut env: serde_json::Value =
1288 serde_json::from_str(GENUINE_INTOTO_ENVELOPE.trim()).unwrap();
1289 env["signatures"][0]["sig"] =
1290 serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(b"forged"));
1291 let tampered = serde_json::to_string(&env).unwrap();
1292
1293 let err = verify_intoto_envelope(&tampered, b"any artifact bytes", 1, &root)
1296 .unwrap_err()
1297 .to_string();
1298 assert!(
1299 err.contains("DSSE signature") || err.contains("signature verification failed"),
1300 "expected signature failure, got {err}"
1301 );
1302 }
1303
1304 #[test]
1305 fn intoto_envelope_rejects_missing_signatures() {
1306 let root = embedded_sigstore_root();
1307 let mut env: serde_json::Value =
1308 serde_json::from_str(GENUINE_INTOTO_ENVELOPE.trim()).unwrap();
1309 env["signatures"] = serde_json::json!([]);
1310 let stripped = serde_json::to_string(&env).unwrap();
1311 let err = verify_intoto_envelope(&stripped, b"any artifact bytes", 1, &root)
1312 .unwrap_err()
1313 .to_string();
1314 assert!(err.contains("no signatures"), "got {err}");
1315 }
1316
1317 #[test]
1318 fn intoto_envelope_rejects_unknown_artifact() {
1319 let root = embedded_sigstore_root();
1321 let err = verify_intoto_envelope(
1322 GENUINE_INTOTO_ENVELOPE.trim(),
1323 b"different artifact contents",
1324 1,
1325 &root,
1326 )
1327 .unwrap_err()
1328 .to_string();
1329 assert!(
1330 err.contains("not found in SLSA statement subjects"),
1331 "expected subject mismatch, got {err}"
1332 );
1333 }
1334
1335 #[test]
1336 fn intoto_envelope_rejects_self_signed_cert() {
1337 let root = embedded_sigstore_root();
1340 let mut env: serde_json::Value =
1341 serde_json::from_str(GENUINE_INTOTO_ENVELOPE.trim()).unwrap();
1342 const SELF_SIGNED: &str = "-----BEGIN CERTIFICATE-----\n\
1345MIIBhTCCASugAwIBAgIUExample0AAAAAAAAAAAAAAAAAAAAwCgYIKoZIzj0EAwIw\n\
1346EzERMA8GA1UEAwwIc2VsZi1jYTAeFw0yNTAxMDEwMDAwMDBaFw0zNTAxMDEwMDAw\n\
1347MDBaMBMxETAPBgNVBAMMCHNlbGYtY2EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC\n\
1348AAQX9YJlbpFy0FmCXn7gC8m/qAh3wZw9w0CIxample/Random/dataABCDEFGHIJ\n\
1349KLMNOPQRSTUVWXYZabcdefghijklmnopo1MwUTAdBgNVHQ4EFgQUExampleHandle\n\
135000000000000000000000003wHwYDVR0jBBgwFoAUExampleHandle00000000000\n\
135100000000003wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNJADBGAiEAExam\n\
1352pleSignature1234567890123456789012345678901234567890CIQDExampleS\n\
1353ignature1234567890123456789012345678901234567890Aa==\n\
1354-----END CERTIFICATE-----\n";
1355 env["signatures"][0]["cert"] = serde_json::Value::String(SELF_SIGNED.to_string());
1356 let forged = serde_json::to_string(&env).unwrap();
1357 let err = verify_intoto_envelope(&forged, b"any artifact bytes", 1, &root)
1358 .unwrap_err()
1359 .to_string();
1360 assert!(
1361 err.to_lowercase().contains("chain")
1362 || err.to_lowercase().contains("trust")
1363 || err.to_lowercase().contains("invalid"),
1364 "expected chain validation failure, got {err}"
1365 );
1366 }
1367}