diff --git a/crates/rattler_build_recipe/src/stage0/source.rs b/crates/rattler_build_recipe/src/stage0/source.rs index 04f688624..c970209f4 100644 --- a/crates/rattler_build_recipe/src/stage0/source.rs +++ b/crates/rattler_build_recipe/src/stage0/source.rs @@ -113,7 +113,7 @@ pub struct GitSource { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AttestationConfig { /// URL to download the attestation bundle from (e.g., .sigstore.json file) - /// Auto-derived for PyPI sources if not specified. + /// Auto-derived for PyPI sources and uploaded GitHub release assets if not specified. #[serde(default, skip_serializing_if = "Option::is_none")] pub bundle_url: Option>, diff --git a/crates/rattler_build_recipe/src/stage1/source.rs b/crates/rattler_build_recipe/src/stage1/source.rs index ef563307f..5475516c1 100644 --- a/crates/rattler_build_recipe/src/stage1/source.rs +++ b/crates/rattler_build_recipe/src/stage1/source.rs @@ -269,7 +269,7 @@ pub fn parse_publisher_string(s: &str) -> Result { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AttestationConfig { /// URL to download the attestation bundle from (e.g., .sigstore.json file) - /// Auto-derived for PyPI sources if not specified. + /// Auto-derived for PyPI sources and uploaded GitHub release assets if not specified. #[serde(default, skip_serializing_if = "Option::is_none")] pub bundle_url: Option, diff --git a/crates/rattler_build_recipe/tests/source_parsing_test.rs b/crates/rattler_build_recipe/tests/source_parsing_test.rs index b0199b69b..4eb90c43c 100644 --- a/crates/rattler_build_recipe/tests/source_parsing_test.rs +++ b/crates/rattler_build_recipe/tests/source_parsing_test.rs @@ -780,6 +780,34 @@ fn test_parse_flask_attestation_example() { } } +#[test] +fn test_parse_uv_attestation_example() { + let yaml = include_str!("../../../examples/uv-attestation/recipe.yaml"); + let recipe = parse_recipe_from_source(yaml).unwrap(); + + assert_eq!(recipe.package.name.as_concrete().unwrap().as_str(), "uv"); + + let source = get_concrete_source(&recipe.source.as_slice()[0]).unwrap(); + match source { + Source::Url(url_src) => { + let att = url_src + .attestation + .as_ref() + .expect("attestation should be set"); + assert!( + att.bundle_url.is_none(), + "GitHub release attestation should be discovered automatically" + ); + assert_eq!(att.publishers.len(), 1); + assert_eq!( + att.publishers[0].as_concrete().unwrap(), + "github:astral-sh/uv" + ); + } + _ => panic!("Expected URL source"), + } +} + #[test] fn test_parse_zstd_attestation_example() { let yaml = include_str!("../../../examples/zstd-attestation/recipe.yaml"); diff --git a/crates/rattler_build_source_cache/src/sigstore.rs b/crates/rattler_build_source_cache/src/sigstore.rs index 8d95a983b..c221ed805 100644 --- a/crates/rattler_build_source_cache/src/sigstore.rs +++ b/crates/rattler_build_source_cache/src/sigstore.rs @@ -1,8 +1,8 @@ //! Sigstore attestation verification //! //! This module contains all sigstore-related functionality for verifying -//! attestation bundles against source artifacts. It handles both standard -//! sigstore bundles and PyPI PEP 740 provenance responses. +//! attestation bundles against source artifacts. It handles standard Sigstore +//! bundles, PyPI PEP 740 provenance responses, and GitHub's attestations API. use std::path::Path; @@ -60,13 +60,55 @@ fn derive_pypi_provenance_url(source_url: &url::Url) -> Option { url::Url::parse(&provenance_url).ok() } +/// Derive the GitHub repository attestations API URL for a release asset. +/// +/// Filtering by the downloaded artifact's digest is important: GitHub release +/// attestations also contain the commit SHA-1, but that does not authenticate +/// GitHub's dynamically generated source archives. An uploaded release asset, +/// on the other hand, is included as a SHA-256 subject and can be verified. +fn derive_github_attestations_url( + source_url: &url::Url, + artifact_sha256_hex: &str, +) -> Option { + if source_url.host_str()? != "github.com" { + return None; + } + + let segments: Vec<_> = source_url.path_segments()?.collect(); + if segments.len() < 6 || segments[2] != "releases" || segments[3] != "download" { + return None; + } + + let owner = segments[0]; + let repo = segments[1].strip_suffix(".git").unwrap_or(segments[1]); + if owner.is_empty() || repo.is_empty() { + return None; + } + + // The digest is a path parameter in GitHub's public list-attestations API. + // Keep the predicate filter as well so unrelated attestations for the same + // artifact are not considered release attestations. + let mut api_url = url::Url::parse(&format!( + "https://api.github.com/repos/{owner}/{repo}/attestations/sha256:{artifact_sha256_hex}" + )) + .ok()?; + api_url + .query_pairs_mut() + .append_pair("predicate_type", "release"); + Some(api_url) +} + /// Result of parsing an attestation response. +#[derive(Debug)] struct ParsedAttestations { bundles: Vec, /// Whether these bundles were converted from PyPI PEP 740 provenance format. /// PyPI-converted bundles lack canonicalized rekor bodies so transparency log /// verification must be skipped. from_pypi: bool, + /// Whether these are GitHub immutable-release attestations. They use + /// GitHub's Sigstore trust domain and RFC 3161 timestamps instead of Rekor. + from_github: bool, } /// Parse an attestation response into one or more sigstore bundles. @@ -76,6 +118,8 @@ struct ParsedAttestations { /// parsed directly via `Bundle::from_json`. /// 2. **PyPI PEP 740 provenance response**: has an `attestation_bundles` array, /// each containing `attestations` that are converted to sigstore bundles. +/// 3. **GitHub attestations API response**: has an `attestations` array whose +/// entries each contain a standard bundle in their `bundle` field. fn parse_attestation_response(json_str: &str) -> Result { let value: serde_json::Value = serde_json::from_str(json_str) .map_err(|e| CacheError::InvalidAttestationBundle(format!("Invalid JSON: {}", e)))?; @@ -88,6 +132,39 @@ fn parse_attestation_response(json_str: &str) -> Result Result Result { - let response = client - .for_host(url) - .client() - .get(url.clone()) + let mut request = client.for_host(url).client().get(url.clone()); + if url.host_str() == Some("api.github.com") { + request = request + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .header(reqwest::header::USER_AGENT, "rattler-build") + .header("X-GitHub-Api-Version", "2022-11-28"); + } + + let response = request .send() .await .map_err(|e| CacheError::AttestationBundleDownload { @@ -298,8 +381,9 @@ fn verify_artifact_subject( /// Verify an attestation for a downloaded artifact. /// -/// Downloads the attestation bundle (either from an explicit URL or auto-derived -/// from PyPI), loads the Sigstore trusted root, and verifies each identity check. +/// Downloads the attestation bundle (either from an explicit URL or discovered +/// from PyPI/GitHub), loads the appropriate Sigstore trusted root, and verifies +/// the signature and each identity check. /// /// Always verifies that the artifact's SHA-256 digest matches a subject in the /// attestation's in-toto statement — regardless of whether publisher identity @@ -323,68 +407,192 @@ fn identity_matches(expected: &str, actual: &str) -> bool { suffix.starts_with('/') || suffix.starts_with('@') } +/// Return repository package-URL subjects from GitHub release statements. +/// +/// GitHub's release attester has the fixed certificate identity +/// `https://dotcom.releases.github.com`; the repository identity is instead +/// cryptographically bound into the signed subject as a package URL. +fn github_release_repository_subjects(bundle: &sigstore_types::Bundle) -> Vec { + let SignatureContent::DsseEnvelope(envelope) = &bundle.content else { + return Vec::new(); + }; + if envelope.payload_type != "application/vnd.in-toto+json" { + return Vec::new(); + } + let Ok(statement) = serde_json::from_slice::(&envelope.decode_payload()) + else { + return Vec::new(); + }; + let Some(subjects) = statement.get("subject").and_then(|value| value.as_array()) else { + return Vec::new(); + }; + + let mut repositories: Vec<_> = subjects + .iter() + .filter_map(|subject| { + subject + .get("uri") + .and_then(|value| value.as_str()) + .filter(|uri| uri.starts_with("pkg:github/")) + .map(ToOwned::to_owned) + }) + .collect(); + repositories.sort(); + repositories.dedup(); + repositories +} + +fn github_release_matches_repository( + repository_subjects: &[String], + expected_identity: &str, +) -> bool { + let Some(expected_repository) = expected_identity.strip_prefix("https://github.com/") else { + return false; + }; + repository_subjects.iter().any(|uri| { + uri.strip_prefix("pkg:github/") + .and_then(|package| package.split_once('@')) + .is_some_and(|(repository, _version)| { + repository.eq_ignore_ascii_case(expected_repository) + }) + }) +} + pub(crate) async fn verify_attestation( client: &BaseClient, file_path: &Path, source_url: &url::Url, attestation_config: &AttestationVerification, ) -> Result<(), CacheError> { - // Determine bundle URL: explicit, or auto-derive from PyPI source URL + // Read and hash the artifact before deriving the URL: GitHub's API can + // filter attestations by the exact release asset digest. + let artifact_bytes = fs_err::tokio::read(file_path).await?; + let artifact_sha256_hex = hex::encode(Sha256::digest(&artifact_bytes)); + + // Determine bundle URL: explicit, or auto-derive from PyPI/GitHub. let bundle_url = if let Some(url) = &attestation_config.bundle_url { Some(url.clone()) } else { derive_pypi_provenance_url(source_url) + .or_else(|| derive_github_attestations_url(source_url, &artifact_sha256_hex)) }; let bundle_url = bundle_url.ok_or_else(|| { CacheError::InvalidAttestationBundle( - "No bundle_url provided and could not auto-derive one (not a PyPI source)".to_string(), + "No bundle_url provided and could not auto-derive one (supported sources are PyPI files and GitHub release assets)".to_string(), ) })?; tracing::info!("Downloading attestation bundle from {}", bundle_url); let response_json = download_attestation_bundle(client, &bundle_url).await?; - // Load the production Sigstore trusted root through TUF so verification - // uses fresh trust material instead of the embedded snapshot. - let trusted_root = TrustedRoot::production().await.map_err(|e| { - CacheError::SigstoreTrustRoot(format!( - "Failed to load Sigstore trusted root via TUF: {}", - e - )) - })?; - - // Read the artifact for verification - let artifact_bytes = fs_err::tokio::read(file_path).await?; - - // Parse the response: could be a plain sigstore bundle or a PyPI provenance response + // Parse before selecting a trust domain: GitHub immutable-release + // attestations use GitHub's own Sigstore instance rather than the public one. let parsed = parse_attestation_response(&response_json)?; + let trusted_root = if parsed.from_github { + TrustedRoot::from_tuf(sigstore_trust_root::TufConfig::github()).await + } else { + TrustedRoot::production().await + } + .map_err(|e| { + CacheError::SigstoreTrustRoot(format!("Failed to load Sigstore trusted root via TUF: {e}")) + })?; // Always verify the artifact's digest matches a subject in the attestation. // This prevents accepting an attestation for a different file (e.g., from a // different release or a different artifact entirely). - let artifact_sha256_hex = hex::encode(Sha256::digest(&artifact_bytes)); verify_artifact_subject(&artifact_sha256_hex, &parsed.bundles)?; + // A bundle URL without publisher constraints must still perform + // cryptographic verification rather than only inspecting the signed payload. + if attestation_config.identity_checks.is_empty() { + let mut errors = Vec::new(); + let mut verified = false; + for bundle in &parsed.bundles { + let policy = if parsed.from_github { + VerificationPolicy::default() + .require_identity("https://dotcom.releases.github.com") + .skip_tlog() + .skip_sct() + } else if parsed.from_pypi { + VerificationPolicy::default().skip_tlog() + } else { + VerificationPolicy::default() + }; + match verify(artifact_bytes.as_slice(), bundle, &policy, &trusted_root) { + Ok(_) => { + verified = true; + break; + } + Err(error) => errors.push(error.to_string()), + } + } + if !verified { + return Err(CacheError::AttestationVerification(format!( + "attestation signature verification failed: {}", + errors.join("; ") + ))); + } + } + // For each required identity check, find a matching bundle and verify it for check in &attestation_config.identity_checks { let mut matched = false; let mut found_identities: Vec = Vec::new(); let mut verification_errors: Vec = Vec::new(); + // Keep an aggregate only for error reporting. Acceptance below checks + // the repository subject from the same bundle that verifies the artifact, + // preventing subjects from separate bundles from being mixed together. + let repository_subjects = if parsed.from_github { + let mut subjects: Vec<_> = parsed + .bundles + .iter() + .flat_map(github_release_repository_subjects) + .collect(); + subjects.sort(); + subjects.dedup(); + subjects + } else { + Vec::new() + }; for bundle in &parsed.bundles { - // Verify with just the issuer in the policy — we do prefix matching on identity ourselves. - // For PyPI-converted bundles, skip tlog verification since we can't reconstruct - // the canonicalized rekor body from the PEP 740 format. - let mut policy = VerificationPolicy::default().require_issuer(check.issuer.clone()); - if parsed.from_pypi { - policy = policy.skip_tlog(); - } + // GitHub immutable releases are signed by GitHub's release service, + // not by the repository's workflow identity. Repository ownership is + // checked in the signed package-URL subject above. GitHub bundles use + // an RFC 3161 timestamp and do not contain Rekor or public CT entries. + let policy = if parsed.from_github { + VerificationPolicy::default() + .require_identity("https://dotcom.releases.github.com") + .skip_tlog() + .skip_sct() + } else { + // Verify with just the issuer in the policy — we do prefix + // matching on identity ourselves. + let mut policy = VerificationPolicy::default().require_issuer(check.issuer.clone()); + if parsed.from_pypi { + // We cannot reconstruct the canonicalized Rekor body from + // PEP 740's representation. + policy = policy.skip_tlog(); + } + policy + }; match verify(artifact_bytes.as_slice(), bundle, &policy, &trusted_root) { Ok(result) => { if let Some(ref actual_identity) = result.identity { - if identity_matches(&check.identity, actual_identity) { + let identity_ok = if parsed.from_github { + // The policy checked the release-service certificate; + // also require the repository in this same signed + // statement. Do not combine evidence across bundles. + github_release_matches_repository( + &github_release_repository_subjects(bundle), + &check.identity, + ) + } else { + identity_matches(&check.identity, actual_identity) + }; + if identity_ok { tracing::info!( "\u{2714} Attestation verified (identity={})", actual_identity, @@ -403,16 +611,31 @@ pub(crate) async fn verify_attestation( } if !matched { - let mut msg = format!( - "attestation identity mismatch for publisher '{}'\n expected identity prefix: {}\n expected issuer: {}", - check - .identity - .trim_start_matches("https://github.com/") - .trim_start_matches("https://gitlab.com/"), - check.identity, - check.issuer, - ); - if !found_identities.is_empty() { + let publisher = check + .identity + .trim_start_matches("https://github.com/") + .trim_start_matches("https://gitlab.com/"); + let mut msg = if parsed.from_github { + let mut msg = format!( + "attestation repository identity mismatch for publisher '{publisher}'\n expected repository identity: {}\n signing certificate identity: https://dotcom.releases.github.com", + check.identity, + ); + if repository_subjects.is_empty() { + msg.push_str("\n found signed repository subjects: none"); + } else { + msg.push_str("\n found signed repository subjects:"); + for subject in &repository_subjects { + msg.push_str(&format!("\n - {subject}")); + } + } + msg + } else { + format!( + "attestation identity mismatch for publisher '{publisher}'\n expected identity prefix: {}\n expected issuer: {}", + check.identity, check.issuer, + ) + }; + if !found_identities.is_empty() && !parsed.from_github { msg.push_str("\n found identities in attestation:"); for id in &found_identities { msg.push_str(&format!("\n - {}", id)); @@ -508,6 +731,31 @@ mod tests { ); } + #[test] + fn test_derive_github_attestations_url() { + let url = url::Url::parse( + "https://github.com/prefix-dev/rattler-build/releases/download/v1.0.0/source.tar.gz", + ) + .unwrap(); + let result = derive_github_attestations_url(&url, "abc123").unwrap(); + assert_eq!(result.host_str(), Some("api.github.com")); + assert_eq!( + result.path(), + "/repos/prefix-dev/rattler-build/attestations/sha256:abc123" + ); + let query: std::collections::HashMap<_, _> = result.query_pairs().collect(); + assert_eq!(query.get("predicate_type").unwrap(), "release"); + } + + #[test] + fn test_github_generated_archive_is_not_auto_derived() { + let url = url::Url::parse( + "https://github.com/prefix-dev/rattler-build/archive/refs/tags/v1.0.0.tar.gz", + ) + .unwrap(); + assert!(derive_github_attestations_url(&url, "abc123").is_none()); + } + #[test] fn test_parse_attestation_response_sigstore_bundle() { // A sigstore bundle has a "mediaType" field @@ -527,6 +775,7 @@ mod tests { let parsed = parse_attestation_response(json).unwrap(); assert_eq!(parsed.bundles.len(), 1); assert!(!parsed.from_pypi); + assert!(!parsed.from_github); } #[test] @@ -556,6 +805,30 @@ mod tests { let parsed = parse_attestation_response(json).unwrap(); assert_eq!(parsed.bundles.len(), 1); assert!(parsed.from_pypi); + assert!(!parsed.from_github); + } + + #[test] + fn test_parse_attestation_response_github() { + let bundle = make_bundle_with_subjects(&[("source.tar.gz", "abc123")]); + let bundle_json: serde_json::Value = + serde_json::from_str(&bundle.to_json().unwrap()).unwrap(); + let response = serde_json::json!({ + "attestations": [{ "id": 42, "bundle": bundle_json }] + }); + let parsed = parse_attestation_response(&response.to_string()).unwrap(); + assert_eq!(parsed.bundles.len(), 1); + assert!(!parsed.from_pypi); + assert!(parsed.from_github); + } + + #[test] + fn test_parse_empty_github_response_explains_generated_archives() { + let err = parse_attestation_response(r#"{ "attestations": [] }"#).unwrap_err(); + assert!( + err.to_string() + .contains("dynamically generated source archives") + ); } #[test] @@ -588,21 +861,12 @@ mod tests { )); } - /// Helper to create a minimal sigstore bundle with an in-toto statement - /// that attests to the given subjects (name, sha256_hex pairs). - fn make_bundle_with_subjects(subjects: &[(&str, &str)]) -> sigstore_types::Bundle { + /// Helper to create a minimal sigstore bundle with an in-toto statement. + fn make_bundle_with_raw_subjects( + subject_json: Vec, + ) -> sigstore_types::Bundle { use base64::{Engine, engine::general_purpose::STANDARD}; - let subject_json: Vec = subjects - .iter() - .map(|(name, sha256)| { - serde_json::json!({ - "name": name, - "digest": { "sha256": sha256 } - }) - }) - .collect(); - let statement = serde_json::json!({ "_type": "https://in-toto.io/Statement/v1", "subject": subject_json, @@ -629,6 +893,65 @@ mod tests { sigstore_types::Bundle::from_json(&serde_json::to_string(&bundle_json).unwrap()).unwrap() } + /// Create a bundle whose subjects are artifact names and SHA-256 pairs. + fn make_bundle_with_subjects(subjects: &[(&str, &str)]) -> sigstore_types::Bundle { + make_bundle_with_raw_subjects( + subjects + .iter() + .map(|(name, sha256)| { + serde_json::json!({ + "name": name, + "digest": { "sha256": sha256 } + }) + }) + .collect(), + ) + } + + #[test] + fn test_github_release_repository_subject_matches() { + let bundle = make_bundle_with_raw_subjects(vec![serde_json::json!({ + "uri": "pkg:github/astral-sh/uv@0.12.1", + "digest": { "sha1": "abc123" } + })]); + let subjects = github_release_repository_subjects(&bundle); + assert_eq!(subjects, ["pkg:github/astral-sh/uv@0.12.1"]); + assert!(github_release_matches_repository( + &subjects, + "https://github.com/astral-sh/uv" + )); + assert!(github_release_matches_repository( + &subjects, + "https://github.com/ASTRAL-SH/UV" + )); + assert!(!github_release_matches_repository( + &subjects, + "https://github.com/astral-sh/uv-extra" + )); + } + + #[test] + fn test_github_repository_identity_is_scoped_to_one_bundle() { + let expected_repository_bundle = make_bundle_with_raw_subjects(vec![serde_json::json!({ + "uri": "pkg:github/victim/project@1.0.0", + "digest": { "sha1": "abc123" } + })]); + let artifact_bundle = make_bundle_with_raw_subjects(vec![serde_json::json!({ + "uri": "pkg:github/attacker/project@1.0.0", + "digest": { "sha1": "def456" } + })]); + + let expected_identity = "https://github.com/victim/project"; + assert!(github_release_matches_repository( + &github_release_repository_subjects(&expected_repository_bundle), + expected_identity, + )); + assert!(!github_release_matches_repository( + &github_release_repository_subjects(&artifact_bundle), + expected_identity, + )); + } + #[test] fn test_verify_artifact_subject_matching_digest() { let artifact = b"hello world"; @@ -673,4 +996,51 @@ mod tests { err ); } + + #[tokio::test] + #[ignore = "requires network access"] + async fn test_uv_github_release_attestation() { + let source_url = url::Url::parse( + "https://github.com/astral-sh/uv/releases/download/0.12.1/source.tar.gz", + ) + .unwrap(); + let client = BaseClient::builder().timeout(300).build(); + let bytes = client + .for_host(&source_url) + .client() + .get(source_url.clone()) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(); + let temp = tempfile::NamedTempFile::new().unwrap(); + fs_err::tokio::write(temp.path(), bytes).await.unwrap(); + let config = AttestationVerification { + bundle_url: None, + identity_checks: vec![crate::source::IdentityCheck { + identity: "https://github.com/astral-sh/uv".to_string(), + issuer: "https://token.actions.githubusercontent.com".to_string(), + }], + }; + verify_attestation(&client, temp.path(), &source_url, &config) + .await + .unwrap(); + + let wrong_config = AttestationVerification { + bundle_url: None, + identity_checks: vec![crate::source::IdentityCheck { + identity: "https://github.com/astral-sh/xv".to_string(), + issuer: "https://token.actions.githubusercontent.com".to_string(), + }], + }; + let error = verify_attestation(&client, temp.path(), &source_url, &wrong_config) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("expected repository identity: https://github.com/astral-sh/xv")); + assert!(error.contains("pkg:github/astral-sh/uv@0.12.1")); + assert!(error.contains("signing certificate identity: https://dotcom.releases.github.com")); + } } diff --git a/crates/rattler_build_source_cache/src/source.rs b/crates/rattler_build_source_cache/src/source.rs index d57199913..1d78b4ad7 100644 --- a/crates/rattler_build_source_cache/src/source.rs +++ b/crates/rattler_build_source_cache/src/source.rs @@ -180,7 +180,7 @@ pub struct IdentityCheck { /// Attestation verification configuration for the cache layer #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AttestationVerification { - /// URL to download the attestation bundle from, or None for auto-derived PyPI URLs + /// URL to download the attestation bundle from, or None for automatic provider discovery pub bundle_url: Option, /// Identity checks to perform. All must pass. pub identity_checks: Vec, diff --git a/docs/experimental_features.md b/docs/experimental_features.md index 38c93d44d..435f6589a 100644 --- a/docs/experimental_features.md +++ b/docs/experimental_features.md @@ -11,7 +11,7 @@ Or, use the environment variable, `RATTLER_BUILD_EXPERIMENTAL=true`. ## Sigstore source attestation -The `attestation` field on URL sources allows verifying that downloaded source archives were produced by a trusted publisher using [Sigstore](https://sigstore.dev) attestations. This is supported for PyPI packages (where the bundle URL is automatically derived) and GitHub releases (where you specify the `bundle_url` manually). +The `attestation` field on URL sources allows verifying that downloaded source archives were produced by a trusted publisher using [Sigstore](https://sigstore.dev) attestations. The bundle is discovered automatically for PyPI packages and uploaded GitHub release assets. GitHub's dynamically generated source archives are not covered by release attestations. ```yaml source: diff --git a/docs/reference/recipe_file.md b/docs/reference/recipe_file.md index 7ca70cbe7..c25761d3f 100644 --- a/docs/reference/recipe_file.md +++ b/docs/reference/recipe_file.md @@ -372,9 +372,11 @@ The attestation config has the following fields: - **`publishers`** - A list of publisher identities in `github:owner/repo` format. At least one publisher must match for verification to succeed. -- **`bundle_url`** (optional) - URL to the Sigstore bundle file. For PyPI sources, this is - automatically derived from the PyPI attestation API. For GitHub releases, use the pattern - `https://github.com/{owner}/{repo}/releases/download/{tag}/{filename}.sigstore.json`. +- **`bundle_url`** (optional) - URL to the Sigstore bundle file. It is automatically derived + from the PyPI attestation API and, for uploaded GitHub release assets, from the GitHub + repository attestations API. GitHub's dynamically generated source archives are not covered + by release attestations; upload the source archive as a release asset instead. A direct bundle + URL can still be used when a project publishes a standalone `.sigstore.json` file. See the [Sigstore source attestation documentation](../sigstore.md#source-attestation-verification) for more details and examples. diff --git a/docs/sigstore.md b/docs/sigstore.md index 63fb5e823..b4c1d4ace 100644 --- a/docs/sigstore.md +++ b/docs/sigstore.md @@ -192,9 +192,10 @@ source: When Rattler-Build downloads the source, it will: -1. Fetch the Sigstore attestation bundle (automatically derived for PyPI packages, or from `bundle_url`) -2. Verify the bundle signature against the Sigstore transparency log -3. Check that the attestation identity matches one of the listed publishers +1. Fetch the Sigstore attestation bundle (automatically derived for PyPI packages and uploaded GitHub release assets, or from `bundle_url`) +2. Verify that the downloaded archive's SHA-256 is a subject of the attestation +3. Verify the bundle signature using the provider's Sigstore trust root and timestamp/transparency evidence +4. Check that the attestation identity matches one of the listed publishers If verification fails, the build is aborted. @@ -213,7 +214,33 @@ source: ### GitHub release sources -For source archives from GitHub releases, specify the `bundle_url` pointing to the `.sigstore.json` bundle: +For an archive **uploaded as a GitHub release asset**, Rattler-Build queries the repository attestations API for a release attestation whose subject is the archive's SHA-256. No unstable attestation download ID or `bundle_url` is needed. + +For example, uv publishes an attested `source.tar.gz`. This binds verification to the `astral-sh/uv` repository: + +```yaml +context: + version: "0.12.1" + +source: + url: https://github.com/astral-sh/uv/releases/download/${{ version }}/source.tar.gz + sha256: 5f0503f896f1209f6114835a7754369c2484331b0caab4c1ecd4579dfd2a31b0 + attestation: + publishers: + - github:astral-sh/uv +``` + +Internally, Rattler-Build requests: + +```text +https://api.github.com/repos/astral-sh/uv/attestations/sha256:5f0503f896f1209f6114835a7754369c2484331b0caab4c1ecd4579dfd2a31b0?predicate_type=release +``` + +It verifies both the SHA-256 subject and the signed repository subject `pkg:github/astral-sh/uv@0.12.1`. The release certificate itself has GitHub's fixed identity `https://dotcom.releases.github.com`, so repository binding comes from that signed package URL rather than a workflow SAN. + +GitHub's dynamically generated source archives (the "Source code" `.zip` and `.tar.gz` links) are **not** covered by a release attestation. Their release attestation contains the source commit's SHA-1, but that does not cryptographically authenticate the generated archive bytes. To make source verification possible, upload a `source.tar.gz` as a release asset; GitHub then includes its SHA-256 in the immutable release attestation. + +You can still provide `bundle_url` explicitly for projects that publish a standalone `.sigstore.json` bundle alongside an asset: ```yaml source: @@ -227,7 +254,7 @@ source: ### Publisher format -Publishers are specified in `github:owner/repo` format. The identity is matched against the Sigstore certificate's Subject Alternative Name (SAN), which for GitHub Actions is the workflow identity. +Publishers are specified in `github:owner/repo` format. For workflow-produced attestations, the repository is matched against the Sigstore certificate's Subject Alternative Name (SAN). For GitHub immutable release attestations, the certificate identifies GitHub's release service, so the repository is instead matched against the signed `pkg:github/owner/repo@version` subject. ## Verifying attestations diff --git a/examples/uv-attestation/recipe.yaml b/examples/uv-attestation/recipe.yaml new file mode 100644 index 000000000..91f0756a9 --- /dev/null +++ b/examples/uv-attestation/recipe.yaml @@ -0,0 +1,52 @@ +# Example: GitHub immutable-release attestation verification +# +# uv uploads source.tar.gz as a release asset. Rattler-Build derives the +# repository attestations API URL from the asset SHA-256, so no bundle_url or +# unstable GitHub attestation download ID is needed. +# +# `github:astral-sh/uv` binds the signed release statement to the repository +# subject `pkg:github/astral-sh/uv@${{ version }}`. The certificate itself is +# issued to GitHub's release service (`https://dotcom.releases.github.com`). + +context: + version: "0.12.1" + +package: + name: uv + version: ${{ version }} + +source: + url: https://github.com/astral-sh/uv/releases/download/${{ version }}/source.tar.gz + sha256: 5f0503f896f1209f6114835a7754369c2484331b0caab4c1ecd4579dfd2a31b0 + attestation: + publishers: + - github:astral-sh/uv + +build: + number: 0 + skip: + - win + script: + - cargo install --locked --root "$PREFIX" --path crates/uv + +requirements: + build: + - ${{ compiler("c") }} + - ${{ compiler("cxx") }} + - ${{ compiler("rust") }} + - ${{ stdlib("c") }} + - cmake + - make + +tests: + - script: + - uv --version + +about: + homepage: https://github.com/astral-sh/uv + license: Apache-2.0 OR MIT + license_file: + - LICENSE-APACHE + - LICENSE-MIT + summary: An extremely fast Python package and project manager, written in Rust + repository: https://github.com/astral-sh/uv diff --git a/examples/zstd-attestation/recipe.yaml b/examples/zstd-attestation/recipe.yaml index b82c77ead..4596351ea 100644 --- a/examples/zstd-attestation/recipe.yaml +++ b/examples/zstd-attestation/recipe.yaml @@ -1,8 +1,7 @@ # Example: GitHub-released software with attestation verification # -# For non-PyPI sources, you must provide `bundle_url` explicitly since it -# cannot be auto-derived. The bundle is a standard sigstore .sigstore.json file -# published alongside the release artifact. +# This project publishes a standalone sigstore bundle alongside the release +# artifact, so its `bundle_url` is supplied explicitly. # # GitHub Actions artifacts are signed with: # identity: https://github.com/{owner}/{repo}/...