Skip to main content

osdk_core/verification/
mod.rs

1//! GitHub artifact-attestation acquisition, caching, and Sigstore verification.
2//!
3//! Verification includes the Fulcio chain and SCT, certificate policy,
4//! artifact signature, DSSE digest, Rekor body consistency, Signed Entry
5//! Timestamp, checkpoint signature, Merkle inclusion proof, and signing time.
6
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9
10use base64::Engine as _;
11use futures_util::StreamExt;
12use serde::{Deserialize, Serialize};
13use sigstore::bundle::verify::{
14    policy::{
15        GitHubWorkflowRepository, OIDCIssuer, PolicyError, SingleX509ExtPolicy, VerificationPolicy,
16    },
17    Verifier,
18};
19use sigstore::bundle::Bundle;
20use sigstore::crypto::{CosignVerificationKey, Signature};
21use sigstore::rekor::models::{
22    checkpoint::SignedCheckpoint, inclusion_proof::InclusionProof as RekorInclusionProof,
23};
24use sigstore::trust::sigstore::SigstoreTrustRoot;
25use sigstore::trust::TrustRoot;
26use sigstore_verify::trust_root::{SigstoreInstance, TrustedRoot};
27use sigstore_verify::types::{Bundle as VerifiedBundle, Sha256Hash, SignatureContent};
28use sigstore_verify::VerificationPolicy as SigstoreVerificationPolicy;
29
30use crate::config::AttestationPolicy;
31use crate::dirs::Dirs;
32use crate::error::{Error, Result};
33use crate::source::Source;
34
35const GITHUB_OIDC_ISSUER: &str = "https://token.actions.githubusercontent.com";
36const MAX_BUNDLE_BYTES: usize = 8 * 1024 * 1024;
37const MAX_COMPRESSED_BUNDLE_BYTES: u64 = 8 * 1024 * 1024;
38const TRUSTED_ROOT: &[u8] = include_bytes!("data/sigstore-public-good-trusted-root.json");
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub struct VerificationEvidence {
42    pub kind: String,
43    pub repository: String,
44    pub issuer: String,
45    pub digest: String,
46}
47
48#[derive(Debug, Clone)]
49pub struct GithubAttestation {
50    pub owner: String,
51    pub repo: String,
52    pub policy: AttestationPolicy,
53    pub sources: Vec<Source>,
54}
55
56struct GithubRepositoryPolicy {
57    issuer: OIDCIssuer,
58    repository: GitHubWorkflowRepository,
59}
60
61impl VerificationPolicy for GithubRepositoryPolicy {
62    fn verify(&self, certificate: &x509_cert::Certificate) -> std::result::Result<(), PolicyError> {
63        let mut errors = Vec::new();
64        if let Err(error) = self.issuer.verify(certificate) {
65            errors.push(error);
66        }
67        if let Err(error) = self.repository.verify(certificate) {
68            errors.push(error);
69        }
70        if errors.is_empty() {
71            Ok(())
72        } else {
73            Err(PolicyError::AllOf { total: 2, errors })
74        }
75    }
76}
77
78#[derive(Debug, Deserialize)]
79struct AttestationResponse {
80    #[serde(default)]
81    attestations: Vec<AttestationEntry>,
82}
83
84#[derive(Debug, Deserialize)]
85struct AttestationEntry {
86    #[serde(default)]
87    bundle: Option<serde_json::Value>,
88    #[serde(default)]
89    bundle_url: Option<String>,
90}
91
92pub async fn verify_github_attestation(
93    client: &reqwest::Client,
94    dirs: &Dirs,
95    offline: bool,
96    artifact: &Path,
97    request: &GithubAttestation,
98) -> Result<Option<VerificationEvidence>> {
99    if request.policy == AttestationPolicy::Off {
100        return Ok(None);
101    }
102    if !artifact.is_file() {
103        if request.policy == AttestationPolicy::Required {
104            return Err(Error::other(format!(
105                "required GitHub artifact attestation cannot be verified: cached artifact missing at {}",
106                artifact.display()
107            )));
108        }
109        return Ok(None);
110    }
111    let digest = crate::pipeline::verify::hash_file(artifact, crate::pipeline::HashAlgo::Sha256)?;
112    let cache_path = bundle_cache_path(dirs, &request.owner, &request.repo, &digest);
113    let bundles = if cache_path.is_file() {
114        read_cached_bundles(&cache_path)?
115    } else {
116        if offline {
117            return missing_attestation(request, &digest, true);
118        }
119        let response = fetch_attestations(
120            client,
121            &request.owner,
122            &request.repo,
123            &digest,
124            &request.sources,
125        )
126        .await?;
127        let bundles = materialize_bundles(client, response.attestations, &request.sources).await?;
128        if !bundles.is_empty() {
129            write_cached_bundles(&cache_path, &bundles)?;
130        }
131        bundles
132    };
133
134    if bundles.is_empty() {
135        return missing_attestation(request, &digest, false);
136    }
137    verify_bundles_for_repository(artifact, &request.owner, &request.repo, &digest, bundles).await
138}
139
140fn missing_attestation(
141    request: &GithubAttestation,
142    digest: &str,
143    offline: bool,
144) -> Result<Option<VerificationEvidence>> {
145    if request.policy == AttestationPolicy::Required {
146        let qualifier = if offline { "cached " } else { "" };
147        return Err(Error::other(format!(
148            "required GitHub artifact attestation unavailable: no {qualifier}bundle for {}/{} sha256:{digest}",
149            request.owner, request.repo
150        )));
151    }
152    Ok(None)
153}
154
155async fn verify_bundles_for_repository(
156    artifact: &Path,
157    owner: &str,
158    repo: &str,
159    digest: &str,
160    bundles: Vec<Vec<u8>>,
161) -> Result<Option<VerificationEvidence>> {
162    let trust_root = SigstoreTrustRoot::from_trusted_root_json_unchecked(TRUSTED_ROOT)
163        .map_err(|error| Error::other(format!("invalid embedded Sigstore trust root: {error}")))?;
164    let rekor_keys = load_rekor_keys(&trust_root)?;
165    let verifier = Verifier::new(Default::default(), trust_root)
166        .map_err(|error| Error::other(format!("building Sigstore verifier: {error}")))?;
167    let repository = format!("{owner}/{repo}");
168    let policy = GithubRepositoryPolicy {
169        issuer: OIDCIssuer::new(GITHUB_OIDC_ISSUER),
170        repository: GitHubWorkflowRepository::new(&repository),
171    };
172
173    let mut errors = Vec::new();
174    for bundle_json in bundles {
175        let bundle: Bundle = match serde_json::from_slice(&bundle_json) {
176            Ok(bundle) => bundle,
177            Err(error) => {
178                errors.push(format!("invalid Sigstore bundle: {error}"));
179                continue;
180            }
181        };
182        if bundle
183            .verification_material
184            .as_ref()
185            .is_some_and(|material| material.tlog_entries.is_empty())
186        {
187            match verify_github_timestamp_bundle(&bundle_json, &repository, digest) {
188                Ok(()) => {
189                    return Ok(Some(VerificationEvidence {
190                        kind: "sigstore-bundle+github-tsa".into(),
191                        repository,
192                        issuer: "https://github.com".into(),
193                        digest: format!("sha256:{digest}"),
194                    }));
195                }
196                Err(error) => {
197                    errors.push(error.to_string());
198                    continue;
199                }
200            }
201        }
202        if let Err(error) = verify_rekor_transparency(&bundle, &rekor_keys) {
203            errors.push(error.to_string());
204            continue;
205        }
206        let file = tokio::fs::File::open(artifact)
207            .await
208            .map_err(|error| Error::io(artifact, error))?;
209        match verifier.verify(file, bundle, &policy, true).await {
210            Ok(()) => {
211                return Ok(Some(VerificationEvidence {
212                    kind: "sigstore-bundle+rekor".into(),
213                    repository,
214                    issuer: GITHUB_OIDC_ISSUER.into(),
215                    digest: format!("sha256:{digest}"),
216                }));
217            }
218            Err(error) => errors.push(error.to_string()),
219        }
220    }
221    Err(Error::other(format!(
222        "GitHub artifact attestation verification failed: {}",
223        errors.join("; ")
224    )))
225}
226
227fn verify_github_timestamp_bundle(
228    bundle_json: &[u8],
229    repository: &str,
230    digest: &str,
231) -> Result<()> {
232    let bundle_json = std::str::from_utf8(bundle_json)
233        .map_err(|error| Error::other(format!("invalid UTF-8 Sigstore bundle: {error}")))?;
234    let bundle = VerifiedBundle::from_json(bundle_json)
235        .map_err(|error| Error::other(format!("invalid GitHub Sigstore bundle: {error}")))?;
236    verify_signed_repository_claim(&bundle, repository)?;
237    let digest = Sha256Hash::from_hex(digest)
238        .map_err(|error| Error::other(format!("invalid artifact SHA-256 digest: {error}")))?;
239    let trust_root = TrustedRoot::from_embedded(SigstoreInstance::GitHub)
240        .map_err(|error| Error::other(format!("invalid embedded GitHub trust root: {error}")))?;
241    let policy = SigstoreVerificationPolicy::default().skip_tlog().skip_sct();
242    sigstore_verify::verify(digest, &bundle, &policy, &trust_root).map_err(|error| {
243        Error::other(format!(
244            "GitHub timestamp bundle verification failed: {error}"
245        ))
246    })?;
247    Ok(())
248}
249
250fn verify_signed_repository_claim(bundle: &VerifiedBundle, repository: &str) -> Result<()> {
251    let SignatureContent::DsseEnvelope(envelope) = &bundle.content else {
252        return Err(Error::other(
253            "GitHub timestamp bundle must contain a DSSE statement",
254        ));
255    };
256    let payload = envelope.decode_payload();
257    let statement: serde_json::Value = serde_json::from_slice(&payload)
258        .map_err(|error| Error::other(format!("invalid GitHub attestation statement: {error}")))?;
259    let claimed = statement
260        .pointer("/predicate/repository")
261        .and_then(serde_json::Value::as_str)
262        .or_else(|| {
263            statement
264                .pointer("/predicate/buildDefinition/externalParameters/workflow/repository")
265                .and_then(serde_json::Value::as_str)
266        });
267    match claimed {
268        Some(claimed) if claimed.eq_ignore_ascii_case(repository) => Ok(()),
269        Some(claimed) => Err(Error::other(format!(
270            "GitHub attestation repository mismatch: expected `{repository}`, got `{claimed}`"
271        ))),
272        None => Err(Error::other(
273            "GitHub attestation statement is missing a supported repository claim",
274        )),
275    }
276}
277
278fn load_rekor_keys(trust_root: &impl TrustRoot) -> Result<BTreeMap<String, CosignVerificationKey>> {
279    trust_root
280        .rekor_keys()
281        .map_err(|error| Error::other(format!("loading Rekor trust root keys: {error}")))?
282        .into_iter()
283        .map(|(id, bytes)| {
284            CosignVerificationKey::try_from_der(bytes)
285                .map_err(|error| Error::other(format!("invalid Rekor public key `{id}`: {error}")))
286                .map(|key| (id, key))
287        })
288        .collect()
289}
290
291fn verify_rekor_transparency(
292    bundle: &Bundle,
293    rekor_keys: &BTreeMap<String, CosignVerificationKey>,
294) -> Result<()> {
295    let material = bundle
296        .verification_material
297        .as_ref()
298        .ok_or_else(|| Error::other("Sigstore bundle missing verification material"))?;
299    let [entry] = material.tlog_entries.as_slice() else {
300        return Err(Error::other(format!(
301            "Sigstore bundle requires exactly one transparency log entry, got {}",
302            material.tlog_entries.len()
303        )));
304    };
305    let log_id = entry
306        .log_id
307        .as_ref()
308        .ok_or_else(|| Error::other("Sigstore bundle transparency entry missing log ID"))?;
309    let key_id = hex::encode(&log_id.key_id);
310    if key_id.is_empty() {
311        return Err(Error::other(
312            "Sigstore bundle transparency entry has empty log ID",
313        ));
314    }
315    let rekor_key = rekor_keys
316        .get(&key_id)
317        .ok_or_else(|| Error::other(format!("untrusted Rekor log ID `{key_id}`")))?;
318    if entry.log_index < 0 || entry.integrated_time < 0 {
319        return Err(Error::other(
320            "Sigstore bundle transparency entry has negative index or time",
321        ));
322    }
323    if entry.canonicalized_body.is_empty() {
324        return Err(Error::other(
325            "Sigstore bundle transparency entry has empty canonical body",
326        ));
327    }
328
329    let promise = entry
330        .inclusion_promise
331        .as_ref()
332        .ok_or_else(|| Error::other("Sigstore bundle missing Rekor Signed Entry Timestamp"))?;
333    if promise.signed_entry_timestamp.is_empty() {
334        return Err(Error::other(
335            "Sigstore bundle has empty Rekor Signed Entry Timestamp",
336        ));
337    }
338    let set_payload = serde_json::json!({
339        "body": base64::engine::general_purpose::STANDARD.encode(&entry.canonicalized_body),
340        "integratedTime": entry.integrated_time,
341        "logIndex": entry.log_index,
342        "logID": key_id,
343    });
344    let canonical_set = serde_json_canonicalizer::to_vec(&set_payload)
345        .map_err(|error| Error::other(format!("canonicalizing Rekor SET payload: {error}")))?;
346    rekor_key
347        .verify_signature(
348            Signature::Raw(&promise.signed_entry_timestamp),
349            &canonical_set,
350        )
351        .map_err(|error| Error::other(format!("Rekor SET verification failed: {error}")))?;
352
353    let proof = entry
354        .inclusion_proof
355        .as_ref()
356        .ok_or_else(|| Error::other("Sigstore bundle missing Rekor inclusion proof"))?;
357    if proof.log_index < 0 {
358        return Err(Error::other(format!(
359            "invalid Rekor proof index {}",
360            proof.log_index
361        )));
362    }
363    let root_hash = fixed_sha256(&proof.root_hash, "root hash")?;
364    let hashes = proof
365        .hashes
366        .iter()
367        .enumerate()
368        .map(|(index, hash)| fixed_sha256(hash, &format!("path hash {index}")))
369        .collect::<Result<Vec<_>>>()?;
370    let checkpoint = proof
371        .checkpoint
372        .as_ref()
373        .ok_or_else(|| Error::other("Sigstore bundle inclusion proof missing checkpoint"))?;
374    let signed_checkpoint: SignedCheckpoint =
375        serde_json::from_value(serde_json::Value::String(checkpoint.envelope.clone()))
376            .map_err(|error| Error::other(format!("invalid Rekor checkpoint: {error}")))?;
377    let tree_size = u64::try_from(proof.tree_size)
378        .map_err(|_| Error::other(format!("invalid Rekor tree size {}", proof.tree_size)))?;
379    let proof = RekorInclusionProof::new(
380        proof.log_index,
381        root_hash,
382        tree_size,
383        hashes,
384        Some(signed_checkpoint),
385    );
386    proof
387        .verify(&entry.canonicalized_body, rekor_key)
388        .map_err(|error| {
389            Error::other(format!(
390                "Rekor inclusion proof verification failed: {error}"
391            ))
392        })
393}
394
395fn fixed_sha256(bytes: &[u8], field: &str) -> Result<[u8; 32]> {
396    bytes
397        .try_into()
398        .map_err(|_| Error::other(format!("Rekor {field} must contain exactly 32 bytes")))
399}
400
401async fn fetch_attestations(
402    client: &reqwest::Client,
403    owner: &str,
404    repo: &str,
405    digest: &str,
406    sources: &[Source],
407) -> Result<AttestationResponse> {
408    let url = format!(
409        "https://api.github.com/repos/{owner}/{repo}/attestations/sha256:{digest}?per_page=30"
410    );
411    let urls = crate::http::github_url_candidates(sources, &url);
412    crate::http::get_github_json_from_urls(client, &urls).await
413}
414
415async fn materialize_bundles(
416    client: &reqwest::Client,
417    entries: Vec<AttestationEntry>,
418    sources: &[Source],
419) -> Result<Vec<Vec<u8>>> {
420    let mut bundles = Vec::new();
421    for entry in entries {
422        if let Some(url) = entry.bundle_url {
423            let urls = crate::http::github_url_candidates(sources, &url);
424            bundles.push(fetch_bundle_urls(client, &urls).await?);
425        } else if let Some(bundle) = entry.bundle {
426            bundles.push(serde_json::to_vec(&bundle)?);
427        } else {
428            return Err(Error::other(
429                "GitHub attestation entry has neither bundle nor bundle_url",
430            ));
431        }
432    }
433    Ok(bundles)
434}
435
436async fn fetch_bundle_urls(client: &reqwest::Client, urls: &[String]) -> Result<Vec<u8>> {
437    let mut last_error = None;
438    for url in urls {
439        match fetch_bundle_url(client, url).await {
440            Ok(bundle) => return Ok(bundle),
441            Err(error) => last_error = Some(error),
442        }
443    }
444    Err(last_error.unwrap_or_else(|| Error::other("no attestation bundle URL candidates")))
445}
446
447async fn fetch_bundle_url(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
448    let parsed = reqwest::Url::parse(url)
449        .map_err(|error| Error::other(format!("invalid attestation bundle URL: {error}")))?;
450    if parsed.scheme() != "https" {
451        return Err(Error::other("attestation bundle URL must use HTTPS"));
452    }
453    let response = crate::http::github_request(client, parsed.as_str())
454        .send()
455        .await?
456        .error_for_status()?;
457    if response.url().scheme() != "https" {
458        return Err(Error::other(
459            "attestation bundle redirect must remain on HTTPS",
460        ));
461    }
462    if response
463        .content_length()
464        .is_some_and(|length| length > MAX_COMPRESSED_BUNDLE_BYTES)
465    {
466        return Err(Error::other(format!(
467            "compressed attestation bundle exceeds {MAX_COMPRESSED_BUNDLE_BYTES} bytes"
468        )));
469    }
470    let mut compressed = Vec::new();
471    let mut stream = response.bytes_stream();
472    while let Some(chunk) = stream.next().await {
473        let chunk = chunk?;
474        if compressed.len() + chunk.len() > MAX_COMPRESSED_BUNDLE_BYTES as usize {
475            return Err(Error::other(format!(
476                "compressed attestation bundle exceeds {MAX_COMPRESSED_BUNDLE_BYTES} bytes"
477            )));
478        }
479        compressed.extend_from_slice(&chunk);
480    }
481    decode_snappy_bundle(&compressed)
482}
483
484fn decode_snappy_bundle(compressed: &[u8]) -> Result<Vec<u8>> {
485    let decoded_len = snap::raw::decompress_len(compressed)
486        .map_err(|error| Error::other(format!("invalid Snappy attestation bundle: {error}")))?;
487    if decoded_len > MAX_BUNDLE_BYTES {
488        return Err(Error::other(format!(
489            "attestation bundle exceeds {MAX_BUNDLE_BYTES} bytes"
490        )));
491    }
492    snap::raw::Decoder::new()
493        .decompress_vec(compressed)
494        .map_err(|error| Error::other(format!("invalid Snappy attestation bundle: {error}")))
495}
496
497fn bundle_cache_path(dirs: &Dirs, owner: &str, repo: &str, digest: &str) -> PathBuf {
498    dirs.remote_cache()
499        .join("attestations")
500        .join(crate::dirs::sanitize_tool_id(&format!("{owner}/{repo}")))
501        .join(format!("{digest}.json"))
502}
503
504fn write_cached_bundles(path: &Path, bundles: &[Vec<u8>]) -> Result<()> {
505    if let Some(parent) = path.parent() {
506        std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
507    }
508    let values: Vec<serde_json::Value> = bundles
509        .iter()
510        .map(|bundle| serde_json::from_slice(bundle))
511        .collect::<std::result::Result<_, _>>()?;
512    let bytes = serde_json::to_vec_pretty(&values)?;
513    let temporary = path.with_extension(format!("tmp-{}", std::process::id()));
514    std::fs::write(&temporary, bytes).map_err(|error| Error::io(&temporary, error))?;
515    match std::fs::rename(&temporary, path) {
516        Ok(()) => Ok(()),
517        Err(_) if path.is_file() => {
518            let _ = std::fs::remove_file(&temporary);
519            Ok(())
520        }
521        Err(error) => Err(Error::io(path, error)),
522    }
523}
524
525fn read_cached_bundles(path: &Path) -> Result<Vec<Vec<u8>>> {
526    let bytes = std::fs::read(path).map_err(|error| Error::io(path, error))?;
527    let values: Vec<serde_json::Value> = serde_json::from_slice(&bytes)?;
528    values
529        .iter()
530        .map(|value| serde_json::to_vec(value).map_err(Error::from))
531        .collect()
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use std::io::Read;
538
539    fn fixture_artifact() -> Vec<u8> {
540        use base64::Engine as _;
541
542        let encoded: String =
543            include_str!("../../tests/fixtures/attestation/kubewarden-manifest.json.b64")
544                .chars()
545                .filter(|character| !character.is_whitespace())
546                .collect();
547        base64::engine::general_purpose::STANDARD
548            .decode(encoded)
549            .unwrap()
550    }
551
552    fn fixture_bundle() -> Vec<u8> {
553        use base64::Engine as _;
554
555        let encoded: String =
556            include_str!("../../tests/fixtures/attestation/kubewarden.sigstore.json.gz.b64")
557                .chars()
558                .filter(|character| !character.is_whitespace())
559                .collect();
560        let compressed = base64::engine::general_purpose::STANDARD
561            .decode(encoded)
562            .unwrap();
563        let mut decoder = flate2::read::GzDecoder::new(compressed.as_slice());
564        let mut bundle = Vec::new();
565        decoder.read_to_end(&mut bundle).unwrap();
566        bundle
567    }
568
569    fn fixture_bundle_value() -> serde_json::Value {
570        serde_json::from_slice(&fixture_bundle()).unwrap()
571    }
572
573    fn github_timestamp_bundle() -> Vec<u8> {
574        let encoded: String =
575            include_str!("../../tests/fixtures/attestation/github-tsa-bundle.json.b64")
576                .chars()
577                .filter(|character| !character.is_whitespace())
578                .collect();
579        base64::engine::general_purpose::STANDARD
580            .decode(encoded)
581            .unwrap()
582    }
583
584    fn fixture_rekor_keys() -> BTreeMap<String, CosignVerificationKey> {
585        let trust_root = SigstoreTrustRoot::from_trusted_root_json_unchecked(TRUSTED_ROOT).unwrap();
586        load_rekor_keys(&trust_root).unwrap()
587    }
588
589    fn verify_fixture_transparency(bundle: serde_json::Value) -> Result<()> {
590        let bundle: Bundle = serde_json::from_value(bundle)?;
591        verify_rekor_transparency(&bundle, &fixture_rekor_keys())
592    }
593
594    fn test_dirs(root: &Path) -> Dirs {
595        let dirs = Dirs::resolve_from(|key| match key {
596            "OSDK_DATA_DIR" => Some(root.join("data").display().to_string()),
597            "OSDK_CACHE_DIR" => Some(root.join("cache").display().to_string()),
598            "OSDK_CONFIG_DIR" => Some(root.join("config").display().to_string()),
599            _ => None,
600        })
601        .unwrap();
602        dirs.ensure().unwrap();
603        dirs
604    }
605
606    async fn verify_fixture(repository: &str, artifact: &[u8]) -> Result<VerificationEvidence> {
607        let temp = tempfile::tempdir().unwrap();
608        let dirs = test_dirs(temp.path());
609        let path = temp.path().join("manifest.json");
610        std::fs::write(&path, artifact).unwrap();
611        let (owner, repo) = repository.split_once('/').unwrap();
612        let digest =
613            crate::pipeline::verify::hash_file(&path, crate::pipeline::HashAlgo::Sha256).unwrap();
614        let bundle_path = bundle_cache_path(&dirs, owner, repo, &digest);
615        write_cached_bundles(&bundle_path, &[fixture_bundle()]).unwrap();
616        let request = GithubAttestation {
617            owner: owner.into(),
618            repo: repo.into(),
619            policy: AttestationPolicy::Required,
620            sources: Vec::new(),
621        };
622        verify_github_attestation(&reqwest::Client::new(), &dirs, true, &path, &request)
623            .await?
624            .ok_or_else(|| Error::other("fixture produced no evidence"))
625    }
626
627    #[test]
628    fn identifies_non_https_bundle_urls() {
629        let parsed = reqwest::Url::parse("http://example.test/bundle").unwrap();
630        assert_ne!(parsed.scheme(), "https");
631    }
632
633    #[test]
634    fn decodes_github_snappy_bundle_payload() {
635        let bundle = fixture_bundle();
636        let compressed = snap::raw::Encoder::new().compress_vec(&bundle).unwrap();
637        assert_eq!(decode_snappy_bundle(&compressed).unwrap(), bundle);
638        assert!(decode_snappy_bundle(b"not-snappy").is_err());
639    }
640
641    #[tokio::test]
642    async fn malformed_api_entry_is_not_treated_as_unavailable() {
643        let error = materialize_bundles(
644            &reqwest::Client::new(),
645            vec![AttestationEntry {
646                bundle: None,
647                bundle_url: None,
648            }],
649            &[],
650        )
651        .await
652        .unwrap_err();
653        assert!(error.to_string().contains("neither bundle nor bundle_url"));
654    }
655
656    #[test]
657    fn embedded_trust_root_parses() {
658        SigstoreTrustRoot::from_trusted_root_json_unchecked(TRUSTED_ROOT).unwrap();
659    }
660
661    #[tokio::test]
662    async fn verifies_offline_github_actions_bundle() {
663        let evidence = verify_fixture("kubewarden/kubewarden-controller", &fixture_artifact())
664            .await
665            .unwrap();
666        assert_eq!(
667            evidence,
668            VerificationEvidence {
669                kind: "sigstore-bundle+rekor".into(),
670                repository: "kubewarden/kubewarden-controller".into(),
671                issuer: GITHUB_OIDC_ISSUER.into(),
672                digest: "sha256:c811d58de79c92f03214e63aa339484e488d694ae8a6283b5f3f17a9faf50172"
673                    .into(),
674            }
675        );
676    }
677
678    #[test]
679    fn verifies_github_timestamp_bundle_without_rekor_entry() {
680        verify_github_timestamp_bundle(
681            &github_timestamp_bundle(),
682            "jdx/communique",
683            "b958c6046bab52febf958c94974e1ffcc450bff78c28d7233e179bfd73828912",
684        )
685        .unwrap();
686    }
687
688    #[test]
689    fn rejects_wrong_repository_for_github_timestamp_bundle() {
690        let error = verify_github_timestamp_bundle(
691            &github_timestamp_bundle(),
692            "jdx/other",
693            "b958c6046bab52febf958c94974e1ffcc450bff78c28d7233e179bfd73828912",
694        )
695        .unwrap_err();
696        assert!(error.to_string().contains("repository mismatch"));
697    }
698
699    #[test]
700    fn rejects_bundle_without_rekor_inclusion_proof() {
701        let mut bundle = fixture_bundle_value();
702        bundle["verificationMaterial"]["tlogEntries"][0]
703            .as_object_mut()
704            .unwrap()
705            .remove("inclusionProof");
706
707        let error = verify_fixture_transparency(bundle).unwrap_err();
708        assert!(error.to_string().contains("missing Rekor inclusion proof"));
709    }
710
711    #[test]
712    fn rejects_tampered_rekor_inclusion_proof() {
713        let mut bundle = fixture_bundle_value();
714        let encoded = bundle["verificationMaterial"]["tlogEntries"][0]["inclusionProof"]["hashes"]
715            [0]
716        .as_str()
717        .unwrap();
718        let mut hash = base64::engine::general_purpose::STANDARD
719            .decode(encoded)
720            .unwrap();
721        hash[0] ^= 0xff;
722        bundle["verificationMaterial"]["tlogEntries"][0]["inclusionProof"]["hashes"][0] =
723            serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(hash));
724
725        let error = verify_fixture_transparency(bundle).unwrap_err();
726        assert!(error
727            .to_string()
728            .contains("Rekor inclusion proof verification failed"));
729    }
730
731    #[test]
732    fn rejects_tampered_rekor_checkpoint() {
733        let mut bundle = fixture_bundle_value();
734        let envelope = bundle["verificationMaterial"]["tlogEntries"][0]["inclusionProof"]
735            ["checkpoint"]["envelope"]
736            .as_str()
737            .unwrap();
738        let (note, signatures) = envelope.split_once("\n\n").unwrap();
739        let mut parts = signatures.trim().splitn(3, ' ');
740        let dash = parts.next().unwrap();
741        let name = parts.next().unwrap();
742        let encoded = parts.next().unwrap();
743        let mut signature = base64::engine::general_purpose::STANDARD
744            .decode(encoded)
745            .unwrap();
746        signature[4] ^= 0xff;
747        let checkpoint = format!(
748            "{note}\n\n{dash} {name} {}\n",
749            base64::engine::general_purpose::STANDARD.encode(signature)
750        );
751        bundle["verificationMaterial"]["tlogEntries"][0]["inclusionProof"]["checkpoint"]
752            ["envelope"] = serde_json::Value::String(checkpoint);
753
754        let error = verify_fixture_transparency(bundle).unwrap_err();
755        assert!(error
756            .to_string()
757            .contains("Rekor inclusion proof verification failed"));
758    }
759
760    #[test]
761    fn rejects_invalid_rekor_signed_entry_timestamp() {
762        let mut bundle = fixture_bundle_value();
763        let encoded = bundle["verificationMaterial"]["tlogEntries"][0]["inclusionPromise"]
764            ["signedEntryTimestamp"]
765            .as_str()
766            .unwrap();
767        let mut signature = base64::engine::general_purpose::STANDARD
768            .decode(encoded)
769            .unwrap();
770        signature[0] ^= 0xff;
771        bundle["verificationMaterial"]["tlogEntries"][0]["inclusionPromise"]
772            ["signedEntryTimestamp"] =
773            serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(signature));
774
775        let error = verify_fixture_transparency(bundle).unwrap_err();
776        assert!(error.to_string().contains("Rekor SET verification failed"));
777    }
778
779    #[tokio::test]
780    async fn attestation_supplies_required_checksum_and_receipt_evidence() {
781        let temp = tempfile::tempdir().unwrap();
782        let dirs = test_dirs(temp.path());
783        let tool = "github:kubewarden/kubewarden-controller";
784        let version = "1.34.0";
785        let file_name = "manifest.json";
786        let artifact =
787            crate::pipeline::artifact_cache_path(&dirs, tool, version, file_name).unwrap();
788        std::fs::create_dir_all(artifact.parent().unwrap()).unwrap();
789        std::fs::write(&artifact, fixture_artifact()).unwrap();
790        let digest =
791            crate::pipeline::verify::hash_file(&artifact, crate::pipeline::HashAlgo::Sha256)
792                .unwrap();
793        let bundle_path = bundle_cache_path(&dirs, "kubewarden", "kubewarden-controller", &digest);
794        write_cached_bundles(&bundle_path, &[fixture_bundle()]).unwrap();
795        let request = GithubAttestation {
796            owner: "kubewarden".into(),
797            repo: "kubewarden-controller".into(),
798            policy: AttestationPolicy::Required,
799            sources: Vec::new(),
800        };
801
802        crate::pipeline::install_single_binary(
803            &reqwest::Client::new(),
804            &dirs,
805            tool,
806            version,
807            &["https://invalid.example/manifest.json".into()],
808            "kubewarden-controller",
809            file_name,
810            crate::platform::Os::Linux,
811            None,
812            false,
813            true,
814            true,
815            Some(&request),
816        )
817        .await
818        .unwrap();
819
820        let receipt = crate::pipeline::artifact_receipt(&dirs, tool, version).unwrap();
821        assert_eq!(receipt.checksum, Some(format!("sha256:{digest}")));
822        assert_eq!(receipt.evidence.len(), 1);
823        assert_eq!(receipt.evidence[0].digest, format!("sha256:{digest}"));
824        assert!(crate::pipeline::is_installed(&dirs, tool, version));
825    }
826
827    #[tokio::test]
828    async fn rejects_wrong_repository_identity() {
829        let error = verify_fixture("kubewarden/other", &fixture_artifact())
830            .await
831            .unwrap_err();
832        assert!(error.to_string().contains("verification failed"));
833    }
834
835    #[tokio::test]
836    async fn rejects_tampered_artifact() {
837        let mut artifact = fixture_artifact();
838        artifact[0] ^= 0xff;
839        let error = verify_fixture("kubewarden/kubewarden-controller", &artifact)
840            .await
841            .unwrap_err();
842        assert!(error.to_string().contains("verification failed"));
843    }
844
845    #[test]
846    fn required_policy_rejects_missing_cached_bundle() {
847        let request = GithubAttestation {
848            owner: "prefix-dev".into(),
849            repo: "sigstore-example".into(),
850            policy: AttestationPolicy::Required,
851            sources: Vec::new(),
852        };
853        let error = missing_attestation(&request, "00", true).unwrap_err();
854        assert!(error.to_string().contains("no cached bundle"));
855
856        let permissive = GithubAttestation {
857            policy: AttestationPolicy::IfAvailable,
858            ..request
859        };
860        assert_eq!(missing_attestation(&permissive, "00", true).unwrap(), None);
861    }
862
863    #[test]
864    fn receipt_evidence_round_trips() {
865        let evidence = VerificationEvidence {
866            kind: "sigstore-bundle+rekor".into(),
867            repository: "cli/cli".into(),
868            issuer: GITHUB_OIDC_ISSUER.into(),
869            digest: "sha256:00".into(),
870        };
871        let encoded = serde_json::to_vec(&evidence).unwrap();
872        assert_eq!(
873            serde_json::from_slice::<VerificationEvidence>(&encoded).unwrap(),
874            evidence
875        );
876    }
877}