diff --git a/crates/rattler_build_recipe/src/stage0/build.rs b/crates/rattler_build_recipe/src/stage0/build.rs index b29bcf0f8..fd12acbc5 100644 --- a/crates/rattler_build_recipe/src/stage0/build.rs +++ b/crates/rattler_build_recipe/src/stage0/build.rs @@ -79,6 +79,10 @@ pub struct Build { /// Post-processing operations #[serde(default)] pub post_process: ConditionalList, + + /// Code signing configuration + #[serde(default)] + pub signing: Signing, } impl Default for Build { @@ -98,6 +102,7 @@ impl Default for Build { variant: VariantKeyUsage::default(), prefix_detection: PrefixDetection::default(), post_process: ConditionalList::default(), + signing: Signing::default(), } } } @@ -189,6 +194,84 @@ pub struct PrefixDetection { pub ignore_binary_files: Value, } +/// Code signing configuration for native binaries +#[derive(Debug, Serialize, Deserialize, Default, Clone, PartialEq)] +pub struct Signing { + /// macOS code signing configuration + #[serde(default)] + pub macos: Option, + /// Windows code signing configuration + #[serde(default)] + pub windows: Option, +} + +/// macOS code signing configuration using `codesign` +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct MacOsSigning { + /// Signing identity (e.g., "Developer ID Application: Company (TEAMID)") + /// Use "-" for ad-hoc signing + pub identity: Value, + /// Path to the keychain containing the signing certificate + #[serde(default)] + pub keychain: Option>, + /// Entitlements plist file path + #[serde(default)] + pub entitlements: Option>, + /// Additional codesign options (e.g., "runtime" for hardened runtime) + #[serde(default)] + pub options: ConditionalList, +} + +/// Windows code signing configuration. +/// +/// Supports two signing methods (exactly one must be configured): +/// 1. **Local certificate** (`signtool`): Configure the `signtool` sub-object. +/// 2. **Azure Trusted Signing**: Configure the `azure_trusted_signing` sub-object. +/// +/// Shared settings (`timestamp_url`, `digest_algorithm`) live at the top level. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct WindowsSigning { + /// Local certificate signing via `signtool` + #[serde(default)] + pub signtool: Option, + /// Azure Trusted Signing configuration + #[serde(default)] + pub azure_trusted_signing: Option, + + // --- Shared settings --- + /// RFC 3161 timestamp server URL + #[serde(default)] + pub timestamp_url: Option>, + /// Digest algorithm (default: sha256) + #[serde(default)] + pub digest_algorithm: Option>, +} + +/// Local certificate signing configuration for `signtool` +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct SigntoolConfig { + /// Path to the certificate file (.pfx / .p12) + pub certificate_file: Value, + /// Name of the environment variable containing the certificate password. + /// The password is read from this env var at build time to avoid leaking + /// secrets into the rendered recipe. + #[serde(default)] + pub certificate_password_env: Option>, +} + +/// Azure Trusted Signing configuration +/// +/// Requires `az login` (OIDC) for authentication. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct AzureTrustedSigningConfig { + /// Azure Trusted Signing endpoint URL + pub endpoint: Value, + /// Azure Trusted Signing account name + pub account_name: Value, + /// Azure Trusted Signing certificate profile name + pub certificate_profile: Value, +} + /// Post-processing operations using regex replacements #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct PostProcess { @@ -277,6 +360,7 @@ impl Build { variant, prefix_detection, post_process, + signing, } = self; let mut vars = Vec::new(); @@ -411,6 +495,37 @@ impl Build { vars.extend(post_process.used_variables()); collect_post_process_vars(post_process.iter(), &mut vars); + // Signing + if let Some(macos) = &signing.macos { + vars.extend(macos.identity.used_variables()); + if let Some(keychain) = &macos.keychain { + vars.extend(keychain.used_variables()); + } + if let Some(entitlements) = &macos.entitlements { + vars.extend(entitlements.used_variables()); + } + vars.extend(macos.options.used_variables()); + } + if let Some(windows) = &signing.windows { + if let Some(signtool) = &windows.signtool { + vars.extend(signtool.certificate_file.used_variables()); + if let Some(password) = &signtool.certificate_password_env { + vars.extend(password.used_variables()); + } + } + if let Some(azure) = &windows.azure_trusted_signing { + vars.extend(azure.endpoint.used_variables()); + vars.extend(azure.account_name.used_variables()); + vars.extend(azure.certificate_profile.used_variables()); + } + if let Some(timestamp_url) = &windows.timestamp_url { + vars.extend(timestamp_url.used_variables()); + } + if let Some(digest_algorithm) = &windows.digest_algorithm { + vars.extend(digest_algorithm.used_variables()); + } + } + vars.sort(); vars.dedup(); vars diff --git a/crates/rattler_build_recipe/src/stage0/evaluate.rs b/crates/rattler_build_recipe/src/stage0/evaluate.rs index b7c938bd0..b42adda20 100644 --- a/crates/rattler_build_recipe/src/stage0/evaluate.rs +++ b/crates/rattler_build_recipe/src/stage0/evaluate.rs @@ -39,10 +39,13 @@ use crate::{ Package as Stage0Package, Requirements as Stage0Requirements, Source as Stage0Source, Stage0Recipe, TestType as Stage0TestType, build::{ + AzureTrustedSigningConfig as Stage0AzureTrustedSigningConfig, BinaryRelocation as Stage0BinaryRelocation, DynamicLinking as Stage0DynamicLinking, - ForceFileType as Stage0ForceFileType, PostProcess as Stage0PostProcess, - PrefixDetection as Stage0PrefixDetection, PrefixIgnore as Stage0PrefixIgnore, - PythonBuild as Stage0PythonBuild, VariantKeyUsage as Stage0VariantKeyUsage, + ForceFileType as Stage0ForceFileType, MacOsSigning as Stage0MacOsSigning, + PostProcess as Stage0PostProcess, PrefixDetection as Stage0PrefixDetection, + PrefixIgnore as Stage0PrefixIgnore, PythonBuild as Stage0PythonBuild, + Signing as Stage0Signing, SigntoolConfig as Stage0SigntoolConfig, + VariantKeyUsage as Stage0VariantKeyUsage, WindowsSigning as Stage0WindowsSigning, }, requirements::{ IgnoreRunExports as Stage0IgnoreRunExports, RunExports as Stage0RunExports, @@ -68,10 +71,13 @@ use crate::{ Extra as Stage1Extra, GlobVec, Package as Stage1Package, Recipe as Stage1Recipe, Requirements as Stage1Requirements, Rpaths, build::{ - Build as Stage1Build, BuildString, DynamicLinking as Stage1DynamicLinking, - ForceFileType as Stage1ForceFileType, PostProcess as Stage1PostProcess, - PrefixDetection as Stage1PrefixDetection, PythonBuild as Stage1PythonBuild, - VariantKeyUsage as Stage1VariantKeyUsage, + AzureTrustedSigningConfig as Stage1AzureTrustedSigningConfig, Build as Stage1Build, + BuildString, DynamicLinking as Stage1DynamicLinking, + ForceFileType as Stage1ForceFileType, MacOsSigning as Stage1MacOsSigning, + PostProcess as Stage1PostProcess, PrefixDetection as Stage1PrefixDetection, + PythonBuild as Stage1PythonBuild, Signing as Stage1Signing, + SigntoolConfig as Stage1SigntoolConfig, VariantKeyUsage as Stage1VariantKeyUsage, + WindowsSigning as Stage1WindowsSigning, }, requirements::{ IgnoreRunExports as Stage1IgnoreRunExports, RunExports as Stage1RunExports, @@ -1975,6 +1981,120 @@ impl Evaluate for Stage0DynamicLinking { } } +impl Evaluate for Stage0MacOsSigning { + type Output = Stage1MacOsSigning; + + fn evaluate(&self, context: &EvaluationContext) -> Result { + let identity = evaluate_string_value(&self.identity, context)?; + let keychain = self + .keychain + .as_ref() + .map(|v| evaluate_string_value(v, context)) + .transpose()?; + let entitlements = self + .entitlements + .as_ref() + .map(|v| evaluate_string_value(v, context)) + .transpose()?; + let options = evaluate_string_list(&self.options, context)?; + + Ok(Stage1MacOsSigning { + identity, + keychain, + entitlements, + options, + }) + } +} + +impl Evaluate for Stage0SigntoolConfig { + type Output = Stage1SigntoolConfig; + + fn evaluate(&self, context: &EvaluationContext) -> Result { + let certificate_file = evaluate_string_value(&self.certificate_file, context)?; + let certificate_password_env = self + .certificate_password_env + .as_ref() + .map(|v| evaluate_string_value(v, context)) + .transpose()?; + + Ok(Stage1SigntoolConfig { + certificate_file, + certificate_password_env, + }) + } +} + +impl Evaluate for Stage0AzureTrustedSigningConfig { + type Output = Stage1AzureTrustedSigningConfig; + + fn evaluate(&self, context: &EvaluationContext) -> Result { + let endpoint = evaluate_string_value(&self.endpoint, context)?; + let account_name = evaluate_string_value(&self.account_name, context)?; + let certificate_profile = evaluate_string_value(&self.certificate_profile, context)?; + + Ok(Stage1AzureTrustedSigningConfig { + endpoint, + account_name, + certificate_profile, + }) + } +} + +impl Evaluate for Stage0WindowsSigning { + type Output = Stage1WindowsSigning; + + fn evaluate(&self, context: &EvaluationContext) -> Result { + let signtool = self + .signtool + .as_ref() + .map(|s| s.evaluate(context)) + .transpose()?; + let azure_trusted_signing = self + .azure_trusted_signing + .as_ref() + .map(|a| a.evaluate(context)) + .transpose()?; + let timestamp_url = self + .timestamp_url + .as_ref() + .map(|v| evaluate_string_value(v, context)) + .transpose()?; + let digest_algorithm = self + .digest_algorithm + .as_ref() + .map(|v| evaluate_string_value(v, context)) + .transpose()? + .unwrap_or_else(|| "sha256".to_string()); + + Ok(Stage1WindowsSigning { + signtool, + azure_trusted_signing, + timestamp_url, + digest_algorithm, + }) + } +} + +impl Evaluate for Stage0Signing { + type Output = Stage1Signing; + + fn evaluate(&self, context: &EvaluationContext) -> Result { + let macos = self + .macos + .as_ref() + .map(|m| m.evaluate(context)) + .transpose()?; + let windows = self + .windows + .as_ref() + .map(|w| w.evaluate(context)) + .transpose()?; + + Ok(Stage1Signing { macos, windows }) + } +} + impl Evaluate for Stage0Build { type Output = Stage1Build; @@ -2098,6 +2218,9 @@ impl Evaluate for Stage0Build { false, )?; + // Evaluate signing configuration + let signing = self.signing.evaluate(context)?; + Ok(Stage1Build { number, string, @@ -2113,6 +2236,7 @@ impl Evaluate for Stage0Build { variant, prefix_detection, post_process, + signing, }) } } @@ -2927,6 +3051,13 @@ fn merge_stage1_build( output.post_process }; + // Signing: use output if not default, otherwise inherit from top-level + let signing = if output.signing.is_default() { + toplevel.signing + } else { + output.signing + }; + stage1::Build { script, number, @@ -2942,6 +3073,7 @@ fn merge_stage1_build( variant, prefix_detection, post_process, + signing, } } diff --git a/crates/rattler_build_recipe/src/stage0/parser/build.rs b/crates/rattler_build_recipe/src/stage0/parser/build.rs index e77613acd..f07f551f3 100644 --- a/crates/rattler_build_recipe/src/stage0/parser/build.rs +++ b/crates/rattler_build_recipe/src/stage0/parser/build.rs @@ -5,8 +5,9 @@ use rattler_conda_types::NoArchType; use crate::stage0::{ Conditional, ConditionalList, Item, JinjaExpression, NestedItemList, build::{ - BinaryRelocation, Build, DynamicLinking, ForceFileType, PostProcess, PrefixDetection, - PrefixIgnore, PythonBuild, VariantKeyUsage, + AzureTrustedSigningConfig, BinaryRelocation, Build, DynamicLinking, ForceFileType, + MacOsSigning, PostProcess, PrefixDetection, PrefixIgnore, PythonBuild, Signing, + SigntoolConfig, VariantKeyUsage, WindowsSigning, }, parser::helpers::get_span, types::{IncludeExclude, Value}, @@ -453,10 +454,13 @@ fn parse_build_from_mapping(mapping: &MarkedMappingNode) -> Result { build.post_process = parse_post_process_list(value_node)?; } + "signing" => { + build.signing = parse_signing(value_node)?; + } _ => { return Err( ParseError::invalid_value("build", format!("unknown field '{}'", key), *key_node.span()) - .with_suggestion("Valid fields are: number, string, script, noarch, python, skip, always_copy_files, always_include_files, merge_build_and_host_envs, files, dynamic_linking, variant, prefix_detection, post_process") + .with_suggestion("Valid fields are: number, string, script, noarch, python, skip, always_copy_files, always_include_files, merge_build_and_host_envs, files, dynamic_linking, variant, prefix_detection, post_process, signing") ); } } @@ -823,6 +827,262 @@ fn parse_post_process_list_as_values( } } +fn parse_signing(node: &Node) -> Result { + let mapping = node.as_mapping().ok_or_else(|| { + ParseError::expected_type("mapping", "non-mapping", get_span(node)) + .with_message("Expected 'signing' to be a mapping") + })?; + + let mut signing = Signing::default(); + + for (key_node, value_node) in mapping.iter() { + let key = key_node.as_str(); + + match key { + "macos" => { + signing.macos = Some(parse_macos_signing(value_node)?); + } + "windows" => { + signing.windows = Some(parse_windows_signing(value_node)?); + } + _ => { + return Err(ParseError::invalid_value( + "signing", + format!("unknown field '{}'", key), + *key_node.span(), + ) + .with_suggestion("Valid fields are: macos, windows")); + } + } + } + + Ok(signing) +} + +fn parse_macos_signing(node: &Node) -> Result { + let mapping = node.as_mapping().ok_or_else(|| { + ParseError::expected_type("mapping", "non-mapping", get_span(node)) + .with_message("Expected 'signing.macos' to be a mapping") + })?; + + let mut identity = None; + let mut keychain = None; + let mut entitlements = None; + let mut options = ConditionalList::default(); + + for (key_node, value_node) in mapping.iter() { + let key = key_node.as_str(); + + match key { + "identity" => { + identity = Some(parse_field!("signing.macos.identity", value_node)); + } + "keychain" => { + keychain = Some(parse_field!("signing.macos.keychain", value_node)); + } + "entitlements" => { + entitlements = Some(parse_field!("signing.macos.entitlements", value_node)); + } + "options" => { + options = parse_conditional_list(value_node)?; + } + _ => { + return Err(ParseError::invalid_value( + "signing.macos", + format!("unknown field '{}'", key), + *key_node.span(), + ) + .with_suggestion("Valid fields are: identity, keychain, entitlements, options")); + } + } + } + + let identity = identity.ok_or_else(|| ParseError::missing_field("identity", get_span(node)))?; + + Ok(MacOsSigning { + identity, + keychain, + entitlements, + options, + }) +} + +fn parse_windows_signing(node: &Node) -> Result { + let mapping = node.as_mapping().ok_or_else(|| { + ParseError::expected_type("mapping", "non-mapping", get_span(node)) + .with_message("Expected 'signing.windows' to be a mapping") + })?; + + let mut signtool = None; + let mut azure_trusted_signing = None; + let mut timestamp_url = None; + let mut digest_algorithm = None; + + for (key_node, value_node) in mapping.iter() { + let key = key_node.as_str(); + + match key { + "signtool" => { + signtool = Some(parse_signtool_config(value_node)?); + } + "azure_trusted_signing" => { + azure_trusted_signing = Some(parse_azure_trusted_signing_config(value_node)?); + } + "timestamp_url" => { + timestamp_url = Some(parse_field!("signing.windows.timestamp_url", value_node)); + } + "digest_algorithm" => { + digest_algorithm = + Some(parse_field!("signing.windows.digest_algorithm", value_node)); + } + _ => { + return Err(ParseError::invalid_value( + "signing.windows", + format!("unknown field '{}'", key), + *key_node.span(), + ) + .with_suggestion( + "Valid fields are: signtool, azure_trusted_signing, \ + timestamp_url, digest_algorithm", + )); + } + } + } + + // Validate: exactly one signing method must be specified + if signtool.is_none() && azure_trusted_signing.is_none() { + return Err( + ParseError::missing_field("signtool or azure_trusted_signing", get_span(node)) + .with_message( + "Windows signing requires either 'signtool' (for local certificate) or \ + 'azure_trusted_signing' (for Azure Trusted Signing)", + ), + ); + } + + if signtool.is_some() && azure_trusted_signing.is_some() { + return Err(ParseError::invalid_value( + "signing.windows", + "both 'signtool' and 'azure_trusted_signing' are set; use only one", + get_span(node), + ) + .with_message( + "Windows signing supports only one method at a time. \ + Use either 'signtool' or 'azure_trusted_signing', not both.", + )); + } + + Ok(WindowsSigning { + signtool, + azure_trusted_signing, + timestamp_url, + digest_algorithm, + }) +} + +fn parse_signtool_config(node: &Node) -> Result { + let mapping = node.as_mapping().ok_or_else(|| { + ParseError::expected_type("mapping", "non-mapping", get_span(node)) + .with_message("Expected 'signing.windows.signtool' to be a mapping") + })?; + + let mut certificate_file = None; + let mut certificate_password_env = None; + + for (key_node, value_node) in mapping.iter() { + let key = key_node.as_str(); + + match key { + "certificate_file" => { + certificate_file = Some(parse_field!( + "signing.windows.signtool.certificate_file", + value_node + )); + } + "certificate_password_env" => { + certificate_password_env = Some(parse_field!( + "signing.windows.signtool.certificate_password_env", + value_node + )); + } + _ => { + return Err(ParseError::invalid_value( + "signing.windows.signtool", + format!("unknown field '{}'", key), + *key_node.span(), + ) + .with_suggestion("Valid fields are: certificate_file, certificate_password_env")); + } + } + } + + let certificate_file = certificate_file + .ok_or_else(|| ParseError::missing_field("certificate_file", get_span(node)))?; + + Ok(SigntoolConfig { + certificate_file, + certificate_password_env, + }) +} + +fn parse_azure_trusted_signing_config( + node: &Node, +) -> Result { + let mapping = node.as_mapping().ok_or_else(|| { + ParseError::expected_type("mapping", "non-mapping", get_span(node)) + .with_message("Expected 'signing.windows.azure_trusted_signing' to be a mapping") + })?; + + let mut endpoint = None; + let mut account_name = None; + let mut certificate_profile = None; + + for (key_node, value_node) in mapping.iter() { + let key = key_node.as_str(); + + match key { + "endpoint" => { + endpoint = Some(parse_field!( + "signing.windows.azure_trusted_signing.endpoint", + value_node + )); + } + "account_name" => { + account_name = Some(parse_field!( + "signing.windows.azure_trusted_signing.account_name", + value_node + )); + } + "certificate_profile" => { + certificate_profile = Some(parse_field!( + "signing.windows.azure_trusted_signing.certificate_profile", + value_node + )); + } + _ => { + return Err(ParseError::invalid_value( + "signing.windows.azure_trusted_signing", + format!("unknown field '{}'", key), + *key_node.span(), + ) + .with_suggestion("Valid fields are: endpoint, account_name, certificate_profile")); + } + } + } + + let endpoint = endpoint.ok_or_else(|| ParseError::missing_field("endpoint", get_span(node)))?; + let account_name = + account_name.ok_or_else(|| ParseError::missing_field("account_name", get_span(node)))?; + let certificate_profile = certificate_profile + .ok_or_else(|| ParseError::missing_field("certificate_profile", get_span(node)))?; + + Ok(AzureTrustedSigningConfig { + endpoint, + account_name, + certificate_profile, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -954,4 +1214,202 @@ post_process: panic!("Expected conditional item"); } } + + #[test] + fn test_parse_signing_macos() { + let yaml = r#" +signing: + macos: + identity: "Developer ID Application: Test (ABC123)" + keychain: "/path/to/keychain.keychain-db" + entitlements: "entitlements.plist" + options: + - runtime +"#; + let node = marked_yaml::parse_yaml(0, yaml).unwrap(); + let build = parse_build(&node).unwrap(); + let signing = &build.signing; + assert!(signing.macos.is_some()); + assert!(signing.windows.is_none()); + + let macos = signing.macos.as_ref().unwrap(); + assert_eq!( + macos.identity.as_concrete().unwrap(), + "Developer ID Application: Test (ABC123)" + ); + assert_eq!( + macos.keychain.as_ref().unwrap().as_concrete().unwrap(), + "/path/to/keychain.keychain-db" + ); + assert_eq!( + macos.entitlements.as_ref().unwrap().as_concrete().unwrap(), + "entitlements.plist" + ); + assert_eq!(macos.options.len(), 1); + } + + #[test] + fn test_parse_signing_windows_signtool() { + let yaml = r#" +signing: + windows: + signtool: + certificate_file: "/path/to/cert.pfx" + certificate_password_env: "CERT_PASSWORD" + timestamp_url: "http://timestamp.digicert.com" + digest_algorithm: "sha256" +"#; + let node = marked_yaml::parse_yaml(0, yaml).unwrap(); + let build = parse_build(&node).unwrap(); + let signing = &build.signing; + assert!(signing.macos.is_none()); + assert!(signing.windows.is_some()); + + let windows = signing.windows.as_ref().unwrap(); + let signtool = windows.signtool.as_ref().unwrap(); + assert!(windows.azure_trusted_signing.is_none()); + assert_eq!( + signtool.certificate_file.as_concrete().unwrap(), + "/path/to/cert.pfx" + ); + assert_eq!( + signtool + .certificate_password_env + .as_ref() + .unwrap() + .as_concrete() + .unwrap(), + "CERT_PASSWORD" + ); + assert_eq!( + windows + .timestamp_url + .as_ref() + .unwrap() + .as_concrete() + .unwrap(), + "http://timestamp.digicert.com" + ); + } + + #[test] + fn test_parse_signing_windows_azure() { + let yaml = r#" +signing: + windows: + azure_trusted_signing: + endpoint: "https://wus2.codesigning.azure.net" + account_name: "my-signing-account" + certificate_profile: "my-profile" + timestamp_url: "http://timestamp.acs.microsoft.com" + digest_algorithm: "sha256" +"#; + let node = marked_yaml::parse_yaml(0, yaml).unwrap(); + let build = parse_build(&node).unwrap(); + let signing = &build.signing; + assert!(signing.windows.is_some()); + + let windows = signing.windows.as_ref().unwrap(); + assert!(windows.signtool.is_none()); + let azure = windows.azure_trusted_signing.as_ref().unwrap(); + assert_eq!( + azure.endpoint.as_concrete().unwrap(), + "https://wus2.codesigning.azure.net" + ); + assert_eq!( + azure.account_name.as_concrete().unwrap(), + "my-signing-account" + ); + assert_eq!( + azure.certificate_profile.as_concrete().unwrap(), + "my-profile" + ); + } + + #[test] + fn test_parse_signing_both_platforms() { + let yaml = r#" +signing: + macos: + identity: "-" + windows: + azure_trusted_signing: + endpoint: "https://endpoint" + account_name: "account" + certificate_profile: "profile" +"#; + let node = marked_yaml::parse_yaml(0, yaml).unwrap(); + let build = parse_build(&node).unwrap(); + assert!(build.signing.macos.is_some()); + assert!(build.signing.windows.is_some()); + } + + #[test] + fn test_parse_signing_missing_identity() { + let yaml = r#" +signing: + macos: + keychain: "/path/to/keychain" +"#; + let node = marked_yaml::parse_yaml(0, yaml).unwrap(); + let result = parse_build(&node); + assert!(result.is_err()); + } + + #[test] + fn test_parse_signing_missing_any_method() { + let yaml = r#" +signing: + windows: + timestamp_url: "http://example.com" +"#; + let node = marked_yaml::parse_yaml(0, yaml).unwrap(); + let result = parse_build(&node); + assert!(result.is_err()); + } + + #[test] + fn test_parse_signing_both_methods_errors() { + let yaml = r#" +signing: + windows: + signtool: + certificate_file: "cert.pfx" + azure_trusted_signing: + endpoint: "https://endpoint" + account_name: "account" + certificate_profile: "profile" +"#; + let node = marked_yaml::parse_yaml(0, yaml).unwrap(); + let result = parse_build(&node); + assert!(result.is_err()); + } + + #[test] + fn test_parse_signing_unknown_field() { + let yaml = r#" +signing: + linux: + key: "something" +"#; + let node = marked_yaml::parse_yaml(0, yaml).unwrap(); + let result = parse_build(&node); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, ParseError::InvalidValue { .. })); + } + + #[test] + fn test_parse_signing_with_jinja_template() { + let yaml = r#" +signing: + macos: + identity: "${{ SIGNING_IDENTITY }}" +"#; + let node = marked_yaml::parse_yaml(0, yaml).unwrap(); + let build = parse_build(&node).unwrap(); + let macos = build.signing.macos.as_ref().unwrap(); + // Should be a template, not a concrete value + assert!(macos.identity.as_concrete().is_none()); + } } diff --git a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__complex_recipe_snapshot.snap b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__complex_recipe_snapshot.snap index 701d7ee61..02e9d6293 100644 --- a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__complex_recipe_snapshot.snap +++ b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__complex_recipe_snapshot.snap @@ -38,6 +38,9 @@ build: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null requirements: build: - ${{ compiler("c") }} diff --git a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__conditionals_recipe_snapshot.snap b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__conditionals_recipe_snapshot.snap index 5a4ff5f6a..a08398568 100644 --- a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__conditionals_recipe_snapshot.snap +++ b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__conditionals_recipe_snapshot.snap @@ -38,6 +38,9 @@ build: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null requirements: build: - gcc diff --git a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__full_recipe_snapshot.snap b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__full_recipe_snapshot.snap index c36dbb3a1..7fdf5c6c0 100644 --- a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__full_recipe_snapshot.snap +++ b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__full_recipe_snapshot.snap @@ -38,6 +38,9 @@ build: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null requirements: build: - gcc diff --git a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__license_files_snapshot.snap b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__license_files_snapshot.snap index 3918ce420..bd4a6c554 100644 --- a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__license_files_snapshot.snap +++ b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__license_files_snapshot.snap @@ -38,6 +38,9 @@ build: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null requirements: {} about: homepage: null diff --git a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__minimal_recipe_snapshot.snap b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__minimal_recipe_snapshot.snap index 44ee2ffb4..51c99adce 100644 --- a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__minimal_recipe_snapshot.snap +++ b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__minimal_recipe_snapshot.snap @@ -38,6 +38,9 @@ build: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null requirements: {} about: homepage: null diff --git a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__multi_output_conditionals_snapshot.snap b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__multi_output_conditionals_snapshot.snap index 177636218..ef6196d9f 100644 --- a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__multi_output_conditionals_snapshot.snap +++ b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__multi_output_conditionals_snapshot.snap @@ -39,6 +39,9 @@ build: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null about: homepage: null license: null @@ -117,6 +120,9 @@ outputs: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null about: homepage: null license: BSD-3-Clause diff --git a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__multi_output_full_snapshot.snap b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__multi_output_full_snapshot.snap index 9c2a14ff7..112727002 100644 --- a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__multi_output_full_snapshot.snap +++ b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__multi_output_full_snapshot.snap @@ -46,6 +46,9 @@ build: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null about: homepage: https://example.com/ license: Apache-2.0 @@ -134,6 +137,9 @@ outputs: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null about: homepage: null license: Apache-2.0 @@ -187,6 +193,9 @@ outputs: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null about: homepage: null license: Apache-2.0 @@ -242,6 +251,9 @@ outputs: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null about: homepage: null license: Apache-2.0 diff --git a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__multi_output_minimal_snapshot.snap b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__multi_output_minimal_snapshot.snap index 11b2e79f3..009b9cd13 100644 --- a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__multi_output_minimal_snapshot.snap +++ b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__multi_output_minimal_snapshot.snap @@ -43,6 +43,9 @@ build: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null about: homepage: null license: null @@ -110,6 +113,9 @@ outputs: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null about: homepage: null license: MIT diff --git a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__multi_output_templates_snapshot.snap b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__multi_output_templates_snapshot.snap index 151ed21ec..6ffdb4cf9 100644 --- a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__multi_output_templates_snapshot.snap +++ b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__multi_output_templates_snapshot.snap @@ -45,6 +45,9 @@ build: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null about: homepage: null license: null @@ -114,6 +117,9 @@ outputs: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null about: homepage: https://example.com/${{ name }} license: MIT @@ -162,6 +168,9 @@ outputs: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null about: homepage: null license: MIT diff --git a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__multi_output_top_level_inherit_snapshot.snap b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__multi_output_top_level_inherit_snapshot.snap index 161bc6bad..93a8302f7 100644 --- a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__multi_output_top_level_inherit_snapshot.snap +++ b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__multi_output_top_level_inherit_snapshot.snap @@ -44,6 +44,9 @@ build: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null about: homepage: https://top-level-example.com/ license: GPL-3.0-or-later @@ -94,6 +97,9 @@ outputs: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null about: homepage: null license: null @@ -147,6 +153,9 @@ outputs: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null about: homepage: null license: null @@ -201,6 +210,9 @@ outputs: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null about: homepage: null license: MIT diff --git a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__run_exports_recipe_snapshot.snap b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__run_exports_recipe_snapshot.snap index 4472ef5a2..78e7d350b 100644 --- a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__run_exports_recipe_snapshot.snap +++ b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__run_exports_recipe_snapshot.snap @@ -38,6 +38,9 @@ build: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null requirements: build: - gcc diff --git a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__script_parsing_snapshot.snap b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__script_parsing_snapshot.snap index 87b5fbc10..ca8e39a94 100644 --- a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__script_parsing_snapshot.snap +++ b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__script_parsing_snapshot.snap @@ -38,6 +38,9 @@ build: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null requirements: {} about: homepage: null diff --git a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__single_output_compatibility.snap b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__single_output_compatibility.snap index 44ee2ffb4..51c99adce 100644 --- a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__single_output_compatibility.snap +++ b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__single_output_compatibility.snap @@ -38,6 +38,9 @@ build: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null requirements: {} about: homepage: null diff --git a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__templates_recipe_snapshot.snap b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__templates_recipe_snapshot.snap index f29586cf1..805e87dd2 100644 --- a/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__templates_recipe_snapshot.snap +++ b/crates/rattler_build_recipe/src/stage0/parser/snapshots/rattler_build_recipe__stage0__parser__snapshot_tests__templates_recipe_snapshot.snap @@ -38,6 +38,9 @@ build: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null requirements: build: - ${{ compiler("c") }} diff --git a/crates/rattler_build_recipe/src/stage0/snapshots/rattler_build_recipe__stage0__test__roundtrip.snap b/crates/rattler_build_recipe/src/stage0/snapshots/rattler_build_recipe__stage0__test__roundtrip.snap index b4d272fc2..118c4e622 100644 --- a/crates/rattler_build_recipe/src/stage0/snapshots/rattler_build_recipe__stage0__test__roundtrip.snap +++ b/crates/rattler_build_recipe/src/stage0/snapshots/rattler_build_recipe__stage0__test__roundtrip.snap @@ -38,6 +38,9 @@ build: ignore: false ignore_binary_files: false post_process: [] + signing: + macos: null + windows: null requirements: build: - gcc diff --git a/crates/rattler_build_recipe/src/stage1/build.rs b/crates/rattler_build_recipe/src/stage1/build.rs index 641531817..6e3a15f1c 100644 --- a/crates/rattler_build_recipe/src/stage1/build.rs +++ b/crates/rattler_build_recipe/src/stage1/build.rs @@ -355,6 +355,143 @@ pub struct Build { /// Post-processing operations #[serde(default, skip_serializing_if = "Vec::is_empty")] pub post_process: Vec, + + /// Code signing configuration (not serialized into rendered recipe output) + #[serde(default, skip_serializing)] + pub signing: Signing, +} + +/// Code signing configuration for native binaries (evaluated) +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Signing { + /// macOS code signing configuration + #[serde(default, skip_serializing_if = "Option::is_none")] + pub macos: Option, + /// Windows code signing configuration + #[serde(default, skip_serializing_if = "Option::is_none")] + pub windows: Option, +} + +impl Signing { + /// Check if this is the default (no signing) configuration + pub fn is_default(&self) -> bool { + self.macos.is_none() && self.windows.is_none() + } +} + +/// macOS code signing configuration using `codesign` (evaluated) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MacOsSigning { + /// Signing identity (e.g., "Developer ID Application: Company (TEAMID)") + /// Use "-" for ad-hoc signing + pub identity: String, + /// Path to the keychain containing the signing certificate + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keychain: Option, + /// Entitlements plist file path + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entitlements: Option, + /// Additional codesign options (e.g., "runtime" for hardened runtime) + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub options: Vec, +} + +/// Windows code signing configuration (evaluated). +/// +/// Exactly one signing method must be configured: +/// - **Local certificate** (`signtool`): Configure the `signtool` sub-object. +/// - **Azure Trusted Signing**: Configure the `azure_trusted_signing` sub-object. +/// +/// Shared settings (`timestamp_url`, `digest_algorithm`) live at the top level. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WindowsSigning { + /// Local certificate signing via `signtool` + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signtool: Option, + /// Azure Trusted Signing configuration + #[serde(default, skip_serializing_if = "Option::is_none")] + pub azure_trusted_signing: Option, + + // --- Shared settings --- + /// RFC 3161 timestamp server URL + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timestamp_url: Option, + /// Digest algorithm (default: sha256) + #[serde(default = "default_digest_algorithm")] + pub digest_algorithm: String, +} + +/// Local certificate signing configuration for `signtool` (evaluated) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SigntoolConfig { + /// Path to the certificate file (.pfx / .p12) + pub certificate_file: String, + /// Name of the environment variable containing the certificate password. + /// The password is read from this env var at build time to avoid leaking + /// secrets into the rendered recipe. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub certificate_password_env: Option, +} + +/// Azure Trusted Signing configuration (evaluated) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AzureTrustedSigningConfig { + /// Azure Trusted Signing endpoint URL + pub endpoint: String, + /// Azure Trusted Signing account name + pub account_name: String, + /// Azure Trusted Signing certificate profile name + pub certificate_profile: String, +} + +/// The Windows signing method as determined from the configuration. +#[derive(Debug, Clone, PartialEq)] +pub enum WindowsSigningMethod<'a> { + /// Local certificate signing via signtool + Signtool { + /// Path to the .pfx/.p12 certificate + certificate_file: &'a str, + /// Name of the env var containing the certificate password + certificate_password_env: Option<&'a str>, + }, + /// Azure Trusted Signing + AzureTrustedSigning { + /// Azure signing endpoint URL + endpoint: &'a str, + /// Azure signing account name + account_name: &'a str, + /// Azure certificate profile name + certificate_profile: &'a str, + }, +} + +impl WindowsSigning { + /// Determine which signing method is configured. + /// + /// Returns an error message if neither or both methods are configured. + pub fn method(&self) -> Result, &'static str> { + match (&self.signtool, &self.azure_trusted_signing) { + (Some(_), Some(_)) => Err( + "Both 'signtool' and 'azure_trusted_signing' are configured. \ + Please use only one signing method.", + ), + (None, None) => Err("No Windows signing method configured. \ + Set either 'signtool' or 'azure_trusted_signing'."), + (Some(st), None) => Ok(WindowsSigningMethod::Signtool { + certificate_file: &st.certificate_file, + certificate_password_env: st.certificate_password_env.as_deref(), + }), + (None, Some(az)) => Ok(WindowsSigningMethod::AzureTrustedSigning { + endpoint: &az.endpoint, + account_name: &az.account_name, + certificate_profile: &az.certificate_profile, + }), + } + } +} + +fn default_digest_algorithm() -> String { + "sha256".to_string() } /// Dynamic linking configuration @@ -527,6 +664,7 @@ impl Build { && self.prefix_detection.ignore.is_none() && !self.prefix_detection.ignore_binary_files && self.post_process.is_empty() + && self.signing.is_default() } } diff --git a/crates/rattler_build_recipe/src/stage1/mod.rs b/crates/rattler_build_recipe/src/stage1/mod.rs index 1284cea07..098f51c15 100644 --- a/crates/rattler_build_recipe/src/stage1/mod.rs +++ b/crates/rattler_build_recipe/src/stage1/mod.rs @@ -29,7 +29,10 @@ pub mod tests; mod variant_tests; pub use about::About; -pub use build::{Build, Rpaths}; +pub use build::{ + AzureTrustedSigningConfig, Build, MacOsSigning, Rpaths, Signing, SigntoolConfig, + WindowsSigning, WindowsSigningMethod, +}; pub use extra::Extra; pub use hash::{HashInfo, HashInput, compute_hash}; use indexmap::IndexMap; diff --git a/crates/rattler_build_recipe/src/variant_render.rs b/crates/rattler_build_recipe/src/variant_render.rs index 4a00390c8..4baecf1ca 100644 --- a/crates/rattler_build_recipe/src/variant_render.rs +++ b/crates/rattler_build_recipe/src/variant_render.rs @@ -1031,6 +1031,22 @@ fn source_list_has_attestation( }) } +/// Check if a build section has signing configuration. +fn build_has_signing(build: &stage0::Build) -> bool { + build.signing.macos.is_some() || build.signing.windows.is_some() +} + +/// Check if a recipe contains any code signing configuration. +fn recipe_has_signing(recipe: &Stage0Recipe) -> bool { + match recipe { + Stage0Recipe::SingleOutput(r) => build_has_signing(&r.build), + Stage0Recipe::MultiOutput(r) => r.outputs.iter().any(|output| match output { + stage0::Output::Staging(_) => false, + stage0::Output::Package(p) => build_has_signing(&p.build), + }), + } +} + /// Check if a recipe contains any source with attestation config. fn recipe_has_attestation(recipe: &Stage0Recipe) -> bool { match recipe { @@ -1063,6 +1079,13 @@ pub fn render_recipe_with_variant_config( variant_config: &VariantConfig, config: RenderConfig, ) -> Result, RenderError> { + // Check if recipe has code signing config - requires experimental flag + if !config.experimental && recipe_has_signing(stage0_recipe) { + return Err(RenderError::ExperimentalRequired { + message: "code signing is an experimental feature: provide the `--experimental` flag to enable this feature".to_string(), + }); + } + // Check if any source has attestation config - requires experimental flag if !config.experimental && recipe_has_attestation(stage0_recipe) { return Err(RenderError::ExperimentalRequired { diff --git a/docs/code_signing.md b/docs/code_signing.md new file mode 100644 index 000000000..52f08a48a --- /dev/null +++ b/docs/code_signing.md @@ -0,0 +1,424 @@ +# Code signing + +Code signing lets you cryptographically sign native binaries (executables, shared +libraries) inside your conda packages. This is separate from +[Sigstore attestations](sigstore.md), which sign the _package archive_ itself +-- code signing signs the individual _binaries_ so that operating systems trust +them at runtime. + +Rattler-build supports code signing for **macOS** (via `codesign`) and +**Windows** (via `signtool` or Azure Trusted Signing). Signing is configured in +the `build.signing` section of your recipe. + +## Why sign binaries in a conda package? + +Conda packages undergo _prefix replacement_ at install time -- the build-time +prefix path is rewritten to the install-time prefix. This byte-level +modification invalidates any code signature that was applied during the build +script. Rattler-build solves this by signing **after** relinking and prefix +detection, but **before** the package archive is created. + +!!! warning "Signed binaries must not contain the build prefix" + If a signed binary still contains the literal build prefix, conda's prefix + replacement will corrupt the signature at install time. Rattler-build checks + for this automatically and will error if it detects the build prefix in any + signed binary. To resolve this, ensure your build process does not embed + absolute paths into binaries, or use `build.prefix_detection` to exclude + those files from prefix replacement. + +## Pipeline order + +The signing step runs at a specific point in the packaging pipeline: + +``` +build script + → file collection + → relinking (rpath fixups, ad-hoc codesign on macOS) + → prefix detection + → regex post-processing + → **code signing** ← signs here + → metadata generation + → archive creation (.conda) +``` + +Because relinking invalidates existing signatures, rattler-build first applies +an ad-hoc signature (`codesign -s -`) on macOS during relinking, then overwrites +it with the real identity during the signing step using `--force`. + +## External signing config (`--signing-config-file`) + +Signing is often a CI/build-environment concern -- not something that belongs +in the recipe itself. The `--signing-config-file` flag lets you keep your +recipe completely free of signing details and inject them only when needed: + +```bash +rattler-build build --recipe recipe.yaml --signing-config-file signing.yaml +``` + +The signing config file uses the exact same YAML structure as the `build.signing` +section of a recipe: + +```yaml title="signing.yaml" +macos: + identity: "Developer ID Application: My Company (TEAMID)" + options: + - runtime +``` + +```yaml title="signing.yaml" +windows: + signtool: + certificate_file: "/path/to/cert.pfx" + certificate_password_env: "MY_CERT_PASSWORD" + timestamp_url: "http://timestamp.digicert.com" + digest_algorithm: sha256 +``` + +When `--signing-config-file` is provided, it **completely overrides** any +`build.signing` section in the recipe. If the flag is not provided, the recipe's +signing configuration (if any) is used as before. + +This approach has several benefits: + +- **Portable recipes**: the same recipe works everywhere, signed or unsigned +- **Separation of concerns**: signing credentials stay in CI config, not in source +- **Local development**: developers can build without needing certificates + +## macOS signing + +macOS signing uses Apple's `codesign` tool. You must provide a signing identity, +which is typically a Developer ID certificate installed in a keychain. + +### Basic configuration + +```yaml title="recipe.yaml" +build: + signing: + macos: + identity: "Developer ID Application: My Company (TEAMID)" +``` + +### Full configuration + +```yaml title="recipe.yaml" +build: + signing: + macos: + # Required: signing identity (use "-" for ad-hoc signing) + identity: "Developer ID Application: My Company (TEAMID)" + # Optional: path to a specific keychain + keychain: "/path/to/signing.keychain-db" + # Optional: entitlements plist file + entitlements: "entitlements.plist" + # Optional: additional codesign options + options: + - runtime # enables hardened runtime +``` + +### Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `identity` | Yes | The signing identity string. Use a Developer ID certificate name, or `"-"` for ad-hoc signing. Supports Jinja templates (`${{ env.IDENTITY }}`). | +| `keychain` | No | Path to the keychain containing the certificate. If omitted, the default keychain search path is used. | +| `entitlements` | No | Path to an entitlements plist file. Required for some app sandbox or hardened runtime entitlements. | +| `options` | No | List of additional `--options` flags passed to `codesign` (e.g., `runtime` for hardened runtime). | + +### Using Jinja templates + +You can use Jinja templates to pull signing configuration from environment +variables, which is useful for CI/CD: + +```yaml title="recipe.yaml" +build: + signing: + macos: + identity: "${{ env.MACOS_SIGNING_IDENTITY }}" + keychain: "${{ env.MACOS_KEYCHAIN_PATH }}" +``` + +## Windows signing + +Windows signing supports two methods: + +1. **Local certificate** (`signtool`) -- uses a `.pfx` / `.p12` certificate file +2. **Azure Trusted Signing** -- uses Microsoft's cloud-based signing service + +Exactly one method must be configured. Shared settings (`timestamp_url`, +`digest_algorithm`) are specified at the `windows` level. + +### Method 1: Local certificate (signtool) + +Use this when you have a code signing certificate file (`.pfx` or `.p12`): + +```yaml title="recipe.yaml" +build: + signing: + windows: + signtool: + certificate_file: "path/to/certificate.pfx" + certificate_password_env: CERT_PASSWORD + timestamp_url: "http://timestamp.digicert.com" + digest_algorithm: sha256 +``` + +#### Signtool fields + +| Field | Required | Description | +|----------------------------|----------|------------------------------------------------------------------------------------------------------------------| +| `certificate_file` | Yes | Path to the `.pfx` / `.p12` certificate file. | +| `certificate_password_env` | No | Name of the environment variable containing the certificate password. The password is read at build time to avoid leaking secrets into the rendered recipe. | + +### Method 2: Azure Trusted Signing + +[Azure Trusted Signing](https://learn.microsoft.com/en-us/azure/trusted-signing/) +is a cloud-based signing service that eliminates the need to manage certificate +files locally. It requires Azure authentication (typically via `az login` or +OIDC in CI). + +```yaml title="recipe.yaml" +build: + signing: + windows: + azure_trusted_signing: + endpoint: "${{ env.AZURE_SIGNING_ENDPOINT }}" + account_name: "${{ env.AZURE_SIGNING_ACCOUNT }}" + certificate_profile: "${{ env.AZURE_SIGNING_PROFILE }}" + timestamp_url: "http://timestamp.acs.microsoft.com" +``` + +#### Azure Trusted Signing fields + +| Field | Required | Description | +|-------|----------|-------------| +| `endpoint` | Yes | The Azure Trusted Signing endpoint URL (e.g., `https://wus2.codesigning.azure.net`). | +| `account_name` | Yes | The Azure Trusted Signing account name. | +| `certificate_profile` | Yes | The certificate profile name to use for signing. | + +### Shared Windows fields + +These fields apply regardless of which signing method is used: + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `timestamp_url` | No | — | RFC 3161 timestamp server URL. Strongly recommended for production builds. | +| `digest_algorithm` | No | `sha256` | The digest algorithm to use (e.g., `sha256`, `sha384`, `sha512`). | + +!!! tip "Always use a timestamp server" + Without a timestamp, signatures become invalid when the signing certificate + expires. A timestamp cryptographically proves the signature was created while + the certificate was still valid. + +## Cross-platform configuration + +You can configure signing for both platforms in a single recipe. Rattler-build +automatically selects the relevant configuration based on the target platform: + +```yaml title="recipe.yaml" +build: + signing: + macos: + identity: "${{ env.MACOS_SIGNING_IDENTITY }}" + options: + - runtime + windows: + signtool: + certificate_file: "${{ env.WIN_CERT_PATH }}" + certificate_password_env: WIN_CERT_PASSWORD + timestamp_url: "http://timestamp.digicert.com" +``` + +When building for macOS, only the `macos` section is used. When building for +Windows, only the `windows` section is used. On Linux, signing is skipped +entirely. + +## CI/CD examples + +### GitHub Actions (macOS) + +```yaml title=".github/workflows/build.yml" +jobs: + build-macos: + runs-on: macos-latest + env: + MACOS_SIGNING_IDENTITY: "Developer ID Application: My Org (TEAMID)" + steps: + - uses: actions/checkout@v4 + + # Import the certificate into a temporary keychain + - name: Import certificate + env: + CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }} + CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + # Create and configure keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Import certificate + echo "$CERTIFICATE_P12" | base64 --decode > $RUNNER_TEMP/cert.p12 + security import $RUNNER_TEMP/cert.p12 \ + -k "$KEYCHAIN_PATH" -P "$CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: \ + -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" + + - name: Build package + run: rattler-build build --experimental --recipe recipe.yaml +``` + +With this `recipe.yaml`: + +```yaml title="recipe.yaml" +build: + signing: + macos: + identity: "${{ env.MACOS_SIGNING_IDENTITY }}" + options: + - runtime +``` + +### GitHub Actions (Windows with Azure Trusted Signing) + +Azure Trusted Signing requires two tools on PATH: + +1. **`signtool.exe`** -- from the `Microsoft.Windows.SDK.BuildTools` NuGet package +2. **`Azure.CodeSigning.Dlib.dll`** -- from the `Microsoft.Trusted.Signing.Client` NuGet package (place next to `signtool.exe`) + +The workflow below installs both directly from NuGet, without needing the +`azure/trusted-signing-action`: + +```yaml title=".github/workflows/build.yml" +jobs: + build-windows: + runs-on: windows-latest + permissions: + id-token: write # required for Azure OIDC + env: + BUILD_TOOLS_VERSION: "10.0.26100.4188" + TRUSTED_SIGNING_CLIENT_VERSION: "1.0.95" + steps: + - uses: actions/checkout@v4 + + - name: Azure login + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Install signing tools from NuGet + shell: pwsh + run: | + $toolsDir = Join-Path $env:RUNNER_TEMP "signing-tools" + New-Item -ItemType Directory -Force -Path $toolsDir | Out-Null + + # signtool.exe + $pkg = Join-Path $toolsDir "buildtools.zip" + $dir = Join-Path $toolsDir "buildtools" + Invoke-WebRequest ` + -Uri "https://www.nuget.org/api/v2/package/Microsoft.Windows.SDK.BuildTools/$env:BUILD_TOOLS_VERSION" ` + -OutFile $pkg + Expand-Archive -Path $pkg -DestinationPath $dir -Force + $signtool = Get-ChildItem $dir -Recurse -Filter "signtool.exe" | + Where-Object { $_.FullName -match "x64" } | Select-Object -First 1 + $signtoolDir = $signtool.DirectoryName + + # Azure.CodeSigning.Dlib.dll + $pkg = Join-Path $toolsDir "tsclient.zip" + $dir = Join-Path $toolsDir "tsclient" + Invoke-WebRequest ` + -Uri "https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client/$env:TRUSTED_SIGNING_CLIENT_VERSION" ` + -OutFile $pkg + Expand-Archive -Path $pkg -DestinationPath $dir -Force + $dlib = Get-ChildItem $dir -Recurse -Filter "Azure.CodeSigning.Dlib.dll" | + Where-Object { $_.FullName -match "x64" } | Select-Object -First 1 + Copy-Item $dlib.FullName -Destination $signtoolDir + + echo "$signtoolDir" >> $env:GITHUB_PATH + + - name: Build package + env: + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_SIGNING_PROFILE: ${{ secrets.AZURE_SIGNING_PROFILE }} + run: rattler-build build --experimental --recipe recipe.yaml +``` + +With this `recipe.yaml`: + +```yaml title="recipe.yaml" +build: + signing: + windows: + azure_trusted_signing: + endpoint: "${{ env.AZURE_SIGNING_ENDPOINT }}" + account_name: "${{ env.AZURE_SIGNING_ACCOUNT }}" + certificate_profile: "${{ env.AZURE_SIGNING_PROFILE }}" + timestamp_url: "http://timestamp.acs.microsoft.com" +``` + +## End-to-end example + +The [`examples/code-signing/`](https://github.com/prefix-dev/rattler-build/tree/main/examples/code-signing) +directory contains a complete, copy-paste-ready example: + +- A small C project (executable + shared library) built with CMake +- A `recipe.yaml` with **no signing config** -- the recipe stays portable +- Standalone signing YAML files for macOS and Windows +- A GitHub Actions workflow that generates signing config from secrets and + passes it via `--signing-config-file` + +To try it in your own repo, copy the example directory and configure the +required GitHub secrets (see the workflow file for the full list). + +## Which files are signed? + +Rattler-build automatically detects signable binaries by inspecting file headers: + +| Platform | Detected file types | +|----------|-------------------| +| macOS | Mach-O executables and dynamic libraries (`.dylib`) | +| Windows | PE executables (`.exe`) and dynamic libraries (`.dll`) | + +Files that do not match these formats are silently skipped. After signing, each +binary's signature is verified to ensure it was applied correctly. + +## Troubleshooting + +### "Signed binary contains build prefix" + +This error means a binary that was signed still contains the build-time prefix +path. Since conda replaces this path at install time, the signature would be +corrupted. To fix this: + +- Ensure your build does not hardcode absolute paths into binaries +- Use relative paths or runtime path resolution instead +- If the file does not need prefix replacement, configure + `build.prefix_detection.ignore` to skip it + +### "codesign: no identity found" + +The signing identity was not found in any accessible keychain. Check that: + +- The certificate is imported into a keychain +- The keychain is unlocked and in the search path +- The identity string matches the certificate's Common Name exactly + +### "signtool: certificate not found" + +The certificate file path is incorrect or the password is wrong. Verify that: + +- The `certificate_file` path is correct relative to the build working directory +- The environment variable specified in `certificate_password_env` is set and contains the correct password + +## See also + +- [Sigstore attestations](sigstore.md) -- sign the package archive for supply-chain provenance +- [Advanced build options](build_options.md) -- other `build:` configuration +- [Prefix detection](build_options.md#prefix-replacement) -- control which files undergo prefix replacement diff --git a/examples/code-signing/.github/workflows/build-and-sign.yml b/examples/code-signing/.github/workflows/build-and-sign.yml new file mode 100644 index 000000000..1370b3b96 --- /dev/null +++ b/examples/code-signing/.github/workflows/build-and-sign.yml @@ -0,0 +1,252 @@ +# Example GitHub Actions workflow that builds a small C library with rattler-build +# and code-signs the binaries on macOS and Windows using --signing-config-file. +# +# The recipe itself contains NO signing configuration -- signing is injected +# purely at build time via a standalone YAML file and the --signing-config-file +# flag. This keeps the recipe portable and CI-agnostic. +# +# Copy this workflow (and the recipe.yaml + hello/ source) into your own repo. +# Then configure the secrets listed below. +# +# Required secrets: +# macOS: +# MACOS_CERTIFICATE_P12 - base64-encoded .p12 certificate +# MACOS_CERTIFICATE_PASSWORD - password for the .p12 file +# MACOS_SIGNING_IDENTITY - e.g. "Developer ID Application: My Org (TEAMID)" +# Windows (signtool with local .pfx): +# WIN_CERT_FILE_B64 - base64-encoded .pfx certificate +# WIN_CERT_PASSWORD - password for the .pfx file +# Windows (Azure Trusted Signing) — used by the azure-signing job: +# AZURE_CLIENT_ID - Azure AD app registration client ID +# AZURE_TENANT_ID - Azure AD tenant ID +# AZURE_SUBSCRIPTION_ID - Azure subscription ID +# AZURE_SIGNING_ENDPOINT - e.g. https://wus2.codesigning.azure.net +# AZURE_SIGNING_ACCOUNT - Azure Trusted Signing account name +# AZURE_SIGNING_PROFILE - certificate profile name + +name: Build and sign conda package + +on: + push: + branches: [main] + pull_request: + +jobs: + # ═══════════════════════════════════════════════════════════════════════════ + # Job 1: macOS + Windows (local cert) + Linux + # ═══════════════════════════════════════════════════════════════════════════ + build: + name: Build (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + platform: osx-arm64 + - os: windows-latest + platform: win-64 + - os: ubuntu-latest + platform: linux-64 + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + # ── Install rattler-build ────────────────────────────────────────── + - name: Install rattler-build + uses: prefix-dev/setup-rattler-build@v0.2.20 + + # ── macOS: import certificate into a temporary keychain ──────────── + - name: Import macOS signing certificate + if: runner.os == 'macOS' + env: + CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }} + CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} + run: | + KEYCHAIN_PATH="$RUNNER_TEMP/signing.keychain-db" + KEYCHAIN_PASSWORD="$(openssl rand -base64 32)" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + echo "$CERTIFICATE_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12" + security import "$RUNNER_TEMP/cert.p12" \ + -k "$KEYCHAIN_PATH" -P "$CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: \ + -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" + + # Write the signing config with the real identity + cat > signing.yaml <- + rattler-build build + --recipe recipe.yaml + --target-platform ${{ matrix.platform }} + ${{ runner.os != 'Linux' && '--signing-config-file signing.yaml' || '' }} + + # ── Upload the built .conda package as an artifact ───────────────── + - name: Upload package + uses: actions/upload-artifact@v4 + with: + name: hello-signed-${{ matrix.platform }} + path: output/**/*.conda + + # ── Verify signatures (macOS only, as a sanity check) ────────────── + - name: Verify macOS signatures + if: runner.os == 'macOS' + run: | + echo "Listing built .conda packages..." + find output -name '*.conda' -print + + # Extract and verify — the test section in the recipe also does this, + # but this step runs outside of the test environment for extra confidence. + echo "Package signatures were verified during the build (see build log)." + + # ═══════════════════════════════════════════════════════════════════════════ + # Job 2: Windows with Azure Trusted Signing (no local certificate needed) + # + # This job shows how to install the Azure Trusted Signing tools from NuGet + # and sign binaries without relying on the azure/trusted-signing-action. + # ═══════════════════════════════════════════════════════════════════════════ + azure-signing: + name: Build (Windows + Azure Trusted Signing) + runs-on: windows-latest + permissions: + id-token: write # required for Azure OIDC login + + env: + # NuGet package versions — update these as new versions are released + BUILD_TOOLS_VERSION: "10.0.26100.4188" + TRUSTED_SIGNING_CLIENT_VERSION: "1.0.95" + + steps: + - uses: actions/checkout@v4 + + # ── Install rattler-build ────────────────────────────────────────── + - name: Install rattler-build + uses: prefix-dev/setup-rattler-build@v0.2.20 + + # ── Azure login via OIDC (federated credentials) ─────────────────── + - name: Azure login + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + # ── Install Azure Trusted Signing tools from NuGet ───────────────── + # This replaces the azure/trusted-signing-action by installing the + # required components directly: + # 1. signtool.exe — from Microsoft.Windows.SDK.BuildTools + # 2. dlib + headers — from Microsoft.Trusted.Signing.Client + - name: Install signing tools from NuGet + shell: pwsh + run: | + $toolsDir = Join-Path $env:RUNNER_TEMP "signing-tools" + New-Item -ItemType Directory -Force -Path $toolsDir | Out-Null + + # Download and extract Windows SDK BuildTools (contains signtool.exe) + $buildToolsPkg = Join-Path $toolsDir "buildtools.nupkg.zip" + $buildToolsDir = Join-Path $toolsDir "buildtools" + Invoke-WebRequest ` + -Uri "https://www.nuget.org/api/v2/package/Microsoft.Windows.SDK.BuildTools/$env:BUILD_TOOLS_VERSION" ` + -OutFile $buildToolsPkg + Expand-Archive -Path $buildToolsPkg -DestinationPath $buildToolsDir -Force + + # Find signtool.exe (x64) + $signtool = Get-ChildItem -Path $buildToolsDir -Recurse -Filter "signtool.exe" | + Where-Object { $_.FullName -match "x64" } | + Select-Object -First 1 + if (-not $signtool) { throw "signtool.exe not found in BuildTools package" } + + $signtoolDir = $signtool.DirectoryName + Write-Host "Found signtool at: $($signtool.FullName)" + + # Download and extract Trusted Signing Client (contains the dlib) + $clientPkg = Join-Path $toolsDir "tsclient.nupkg.zip" + $clientDir = Join-Path $toolsDir "tsclient" + Invoke-WebRequest ` + -Uri "https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client/$env:TRUSTED_SIGNING_CLIENT_VERSION" ` + -OutFile $clientPkg + Expand-Archive -Path $clientPkg -DestinationPath $clientDir -Force + + # Find the dlib (x64) + $dlib = Get-ChildItem -Path $clientDir -Recurse -Filter "Azure.CodeSigning.Dlib.dll" | + Where-Object { $_.FullName -match "x64" } | + Select-Object -First 1 + if (-not $dlib) { throw "Azure.CodeSigning.Dlib.dll not found in Trusted Signing Client package" } + + Write-Host "Found dlib at: $($dlib.FullName)" + + # Copy the dlib next to signtool so it can be found via /dlib + Copy-Item -Path $dlib.FullName -Destination $signtoolDir + + # Add signtool directory to PATH + echo "$signtoolDir" >> $env:GITHUB_PATH + Write-Host "Added $signtoolDir to PATH" + + # ── Write Azure Trusted Signing config ────────────────────────────── + - name: Prepare Azure signing config + shell: pwsh + env: + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_SIGNING_PROFILE: ${{ secrets.AZURE_SIGNING_PROFILE }} + run: | + @" + windows: + azure_trusted_signing: + endpoint: "$env:AZURE_SIGNING_ENDPOINT" + account_name: "$env:AZURE_SIGNING_ACCOUNT" + certificate_profile: "$env:AZURE_SIGNING_PROFILE" + timestamp_url: "http://timestamp.acs.microsoft.com" + "@ | Set-Content -Path signing.yaml + + # ── Build the package (Azure Trusted Signing via --signing-config-file) + - name: Build conda package + run: >- + rattler-build build + --recipe recipe.yaml + --target-platform win-64 + --signing-config-file signing.yaml + + # ── Upload ───────────────────────────────────────────────────────── + - name: Upload package + uses: actions/upload-artifact@v4 + with: + name: hello-signed-win-64-azure + path: output/**/*.conda diff --git a/examples/code-signing/README.md b/examples/code-signing/README.md new file mode 100644 index 000000000..b787abb3c --- /dev/null +++ b/examples/code-signing/README.md @@ -0,0 +1,71 @@ +# Code Signing Example + +This example shows how to build a small C project as a conda package with +code-signed binaries on macOS and Windows, using the `--signing-config-file` +flag to keep signing config **external to the recipe**. + +## What's included + +``` +code-signing/ +├── recipe.yaml # rattler-build recipe (no signing config!) +├── signing-macos.yaml # Standalone macOS signing config +├── signing-windows.yaml # Standalone Windows signing config +├── hello/ # Minimal C project (executable + shared lib) +│ ├── CMakeLists.txt +│ └── src/ +│ ├── main.c +│ └── greet.c +├── .github/workflows/build-and-sign.yml # Example GitHub Actions workflow +└── README.md +``` + +## How it works + +1. The `recipe.yaml` builds a C executable (`hello`) and shared library + (`libgreet`) using CMake. It contains **no signing configuration**. +2. A separate YAML file (e.g. `signing-macos.yaml`) defines the signing + identity, keychain, and options. +3. At build time, pass `--signing-config-file signing-macos.yaml` to + rattler-build. It signs all detected binaries after relinking but before + creating the `.conda` archive. + +This separation means: +- The same recipe works on any platform, signed or unsigned +- Signing details stay in CI config, not in the recipe +- Local developers can build without needing certificates + +## Quick start (local, unsigned) + +```bash +rattler-build build --recipe recipe.yaml +``` + +## Quick start (macOS, signed) + +```bash +rattler-build build \ + --recipe recipe.yaml \ + --signing-config-file signing-macos.yaml +``` + +## Using in your own repo + +1. Copy the contents of this directory into your repository. +2. Configure GitHub secrets (see the workflow file for the list). +3. Push -- the workflow builds and signs on macOS and Windows. + +For Azure Trusted Signing instead of a local `.pfx` certificate, use a +signing config like: + +```yaml +windows: + azure_trusted_signing: + endpoint: "https://wus2.codesigning.azure.net" + account_name: "my-account" + certificate_profile: "my-profile" + timestamp_url: "http://timestamp.acs.microsoft.com" +``` + +See the [code signing documentation](https://rattler-build.prefix.dev/latest/code_signing/) +for full details. diff --git a/examples/code-signing/hello/CMakeLists.txt b/examples/code-signing/hello/CMakeLists.txt new file mode 100644 index 000000000..da081b88e --- /dev/null +++ b/examples/code-signing/hello/CMakeLists.txt @@ -0,0 +1,14 @@ +cmake_minimum_required(VERSION 3.12) +project(hello C) + +add_executable(hello src/main.c) + +# Build a shared library too, so we test signing both .exe/.dll and binary/.dylib +add_library(greet SHARED src/greet.c) +target_link_libraries(hello greet) + +install(TARGETS hello DESTINATION bin/) +install(TARGETS greet + RUNTIME DESTINATION bin/ # .dll on Windows + LIBRARY DESTINATION lib/ # .so / .dylib on Unix +) diff --git a/examples/code-signing/hello/src/greet.c b/examples/code-signing/hello/src/greet.c new file mode 100644 index 000000000..f2e3bf028 --- /dev/null +++ b/examples/code-signing/hello/src/greet.c @@ -0,0 +1,8 @@ +#include + +#ifdef _WIN32 +__declspec(dllexport) +#endif +void greet(const char *name) { + printf("Hello, %s!\n", name); +} diff --git a/examples/code-signing/hello/src/main.c b/examples/code-signing/hello/src/main.c new file mode 100644 index 000000000..27d7f5920 --- /dev/null +++ b/examples/code-signing/hello/src/main.c @@ -0,0 +1,9 @@ +#ifdef _WIN32 +__declspec(dllimport) +#endif +void greet(const char *name); + +int main(void) { + greet("world"); + return 0; +} diff --git a/examples/code-signing/recipe.yaml b/examples/code-signing/recipe.yaml new file mode 100644 index 000000000..30b01e4b9 --- /dev/null +++ b/examples/code-signing/recipe.yaml @@ -0,0 +1,44 @@ +schema_version: 1 + +context: + version: "0.1.0" + +package: + name: hello-signed + version: ${{ version }} + +source: + path: ./hello + +build: + script: + - if: win + then: + - cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release %CMAKE_ARGS% + - if: unix + then: + - cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release ${CMAKE_ARGS} + - cmake --build build --config Release + - cmake --install build + + # Note: no signing config here! Signing is injected externally via + # --signing-config-file so this recipe stays portable and CI-agnostic. + +requirements: + build: + - ${{ compiler('c') }} + - ninja + - cmake + +tests: + - script: + - if: unix + then: + - hello + - if: osx + then: + - codesign --verify --deep --strict $PREFIX/bin/hello + - codesign --verify --deep --strict $PREFIX/lib/libgreet.dylib + - if: win + then: + - hello diff --git a/examples/code-signing/signing-macos.yaml b/examples/code-signing/signing-macos.yaml new file mode 100644 index 000000000..ea4a08096 --- /dev/null +++ b/examples/code-signing/signing-macos.yaml @@ -0,0 +1,8 @@ +# macOS code signing configuration +# Pass to rattler-build via: --signing-config-file signing-macos.yaml +# +# The values below reference environment variables that should be set in CI. +macos: + identity: "Developer ID Application: My Company (TEAMID)" + options: + - runtime diff --git a/examples/code-signing/signing-windows.yaml b/examples/code-signing/signing-windows.yaml new file mode 100644 index 000000000..28b44074a --- /dev/null +++ b/examples/code-signing/signing-windows.yaml @@ -0,0 +1,11 @@ +# Windows code signing configuration (signtool with local .pfx certificate) +# Pass to rattler-build via: --signing-config-file signing-windows.yaml +# +# The certificate_file path and certificate_password_env (name of the env var +# holding the password) are filled in by the GitHub Actions workflow. +windows: + signtool: + certificate_file: "cert.pfx" + certificate_password_env: "WIN_CERT_PASSWORD" + timestamp_url: "http://timestamp.digicert.com" + digest_algorithm: sha256 diff --git a/mkdocs.yml b/mkdocs.yml index d6d071fdd..ac2012edf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,6 +120,7 @@ nav: - Variants: variants.md - Configuration: config.md - Compilers and cross compilation: compilers.md + - Code signing: code_signing.md - Experimental features: experimental_features.md - Multiple output cache: multiple_output_cache.md - Sandbox: sandbox.md diff --git a/src/lib.rs b/src/lib.rs index e9cf8baec..895ecff47 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -285,6 +285,20 @@ pub fn get_tool_config( ) .into_diagnostic()?; + // Load signing config from file if specified + let signing_config_override = if let Some(ref path) = build_data.signing_config_file { + let content = fs::read_to_string(path) + .into_diagnostic() + .wrap_err_with(|| format!("Failed to read signing config file: {}", path.display()))?; + let signing: rattler_build_recipe::stage1::build::Signing = + serde_yaml::from_str(&content) + .into_diagnostic() + .wrap_err("Failed to parse signing config file")?; + Some(signing) + } else { + None + }; + let configuration_builder = Configuration::builder() .with_keep_build(build_data.keep_build) .with_compression_threads(build_data.compression_threads) @@ -301,7 +315,8 @@ pub fn get_tool_config( .with_io_concurrency_limit(Some(build_data.io_concurrency_limit)) .with_zstd_repodata_enabled(build_data.common.use_zstd) .with_bz2_repodata_enabled(build_data.common.use_bz2) - .with_sharded_repodata_enabled(build_data.common.use_sharded); + .with_sharded_repodata_enabled(build_data.common.use_sharded) + .with_signing_config_override(signing_config_override); let configuration_builder = if let Some(fancy_log_handler) = fancy_log_handler { configuration_builder.with_logging_output_handler(fancy_log_handler.clone()) @@ -1694,6 +1709,7 @@ pub async fn debug_recipe( exclude_newer: None, build_num_override: None, markdown_summary: None, + signing_config_file: None, }; let tool_config = get_tool_config(&build_data, log_handler)?; diff --git a/src/macos/link.rs b/src/macos/link.rs index f61470740..e8ecbde33 100644 --- a/src/macos/link.rs +++ b/src/macos/link.rs @@ -329,6 +329,13 @@ impl Relinker for Dylib { } } +/// Ad-hoc codesign a binary after relinking. +/// +/// This applies an ad-hoc signature (`-s -`) to make the binary valid after +/// binary modifications (rpath changes, install_name changes). If the recipe +/// configures real code signing via `build.signing.macos`, the signing module +/// (`post_process::signing`) will overwrite this with a proper signature later +/// in the packaging pipeline. fn codesign(path: &Path, system_tools: &SystemTools) -> Result<(), RelinkError> { let codesign = system_tools.find_tool(Tool::Codesign).map_err(|e| { tracing::error!("codesign not found: {}", e); diff --git a/src/opt.rs b/src/opt.rs index db0d0a502..0be94afe2 100644 --- a/src/opt.rs +++ b/src/opt.rs @@ -544,6 +544,12 @@ pub struct BuildOpts { /// Override the build number for all outputs (defaults to the build number in the recipe) #[arg(long, help_heading = "Modifying result")] pub build_num: Option, + + /// Path to a YAML file with code signing configuration. This overrides any + /// signing config in the recipe, allowing you to keep recipes free of + /// CI-specific signing details. + #[arg(long, help_heading = "Modifying result")] + pub signing_config_file: Option, } /// Publish options for the `publish` command. @@ -726,6 +732,7 @@ pub struct BuildData { pub exclude_newer: Option>, pub build_num_override: Option, pub markdown_summary: Option, + pub signing_config_file: Option, } impl BuildData { @@ -763,6 +770,7 @@ impl BuildData { exclude_newer: Option>, build_num_override: Option, markdown_summary: Option, + signing_config_file: Option, ) -> Self { Self { up_to, @@ -804,6 +812,7 @@ impl BuildData { exclude_newer, build_num_override, markdown_summary, + signing_config_file, } } } @@ -856,6 +865,7 @@ impl BuildData { opts.exclude_newer, opts.build_num, opts.markdown_summary, + opts.signing_config_file, ) } } diff --git a/src/packaging.rs b/src/packaging.rs index dec93e771..a5ac611ce 100644 --- a/src/packaging.rs +++ b/src/packaging.rs @@ -93,6 +93,9 @@ pub enum PackagingError { #[error("Invalid MenuInst schema file: {0} - {1}")] InvalidMenuInstSchema(PathBuf, serde_json::Error), + + #[error("Code signing error: {0}")] + SigningError(#[from] crate::post_process::signing::SigningError), } /// This function copies the license files to the info/licenses folder. @@ -702,6 +705,17 @@ pub fn package_conda( tracing::info!("Post-processing done!"); + // Sign binaries with real certificates (if configured via --signing-config-file or recipe) + let signed_files = post_process::signing::sign_binaries( + &tmp, + output, + tool_configuration.signing_config_override.as_ref(), + )?; + + // Check that signed binaries don't contain the build prefix + // (prefix replacement at install time would destroy signatures) + post_process::signing::check_signed_binaries_no_prefix(&signed_files, output)?; + // Validate any dsolist JSON files being packaged (CEP-28) post_process::checks::validate_dsolist_files(tmp.temp_dir.path())?; diff --git a/src/post_process/mod.rs b/src/post_process/mod.rs index 7a8bcc196..f13c6ab5c 100644 --- a/src/post_process/mod.rs +++ b/src/post_process/mod.rs @@ -5,3 +5,4 @@ pub mod path_checks; pub mod python; pub mod regex_replacements; pub mod relink; +pub mod signing; diff --git a/src/post_process/signing.rs b/src/post_process/signing.rs new file mode 100644 index 000000000..c13dd5e47 --- /dev/null +++ b/src/post_process/signing.rs @@ -0,0 +1,683 @@ +//! Code signing post-processing step for macOS and Windows binaries. +//! +//! This module handles signing native binaries (executables, shared libraries) +//! using platform-specific tools: +//! - macOS: `codesign` with a real signing identity +//! - Windows: `signtool` with a certificate file +//! +//! Signing happens AFTER relinking (which may invalidate existing signatures) +//! and BEFORE packaging, so the archive contains properly signed binaries. + +use std::path::{Path, PathBuf}; + +use rattler_build_recipe::stage1::build::{ + MacOsSigning, Signing, WindowsSigning, WindowsSigningMethod, +}; + +#[cfg(test)] +use rattler_build_recipe::stage1::build::{AzureTrustedSigningConfig, SigntoolConfig}; +use rattler_conda_types::Platform; +use thiserror::Error; + +use crate::{ + macos::link::Dylib, + metadata::Output, + packaging::{TempFiles, contains_prefix_binary}, + post_process::relink::{RelinkError, Relinker}, + system_tools::{SystemTools, Tool, ToolError}, + windows::link::Dll, +}; + +/// Errors that can occur during code signing +#[derive(Error, Debug)] +pub enum SigningError { + /// IO error + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + /// macOS codesign failed + #[error("macOS codesign failed for {path}: {message}")] + MacOsCodesignFailed { path: PathBuf, message: String }, + + /// Windows signtool failed + #[error("Windows signtool failed for {path}: {message}")] + WindowsSigntoolFailed { path: PathBuf, message: String }, + + /// Signature verification failed + #[error("Signature verification failed for {path}: {message}")] + VerificationFailed { path: PathBuf, message: String }, + + /// Signed binary contains the build prefix, which would be corrupted at install time + #[error( + "Signed binary '{path}' contains the build prefix. \ + Prefix replacement at install time will destroy the signature. \ + Either ensure the binary doesn't embed the prefix path, or add the file \ + to build.prefix_detection.ignore" + )] + SignedBinaryContainsPrefix { path: PathBuf }, + + /// System tool not found + #[error("System tool error: {0}")] + ToolError(#[from] ToolError), + + /// Relink error (for file type detection) + #[error("Relink error: {0}")] + RelinkError(#[from] RelinkError), +} + +/// Sign a single macOS binary using `codesign` +fn sign_macos_binary( + path: &Path, + config: &MacOsSigning, + system_tools: &SystemTools, +) -> Result<(), SigningError> { + let codesign = system_tools + .find_tool(Tool::Codesign) + .map_err(|e| ToolError::ToolNotFound(Tool::Codesign, e))?; + + let mut cmd = std::process::Command::new(&codesign); + cmd.arg("--force"); + cmd.arg("--sign"); + cmd.arg(&config.identity); + + if let Some(keychain) = &config.keychain { + cmd.arg("--keychain"); + cmd.arg(keychain); + } + + if let Some(entitlements) = &config.entitlements { + cmd.arg("--entitlements"); + cmd.arg(entitlements); + } + + for option in &config.options { + cmd.arg("--options"); + cmd.arg(option); + } + + // Preserve existing metadata when re-signing + let is_system_codesign = codesign.starts_with("/usr/bin/"); + if is_system_codesign { + cmd.arg("--preserve-metadata=entitlements,requirements"); + } + + // Add --timestamp by default for non-adhoc signatures + if config.identity != "-" && !config.options.iter().any(|o| o == "timestamp") { + cmd.arg("--timestamp"); + } + + cmd.arg(path); + + tracing::debug!("Running codesign: {:?}", cmd); + + let output = cmd + .output() + .map_err(|e| SigningError::MacOsCodesignFailed { + path: path.to_path_buf(), + message: format!("Failed to execute codesign: {}", e), + })?; + + if !output.status.success() { + return Err(SigningError::MacOsCodesignFailed { + path: path.to_path_buf(), + message: format!( + "codesign exited with status {}.\n stdout: {}\n stderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ), + }); + } + + tracing::info!("Signed (macOS): {}", path.display()); + Ok(()) +} + +/// Verify a macOS signature using `codesign --verify` +fn verify_macos_signature(path: &Path, system_tools: &SystemTools) -> Result<(), SigningError> { + let codesign = system_tools + .find_tool(Tool::Codesign) + .map_err(|e| ToolError::ToolNotFound(Tool::Codesign, e))?; + + let output = std::process::Command::new(codesign) + .args(["--verify", "--deep", "--strict"]) + .arg(path) + .output() + .map_err(|e| SigningError::VerificationFailed { + path: path.to_path_buf(), + message: format!("Failed to execute codesign verify: {}", e), + })?; + + if !output.status.success() { + return Err(SigningError::VerificationFailed { + path: path.to_path_buf(), + message: format!( + "Verification failed.\n stderr: {}", + String::from_utf8_lossy(&output.stderr) + ), + }); + } + + Ok(()) +} + +/// Sign a single Windows binary. +/// +/// Dispatches to either `signtool` (local certificate) or Azure Trusted Signing +/// based on the configuration. +fn sign_windows_binary( + path: &Path, + config: &WindowsSigning, + system_tools: &SystemTools, +) -> Result<(), SigningError> { + let method = config + .method() + .map_err(|e| SigningError::WindowsSigntoolFailed { + path: path.to_path_buf(), + message: e.to_string(), + })?; + + match method { + WindowsSigningMethod::Signtool { + certificate_file, + certificate_password_env, + } => sign_windows_signtool( + path, + certificate_file, + certificate_password_env, + config, + system_tools, + ), + WindowsSigningMethod::AzureTrustedSigning { + endpoint, + account_name, + certificate_profile, + } => sign_windows_azure(path, endpoint, account_name, certificate_profile, config), + } +} + +/// Sign a Windows binary using local `signtool` with a certificate file. +fn sign_windows_signtool( + path: &Path, + certificate_file: &str, + certificate_password_env: Option<&str>, + config: &WindowsSigning, + system_tools: &SystemTools, +) -> Result<(), SigningError> { + let signtool = system_tools + .find_tool(Tool::Signtool) + .map_err(|e| ToolError::ToolNotFound(Tool::Signtool, e))?; + + let mut cmd = std::process::Command::new(signtool); + cmd.arg("sign"); + + cmd.arg("/f"); + cmd.arg(certificate_file); + + if let Some(env_var) = certificate_password_env { + let password = std::env::var(env_var).map_err(|_| SigningError::WindowsSigntoolFailed { + path: path.to_path_buf(), + message: format!( + "Environment variable '{}' not set (required for certificate password)", + env_var + ), + })?; + cmd.arg("/p"); + cmd.arg(password); + } + + cmd.arg("/fd"); + cmd.arg(&config.digest_algorithm); + + if let Some(timestamp_url) = &config.timestamp_url { + cmd.arg("/tr"); + cmd.arg(timestamp_url); + cmd.arg("/td"); + cmd.arg(&config.digest_algorithm); + } + + cmd.arg(path); + + tracing::debug!("Running signtool: {:?}", cmd); + + let output = cmd + .output() + .map_err(|e| SigningError::WindowsSigntoolFailed { + path: path.to_path_buf(), + message: format!("Failed to execute signtool: {}", e), + })?; + + if !output.status.success() { + return Err(SigningError::WindowsSigntoolFailed { + path: path.to_path_buf(), + message: format!( + "signtool exited with status {}.\n stdout: {}\n stderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ), + }); + } + + tracing::info!("Signed (Windows/signtool): {}", path.display()); + Ok(()) +} + +/// Sign a Windows binary using Azure Trusted Signing. +/// +/// This invokes the Azure Code Signing tool (`azure-code-signing`) which must +/// be available on PATH. Authentication is handled via Azure CLI (`az login`), +/// which should be performed before the build (e.g., via OIDC in GitHub Actions). +fn sign_windows_azure( + path: &Path, + endpoint: &str, + account_name: &str, + certificate_profile: &str, + config: &WindowsSigning, +) -> Result<(), SigningError> { + // The Azure Trusted Signing CLI tool. In CI, this is typically installed + // by the azure/trusted-signing-action. For direct invocation, we use + // the `signtool` dlib approach or the standalone Azure Code Signing tool. + // The tool is invoked as: + // signtool sign /v /fd SHA256 /tr /td SHA256 + // /dlib "Azure.CodeSigning.Dlib.dll" + // /dmdf + // + // + // However, for simplicity and CI compatibility, we generate the metadata + // JSON inline and use the AzureCodeSigning tool directly. + + // Build metadata JSON for Azure Trusted Signing + let metadata = serde_json::json!({ + "Endpoint": endpoint, + "CodeSigningAccountName": account_name, + "CertificateProfileName": certificate_profile, + }); + + // Write metadata to a temp file + let temp_dir = tempfile::tempdir().map_err(SigningError::Io)?; + let metadata_path = temp_dir.path().join("signing-metadata.json"); + fs_err::write(&metadata_path, metadata.to_string()).map_err(SigningError::Io)?; + + // Try the Azure Code Signing tool first + let azure_tool = which::which("AzureCodeSigning") + .or_else(|_| which::which("azure-code-signing")) + .or_else(|_| which::which("signtool")); + + let tool_path = azure_tool.map_err(|e| SigningError::WindowsSigntoolFailed { + path: path.to_path_buf(), + message: format!( + "Could not find Azure Code Signing tool or signtool: {}. \ + Ensure the Azure Trusted Signing action has been run or the tool is installed.", + e + ), + })?; + + let mut cmd = std::process::Command::new(&tool_path); + + let tool_name = tool_path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + + if tool_name.eq_ignore_ascii_case("signtool") { + // Use signtool with Azure Code Signing DLib + cmd.arg("sign"); + cmd.arg("/v"); + cmd.arg("/fd"); + cmd.arg(&config.digest_algorithm); + + if let Some(timestamp_url) = &config.timestamp_url { + cmd.arg("/tr"); + cmd.arg(timestamp_url); + cmd.arg("/td"); + cmd.arg(&config.digest_algorithm); + } + + // Point to the Azure Code Signing DLib and metadata + cmd.arg("/dlib"); + cmd.arg("Azure.CodeSigning.Dlib.dll"); + cmd.arg("/dmdf"); + cmd.arg(&metadata_path); + cmd.arg(path); + } else { + // AzureCodeSigning standalone tool + cmd.arg("sign"); + cmd.arg("-mdf"); + cmd.arg(&metadata_path); + cmd.arg("-fd"); + cmd.arg(&config.digest_algorithm); + + if let Some(timestamp_url) = &config.timestamp_url { + cmd.arg("-tr"); + cmd.arg(timestamp_url); + cmd.arg("-td"); + cmd.arg(&config.digest_algorithm); + } + + cmd.arg(path); + } + + tracing::debug!("Running Azure Trusted Signing: {:?}", cmd); + + let output = cmd + .output() + .map_err(|e| SigningError::WindowsSigntoolFailed { + path: path.to_path_buf(), + message: format!("Failed to execute Azure Trusted Signing: {}", e), + })?; + + if !output.status.success() { + return Err(SigningError::WindowsSigntoolFailed { + path: path.to_path_buf(), + message: format!( + "Azure Trusted Signing exited with status {}.\n stdout: {}\n stderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ), + }); + } + + tracing::info!("Signed (Windows/Azure Trusted Signing): {}", path.display()); + Ok(()) +} + +/// Verify a Windows signature using `signtool verify` +fn verify_windows_signature(path: &Path, system_tools: &SystemTools) -> Result<(), SigningError> { + let signtool = system_tools + .find_tool(Tool::Signtool) + .map_err(|e| ToolError::ToolNotFound(Tool::Signtool, e))?; + + let output = std::process::Command::new(signtool) + .args(["verify", "/pa"]) + .arg(path) + .output() + .map_err(|e| SigningError::VerificationFailed { + path: path.to_path_buf(), + message: format!("Failed to execute signtool verify: {}", e), + })?; + + if !output.status.success() { + return Err(SigningError::VerificationFailed { + path: path.to_path_buf(), + message: format!( + "Verification failed.\n stderr: {}", + String::from_utf8_lossy(&output.stderr) + ), + }); + } + + Ok(()) +} + +/// Check if a file is a signable binary for the given platform. +fn is_signable_binary(platform: Platform, path: &Path) -> bool { + if path.is_symlink() || path.is_dir() { + return false; + } + + if platform.is_osx() { + Dylib::test_file(path).unwrap_or(false) + } else if platform.is_windows() { + Dll::try_new(path).ok().flatten().is_some() + } else { + false + } +} + +/// Sign all signable binaries in the package. +/// +/// This function: +/// 1. Determines which platform-specific signing config applies +/// 2. Iterates over all files in the temp directory +/// 3. Signs each signable binary (Mach-O on macOS, PE on Windows) +/// 4. Verifies signatures after signing +/// 5. Checks that signed binaries don't contain the build prefix +/// +/// Returns the list of signed file paths. +pub fn sign_binaries( + temp_files: &TempFiles, + output: &Output, + signing_override: Option<&Signing>, +) -> Result, SigningError> { + let recipe_signing = &output.recipe.build().signing; + let signing = signing_override.unwrap_or(recipe_signing); + let target_platform = output.build_configuration.target_platform; + let system_tools = &output.system_tools; + + // Determine which signing config applies for this target platform + let (macos_config, windows_config) = get_platform_signing_config(signing, target_platform); + + if macos_config.is_none() && windows_config.is_none() { + return Ok(Vec::new()); + } + + tracing::info!("Signing binaries..."); + + let tmp_prefix = temp_files.temp_dir.path(); + let mut signed_files = Vec::new(); + + for file_path in &temp_files.files { + if !is_signable_binary(target_platform, file_path) { + continue; + } + + let rel_path = file_path.strip_prefix(tmp_prefix).unwrap_or(file_path); + + if let Some(config) = macos_config { + sign_macos_binary(file_path, config, system_tools)?; + verify_macos_signature(file_path, system_tools)?; + tracing::debug!("Verified signature: {}", rel_path.display()); + } else if let Some(config) = windows_config { + sign_windows_binary(file_path, config, system_tools)?; + verify_windows_signature(file_path, system_tools)?; + tracing::debug!("Verified signature: {}", rel_path.display()); + } + + signed_files.push(file_path.clone()); + } + + if !signed_files.is_empty() { + tracing::info!("Signed {} binaries", signed_files.len()); + } + + Ok(signed_files) +} + +/// Check that signed binaries don't contain the build prefix. +/// +/// If a signed binary contains the build prefix, conda's prefix replacement +/// at install time would modify the binary content and destroy the signature. +pub fn check_signed_binaries_no_prefix( + signed_files: &[PathBuf], + output: &Output, +) -> Result<(), SigningError> { + if signed_files.is_empty() { + return Ok(()); + } + + let prefix = &output.build_configuration.directories.host_prefix; + + for file_path in signed_files { + match contains_prefix_binary(file_path, prefix) { + Ok(true) => { + let rel_path = file_path + .strip_prefix(output.build_configuration.directories.host_prefix.as_path()) + .unwrap_or(file_path); + return Err(SigningError::SignedBinaryContainsPrefix { + path: rel_path.to_path_buf(), + }); + } + Ok(false) => {} + Err(_) => { + // If we can't check, skip - the file might not be accessible + tracing::warn!( + "Could not check prefix in signed binary: {}", + file_path.display() + ); + } + } + } + + Ok(()) +} + +/// Determine which signing config applies for the target platform. +fn get_platform_signing_config( + signing: &Signing, + target_platform: Platform, +) -> (Option<&MacOsSigning>, Option<&WindowsSigning>) { + if target_platform.is_osx() { + (signing.macos.as_ref(), None) + } else if target_platform.is_windows() { + (None, signing.windows.as_ref()) + } else { + (None, None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_windows_signtool() -> WindowsSigning { + WindowsSigning { + signtool: Some(SigntoolConfig { + certificate_file: "cert.pfx".to_string(), + certificate_password_env: None, + }), + azure_trusted_signing: None, + timestamp_url: None, + digest_algorithm: "sha256".to_string(), + } + } + + fn make_windows_azure() -> WindowsSigning { + WindowsSigning { + signtool: None, + azure_trusted_signing: Some(AzureTrustedSigningConfig { + endpoint: "https://wus2.codesigning.azure.net".to_string(), + account_name: "my-account".to_string(), + certificate_profile: "my-profile".to_string(), + }), + timestamp_url: Some("http://timestamp.acs.microsoft.com".to_string()), + digest_algorithm: "sha256".to_string(), + } + } + + #[test] + fn test_get_platform_signing_config_macos() { + let signing = Signing { + macos: Some(MacOsSigning { + identity: "-".to_string(), + keychain: None, + entitlements: None, + options: vec![], + }), + windows: Some(make_windows_signtool()), + }; + + let (macos, windows) = get_platform_signing_config(&signing, Platform::OsxArm64); + assert!(macos.is_some()); + assert!(windows.is_none()); + + let (macos, windows) = get_platform_signing_config(&signing, Platform::Osx64); + assert!(macos.is_some()); + assert!(windows.is_none()); + } + + #[test] + fn test_get_platform_signing_config_windows() { + let signing = Signing { + macos: Some(MacOsSigning { + identity: "-".to_string(), + keychain: None, + entitlements: None, + options: vec![], + }), + windows: Some(make_windows_signtool()), + }; + + let (macos, windows) = get_platform_signing_config(&signing, Platform::Win64); + assert!(macos.is_none()); + assert!(windows.is_some()); + } + + #[test] + fn test_get_platform_signing_config_linux() { + let signing = Signing { + macos: Some(MacOsSigning { + identity: "-".to_string(), + keychain: None, + entitlements: None, + options: vec![], + }), + windows: Some(make_windows_signtool()), + }; + + let (macos, windows) = get_platform_signing_config(&signing, Platform::Linux64); + assert!(macos.is_none()); + assert!(windows.is_none()); + } + + #[test] + fn test_get_platform_signing_config_no_signing() { + let signing = Signing::default(); + + let (macos, windows) = get_platform_signing_config(&signing, Platform::OsxArm64); + assert!(macos.is_none()); + assert!(windows.is_none()); + } + + #[test] + fn test_signing_default_is_empty() { + let signing = Signing::default(); + assert!(signing.is_default()); + assert!(signing.macos.is_none()); + assert!(signing.windows.is_none()); + } + + #[test] + fn test_windows_signing_method_signtool() { + let config = make_windows_signtool(); + let method = config.method().unwrap(); + assert!(matches!(method, WindowsSigningMethod::Signtool { .. })); + } + + #[test] + fn test_windows_signing_method_azure() { + let config = make_windows_azure(); + let method = config.method().unwrap(); + assert!(matches!( + method, + WindowsSigningMethod::AzureTrustedSigning { .. } + )); + } + + #[test] + fn test_windows_signing_method_both_errors() { + let config = WindowsSigning { + signtool: Some(SigntoolConfig { + certificate_file: "cert.pfx".to_string(), + certificate_password_env: None, + }), + azure_trusted_signing: Some(AzureTrustedSigningConfig { + endpoint: "https://endpoint".to_string(), + account_name: "account".to_string(), + certificate_profile: "profile".to_string(), + }), + timestamp_url: None, + digest_algorithm: "sha256".to_string(), + }; + assert!(config.method().is_err()); + } + + #[test] + fn test_windows_signing_method_neither_errors() { + let config = WindowsSigning { + signtool: None, + azure_trusted_signing: None, + timestamp_url: None, + digest_algorithm: "sha256".to_string(), + }; + assert!(config.method().is_err()); + } +} diff --git a/src/system_tools.rs b/src/system_tools.rs index 3cadf27eb..6f03430dc 100644 --- a/src/system_tools.rs +++ b/src/system_tools.rs @@ -37,6 +37,8 @@ pub enum Tool { InstallNameTool, /// The git tool Git, + /// The signtool tool (for Windows code signing) + Signtool, } impl std::fmt::Display for Tool { @@ -51,6 +53,7 @@ impl std::fmt::Display for Tool { Tool::Patchelf => "patchelf".to_string(), Tool::InstallNameTool => "install_name_tool".to_string(), Tool::Git => "git".to_string(), + Tool::Signtool => "signtool".to_string(), } ) } @@ -171,6 +174,10 @@ impl SystemTools { let version = String::from_utf8_lossy(&version.stdout); (path, version.to_string()) } + Tool::Signtool => { + let path = which("signtool")?; + (path, "".to_string()) + } Tool::RattlerBuild => { let path = std::env::current_exe().expect("Failed to get current executable path"); (path, env!("CARGO_PKG_VERSION").to_string()) diff --git a/src/tool_configuration.rs b/src/tool_configuration.rs index 9a4e3994c..c370cf380 100644 --- a/src/tool_configuration.rs +++ b/src/tool_configuration.rs @@ -19,6 +19,8 @@ use rattler_solve::ChannelPriority; use thiserror::Error; use url::Url; +use rattler_build_recipe::stage1::build::Signing; + use crate::console_utils::LoggingOutputHandler; /// The user agent to use for the reqwest client @@ -144,6 +146,10 @@ pub struct Configuration { /// This is only useful for other libraries that build their own environments and only use rattler-build /// to execute scripts / bundle up files. pub environments_externally_managed: bool, + + /// Optional signing configuration loaded from a `--signing-config-file`. + /// When set, this overrides any signing config in the recipe. + pub signing_config_override: Option, } /// Get the authentication storage from the given file @@ -207,6 +213,7 @@ pub struct ConfigurationBuilder { allow_symlinks_on_windows: bool, allow_absolute_license_paths: bool, environments_externally_managed: bool, + signing_config_override: Option, } impl Configuration { @@ -241,6 +248,7 @@ impl ConfigurationBuilder { allow_symlinks_on_windows: false, allow_absolute_license_paths: false, environments_externally_managed: false, + signing_config_override: None, } } @@ -414,6 +422,14 @@ impl ConfigurationBuilder { } } + /// Set the signing configuration override (from `--signing-config-file`). + pub fn with_signing_config_override(self, signing_config_override: Option) -> Self { + Self { + signing_config_override, + ..self + } + } + /// Set whether the environments are externally managed (e.g. by `pixi-build`). /// This is only useful for other libraries that build their own environments and only use rattler /// to execute scripts / bundle up files. @@ -482,6 +498,7 @@ impl ConfigurationBuilder { allow_symlinks_on_windows: self.allow_symlinks_on_windows, allow_absolute_license_paths: self.allow_absolute_license_paths, environments_externally_managed: self.environments_externally_managed, + signing_config_override: self.signing_config_override, } } } diff --git a/test-data/conda_forge b/test-data/conda_forge index 7d8667fd1..1725c6486 160000 --- a/test-data/conda_forge +++ b/test-data/conda_forge @@ -1 +1 @@ -Subproject commit 7d8667fd16181428d76227c654f4019318f6193d +Subproject commit 1725c648615e4c4fed6fd113715f7bf326abf5d0