1use 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}