Skip to main content

libverify_github/
attestation.rs

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// -- gh CLI attestation types (kept for verify_artifact) --
19
20#[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// -- GitHub Attestations REST API types --
62
63#[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
107// -- gh CLI verification (kept for backward compat) --
108
109pub 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
185// -- DSSE cryptographic verification (no binary download) --
186
187/// Compute the DSSE Pre-Authentication Encoding (PAE).
188///
189/// PAE = "DSSEv1" + SP + len(payloadType) + SP + payloadType + SP + len(payload) + SP + payload
190fn 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
203/// Verify a DSSE envelope's signature using the certificate's public key.
204///
205/// Returns Ok(()) if the signature is cryptographically valid.
206fn 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    // Parse the X.509 certificate and extract the ECDSA P-256 public key
224    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
245/// Extract subject digests and predicate type from a DSSE payload.
246fn 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
255// -- Release attestation collection --
256
257const 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
277/// Fetch attestation bundles for a given digest via the GitHub REST API.
278fn 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
295/// Verify a single attestation bundle against an expected digest.
296///
297/// Performs:
298/// 1. DSSE signature verification using the certificate's public key
299/// 2. Subject digest matching against the expected digest
300fn 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    // 1. Parse the payload to get predicate type and subject digests
325    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    // 2. Check that the expected digest appears in the attestation subjects
345    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    // 3. Verify DSSE signature using the certificate
369    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
410/// Collect and cryptographically verify attestations for release assets.
411///
412/// 1. Downloads `.sha256` sidecar files (a few KB) to obtain digests
413/// 2. Fetches attestation bundles from the GitHub Attestations REST API
414/// 3. Verifies DSSE signatures using the certificate's public key
415/// 4. Confirms subject digests match the expected sidecar digests
416///
417/// No release binaries are downloaded.
418pub 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
513/// Download `.sha256` sidecar files and build a map of asset_name -> hex_digest.
514fn 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}