diff --git a/.github/workflows/test-cosign-attestation.yml b/.github/workflows/test-cosign-attestation.yml new file mode 100644 index 000000000..b19505373 --- /dev/null +++ b/.github/workflows/test-cosign-attestation.yml @@ -0,0 +1,74 @@ +on: + workflow_dispatch: + inputs: + package_url: + description: 'Full URL to download the package from' + required: true + default: 'https://repo.prefix.dev/conda-forge/noarch/rich-13.7.0-pyhd8ed1ab_0.conda' + channel: + description: 'Channel to upload to' + required: true + default: 'wolf-private-test' + +name: Test Cosign Attestation + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + RUST_LOG: info + RUST_BACKTRACE: 1 + CARGO_TERM_COLOR: always + +jobs: + test-attestation: + name: Test Cosign Attestation Upload + runs-on: ubuntu-22.04 + + # These permissions are needed to create a sigstore certificate. + permissions: + id-token: write + contents: read + attestations: write + + steps: + - name: Checkout source code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + submodules: recursive + + - name: Install Rust toolchain + run: | + rustup component add rustfmt + rustup target add x86_64-unknown-linux-musl + + - name: Install musl tools + run: | + sudo apt install musl-tools gcc g++ + sudo ln -s /usr/bin/musl-gcc /usr/bin/musl-g++ + + - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8 + + - name: Show version information (Rust, cargo, GCC) + shell: bash + run: | + gcc --version || true + rustup -V + rustup toolchain list + cargo -V + rustc -V + + - name: Install Cosign + uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0 + + - name: Download package + run: | + PACKAGE_NAME=$(basename ${{ inputs.package_url }}) + curl -L -o $PACKAGE_NAME ${{ inputs.package_url }} + ls -lh $PACKAGE_NAME + echo "PACKAGE_NAME=$PACKAGE_NAME" >> $GITHUB_ENV + + - name: Run rattler upload with attestation generation + run: | + cargo run --bin rattler -- upload prefix --generate-attestation -c ${{ inputs.channel }} $PACKAGE_NAME diff --git a/crates/rattler_upload/src/upload/attestation.rs b/crates/rattler_upload/src/upload/attestation.rs new file mode 100644 index 000000000..c50b829f3 --- /dev/null +++ b/crates/rattler_upload/src/upload/attestation.rs @@ -0,0 +1,333 @@ +use miette::IntoDiagnostic; +use reqwest::header; +use reqwest_middleware::ClientWithMiddleware; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::{io::Write as _, path::Path}; +use tempfile::NamedTempFile; +use tokio::process::Command as AsyncCommand; + +/// Conda V1 predicate +#[derive(Debug, Serialize, Deserialize)] +pub struct CondaV1Predicate { + #[serde(rename = "targetChannel", skip_serializing_if = "Option::is_none")] + pub target_channel: Option, +} + +/// In-toto Statement structure for conda packages +#[derive(Debug, Serialize, Deserialize)] +pub struct Statement { + #[serde(rename = "_type")] + pub statement_type: String, + pub subject: Vec, + #[serde(rename = "predicateType")] + pub predicate_type: String, + pub predicate: CondaV1Predicate, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Subject { + pub name: String, + pub digest: DigestSet, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DigestSet { + pub sha256: String, +} + +/// Response from GitHub attestation API +#[derive(Debug, Serialize, Deserialize)] +pub struct AttestationResponse { + pub id: String, +} + +/// Configuration for attestation creation +#[derive(Debug, Clone)] +pub struct AttestationConfig { + pub repo_owner: Option, + pub repo_name: Option, + pub github_token: Option, + pub use_github_oidc: bool, + /// Path to a local cosign private key for testing (optional) + pub cosign_private_key: Option, +} + +/// Create and store an attestation for a conda package using cosign +/// +/// This function: +/// 1. Creates an in-toto statement for the package +/// 2. Uses cosign to sign the statement with GitHub OIDC or other identity +/// 3. Optionally stores the signed attestation to GitHub's attestation API (if token provided) +/// +/// Returns the attestation bundle JSON or GitHub attestation ID +pub async fn create_attestation_with_cosign( + package_path: &Path, + channel_url: &str, + config: &AttestationConfig, + client: &ClientWithMiddleware, +) -> miette::Result { + // Check if cosign is installed + check_cosign_installed().await?; + + // Step 1: Create just the predicate data for cosign (not a full statement) + let predicate = create_conda_intoto_statement(package_path, channel_url).await?; + + // Step 2: Sign with cosign + let bundle_json = sign_with_cosign(predicate.path(), package_path, config).await?; + + // Step 3: Optionally store to GitHub if token is provided + if let (Some(token), Some(owner), Some(repo)) = + (&config.github_token, &config.repo_owner, &config.repo_name) + { + let attestation_id = + store_attestation_to_github(&bundle_json, token, owner, repo, client).await?; + + tracing::info!("Attestation stored to GitHub with ID: {}", attestation_id); + Ok(attestation_id) + } else { + tracing::info!("GitHub token not provided, skipping GitHub attestation storage"); + // Return the bundle JSON for use elsewhere (e.g., prefix.dev upload) + Ok(bundle_json) + } +} + +/// Check if cosign is installed and available +async fn check_cosign_installed() -> miette::Result<()> { + let output = AsyncCommand::new("cosign") + .arg("version") + .output() + .await + .into_diagnostic() + .map_err(|_| { + miette::miette!( + "cosign is not installed or not found in PATH.\n\ + Install it with: pixi global install cosign" + ) + })?; + + if !output.status.success() { + return Err(miette::miette!( + "cosign command failed. Please ensure cosign is properly installed.\n\ + Install it with: pixi global install cosign" + )); + } + + let version = String::from_utf8_lossy(&output.stdout); + tracing::info!("Using cosign version: {}", version.trim()); + + Ok(()) +} + +/// Create just the predicate data for conda package attestation +async fn create_conda_intoto_statement( + filepath: &Path, + channel_url: &str, +) -> miette::Result { + let mut temp_file = NamedTempFile::new().into_diagnostic()?; + let subject = Subject { + name: filepath + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| miette::miette!("Invalid package file name"))? + .to_string(), + digest: { + let digest = rattler_digest::compute_file_digest::(filepath) + .into_diagnostic()?; + DigestSet { + sha256: format!("{digest:x}"), + } + }, + }; + + let statement = Statement { + statement_type: "https://in-toto.io/Statement/v1".to_string(), + subject: vec![subject], + predicate_type: "https://schemas.conda.org/attestations-publish-1.schema.json".to_string(), + predicate: CondaV1Predicate { + target_channel: Some(channel_url.to_string()), + }, + }; + temp_file + .write_all( + serde_json::to_string(&statement) + .into_diagnostic()? + .as_bytes(), + ) + .into_diagnostic()?; + Ok(temp_file) +} + +/// Sign a predicate using cosign +async fn sign_with_cosign( + predicate_path: &Path, + package_path: &Path, + config: &AttestationConfig, +) -> miette::Result { + tracing::debug!( + "Signing predicate with cosign: {}", + predicate_path.display() + ); + + // Always use a tempfile for the bundle output + let bundle_file = NamedTempFile::new().into_diagnostic()?; + let bundle_path = bundle_file.path().to_string_lossy().to_string(); + + // Build cosign attest command + let mut cmd = AsyncCommand::new("cosign"); + cmd.arg("attest-blob") + .arg(package_path) // the blob (package file) to attest + .arg("--statement") + .arg(predicate_path) + .arg("--type") + .arg("https://schemas.conda.org/attestations-publish-1.schema.json") + .arg("--bundle") + .arg(&bundle_path) + .arg("--new-bundle-format=true") + .env("COSIGN_YES", "true"); // Skip prompts in subprocess + + // Check if using local key for testing + if let Some(key_path) = &config.cosign_private_key { + tracing::info!("Using local cosign key for signing: {}", key_path); + cmd.arg("--key").arg(key_path); + + // Check if password is needed + if std::env::var("COSIGN_PASSWORD").is_err() { + tracing::warn!( + "No COSIGN_PASSWORD set. If your key is password-protected, set COSIGN_PASSWORD env var." + ); + } + + cmd.arg("--tlog-upload=false"); // Don't upload to transparency log for local testing + + tracing::warn!( + "Local key signing produces DSSE format, not Sigstore bundle format.\n\ + For prefix.dev uploads, use keyless signing (GitHub Actions) to get proper Sigstore bundles." + ); + } + // Configure identity provider for keyless signing + else if config.use_github_oidc { + if std::env::var("GITHUB_ACTIONS").is_err() { + if std::env::var("COSIGN_EXPERIMENTAL").is_ok() { + tracing::info!( + "Local testing: Using cosign keyless signing via browser OAuth flow." + ); + } else { + tracing::warn!( + "Not in GitHub Actions. For local testing:\n\ + 1. Set COSIGN_EXPERIMENTAL=1 for keyless signing via browser\n\ + 2. Use cosign generate-key-pair for local key-based signing" + ); + } + } + } + + tracing::info!("Running cosign to create attestation..."); + + // Add timeout to prevent hanging + let output = tokio::time::timeout(std::time::Duration::from_secs(30), cmd.output()) + .await + .into_diagnostic() + .map_err(|_| miette::miette!("cosign command timed out after 30 seconds"))? + .into_diagnostic() + .map_err(|e| miette::miette!("Failed to run cosign: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(miette::miette!( + "cosign attestation failed with exit code {:?}:\n\ + stdout: {}\n\ + stderr: {}\n\n\ + Troubleshooting:\n\ + 1. Ensure you're running in GitHub Actions with 'id-token: write' permission\n\ + 2. Check that GITHUB_TOKEN is set if uploading to GitHub\n\ + 3. For local testing, ensure you have valid credentials configured", + output.status.code(), + if stdout.is_empty() { + "(empty)" + } else { + &stdout + }, + if stderr.is_empty() { + "(empty)" + } else { + &stderr + } + )); + } + + // Read the bundle from the tempfile + let bundle_json = std::fs::read_to_string(bundle_file.path()) + .into_diagnostic() + .map_err(|e| miette::miette!("Failed to read bundle file: {}", e))?; + + if bundle_json.is_empty() { + return Err(miette::miette!( + "cosign produced empty bundle file. This may indicate cosign failed silently." + )); + } + + tracing::info!("Successfully created attestation with cosign"); + + Ok(bundle_json) +} + +/// Store a signed attestation bundle to GitHub's attestation API +async fn store_attestation_to_github( + bundle_json: &str, + github_token: &str, + owner: &str, + repo: &str, + client: &ClientWithMiddleware, +) -> miette::Result { + let url = format!("https://api.github.com/repos/{owner}/{repo}/attestations"); + + // Parse the bundle JSON to ensure it's valid + let bundle: serde_json::Value = serde_json::from_str(bundle_json) + .into_diagnostic() + .map_err(|e| miette::miette!("Invalid bundle JSON from cosign: {}", e))?; + + let request_body = json!({ + "bundle": bundle, + }); + + tracing::debug!("Storing attestation to GitHub at {}", url); + + let response = client + .post(&url) + .bearer_auth(github_token) + .header(header::ACCEPT, "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .json(&request_body) + .send() + .await + .into_diagnostic()?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.into_diagnostic()?; + let error_detail = match status.as_u16() { + 401 => "Authentication failed. Check your GitHub token.", + 403 => "Permission denied. Ensure token has 'attestations:write' and repository allows attestations.", + 404 => "Repository not found or attestations API unavailable. Ensure you're on a supported GitHub plan.", + 422 => "Invalid attestation bundle format.", + _ => "Unknown error storing attestation.", + }; + + return Err(miette::miette!( + "{}\nStatus: {}\nResponse: {}", + error_detail, + status, + body + )); + } + + let response_data: AttestationResponse = response.json().await.into_diagnostic()?; + tracing::info!( + "Successfully stored attestation with ID: {}", + response_data.id + ); + + Ok(response_data.id) +} diff --git a/crates/rattler_upload/src/upload/mod.rs b/crates/rattler_upload/src/upload/mod.rs index 965bb13e8..ed4798899 100644 --- a/crates/rattler_upload/src/upload/mod.rs +++ b/crates/rattler_upload/src/upload/mod.rs @@ -22,6 +22,7 @@ use url::Url; use crate::upload::package::{sha256_sum, ExtractedPackage}; mod anaconda; +pub mod attestation; pub mod conda_forge; pub mod opt; mod package; diff --git a/crates/rattler_upload/src/upload/opt.rs b/crates/rattler_upload/src/upload/opt.rs index e170a579b..ce759a504 100644 --- a/crates/rattler_upload/src/upload/opt.rs +++ b/crates/rattler_upload/src/upload/opt.rs @@ -338,6 +338,10 @@ pub struct PrefixOpts { #[arg(long, required = false)] pub attestation: Option, + /// Automatically generate attestations when using trusted publishing + #[arg(long, default_value = "false")] + pub generate_attestation: bool, + /// Skip upload if package is existed. #[arg(short, long)] pub skip_existing: bool, @@ -350,6 +354,7 @@ pub struct PrefixData { pub channel: String, pub api_key: Option, pub attestation: Option, + pub generate_attestation: bool, pub skip_existing: bool, } @@ -360,6 +365,7 @@ impl From for PrefixData { value.channel, value.api_key, value.attestation, + value.generate_attestation, value.skip_existing, ) } @@ -372,6 +378,7 @@ impl PrefixData { channel: String, api_key: Option, attestation: Option, + generate_attestation: bool, skip_existing: bool, ) -> Self { Self { @@ -379,6 +386,7 @@ impl PrefixData { channel, api_key, attestation, + generate_attestation, skip_existing, } } diff --git a/crates/rattler_upload/src/upload/prefix.rs b/crates/rattler_upload/src/upload/prefix.rs index 780a0021c..2ebf1ec32 100644 --- a/crates/rattler_upload/src/upload/prefix.rs +++ b/crates/rattler_upload/src/upload/prefix.rs @@ -21,8 +21,9 @@ use super::opt::{ }; use crate::upload::{ + attestation::{create_attestation_with_cosign, AttestationConfig}, default_bytes_style, get_client_with_retry, get_default_client, - trusted_publishing::{check_trusted_publishing, TrustedPublishResult}, + trusted_publishing::{check_trusted_publishing, get_raw_oidc_token, TrustedPublishResult}, }; use super::package::sha256_sum; @@ -103,31 +104,43 @@ pub async fn upload_package_to_prefix( } }; - let token = match prefix_data.api_key { - Some(api_key) => api_key, - None => match check_trusted_publishing( - &get_client_with_retry().into_diagnostic()?, - &prefix_data.url, - ) - .await - { - TrustedPublishResult::Configured(token) => token.secret().to_string(), + let client = get_client_with_retry().into_diagnostic()?; + + // Check if we're using trusted publishing and if we should generate attestations + let (token, should_generate_attestation, oidc_token) = match prefix_data.api_key { + Some(api_key) => (api_key, false, None), + None => match check_trusted_publishing(&client, &prefix_data.url).await { + TrustedPublishResult::Configured(token) => { + // Get the OIDC token for attestation generation + let oidc_token = if prefix_data.generate_attestation { + match get_raw_oidc_token(&client).await { + Ok(token) => Some(token), + Err(e) => { + tracing::warn!("Failed to get OIDC token for attestation: {e}"); + None + } + } + } else { + None + }; + (token.secret().to_string(), oidc_token.is_some(), oidc_token) + } TrustedPublishResult::Skipped => { - if prefix_data.attestation.is_some() { + if prefix_data.attestation.is_some() || prefix_data.generate_attestation { return Err(miette::miette!( - "An attestation was provided, but trusted publishing is not configured" + "Attestation was requested, but trusted publishing is not configured" )); } - check_storage()? + (check_storage()?, false, None) } TrustedPublishResult::Ignored(err) => { tracing::warn!("Checked for trusted publishing but failed with {err}"); - if prefix_data.attestation.is_some() { + if prefix_data.attestation.is_some() || prefix_data.generate_attestation { return Err(miette::miette!( - "An attestation was provided, but trusted publishing is not configured" + "Attestation was requested, but trusted publishing is not configured" )); } - check_storage()? + (check_storage()?, false, None) } }, }; @@ -144,6 +157,60 @@ pub async fn upload_package_to_prefix( .join(&format!("api/v1/upload/{}", prefix_data.channel)) .into_diagnostic()?; + // Generate attestation if we're using trusted publishing and it was requested + let attestation_path = if should_generate_attestation && oidc_token.is_some() { + let channel_url = prefix_data + .url + .join(&prefix_data.channel) + .into_diagnostic()?; + + // Build attestation configuration. We deliberately avoid providing a GitHub token + // so we always return a Sigstore bundle JSON for uploading to prefix.dev. + let config = AttestationConfig { + repo_owner: None, + repo_name: None, + github_token: None, + use_github_oidc: true, + cosign_private_key: std::env::var("COSIGN_KEY_PATH").ok(), + }; + + match create_attestation_with_cosign( + package_file, + channel_url.as_str(), + &config, + &client, + ) + .await + { + Ok(attestation_bundle_json) => { + // Save attestation bundle JSON to a file next to the package + tracing::info!("Generated attestation: {}", attestation_bundle_json); + let attestation_file = package_file.with_extension("attestation.json"); + tokio_fs::write(&attestation_file, attestation_bundle_json) + .await + .into_diagnostic()?; + info!("Generated attestation for {}", filename); + Some(attestation_file) + } + Err(e) => { + // If attestation generation was explicitly requested, fail the upload + return Err(miette::miette!( + "Failed to generate attestation for {}: {}\n\ + Upload aborted because attestation generation was requested but failed.\n\ + \n\ + Troubleshooting:\n\ + 1. Ensure cosign is installed: pixi global install cosign\n\ + 2. Check that you're running in GitHub Actions with 'id-token: write' permission\n\ + 3. Verify OIDC token is available and valid\n\ + 4. Check cosign logs above for specific errors", + filename, e + )); + } + } + } else { + prefix_data.attestation.clone() + }; + let progress_bar = indicatif::ProgressBar::new(file_size) .with_prefix("Uploading") .with_style(default_bytes_style().into_diagnostic()?); @@ -160,7 +227,7 @@ pub async fn upload_package_to_prefix( &filename, file_size, progress_bar.clone(), - &prefix_data.attestation, + &attestation_path, ) .await?; diff --git a/crates/rattler_upload/src/upload/trusted_publishing.rs b/crates/rattler_upload/src/upload/trusted_publishing.rs index faa7709dd..aaa771c2e 100644 --- a/crates/rattler_upload/src/upload/trusted_publishing.rs +++ b/crates/rattler_upload/src/upload/trusted_publishing.rs @@ -97,7 +97,7 @@ struct MintTokenRequest { } /// Returns the short-lived token to use for uploading. -pub(crate) async fn get_token( +pub async fn get_token( client: &ClientWithMiddleware, prefix_url: &Url, ) -> Result { @@ -123,6 +123,18 @@ pub(crate) async fn get_token( Ok(publish_token) } +/// Get raw OIDC token for attestation generation +pub async fn get_raw_oidc_token( + client: &ClientWithMiddleware, +) -> Result { + let oidc_token_request_token = + env::var(consts::ACTIONS_ID_TOKEN_REQUEST_TOKEN).map_err(|err| { + TrustedPublishingError::from_var_err(consts::ACTIONS_ID_TOKEN_REQUEST_TOKEN, err) + })?; + + get_oidc_token(&oidc_token_request_token, client).await +} + async fn get_oidc_token( oidc_token_request_token: &str, client: &ClientWithMiddleware, diff --git a/typos.toml b/typos.toml index c9cf813ae..23e918c22 100644 --- a/typos.toml +++ b/typos.toml @@ -14,3 +14,4 @@ ignore-hidden = false [default.extend-words] strat = "strat" haa = "haa" +intoto = "intoto" \ No newline at end of file