diff --git a/Cargo.lock b/Cargo.lock index 76a7d80..bc1f037 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1609,6 +1609,7 @@ dependencies = [ "sigstore-fulcio", "sigstore-oidc", "sigstore-rekor", + "sigstore-trust-root", "sigstore-tsa", "sigstore-types", "thiserror", diff --git a/crates/sigstore-bundle/src/builder.rs b/crates/sigstore-bundle/src/builder.rs index e1b47a5..04e54cb 100644 --- a/crates/sigstore-bundle/src/builder.rs +++ b/crates/sigstore-bundle/src/builder.rs @@ -280,7 +280,13 @@ impl TlogEntryBuilder { kind: self.kind, version: self.kind_version, }, - integrated_time: self.integrated_time.to_string(), + // For V2 entries, integrated_time is 0 and should be omitted from JSON + // (skip_serializing_if = "String::is_empty" handles this) + integrated_time: if self.integrated_time == 0 { + String::new() + } else { + self.integrated_time.to_string() + }, inclusion_promise: self.inclusion_promise, inclusion_proof: self.inclusion_proof, canonicalized_body: CanonicalizedBody::new(self.canonicalized_body), diff --git a/crates/sigstore-conformance/src/main.rs b/crates/sigstore-conformance/src/main.rs index 4b2b433..0f4a5a1 100644 --- a/crates/sigstore-conformance/src/main.rs +++ b/crates/sigstore-conformance/src/main.rs @@ -10,7 +10,7 @@ use sigstore_crypto::KeyPair; use sigstore_fulcio::FulcioClient; use sigstore_oidc::IdentityToken; use sigstore_rekor::RekorClient; -use sigstore_trust_root::TrustedRoot; +use sigstore_trust_root::{SigningConfig, TrustedRoot}; use sigstore_tsa::TimestampClient; use sigstore_types::{Bundle, Sha256Hash, SignatureContent}; use sigstore_verify::{verify, VerificationPolicy}; @@ -114,62 +114,29 @@ async fn sign_bundle(args: &[String]) -> Result<(), Box> let bundle_path = bundle_path.ok_or("Missing required --bundle")?; let artifact_path = artifact_path.ok_or("Missing artifact path")?; - // Parse signing config if present to get URLs - let (fulcio_url, rekor_url, use_rekor_v2, tsa_url) = if let Some(config_path) = &_signing_config - { - let config_content = fs::read_to_string(config_path)?; - let config_json: serde_json::Value = serde_json::from_str(&config_content)?; - - let fulcio_url = config_json - .get("caUrls") - .and_then(|v| v.as_array()) - .and_then(|a| a.first()) - .and_then(|s| s.get("url")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .ok_or("No caUrls in signing config")?; - - let tlogs = config_json - .get("tlogs") - .or_else(|| config_json.get("rekorTlogUrls")) - .and_then(|v| v.as_array()) - .ok_or("No tlogs in signing config")?; - - let log = tlogs.first().ok_or("Empty tlogs list")?; - let url = log - .get("baseUrl") - .or_else(|| log.get("url")) - .and_then(|v| v.as_str()) - .ok_or("No baseUrl or url in tlog")?; - let version = log - .get("majorApiVersion") - .and_then(|v| v.as_u64()) - .unwrap_or(1); - - let tsa_url = config_json - .get("tsaUrls") - .and_then(|v| v.as_array()) - .and_then(|a| a.first()) - .and_then(|t| t.get("url")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - (fulcio_url, url.to_string(), version == 2, tsa_url) + // Parse signing config to get URLs + let signing_config = if let Some(config_path) = &_signing_config { + SigningConfig::from_file(config_path)? + } else if staging { + SigningConfig::staging()? } else { - let fulcio_url = if staging { - "https://fulcio.sigstage.dev".to_string() - } else { - "https://fulcio.sigstore.dev".to_string() - }; - - let url = if staging { - "https://rekor.sigstage.dev".to_string() - } else { - "https://rekor.sigstore.dev".to_string() - }; - (fulcio_url, url, false, None) + SigningConfig::production()? }; + // Get the best endpoints from signing config + let fulcio_endpoint = signing_config + .get_fulcio_url() + .ok_or("No valid Fulcio CA found in signing config")?; + let fulcio_url = fulcio_endpoint.url.clone(); + + let rekor_endpoint = signing_config + .get_rekor_url(None) // Get highest available version + .ok_or("No valid Rekor tlog found in signing config")?; + let rekor_url = rekor_endpoint.url.clone(); + let use_rekor_v2 = rekor_endpoint.major_api_version == 2; + + let tsa_url = signing_config.get_tsa_url().map(|e| e.url.clone()); + // Read artifact let artifact_data = fs::read(&artifact_path)?; diff --git a/crates/sigstore-rekor/src/client.rs b/crates/sigstore-rekor/src/client.rs index 3c1d126..d86d744 100644 --- a/crates/sigstore-rekor/src/client.rs +++ b/crates/sigstore-rekor/src/client.rs @@ -1,7 +1,8 @@ //! Rekor client for transparency log operations use crate::entry::{ - DsseEntry, HashedRekord, HashedRekordV2, LogEntry, LogEntryResponse, LogInfo, SearchIndex, + DsseEntry, DsseEntryV2, HashedRekord, HashedRekordV2, LogEntry, LogEntryResponse, LogInfo, + SearchIndex, }; use crate::error::{Error, Result}; @@ -257,7 +258,7 @@ impl RekorClient { }) } - /// Create a new DSSE log entry + /// Create a new DSSE log entry (V1) pub async fn create_dsse_entry(&self, entry: DsseEntry) -> Result { let url = format!("{}/api/v1/log/entries", self.url); let response = self @@ -291,6 +292,62 @@ impl RekorClient { Ok(entry) } + /// Create a new DSSE log entry (V2) + pub async fn create_dsse_entry_v2(&self, entry: DsseEntryV2) -> Result { + let url = format!("{}/api/v2/log/entries", self.url); + let response = self + .client + .post(&url) + .json(&entry) + .send() + .await + .map_err(|e| Error::Http(e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(Error::Api(format!( + "failed to create DSSE entry: {} - {}", + status, body + ))); + } + + let response_text = response + .text() + .await + .map_err(|e| Error::Http(e.to_string()))?; + + let entry_v2: crate::entry::LogEntryV2 = serde_json::from_str(&response_text) + .map_err(|e| Error::Http(format!("failed to parse JSON: {}", e)))?; + + // Convert V2 entry to LogEntry + let log_index = entry_v2.log_index.parse::().unwrap_or_default(); + let integrated_time = entry_v2.integrated_time.parse::().unwrap_or_default(); + + let verification = Some(crate::entry::Verification { + inclusion_proof: entry_v2 + .inclusion_proof + .map(|p| crate::entry::RekorInclusionProof { + checkpoint: p.checkpoint.envelope, + // Convert Sha256Hash to hex strings (V1 format) + hashes: p.hashes.iter().map(|h| h.to_hex()).collect(), + log_index: p.log_index.parse::().unwrap_or_default(), + root_hash: p.root_hash.to_hex(), + tree_size: p.tree_size.parse::().unwrap_or_default(), + }), + signed_entry_timestamp: entry_v2.inclusion_promise.map(|p| p.signed_entry_timestamp), + }); + + Ok(LogEntry { + uuid: Default::default(), // V2 response doesn't include UUID in body + body: entry_v2.canonicalized_body, + integrated_time, + log_id: entry_v2.log_id.key_id.into_string().into(), + log_index, + verification, + }) + } + /// Search the index for entries pub async fn search_index(&self, query: SearchIndex) -> Result> { let url = format!("{}/api/v1/index/retrieve", self.url); diff --git a/crates/sigstore-rekor/src/entry.rs b/crates/sigstore-rekor/src/entry.rs index 3212f31..1f4dea8 100644 --- a/crates/sigstore-rekor/src/entry.rs +++ b/crates/sigstore-rekor/src/entry.rs @@ -7,6 +7,38 @@ use sigstore_types::{ }; use std::collections::HashMap; +/// Rekor API version +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RekorApiVersion { + /// V1 API - uses hashedrekord 0.0.1 and dsse 0.0.1 + /// Available at: + #[default] + V1, + /// V2 API - uses hashedrekord 0.0.2 and dsse 0.0.2 + /// Returns inclusion proofs with checkpoints + /// Available at: (as of Oct 2025) + /// Note: V2 uses a different URL than V1! + V2, +} + +impl RekorApiVersion { + /// Get the default Rekor URL for this API version + pub fn default_url(&self) -> &'static str { + match self { + RekorApiVersion::V1 => "https://rekor.sigstore.dev", + RekorApiVersion::V2 => "https://log2025-1.rekor.sigstore.dev", + } + } + + /// Get the default staging Rekor URL for this API version + pub fn default_staging_url(&self) -> &'static str { + match self { + RekorApiVersion::V1 => "https://rekor.sigstage.dev", + RekorApiVersion::V2 => "https://log2025-alpha2.rekor.sigstage.dev", + } + } +} + /// A log entry from Rekor #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -357,6 +389,58 @@ impl HashedRekordV2 { } } +/// DSSE entry for creating new log entries (V2) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DsseEntryV2 { + #[serde(rename = "dsseRequestV002")] + pub request: DsseRequestV002, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DsseRequestV002 { + /// The DSSE envelope + pub envelope: sigstore_types::DsseEnvelope, + /// Verifiers (certificates) for the signatures + pub verifiers: Vec, +} + +/// Verifier in DSSE V2 (same structure as HashedRekord V2) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DsseVerifierV2 { + /// Key details (enum value as string) + pub key_details: String, + /// X.509 certificate + #[serde(skip_serializing_if = "Option::is_none")] + pub x509_certificate: Option, + /// Public key (alternative to certificate) + #[serde(skip_serializing_if = "Option::is_none")] + pub public_key: Option, +} + +impl DsseEntryV2 { + /// Create a new DsseEntryV2 from an envelope and certificate + /// + /// # Arguments + /// * `envelope` - The DSSE envelope containing signatures + /// * `certificate` - DER-encoded X.509 certificate from Fulcio + pub fn new(envelope: &sigstore_types::DsseEnvelope, certificate: &DerCertificate) -> Self { + Self { + request: DsseRequestV002 { + envelope: envelope.clone(), + verifiers: vec![DsseVerifierV2 { + // Assuming ECDSA P-256 SHA-256 for now as per conformance tests + key_details: "PKIX_ECDSA_P256_SHA_256".to_string(), + x509_certificate: Some(HashedRekordPublicKeyV2 { + content: certificate.clone(), + }), + public_key: None, + }], + }, + } + } +} + /// V2 Log Entry response #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/sigstore-rekor/src/lib.rs b/crates/sigstore-rekor/src/lib.rs index b3637f1..4dd521a 100644 --- a/crates/sigstore-rekor/src/lib.rs +++ b/crates/sigstore-rekor/src/lib.rs @@ -40,5 +40,8 @@ pub mod error; pub use body::RekorEntryBody; pub use client::{get_public_log_info, RekorClient, RekorClientBuilder}; -pub use entry::{DsseEntry, HashedRekord, HashedRekordV2, LogEntry, LogInfo, SearchIndex}; +pub use entry::{ + DsseEntry, DsseEntryV2, HashedRekord, HashedRekordV2, LogEntry, LogInfo, RekorApiVersion, + SearchIndex, +}; pub use error::{Error, Result}; diff --git a/crates/sigstore-sign/Cargo.toml b/crates/sigstore-sign/Cargo.toml index b2300b3..4c0ee5c 100644 --- a/crates/sigstore-sign/Cargo.toml +++ b/crates/sigstore-sign/Cargo.toml @@ -15,6 +15,7 @@ sigstore-rekor = { workspace = true } sigstore-fulcio = { workspace = true } sigstore-oidc = { workspace = true } sigstore-tsa = { workspace = true } +sigstore-trust-root = { workspace = true } thiserror = { workspace = true } base64 = { workspace = true } x509-cert = { workspace = true } diff --git a/crates/sigstore-sign/examples/sign_blob.rs b/crates/sigstore-sign/examples/sign_blob.rs index aadd81f..192b1d0 100644 --- a/crates/sigstore-sign/examples/sign_blob.rs +++ b/crates/sigstore-sign/examples/sign_blob.rs @@ -16,6 +16,11 @@ //! artifact.txt -o artifact.sigstore.json //! ``` //! +//! Use Rekor V2 API (when available): +//! ```sh +//! cargo run -p sigstore-sign --example sign_blob -- --v2 artifact.txt +//! ``` +//! //! # In GitHub Actions //! //! The example will automatically detect GitHub Actions and use ambient credentials: @@ -32,7 +37,8 @@ //! ``` use sigstore_oidc::{get_ambient_token, get_identity_token, is_ci_environment, IdentityToken}; -use sigstore_sign::SigningContext; +use sigstore_rekor::RekorApiVersion; +use sigstore_sign::{SigningConfig, SigningContext}; use std::env; use std::fs; @@ -46,6 +52,7 @@ async fn main() { let mut token: Option = None; let mut output: Option = None; let mut staging = false; + let mut use_v2 = false; let mut positional: Vec = Vec::new(); let mut i = 1; @@ -70,6 +77,9 @@ async fn main() { "--staging" => { staging = true; } + "--v2" => { + use_v2 = true; + } "--help" | "-h" => { print_usage(&args[0]); process::exit(0); @@ -123,15 +133,31 @@ async fn main() { } println!(" Issuer: {}", identity_token.issuer()); - // Create signing context - let context = if staging { + // Create signing context with appropriate API version + let base_config = if staging { println!(" Using: staging infrastructure"); - SigningContext::staging() + SigningConfig::staging() } else { println!(" Using: production infrastructure"); - SigningContext::production() + SigningConfig::production() }; + let config = if use_v2 { + base_config.with_rekor_version(RekorApiVersion::V2) + } else { + base_config + }; + + println!(" Rekor API: {:?}", config.rekor_api_version); + println!(" Rekor URL: {}", config.rekor_url); + if let Some(ref tsa_url) = config.tsa_url { + println!(" TSA URL: {}", tsa_url); + } else { + println!(" TSA URL: (none)"); + } + + let context = SigningContext::with_config(config); + // Create signer and sign let signer = context.signer(identity_token); @@ -164,13 +190,40 @@ async fn main() { // Print tlog entry info if let Some(entry) = bundle.verification_material.tlog_entries.first() { + println!( + " Entry Kind: {} v{}", + entry.kind_version.kind, entry.kind_version.version + ); println!(" Log Index: {}", entry.log_index); + // For V2, integrated_time is always 0 - RFC3161 timestamps are used instead if let Ok(ts) = entry.integrated_time.parse::() { - use chrono::{DateTime, Utc}; - if let Some(dt) = DateTime::::from_timestamp(ts, 0) { - println!(" Integrated Time: {}", dt); + if ts == 0 && entry.kind_version.version == "0.0.2" { + println!(" Integrated Time: (V2 uses RFC3161 timestamps)"); + } else { + use chrono::{DateTime, Utc}; + if let Some(dt) = DateTime::::from_timestamp(ts, 0) { + println!(" Integrated Time: {}", dt); + } } } + // Show if we have inclusion proof (V2) vs just promise (V1) + if entry.inclusion_proof.is_some() { + println!(" Inclusion Proof: yes (with checkpoint)"); + } else if entry.inclusion_promise.is_some() { + println!(" Inclusion Promise: yes (SET)"); + } + } + + // Print RFC3161 timestamp info + let ts_count = bundle + .verification_material + .timestamp_verification_data + .rfc3161_timestamps + .len(); + if ts_count > 0 { + println!(" RFC3161 Timestamps: {}", ts_count); + } else { + println!(" RFC3161 Timestamps: none (V2 bundles require timestamps!)"); } println!("\nVerify with:"); @@ -224,8 +277,12 @@ fn print_usage(program: &str) { eprintln!(" -o, --output Output bundle path (default: .sigstore.json)"); eprintln!(" -t, --token OIDC identity token (skips interactive auth)"); eprintln!(" --staging Use Sigstore staging infrastructure"); + eprintln!(" --v2 Use Rekor V2 API (uses log2025-1.rekor.sigstore.dev)"); eprintln!(" -h, --help Print this help message"); eprintln!(); + eprintln!("By default, Rekor V1 API is used (rekor.sigstore.dev)."); + eprintln!("Use --v2 to use the new Rekor V2 API with inclusion proofs and checkpoints."); + eprintln!(); eprintln!("Examples:"); eprintln!(" # Sign interactively (opens browser for OAuth)"); eprintln!(" {} artifact.txt", program); @@ -236,6 +293,9 @@ fn print_usage(program: &str) { eprintln!(" # Sign with a pre-obtained token"); eprintln!(" {} --token \"$OIDC_TOKEN\" artifact.txt", program); eprintln!(); + eprintln!(" # Sign using Rekor V2 API"); + eprintln!(" {} --v2 artifact.txt", program); + eprintln!(); eprintln!("In GitHub Actions:"); eprintln!(" # Add 'id-token: write' permission, then run without --token"); eprintln!(" # The example auto-detects GitHub Actions and uses ambient OIDC"); diff --git a/crates/sigstore-sign/src/sign.rs b/crates/sigstore-sign/src/sign.rs index 061e874..c9478b0 100644 --- a/crates/sigstore-sign/src/sign.rs +++ b/crates/sigstore-sign/src/sign.rs @@ -7,7 +7,10 @@ use sigstore_bundle::{BundleV03, TlogEntryBuilder}; use sigstore_crypto::{KeyPair, SigningScheme}; use sigstore_fulcio::FulcioClient; use sigstore_oidc::IdentityToken; -use sigstore_rekor::{DsseEntry, HashedRekord, RekorClient}; +use sigstore_rekor::{ + DsseEntry, DsseEntryV2, HashedRekord, HashedRekordV2, RekorApiVersion, RekorClient, +}; +use sigstore_trust_root::SigningConfig as TufSigningConfig; use sigstore_tsa::TimestampClient; use sigstore_types::{ Artifact, Bundle, DerCertificate, DsseEnvelope, DsseSignature, KeyId, PayloadBytes, Sha256Hash, @@ -25,34 +28,97 @@ pub struct SigningConfig { pub tsa_url: Option, /// Signing scheme to use pub signing_scheme: SigningScheme, + /// Rekor API version to use (defaults to V2) + pub rekor_api_version: RekorApiVersion, } impl Default for SigningConfig { fn default() -> Self { + let rekor_api_version = RekorApiVersion::default(); Self { fulcio_url: "https://fulcio.sigstore.dev".to_string(), - rekor_url: "https://rekor.sigstore.dev".to_string(), + rekor_url: rekor_api_version.default_url().to_string(), tsa_url: Some("https://timestamp.sigstore.dev/api/v1/timestamp".to_string()), signing_scheme: SigningScheme::EcdsaP256Sha256, + rekor_api_version, } } } impl SigningConfig { /// Create configuration for Sigstore public-good instance + /// + /// This uses the embedded signing config to get the best available endpoints. pub fn production() -> Self { - Self::default() + Self::from_tuf_config(&TufSigningConfig::production().expect("embedded config is valid")) } /// Create configuration for Sigstore staging instance + /// + /// This uses the embedded signing config to get the best available endpoints. pub fn staging() -> Self { + Self::from_tuf_config(&TufSigningConfig::staging().expect("embedded config is valid")) + } + + /// Create configuration from a TUF signing config + /// + /// This extracts the best available endpoints from the signing config, + /// preferring higher API versions when available. + /// + /// # Arguments + /// + /// * `tuf_config` - The signing config from TUF + pub fn from_tuf_config(tuf_config: &TufSigningConfig) -> Self { + Self::from_tuf_config_with_rekor_version(tuf_config, None) + } + + /// Create configuration from a TUF signing config with optional forced Rekor version + /// + /// # Arguments + /// + /// * `tuf_config` - The signing config from TUF + /// * `force_rekor_version` - If Some, force a specific Rekor API version + pub fn from_tuf_config_with_rekor_version( + tuf_config: &TufSigningConfig, + force_rekor_version: Option, + ) -> Self { + let fulcio_url = tuf_config + .get_fulcio_url() + .map(|e| e.url.clone()) + .unwrap_or_else(|| "https://fulcio.sigstore.dev".to_string()); + + let (rekor_url, rekor_api_version) = + if let Some(rekor) = tuf_config.get_rekor_url(force_rekor_version) { + let version = if rekor.major_api_version == 2 { + RekorApiVersion::V2 + } else { + RekorApiVersion::V1 + }; + (rekor.url.clone(), version) + } else { + ( + "https://rekor.sigstore.dev".to_string(), + RekorApiVersion::V1, + ) + }; + + let tsa_url = tuf_config.get_tsa_url().map(|e| e.url.clone()); + Self { - fulcio_url: "https://fulcio.sigstage.dev".to_string(), - rekor_url: "https://rekor.sigstage.dev".to_string(), - tsa_url: Some("https://timestamp.sigstage.dev/api/v1/timestamp".to_string()), + fulcio_url, + rekor_url, + tsa_url, signing_scheme: SigningScheme::EcdsaP256Sha256, + rekor_api_version, } } + + /// Set the Rekor API version and automatically update the URL + pub fn with_rekor_version(mut self, version: RekorApiVersion) -> Self { + self.rekor_api_version = version; + self.rekor_url = version.default_url().to_string(); + self + } } /// Context for signing operations @@ -95,6 +161,7 @@ impl SigningContext { fulcio_url: self.config.fulcio_url.clone(), rekor_url: self.config.rekor_url.clone(), tsa_url: self.config.tsa_url.clone(), + rekor_api_version: self.config.rekor_api_version, } } } @@ -112,6 +179,7 @@ pub struct Signer { fulcio_url: String, rekor_url: String, tsa_url: Option, + rekor_api_version: RekorApiVersion, } impl Signer { @@ -230,18 +298,30 @@ impl Signer { // Compute artifact hash let artifact_hash = sigstore_crypto::sha256(artifact); - // Create hashedrekord entry with the certificate - let hashed_rekord = HashedRekord::new(&artifact_hash, signature, certificate); - - // Create Rekor client and upload + // Create Rekor client let rekor = RekorClient::new(&self.rekor_url); - let log_entry = rekor - .create_entry(hashed_rekord) - .await - .map_err(|e| Error::Signing(format!("Failed to create Rekor entry: {}", e)))?; + + // Use V1 or V2 API based on configuration + let (log_entry, version) = + match self.rekor_api_version { + RekorApiVersion::V1 => { + let hashed_rekord = HashedRekord::new(&artifact_hash, signature, certificate); + let entry = rekor.create_entry(hashed_rekord).await.map_err(|e| { + Error::Signing(format!("Failed to create Rekor entry: {}", e)) + })?; + (entry, "0.0.1") + } + RekorApiVersion::V2 => { + let hashed_rekord = HashedRekordV2::new(&artifact_hash, signature, certificate); + let entry = rekor.create_entry_v2(hashed_rekord).await.map_err(|e| { + Error::Signing(format!("Failed to create Rekor entry: {}", e)) + })?; + (entry, "0.0.2") + } + }; // Build TlogEntry from the log entry response - let tlog_builder = TlogEntryBuilder::from_log_entry(&log_entry, "hashedrekord", "0.0.1"); + let tlog_builder = TlogEntryBuilder::from_log_entry(&log_entry, "hashedrekord", version); Ok(tlog_builder) } @@ -346,18 +426,29 @@ impl Signer { envelope: &DsseEnvelope, certificate: &DerCertificate, ) -> Result { - // Create DSSE entry - let dsse_entry = DsseEntry::new(envelope, certificate); - - // Create Rekor client and upload + // Create Rekor client let rekor = RekorClient::new(&self.rekor_url); - let log_entry = rekor - .create_dsse_entry(dsse_entry) - .await - .map_err(|e| Error::Signing(format!("Failed to create DSSE Rekor entry: {}", e)))?; + + // Use V1 or V2 API based on configuration + let (log_entry, version) = match self.rekor_api_version { + RekorApiVersion::V1 => { + let dsse_entry = DsseEntry::new(envelope, certificate); + let entry = rekor.create_dsse_entry(dsse_entry).await.map_err(|e| { + Error::Signing(format!("Failed to create DSSE Rekor entry: {}", e)) + })?; + (entry, "0.0.1") + } + RekorApiVersion::V2 => { + let dsse_entry = DsseEntryV2::new(envelope, certificate); + let entry = rekor.create_dsse_entry_v2(dsse_entry).await.map_err(|e| { + Error::Signing(format!("Failed to create DSSE Rekor entry: {}", e)) + })?; + (entry, "0.0.2") + } + }; // Build TlogEntry from the log entry response - let tlog_builder = TlogEntryBuilder::from_log_entry(&log_entry, "dsse", "0.0.1"); + let tlog_builder = TlogEntryBuilder::from_log_entry(&log_entry, "dsse", version); Ok(tlog_builder) } diff --git a/crates/sigstore-trust-root/repository/signing_config.json b/crates/sigstore-trust-root/repository/signing_config.json new file mode 100644 index 0000000..beaadec --- /dev/null +++ b/crates/sigstore-trust-root/repository/signing_config.json @@ -0,0 +1,49 @@ +{ + "mediaType": "application/vnd.dev.sigstore.signingconfig.v0.2+json", + "caUrls": [ + { + "url": "https://fulcio.sigstore.dev", + "majorApiVersion": 1, + "validFor": { + "start": "2022-04-13T20:06:15.000Z" + }, + "operator": "sigstore.dev" + } + ], + "oidcUrls": [ + { + "url": "https://oauth2.sigstore.dev/auth", + "majorApiVersion": 1, + "validFor": { + "start": "2022-04-13T20:06:15.000Z" + }, + "operator": "sigstore.dev" + } + ], + "rekorTlogUrls": [ + { + "url": "https://rekor.sigstore.dev", + "majorApiVersion": 1, + "validFor": { + "start": "2021-01-12T11:53:27.000Z" + }, + "operator": "sigstore.dev" + } + ], + "tsaUrls": [ + { + "url": "https://timestamp.sigstore.dev/api/v1/timestamp", + "majorApiVersion": 1, + "validFor": { + "start": "2025-07-04T00:00:00Z" + }, + "operator": "sigstore.dev" + } + ], + "rekorTlogConfig": { + "selector": "ANY" + }, + "tsaConfig": { + "selector": "ANY" + } +} diff --git a/crates/sigstore-trust-root/repository/signing_config_staging.json b/crates/sigstore-trust-root/repository/signing_config_staging.json new file mode 100644 index 0000000..66ef68c --- /dev/null +++ b/crates/sigstore-trust-root/repository/signing_config_staging.json @@ -0,0 +1,66 @@ +{ + "mediaType": "application/vnd.dev.sigstore.signingconfig.v0.2+json", + "caUrls": [ + { + "url": "https://fulcio.sigstage.dev", + "majorApiVersion": 1, + "validFor": { + "start": "2022-04-14T21:38:40Z" + }, + "operator": "sigstore.dev" + } + ], + "oidcUrls": [ + { + "url": "https://oauth2.sigstage.dev/auth", + "majorApiVersion": 1, + "validFor": { + "start": "2025-04-16T00:00:00Z" + }, + "operator": "sigstore.dev" + } + ], + "rekorTlogUrls": [ + { + "url": "https://log2025-alpha2.rekor.sigstage.dev", + "majorApiVersion": 2, + "validFor": { + "start": "2025-08-20T07:24:08Z" + }, + "operator": "sigstore.dev" + }, + { + "url": "https://log2025-alpha1.rekor.sigstage.dev", + "majorApiVersion": 2, + "validFor": { + "start": "2025-05-07T12:00:00Z", + "end": "2025-08-20T07:24:08Z" + }, + "operator": "sigstore.dev" + }, + { + "url": "https://rekor.sigstage.dev", + "majorApiVersion": 1, + "validFor": { + "start": "2021-01-12T11:53:27Z" + }, + "operator": "sigstore.dev" + } + ], + "tsaUrls": [ + { + "url": "https://timestamp.sigstage.dev/api/v1/timestamp", + "majorApiVersion": 1, + "validFor": { + "start": "2025-04-09T00:00:00Z" + }, + "operator": "sigstore.dev" + } + ], + "rekorTlogConfig": { + "selector": "ANY" + }, + "tsaConfig": { + "selector": "ANY" + } +} diff --git a/crates/sigstore-trust-root/src/lib.rs b/crates/sigstore-trust-root/src/lib.rs index 18258c6..0683186 100644 --- a/crates/sigstore-trust-root/src/lib.rs +++ b/crates/sigstore-trust-root/src/lib.rs @@ -1,12 +1,24 @@ //! Sigstore trusted root parsing and management //! -//! This crate provides functionality to parse and manage Sigstore trusted root bundles. +//! This crate provides functionality to parse and manage Sigstore trusted root bundles +//! and signing configuration. +//! +//! ## Trusted Root +//! //! The trusted root contains all the trust anchors needed for verification: //! - Fulcio certificate authorities (for signing certificates) //! - Rekor transparency log public keys (for log entry verification) //! - Certificate Transparency log public keys (for CT verification) //! - Timestamp authority certificates (for RFC 3161 timestamp verification) //! +//! ## Signing Config +//! +//! The signing config specifies service endpoints for signing operations: +//! - Fulcio CA URLs for certificate issuance +//! - Rekor transparency log URLs (V1 and V2 endpoints) +//! - TSA URLs for RFC 3161 timestamp requests +//! - OIDC provider URLs for authentication +//! //! # Features //! //! - `tuf` - Enable TUF (The Update Framework) support for securely fetching @@ -16,38 +28,50 @@ //! # Example //! //! ```no_run -//! use sigstore_trust_root::TrustedRoot; +//! use sigstore_trust_root::{TrustedRoot, SigningConfig}; //! //! // Load embedded production trusted root //! let root = TrustedRoot::production().unwrap(); //! -//! // Load embedded staging trusted root (for testing) -//! let staging_root = TrustedRoot::staging().unwrap(); +//! // Load embedded production signing config +//! let config = SigningConfig::production().unwrap(); //! -//! // Or load from a file -//! let root = TrustedRoot::from_file("trusted_root.json").unwrap(); +//! // Get the best Rekor endpoint (highest available version) +//! if let Some(rekor) = config.get_rekor_url(None) { +//! println!("Rekor URL: {} (v{})", rekor.url, rekor.major_api_version); +//! } //! ``` //! //! With the `tuf` feature enabled: //! //! ```ignore -//! use sigstore_trust_root::TrustedRoot; +//! use sigstore_trust_root::{TrustedRoot, SigningConfig}; //! //! // Fetch via TUF protocol (secure, up-to-date) //! let root = TrustedRoot::from_tuf().await?; +//! let config = SigningConfig::from_tuf().await?; //! ``` pub mod error; +pub mod signing_config; pub mod trusted_root; #[cfg(feature = "tuf")] pub mod tuf; pub use error::{Error, Result}; +pub use signing_config::{ + ServiceConfiguration, ServiceEndpoint, ServiceSelector, ServiceValidityPeriod, SigningConfig, + SIGNING_CONFIG_MEDIA_TYPE, SIGSTORE_PRODUCTION_SIGNING_CONFIG, SIGSTORE_STAGING_SIGNING_CONFIG, + SUPPORTED_FULCIO_VERSIONS, SUPPORTED_REKOR_VERSIONS, SUPPORTED_TSA_VERSIONS, +}; pub use trusted_root::{ CertificateAuthority, CertificateTransparencyLog, TimestampAuthority, TransparencyLog, TrustedRoot, ValidityPeriod, SIGSTORE_PRODUCTION_TRUSTED_ROOT, SIGSTORE_STAGING_TRUSTED_ROOT, }; #[cfg(feature = "tuf")] -pub use tuf::TufConfig; +pub use tuf::{ + TufConfig, DEFAULT_TUF_URL, PRODUCTION_TUF_ROOT, SIGNING_CONFIG_TARGET, STAGING_TUF_ROOT, + STAGING_TUF_URL, TRUSTED_ROOT_TARGET, +}; diff --git a/crates/sigstore-trust-root/src/signing_config.rs b/crates/sigstore-trust-root/src/signing_config.rs new file mode 100644 index 0000000..9830f55 --- /dev/null +++ b/crates/sigstore-trust-root/src/signing_config.rs @@ -0,0 +1,318 @@ +//! Signing configuration for Sigstore instances +//! +//! This module provides functionality to parse and manage Sigstore signing configuration +//! which specifies the service endpoints for signing operations: +//! - Fulcio CA URLs for certificate issuance +//! - Rekor transparency log URLs for log entry submission +//! - TSA URLs for RFC 3161 timestamp requests +//! - OIDC provider URLs for authentication +//! +//! # Example +//! +//! ```no_run +//! use sigstore_trust_root::SigningConfig; +//! +//! // Load embedded production signing config +//! let config = SigningConfig::production().unwrap(); +//! +//! // Get the best Rekor endpoint (highest available version) +//! if let Some(rekor) = config.get_rekor_url(None) { +//! println!("Rekor URL: {} (v{})", rekor.url, rekor.major_api_version); +//! } +//! ``` + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::{Error, Result}; + +/// Embedded production signing config +pub const SIGSTORE_PRODUCTION_SIGNING_CONFIG: &str = + include_str!("../repository/signing_config.json"); + +/// Embedded staging signing config +pub const SIGSTORE_STAGING_SIGNING_CONFIG: &str = + include_str!("../repository/signing_config_staging.json"); + +/// Supported Rekor API versions +pub const SUPPORTED_REKOR_VERSIONS: &[u32] = &[1, 2]; + +/// Supported TSA API versions +pub const SUPPORTED_TSA_VERSIONS: &[u32] = &[1]; + +/// Supported Fulcio API versions +pub const SUPPORTED_FULCIO_VERSIONS: &[u32] = &[1]; + +/// Expected media type for signing config v0.2 +pub const SIGNING_CONFIG_MEDIA_TYPE: &str = "application/vnd.dev.sigstore.signingconfig.v0.2+json"; + +/// Validity period for a service +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServiceValidityPeriod { + /// Start time of validity + pub start: DateTime, + /// End time of validity (optional, None means still valid) + #[serde(default)] + pub end: Option>, +} + +impl ServiceValidityPeriod { + /// Check if this period is currently valid + pub fn is_valid(&self) -> bool { + let now = Utc::now(); + if now < self.start { + return false; + } + if let Some(end) = self.end { + if now >= end { + return false; + } + } + true + } +} + +/// A service endpoint configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServiceEndpoint { + /// URL of the service + pub url: String, + /// Major API version supported by this endpoint + pub major_api_version: u32, + /// Validity period for this endpoint + pub valid_for: ServiceValidityPeriod, + /// Operator of this service + #[serde(default)] + pub operator: Option, +} + +impl ServiceEndpoint { + /// Check if this endpoint is currently valid + pub fn is_valid(&self) -> bool { + self.valid_for.is_valid() + } +} + +/// Service selector configuration +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ServiceSelector { + /// Use any available service + #[default] + Any, + /// Use exactly the specified number of services + Exact, +} + +/// Service configuration +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ServiceConfiguration { + /// How to select services + #[serde(default)] + pub selector: ServiceSelector, + /// Number of services to use (for EXACT selector) + #[serde(default)] + pub count: Option, +} + +/// Signing configuration for a Sigstore instance +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SigningConfig { + /// Media type of this configuration + pub media_type: String, + /// Fulcio CA URLs + #[serde(default)] + pub ca_urls: Vec, + /// OIDC provider URLs + #[serde(default)] + pub oidc_urls: Vec, + /// Rekor transparency log URLs + #[serde(default)] + pub rekor_tlog_urls: Vec, + /// Timestamp authority URLs + #[serde(default)] + pub tsa_urls: Vec, + /// Rekor tlog configuration + #[serde(default)] + pub rekor_tlog_config: ServiceConfiguration, + /// TSA configuration + #[serde(default)] + pub tsa_config: ServiceConfiguration, +} + +impl SigningConfig { + /// Load the embedded production signing config + pub fn production() -> Result { + Self::from_json(SIGSTORE_PRODUCTION_SIGNING_CONFIG) + } + + /// Load the embedded staging signing config + pub fn staging() -> Result { + Self::from_json(SIGSTORE_STAGING_SIGNING_CONFIG) + } + + /// Parse signing config from JSON + pub fn from_json(json: &str) -> Result { + let config: SigningConfig = serde_json::from_str(json)?; + + // Validate media type + if config.media_type != SIGNING_CONFIG_MEDIA_TYPE { + return Err(Error::UnsupportedMediaType(config.media_type)); + } + + Ok(config) + } + + /// Parse signing config from a file + pub fn from_file(path: &str) -> Result { + let json = std::fs::read_to_string(path) + .map_err(|e| Error::MissingField(format!("Failed to read file {}: {}", path, e)))?; + Self::from_json(&json) + } + + /// Get valid Rekor endpoints, optionally filtered by version + /// + /// If `force_version` is Some, only returns endpoints with that major version. + /// Otherwise returns all valid endpoints for supported versions. + /// + /// Endpoints are sorted by version descending (highest first). + pub fn get_rekor_urls(&self, force_version: Option) -> Vec<&ServiceEndpoint> { + let mut endpoints: Vec<_> = self + .rekor_tlog_urls + .iter() + .filter(|e| { + // Must be valid + if !e.is_valid() { + return false; + } + // Must be a supported version + if !SUPPORTED_REKOR_VERSIONS.contains(&e.major_api_version) { + return false; + } + // If forcing a version, must match + if let Some(v) = force_version { + return e.major_api_version == v; + } + true + }) + .collect(); + + // Sort by version descending (highest version first) + endpoints.sort_by(|a, b| b.major_api_version.cmp(&a.major_api_version)); + endpoints + } + + /// Get the best Rekor endpoint (highest version available) + /// + /// If `force_version` is Some, returns the first endpoint with that version. + pub fn get_rekor_url(&self, force_version: Option) -> Option<&ServiceEndpoint> { + self.get_rekor_urls(force_version).first().copied() + } + + /// Get valid Fulcio endpoints + pub fn get_fulcio_urls(&self) -> Vec<&ServiceEndpoint> { + self.ca_urls + .iter() + .filter(|e| e.is_valid() && SUPPORTED_FULCIO_VERSIONS.contains(&e.major_api_version)) + .collect() + } + + /// Get the best Fulcio endpoint + pub fn get_fulcio_url(&self) -> Option<&ServiceEndpoint> { + self.get_fulcio_urls().first().copied() + } + + /// Get valid TSA endpoints + pub fn get_tsa_urls(&self) -> Vec<&ServiceEndpoint> { + self.tsa_urls + .iter() + .filter(|e| e.is_valid() && SUPPORTED_TSA_VERSIONS.contains(&e.major_api_version)) + .collect() + } + + /// Get the best TSA endpoint + pub fn get_tsa_url(&self) -> Option<&ServiceEndpoint> { + self.get_tsa_urls().first().copied() + } + + /// Get valid OIDC provider URLs + pub fn get_oidc_urls(&self) -> Vec<&ServiceEndpoint> { + self.oidc_urls.iter().filter(|e| e.is_valid()).collect() + } + + /// Get the best OIDC provider URL + pub fn get_oidc_url(&self) -> Option<&ServiceEndpoint> { + self.get_oidc_urls().first().copied() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_production_signing_config() { + let config = SigningConfig::production().expect("Failed to parse production config"); + assert_eq!(config.media_type, SIGNING_CONFIG_MEDIA_TYPE); + assert!(!config.ca_urls.is_empty()); + assert!(!config.rekor_tlog_urls.is_empty()); + } + + #[test] + fn test_parse_staging_signing_config() { + let config = SigningConfig::staging().expect("Failed to parse staging config"); + assert_eq!(config.media_type, SIGNING_CONFIG_MEDIA_TYPE); + assert!(!config.ca_urls.is_empty()); + assert!(!config.rekor_tlog_urls.is_empty()); + } + + #[test] + fn test_get_rekor_url_highest_version() { + let config = SigningConfig::staging().expect("Failed to parse staging config"); + if let Some(rekor) = config.get_rekor_url(None) { + // Staging should have V2 available + println!("Best Rekor: {} v{}", rekor.url, rekor.major_api_version); + } + } + + #[test] + fn test_get_rekor_url_force_version() { + let config = SigningConfig::staging().expect("Failed to parse staging config"); + + // Force V1 + if let Some(rekor) = config.get_rekor_url(Some(1)) { + assert_eq!(rekor.major_api_version, 1); + } + + // Force V2 + if let Some(rekor) = config.get_rekor_url(Some(2)) { + assert_eq!(rekor.major_api_version, 2); + } + } + + #[test] + fn test_service_validity() { + let valid_period = ServiceValidityPeriod { + start: DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z") + .unwrap() + .into(), + end: None, + }; + assert!(valid_period.is_valid()); + + let expired_period = ServiceValidityPeriod { + start: DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z") + .unwrap() + .into(), + end: Some( + DateTime::parse_from_rfc3339("2021-01-01T00:00:00Z") + .unwrap() + .into(), + ), + }; + assert!(!expired_period.is_valid()); + } +} diff --git a/crates/sigstore-trust-root/src/tuf.rs b/crates/sigstore-trust-root/src/tuf.rs index 39e473e..785bfdc 100644 --- a/crates/sigstore-trust-root/src/tuf.rs +++ b/crates/sigstore-trust-root/src/tuf.rs @@ -1,19 +1,23 @@ -//! TUF client for fetching Sigstore trusted roots +//! TUF client for fetching Sigstore trusted roots and signing configuration //! //! This module provides functionality to securely fetch trusted root configuration -//! from Sigstore's TUF repository using The Update Framework protocol. +//! and signing configuration from Sigstore's TUF repository using The Update Framework protocol. //! //! # Example //! //! ```no_run -//! use sigstore_trust_root::TrustedRoot; +//! use sigstore_trust_root::{TrustedRoot, SigningConfig}; //! //! # async fn example() -> Result<(), sigstore_trust_root::Error> { //! // Fetch trusted root via TUF from production Sigstore //! let root = TrustedRoot::from_tuf().await?; //! +//! // Fetch signing config via TUF +//! let config = SigningConfig::from_tuf().await?; +//! //! // Or from staging //! let staging_root = TrustedRoot::from_tuf_staging().await?; +//! let staging_config = SigningConfig::from_tuf_staging().await?; //! # Ok(()) //! # } //! ``` @@ -23,7 +27,7 @@ use std::path::PathBuf; use tough::{HttpTransport, IntoVec, RepositoryLoader, TargetName}; use url::Url; -use crate::{Error, Result, TrustedRoot}; +use crate::{Error, Result, SigningConfig, TrustedRoot}; /// Default Sigstore production TUF repository URL pub const DEFAULT_TUF_URL: &str = "https://tuf-repo-cdn.sigstore.dev"; @@ -37,6 +41,12 @@ pub const PRODUCTION_TUF_ROOT: &[u8] = include_bytes!("../repository/tuf_root.js /// Embedded root.json for staging TUF instance pub const STAGING_TUF_ROOT: &[u8] = include_bytes!("../repository/tuf_staging_root.json"); +/// TUF target name for trusted root +pub const TRUSTED_ROOT_TARGET: &str = "trusted_root.json"; + +/// TUF target name for signing configuration +pub const SIGNING_CONFIG_TARGET: &str = "signing_config.v0.2.json"; + /// Configuration for TUF client #[derive(Debug, Clone)] pub struct TufConfig { @@ -46,6 +56,8 @@ pub struct TufConfig { pub cache_dir: Option, /// Whether to disable local caching pub disable_cache: bool, + /// Whether to use offline mode (no network, use cached/embedded data) + pub offline: bool, } impl Default for TufConfig { @@ -54,6 +66,7 @@ impl Default for TufConfig { url: DEFAULT_TUF_URL.to_string(), cache_dir: None, disable_cache: false, + offline: false, } } } @@ -83,12 +96,42 @@ impl TufConfig { self.disable_cache = true; self } + + /// Enable offline mode (skip network, use cached or embedded data) + /// + /// In offline mode: + /// 1. First checks the local TUF cache for previously downloaded targets + /// 2. Falls back to embedded data if cache is empty + /// 3. No network requests are made + /// + /// **Warning**: Offline mode uses unverified cached data. The cached data + /// was verified when originally downloaded, but freshness is not checked. + pub fn offline(mut self) -> Self { + self.offline = true; + self + } } +/// Embedded production trusted root (same as SIGSTORE_PRODUCTION_TRUSTED_ROOT but as bytes) +const EMBEDDED_PRODUCTION_TRUSTED_ROOT: &[u8] = include_bytes!("trusted_root.json"); + +/// Embedded production signing config +const EMBEDDED_PRODUCTION_SIGNING_CONFIG: &[u8] = + include_bytes!("../repository/signing_config.json"); + +/// Embedded staging trusted root (same as SIGSTORE_STAGING_TRUSTED_ROOT but as bytes) +const EMBEDDED_STAGING_TRUSTED_ROOT: &[u8] = include_bytes!("trusted_root_staging.json"); + +/// Embedded staging signing config +const EMBEDDED_STAGING_SIGNING_CONFIG: &[u8] = + include_bytes!("../repository/signing_config_staging.json"); + /// Internal TUF client for fetching targets struct TufClient { config: TufConfig, root_json: &'static [u8], + /// Embedded targets for offline fallback (target_name -> bytes) + embedded_targets: &'static [(&'static str, &'static [u8])], } impl TufClient { @@ -97,6 +140,10 @@ impl TufClient { Self { config: TufConfig::production(), root_json: PRODUCTION_TUF_ROOT, + embedded_targets: &[ + (TRUSTED_ROOT_TARGET, EMBEDDED_PRODUCTION_TRUSTED_ROOT), + (SIGNING_CONFIG_TARGET, EMBEDDED_PRODUCTION_SIGNING_CONFIG), + ], } } @@ -105,16 +152,32 @@ impl TufClient { Self { config: TufConfig::staging(), root_json: STAGING_TUF_ROOT, + embedded_targets: &[ + (TRUSTED_ROOT_TARGET, EMBEDDED_STAGING_TRUSTED_ROOT), + (SIGNING_CONFIG_TARGET, EMBEDDED_STAGING_SIGNING_CONFIG), + ], } } - /// Create a new client with custom configuration + /// Create a new client with custom configuration (no embedded fallback) fn new(config: TufConfig, root_json: &'static [u8]) -> Self { - Self { config, root_json } + Self { + config, + root_json, + embedded_targets: &[], + } } /// Fetch a target file from the TUF repository + /// + /// In online mode: fetches via TUF protocol with verification + /// In offline mode: returns cached data, falling back to embedded data async fn fetch_target(&self, target_name: &str) -> Result> { + if self.config.offline { + return self.fetch_target_offline(target_name).await; + } + + // Online mode: use TUF protocol // Parse URLs let base_url = Url::parse(&self.config.url).map_err(|e| Error::Tuf(e.to_string()))?; let metadata_url = base_url.clone(); @@ -162,6 +225,35 @@ impl TufClient { Ok(bytes) } + /// Fetch target in offline mode (no network) + /// + /// Priority: + /// 1. Check local TUF cache for previously downloaded target + /// 2. Fall back to embedded data + async fn fetch_target_offline(&self, target_name: &str) -> Result> { + // Try to read from cache first + if !self.config.disable_cache { + if let Ok(cache_dir) = self.get_cache_dir() { + let cached_path = cache_dir.join("targets").join(target_name); + if let Ok(bytes) = tokio::fs::read(&cached_path).await { + return Ok(bytes); + } + } + } + + // Fall back to embedded data + for (name, data) in self.embedded_targets { + if *name == target_name { + return Ok(data.to_vec()); + } + } + + Err(Error::Tuf(format!( + "Target '{}' not found in cache or embedded data (offline mode)", + target_name + ))) + } + /// Get the cache directory path fn get_cache_dir(&self) -> Result { if let Some(ref dir) = self.config.cache_dir { @@ -195,9 +287,9 @@ impl TrustedRoot { /// ``` pub async fn from_tuf() -> Result { let client = TufClient::production(); - let bytes = client.fetch_target("trusted_root.json").await?; + let bytes = client.fetch_target(TRUSTED_ROOT_TARGET).await?; let json = String::from_utf8(bytes) - .map_err(|e| Error::Tuf(format!("Invalid UTF-8 in trusted_root.json: {}", e)))?; + .map_err(|e| Error::Tuf(format!("Invalid UTF-8 in {}: {}", TRUSTED_ROOT_TARGET, e)))?; Self::from_json(&json) } @@ -206,9 +298,9 @@ impl TrustedRoot { /// This is useful for testing against the staging Sigstore infrastructure. pub async fn from_tuf_staging() -> Result { let client = TufClient::staging(); - let bytes = client.fetch_target("trusted_root.json").await?; + let bytes = client.fetch_target(TRUSTED_ROOT_TARGET).await?; let json = String::from_utf8(bytes) - .map_err(|e| Error::Tuf(format!("Invalid UTF-8 in trusted_root.json: {}", e)))?; + .map_err(|e| Error::Tuf(format!("Invalid UTF-8 in {}: {}", TRUSTED_ROOT_TARGET, e)))?; Self::from_json(&json) } @@ -220,9 +312,72 @@ impl TrustedRoot { /// * `tuf_root` - The TUF root.json to use for bootstrapping trust pub async fn from_tuf_with_config(config: TufConfig, tuf_root: &'static [u8]) -> Result { let client = TufClient::new(config, tuf_root); - let bytes = client.fetch_target("trusted_root.json").await?; + let bytes = client.fetch_target(TRUSTED_ROOT_TARGET).await?; let json = String::from_utf8(bytes) - .map_err(|e| Error::Tuf(format!("Invalid UTF-8 in trusted_root.json: {}", e)))?; + .map_err(|e| Error::Tuf(format!("Invalid UTF-8 in {}: {}", TRUSTED_ROOT_TARGET, e)))?; + Self::from_json(&json) + } +} + +impl SigningConfig { + /// Fetch the signing configuration from Sigstore's production TUF repository + /// + /// This securely fetches the `signing_config.v0.2.json` using the TUF protocol, + /// verifying all metadata signatures against the embedded root of trust. + /// + /// The signing config contains service endpoints for signing operations: + /// - Fulcio CA URLs for certificate issuance + /// - Rekor transparency log URLs (V1 and V2 endpoints) + /// - TSA URLs for RFC 3161 timestamp requests + /// - OIDC provider URLs for authentication + /// + /// # Example + /// + /// ```no_run + /// use sigstore_trust_root::SigningConfig; + /// + /// # async fn example() -> Result<(), sigstore_trust_root::Error> { + /// let config = SigningConfig::from_tuf().await?; + /// if let Some(rekor) = config.get_rekor_url(None) { + /// println!("Rekor URL: {} (v{})", rekor.url, rekor.major_api_version); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn from_tuf() -> Result { + let client = TufClient::production(); + let bytes = client.fetch_target(SIGNING_CONFIG_TARGET).await?; + let json = String::from_utf8(bytes).map_err(|e| { + Error::Tuf(format!("Invalid UTF-8 in {}: {}", SIGNING_CONFIG_TARGET, e)) + })?; + Self::from_json(&json) + } + + /// Fetch the signing configuration from Sigstore's staging TUF repository + /// + /// This is useful for testing against the staging Sigstore infrastructure, + /// which may have newer API versions (e.g., Rekor V2) available. + pub async fn from_tuf_staging() -> Result { + let client = TufClient::staging(); + let bytes = client.fetch_target(SIGNING_CONFIG_TARGET).await?; + let json = String::from_utf8(bytes).map_err(|e| { + Error::Tuf(format!("Invalid UTF-8 in {}: {}", SIGNING_CONFIG_TARGET, e)) + })?; + Self::from_json(&json) + } + + /// Fetch the signing configuration from a custom TUF repository + /// + /// # Arguments + /// + /// * `config` - TUF client configuration + /// * `tuf_root` - The TUF root.json to use for bootstrapping trust + pub async fn from_tuf_with_config(config: TufConfig, tuf_root: &'static [u8]) -> Result { + let client = TufClient::new(config, tuf_root); + let bytes = client.fetch_target(SIGNING_CONFIG_TARGET).await?; + let json = String::from_utf8(bytes).map_err(|e| { + Error::Tuf(format!("Invalid UTF-8 in {}: {}", SIGNING_CONFIG_TARGET, e)) + })?; Self::from_json(&json) } } @@ -237,6 +392,7 @@ mod tests { assert_eq!(config.url, DEFAULT_TUF_URL); assert!(config.cache_dir.is_none()); assert!(!config.disable_cache); + assert!(!config.offline); } #[test] @@ -249,8 +405,10 @@ mod tests { fn test_tuf_config_builder() { let config = TufConfig::production() .with_cache_dir(PathBuf::from("/tmp/test")) - .without_cache(); + .without_cache() + .offline(); assert!(config.disable_cache); + assert!(config.offline); assert_eq!(config.cache_dir, Some(PathBuf::from("/tmp/test"))); } @@ -262,4 +420,57 @@ mod tests { let _: serde_json::Value = serde_json::from_slice(STAGING_TUF_ROOT).expect("Invalid staging TUF root"); } + + #[test] + fn test_embedded_targets_are_valid() { + // Verify embedded trusted roots can be parsed + let _root: crate::TrustedRoot = serde_json::from_slice(EMBEDDED_PRODUCTION_TRUSTED_ROOT) + .expect("Invalid production trusted root"); + let _root: crate::TrustedRoot = serde_json::from_slice(EMBEDDED_STAGING_TRUSTED_ROOT) + .expect("Invalid staging trusted root"); + + // Verify embedded signing configs can be parsed + let _config: crate::SigningConfig = + serde_json::from_slice(EMBEDDED_PRODUCTION_SIGNING_CONFIG) + .expect("Invalid production signing config"); + let _config: crate::SigningConfig = serde_json::from_slice(EMBEDDED_STAGING_SIGNING_CONFIG) + .expect("Invalid staging signing config"); + } + + #[tokio::test] + async fn test_offline_mode_uses_embedded_data() { + // Create a client in offline mode with cache disabled + // This should fall back to embedded data + let client = TufClient { + config: TufConfig::production().offline().without_cache(), + root_json: PRODUCTION_TUF_ROOT, + embedded_targets: &[ + (TRUSTED_ROOT_TARGET, EMBEDDED_PRODUCTION_TRUSTED_ROOT), + (SIGNING_CONFIG_TARGET, EMBEDDED_PRODUCTION_SIGNING_CONFIG), + ], + }; + + // Should successfully return embedded trusted root + let bytes = client.fetch_target(TRUSTED_ROOT_TARGET).await.unwrap(); + assert!(!bytes.is_empty()); + let _root: crate::TrustedRoot = serde_json::from_slice(&bytes).unwrap(); + + // Should successfully return embedded signing config + let bytes = client.fetch_target(SIGNING_CONFIG_TARGET).await.unwrap(); + assert!(!bytes.is_empty()); + let _config: crate::SigningConfig = serde_json::from_slice(&bytes).unwrap(); + } + + #[tokio::test] + async fn test_offline_mode_fails_for_unknown_target() { + let client = TufClient { + config: TufConfig::production().offline().without_cache(), + root_json: PRODUCTION_TUF_ROOT, + embedded_targets: &[], // No embedded data + }; + + // Should fail for unknown target + let result = client.fetch_target("unknown.json").await; + assert!(result.is_err()); + } } diff --git a/crates/sigstore-verify/src/verify_impl/helpers.rs b/crates/sigstore-verify/src/verify_impl/helpers.rs index 3adff42..667f244 100644 --- a/crates/sigstore-verify/src/verify_impl/helpers.rs +++ b/crates/sigstore-verify/src/verify_impl/helpers.rs @@ -45,31 +45,6 @@ pub fn extract_signature(content: &SignatureContent) -> Result { } } -/// Extract the integrated time from transparency log entries -/// Returns the earliest integrated time if multiple entries are present -pub fn extract_integrated_time(bundle: &Bundle) -> Result> { - let mut earliest_time: Option = None; - - for entry in &bundle.verification_material.tlog_entries { - if !entry.integrated_time.is_empty() { - if let Ok(time) = entry.integrated_time.parse::() { - // Ignore 0 as it indicates invalid/missing time (e.g. from test instances) - if time > 0 { - if let Some(earliest) = earliest_time { - if time < earliest { - earliest_time = Some(time); - } - } else { - earliest_time = Some(time); - } - } - } - } - } - - Ok(earliest_time) -} - /// Extract and verify TSA RFC 3161 timestamps /// Returns the earliest verified timestamp if any are present pub fn extract_tsa_timestamp( @@ -159,22 +134,95 @@ pub fn extract_tsa_timestamp( Ok(earliest_timestamp) } -/// Determine validation time from timestamps -/// Priority order: +/// Check if bundle contains V2 tlog entries (hashedrekord/dsse v0.0.2) +/// V2 entries have integrated_time=0 and require RFC3161 timestamps +pub fn has_v2_tlog_entries(bundle: &Bundle) -> bool { + bundle + .verification_material + .tlog_entries + .iter() + .any(|entry| entry.kind_version.version == "0.0.2") +} + +/// Extract integrated time from V1 tlog entries that have inclusion promises. +/// +/// Per sigstore-python, integrated_time is only valid as a timestamp source when: +/// 1. The entry has an inclusion_promise (SET) that cryptographically binds it +/// 2. The entry is a V1 type (hashedrekord/dsse v0.0.1) +/// 3. The integrated_time is > 0 +/// +/// Returns the earliest valid integrated time if any are present. +fn extract_v1_integrated_time_with_promise(bundle: &Bundle) -> Option { + let mut earliest_time: Option = None; + + for entry in &bundle.verification_material.tlog_entries { + // Only V1 entries (0.0.1) with inclusion promises are valid timestamp sources + let is_v1 = entry.kind_version.version == "0.0.1" + && (entry.kind_version.kind == "hashedrekord" || entry.kind_version.kind == "dsse"); + + if !is_v1 || entry.inclusion_promise.is_none() { + continue; + } + + if let Ok(time) = entry.integrated_time.parse::() { + if time > 0 { + if let Some(earliest) = earliest_time { + if time < earliest { + earliest_time = Some(time); + } + } else { + earliest_time = Some(time); + } + } + } + } + + earliest_time +} + +/// Determine validation time from timestamps. +/// +/// At least one verified timestamp source is REQUIRED. This matches sigstore-python's +/// behavior which enforces `VERIFIED_TIME_THRESHOLD = 1`. +/// +/// Valid timestamp sources (in priority order): /// 1. TSA timestamp (RFC 3161) - most authoritative -/// 2. Integrated time from transparency log -/// 3. Current time - fallback +/// 2. Integrated time from V1 tlog entries with inclusion promises +/// +/// Note: There is NO fallback to current time. If no verified timestamp is found, +/// verification fails. pub fn determine_validation_time( bundle: &Bundle, signature: &SignatureBytes, trusted_root: &TrustedRoot, ) -> Result { + // Try TSA timestamp first (most authoritative) if let Some(tsa_time) = extract_tsa_timestamp(bundle, signature.as_bytes(), trusted_root)? { - Ok(tsa_time) - } else if let Some(integrated_time) = extract_integrated_time(bundle)? { - Ok(integrated_time) + return Ok(tsa_time); + } + + // Try integrated time from V1 tlog entries with inclusion promises + // Per sigstore-python: integrated_time only counts if accompanied by inclusion_promise + if let Some(integrated_time) = extract_v1_integrated_time_with_promise(bundle) { + return Ok(integrated_time); + } + + // No verified timestamp found - fail verification + // This matches sigstore-python's behavior: "not enough sources of verified time" + let is_v2 = has_v2_tlog_entries(bundle); + if is_v2 { + Err(Error::Verification( + "V2 bundle requires RFC3161 timestamp but none could be verified. \ + V2 tlog entries have integrated_time=0 by design. \ + Ensure TSA certificates are present in the trusted root." + .to_string(), + )) } else { - Ok(chrono::Utc::now().timestamp()) + Err(Error::Verification( + "No verified timestamp found. V1 bundles require either an RFC3161 timestamp \ + or a tlog entry with both integrated_time > 0 and an inclusion_promise (SET)." + .to_string(), + )) } }