Skip to main content

mise_sigstore/
lib.rs

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) = &params.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) = &params.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    // Legacy cosign v1 bundle (`{base64Signature, cert, rekorBundle}`).
404    // sigstore-verify only consumes the modern bundle shape, so we verify
405    // these manually: chain-validate the embedded cert against Sigstore
406    // Fulcio, then ECDSA-verify the signature over the artifact bytes.
407    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    // Read the file once, propagating real I/O errors. Only a JSON-parse
421    // failure means "this isn't a sigstore bundle, treat it as a raw `.sig`."
422    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        // Bundle path: needs the trust root for tlog (Rekor) verification.
428        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    // Raw `.sig` path: only needs the local public key — no network access.
445    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        // Bundle::from_json failure falls through to the DSSE envelope path.
492        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        // slsa-github-generator and goreleaser write the provenance as a raw
505        // DSSE envelope (`*.intoto.jsonl`) rather than a sigstore bundle —
506        // there is no `verificationMaterial`, so `Bundle::from_json` rejects
507        // it. Match the in-toto payload manually and check artifact digest +
508        // SLSA predicate without going through sigstore-verify. Use the public
509        // Sigstore trust root since slsa-github-generator certs are issued by
510        // Sigstore Fulcio.
511        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    // DSSE signature verification. The envelope's signatures sign the
570    // Pre-Authentication Encoding of the payload, not the payload itself.
571    // Without this check, anyone able to substitute the provenance file could
572    // forge a passing attestation just by including the artifact's digest in
573    // the in-toto subject list.
574    //
575    // Each signature embeds the Sigstore Fulcio leaf cert that signed it
576    // (slsa-github-generator format). We chain-validate that cert against the
577    // public Sigstore trust root, then verify the signature against the PAE
578    // using the cert's public key. A self-signed forged cert would be
579    // rejected at the chain step. Bundles in the modern sigstore format
580    // (which carry tlog/TSA) take the strict `verify_bundle` path above.
581    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
614/// Verify a legacy cosign v1 keyless bundle (`{base64Signature, cert, rekorBundle}`).
615///
616/// Cosign 2.x and earlier `cosign sign-blob --bundle` writes this format. The
617/// modern sigstore Bundle (with `verificationMaterial`/`messageSignature`)
618/// replaces it, but tools like goreleaser still produce v1 bundles in their
619/// release artifacts. Verification mirrors what we do for raw DSSE envelopes:
620/// decode the embedded Fulcio cert (PEM in `cert`), chain-validate it against
621/// the public Sigstore trust root, then ECDSA-verify `base64Signature` over
622/// the raw artifact bytes with the cert's public key.
623///
624/// The Rekor `SignedEntryTimestamp` and the artifact hash recorded in the
625/// rekord entry aren't independently re-checked here — re-verifying them
626/// would require a Rekor public key lookup and adds little: the cert+sig
627/// step already cryptographically binds the signer to the artifact bytes,
628/// which is what every downstream consumer cares about.
629fn 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    // Chain-validate the embedded cert before trusting its public key.
684    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    // sigstore-verify's default policy *requires* an inclusion proof when
870    // `verify_tlog` is on. GitHub artifact attestations and TSA-only bundles
871    // never carry one, so we'd reject them outright. Skip tlog only when the
872    // bundle has no inclusion proof — public-Sigstore cosign bundles, which do
873    // ship a Rekor inclusion proof, still get full tlog verification (Rekor
874    // checkpoint signature, SET, inclusion-proof Merkle path).
875    if !bundle.has_inclusion_proof() {
876        policy = policy.skip_tlog();
877    }
878    // GitHub-internal leaf certs don't carry an SCT extension (GitHub's CA
879    // doesn't log to public CT). `skip_sct` keeps full certificate-chain
880    // validation against the GitHub trust root's Fulcio certs but turns off
881    // the SCT check, which is exactly what GitHub artifact attestations need.
882    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
904/// Verify that a leaf certificate chains to one of the trust root's CA certs.
905///
906/// Used for raw DSSE envelopes (`*.intoto.jsonl` from slsa-github-generator),
907/// which don't have the bundle structure sigstore-verify expects, so we can't
908/// delegate to `sigstore_verify::verify`. GitHub-internal bundles go through
909/// sigstore-verify directly with `skip_sct`.
910///
911/// webpki performs the same chain-building, ECDSA/RSA signature checks, and
912/// CODE_SIGNING EKU enforcement as sigstore-verify, just without the SCT step.
913///
914/// Validation time is the leaf cert's `notAfter`. Fulcio leaves are
915/// short-lived (~10 min) so by `now()` they're already expired and we have no
916/// independently verified time source here. Using `notAfter` rather than
917/// `notBefore` is the stricter choice: it catches any intermediate CA whose
918/// own validity ends before the leaf's, which would otherwise slip through.
919fn 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    // Use every CA cert in the trust root as both a trust anchor and as a
945    // possible intermediate. `anchor_from_trusted_cert` accepts any parseable
946    // cert (not just self-signed roots), and that's intentional: we trust the
947    // whole CA bundle the trust root ships, so it's fine for chain validation
948    // to terminate at an intermediate rather than walk all the way up to the
949    // self-signed root. This matches what sigstore-verify does internally.
950    // The chain itself is still cryptographically verified end-to-end.
951    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    // 1.3.6.1.5.5.7.3.3 — id-kp-codeSigning, raw OID bytes (no DER tag/length).
975    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
993/// Return the X.509 Issuer's `O` (organizationName) attribute, if present.
994///
995/// Used to dispatch verification policy: certs issued by GitHub's internal
996/// Fulcio (`O=GitHub, Inc.`) need a separate trust root and a relaxed policy.
997/// Parses the cert with x509-cert rather than byte-searching the DER, so we
998/// only match the actual issuer organization field — not arbitrary substrings
999/// elsewhere in the certificate.
1000fn 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            // 2.5.4.10 = id-at-organizationName
1007            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
1026/// Extract the SubjectPublicKeyInfo bytes (DER) from an X.509 certificate.
1027fn 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/// Per-process cache so we only fetch the Sigstore TUF root or parse the
1049/// embedded GitHub trusted root once per `verify_*` invocation. Each is
1050/// loaded lazily — a verification flow that only ever sees GitHub bundles
1051/// never triggers a network call to the Sigstore TUF CDN, and vice versa.
1052#[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
1102/// SLSA-specific checks once `verify_bundle` has cryptographically verified
1103/// the bundle: the DSSE payload is an SLSA provenance statement, the policy
1104/// level is supported, and the artifact's SHA-256 appears in the statement's
1105/// `subject` array. The subject check is the load-bearing part — without it,
1106/// a valid SLSA bundle signed for *some* artifact would accept *any* artifact.
1107fn 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    /// A genuine `*.intoto.jsonl` produced by slsa-github-generator (sops
1240    /// v3.9.0 release). Signed by Sigstore Fulcio. Tests that don't need a
1241    /// matching artifact can run against this fixture alone.
1242    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        // Signature verification happens before the subject digest check, so a
1294        // forged sig fails regardless of which artifact bytes we pass.
1295        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        // Genuine signature verifies, but a foreign artifact is not in subjects.
1320        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        // Replace the embedded Fulcio cert with an unrelated self-signed cert
1338        // and a recomputed signature. Chain validation must reject it.
1339        let root = embedded_sigstore_root();
1340        let mut env: serde_json::Value =
1341            serde_json::from_str(GENUINE_INTOTO_ENVELOPE.trim()).unwrap();
1342        // A self-signed P-256 cert (any will do — the issuer doesn't chain to
1343        // the Sigstore trust root).
1344        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}