1use anyhow::{Context, Result, bail};
2use base64::Engine;
3use base64::engine::general_purpose::STANDARD as BASE64;
4use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
5use serde::Deserialize;
6use std::collections::HashMap;
7use std::process::Command;
8use x509_cert::Certificate;
9use x509_cert::der::Decode;
10
11use libverify_core::evidence::{
12 ArtifactAttestation, EvidenceGap, EvidenceState, VerificationOutcome,
13};
14
15use crate::client::GitHubClient;
16use crate::types::ReleaseAsset;
17
18#[derive(Debug, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct GhAttestationOutput {
23 pub verification_result: GhVerificationResult,
24}
25
26#[derive(Debug, Deserialize)]
27pub struct GhVerificationResult {
28 pub statement: Statement,
29 pub signature: Option<SignatureInfo>,
30}
31
32#[derive(Debug, Deserialize)]
33pub struct Statement {
34 #[serde(rename = "predicateType")]
35 pub predicate_type: String,
36 #[serde(default)]
37 pub subject: Vec<StatementSubject>,
38}
39
40#[derive(Debug, Deserialize)]
41pub struct StatementSubject {
42 #[serde(default)]
43 pub name: String,
44 #[serde(default)]
45 pub digest: HashMap<String, String>,
46}
47
48#[derive(Debug, Deserialize)]
49pub struct SignatureInfo {
50 pub certificate: Option<CertificateInfo>,
51}
52
53#[derive(Debug, Deserialize)]
54pub struct CertificateInfo {
55 #[serde(rename = "sourceRepositoryURI")]
56 pub source_repository_uri: Option<String>,
57 #[serde(rename = "buildSignerURI")]
58 pub build_signer_uri: Option<String>,
59}
60
61#[derive(Debug, Deserialize)]
64struct AttestationsApiResponse {
65 #[serde(default)]
66 attestations: Vec<ApiAttestation>,
67}
68
69#[derive(Debug, Deserialize)]
70struct ApiAttestation {
71 bundle: Option<ApiBundle>,
72}
73
74#[derive(Debug, Clone, Deserialize)]
75struct ApiBundle {
76 #[serde(rename = "dsseEnvelope")]
77 dsse_envelope: Option<DsseEnvelope>,
78 #[serde(rename = "verificationMaterial")]
79 verification_material: Option<VerificationMaterial>,
80}
81
82#[derive(Debug, Clone, Deserialize)]
83struct DsseEnvelope {
84 payload: String,
85 #[serde(rename = "payloadType")]
86 payload_type: String,
87 #[serde(default)]
88 signatures: Vec<DsseSignature>,
89}
90
91#[derive(Debug, Clone, Deserialize)]
92struct DsseSignature {
93 sig: String,
94}
95
96#[derive(Debug, Clone, Deserialize)]
97struct VerificationMaterial {
98 certificate: Option<CertificateRaw>,
99}
100
101#[derive(Debug, Clone, Deserialize)]
102struct CertificateRaw {
103 #[serde(rename = "rawBytes")]
104 raw_bytes: String,
105}
106
107pub fn verify_artifact(
110 artifact: &str,
111 owner: Option<&str>,
112 repo: Option<&str>,
113) -> Result<Vec<GhAttestationOutput>> {
114 let mut cmd = Command::new("gh");
115 cmd.args(["attestation", "verify", artifact, "--format", "json"]);
116
117 if let Some(r) = repo {
118 cmd.args(["--repo", r]);
119 } else if let Some(o) = owner {
120 cmd.args(["--owner", o]);
121 } else {
122 bail!("either --owner or --repo is required for attestation verification");
123 }
124
125 let output = cmd
126 .output()
127 .context("failed to execute `gh attestation verify`")?;
128
129 if !output.status.success() {
130 let stderr = String::from_utf8_lossy(&output.stderr);
131 bail!("gh attestation verify failed: {stderr}");
132 }
133
134 let stdout = String::from_utf8(output.stdout).context("invalid UTF-8 in gh output")?;
135 let results: Vec<GhAttestationOutput> =
136 serde_json::from_str(&stdout).context("failed to parse gh attestation verify output")?;
137
138 Ok(results)
139}
140
141pub fn to_artifact_attestations(
142 artifact: &str,
143 results: &[GhAttestationOutput],
144 subject_digest: Option<String>,
145) -> Vec<ArtifactAttestation> {
146 results
147 .iter()
148 .map(|r| {
149 let cert = r
150 .verification_result
151 .signature
152 .as_ref()
153 .and_then(|s| s.certificate.as_ref());
154
155 let claimed_digest = r
156 .verification_result
157 .statement
158 .subject
159 .iter()
160 .find(|s| s.name == artifact)
161 .and_then(|s| s.digest.get("sha256"))
162 .map(|hex| format!("sha256:{hex}"));
163
164 let verification = match (&subject_digest, &claimed_digest) {
165 (Some(local), Some(claimed)) if local != claimed => {
166 VerificationOutcome::SignatureInvalid {
167 detail: format!("digest mismatch: local={local}, attestation={claimed}"),
168 }
169 }
170 _ => VerificationOutcome::Verified,
171 };
172
173 ArtifactAttestation {
174 subject: artifact.to_string(),
175 subject_digest: subject_digest.clone(),
176 predicate_type: r.verification_result.statement.predicate_type.clone(),
177 signer_workflow: cert.and_then(|c| c.build_signer_uri.clone()),
178 source_repo: cert.and_then(|c| c.source_repository_uri.clone()),
179 verification,
180 }
181 })
182 .collect()
183}
184
185fn dsse_pae(payload_type: &str, payload: &[u8]) -> Vec<u8> {
191 let mut pae = Vec::new();
192 pae.extend_from_slice(b"DSSEv1 ");
193 pae.extend_from_slice(payload_type.len().to_string().as_bytes());
194 pae.push(b' ');
195 pae.extend_from_slice(payload_type.as_bytes());
196 pae.push(b' ');
197 pae.extend_from_slice(payload.len().to_string().as_bytes());
198 pae.push(b' ');
199 pae.extend_from_slice(payload);
200 pae
201}
202
203fn verify_dsse_signature(envelope: &DsseEnvelope, cert_der: &[u8]) -> Result<()> {
207 let sig_b64 = &envelope
208 .signatures
209 .first()
210 .context("no signatures in DSSE envelope")?
211 .sig;
212
213 let sig_bytes = BASE64
214 .decode(sig_b64)
215 .context("invalid base64 in signature")?;
216
217 let payload_bytes = BASE64
218 .decode(&envelope.payload)
219 .context("invalid base64 in payload")?;
220
221 let pae = dsse_pae(&envelope.payload_type, &payload_bytes);
222
223 let cert = Certificate::from_der(cert_der).context("failed to parse X.509 certificate")?;
225 let spki = cert
226 .tbs_certificate
227 .subject_public_key_info
228 .subject_public_key
229 .as_bytes()
230 .context("missing public key bytes")?;
231
232 let verifying_key =
233 VerifyingKey::from_sec1_bytes(spki).context("failed to parse ECDSA P-256 public key")?;
234
235 let signature =
236 Signature::from_der(&sig_bytes).context("failed to parse ECDSA DER signature")?;
237
238 verifying_key
239 .verify(&pae, &signature)
240 .map_err(|e| anyhow::anyhow!("DSSE signature verification failed: {e}"))?;
241
242 Ok(())
243}
244
245fn parse_dsse_payload(envelope: &DsseEnvelope) -> Result<Statement> {
247 let payload_bytes = BASE64
248 .decode(&envelope.payload)
249 .context("invalid base64 in payload")?;
250 let stmt: Statement =
251 serde_json::from_slice(&payload_bytes).context("failed to parse in-toto statement")?;
252 Ok(stmt)
253}
254
255const SKIP_EXTENSIONS: &[&str] = &[".sha256", ".sha512", ".md5", ".sig", ".asc", ".pem"];
258const SKIP_NAMES: &[&str] = &["sha256.sum", "sha512.sum", "checksums.txt"];
259
260fn is_attestation_irrelevant(name: &str) -> bool {
261 let lower = name.to_lowercase();
262 SKIP_EXTENSIONS.iter().any(|ext| lower.ends_with(ext))
263 || SKIP_NAMES.iter().any(|n| lower == *n)
264 || lower.ends_with(".sh")
265 || lower.ends_with(".ps1")
266}
267
268fn parse_sha256_line(line: &str) -> Option<&str> {
269 let hex = line.split_whitespace().next()?;
270 if hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
271 Some(hex)
272 } else {
273 None
274 }
275}
276
277fn fetch_attestation_bundles(
279 client: &GitHubClient,
280 owner: &str,
281 repo: &str,
282 digest: &str,
283) -> Result<Vec<ApiBundle>> {
284 let path = format!("/repos/{owner}/{repo}/attestations/sha256:{digest}");
285 let body = client.get(&path)?;
286 let resp: AttestationsApiResponse =
287 serde_json::from_str(&body).context("failed to parse attestations API response")?;
288 Ok(resp
289 .attestations
290 .into_iter()
291 .filter_map(|a| a.bundle)
292 .collect())
293}
294
295fn verify_bundle(
301 bundle: &ApiBundle,
302 asset_name: &str,
303 expected_digest: &str,
304) -> (ArtifactAttestation, Option<String>) {
305 let envelope = match &bundle.dsse_envelope {
306 Some(e) => e,
307 None => {
308 return (
309 ArtifactAttestation {
310 subject: asset_name.to_string(),
311 subject_digest: Some(format!("sha256:{expected_digest}")),
312 predicate_type: String::new(),
313 signer_workflow: None,
314 source_repo: None,
315 verification: VerificationOutcome::Failed {
316 detail: "attestation bundle missing DSSE envelope".to_string(),
317 },
318 },
319 None,
320 );
321 }
322 };
323
324 let stmt = match parse_dsse_payload(envelope) {
326 Ok(s) => s,
327 Err(e) => {
328 return (
329 ArtifactAttestation {
330 subject: asset_name.to_string(),
331 subject_digest: Some(format!("sha256:{expected_digest}")),
332 predicate_type: String::new(),
333 signer_workflow: None,
334 source_repo: None,
335 verification: VerificationOutcome::Failed {
336 detail: format!("failed to parse DSSE payload: {e}"),
337 },
338 },
339 None,
340 );
341 }
342 };
343
344 let digest_matched = stmt
346 .subject
347 .iter()
348 .any(|s| s.digest.get("sha256").is_some_and(|d| d == expected_digest));
349
350 if !digest_matched {
351 return (
352 ArtifactAttestation {
353 subject: asset_name.to_string(),
354 subject_digest: Some(format!("sha256:{expected_digest}")),
355 predicate_type: stmt.predicate_type,
356 signer_workflow: None,
357 source_repo: None,
358 verification: VerificationOutcome::SignatureInvalid {
359 detail: format!(
360 "digest sha256:{expected_digest} not found in attestation subjects"
361 ),
362 },
363 },
364 None,
365 );
366 }
367
368 let sig_result = bundle
370 .verification_material
371 .as_ref()
372 .and_then(|vm| vm.certificate.as_ref())
373 .map(|cert_raw| {
374 let cert_der = BASE64
375 .decode(&cert_raw.raw_bytes)
376 .context("invalid base64 in certificate")?;
377 verify_dsse_signature(envelope, &cert_der)
378 });
379
380 let verification = match sig_result {
381 Some(Ok(())) => VerificationOutcome::Verified,
382 Some(Err(e)) => VerificationOutcome::SignatureInvalid {
383 detail: format!("{e}"),
384 },
385 None => VerificationOutcome::Failed {
386 detail: "no certificate in verification material".to_string(),
387 },
388 };
389
390 let error_detail = match &verification {
391 VerificationOutcome::Verified => None,
392 VerificationOutcome::SignatureInvalid { detail }
393 | VerificationOutcome::Failed { detail } => Some(detail.clone()),
394 _ => None,
395 };
396
397 (
398 ArtifactAttestation {
399 subject: asset_name.to_string(),
400 subject_digest: Some(format!("sha256:{expected_digest}")),
401 predicate_type: stmt.predicate_type,
402 signer_workflow: None,
403 source_repo: None,
404 verification,
405 },
406 error_detail,
407 )
408}
409
410pub fn collect_release_attestations(
419 owner: &str,
420 repo: &str,
421 tag: &str,
422 assets: &[ReleaseAsset],
423 client: &GitHubClient,
424) -> EvidenceState<Vec<ArtifactAttestation>> {
425 if assets.is_empty() {
426 return EvidenceState::not_applicable();
427 }
428
429 let verifiable: Vec<&ReleaseAsset> = assets
430 .iter()
431 .filter(|a| !is_attestation_irrelevant(&a.name))
432 .collect();
433
434 if verifiable.is_empty() {
435 return EvidenceState::not_applicable();
436 }
437
438 let digest_map = collect_sidecar_digests(owner, repo, tag, assets);
439
440 let mut attestations = Vec::new();
441 let mut gaps: Vec<EvidenceGap> = Vec::new();
442
443 for asset in &verifiable {
444 let digest = match digest_map.get(asset.name.as_str()) {
445 Some(d) => d.clone(),
446 None => {
447 gaps.push(EvidenceGap::CollectionFailed {
448 source: "gh-attestation-api".to_string(),
449 subject: asset.name.clone(),
450 detail: "no .sha256 sidecar file found for digest lookup".to_string(),
451 });
452 attestations.push(ArtifactAttestation {
453 subject: asset.name.clone(),
454 subject_digest: None,
455 predicate_type: String::new(),
456 signer_workflow: None,
457 source_repo: None,
458 verification: VerificationOutcome::Failed {
459 detail: "cannot verify without digest".to_string(),
460 },
461 });
462 continue;
463 }
464 };
465
466 match fetch_attestation_bundles(client, owner, repo, &digest) {
467 Ok(bundles) if !bundles.is_empty() => {
468 for bundle in &bundles {
469 let (att, err) = verify_bundle(bundle, &asset.name, &digest);
470 if let Some(detail) = err {
471 gaps.push(EvidenceGap::CollectionFailed {
472 source: "dsse-verification".to_string(),
473 subject: asset.name.clone(),
474 detail,
475 });
476 }
477 attestations.push(att);
478 }
479 }
480 Ok(_) => {
481 attestations.push(ArtifactAttestation {
482 subject: asset.name.clone(),
483 subject_digest: Some(format!("sha256:{digest}")),
484 predicate_type: String::new(),
485 signer_workflow: None,
486 source_repo: None,
487 verification: VerificationOutcome::AttestationAbsent {
488 detail: "no attestation found via API".to_string(),
489 },
490 });
491 }
492 Err(e) => {
493 let detail = format!("{e}");
494 attestations.push(ArtifactAttestation {
495 subject: asset.name.clone(),
496 subject_digest: Some(format!("sha256:{digest}")),
497 predicate_type: String::new(),
498 signer_workflow: None,
499 source_repo: None,
500 verification: classify_verification_error(&detail),
501 });
502 }
503 }
504 }
505
506 if gaps.is_empty() {
507 EvidenceState::complete(attestations)
508 } else {
509 EvidenceState::partial(attestations, gaps)
510 }
511}
512
513fn collect_sidecar_digests(
515 owner: &str,
516 repo: &str,
517 tag: &str,
518 assets: &[ReleaseAsset],
519) -> HashMap<String, String> {
520 let sidecar_names: Vec<&str> = assets
521 .iter()
522 .filter(|a| a.name.ends_with(".sha256"))
523 .map(|a| a.name.as_str())
524 .collect();
525
526 if sidecar_names.is_empty() {
527 return HashMap::new();
528 }
529
530 let tmp_dir = match tempfile::tempdir() {
531 Ok(d) => d,
532 Err(_) => return HashMap::new(),
533 };
534
535 let repo_full = format!("{owner}/{repo}");
536 let mut cmd = Command::new("gh");
537 cmd.args(["release", "download", tag, "--repo", &repo_full]);
538 for name in &sidecar_names {
539 cmd.args(["--pattern", name]);
540 }
541 cmd.args(["--dir", &tmp_dir.path().to_string_lossy(), "--clobber"]);
542
543 if cmd.output().map(|o| o.status.success()).unwrap_or(false) {
544 let mut map = HashMap::new();
545 for name in &sidecar_names {
546 let path = tmp_dir.path().join(name);
547 if let Ok(content) = std::fs::read_to_string(&path)
548 && let Some(line) = content.lines().next()
549 && let Some(hex) = parse_sha256_line(line)
550 {
551 let asset_name = name.trim_end_matches(".sha256");
552 map.insert(asset_name.to_string(), hex.to_string());
553 }
554 }
555 map
556 } else {
557 HashMap::new()
558 }
559}
560
561fn classify_verification_error(detail: &str) -> VerificationOutcome {
562 let lower = detail.to_lowercase();
563 if lower.contains("no attestation") || lower.contains("not found") {
564 VerificationOutcome::AttestationAbsent {
565 detail: detail.to_string(),
566 }
567 } else if lower.contains("signature") || lower.contains("cosign") {
568 VerificationOutcome::SignatureInvalid {
569 detail: detail.to_string(),
570 }
571 } else if lower.contains("transparency") || lower.contains("rekor") || lower.contains("tlog") {
572 VerificationOutcome::TransparencyLogMissing {
573 detail: detail.to_string(),
574 }
575 } else if lower.contains("signer") || lower.contains("identity") || lower.contains("issuer") {
576 VerificationOutcome::SignerMismatch {
577 detail: detail.to_string(),
578 }
579 } else {
580 VerificationOutcome::Failed {
581 detail: detail.to_string(),
582 }
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589
590 #[test]
591 fn empty_assets_returns_not_applicable() {
592 let client = mock_client();
593 let result = collect_release_attestations("owner", "repo", "v1.0.0", &[], &client);
594 assert!(matches!(result, EvidenceState::NotApplicable));
595 }
596
597 #[test]
598 fn filter_skips_checksums_and_scripts() {
599 assert!(is_attestation_irrelevant("ruff-x86_64.tar.gz.sha256"));
600 assert!(is_attestation_irrelevant("binary.sig"));
601 assert!(is_attestation_irrelevant("sha256.sum"));
602 assert!(is_attestation_irrelevant("installer.sh"));
603 assert!(is_attestation_irrelevant("installer.ps1"));
604 assert!(!is_attestation_irrelevant("ruff-x86_64-linux.tar.gz"));
605 assert!(!is_attestation_irrelevant("dist-manifest.json"));
606 }
607
608 #[test]
609 fn only_checksums_returns_not_applicable() {
610 let client = mock_client();
611 let assets = vec![
612 ReleaseAsset {
613 name: "file.sha256".to_string(),
614 browser_download_url: String::new(),
615 },
616 ReleaseAsset {
617 name: "sha256.sum".to_string(),
618 browser_download_url: String::new(),
619 },
620 ];
621 let result = collect_release_attestations("owner", "repo", "v1.0.0", &assets, &client);
622 assert!(matches!(result, EvidenceState::NotApplicable));
623 }
624
625 #[test]
626 fn parse_sha256_line_formats() {
627 assert_eq!(
628 parse_sha256_line(
629 "e573cdb504fce521af501cc16b7018fb6560ac0e7af5d05056c942b3a1ad5a79 ruff-aarch64-apple-darwin.tar.gz"
630 ),
631 Some("e573cdb504fce521af501cc16b7018fb6560ac0e7af5d05056c942b3a1ad5a79")
632 );
633 assert_eq!(
634 parse_sha256_line(
635 "beb2eb063e52f197694fb79045cef276735a7becbbd8f8f79e1c99613a12d7e7 *ruff-aarch64-pc-windows-msvc.zip"
636 ),
637 Some("beb2eb063e52f197694fb79045cef276735a7becbbd8f8f79e1c99613a12d7e7")
638 );
639 assert_eq!(parse_sha256_line("not-a-hash file.txt"), None);
640 }
641
642 #[test]
643 fn dsse_pae_encoding() {
644 let pae = dsse_pae("application/vnd.in-toto+json", b"{}");
645 let expected = b"DSSEv1 28 application/vnd.in-toto+json 2 {}";
646 assert_eq!(pae, expected);
647 }
648
649 fn mock_client() -> GitHubClient {
650 let cfg = crate::config::GitHubConfig {
651 token: String::new(),
652 repo: String::new(),
653 host: "https://api.github.com".to_string(),
654 };
655 GitHubClient::new(&cfg).unwrap()
656 }
657}