diff --git a/Cargo.lock b/Cargo.lock index 95d046c..43a3a20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2018,6 +2018,7 @@ dependencies = [ "psign-codesigning-rest", "psign-digest-cli", "psign-opc-sign", + "psign-portable-core", "psign-sip-digest", "rand 0.8.6", "rayon", diff --git a/Cargo.toml b/Cargo.toml index 3210df1..12e69a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,12 @@ azure-kv-sign = [ "psign-digest-cli/azure-kv-sign-portable", ] ## Azure Artifact Signing / Trusted Signing **data-plane** hash signing (REST LRO); experimental helper command `artifact-signing-submit`. -artifact-signing-rest = ["dep:psign-codesigning-rest", "psign-digest-cli/artifact-signing-rest"] +artifact-signing-rest = [ + "dep:psign-codesigning-rest", + "dep:psign-portable-core", + "psign-digest-cli/artifact-signing-rest", + "psign-portable-core/artifact-signing-rest", +] ## Portable RFC 3161 TSA HTTP POST helper under `psign-tool portable`. timestamp-http = ["psign-digest-cli/timestamp-http"] ## Local RFC 3161 timestamp test server (`psign-server`). @@ -62,6 +67,7 @@ psign-sip-digest = { path = "crates/psign-sip-digest" } psign-authenticode-trust = { path = "crates/psign-authenticode-trust" } psign-digest-cli = { path = "crates/psign-digest-cli" } psign-opc-sign = { path = "crates/psign-opc-sign" } +psign-portable-core = { path = "crates/psign-portable-core", optional = true } anyhow = "1" clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } @@ -73,7 +79,9 @@ goblin = "0.9" picky = { version = "7.0.0-rc.23", default-features = false, features = ["pkcs12"] } cms = { version = "0.2.3", features = ["builder"], optional = true } der = { version = "0.7", features = ["derive"], optional = true } +glob = "0.3" rand = { version = "0.8", optional = true } +rayon = "1.10" rsa = { version = "0.9.10", features = ["sha2"] } x509-cert = "0.2.5" zip = { version = "0.6.6", default-features = false, features = ["deflate"] } @@ -82,8 +90,6 @@ psign-azure-kv-rest = { path = "crates/psign-azure-kv-rest", optional = true } psign-codesigning-rest = { path = "crates/psign-codesigning-rest", optional = true } [target.'cfg(windows)'.dependencies] -glob = "0.3" -rayon = "1.10" uuid = "1" windows = { version = "0.59", features = [ "Win32_Foundation", diff --git a/crates/psign-codesigning-rest/src/lib.rs b/crates/psign-codesigning-rest/src/lib.rs index c0f8d46..3a0ce8e 100644 --- a/crates/psign-codesigning-rest/src/lib.rs +++ b/crates/psign-codesigning-rest/src/lib.rs @@ -15,13 +15,46 @@ const MI_RESOURCE: &str = "https://codesigning.azure.net"; /// Authentication mode for **`codesigning.azure.net`**. #[derive(Debug, Clone)] pub enum CodesigningAuth { - ManagedIdentity, + ManagedIdentity { + client_id: Option, + resource_id: Option, + }, Bearer(String), ClientCredentials { tenant_id: String, client_id: String, client_secret: String, }, + WorkloadIdentity { + tenant_id: String, + client_id: String, + federated_token_file: String, + }, + DefaultChain { + exclude_credentials: Vec, + }, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum CodesigningCredentialType { + Default, + ManagedIdentity, + AccessToken, + ClientSecret, + WorkloadIdentity, +} + +#[derive(Debug, Clone, Default)] +pub struct CodesigningAuthInput { + pub access_token: Option, + pub managed_identity: bool, + pub managed_identity_resource_id: Option, + pub tenant_id: Option, + pub client_id: Option, + pub client_secret: Option, + pub federated_token_file: Option, + pub credential_type: Option, + pub exclude_credentials: Vec, } /// Parameters for **`…/certificateprofiles/{profile}:sign`** (blocking). @@ -77,6 +110,314 @@ fn normalize_authority(authority: Option<&str>) -> String { .to_string() } +fn text_opt(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned) +} + +fn env_text(name: &str) -> Option { + std::env::var(name).ok().and_then(|v| text_opt(Some(&v))) +} + +fn credential_excluded(exclude_credentials: &[String], names: &[&str]) -> bool { + exclude_credentials.iter().any(|value| { + let normalized = value.trim().replace(['-', '_', ' '], ""); + names + .iter() + .any(|name| normalized.eq_ignore_ascii_case(&name.replace(['-', '_', ' '], ""))) + }) +} + +pub fn resolve_codesigning_auth(input: &CodesigningAuthInput) -> Result { + let token = text_opt(input.access_token.as_deref()); + let tenant = text_opt(input.tenant_id.as_deref()); + let client = text_opt(input.client_id.as_deref()); + let secret = text_opt(input.client_secret.as_deref()); + let federated_token_file = text_opt(input.federated_token_file.as_deref()); + let resource_id = text_opt(input.managed_identity_resource_id.as_deref()); + let client_parts = tenant.is_some() as u8 + client.is_some() as u8 + secret.is_some() as u8; + + match input + .credential_type + .unwrap_or(CodesigningCredentialType::Default) + { + CodesigningCredentialType::AccessToken => { + if client_parts != 0 || input.managed_identity || federated_token_file.is_some() { + return Err(anyhow!( + "Artifact Signing access-token credential cannot be combined with managed identity, workload identity, or client credentials" + )); + } + return token.map(CodesigningAuth::Bearer).ok_or_else(|| { + anyhow!("Artifact Signing access-token credential requires access-token") + }); + } + CodesigningCredentialType::ClientSecret => { + if token.is_some() || input.managed_identity || federated_token_file.is_some() { + return Err(anyhow!( + "Artifact Signing client-secret credential cannot be combined with access token, managed identity, or workload identity" + )); + } + if client_parts != 3 { + return Err(anyhow!( + "Artifact Signing client-secret credential requires tenant-id, client-id, and client-secret" + )); + } + return Ok(CodesigningAuth::ClientCredentials { + tenant_id: tenant.unwrap(), + client_id: client.unwrap(), + client_secret: secret.unwrap(), + }); + } + CodesigningCredentialType::ManagedIdentity => { + if token.is_some() + || tenant.is_some() + || secret.is_some() + || federated_token_file.is_some() + { + return Err(anyhow!( + "Artifact Signing managed identity credential cannot be combined with access token, tenant/client-secret, or workload identity" + )); + } + return Ok(CodesigningAuth::ManagedIdentity { + client_id: client, + resource_id, + }); + } + CodesigningCredentialType::WorkloadIdentity => { + if token.is_some() + || secret.is_some() + || input.managed_identity + || resource_id.is_some() + { + return Err(anyhow!( + "Artifact Signing workload identity credential cannot be combined with access token, client secret, or managed identity" + )); + } + let tenant_id = tenant + .or_else(|| env_text("AZURE_TENANT_ID")) + .ok_or_else(|| { + anyhow!( + "Artifact Signing workload identity requires tenant-id or AZURE_TENANT_ID" + ) + })?; + let client_id = client + .or_else(|| env_text("AZURE_CLIENT_ID")) + .ok_or_else(|| { + anyhow!( + "Artifact Signing workload identity requires client-id or AZURE_CLIENT_ID" + ) + })?; + let token_file = federated_token_file + .or_else(|| env_text("AZURE_FEDERATED_TOKEN_FILE")) + .ok_or_else(|| { + anyhow!("Artifact Signing workload identity requires federated-token-file or AZURE_FEDERATED_TOKEN_FILE") + })?; + return Ok(CodesigningAuth::WorkloadIdentity { + tenant_id, + client_id, + federated_token_file: token_file, + }); + } + CodesigningCredentialType::Default => {} + } + + if input.managed_identity { + if token.is_some() || tenant.is_some() || secret.is_some() || federated_token_file.is_some() + { + return Err(anyhow!( + "use either Artifact Signing managed identity, access token, workload identity, or client credentials, not multiple" + )); + } + return Ok(CodesigningAuth::ManagedIdentity { + client_id: client, + resource_id, + }); + } + if let Some(token) = token { + if client_parts != 0 || federated_token_file.is_some() { + return Err(anyhow!( + "use either Artifact Signing access token, workload identity, or client credentials, not multiple" + )); + } + return Ok(CodesigningAuth::Bearer(token)); + } + if let Some(federated_token_file) = federated_token_file { + if secret.is_some() { + return Err(anyhow!( + "use either Artifact Signing workload identity or client credentials, not both" + )); + } + if tenant.is_none() || client.is_none() { + return Err(anyhow!( + "Artifact Signing workload identity requires tenant-id, client-id, and federated-token-file" + )); + } + return Ok(CodesigningAuth::WorkloadIdentity { + tenant_id: tenant.unwrap(), + client_id: client.unwrap(), + federated_token_file, + }); + } + if client_parts != 0 && client_parts != 3 { + return Err(anyhow!( + "Artifact Signing client credentials require all of tenant-id, client-id, and client-secret" + )); + } + if client_parts == 3 { + return Ok(CodesigningAuth::ClientCredentials { + tenant_id: tenant.unwrap(), + client_id: client.unwrap(), + client_secret: secret.unwrap(), + }); + } + Ok(CodesigningAuth::DefaultChain { + exclude_credentials: input.exclude_credentials.clone(), + }) +} + +fn acquire_managed_identity_token( + client_id: Option<&str>, + resource_id: Option<&str>, +) -> Result { + let endpoint = std::env::var("PSIGN_CODESIGNING_IMDS_ENDPOINT") + .unwrap_or_else(|_| "http://169.254.169.254/metadata/identity/oauth2/token".to_string()); + let http = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .map_err(|e| anyhow!("HTTP client: {e}"))?; + let mut query = vec![ + ("api-version".to_string(), "2018-02-01".to_string()), + ("resource".to_string(), MI_RESOURCE.to_string()), + ]; + if let Some(client_id) = text_opt(client_id) { + query.push(("client_id".to_string(), client_id)); + } + if let Some(resource_id) = text_opt(resource_id) { + query.push(("mi_res_id".to_string(), resource_id)); + } + let rsp = http + .get(endpoint) + .query(&query) + .header("Metadata", "true") + .send() + .context("managed identity token (IMDS) for codesigning.azure.net")?; + if !rsp.status().is_success() { + return Err(anyhow!( + "managed identity token HTTP {}: {}", + rsp.status(), + rsp.text().unwrap_or_default() + )); + } + #[derive(Deserialize)] + struct MiJson { + access_token: String, + } + let j: MiJson = rsp.json().context("managed identity JSON")?; + Ok(j.access_token) +} + +fn acquire_client_credentials_token( + authority: Option<&str>, + tenant_id: &str, + client_id: &str, + client_secret: &str, +) -> Result { + let tenant = tenant_id.trim(); + let cid = client_id.trim(); + let sec = client_secret.trim(); + if tenant.is_empty() || cid.is_empty() || sec.is_empty() { + return Err(anyhow!( + "client credentials require non-empty tenant_id, client_id, client_secret" + )); + } + let http = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .map_err(|e| anyhow!("HTTP client: {e}"))?; + let token_url = format!( + "{}/{tenant}/oauth2/v2.0/token", + normalize_authority(authority) + ); + let rsp = http + .post(&token_url) + .form(&[ + ("client_id", cid), + ("client_secret", sec), + ("grant_type", "client_credentials"), + ("scope", DEFAULT_SCOPE), + ]) + .send() + .context("OAuth token request (codesigning.azure.net)")?; + if !rsp.status().is_success() { + return Err(anyhow!( + "OAuth HTTP {}: {}", + rsp.status(), + rsp.text().unwrap_or_default() + )); + } + #[derive(Deserialize)] + struct TokenJson { + access_token: String, + } + let j: TokenJson = rsp.json().context("OAuth JSON")?; + Ok(j.access_token) +} + +fn acquire_workload_identity_token( + authority: Option<&str>, + tenant_id: &str, + client_id: &str, + federated_token_file: &str, +) -> Result { + let assertion = std::fs::read_to_string(federated_token_file) + .with_context(|| format!("read federated token file {federated_token_file}"))?; + let tenant = tenant_id.trim(); + let cid = client_id.trim(); + let assertion = assertion.trim(); + if tenant.is_empty() || cid.is_empty() || assertion.is_empty() { + return Err(anyhow!( + "workload identity requires non-empty tenant_id, client_id, and federated token file" + )); + } + let http = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .map_err(|e| anyhow!("HTTP client: {e}"))?; + let token_url = format!( + "{}/{tenant}/oauth2/v2.0/token", + normalize_authority(authority) + ); + let rsp = http + .post(&token_url) + .form(&[ + ("client_id", cid), + ("client_assertion", assertion), + ( + "client_assertion_type", + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + ), + ("grant_type", "client_credentials"), + ("scope", DEFAULT_SCOPE), + ]) + .send() + .context("OAuth workload identity token request (codesigning.azure.net)")?; + if !rsp.status().is_success() { + return Err(anyhow!( + "OAuth workload identity HTTP {}: {}", + rsp.status(), + rsp.text().unwrap_or_default() + )); + } + #[derive(Deserialize)] + struct TokenJson { + access_token: String, + } + let j: TokenJson = rsp.json().context("OAuth workload identity JSON")?; + Ok(j.access_token) +} + fn acquire_codesigning_token(params: &CodesigningSubmitParams) -> Result { match ¶ms.auth { CodesigningAuth::Bearer(tok) => { @@ -86,78 +427,87 @@ fn acquire_codesigning_token(params: &CodesigningSubmitParams) -> Result } Ok(t.to_string()) } - CodesigningAuth::ManagedIdentity => { - let endpoint = std::env::var("PSIGN_CODESIGNING_IMDS_ENDPOINT").unwrap_or_else(|_| { - "http://169.254.169.254/metadata/identity/oauth2/token".to_string() - }); - let http = reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(120)) - .build() - .map_err(|e| anyhow!("HTTP client: {e}"))?; - let rsp = http - .get(endpoint) - .query(&[("api-version", "2018-02-01"), ("resource", MI_RESOURCE)]) - .header("Metadata", "true") - .send() - .context("managed identity token (IMDS) for codesigning.azure.net")?; - if !rsp.status().is_success() { - return Err(anyhow!( - "managed identity token HTTP {}: {}", - rsp.status(), - rsp.text().unwrap_or_default() - )); - } - #[derive(Deserialize)] - struct MiJson { - access_token: String, - } - let j: MiJson = rsp.json().context("managed identity JSON")?; - Ok(j.access_token) - } + CodesigningAuth::ManagedIdentity { + client_id, + resource_id, + } => acquire_managed_identity_token(client_id.as_deref(), resource_id.as_deref()), CodesigningAuth::ClientCredentials { tenant_id, client_id, client_secret, + } => acquire_client_credentials_token( + params.authority.as_deref(), + tenant_id, + client_id, + client_secret, + ), + CodesigningAuth::WorkloadIdentity { + tenant_id, + client_id, + federated_token_file, + } => acquire_workload_identity_token( + params.authority.as_deref(), + tenant_id, + client_id, + federated_token_file, + ), + CodesigningAuth::DefaultChain { + exclude_credentials, } => { - let tenant = tenant_id.trim(); - let cid = client_id.trim(); - let sec = client_secret.trim(); - if tenant.is_empty() || cid.is_empty() || sec.is_empty() { - return Err(anyhow!( - "client credentials require non-empty tenant_id, client_id, client_secret" - )); + let mut errors = Vec::new(); + if !credential_excluded( + exclude_credentials, + &["EnvironmentCredential", "ClientSecretCredential"], + ) && let (Some(tenant), Some(client), Some(secret)) = ( + env_text("AZURE_TENANT_ID"), + env_text("AZURE_CLIENT_ID"), + env_text("AZURE_CLIENT_SECRET"), + ) { + match acquire_client_credentials_token( + params.authority.as_deref(), + &tenant, + &client, + &secret, + ) { + Ok(token) => return Ok(token), + Err(e) => errors.push(format!("EnvironmentCredential: {e:#}")), + } } - let http = reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(120)) - .build() - .map_err(|e| anyhow!("HTTP client: {e}"))?; - let token_url = format!( - "{}/{tenant}/oauth2/v2.0/token", - normalize_authority(params.authority.as_deref()) - ); - let rsp = http - .post(&token_url) - .form(&[ - ("client_id", cid), - ("client_secret", sec), - ("grant_type", "client_credentials"), - ("scope", DEFAULT_SCOPE), - ]) - .send() - .context("OAuth token request (codesigning.azure.net)")?; - if !rsp.status().is_success() { - return Err(anyhow!( - "OAuth HTTP {}: {}", - rsp.status(), - rsp.text().unwrap_or_default() - )); + if !credential_excluded(exclude_credentials, &["WorkloadIdentityCredential"]) + && let (Some(tenant), Some(client), Some(token_file)) = ( + env_text("AZURE_TENANT_ID"), + env_text("AZURE_CLIENT_ID"), + env_text("AZURE_FEDERATED_TOKEN_FILE"), + ) + { + match acquire_workload_identity_token( + params.authority.as_deref(), + &tenant, + &client, + &token_file, + ) { + Ok(token) => return Ok(token), + Err(e) => errors.push(format!("WorkloadIdentityCredential: {e:#}")), + } + } + if !credential_excluded(exclude_credentials, &["ManagedIdentityCredential"]) { + let client_id = env_text("AZURE_MANAGED_IDENTITY_CLIENT_ID") + .or_else(|| env_text("AZURE_CLIENT_ID")); + match acquire_managed_identity_token(client_id.as_deref(), None) { + Ok(token) => return Ok(token), + Err(e) => errors.push(format!("ManagedIdentityCredential: {e:#}")), + } } - #[derive(Deserialize)] - struct TokenJson { - access_token: String, + if errors.is_empty() { + Err(anyhow!( + "no Artifact Signing credential was available in the Rust default chain" + )) + } else { + Err(anyhow!( + "Artifact Signing Rust default credential chain failed: {}", + errors.join("; ") + )) } - let j: TokenJson = rsp.json().context("OAuth JSON")?; - Ok(j.access_token) } } } @@ -410,4 +760,88 @@ mod tests { let err = submit_codesign_hash_blocking(&p, |_| {}).unwrap_err(); assert!(err.to_string().contains("digest is empty"), "{err}"); } + + #[test] + fn resolver_accepts_user_assigned_managed_identity() { + let auth = resolve_codesigning_auth(&CodesigningAuthInput { + managed_identity: true, + client_id: Some("client-id".into()), + managed_identity_resource_id: Some("/subscriptions/s/resourceGroups/g/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id".into()), + ..Default::default() + }) + .unwrap(); + match auth { + CodesigningAuth::ManagedIdentity { + client_id, + resource_id, + } => { + assert_eq!(client_id.as_deref(), Some("client-id")); + assert_eq!( + resource_id.as_deref(), + Some( + "/subscriptions/s/resourceGroups/g/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id" + ) + ); + } + other => panic!("unexpected auth: {other:?}"), + } + } + + #[test] + fn resolver_accepts_workload_identity_inputs() { + let auth = resolve_codesigning_auth(&CodesigningAuthInput { + tenant_id: Some("tenant".into()), + client_id: Some("client".into()), + federated_token_file: Some("token.jwt".into()), + credential_type: Some(CodesigningCredentialType::WorkloadIdentity), + ..Default::default() + }) + .unwrap(); + match auth { + CodesigningAuth::WorkloadIdentity { + tenant_id, + client_id, + federated_token_file, + } => { + assert_eq!(tenant_id, "tenant"); + assert_eq!(client_id, "client"); + assert_eq!(federated_token_file, "token.jwt"); + } + other => panic!("unexpected auth: {other:?}"), + } + } + + #[test] + fn resolver_keeps_metadata_excludes_on_default_chain() { + let auth = resolve_codesigning_auth(&CodesigningAuthInput { + exclude_credentials: vec![ + "EnvironmentCredential".into(), + "ManagedIdentityCredential".into(), + ], + ..Default::default() + }) + .unwrap(); + match auth { + CodesigningAuth::DefaultChain { + exclude_credentials, + } => assert_eq!( + exclude_credentials, + vec!["EnvironmentCredential", "ManagedIdentityCredential"] + ), + other => panic!("unexpected auth: {other:?}"), + } + } + + #[test] + fn resolver_rejects_mixed_explicit_credentials() { + let err = resolve_codesigning_auth(&CodesigningAuthInput { + access_token: Some("token".into()), + tenant_id: Some("tenant".into()), + client_id: Some("client".into()), + client_secret: Some("secret".into()), + ..Default::default() + }) + .unwrap_err(); + assert!(err.to_string().contains("not multiple"), "{err}"); + } } diff --git a/crates/psign-digest-cli/src/main.rs b/crates/psign-digest-cli/src/main.rs index 112d726..f3260a2 100644 --- a/crates/psign-digest-cli/src/main.rs +++ b/crates/psign-digest-cli/src/main.rs @@ -23,8 +23,9 @@ use psign_azure_kv_rest::{ }; #[cfg(feature = "artifact-signing-rest")] use psign_codesigning_rest::{ - CodesigningAuth, CodesigningSubmitParams, DEFAULT_API_VERSION, - submit_codesign_hash_blocking, submit_codesign_hash_signature_blocking, + CodesigningAuth, CodesigningAuthInput, CodesigningCredentialType, CodesigningSubmitParams, + DEFAULT_API_VERSION, resolve_codesigning_auth, submit_codesign_hash_blocking, + submit_codesign_hash_signature_blocking, }; use psign_opc_sign::{nuget, vsix}; use psign_sip_digest::cab_digest::{self, @@ -1744,6 +1745,42 @@ fn timestamp_pkcs7_der_rfc3161( pkcs7::encode_pkcs7_content_info_signed_data_der(&stamped) } +fn timestamp_authenticode_pkcs7_der_if_requested( + pkcs7_der: Vec, + timestamp_url: Option, + timestamp_digest: Option, + command_name: &str, +) -> Result> { + match (timestamp_url, timestamp_digest) { + (Some(url), Some(timestamp_digest)) => { + #[cfg(feature = "timestamp-http")] + { + timestamp_pkcs7_der_rfc3161( + &pkcs7_der, + &url, + timestamp_digest, + Rfc3161TimestampAttribute::MicrosoftAuthenticode, + ) + .with_context(|| format!("{command_name}: RFC3161 timestamp signature")) + } + #[cfg(not(feature = "timestamp-http"))] + { + let _ = (url, timestamp_digest); + Err(anyhow!( + "{command_name} RFC3161 timestamping requires the timestamp-http feature" + )) + } + } + (Some(_), None) => Err(anyhow!( + "{command_name} requires --timestamp-digest with --timestamp-url" + )), + (None, Some(_)) => Err(anyhow!( + "{command_name} requires --timestamp-url with --timestamp-digest" + )), + (None, None) => Ok(pkcs7_der), + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Rfc3161TimestampAttribute { MicrosoftAuthenticode, @@ -2012,18 +2049,26 @@ enum Command { /// Input CAB path. #[arg(value_name = "PATH")] path: PathBuf, - /// Signer certificate as DER or PEM. + /// Signer certificate as DER or PEM. Omit when using --artifact-signing-*. #[arg(long, value_name = "PATH")] - cert: PathBuf, - /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM. + cert: Option, + /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM. Omit when using --artifact-signing-*. #[arg(long, value_name = "PATH")] - key: PathBuf, + key: Option, /// Additional certificate to include in the PKCS#7 certificate set. #[arg(long = "chain-cert", value_name = "PATH")] chain_certs: Vec, /// File digest algorithm for the CAB Authenticode indirect digest and CMS signer. #[arg(long, value_enum, default_value_t = PortableSignDigest::Sha256)] digest: PortableSignDigest, + /// RFC3161 timestamp URL to timestamp the CAB Authenticode signature after signing. + #[arg(long = "timestamp-url", visible_alias = "tr")] + timestamp_url: Option, + /// RFC3161 timestamp digest algorithm. + #[arg(long = "timestamp-digest", visible_alias = "td", value_enum)] + timestamp_digest: Option, + #[command(flatten)] + artifact_signing: Box, /// Output signed CAB path. #[arg(long, value_name = "PATH")] output: PathBuf, @@ -2033,18 +2078,26 @@ enum Command { /// Input MSI/MSP path. #[arg(value_name = "PATH")] path: PathBuf, - /// Signer certificate as DER or PEM. + /// Signer certificate as DER or PEM. Omit when using --artifact-signing-*. #[arg(long, value_name = "PATH")] - cert: PathBuf, - /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM. + cert: Option, + /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM. Omit when using --artifact-signing-*. #[arg(long, value_name = "PATH")] - key: PathBuf, + key: Option, /// Additional certificate to include in the PKCS#7 certificate set. #[arg(long = "chain-cert", value_name = "PATH")] chain_certs: Vec, /// File digest algorithm for the MSI Authenticode indirect digest and CMS signer. #[arg(long, value_enum, default_value_t = PortableSignDigest::Sha256)] digest: PortableSignDigest, + /// RFC3161 timestamp URL to timestamp the MSI Authenticode signature after signing. + #[arg(long = "timestamp-url", visible_alias = "tr")] + timestamp_url: Option, + /// RFC3161 timestamp digest algorithm. + #[arg(long = "timestamp-digest", visible_alias = "td", value_enum)] + timestamp_digest: Option, + #[command(flatten)] + artifact_signing: Box, /// Output signed MSI/MSP path. #[arg(long, value_name = "PATH")] output: PathBuf, @@ -2057,18 +2110,26 @@ enum Command { /// Subject file(s) to include as catalog members. #[arg(required = true, value_name = "PATH")] files: Vec, - /// Signer certificate as DER or PEM. + /// Signer certificate as DER or PEM. Omit when using --artifact-signing-*. #[arg(long, value_name = "PATH")] - cert: PathBuf, - /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM. + cert: Option, + /// RSA private key as PKCS#8 or PKCS#1, DER or unencrypted PEM. Omit when using --artifact-signing-*. #[arg(long, value_name = "PATH")] - key: PathBuf, + key: Option, /// Additional certificate to include in the PKCS#7 certificate set. #[arg(long = "chain-cert", value_name = "PATH")] chain_certs: Vec, /// File digest algorithm for catalog member digests and CMS signer. #[arg(long, value_enum, default_value_t = PortableSignDigest::Sha256)] digest: PortableSignDigest, + /// RFC3161 timestamp URL to timestamp the catalog signature after signing. + #[arg(long = "timestamp-url", visible_alias = "tr")] + timestamp_url: Option, + /// RFC3161 timestamp digest algorithm. + #[arg(long = "timestamp-digest", visible_alias = "td", value_enum)] + timestamp_digest: Option, + #[command(flatten)] + artifact_signing: Box, /// Output signed catalog path. #[arg(long, value_name = "PATH")] output: PathBuf, @@ -3053,6 +3114,15 @@ fn write_digest_output( Ok(()) } +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +enum ArtifactSigningCredentialType { + Default, + ManagedIdentity, + AccessToken, + ClientSecret, + WorkloadIdentity, +} + #[cfg(feature = "artifact-signing-rest")] #[derive(Args, Debug, Clone)] struct ArtifactSigningSubmitPortableArgs { @@ -3075,12 +3145,18 @@ struct ArtifactSigningSubmitPortableArgs { #[arg(long)] managed_identity: bool, #[arg(long)] + managed_identity_resource_id: Option, + #[arg(long, value_enum)] + credential_type: Option, + #[arg(long)] tenant_id: Option, #[arg(long)] client_id: Option, #[arg(long)] client_secret: Option, #[arg(long)] + federated_token_file: Option, + #[arg(long)] authority: Option, /// Override data-plane origin for deterministic local tests. #[arg(long, hide = true)] @@ -3112,12 +3188,18 @@ struct ArtifactSigningPortableOptions { access_token: Option, #[arg(long = "artifact-signing-managed-identity")] managed_identity: bool, + #[arg(long = "artifact-signing-managed-identity-resource-id")] + managed_identity_resource_id: Option, + #[arg(long = "artifact-signing-credential-type", value_enum)] + credential_type: Option, #[arg(long = "artifact-signing-tenant-id")] tenant_id: Option, #[arg(long = "artifact-signing-client-id")] client_id: Option, #[arg(long = "artifact-signing-client-secret")] client_secret: Option, + #[arg(long = "artifact-signing-federated-token-file")] + federated_token_file: Option, #[arg(long = "artifact-signing-authority")] authority: Option, /// Override data-plane origin for deterministic local tests. @@ -3127,75 +3209,23 @@ struct ArtifactSigningPortableOptions { #[cfg(feature = "artifact-signing-rest")] fn validate_portable_submit_args(args: &ArtifactSigningSubmitPortableArgs) -> Result<()> { - let has_tok = args - .access_token - .as_ref() - .map(|s| !s.trim().is_empty()) - .unwrap_or(false); - let sp_count = (args - .tenant_id - .as_ref() - .map(|s| !s.trim().is_empty()) - .unwrap_or(false) as u8) - + (args - .client_id - .as_ref() - .map(|s| !s.trim().is_empty()) - .unwrap_or(false) as u8) - + (args - .client_secret - .as_ref() - .map(|s| !s.trim().is_empty()) - .unwrap_or(false) as u8); - if args.managed_identity { - if has_tok || sp_count != 0 { - return Err(anyhow!( - "use either --managed-identity or access token / client credentials, not multiple" - )); - } - return Ok(()); - } - if has_tok { - if sp_count != 0 { - return Err(anyhow!( - "use either --access-token or client credentials tenant/id/secret, not both" - )); - } - return Ok(()); - } - if sp_count != 0 && sp_count != 3 { - return Err(anyhow!( - "client credentials require all of --tenant-id, --client-id, and --client-secret" - )); - } - if sp_count == 0 { - return Err(anyhow!( - "choose authentication: --managed-identity, --access-token, or tenant/client-id/client-secret" - )); - } + portable_submit_auth(args)?; Ok(()) } #[cfg(feature = "artifact-signing-rest")] fn portable_submit_auth(args: &ArtifactSigningSubmitPortableArgs) -> Result { - let has_tok = args - .access_token - .as_ref() - .map(|s| !s.trim().is_empty()) - .unwrap_or(false); - if args.managed_identity { - return Ok(CodesigningAuth::ManagedIdentity); - } - if has_tok { - return Ok(CodesigningAuth::Bearer( - args.access_token.as_ref().unwrap().trim().to_string(), - )); - } - Ok(CodesigningAuth::ClientCredentials { - tenant_id: args.tenant_id.as_ref().unwrap().trim().to_string(), - client_id: args.client_id.as_ref().unwrap().trim().to_string(), - client_secret: args.client_secret.as_ref().unwrap().trim().to_string(), - }) + portable_submit_auth_parts( + args.access_token.as_deref(), + args.managed_identity, + args.managed_identity_resource_id.as_deref(), + args.credential_type, + args.tenant_id.as_deref(), + args.client_id.as_deref(), + args.client_secret.as_deref(), + args.federated_token_file.as_deref(), + Vec::new(), + ) } #[cfg(feature = "artifact-signing-rest")] @@ -3516,56 +3546,52 @@ fn artifact_signing_requested(args: &ArtifactSigningPortableOptions) -> bool { || text_opt(args.correlation_id.as_deref()).is_some() || text_opt(args.access_token.as_deref()).is_some() || args.managed_identity + || text_opt(args.managed_identity_resource_id.as_deref()).is_some() + || args.credential_type.is_some() || text_opt(args.tenant_id.as_deref()).is_some() || text_opt(args.client_id.as_deref()).is_some() || text_opt(args.client_secret.as_deref()).is_some() + || text_opt(args.federated_token_file.as_deref()).is_some() || text_opt(args.authority.as_deref()).is_some() || text_opt(args.endpoint_base_url.as_deref()).is_some() } #[cfg(feature = "artifact-signing-rest")] +fn codesigning_credential_type( + value: Option, +) -> Option { + value.map(|value| match value { + ArtifactSigningCredentialType::Default => CodesigningCredentialType::Default, + ArtifactSigningCredentialType::ManagedIdentity => CodesigningCredentialType::ManagedIdentity, + ArtifactSigningCredentialType::AccessToken => CodesigningCredentialType::AccessToken, + ArtifactSigningCredentialType::ClientSecret => CodesigningCredentialType::ClientSecret, + ArtifactSigningCredentialType::WorkloadIdentity => CodesigningCredentialType::WorkloadIdentity, + }) +} + +#[cfg(feature = "artifact-signing-rest")] +#[allow(clippy::too_many_arguments)] fn portable_submit_auth_parts( access_token: Option<&str>, managed_identity: bool, + managed_identity_resource_id: Option<&str>, + credential_type: Option, tenant_id: Option<&str>, client_id: Option<&str>, client_secret: Option<&str>, + federated_token_file: Option<&str>, + exclude_credentials: Vec, ) -> Result { - let has_tok = text_opt(access_token).is_some(); - let tenant = text_opt(tenant_id); - let client = text_opt(client_id); - let secret = text_opt(client_secret); - let sp_count = tenant.is_some() as u8 + client.is_some() as u8 + secret.is_some() as u8; - if managed_identity { - if has_tok || sp_count != 0 { - return Err(anyhow!( - "use either Artifact Signing managed identity, access token, or client credentials, not multiple" - )); - } - return Ok(CodesigningAuth::ManagedIdentity); - } - if let Some(tok) = text_opt(access_token) { - if sp_count != 0 { - return Err(anyhow!( - "use either Artifact Signing access token or client credentials, not both" - )); - } - return Ok(CodesigningAuth::Bearer(tok)); - } - if sp_count != 0 && sp_count != 3 { - return Err(anyhow!( - "Artifact Signing client credentials require all of tenant-id, client-id, and client-secret" - )); - } - if sp_count == 0 { - return Err(anyhow!( - "choose Artifact Signing authentication: managed identity, access token, or tenant/client-id/client-secret" - )); - } - Ok(CodesigningAuth::ClientCredentials { - tenant_id: tenant.unwrap(), - client_id: client.unwrap(), - client_secret: secret.unwrap(), + resolve_codesigning_auth(&CodesigningAuthInput { + access_token: text_opt(access_token), + managed_identity, + managed_identity_resource_id: text_opt(managed_identity_resource_id), + tenant_id: text_opt(tenant_id), + client_id: text_opt(client_id), + client_secret: text_opt(client_secret), + federated_token_file: text_opt(federated_token_file), + credential_type: codesigning_credential_type(credential_type), + exclude_credentials, }) } @@ -3605,9 +3631,16 @@ fn artifact_signing_params_for_digest( let auth = portable_submit_auth_parts( args.access_token.as_deref(), args.managed_identity, + args.managed_identity_resource_id.as_deref(), + args.credential_type, args.tenant_id.as_deref(), args.client_id.as_deref(), args.client_secret.as_deref(), + args.federated_token_file.as_deref(), + metadata + .as_ref() + .and_then(|m| m.ExcludeCredentials.clone()) + .unwrap_or_default(), )?; Ok(CodesigningSubmitParams { region, @@ -3668,6 +3701,134 @@ fn create_pe_authenticode_pkcs7_der_artifact_signing( ) } +#[cfg(feature = "artifact-signing-rest")] +fn create_cab_authenticode_pkcs7_der_artifact_signing( + cab: &[u8], + digest: PortableSignDigest, + chain_certs: Vec, + args: &ArtifactSigningPortableOptions, +) -> Result> { + let digest_algorithm: pkcs7::AuthenticodeSigningDigest = digest.into(); + let cab_digest = + cab_digest::cab_authenticode_digest_for_signing(cab, digest_algorithm.pe_hash_kind())?; + let indirect = pkcs7::cab_spc_indirect_data(digest_algorithm, &cab_digest)?; + create_authenticode_pkcs7_der_artifact_signing_from_indirect( + indirect, + digest, + chain_certs, + args, + ) +} + +#[cfg(feature = "artifact-signing-rest")] +fn create_msi_authenticode_pkcs7_der_artifact_signing( + msi: &[u8], + digest: PortableSignDigest, + chain_certs: Vec, + args: &ArtifactSigningPortableOptions, +) -> Result> { + let digest_algorithm: pkcs7::AuthenticodeSigningDigest = digest.into(); + let msi_digest = + msi_digest::compute_msi_authenticode_digest(msi, digest_algorithm.pe_hash_kind())?; + let indirect = pkcs7::msi_spc_indirect_data(digest_algorithm, &msi_digest)?; + create_authenticode_pkcs7_der_artifact_signing_from_indirect( + indirect, + digest, + chain_certs, + args, + ) +} + +#[cfg(feature = "artifact-signing-rest")] +fn create_authenticode_pkcs7_der_artifact_signing_from_indirect( + indirect: pkcs7::SpcIndirectDataContent, + digest: PortableSignDigest, + chain_certs: Vec, + args: &ArtifactSigningPortableOptions, +) -> Result> { + let digest_algorithm: pkcs7::AuthenticodeSigningDigest = digest.into(); + let signer_prehash = + pkcs7::authenticode_remote_rsa_signed_attrs_digest(&indirect, digest_algorithm)?; + let params = artifact_signing_params_for_digest( + args, + signer_prehash, + artifact_signature_algorithm_for_digest(digest), + )?; + let debug_portable = std::env::var_os("SIGNTOOL_PORTABLE_DEBUG").is_some(); + let signed = submit_codesign_hash_signature_blocking(¶ms, |msg| { + if debug_portable { + eprintln!("[debug] {msg}"); + } + })?; + let (signer_cert, mut chain) = + pkcs7::parse_artifact_signing_certificates(&signed.signing_certificate)?; + chain.extend(load_chain_certs(chain_certs)?); + pkcs7::create_authenticode_pkcs7_der_with_rsa_signature( + indirect, + digest_algorithm, + signer_cert, + chain, + &signed.signature, + ) +} + +#[cfg(feature = "artifact-signing-rest")] +fn create_catalog_pkcs7_der_artifact_signing( + subjects: &[catalog_digest::CatalogSubjectInput], + digest: PortableSignDigest, + chain_certs: Vec, + args: &ArtifactSigningPortableOptions, +) -> Result { + let digest_algorithm: pkcs7::AuthenticodeSigningDigest = digest.into(); + let (econtent_der, members) = + catalog_digest::create_catalog_ctl_econtent_der(subjects, digest_algorithm)?; + let id_ms_ctl = ObjectIdentifier::new_unwrap("1.3.6.1.4.1.311.10.1"); + let signer_prehash = pkcs7::pkcs7_remote_rsa_signed_attrs_digest_with_profile( + id_ms_ctl, + &econtent_der, + digest_algorithm, + pkcs7::Pkcs7SignedAttributeProfile::Basic, + None, + )?; + let params = artifact_signing_params_for_digest( + args, + signer_prehash, + artifact_signature_algorithm_for_digest(digest), + )?; + let debug_portable = std::env::var_os("SIGNTOOL_PORTABLE_DEBUG").is_some(); + let signed = submit_codesign_hash_signature_blocking(¶ms, |msg| { + if debug_portable { + eprintln!("[debug] {msg}"); + } + })?; + let (signer_cert, mut chain) = + pkcs7::parse_artifact_signing_certificates(&signed.signing_certificate)?; + chain.extend(load_chain_certs(chain_certs)?); + let pkcs7_der = pkcs7::create_pkcs7_signed_data_der_with_rsa_signature( + id_ms_ctl, + &econtent_der, + digest_algorithm, + signer_cert, + chain, + &signed.signature, + pkcs7::Pkcs7ContentMode::Attached, + )?; + Ok(catalog_digest::CatalogSignResult { pkcs7_der, members }) +} + +fn load_chain_certs(chain_certs: Vec) -> Result> { + let mut chain = Vec::with_capacity(chain_certs.len()); + for chain_cert in chain_certs { + let bytes = + std::fs::read(&chain_cert).with_context(|| format!("read {}", chain_cert.display()))?; + chain.push( + rdp::parse_certificate(&bytes) + .with_context(|| format!("parse chain certificate {}", chain_cert.display()))?, + ); + } + Ok(chain) +} + fn read_json_input(path: Option<&Path>) -> Result> { use std::io::Read; match path { @@ -4329,38 +4490,73 @@ where key, chain_certs, digest, + timestamp_url, + timestamp_digest, + artifact_signing, output, } => { let cab = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; - let cert_bytes = - std::fs::read(&cert).with_context(|| format!("read {}", cert.display()))?; - let signer_cert = rdp::parse_certificate(&cert_bytes) - .with_context(|| format!("parse signer certificate {}", cert.display()))?; - let key_bytes = std::fs::read(&key).with_context(|| format!("read {}", key.display()))?; - let private_key = rdp::parse_rsa_private_key(&key_bytes) - .with_context(|| format!("parse RSA private key {}", key.display()))?; - let mut chain = Vec::with_capacity(chain_certs.len()); - for chain_cert in chain_certs { - let bytes = std::fs::read(&chain_cert) - .with_context(|| format!("read {}", chain_cert.display()))?; - chain.push( - rdp::parse_certificate(&bytes) - .with_context(|| format!("parse chain certificate {}", chain_cert.display()))?, - ); + let has_artifact = artifact_signing_requested(&artifact_signing); + if has_artifact && (cert.is_some() || key.is_some()) { + return Err(anyhow!( + "portable sign-cab accepts either --cert/--key or --artifact-signing-*, not both" + )); } - let pkcs7 = pkcs7::create_cab_authenticode_pkcs7_der_rsa( - &cab, - digest.into(), - signer_cert, - chain, - private_key, - ) - .with_context(|| { - format!( - "create portable CAB Authenticode signature for {}", - path.display() + let pkcs7 = if has_artifact { + #[cfg(feature = "artifact-signing-rest")] + { + create_cab_authenticode_pkcs7_der_artifact_signing( + &cab, + digest, + chain_certs, + &artifact_signing, + ) + .with_context(|| { + format!( + "create portable Artifact Signing CAB Authenticode signature for {}", + path.display() + ) + })? + } + #[cfg(not(feature = "artifact-signing-rest"))] + { + return Err(anyhow!( + "portable sign-cab Artifact Signing support requires the artifact-signing-rest feature" + )); + } + } else { + let (cert, key) = match (cert, key) { + (Some(cert), Some(key)) => (cert, key), + _ => return Err(anyhow!("portable sign-cab requires --cert and --key, or --artifact-signing-* options")), + }; + let cert_bytes = + std::fs::read(&cert).with_context(|| format!("read {}", cert.display()))?; + let signer_cert = rdp::parse_certificate(&cert_bytes) + .with_context(|| format!("parse signer certificate {}", cert.display()))?; + let key_bytes = + std::fs::read(&key).with_context(|| format!("read {}", key.display()))?; + let private_key = rdp::parse_rsa_private_key(&key_bytes) + .with_context(|| format!("parse RSA private key {}", key.display()))?; + pkcs7::create_cab_authenticode_pkcs7_der_rsa( + &cab, + digest.into(), + signer_cert, + load_chain_certs(chain_certs)?, + private_key, ) - })?; + .with_context(|| { + format!( + "create portable CAB Authenticode signature for {}", + path.display() + ) + })? + }; + let pkcs7 = timestamp_authenticode_pkcs7_der_if_requested( + pkcs7, + timestamp_url, + timestamp_digest, + "portable sign-cab", + )?; let signed = cab_digest::cab_append_authenticode_pkcs7_signature(&cab, &pkcs7) .with_context(|| format!("embed Authenticode signature in {}", path.display()))?; std::fs::write(&output, signed).with_context(|| format!("write {}", output.display()))?; @@ -4377,38 +4573,73 @@ where key, chain_certs, digest, + timestamp_url, + timestamp_digest, + artifact_signing, output, } => { let msi = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; - let cert_bytes = - std::fs::read(&cert).with_context(|| format!("read {}", cert.display()))?; - let signer_cert = rdp::parse_certificate(&cert_bytes) - .with_context(|| format!("parse signer certificate {}", cert.display()))?; - let key_bytes = std::fs::read(&key).with_context(|| format!("read {}", key.display()))?; - let private_key = rdp::parse_rsa_private_key(&key_bytes) - .with_context(|| format!("parse RSA private key {}", key.display()))?; - let mut chain = Vec::with_capacity(chain_certs.len()); - for chain_cert in chain_certs { - let bytes = std::fs::read(&chain_cert) - .with_context(|| format!("read {}", chain_cert.display()))?; - chain.push( - rdp::parse_certificate(&bytes) - .with_context(|| format!("parse chain certificate {}", chain_cert.display()))?, - ); + let has_artifact = artifact_signing_requested(&artifact_signing); + if has_artifact && (cert.is_some() || key.is_some()) { + return Err(anyhow!( + "portable sign-msi accepts either --cert/--key or --artifact-signing-*, not both" + )); } - let pkcs7 = pkcs7::create_msi_authenticode_pkcs7_der_rsa( - &msi, - digest.into(), - signer_cert, - chain, - private_key, - ) - .with_context(|| { - format!( - "create portable MSI Authenticode signature for {}", - path.display() + let pkcs7 = if has_artifact { + #[cfg(feature = "artifact-signing-rest")] + { + create_msi_authenticode_pkcs7_der_artifact_signing( + &msi, + digest, + chain_certs, + &artifact_signing, + ) + .with_context(|| { + format!( + "create portable Artifact Signing MSI Authenticode signature for {}", + path.display() + ) + })? + } + #[cfg(not(feature = "artifact-signing-rest"))] + { + return Err(anyhow!( + "portable sign-msi Artifact Signing support requires the artifact-signing-rest feature" + )); + } + } else { + let (cert, key) = match (cert, key) { + (Some(cert), Some(key)) => (cert, key), + _ => return Err(anyhow!("portable sign-msi requires --cert and --key, or --artifact-signing-* options")), + }; + let cert_bytes = + std::fs::read(&cert).with_context(|| format!("read {}", cert.display()))?; + let signer_cert = rdp::parse_certificate(&cert_bytes) + .with_context(|| format!("parse signer certificate {}", cert.display()))?; + let key_bytes = + std::fs::read(&key).with_context(|| format!("read {}", key.display()))?; + let private_key = rdp::parse_rsa_private_key(&key_bytes) + .with_context(|| format!("parse RSA private key {}", key.display()))?; + pkcs7::create_msi_authenticode_pkcs7_der_rsa( + &msi, + digest.into(), + signer_cert, + load_chain_certs(chain_certs)?, + private_key, ) - })?; + .with_context(|| { + format!( + "create portable MSI Authenticode signature for {}", + path.display() + ) + })? + }; + let pkcs7 = timestamp_authenticode_pkcs7_der_if_requested( + pkcs7, + timestamp_url, + timestamp_digest, + "portable sign-msi", + )?; msi_digest::msi_embed_authenticode_pkcs7_signature(&path, &output, &pkcs7) .with_context(|| format!("embed Authenticode signature in {}", path.display()))?; println!( @@ -4424,24 +4655,11 @@ where key, chain_certs, digest, + timestamp_url, + timestamp_digest, + artifact_signing, output, } => { - let cert_bytes = - std::fs::read(&cert).with_context(|| format!("read {}", cert.display()))?; - let signer_cert = rdp::parse_certificate(&cert_bytes) - .with_context(|| format!("parse signer certificate {}", cert.display()))?; - let key_bytes = std::fs::read(&key).with_context(|| format!("read {}", key.display()))?; - let private_key = rdp::parse_rsa_private_key(&key_bytes) - .with_context(|| format!("parse RSA private key {}", key.display()))?; - let mut chain = Vec::with_capacity(chain_certs.len()); - for chain_cert in chain_certs { - let bytes = std::fs::read(&chain_cert) - .with_context(|| format!("read {}", chain_cert.display()))?; - chain.push( - rdp::parse_certificate(&bytes) - .with_context(|| format!("parse chain certificate {}", chain_cert.display()))?, - ); - } let mut subjects = Vec::with_capacity(files.len()); for file in &files { let name = file @@ -4453,14 +4671,62 @@ where std::fs::read(file).with_context(|| format!("read {}", file.display()))?; subjects.push(catalog_digest::CatalogSubjectInput { name, bytes }); } - let catalog = catalog_digest::create_catalog_pkcs7_der_rsa( - &subjects, - digest.into(), - signer_cert, - chain, - private_key, - ) - .with_context(|| format!("create portable catalog {}", output.display()))?; + let has_artifact = artifact_signing_requested(&artifact_signing); + if has_artifact && (cert.is_some() || key.is_some()) { + return Err(anyhow!( + "portable sign-catalog accepts either --cert/--key or --artifact-signing-*, not both" + )); + } + let mut catalog = if has_artifact { + #[cfg(feature = "artifact-signing-rest")] + { + create_catalog_pkcs7_der_artifact_signing( + &subjects, + digest, + chain_certs, + &artifact_signing, + ) + .with_context(|| { + format!( + "create portable Artifact Signing catalog {}", + output.display() + ) + })? + } + #[cfg(not(feature = "artifact-signing-rest"))] + { + return Err(anyhow!( + "portable sign-catalog Artifact Signing support requires the artifact-signing-rest feature" + )); + } + } else { + let (cert, key) = match (cert, key) { + (Some(cert), Some(key)) => (cert, key), + _ => return Err(anyhow!("portable sign-catalog requires --cert and --key, or --artifact-signing-* options")), + }; + let cert_bytes = + std::fs::read(&cert).with_context(|| format!("read {}", cert.display()))?; + let signer_cert = rdp::parse_certificate(&cert_bytes) + .with_context(|| format!("parse signer certificate {}", cert.display()))?; + let key_bytes = + std::fs::read(&key).with_context(|| format!("read {}", key.display()))?; + let private_key = rdp::parse_rsa_private_key(&key_bytes) + .with_context(|| format!("parse RSA private key {}", key.display()))?; + catalog_digest::create_catalog_pkcs7_der_rsa( + &subjects, + digest.into(), + signer_cert, + load_chain_certs(chain_certs)?, + private_key, + ) + .with_context(|| format!("create portable catalog {}", output.display()))? + }; + catalog.pkcs7_der = timestamp_authenticode_pkcs7_der_if_requested( + catalog.pkcs7_der, + timestamp_url, + timestamp_digest, + "portable sign-catalog", + )?; std::fs::write(&output, &catalog.pkcs7_der) .with_context(|| format!("write {}", output.display()))?; println!( diff --git a/crates/psign-portable-core/src/lib.rs b/crates/psign-portable-core/src/lib.rs index 115c92f..9df87b5 100644 --- a/crates/psign-portable-core/src/lib.rs +++ b/crates/psign-portable-core/src/lib.rs @@ -251,11 +251,58 @@ pub struct PortableSignRequest { #[serde(default)] pub artifact_signing_managed_identity: Option, #[serde(default)] + pub artifact_signing_managed_identity_resource_id: Option, + #[serde(default)] + pub artifact_signing_credential_type: Option, + #[serde(default)] pub artifact_signing_tenant_id: Option, #[serde(default)] pub artifact_signing_client_id: Option, #[serde(default)] pub artifact_signing_client_secret: Option, + #[serde(default)] + pub artifact_signing_federated_token_file: Option, + #[serde(default)] + pub artifact_signing_exclude_credentials: Vec, +} + +impl Default for PortableSignRequest { + fn default() -> Self { + Self { + path: PathBuf::new(), + output_path: None, + hash_algorithm: PortableDigestAlgorithm::default(), + certificate_path: None, + private_key_path: None, + certificate_der_base64: None, + private_key_der_base64: None, + pfx_path: None, + pfx_password: None, + chain_certificate_paths: Vec::new(), + chain_certificates_der_base64: Vec::new(), + timestamp_server: None, + timestamp_hash_algorithm: None, + azure_key_vault_url: None, + azure_key_vault_certificate: None, + azure_key_vault_access_token: None, + azure_key_vault_client_id: None, + azure_key_vault_client_secret: None, + azure_key_vault_tenant_id: None, + azure_key_vault_managed_identity: None, + artifact_signing_endpoint: None, + artifact_signing_account_name: None, + artifact_signing_profile_name: None, + artifact_signing_access_token: None, + artifact_signing_managed_identity: None, + artifact_signing_managed_identity_resource_id: None, + artifact_signing_credential_type: None, + artifact_signing_tenant_id: None, + artifact_signing_client_id: None, + artifact_signing_client_secret: None, + artifact_signing_federated_token_file: None, + artifact_signing_exclude_credentials: Vec::new(), + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -912,42 +959,54 @@ fn validate_kv_auth_inputs(request: &PortableSignRequest) -> Result<()> { fn artifact_signing_auth( request: &PortableSignRequest, ) -> Result { - let has_token = text_opt(request.artifact_signing_access_token.as_deref()).is_some(); - let tenant = text_opt(request.artifact_signing_tenant_id.as_deref()); - let client = text_opt(request.artifact_signing_client_id.as_deref()); - let secret = text_opt(request.artifact_signing_client_secret.as_deref()); - let managed_identity = request.artifact_signing_managed_identity.unwrap_or(false); - let sp_count = tenant.is_some() as u8 + client.is_some() as u8 + secret.is_some() as u8; - - if managed_identity { - if has_token || sp_count != 0 { - bail!( - "use either Artifact Signing managed identity, access token, or client credentials, not multiple" - ); + psign_codesigning_rest::resolve_codesigning_auth( + &psign_codesigning_rest::CodesigningAuthInput { + access_token: request.artifact_signing_access_token.clone(), + managed_identity: request.artifact_signing_managed_identity.unwrap_or(false), + managed_identity_resource_id: request + .artifact_signing_managed_identity_resource_id + .clone(), + tenant_id: request.artifact_signing_tenant_id.clone(), + client_id: request.artifact_signing_client_id.clone(), + client_secret: request.artifact_signing_client_secret.clone(), + federated_token_file: request.artifact_signing_federated_token_file.clone(), + credential_type: request + .artifact_signing_credential_type + .as_deref() + .map(parse_artifact_signing_credential_type) + .transpose()?, + exclude_credentials: request.artifact_signing_exclude_credentials.clone(), + }, + ) +} + +#[cfg(feature = "artifact-signing-rest")] +fn parse_artifact_signing_credential_type( + value: &str, +) -> Result { + let normalized = value + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .flat_map(|ch| ch.to_lowercase()) + .collect::(); + match normalized.as_str() { + "" | "default" | "defaultazurecredential" => { + Ok(psign_codesigning_rest::CodesigningCredentialType::Default) } - return Ok(psign_codesigning_rest::CodesigningAuth::ManagedIdentity); - } - if let Some(token) = text_opt(request.artifact_signing_access_token.as_deref()) { - if sp_count != 0 { - bail!("use either Artifact Signing access token or client credentials, not both"); + "managedidentity" | "managedidentitycredential" => { + Ok(psign_codesigning_rest::CodesigningCredentialType::ManagedIdentity) } - return Ok(psign_codesigning_rest::CodesigningAuth::Bearer(token)); - } - if sp_count != 0 && sp_count != 3 { - bail!( - "Artifact Signing client credentials require artifact_signing_tenant_id, artifact_signing_client_id, and artifact_signing_client_secret" - ); - } - if sp_count == 0 { - bail!( - "choose Artifact Signing authentication: managed identity, access token, or tenant/client-id/client-secret" - ); + "accesstoken" | "bearer" => { + Ok(psign_codesigning_rest::CodesigningCredentialType::AccessToken) + } + "clientsecret" | "clientsecretcredential" => { + Ok(psign_codesigning_rest::CodesigningCredentialType::ClientSecret) + } + "workloadidentity" | "workloadidentitycredential" => { + Ok(psign_codesigning_rest::CodesigningCredentialType::WorkloadIdentity) + } + _ => bail!("unsupported Artifact Signing credential type '{value}'"), } - Ok(psign_codesigning_rest::CodesigningAuth::ClientCredentials { - tenant_id: tenant.unwrap(), - client_id: client.unwrap(), - client_secret: secret.unwrap(), - }) } #[cfg(feature = "azure-kv-sign")] @@ -2811,9 +2870,13 @@ mod tests { artifact_signing_profile_name: None, artifact_signing_access_token: None, artifact_signing_managed_identity: None, + artifact_signing_managed_identity_resource_id: None, + artifact_signing_credential_type: None, artifact_signing_tenant_id: None, artifact_signing_client_id: None, artifact_signing_client_secret: None, + artifact_signing_federated_token_file: None, + artifact_signing_exclude_credentials: Vec::new(), } } } diff --git a/crates/psign-sip-digest/src/pkcs7.rs b/crates/psign-sip-digest/src/pkcs7.rs index 2463e45..bb8f35e 100644 --- a/crates/psign-sip-digest/src/pkcs7.rs +++ b/crates/psign-sip-digest/src/pkcs7.rs @@ -10,7 +10,8 @@ //! [`parse_pe_pkcs7_spc_indirect_data_at`] / [`parse_pe_pkcs7_spc_indirect_data`] and [`spc_indirect_data_replace_message_digest`] support **Linux-side digest substitution** before a future **`SignedData`** signer assembles countersignatures / PKCS#9 attributes. [`cms_digest_encapsulated_econtent_bytes`] and [`signer_info_pkcs9_message_digest_octets`] pin **RFC 5652 §5.4** **`eContent`** hashing to PKCS#9 **`messageDigest`** on fixtures (RustCrypto **`cms` SignerInfoBuilder** semantics). [`signer_info_signed_attributes_sequence_der`] yields the **`SET OF Attribute`** octets for §5.4 authenticated-attribute signing; [`signed_attributes_replace_pkcs9_message_digest`] refreshes PKCS#9 **`messageDigest`** after **`encapContentInfo`** changes (**`encryptedDigest`** still requires re-sign). [`signer_info_sha256_digest_over_signed_attrs`] and [`signed_data_rsa_sha256_signer_prehash_digest`] **SHA-256**-hash **of** that **`SET`** (staging digest before PKCS#1 **DigestInfo** / **KV `RS256`** validation). [`signer_info_clone_with_signed_attrs`] / [`signer_info_clone_with_signature_octets`] patch **`SignerInfo`** after remote signing; [`signed_data_replace_signer_info_at`] / [`signed_data_replace_first_signer_info`] splice it back into **`SignedData.signerInfos`**. **`WIN_CERTIFICATE`** embedding remains [`crate::pe_embed`]. use anyhow::{Context as _, Result, anyhow}; -use authenticode::{DigestInfo, SpcAttributeTypeAndOptionalValue, SpcIndirectDataContent}; +pub use authenticode::SpcIndirectDataContent; +use authenticode::{DigestInfo, SpcAttributeTypeAndOptionalValue}; use base64::Engine as _; use cms::builder::{SignedDataBuilder, SignerInfoBuilder}; use cms::cert::CertificateChoices; diff --git a/docs/gap-analysis-signing-platforms.md b/docs/gap-analysis-signing-platforms.md index a852903..9d03e6e 100644 --- a/docs/gap-analysis-signing-platforms.md +++ b/docs/gap-analysis-signing-platforms.md @@ -13,12 +13,12 @@ Legend: **Sign** = produce/embed Authenticode; **WT verify** = `WinVerifyTrust`- | Subject format | Native `signtool` | `psign-tool` | `psign-tool portable` | |----------------|-------------------|--------------------|---------------------| | PE / WinMD | Sign, WT verify | Sign, WT verify, optional `--rust-sip pe` | Digest, inspect, trust-verify-pe, sign-pe (local RSA, Azure Key Vault, or Azure Artifact Signing REST), timestamp-pe-rfc3161 | -| CAB | Sign, WT verify | Same | verify-cab, trust-verify-cab, cab-digest, sign-cab | -| MSI | Sign, WT verify | Same | verify-msi, sign-msi | +| CAB | Sign, WT verify | Same | verify-cab, trust-verify-cab, cab-digest, sign-cab (local RSA or Azure Artifact Signing REST) | +| MSI | Sign, WT verify | Same | verify-msi, sign-msi (local RSA or Azure Artifact Signing REST) | | ESD / WIM | Sign, WT verify | Same | verify-esd | -| MSIX / APPX (cleartext) | Sign, WT verify | Same (+ `--dlib` / `--dmdf`) | verify-msix | +| MSIX / APPX (cleartext) | Sign, WT verify | Same (+ `--dlib` / `--dmdf`) | verify-msix; native-shaped flat `.msix` / `.appx` Artifact Signing REST final signing | | MSIX encrypted | Sign (OS) | Delegates OS | **Rejected** (explicit error) | -| Catalog `.cat` | Sign, WT verify | WT + Rust assists | sign-catalog, verify-catalog, verify-catalog-member, trust-verify-catalog | +| Catalog `.cat` | Sign, WT verify | WT + Rust assists | sign-catalog (local RSA or Azure Artifact Signing REST), verify-catalog, verify-catalog-member, trust-verify-catalog | | PS scripts | Sign, WT verify | Same | verify-script | | WSH `.js`/`.vbs`/`.wsf` | Sign, WT verify | Same | verify-script | | Detached PKCS#7 | Verify | Verify | trust-verify-detached | @@ -27,7 +27,7 @@ Legend: **Sign** = produce/embed Authenticode; **WT verify** = `WinVerifyTrust`- **AzureSignTool** targets the same **embedding path as SignTool** (Windows): typically PE (and same SIP stack as invoked by `SignerSignEx3`). It does **not** define new subject formats—it replaces the CSP with **KV `keys/sign`**. -**Artifact Signing REST** (`:sign` LRO) returns **signature material** for a **hash**; PE/WinMD portable signing now builds CMS, asks the service to sign the CMS authenticated-attributes digest, timestamps, and embeds the PKCS#7 without Microsoft client DLLs. Non-PE remote-sign embedding still requires **Windows `SignerSignEx3` + dlib** or future portable embedders. +**Artifact Signing REST** (`:sign` LRO) returns **signature material** for a **hash**; PE/WinMD, CAB, MSI/MSP, flat MSIX/AppX, and generic catalog portable signing now build CMS, ask the service to sign the CMS authenticated-attributes digest, and embed the PKCS#7 without Microsoft client DLLs. The portable Rust credential resolver supports bearer tokens, client-secret credentials, system- and user-assigned managed identity, workload identity federation, and metadata `ExcludeCredentials` for the non-interactive default chain. PE/WinMD, CAB, MSI/MSP, generic catalog, and flat MSIX/AppX Artifact Signing paths support sign-time RFC3161 timestamping when built with `timestamp-http`. MSIX/AppX bundles/uploads, encrypted packages, and other SIP remote-sign embedding still require **Windows `SignerSignEx3` + dlib** or future portable embedders. ## Expanded signable-surface audit by mode @@ -37,12 +37,12 @@ This inventory starts from the in-tree supported formats, then expands to inbox | Surface | Windows mode coverage | Windows-mode gaps | Portable mode coverage | Portable-mode gaps | |---------|-----------------------|-------------------|------------------------|--------------------| -| **PE / WinMD** (`.exe`, `.dll`, `.sys`, `.ocx`, `.efi`, `.scr`, `.cpl`, `.mui`, `.winmd`, and other PE-by-content subjects) | `sign`, `verify`, `timestamp`, PE `remove`, optional Rust PE digest gate. | Portable-style greenfield CMS is not used; `/ph` page-hash parity and extension corpus coverage need more fixtures. | PE digest, PKCS#7 extraction/inspection, explicit-anchor `trust-verify-pe`, local RSA `sign-pe`, PE RFC3161 token embedding, remote-signature CMS injection helpers, experimental PKCS#7 append helpers. | No top-level native-shaped `--mode portable sign`, WinTrust policy, OS stores, PinRules, or full `/ph` semantics. | -| **CAB** (`.cab`) | Sign/verify through OS SIP. | No first-class CAB remove; parity success fixtures are thinner than PE. | `verify-cab`, `trust-verify-cab`, `cab-digest`, local RSA `sign-cab` for unsigned single-volume CABs, PKCS#7 extraction/prehash. | No CAB signature replacement, multivolume CAB signing, timestamp embed, or WinTrust CAB policy equivalent. | -| **Catalog** (`.cat`) and driver-package catalogs | Catalog verify paths and `catdb`; can Authenticode-sign an existing `.cat`. | No catalog authoring (`MakeCat`/`Inf2Cat`/`New-FileCatalog` equivalent) or full driver-package workflow. | `sign-catalog` for portable generic CTL catalogs, `verify-catalog`, `verify-catalog-member` for explicit file + MakeCat/psign catalog inputs, `trust-verify-catalog`, catalog PKCS#7 consistency, signer prehash. | No `CryptCATAdmin` database search, driver/INF policy, OS catalog stores, catalog-store revocation policy, or MakeCat byte-for-byte output. | -| **MSI family** (`.msi`, `.msp`, `.mst`) | Sign/verify through `MSISIP.DLL`. | Generic SIP remove is not implemented; optional parity corpus depends on external fixtures. | `verify-msi`, local RSA `sign-msi` through the `DigitalSignature` stream, PKCS#7 extraction/prehash. | No timestamp embed, `MsiDigitalSignatureEx` authoring, or installer policy branches such as `DisableSizeVerification` / `DisableLegacyVerification`. | +| **PE / WinMD** (`.exe`, `.dll`, `.sys`, `.ocx`, `.efi`, `.scr`, `.cpl`, `.mui`, `.winmd`, and other PE-by-content subjects) | `sign`, `verify`, `timestamp`, PE `remove`, optional Rust PE digest gate. | Portable-style greenfield CMS is not used; `/ph` page-hash parity and extension corpus coverage need more fixtures. | PE digest, PKCS#7 extraction/inspection, explicit-anchor `trust-verify-pe`, local RSA `sign-pe`, Azure Key Vault and Artifact Signing REST PE signing, native-shaped `--mode portable sign` for PE/WinMD, PE RFC3161 token embedding, remote-signature CMS injection helpers, experimental PKCS#7 append helpers. | No WinTrust policy, OS stores, PinRules, or full `/ph` semantics. | +| **CAB** (`.cab`) | Sign/verify through OS SIP. | No first-class CAB remove; parity success fixtures are thinner than PE. | `verify-cab`, `trust-verify-cab`, `cab-digest`, local RSA or Artifact Signing REST `sign-cab` for unsigned single-volume CABs, native-shaped `--mode portable sign --artifact-signing-*`, PKCS#7 extraction/prehash, RFC3161 timestamp embed. | No CAB signature replacement, multivolume CAB signing, or WinTrust CAB policy equivalent. | +| **Catalog** (`.cat`) and driver-package catalogs | Catalog verify paths and `catdb`; can Authenticode-sign an existing `.cat`. | No catalog authoring (`MakeCat`/`Inf2Cat`/`New-FileCatalog` equivalent) or full driver-package workflow. | `sign-catalog` for portable generic CTL catalogs with local RSA or Artifact Signing REST, RFC3161 timestamp embed, `verify-catalog`, `verify-catalog-member` for explicit file + MakeCat/psign catalog inputs, `trust-verify-catalog`, catalog PKCS#7 consistency, signer prehash. | No native-shaped in-place `.cat` Artifact Signing route, `CryptCATAdmin` database search, driver/INF policy, OS catalog stores, catalog-store revocation policy, or MakeCat byte-for-byte output. | +| **MSI family** (`.msi`, `.msp`, `.mst`) | Sign/verify through `MSISIP.DLL`. | Generic SIP remove is not implemented; optional parity corpus depends on external fixtures. | `verify-msi`, local RSA or Artifact Signing REST `sign-msi` through the `DigitalSignature` stream, native-shaped `--mode portable sign --artifact-signing-*`, PKCS#7 extraction/prehash, RFC3161 timestamp embed. | No `MsiDigitalSignatureEx` authoring or installer policy branches such as `DisableSizeVerification` / `DisableLegacyVerification`. | | **WIM / ESD** (`.wim`, `.esd`) | Sign/verify through `EsdSip.dll`. | Positive parity fixtures are limited; no remove. | `verify-esd`. | No WIM/ESD signing/embed, timestamp embed, or WinTrust policy equivalent. | -| **Cleartext AppX/MSIX** (`.appx`, `.msix`, `.appxbundle`, `.msixbundle`, `.appxupload`, `.msixupload`) | Sign/verify with AppX client data and dlib bridge. | Remaining native parity failures can occur around `SignerSignEx3` AppX glue, publisher binding, sealing, and package constraints. | `verify-msix` digest consistency; `msix-manifest-info` / `msix-set-publisher`; guarded `psign-tool code` prepare execution signs nested PE/package entries, updates `AppxManifest.xml` Publisher from `--publisher-name`, regenerates `AppxBlockMap.xml`, propagates publisher updates into nested packages inside upload/bundle containers, and rejects already-final-signed `AppxSignature.p7x` packages before final AppX SIP signing. | No `AppxSipCreateIndirectData` equivalent, package PKCS#7 embed, timestamp/signing, manifest publisher-vs-signer policy, or full package policy. | +| **Cleartext AppX/MSIX** (`.appx`, `.msix`, `.appxbundle`, `.msixbundle`, `.appxupload`, `.msixupload`) | Sign/verify with AppX client data and dlib bridge. | Remaining native parity failures can occur around `SignerSignEx3` AppX glue, publisher binding, sealing, and package constraints. | `verify-msix` digest consistency; `msix-manifest-info` / `msix-set-publisher`; native-shaped portable Artifact Signing final signing for flat `.appx` / `.msix` packages with `AppxSignature.p7x` / `PKCX` embedding and optional RFC3161 timestamping; guarded `psign-tool code` prepare execution signs nested PE/package entries, updates `AppxManifest.xml` Publisher from `--publisher-name`, regenerates `AppxBlockMap.xml`, propagates publisher updates into nested packages inside upload/bundle containers, and rejects already-final-signed `AppxSignature.p7x` packages before final AppX SIP signing. | Bundle/upload final signing, encrypted packages, manifest publisher-vs-signer policy, and full AppX package policy remain pending. | | **Encrypted AppX/MSIX** (`.eappx`, `.emsix`, `.eappxbundle`, `.emsixbundle`) | Delegates to OS `EappxSip*` / `EappxBundleSip*`. | No in-tree understanding beyond OS delegation and parity fixtures. | Explicitly rejected by `verify-msix`, MSIX metadata helpers, and `psign-tool code` with Windows AppxSip OS-delegation diagnostics. | Encrypted package crypto/header handling is absent; ZIP-only digest logic is insufficient. | | **AppX extension SIP chain** | Delegates to installed `ExtensionsSip*` providers. | No bundled/provider-specific parity coverage; behavior depends on optional third-party SIP DLLs. | Not implemented. | No extension-provider discovery, DLL contract, or portable provider model. | | **Standalone P7X / PKCX** (`.p7x`) | OS `P7xSip*` can participate when registered; real package signatures are produced as `AppxSignature.p7x` inside signed AppX/MSIX packages. | Direct standalone `.p7x` signing is rejected by current SignTool; first-class commands for extracting/interpreting PKCX remain absent. | Raw PKCS#7 inspection/trust primitives may apply after extraction. | No dedicated PKCX/P7X container command or portable PKCX header handling. | @@ -85,10 +85,10 @@ The committed corpus already includes generated unsigned and signed vectors for |------|--------|-----| | **Drop-in Linux replacement for `signtool.exe` sign/verify** | Not supported | Signing and WinTrust-backed verify require Windows CryptAPI/SIP (`SignerSignEx3`, `WinVerifyTrust`). | | **Drop-in Linux replacement for AzureSignTool** | Partial | **`psign-tool portable sign-pe --azure-key-vault-* --timestamp-url ...`** and **`psign-tool --mode portable sign --azure-key-vault-* --timestamp-url ...`** can build timestamped PE Authenticode signatures with Key Vault RSA signing. **`azure-key-vault-sign-digest`** remains available for lower-level **`keys/sign`** workflows. Gaps: non-PE remote-sign embedding still requires Windows mode or future portable signer support. | -| **Drop-in Linux replacement for Artifact Signing (dlib / REST)** | Partial | PE/WinMD is supported through **`psign-tool portable sign-pe --artifact-signing-* --timestamp-url ...`** and **`psign-tool --mode portable sign --dmdf ... --artifact-signing-* --timestamp-url ...`**. The lower-level **`artifact-signing-submit`** helper remains available for digest → JSON workflows. Gaps: MSIX/AppX and other non-PE SIP formats still require Windows dlib mode or future portable embedders. | +| **Drop-in Linux replacement for Artifact Signing (dlib / REST)** | Partial | PE/WinMD is supported through **`psign-tool portable sign-pe --artifact-signing-* --timestamp-url ...`** and **`psign-tool --mode portable sign --dmdf ... --artifact-signing-* --timestamp-url ...`**. CAB and MSI/MSP are supported through scoped portable commands and native-shaped in-place Artifact Signing; generic catalogs are supported through **`portable sign-catalog --artifact-signing-*`**. Native-shaped portable Artifact Signing supports input file lists, skip-signed, continue-on-error, and max parallelism for supported targets. The lower-level **`artifact-signing-submit`** helper remains available for digest → JSON workflows. Gaps: MSIX/AppX, non-PE timestamp mutation, and other SIP formats still require Windows dlib mode or future portable embedders. | | **Linux verify + digest parity for many Authenticode formats** | Supported | **`psign-tool portable`** covers PE, CAB, MSI, ESD/WIM, cleartext MSIX, catalog, scripts; **`trust-verify-*`** adds anchor-based CMS trust (see [`authenticode-trust-stack.md`](authenticode-trust-stack.md)). | | **Maximum Windows-mode Authenticode subject formats** | Windows mode delegates most SIP-registered subjects to OS providers | Remaining gaps are first-class CLI affordances, parity fixtures, generic SIP remove, catalog authoring/member policy, Office/VBA ergonomics, extension SIP coverage, and standalone `.p7x` handling. | -| **Maximum portable-mode Authenticode subject formats** | Portable mode covers digest/trust for PE, CAB, MSI, ESD/WIM, cleartext MSIX, catalogs, scripts, and detached PKCS#7; local signing for PE/CAB/MSI/generic catalogs is explicitly scoped | Portable gaps include MSIX signing/embed, non-PE timestamp mutation, WinTrust/CryptoAPI policy, encrypted MSIX, extension SIPs, Office/VBA, standalone `.p7x`, and package-specific ecosystems. | +| **Maximum portable-mode Authenticode subject formats** | Portable mode covers digest/trust for PE, CAB, MSI, ESD/WIM, cleartext MSIX, catalogs, scripts, and detached PKCS#7; local signing for PE/CAB/MSI/generic catalogs is explicitly scoped; Artifact Signing REST can sign PE/WinMD, CAB, MSI/MSP, and generic catalogs | Portable gaps include MSIX signing/embed, non-PE timestamp mutation, WinTrust/CryptoAPI policy, encrypted MSIX, extension SIPs, Office/VBA, standalone `.p7x`, and package-specific ecosystems. | **Practical Linux path today:** Use **`psign-tool portable`** for **digest computation**, **local signing** of PE/CAB/MSI/generic catalogs, **Key Vault PE signing** (`portable sign-pe` or `--mode portable sign`), **Artifact Signing REST PE signing** (`portable sign-pe --artifact-signing-*` or `--mode portable sign --dmdf ... --artifact-signing-*`), **Key Vault `keys/sign`** on digest files (**`azure-key-vault-sign-digest`** with **`--features azure-kv-sign-portable`**), low-level **`:sign` REST** (**`artifact-signing-submit`** with **`--features artifact-signing-rest`**), **inspect**, and **verify/trust** across supported formats. Broader native-shaped signing and unsupported SIP embedders still require **`psign-tool`** / **`SignerSignEx3`** (or native **`signtool.exe`**). Cookbook: [`linux-signing-pipelines.md`](linux-signing-pipelines.md). diff --git a/docs/linux-signing-pipelines.md b/docs/linux-signing-pipelines.md index 137db45..82c8533 100644 --- a/docs/linux-signing-pipelines.md +++ b/docs/linux-signing-pipelines.md @@ -1,6 +1,6 @@ # Linux signing pipelines (what works today) -**`psign-tool portable`** on Linux/macOS can now sign PE with local RSA/SHA-2 keys, Azure Key Vault RSA signing, or Azure Artifact Signing REST, and can sign unsigned single-volume CAB, MSI/MSP, generic catalogs, and RDP files with local RSA/SHA-2 keys. It still does not provide a broad native-compatible `sign` verb, MSIX signing/embed, OS catalog database policy, or WinTrust policy emulation (see [`rust-sip-gaps.md`](rust-sip-gaps.md)). This page describes **practical portable**, **hybrid**, and **verify-only** flows. +**`psign-tool portable`** on Linux/macOS can now sign PE with local RSA/SHA-2 keys, Azure Key Vault RSA signing, or Azure Artifact Signing REST, and can sign unsigned single-volume CAB, MSI/MSP, generic catalogs, and RDP files with local RSA/SHA-2 keys. CAB, MSI/MSP, generic catalog, and flat MSIX/AppX signing can also use Azure Artifact Signing REST. It still does not provide MSIX/AppX bundle, upload, or encrypted package final signing, OS catalog database policy, or WinTrust policy emulation (see [`rust-sip-gaps.md`](rust-sip-gaps.md)). This page describes **practical portable**, **hybrid**, and **verify-only** flows. For tool-by-tool gaps vs **`signtool.exe`**, AzureSignTool, and Artifact Signing, see [`gap-analysis-signing-platforms.md`](gap-analysis-signing-platforms.md). On Windows, for writable copies of native signing binaries outside protected install paths, see [`writable-signing-binaries.md`](writable-signing-binaries.md). @@ -59,9 +59,9 @@ psign-tool --mode portable sign \ Portable Key Vault PE signing supports SHA-256/SHA-384/SHA-512, optional chain certificates (`--chain-cert` on `portable sign-pe`, `--ac` on `--mode portable sign`), and RFC3161 sign-time timestamping through `--timestamp-url` plus `--timestamp-digest`. `timestamp-pe-rfc3161` remains available as a separate mutation step when you already have a timestamp token or granted response. -## 1.3 Portable PE signing with Azure Artifact Signing REST +## 1.3 Portable signing with Azure Artifact Signing REST -With **`--features artifact-signing-rest`**, PE/WinMD signing can use Azure Artifact Signing as a REST remote signer without Microsoft client DLLs or SignTool: +With **`--features artifact-signing-rest`**, PE/WinMD, CAB, MSI/MSP, flat MSIX/AppX, and generic catalog signing can use Azure Artifact Signing as a REST remote signer without Microsoft client DLLs or SignTool: ```bash psign-tool portable sign-pe ./MyApp.exe \ @@ -85,7 +85,57 @@ psign-tool --mode portable sign \ ./MyApp.exe ``` -This path builds Authenticode CMS locally, sends the CMS authenticated-attributes digest to Artifact Signing `:sign`, embeds the returned RSA signature and signing certificate, then attaches the RFC3161 timestamp before PE embedding. For production, keep timestamping enabled because Artifact Signing profile certificates are short-lived. +This path builds Authenticode CMS locally, sends the CMS authenticated-attributes digest to Artifact Signing `:sign`, embeds the returned RSA signature and signing certificate, then attaches the RFC3161 timestamp before embedding when `timestamp-http` is enabled. For production signatures, keep timestamping enabled because Artifact Signing profile certificates are short-lived. + +CAB, MSI/MSP, and flat MSIX/AppX can also use the native-shaped in-place form: + +```bash +psign-tool --mode portable sign \ + --dmdf ./artifact-signing-metadata.json \ + --artifact-signing-managed-identity \ + --digest sha256 \ + ./installer.msi ./payload.cab ./package.msix +``` + +For output-path control or catalog authoring, use the scoped portable commands: + +```bash +psign-tool portable sign-cab ./payload.cab \ + --artifact-signing-metadata ./artifact-signing-metadata.json \ + --artifact-signing-managed-identity \ + --digest sha256 \ + --output ./payload.signed.cab + +psign-tool portable sign-msi ./installer.msi \ + --artifact-signing-metadata ./artifact-signing-metadata.json \ + --artifact-signing-managed-identity \ + --digest sha256 \ + --output ./installer.signed.msi + +psign-tool portable sign-catalog \ + --artifact-signing-metadata ./artifact-signing-metadata.json \ + --artifact-signing-managed-identity \ + --digest sha256 \ + --output ./files.cat \ + ./file1.exe ./file2.txt +``` + +Non-PE sign-time timestamp mutation is still not a general `SignerTimeStampEx3` replacement for every SIP target, but CAB/MSI/catalog and flat MSIX/AppX Artifact Signing persist RFC3161 tokens in their generated PKCS#7 when built with `timestamp-http`. + +Native-shaped portable Artifact Signing batches can use `--input-file-list`, `--skip-signed`, `--continue-on-error`, and `--max-degree-of-parallelism`: + +```bash +psign-tool --mode portable sign \ + --dmdf ./artifact-signing-metadata.json \ + --artifact-signing-managed-identity \ + --digest sha256 \ + --input-file-list ./files-to-sign.txt \ + --skip-signed \ + --continue-on-error \ + --max-degree-of-parallelism 4 +``` + +The file list accepts one path or glob per line; blank lines and `#` comments are ignored. Skip detection currently covers PE/WinMD certificate tables, CAB signatures, MSI/MSP `DigitalSignature` streams, and flat MSIX/AppX `AppxSignature.p7x` packages. ## 1.4 Package-native helper workflows @@ -184,10 +234,12 @@ Build **`psign-tool portable`** with **`--features artifact-signing-rest`**. For psign-tool portable artifact-signing-submit \ --region REGION --account-name ACCOUNT --profile-name PROFILE \ --digest-file digest.bin --signature-algorithm RS256 \ - --managed-identity # or --access-token / tenant + client-id + client-secret + --managed-identity # or --access-token / client-secret / workload identity / default chain ``` -3. **Embed** PKCS#7 / complete Authenticode: PE/WinMD is now handled by `portable sign-pe --artifact-signing-*`; non-PE remote-sign embedding still requires Windows mode or future portable remote-signer support. + The portable Rust credential resolver supports explicit bearer tokens, system- or user-assigned managed identity, client-secret service principals, workload identity federation, and a DefaultAzureCredential-like non-interactive chain from `AZURE_*` environment variables. Metadata `ExcludeCredentials` entries are honored by that default chain. + +3. **Embed** PKCS#7 / complete Authenticode: PE/WinMD, CAB, MSI/MSP, and generic catalog signing are now handled by `portable sign-* --artifact-signing-*`; native-shaped `--mode portable sign --artifact-signing-*` supports PE/WinMD, CAB, and MSI/MSP in place. Add `--timestamp-url ... --timestamp-digest sha256` to timestamp the generated Authenticode CMS where the `timestamp-http` feature is enabled. Optional debug: **`SIGNTOOL_PORTABLE_DEBUG=1`**. diff --git a/docs/migration-artifact-signing.md b/docs/migration-artifact-signing.md index 96a6388..e243ac4 100644 --- a/docs/migration-artifact-signing.md +++ b/docs/migration-artifact-signing.md @@ -4,7 +4,7 @@ Microsoft **Artifact Signing** (often called **Trusted Signing**) integrates wit **psign-tool** uses the same Win32 bridge as SignTool: **`SignerSignEx3`** with **`SIGNER_DIGEST_SIGN_INFO`** pointing at the DLL exports (this repo prefers **`AuthenticodeDigestSignExWithFileHandle`** when present, matching Microsoft’s Azure dlib). -**psign-tool portable** cannot load the mixed-mode/.NET dlib or call **`SignerSignEx3`**. For PE/WinMD, it can now avoid Microsoft client-side signing tools entirely by building Authenticode CMS locally, asking Artifact Signing REST to sign the CMS authenticated-attributes digest, optionally adding an RFC3161 timestamp, and embedding the PKCS#7 as a PE `WIN_CERTIFICATE`. Other SIP formats still use Windows mode or the dlib bridge until their portable embedders are implemented. +**psign-tool portable** cannot load the mixed-mode/.NET dlib or call **`SignerSignEx3`**. For PE/WinMD, CAB, MSI/MSP, flat MSIX/AppX packages, and generic catalogs, it can now avoid Microsoft client-side signing tools entirely by building CMS locally, asking Artifact Signing REST to sign the CMS authenticated-attributes digest, and embedding the returned PKCS#7. Other SIP formats, MSIX/AppX bundles/uploads, and encrypted packages still use Windows mode or the dlib bridge until their portable embedders are implemented. ### Azure Code Signing **REST** hash signing @@ -40,7 +40,7 @@ cargo build -p psign-digest-cli --features artifact-signing-rest --locked Optional debug logs: **`SIGNTOOL_PORTABLE_DEBUG=1`**. -## Pure REST PE/WinMD signing (no Microsoft client tools) +## Pure REST portable signing (no Microsoft client tools) For PE/WinMD, prefer the first-class portable signer instead of manually staging a digest: @@ -66,9 +66,51 @@ psign-tool --mode portable sign \ ./MyApp.exe ``` -Authentication choices are mutually exclusive: use **`--artifact-signing-managed-identity`**, **`--artifact-signing-access-token`**, or the service-principal trio **`--artifact-signing-tenant-id`**, **`--artifact-signing-client-id`**, and **`--artifact-signing-client-secret`**. Without metadata, pass **`--artifact-signing-endpoint`** or **`--artifact-signing-region`** plus **`--artifact-signing-account-name`** and **`--artifact-signing-profile-name`**. +Authentication choices are mutually exclusive when explicit: use **`--artifact-signing-access-token`**, **`--artifact-signing-managed-identity`** (optionally with **`--artifact-signing-client-id`** or **`--artifact-signing-managed-identity-resource-id`** for user-assigned identities), the service-principal trio **`--artifact-signing-tenant-id`**, **`--artifact-signing-client-id`**, and **`--artifact-signing-client-secret`**, or workload identity with **`--artifact-signing-credential-type workload-identity`** plus tenant/client/token-file inputs or the standard **`AZURE_TENANT_ID`**, **`AZURE_CLIENT_ID`**, and **`AZURE_FEDERATED_TOKEN_FILE`** environment variables. If no explicit credential is supplied, the in-tree Rust default chain tries environment client-secret credentials, workload identity, then managed identity while honoring metadata **`ExcludeCredentials`**. Without metadata, pass **`--artifact-signing-endpoint`** or **`--artifact-signing-region`** plus **`--artifact-signing-account-name`** and **`--artifact-signing-profile-name`**. -Artifact Signing certificates are short-lived; include **`--timestamp-url http://timestamp.acs.microsoft.com/ --timestamp-digest sha256`** for production signatures. +Artifact Signing certificates are short-lived; include **`--timestamp-url http://timestamp.acs.microsoft.com/ --timestamp-digest sha256`** for production signatures. Portable PE/WinMD, CAB, MSI/MSP, generic catalog, and flat MSIX/AppX Artifact Signing paths attach RFC3161 tokens to the generated Authenticode PKCS#7 when the `timestamp-http` feature is enabled. + +CAB, MSI/MSP, and generic catalogs can use the same Artifact Signing profile through scoped portable commands: + +```bash +psign-tool portable sign-cab ./setup.cab \ + --artifact-signing-metadata ./artifact-signing-metadata.json \ + --artifact-signing-managed-identity \ + --timestamp-url http://timestamp.acs.microsoft.com/ \ + --timestamp-digest sha256 \ + --digest sha256 \ + --output ./setup.signed.cab + +psign-tool portable sign-msi ./installer.msi \ + --artifact-signing-metadata ./artifact-signing-metadata.json \ + --artifact-signing-managed-identity \ + --digest sha256 \ + --output ./installer.signed.msi + +psign-tool portable sign-catalog \ + --artifact-signing-metadata ./artifact-signing-metadata.json \ + --artifact-signing-managed-identity \ + --digest sha256 \ + --output ./files.cat \ + ./file1.exe ./file2.txt +``` + +The native-shaped in-place portable `sign` route also supports CAB, MSI/MSP, and flat `.msix` / `.appx` packages with Artifact Signing options. Catalog authoring still uses `portable sign-catalog` because a `.cat` target alone does not describe the member list to author. MSIX/AppX bundle, upload, and encrypted containers remain explicitly unsupported in portable final signing. + +For native-shaped batches, the portable Artifact Signing route accepts the AzureSignTool-style convenience flags: + +```bash +psign-tool --mode portable sign \ + --dmdf ./artifact-signing-metadata.json \ + --artifact-signing-managed-identity \ + --digest sha256 \ + --input-file-list ./files-to-sign.txt \ + --skip-signed \ + --continue-on-error \ + --max-degree-of-parallelism 4 +``` + +`--input-file-list` accepts one path or glob per line; blank lines and `#` comments are ignored. `--skip-signed` skips PE/WinMD, CAB, MSI/MSP, and flat MSIX/AppX files that already contain embedded signature material. `--continue-on-error` preserves per-file failure diagnostics and returns a non-zero batch exit code when any target fails. ## Flag mapping (Microsoft sample → psign-tool) @@ -115,7 +157,7 @@ Microsoft recommends **`http://timestamp.acs.microsoft.com/`** with **`SHA256`** ### Metadata JSON (`--dmdf`) -Follow Microsoft’s documented shape: regional **`Endpoint`**, **`CodeSigningAccountName`**, **`CertificateProfileName`**, and optionally **`ExcludeCredentials`** (array of credential type names to exclude from the Azure credential chain). Keep **`Endpoint`** aligned with your Artifact Signing region. +Follow Microsoft’s documented shape: regional **`Endpoint`**, **`CodeSigningAccountName`**, **`CertificateProfileName`**, and optionally **`ExcludeCredentials`** (array of credential type names to exclude from the Rust default chain, such as **`EnvironmentCredential`**, **`WorkloadIdentityCredential`**, or **`ManagedIdentityCredential`**). Keep **`Endpoint`** aligned with your Artifact Signing region. Validate checked-in templates **without signing** using portable **`artifact-signing-metadata-check`**: @@ -226,6 +268,9 @@ Authentication (**one** path): |----------|---------| | `PSIGN_ARTIFACT_SIGNING_REST_ACCESS_TOKEN` | Bearer token for **`https://codesigning.azure.net/.default`** | | `PSIGN_ARTIFACT_SIGNING_REST_MANAGED_IDENTITY` | Set to **`1`** / **`true`** / **`yes`** for IMDS (VMs/containers) | -| `PSIGN_ARTIFACT_SIGNING_REST_TENANT_ID` | With client credentials | -| `PSIGN_ARTIFACT_SIGNING_REST_CLIENT_ID` | With client credentials | +| `PSIGN_ARTIFACT_SIGNING_REST_TENANT_ID` | With client credentials or workload identity | +| `PSIGN_ARTIFACT_SIGNING_REST_CLIENT_ID` | With client credentials, workload identity, or user-assigned managed identity | | `PSIGN_ARTIFACT_SIGNING_REST_CLIENT_SECRET` | With client credentials | +| `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` | Environment credential used by the Rust default chain | +| `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_FEDERATED_TOKEN_FILE` | Workload identity credential used by the Rust default chain | +| `AZURE_MANAGED_IDENTITY_CLIENT_ID` | User-assigned managed identity client ID used by the Rust default chain | diff --git a/src/cli.rs b/src/cli.rs index 1b1fe9e..38467a8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -215,6 +215,9 @@ pub struct CodeArgs { pub artifact_signing_access_token: Option, #[arg(long = "artifact-signing-managed-identity")] pub artifact_signing_managed_identity: bool, + /// Managed identity resource ID for Artifact Signing user-assigned identity auth. + #[arg(long = "artifact-signing-managed-identity-resource-id")] + pub artifact_signing_managed_identity_resource_id: Option, /// Azure.Identity-style credential selector for Artifact Signing. #[arg(long = "artifact-signing-credential-type", value_enum)] pub artifact_signing_credential_type: Option, @@ -224,6 +227,9 @@ pub struct CodeArgs { pub artifact_signing_client_id: Option, #[arg(long = "artifact-signing-client-secret")] pub artifact_signing_client_secret: Option, + /// Federated token file for workload identity Artifact Signing auth. + #[arg(long = "artifact-signing-federated-token-file")] + pub artifact_signing_federated_token_file: Option, #[arg(long = "artifact-signing-authority")] pub artifact_signing_authority: Option, /// Override Artifact Signing data-plane origin for deterministic local tests. @@ -429,7 +435,7 @@ pub enum AzureCredentialType { AccessToken, /// Use tenant/client-id/client-secret service-principal credentials. ClientSecret, - /// Reserve the Azure.Identity workload identity shape; execution support is not wired yet. + /// Use workload identity federation from CLI inputs or AZURE_* environment variables. WorkloadIdentity, } @@ -507,12 +513,18 @@ pub struct ArtifactSigningSubmitArgs { #[arg(long)] pub managed_identity: bool, #[arg(long)] + pub managed_identity_resource_id: Option, + #[arg(long, value_enum)] + pub credential_type: Option, + #[arg(long)] pub tenant_id: Option, #[arg(long)] pub client_id: Option, #[arg(long)] pub client_secret: Option, #[arg(long)] + pub federated_token_file: Option, + #[arg(long)] pub authority: Option, /// Override data-plane origin for deterministic local tests. #[arg(long, hide = true)] @@ -900,6 +912,9 @@ pub struct SignArgs { pub artifact_signing_access_token: Option, #[arg(long = "artifact-signing-managed-identity")] pub artifact_signing_managed_identity: bool, + /// Managed identity resource ID for Artifact Signing user-assigned identity auth. + #[arg(long = "artifact-signing-managed-identity-resource-id")] + pub artifact_signing_managed_identity_resource_id: Option, /// Azure.Identity-style credential selector for Artifact Signing. #[arg(long = "artifact-signing-credential-type", value_enum)] pub artifact_signing_credential_type: Option, @@ -909,6 +924,9 @@ pub struct SignArgs { pub artifact_signing_client_id: Option, #[arg(long = "artifact-signing-client-secret")] pub artifact_signing_client_secret: Option, + /// Federated token file for workload identity Artifact Signing auth. + #[arg(long = "artifact-signing-federated-token-file")] + pub artifact_signing_federated_token_file: Option, #[arg(long = "artifact-signing-authority")] pub artifact_signing_authority: Option, /// Override Artifact Signing data-plane origin for deterministic local tests. @@ -930,7 +948,7 @@ pub struct SignArgs { #[arg(long = "exit-codes", value_enum)] pub exit_codes: Option, /// File(s) to sign (native trailing ``). - #[arg(required = true)] + #[arg(required_unless_present = "sign_input_file_list")] pub files: Vec, } diff --git a/src/code.rs b/src/code.rs index e7214d5..f27f9a1 100644 --- a/src/code.rs +++ b/src/code.rs @@ -10,12 +10,12 @@ use psign_azure_kv_rest::{ }; #[cfg(feature = "artifact-signing-rest")] use psign_codesigning_rest::{ - CodesigningAuth, CodesigningSubmitParams, DEFAULT_API_VERSION, - submit_codesign_hash_signature_blocking, + CodesigningAuth, CodesigningAuthInput, CodesigningCredentialType, CodesigningSubmitParams, + DEFAULT_API_VERSION, resolve_codesigning_auth, submit_codesign_hash_signature_blocking, }; use psign_opc_sign::{nuget, opc, vsix}; use psign_sip_digest::timestamp::{build_timestamp_request_bytes, parse_time_stamp_resp_der}; -use psign_sip_digest::{pe_digest, pe_embed, pkcs7, rdp}; +use psign_sip_digest::{pe_digest, pe_embed, pkcs7, rdp, verify_pe}; use rsa::signature::{SignatureEncoding as _, Signer as _, hazmat::PrehashSigner as _}; use serde::{Deserialize, Serialize}; use sha2::Digest as _; @@ -235,11 +235,16 @@ fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result display_path(&output) )) } else { - let signed = - sign_pe_bytes(&input_bytes, &node.path, &signer, signing_digest, false) - .with_context(|| { - format!("sign Authenticode payload {}", input.display()) - })?; + let signed = sign_pe_bytes( + &input_bytes, + &node.path, + &signer, + signing_digest, + false, + args.timestamp_url.as_deref(), + args.timestamp_digest, + ) + .with_context(|| format!("sign Authenticode payload {}", input.display()))?; std::fs::write(&output, signed).with_context(|| { format!("write signed Authenticode payload {}", output.display()) })?; @@ -503,11 +508,15 @@ fn execute_code_plan(args: &CodeArgs, plan: &CodePlan) -> Result ensure_parent_dir(&output)?; let input_bytes = std::fs::read(&input).with_context(|| format!("read {}", input.display()))?; - let signed = - sign_clickonce_deploy_bytes(&input_bytes, &node.path, &signer, signing_digest) - .with_context(|| { - format!("sign ClickOnce deploy payload {}", input.display()) - })?; + let signed = sign_clickonce_deploy_bytes( + &input_bytes, + &node.path, + &signer, + signing_digest, + args.timestamp_url.as_deref(), + args.timestamp_digest, + ) + .with_context(|| format!("sign ClickOnce deploy payload {}", input.display()))?; std::fs::write(&output, signed).with_context(|| { format!("write signed ClickOnce deploy payload {}", output.display()) })?; @@ -966,12 +975,27 @@ fn sign_nested_package_entries( } CodeFormat::Deploy => entry_updates.push(ZipEntryUpdate { name, - bytes: sign_clickonce_deploy_bytes(&bytes, &nested_label, signer, signing_digest)?, + bytes: sign_clickonce_deploy_bytes( + &bytes, + &nested_label, + signer, + signing_digest, + timestamp_url, + timestamp_digest, + )?, compression, }), CodeFormat::Pe | CodeFormat::Winmd => entry_updates.push(ZipEntryUpdate { name, - bytes: sign_pe_bytes(&bytes, &nested_label, signer, signing_digest, skip_signed)?, + bytes: sign_pe_bytes( + &bytes, + &nested_label, + signer, + signing_digest, + skip_signed, + timestamp_url, + timestamp_digest, + )?, compression, }), CodeFormat::Msix @@ -1036,6 +1060,8 @@ fn sign_clickonce_deploy_bytes( label: &str, signer: &CodeSigner, signing_digest: pkcs7::AuthenticodeSigningDigest, + timestamp_url: Option<&str>, + timestamp_digest: Option, ) -> Result> { if signing_digest != pkcs7::AuthenticodeSigningDigest::Sha256 { return Err(anyhow!( @@ -1054,8 +1080,16 @@ fn sign_clickonce_deploy_bytes( "ClickOnce .deploy payload {label} maps to unsupported content name {content_name}" )); } - sign_pe_bytes(input_bytes, label, signer, signing_digest, false) - .with_context(|| format!("sign ClickOnce .deploy PE payload {label}")) + sign_pe_bytes( + input_bytes, + label, + signer, + signing_digest, + false, + timestamp_url, + timestamp_digest, + ) + .with_context(|| format!("sign ClickOnce .deploy PE payload {label}")) } fn clickonce_deploy_content_name(label: &str) -> Option { @@ -1080,6 +1114,8 @@ fn sign_pe_bytes( signer: &CodeSigner, signing_digest: pkcs7::AuthenticodeSigningDigest, skip_signed: bool, + timestamp_url: Option<&str>, + timestamp_digest: Option, ) -> Result> { if skip_signed && pe_has_signature(input_bytes) { return Ok(input_bytes.to_vec()); @@ -1087,9 +1123,67 @@ fn sign_pe_bytes( if signing_digest != pkcs7::AuthenticodeSigningDigest::Sha256 { return Err(anyhow!("PE/WinMD signing currently supports only SHA-256")); } - signer + let signed = signer .sign_pe_bytes(input_bytes, signing_digest) - .with_context(|| format!("sign PE/WinMD payload {label}")) + .with_context(|| format!("sign PE/WinMD payload {label}"))?; + timestamp_pe_if_requested(&signed, timestamp_url, timestamp_digest) + .with_context(|| format!("timestamp PE/WinMD payload {label}")) +} + +fn timestamp_pe_if_requested( + pe_image: &[u8], + timestamp_url: Option<&str>, + timestamp_digest: Option, +) -> Result> { + match (timestamp_url, timestamp_digest) { + (Some(url), Some(digest)) => timestamp_pe_rfc3161(pe_image, url, digest), + (Some(_), None) => Err(anyhow!( + "`psign-tool code` requires --timestamp-digest with --timestamp-url" + )), + (None, Some(_)) => Err(anyhow!( + "`psign-tool code` requires --timestamp-url with --timestamp-digest" + )), + (None, None) => Ok(pe_image.to_vec()), + } +} + +#[cfg(feature = "timestamp-http")] +fn timestamp_pe_rfc3161( + pe_image: &[u8], + timestamp_url: &str, + timestamp_digest: DigestAlgorithm, +) -> Result> { + let pkcs7_count = verify_pe::pe_pkcs7_signed_data_entry_count(pe_image) + .context("inspect PE/WinMD Authenticode PKCS#7 rows")?; + let pkcs7_index = pkcs7_count + .checked_sub(1) + .ok_or_else(|| anyhow!("PE/WinMD has no PKCS#7 Authenticode row to timestamp"))?; + let pkcs7_der = verify_pe::pe_nth_pkcs7_signed_data_der(pe_image, pkcs7_index) + .with_context(|| format!("extract PE/WinMD PKCS#7 row {pkcs7_index}"))?; + let stamped_pkcs7 = timestamp_pkcs7_der_rfc3161( + &pkcs7_der, + timestamp_url, + timestamp_digest, + Rfc3161TimestampAttribute::MicrosoftAuthenticode, + ) + .with_context(|| format!("timestamp PE/WinMD PKCS#7 row {pkcs7_index}"))?; + pe_embed::pe_replace_authenticode_pkcs7_certificate_at( + pe_image.to_vec(), + pkcs7_index, + &stamped_pkcs7, + ) + .with_context(|| format!("replace PE/WinMD PKCS#7 row {pkcs7_index}")) +} + +#[cfg(not(feature = "timestamp-http"))] +fn timestamp_pe_rfc3161( + _pe_image: &[u8], + _timestamp_url: &str, + _timestamp_digest: DigestAlgorithm, +) -> Result> { + Err(anyhow!( + "`psign-tool code` RFC3161 timestamping requires the timestamp-http feature" + )) } #[allow(clippy::too_many_arguments)] @@ -1735,9 +1829,12 @@ struct CodeArtifactSigningSigner { correlation_id: Option, access_token: Option, managed_identity: bool, + managed_identity_resource_id: Option, + credential_type: Option, tenant_id: Option, client_id: Option, client_secret: Option, + federated_token_file: Option, authority: Option, endpoint_base_url: Option, } @@ -2075,51 +2172,44 @@ impl CodeArtifactSigningSigner { .and_then(|m| text_opt(m.CorrelationId.as_deref())) }), authority: text_opt(self.authority.as_deref()), - auth: self.auth()?, + auth: self.auth( + metadata + .as_ref() + .and_then(|m| m.ExcludeCredentials.clone()) + .unwrap_or_default(), + )?, endpoint_base_url: endpoint, }) } - fn auth(&self) -> Result { - let has_token = text_opt(self.access_token.as_deref()).is_some(); - let tenant = text_opt(self.tenant_id.as_deref()); - let client = text_opt(self.client_id.as_deref()); - let secret = text_opt(self.client_secret.as_deref()); - let client_parts = tenant.is_some() as u8 + client.is_some() as u8 + secret.is_some() as u8; - if self.managed_identity { - if has_token || client_parts != 0 { - return Err(anyhow!( - "use either Artifact Signing managed identity, access token, or client credentials, not multiple" - )); - } - return Ok(CodesigningAuth::ManagedIdentity); - } - if let Some(token) = text_opt(self.access_token.as_deref()) { - if client_parts != 0 { - return Err(anyhow!( - "use either Artifact Signing access token or client credentials, not both" - )); - } - return Ok(CodesigningAuth::Bearer(token)); - } - if client_parts != 0 && client_parts != 3 { - return Err(anyhow!( - "Artifact Signing client credentials require all of tenant-id, client-id, and client-secret" - )); - } - if client_parts == 0 { - return Err(anyhow!( - "choose Artifact Signing authentication: managed identity, access token, or tenant/client-id/client-secret" - )); - } - Ok(CodesigningAuth::ClientCredentials { - tenant_id: tenant.unwrap(), - client_id: client.unwrap(), - client_secret: secret.unwrap(), + fn auth(&self, exclude_credentials: Vec) -> Result { + resolve_codesigning_auth(&CodesigningAuthInput { + access_token: self.access_token.clone(), + managed_identity: self.managed_identity, + managed_identity_resource_id: self.managed_identity_resource_id.clone(), + tenant_id: self.tenant_id.clone(), + client_id: self.client_id.clone(), + client_secret: self.client_secret.clone(), + federated_token_file: self.federated_token_file.clone(), + credential_type: codesigning_credential_type(self.credential_type), + exclude_credentials, }) } } +#[cfg(feature = "artifact-signing-rest")] +fn codesigning_credential_type( + value: Option, +) -> Option { + value.map(|value| match value { + AzureCredentialType::Default => CodesigningCredentialType::Default, + AzureCredentialType::ManagedIdentity => CodesigningCredentialType::ManagedIdentity, + AzureCredentialType::AccessToken => CodesigningCredentialType::AccessToken, + AzureCredentialType::ClientSecret => CodesigningCredentialType::ClientSecret, + AzureCredentialType::WorkloadIdentity => CodesigningCredentialType::WorkloadIdentity, + }) +} + fn resolve_code_signer(args: &CodeArgs) -> Result { let local_files = args.cert.is_some() || args.key.is_some(); let pfx = args.pfx.is_some(); @@ -2285,14 +2375,6 @@ fn resolve_code_signer_azure_key_vault(_args: &CodeArgs) -> Result { #[cfg(feature = "artifact-signing-rest")] fn resolve_code_signer_artifact_signing(args: &CodeArgs) -> Result { - if matches!( - args.artifact_signing_credential_type, - Some(AzureCredentialType::WorkloadIdentity) - ) { - return Err(anyhow!( - "`psign-tool code` Artifact Signing execution does not support workload identity yet" - )); - } Ok(CodeSigner { backend: CodeSignerBackend::ArtifactSigning(Box::new(CodeArtifactSigningSigner { metadata: args.artifact_signing_metadata.clone(), @@ -2309,9 +2391,14 @@ fn resolve_code_signer_artifact_signing(args: &CodeArgs) -> Result { args.artifact_signing_credential_type, Some(AzureCredentialType::ManagedIdentity) ), + managed_identity_resource_id: args + .artifact_signing_managed_identity_resource_id + .clone(), + credential_type: args.artifact_signing_credential_type, tenant_id: args.artifact_signing_tenant_id.clone(), client_id: args.artifact_signing_client_id.clone(), client_secret: args.artifact_signing_client_secret.clone(), + federated_token_file: args.artifact_signing_federated_token_file.clone(), authority: args.artifact_signing_authority.clone(), endpoint_base_url: args.artifact_signing_endpoint_base_url.clone(), })), @@ -2349,10 +2436,16 @@ fn artifact_signing_requested(args: &CodeArgs) -> bool { || text_opt(args.artifact_signing_correlation_id.as_deref()).is_some() || text_opt(args.artifact_signing_access_token.as_deref()).is_some() || args.artifact_signing_managed_identity + || text_opt( + args.artifact_signing_managed_identity_resource_id + .as_deref(), + ) + .is_some() || args.artifact_signing_credential_type.is_some() || text_opt(args.artifact_signing_tenant_id.as_deref()).is_some() || text_opt(args.artifact_signing_client_id.as_deref()).is_some() || text_opt(args.artifact_signing_client_secret.as_deref()).is_some() + || text_opt(args.artifact_signing_federated_token_file.as_deref()).is_some() || text_opt(args.artifact_signing_authority.as_deref()).is_some() || text_opt(args.artifact_signing_endpoint_base_url.as_deref()).is_some() } diff --git a/src/portable_sign.rs b/src/portable_sign.rs index bff2f64..976668a 100644 --- a/src/portable_sign.rs +++ b/src/portable_sign.rs @@ -1,9 +1,30 @@ use crate::CommandOutput; -use crate::cli::{AzureCredentialType, DigestAlgorithm, GlobalOpts, SignArgs}; +use crate::cli::{AzureCredentialType, DigestAlgorithm, GlobalOpts, SignArgs, SignExitCodes}; +use crate::{AZURE_SIGN_EXIT_ALL_FAILED, AZURE_SIGN_EXIT_PARTIAL_SUCCESS}; use anyhow::{Context, Result, anyhow}; +use clap::ValueEnum; +use glob::glob; +use rayon::ThreadPoolBuilder; +use rayon::prelude::*; +#[cfg(feature = "artifact-signing-rest")] +use serde::Deserialize; +use std::collections::HashSet; use std::ffi::OsString; use std::path::{Path, PathBuf}; +#[cfg(feature = "artifact-signing-rest")] +#[derive(Debug, Deserialize)] +#[allow(non_snake_case, dead_code)] +struct ArtifactSigningMetadataDoc { + Endpoint: String, + CodeSigningAccountName: String, + CertificateProfileName: String, + #[serde(default)] + CorrelationId: Option, + #[serde(default)] + ExcludeCredentials: Option>, +} + pub fn sign_file(args: &SignArgs, _global: &GlobalOpts) -> Result { if artifact_signing_requested(args) && azure_key_vault_requested(args) { return Err(anyhow!( @@ -53,28 +74,80 @@ pub fn sign_file(args: &SignArgs, _global: &GlobalOpts) -> Result fn sign_file_artifact_signing(args: &SignArgs) -> Result { validate_artifact_signing_supported_options(args)?; - if args.files.is_empty() { + let targets = expand_sign_targets(args)?; + if targets.is_empty() { return Err(anyhow!( "portable Artifact Signing sign requires at least one file" )); } + let exit_style = resolved_sign_exit_codes(args); + let parallel = args.max_degree_parallelism != Some(1) && targets.len() > 1; + let threads = args + .max_degree_parallelism + .unwrap_or_else(rayon::current_num_threads); + + struct Row { + idx: usize, + result: Result, + } + + let rows: Vec = if parallel { + let pool = ThreadPoolBuilder::new() + .num_threads(threads.max(1)) + .build() + .map_err(|e| anyhow!("thread pool: {e}"))?; + pool.install(|| { + targets + .par_iter() + .enumerate() + .map(|(idx, target)| Row { + idx, + result: try_sign_one_artifact_signing(target, args), + }) + .collect() + }) + } else { + targets + .iter() + .enumerate() + .map(|(idx, target)| Row { + idx, + result: try_sign_one_artifact_signing(target, args), + }) + .collect() + }; + + let mut ordered = rows; + ordered.sort_by_key(|r| r.idx); + let mut combined = String::new(); - for (idx, target) in args.files.iter().enumerate() { - if idx > 0 { + let mut successes: usize = 0; + let mut failures: usize = 0; + for (n, row) in ordered.into_iter().enumerate() { + if n > 0 { combined.push('\n'); } - sign_one_target_artifact_signing(target, args) - .with_context(|| format!("portable Artifact Signing sign '{}'", target.display()))?; - combined.push_str(&format!( - "Signed: {}\nartifact_signing_profile={}\n", - target.display(), - args.artifact_signing_profile_name - .as_deref() - .unwrap_or("") - )); + let target_display = targets[row.idx].display().to_string(); + match row.result { + Ok(block) => { + successes += 1; + combined.push_str(&block); + } + Err(e) => { + failures += 1; + if args.continue_on_error { + combined.push_str(&format!("Failed: {target_display}: {e:#}\n")); + } else { + return Err(e); + } + } + } } - Ok(CommandOutput::with_exit(combined, success_exit_code(args))) + Ok(CommandOutput::with_exit( + combined, + batch_exit_code(exit_style, successes, failures), + )) } fn sign_file_azure_key_vault(args: &SignArgs) -> Result { @@ -199,16 +272,99 @@ fn validate_supported_options(args: &SignArgs) -> Result<()> { )?; reject_string_option("--azure-authority", &args.azure_authority)?; reject_artifact_signing_options(args)?; - reject_path_option("--input-file-list", &args.sign_input_file_list)?; - reject_bool_option("--continue-on-error", args.continue_on_error)?; - reject_bool_option("--skip-signed", args.skip_signed)?; - reject_option( - "--max-degree-of-parallelism", - args.max_degree_parallelism.is_some(), - )?; + if args.max_degree_parallelism == Some(0) { + return Err(anyhow!( + "portable Artifact Signing sign requires --max-degree-of-parallelism to be at least 1" + )); + } Ok(()) } +fn expand_glob_pattern( + pattern: &str, + out: &mut Vec, + seen: &mut HashSet, +) -> Result<()> { + let pattern = pattern.trim(); + if pattern.is_empty() { + return Ok(()); + } + if pattern.contains('*') || pattern.contains('?') { + for entry in glob(pattern).map_err(|e| anyhow!("{e}"))? { + let p = entry.map_err(|e| anyhow!("{e}"))?; + if seen.insert(p.clone()) { + out.push(p); + } + } + } else { + let p = PathBuf::from(pattern); + if seen.insert(p.clone()) { + out.push(p); + } + } + Ok(()) +} + +fn expand_sign_targets(args: &SignArgs) -> Result> { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + if let Some(ifl) = &args.sign_input_file_list { + let txt = std::fs::read_to_string(ifl) + .with_context(|| format!("read --input-file-list {}", ifl.display()))?; + for line in txt.lines() { + let t = line.trim(); + if t.is_empty() || t.starts_with('#') { + continue; + } + expand_glob_pattern(t, &mut out, &mut seen)?; + } + } + for p in &args.files { + expand_glob_pattern(&p.to_string_lossy(), &mut out, &mut seen)?; + } + Ok(out) +} + +fn try_sign_one_artifact_signing(target: &Path, args: &SignArgs) -> Result { + if args.skip_signed && target_appears_signed(target) { + return Ok(format!("Skipped (already signed): {}\n", target.display())); + } + sign_one_target_artifact_signing(target, args) + .with_context(|| format!("portable Artifact Signing sign '{}'", target.display()))?; + Ok(format!( + "Signed: {}\nartifact_signing_profile={}\n", + target.display(), + args.artifact_signing_profile_name + .as_deref() + .unwrap_or("") + )) +} + +fn target_appears_signed(target: &Path) -> bool { + let ext = target + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + let Ok(bytes) = std::fs::read(target) else { + return false; + }; + match ext.as_str() { + "exe" | "dll" | "sys" | "ocx" | "efi" | "winmd" => { + psign_sip_digest::verify_pe::pe_pkcs7_signed_data_entry_count(&bytes) + .is_ok_and(|count| count > 0) + } + "cab" => psign_sip_digest::cab_digest::cab_signature_pkcs7_der(&bytes).is_ok(), + "msi" | "msp" => { + psign_sip_digest::msi_digest::msi_digital_signature_pkcs7_der(&bytes).is_ok() + } + "appx" | "msix" => { + psign_sip_digest::msix_digest::verify_msix_digest_consistency(target).is_ok() + } + _ => false, + } +} + fn validate_azure_key_vault_supported_options(args: &SignArgs) -> Result<()> { match args.digest { DigestAlgorithm::Sha256 | DigestAlgorithm::Sha384 | DigestAlgorithm::Sha512 => {} @@ -333,10 +489,6 @@ fn validate_artifact_signing_supported_options(args: &SignArgs) -> Result<()> { "use either --artifact-signing-metadata or --dmdf as Artifact Signing metadata, not both" )); } - reject_workload_identity( - "--artifact-signing-credential-type", - args.artifact_signing_credential_type, - )?; if args.timestamp_url.is_some() && args.timestamp_digest.is_none() { return Err(anyhow!( "portable Artifact Signing sign requires --td/--timestamp-digest with --tr/--timestamp-url" @@ -382,13 +534,11 @@ fn validate_artifact_signing_supported_options(args: &SignArgs) -> Result<()> { reject_bool_option("--nosealwarn", args.sign_no_seal_warn)?; reject_bool_option("--noenclavewarn", args.sign_no_enclave_warn)?; reject_option("--rust-sip", args.rust_sip.is_some())?; - reject_path_option("--input-file-list", &args.sign_input_file_list)?; - reject_bool_option("--continue-on-error", args.continue_on_error)?; - reject_bool_option("--skip-signed", args.skip_signed)?; - reject_option( - "--max-degree-of-parallelism", - args.max_degree_parallelism.is_some(), - )?; + if args.max_degree_parallelism == Some(0) { + return Err(anyhow!( + "portable Artifact Signing sign requires --max-degree-of-parallelism to be at least 1" + )); + } Ok(()) } @@ -457,18 +607,22 @@ fn sign_one_target_artifact_signing(target: &Path, args: &SignArgs) -> Result<() .and_then(|e| e.to_str()) .map(str::to_ascii_lowercase) .unwrap_or_default(); - if !matches!( - ext.as_str(), - "exe" | "dll" | "sys" | "ocx" | "efi" | "winmd" - ) { - return Err(anyhow!( - "portable Artifact Signing is currently implemented only for PE/WinMD targets; got {}", + let tmp = temporary_output_path(target); + let result = match ext.as_str() { + "exe" | "dll" | "sys" | "ocx" | "efi" | "winmd" => { + run_portable_sign_pe_artifact_signing(target, &tmp, args) + } + "cab" => run_portable_sign_cab_artifact_signing(target, &tmp, args), + "msi" | "msp" => run_portable_sign_msi_artifact_signing(target, &tmp, args), + "appx" | "msix" => run_portable_sign_msix_artifact_signing(target, &tmp, args), + "cat" => Err(anyhow!( + "portable Artifact Signing for catalog targets is available through `psign-tool portable sign-catalog ... --artifact-signing-*`; native-shaped in-place .cat signing needs a catalog-authenticode replacement path and is not implemented yet" + )), + _ => Err(anyhow!( + "portable Artifact Signing is currently implemented for PE/WinMD, CAB, MSI/MSP, and flat MSIX/AppX targets; got {}", target.display() - )); + )), } - - let tmp = temporary_output_path(target); - let result = run_portable_sign_pe_artifact_signing(target, &tmp, args) .and_then(|_| { std::fs::copy(&tmp, target) .with_context(|| format!("replace '{}' with signed output", target.display()))?; @@ -625,6 +779,17 @@ fn run_portable_sign_pe_artifact_signing( if effective_artifact_signing_managed_identity(args) { argv.push(OsString::from("--artifact-signing-managed-identity")); } + push_option( + &mut argv, + "--artifact-signing-managed-identity-resource-id", + &args.artifact_signing_managed_identity_resource_id, + ); + if let Some(value) = args.artifact_signing_credential_type { + argv.push(OsString::from("--artifact-signing-credential-type")); + argv.push(OsString::from( + value.to_possible_value().unwrap().get_name(), + )); + } push_option( &mut argv, "--artifact-signing-tenant-id", @@ -640,6 +805,11 @@ fn run_portable_sign_pe_artifact_signing( "--artifact-signing-client-secret", &args.artifact_signing_client_secret, ); + push_option( + &mut argv, + "--artifact-signing-federated-token-file", + &args.artifact_signing_federated_token_file, + ); push_option( &mut argv, "--artifact-signing-authority", @@ -662,6 +832,330 @@ fn run_portable_sign_pe_artifact_signing( .map_err(|_| anyhow!("portable Artifact Signing sign-pe runner panicked"))? } +fn run_portable_sign_cab_artifact_signing( + target: &Path, + output: &Path, + args: &SignArgs, +) -> Result<()> { + let mut argv = vec![ + OsString::from("psign-tool"), + OsString::from("sign-cab"), + target.as_os_str().to_os_string(), + OsString::from("--digest"), + OsString::from(portable_digest_name(args.digest)?), + ]; + for chain_cert in &args.additional_certs { + argv.push(OsString::from("--chain-cert")); + argv.push(chain_cert.as_os_str().to_os_string()); + } + push_timestamp_options(&mut argv, args)?; + push_artifact_signing_options(&mut argv, args); + argv.push(OsString::from("--output")); + argv.push(output.as_os_str().to_os_string()); + + std::thread::Builder::new() + .name("psign-portable-sign-cab-artifact".to_string()) + .stack_size(8 * 1024 * 1024) + .spawn(move || psign_digest_cli::run_from(argv)) + .map_err(|e| anyhow!("spawn portable Artifact Signing sign-cab runner: {e}"))? + .join() + .map_err(|_| anyhow!("portable Artifact Signing sign-cab runner panicked"))? +} + +fn run_portable_sign_msi_artifact_signing( + target: &Path, + output: &Path, + args: &SignArgs, +) -> Result<()> { + let mut argv = vec![ + OsString::from("psign-tool"), + OsString::from("sign-msi"), + target.as_os_str().to_os_string(), + OsString::from("--digest"), + OsString::from(portable_digest_name(args.digest)?), + ]; + for chain_cert in &args.additional_certs { + argv.push(OsString::from("--chain-cert")); + argv.push(chain_cert.as_os_str().to_os_string()); + } + push_timestamp_options(&mut argv, args)?; + push_artifact_signing_options(&mut argv, args); + argv.push(OsString::from("--output")); + argv.push(output.as_os_str().to_os_string()); + + std::thread::Builder::new() + .name("psign-portable-sign-msi-artifact".to_string()) + .stack_size(8 * 1024 * 1024) + .spawn(move || psign_digest_cli::run_from(argv)) + .map_err(|e| anyhow!("spawn portable Artifact Signing sign-msi runner: {e}"))? + .join() + .map_err(|_| anyhow!("portable Artifact Signing sign-msi runner panicked"))? +} + +#[cfg(feature = "artifact-signing-rest")] +fn run_portable_sign_msix_artifact_signing( + target: &Path, + output: &Path, + args: &SignArgs, +) -> Result<()> { + let metadata = artifact_signing_metadata(args)?; + let endpoint = text_opt(args.artifact_signing_endpoint.as_deref()) + .map(ToOwned::to_owned) + .or_else(|| metadata.as_ref().map(|m| m.Endpoint.clone())) + .or_else(|| { + text_opt(args.artifact_signing_endpoint_base_url.as_deref()).map(ToOwned::to_owned) + }) + .or_else(|| { + text_opt(args.artifact_signing_region.as_deref()) + .map(|region| format!("https://{region}.codesigning.azure.net")) + }) + .ok_or_else(|| { + anyhow!( + "portable MSIX/AppX Artifact Signing requires --artifact-signing-endpoint, --artifact-signing-endpoint-base-url, --artifact-signing-region, or metadata Endpoint" + ) + })?; + let account_name = text_opt(args.artifact_signing_account_name.as_deref()) + .map(ToOwned::to_owned) + .or_else(|| metadata.as_ref().map(|m| m.CodeSigningAccountName.clone())) + .ok_or_else(|| anyhow!("portable MSIX/AppX Artifact Signing requires --artifact-signing-account-name or metadata CodeSigningAccountName"))?; + let profile_name = text_opt(args.artifact_signing_profile_name.as_deref()) + .map(ToOwned::to_owned) + .or_else(|| metadata.as_ref().map(|m| m.CertificateProfileName.clone())) + .ok_or_else(|| anyhow!("portable MSIX/AppX Artifact Signing requires --artifact-signing-profile-name or metadata CertificateProfileName"))?; + let correlation_id = text_opt(args.artifact_signing_correlation_id.as_deref()) + .map(ToOwned::to_owned) + .or_else(|| metadata.as_ref().and_then(|m| m.CorrelationId.clone())); + if correlation_id.is_some() + || text_present(&args.artifact_signing_signature_algorithm) + || text_present(&args.artifact_signing_api_version) + || text_present(&args.artifact_signing_authority) + { + return Err(anyhow!( + "native-shaped portable MSIX/AppX Artifact Signing does not yet support correlation ID, signature-algorithm, api-version, or authority overrides" + )); + } + + let mut exclude_credentials = metadata + .as_ref() + .and_then(|m| m.ExcludeCredentials.clone()) + .unwrap_or_default(); + let credential_type = args + .artifact_signing_credential_type + .map(|value| value.to_possible_value().unwrap().get_name().to_string()); + let request = psign_portable_core::PortableSignRequest { + path: target.to_path_buf(), + output_path: Some(output.to_path_buf()), + hash_algorithm: portable_core_digest(args.digest)?, + chain_certificate_paths: args.additional_certs.clone(), + timestamp_server: text_opt(args.timestamp_url.as_deref()).map(ToOwned::to_owned), + timestamp_hash_algorithm: args + .timestamp_digest + .map(portable_core_timestamp_digest) + .transpose()?, + artifact_signing_endpoint: Some(endpoint), + artifact_signing_account_name: Some(account_name), + artifact_signing_profile_name: Some(profile_name), + artifact_signing_access_token: text_opt(args.artifact_signing_access_token.as_deref()) + .map(ToOwned::to_owned), + artifact_signing_managed_identity: Some(effective_artifact_signing_managed_identity(args)), + artifact_signing_managed_identity_resource_id: text_opt( + args.artifact_signing_managed_identity_resource_id + .as_deref(), + ) + .map(ToOwned::to_owned), + artifact_signing_credential_type: credential_type, + artifact_signing_tenant_id: text_opt(args.artifact_signing_tenant_id.as_deref()) + .map(ToOwned::to_owned), + artifact_signing_client_id: text_opt(args.artifact_signing_client_id.as_deref()) + .map(ToOwned::to_owned), + artifact_signing_client_secret: text_opt(args.artifact_signing_client_secret.as_deref()) + .map(ToOwned::to_owned), + artifact_signing_federated_token_file: text_opt( + args.artifact_signing_federated_token_file.as_deref(), + ) + .map(ToOwned::to_owned), + artifact_signing_exclude_credentials: std::mem::take(&mut exclude_credentials), + ..Default::default() + }; + psign_portable_core::portable_sign(request) + .map(|_| ()) + .with_context(|| { + format!( + "portable Artifact Signing MSIX/AppX target '{}'", + target.display() + ) + }) +} + +#[cfg(not(feature = "artifact-signing-rest"))] +fn run_portable_sign_msix_artifact_signing( + _target: &Path, + _output: &Path, + _args: &SignArgs, +) -> Result<()> { + Err(anyhow!( + "portable MSIX/AppX Artifact Signing support is not compiled into this build (feature: artifact-signing-rest)" + )) +} + +#[cfg(feature = "artifact-signing-rest")] +fn artifact_signing_metadata(args: &SignArgs) -> Result> { + let metadata_path = args + .artifact_signing_metadata + .as_ref() + .or(args.dmdf.as_ref()); + let Some(path) = metadata_path else { + return Ok(None); + }; + let text = + std::fs::read_to_string(path).with_context(|| format!("read '{}'", path.display()))?; + let doc = serde_json::from_str::(&text) + .with_context(|| format!("parse Artifact Signing metadata '{}'", path.display()))?; + Ok(Some(doc)) +} + +#[cfg(feature = "artifact-signing-rest")] +fn portable_core_digest( + digest: crate::cli::DigestAlgorithm, +) -> Result { + Ok(match digest { + crate::cli::DigestAlgorithm::Sha256 => psign_portable_core::PortableDigestAlgorithm::Sha256, + crate::cli::DigestAlgorithm::Sha384 => psign_portable_core::PortableDigestAlgorithm::Sha384, + crate::cli::DigestAlgorithm::Sha512 => psign_portable_core::PortableDigestAlgorithm::Sha512, + crate::cli::DigestAlgorithm::Sha1 | crate::cli::DigestAlgorithm::CertHash => { + return Err(anyhow!( + "portable MSIX/AppX Artifact Signing supports SHA256, SHA384, and SHA512 file digests" + )); + } + }) +} + +#[cfg(feature = "artifact-signing-rest")] +fn portable_core_timestamp_digest( + digest: crate::cli::DigestAlgorithm, +) -> Result { + Ok(match digest { + crate::cli::DigestAlgorithm::Sha1 => { + psign_portable_core::PortableTimestampDigestAlgorithm::Sha1 + } + crate::cli::DigestAlgorithm::Sha256 => { + psign_portable_core::PortableTimestampDigestAlgorithm::Sha256 + } + crate::cli::DigestAlgorithm::Sha384 => { + psign_portable_core::PortableTimestampDigestAlgorithm::Sha384 + } + crate::cli::DigestAlgorithm::Sha512 => { + psign_portable_core::PortableTimestampDigestAlgorithm::Sha512 + } + crate::cli::DigestAlgorithm::CertHash => { + return Err(anyhow!( + "portable MSIX/AppX Artifact Signing timestamp digest does not support certHash" + )); + } + }) +} + +fn push_artifact_signing_options(argv: &mut Vec, args: &SignArgs) { + let metadata = args + .artifact_signing_metadata + .as_ref() + .or(args.dmdf.as_ref()); + push_path_option(argv, "--artifact-signing-metadata", metadata); + push_option( + argv, + "--artifact-signing-region", + &args.artifact_signing_region, + ); + push_option( + argv, + "--artifact-signing-endpoint", + &args.artifact_signing_endpoint, + ); + push_option( + argv, + "--artifact-signing-account-name", + &args.artifact_signing_account_name, + ); + push_option( + argv, + "--artifact-signing-profile-name", + &args.artifact_signing_profile_name, + ); + push_option( + argv, + "--artifact-signing-signature-algorithm", + &args.artifact_signing_signature_algorithm, + ); + push_option( + argv, + "--artifact-signing-api-version", + &args.artifact_signing_api_version, + ); + push_option( + argv, + "--artifact-signing-correlation-id", + &args.artifact_signing_correlation_id, + ); + push_option( + argv, + "--artifact-signing-access-token", + &args.artifact_signing_access_token, + ); + if effective_artifact_signing_managed_identity(args) { + argv.push(OsString::from("--artifact-signing-managed-identity")); + } + push_option( + argv, + "--artifact-signing-managed-identity-resource-id", + &args.artifact_signing_managed_identity_resource_id, + ); + if let Some(value) = args.artifact_signing_credential_type { + argv.push(OsString::from("--artifact-signing-credential-type")); + argv.push(OsString::from( + value.to_possible_value().unwrap().get_name(), + )); + } + push_option( + argv, + "--artifact-signing-tenant-id", + &args.artifact_signing_tenant_id, + ); + push_option( + argv, + "--artifact-signing-client-id", + &args.artifact_signing_client_id, + ); + push_option( + argv, + "--artifact-signing-client-secret", + &args.artifact_signing_client_secret, + ); + push_option( + argv, + "--artifact-signing-federated-token-file", + &args.artifact_signing_federated_token_file, + ); + push_option( + argv, + "--artifact-signing-authority", + &args.artifact_signing_authority, + ); + push_option( + argv, + "--artifact-signing-endpoint-base-url", + &args.artifact_signing_endpoint_base_url, + ); +} + +fn push_timestamp_options(argv: &mut Vec, args: &SignArgs) -> Result<()> { + push_option(argv, "--timestamp-url", &args.timestamp_url); + if let Some(timestamp_digest) = args.timestamp_digest { + argv.push(OsString::from("--timestamp-digest")); + argv.push(OsString::from(timestamp_digest_name(timestamp_digest)?)); + } + Ok(()) +} + fn portable_digest_name(digest: DigestAlgorithm) -> Result<&'static str> { match digest { DigestAlgorithm::Sha256 => Ok("sha256"), @@ -732,10 +1226,12 @@ fn artifact_signing_requested(args: &SignArgs) -> bool { || text_present(&args.artifact_signing_correlation_id) || text_present(&args.artifact_signing_access_token) || args.artifact_signing_managed_identity + || text_present(&args.artifact_signing_managed_identity_resource_id) || args.artifact_signing_credential_type.is_some() || text_present(&args.artifact_signing_tenant_id) || text_present(&args.artifact_signing_client_id) || text_present(&args.artifact_signing_client_secret) + || text_present(&args.artifact_signing_federated_token_file) || text_present(&args.artifact_signing_authority) || text_present(&args.artifact_signing_endpoint_base_url) } @@ -744,6 +1240,17 @@ fn text_present(value: &Option) -> bool { value.as_deref().is_some_and(|s| !s.trim().is_empty()) } +fn text_opt(value: Option<&str>) -> Option<&str> { + value.and_then(|s| { + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + fn effective_azure_key_vault_managed_identity(args: &SignArgs) -> bool { args.azure_key_vault_managed_identity || matches!( @@ -763,16 +1270,55 @@ fn effective_artifact_signing_managed_identity(args: &SignArgs) -> bool { fn reject_workload_identity(name: &str, value: Option) -> Result<()> { if matches!(value, Some(AzureCredentialType::WorkloadIdentity)) { return Err(anyhow!( - "{name}=workload-identity is accepted by provider planning but is not wired for signing execution yet" + "{name}=workload-identity is accepted by provider planning but is not wired for portable Azure Key Vault signing execution yet" )); } Ok(()) } fn success_exit_code(args: &SignArgs) -> i32 { - match args.exit_codes { - Some(crate::cli::SignExitCodes::Azuresigntool) => 0, - Some(crate::cli::SignExitCodes::Signtool) | None => 0, + match resolved_sign_exit_codes(args) { + SignExitCodes::Azuresigntool | SignExitCodes::Signtool => 0, + } +} + +fn resolved_sign_exit_codes(args: &SignArgs) -> SignExitCodes { + if let Some(x) = args.exit_codes { + return x; + } + match crate::env_var_with_legacy(crate::ENV_EXIT_CODES, crate::LEGACY_ENV_EXIT_CODES) { + Some(v) => { + let t = v.trim(); + if t.eq_ignore_ascii_case("azure") || t.eq_ignore_ascii_case("azuresigntool") { + SignExitCodes::Azuresigntool + } else { + SignExitCodes::Signtool + } + } + None => SignExitCodes::Signtool, + } +} + +fn batch_exit_code(exit_style: SignExitCodes, successes: usize, failures: usize) -> i32 { + match exit_style { + SignExitCodes::Signtool => { + if failures > 0 { + 1 + } else { + 0 + } + } + SignExitCodes::Azuresigntool => { + if successes > 0 && failures == 0 { + 0 + } else if successes > 0 && failures > 0 { + AZURE_SIGN_EXIT_PARTIAL_SUCCESS + } else if successes == 0 && failures > 0 { + AZURE_SIGN_EXIT_ALL_FAILED + } else { + 0 + } + } } } @@ -839,6 +1385,10 @@ fn reject_artifact_signing_options(args: &SignArgs) -> Result<()> { "--artifact-signing-managed-identity", args.artifact_signing_managed_identity, )?; + reject_string_option( + "--artifact-signing-managed-identity-resource-id", + &args.artifact_signing_managed_identity_resource_id, + )?; reject_option( "--artifact-signing-credential-type", args.artifact_signing_credential_type.is_some(), @@ -855,6 +1405,10 @@ fn reject_artifact_signing_options(args: &SignArgs) -> Result<()> { "--artifact-signing-client-secret", &args.artifact_signing_client_secret, )?; + reject_string_option( + "--artifact-signing-federated-token-file", + &args.artifact_signing_federated_token_file, + )?; reject_string_option( "--artifact-signing-authority", &args.artifact_signing_authority, diff --git a/src/signing_provider.rs b/src/signing_provider.rs index dc515d5..d328be3 100644 --- a/src/signing_provider.rs +++ b/src/signing_provider.rs @@ -71,11 +71,13 @@ pub enum AzureAuthConfig { }, ManagedIdentity { client_id: Option, + resource_id: Option, authority: Option, }, WorkloadIdentity { tenant_id: Option, client_id: Option, + federated_token_file: Option, authority: Option, }, Ambient { @@ -127,9 +129,11 @@ impl SigningProviderConfig { args.azure_key_vault_credential_type, args.azure_key_vault_access_token.as_deref(), args.azure_key_vault_managed_identity, + None, args.azure_key_vault_tenant_id.as_deref(), args.azure_key_vault_client_id.as_deref(), args.azure_key_vault_client_secret.as_deref(), + None, args.azure_authority.as_deref(), ), }, @@ -156,9 +160,12 @@ impl SigningProviderConfig { args.artifact_signing_credential_type, args.artifact_signing_access_token.as_deref(), args.artifact_signing_managed_identity, + args.artifact_signing_managed_identity_resource_id + .as_deref(), args.artifact_signing_tenant_id.as_deref(), args.artifact_signing_client_id.as_deref(), args.artifact_signing_client_secret.as_deref(), + args.artifact_signing_federated_token_file.as_deref(), args.artifact_signing_authority.as_deref(), ), }, @@ -248,21 +255,26 @@ fn artifact_signing_requested(args: &SignArgs) -> bool { || text_present(&args.artifact_signing_correlation_id) || text_present(&args.artifact_signing_access_token) || args.artifact_signing_managed_identity + || text_present(&args.artifact_signing_managed_identity_resource_id) || args.artifact_signing_credential_type.is_some() || text_present(&args.artifact_signing_tenant_id) || text_present(&args.artifact_signing_client_id) || text_present(&args.artifact_signing_client_secret) + || text_present(&args.artifact_signing_federated_token_file) || text_present(&args.artifact_signing_authority) || text_present(&args.artifact_signing_endpoint_base_url) } +#[allow(clippy::too_many_arguments)] fn azure_auth( credential_type: Option, access_token: Option<&str>, managed_identity: bool, + managed_identity_resource_id: Option<&str>, tenant_id: Option<&str>, client_id: Option<&str>, client_secret: Option<&str>, + federated_token_file: Option<&str>, authority: Option<&str>, ) -> AzureAuthConfig { let authority = trim_str(authority).map(str::to_owned); @@ -270,6 +282,7 @@ fn azure_auth( AzureCredentialType::AccessToken => AzureAuthConfig::AccessToken { authority }, AzureCredentialType::ManagedIdentity => AzureAuthConfig::ManagedIdentity { client_id: trim_str(client_id).map(str::to_owned), + resource_id: trim_str(managed_identity_resource_id).map(str::to_owned), authority, }, AzureCredentialType::ClientSecret => AzureAuthConfig::ClientSecret { @@ -280,6 +293,7 @@ fn azure_auth( AzureCredentialType::WorkloadIdentity => AzureAuthConfig::WorkloadIdentity { tenant_id: trim_str(tenant_id).map(str::to_owned), client_id: trim_str(client_id).map(str::to_owned), + federated_token_file: trim_str(federated_token_file).map(str::to_owned), authority, }, AzureCredentialType::Default => { @@ -288,6 +302,7 @@ fn azure_auth( } else if managed_identity { AzureAuthConfig::ManagedIdentity { client_id: trim_str(client_id).map(str::to_owned), + resource_id: trim_str(managed_identity_resource_id).map(str::to_owned), authority, } } else if trim_str(client_secret).is_some() { @@ -296,6 +311,13 @@ fn azure_auth( client_id: trim_str(client_id).map(str::to_owned), authority, } + } else if trim_str(federated_token_file).is_some() { + AzureAuthConfig::WorkloadIdentity { + tenant_id: trim_str(tenant_id).map(str::to_owned), + client_id: trim_str(client_id).map(str::to_owned), + federated_token_file: trim_str(federated_token_file).map(str::to_owned), + authority, + } } else { AzureAuthConfig::Ambient { authority } } diff --git a/src/win/artifact_signing_rest.rs b/src/win/artifact_signing_rest.rs index 5dfdfe9..5c88d06 100644 --- a/src/win/artifact_signing_rest.rs +++ b/src/win/artifact_signing_rest.rs @@ -4,10 +4,11 @@ //! `specification/codesigning/data-plane/Azure.CodeSigning/preview/2023-06-15-preview/azure.codesigning.json`. use crate::CommandOutput; -use crate::cli::{ArtifactSigningSubmitArgs, GlobalOpts}; +use crate::cli::{ArtifactSigningSubmitArgs, AzureCredentialType, GlobalOpts}; use anyhow::{Result, anyhow}; use psign_codesigning_rest::{ - CodesigningAuth, CodesigningSubmitParams, submit_codesign_hash_blocking, + CodesigningAuth, CodesigningAuthInput, CodesigningCredentialType, CodesigningSubmitParams, + resolve_codesigning_auth, submit_codesign_hash_blocking, }; pub fn artifact_signing_submit_command( args: &ArtifactSigningSubmitArgs, @@ -46,72 +47,26 @@ pub fn artifact_signing_submit_command( } fn build_auth(args: &ArtifactSigningSubmitArgs) -> Result { - let has_tok = args - .access_token - .as_ref() - .map(|s| !s.trim().is_empty()) - .unwrap_or(false); - if args.managed_identity { - return Ok(CodesigningAuth::ManagedIdentity); - } - if has_tok { - return Ok(CodesigningAuth::Bearer( - args.access_token.as_ref().unwrap().trim().to_string(), - )); - } - Ok(CodesigningAuth::ClientCredentials { - tenant_id: args.tenant_id.as_ref().unwrap().trim().to_string(), - client_id: args.client_id.as_ref().unwrap().trim().to_string(), - client_secret: args.client_secret.as_ref().unwrap().trim().to_string(), + resolve_codesigning_auth(&CodesigningAuthInput { + access_token: args.access_token.clone(), + managed_identity: args.managed_identity, + managed_identity_resource_id: args.managed_identity_resource_id.clone(), + tenant_id: args.tenant_id.clone(), + client_id: args.client_id.clone(), + client_secret: args.client_secret.clone(), + federated_token_file: args.federated_token_file.clone(), + credential_type: args.credential_type.map(|value| match value { + AzureCredentialType::Default => CodesigningCredentialType::Default, + AzureCredentialType::ManagedIdentity => CodesigningCredentialType::ManagedIdentity, + AzureCredentialType::AccessToken => CodesigningCredentialType::AccessToken, + AzureCredentialType::ClientSecret => CodesigningCredentialType::ClientSecret, + AzureCredentialType::WorkloadIdentity => CodesigningCredentialType::WorkloadIdentity, + }), + exclude_credentials: Vec::new(), }) } fn validate_submit_args(args: &ArtifactSigningSubmitArgs) -> Result<()> { - let has_tok = args - .access_token - .as_ref() - .map(|s| !s.trim().is_empty()) - .unwrap_or(false); - let sp_count = (args - .tenant_id - .as_ref() - .map(|s| !s.trim().is_empty()) - .unwrap_or(false) as u8) - + (args - .client_id - .as_ref() - .map(|s| !s.trim().is_empty()) - .unwrap_or(false) as u8) - + (args - .client_secret - .as_ref() - .map(|s| !s.trim().is_empty()) - .unwrap_or(false) as u8); - if args.managed_identity { - if has_tok || sp_count != 0 { - return Err(anyhow!( - "use either --managed-identity or access token / client credentials, not multiple" - )); - } - return Ok(()); - } - if has_tok { - if sp_count != 0 { - return Err(anyhow!( - "use either --access-token or client credentials tenant/id/secret, not both" - )); - } - return Ok(()); - } - if sp_count != 0 && sp_count != 3 { - return Err(anyhow!( - "client credentials require all of --tenant-id, --client-id, and --client-secret" - )); - } - if sp_count == 0 { - return Err(anyhow!( - "choose authentication: --managed-identity, --access-token, or tenant/client-id/client-secret" - )); - } + build_auth(args)?; Ok(()) } diff --git a/tests/cli_pe_digest.rs b/tests/cli_pe_digest.rs index 3e239d0..76c415f 100644 --- a/tests/cli_pe_digest.rs +++ b/tests/cli_pe_digest.rs @@ -5105,13 +5105,202 @@ fn psign_server_artifact_signing_signs_winmd_with_portable_cli() { .stdout(predicate::str::contains("trust-verify-pe: ok")); } -#[cfg(feature = "artifact-signing-rest")] +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +#[test] +fn psign_server_artifact_signing_signs_cab_with_portable_cli() { + let dir = tempfile::tempdir().unwrap(); + let in_cab = dir.path().join("tiny.cab"); + let out_cab = dir.path().join("tiny.artifact-signed.cab"); + let unsigned = minimal_unsigned_cab_fixture_bytes(); + std::fs::write(&in_cab, &unsigned).expect("write unsigned CAB"); + + let (mut guard, endpoint) = spawn_psign_artifact_signing_server(2); + let mut cmd = portable_cmd(); + cmd.arg("sign-cab") + .arg(&in_cab) + .arg("--artifact-signing-account-name") + .arg("acct") + .arg("--artifact-signing-profile-name") + .arg("prof") + .arg("--artifact-signing-access-token") + .arg("test-token") + .arg("--artifact-signing-endpoint-base-url") + .arg(&endpoint) + .arg("--output") + .arg(&out_cab); + cmd.assert() + .success() + .stdout(predicate::str::contains("sign-cab: ok")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let mut verify = portable_cmd(); + verify.arg("verify-cab").arg(&out_cab); + verify.assert().success(); + + let signed = std::fs::read(&out_cab).expect("read signed CAB"); + let pkcs7 = cab_digest::cab_signature_pkcs7_der(&signed).expect("extract CAB PKCS#7"); + let sd = pkcs7::parse_pkcs7_signed_data_der(pkcs7).expect("parse SignedData"); + let indirect = pkcs7::signed_data_spc_indirect_message_digest_octets(&sd).expect("indirect"); + pkcs7::verify_signed_data_authenticode_indirect_digest_and_rsa_sha256_pkcs1v15_signature( + &sd, 0, &indirect, + ) + .expect("portable Artifact Signing CAB RSA signature verifies"); +} + +#[cfg(all( + feature = "timestamp-server", + feature = "timestamp-http", + feature = "artifact-signing-rest" +))] #[test] -fn mode_portable_artifact_signing_rejects_non_pe_targets_before_remote_submit() { +fn psign_server_artifact_signing_timestamps_cab_with_portable_cli() { let dir = tempfile::tempdir().unwrap(); - let cab_path = dir.path().join("unsigned.cab"); - std::fs::write(&cab_path, minimal_unsigned_cab_bytes()).expect("write CAB"); + let in_cab = dir.path().join("tiny.cab"); + let out_cab = dir.path().join("tiny.artifact-signed-timestamped.cab"); + std::fs::write(&in_cab, minimal_unsigned_cab_fixture_bytes()).expect("write unsigned CAB"); + let (mut artifact_guard, endpoint) = spawn_psign_artifact_signing_server(2); + let (mut timestamp_guard, timestamp_url) = spawn_psign_server(&[]); + let mut cmd = portable_cmd(); + cmd.arg("sign-cab") + .arg(&in_cab) + .arg("--artifact-signing-account-name") + .arg("acct") + .arg("--artifact-signing-profile-name") + .arg("prof") + .arg("--artifact-signing-access-token") + .arg("test-token") + .arg("--artifact-signing-endpoint-base-url") + .arg(&endpoint) + .arg("--timestamp-url") + .arg(×tamp_url) + .arg("--timestamp-digest") + .arg("sha256") + .arg("--output") + .arg(&out_cab); + cmd.assert() + .success() + .stdout(predicate::str::contains("sign-cab: ok")); + let artifact_status = artifact_guard.0.wait().expect("artifact server exit"); + assert!( + artifact_status.success(), + "artifact server failed with {artifact_status}" + ); + let timestamp_status = timestamp_guard.0.wait().expect("timestamp server exit"); + assert!( + timestamp_status.success(), + "timestamp server failed with {timestamp_status}" + ); + + let signed = std::fs::read(&out_cab).expect("read signed CAB"); + let pkcs7 = cab_digest::cab_signature_pkcs7_der(&signed).expect("extract CAB PKCS#7"); + let sd = pkcs7::parse_pkcs7_signed_data_der(pkcs7).expect("parse SignedData"); + let signer = sd.signer_infos.0.as_slice().first().expect("SignerInfo"); + let has_timestamp = signer + .unsigned_attrs + .as_ref() + .map(|attrs| { + attrs + .iter() + .any(|attr| attr.oid == pkcs7::MS_RFC3161_TIMESTAMP_TOKEN_OID) + }) + .unwrap_or(false); + assert!(has_timestamp, "CAB signature has no RFC3161 timestamp"); +} + +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +#[test] +fn psign_server_artifact_signing_signs_msi_with_portable_cli() { + let dir = tempfile::tempdir().unwrap(); + let out_msi = dir.path().join("tiny.artifact-signed.msi"); + let input = repo_root().join("tests/fixtures/msi-authenticode-upstream/tiny-pkcs7-stub.msi"); + + let (mut guard, endpoint) = spawn_psign_artifact_signing_server(2); + let mut cmd = portable_cmd(); + cmd.arg("sign-msi") + .arg(&input) + .arg("--artifact-signing-account-name") + .arg("acct") + .arg("--artifact-signing-profile-name") + .arg("prof") + .arg("--artifact-signing-access-token") + .arg("test-token") + .arg("--artifact-signing-endpoint-base-url") + .arg(&endpoint) + .arg("--output") + .arg(&out_msi); + cmd.assert() + .success() + .stdout(predicate::str::contains("sign-msi: ok")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let mut verify = portable_cmd(); + verify.arg("verify-msi").arg(&out_msi); + verify.assert().success(); +} + +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +#[test] +fn psign_server_artifact_signing_signs_catalog_with_portable_cli() { + let dir = tempfile::tempdir().unwrap(); + let out_cat = dir.path().join("artifact-signed.cat"); + let subjects = [ + tiny32_unsigned_fixture(), + catalog_workflow_subject("hello.txt"), + catalog_workflow_subject("blob.bin"), + ]; + + let (mut guard, endpoint) = spawn_psign_artifact_signing_server(2); + let mut cmd = portable_cmd(); + cmd.arg("sign-catalog") + .arg(subjects[0].as_path()) + .arg(subjects[1].as_path()) + .arg(subjects[2].as_path()) + .arg("--artifact-signing-account-name") + .arg("acct") + .arg("--artifact-signing-profile-name") + .arg("prof") + .arg("--artifact-signing-access-token") + .arg("test-token") + .arg("--artifact-signing-endpoint-base-url") + .arg(&endpoint) + .arg("--output") + .arg(&out_cat); + cmd.assert() + .success() + .stdout(predicate::str::contains("sign-catalog: ok")) + .stdout(predicate::str::contains("members=3")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let mut verify = portable_cmd(); + verify.arg("verify-catalog").arg(&out_cat); + verify.assert().success(); + + for subject in &subjects { + let mut member = portable_cmd(); + member + .arg("verify-catalog-member") + .arg("--catalog") + .arg(&out_cat) + .arg(subject); + member + .assert() + .success() + .stdout(predicate::str::contains("verify-catalog-member: ok")); + } +} + +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +#[test] +fn mode_portable_artifact_signing_signs_cab() { + let dir = tempfile::tempdir().unwrap(); + let cab_path = dir.path().join("tiny.mode-portable.cab"); + std::fs::write(&cab_path, minimal_unsigned_cab_fixture_bytes()).expect("write unsigned CAB"); + + let (mut guard, endpoint) = spawn_psign_artifact_signing_server(2); let mut cmd = Command::cargo_bin("psign-tool").unwrap(); cmd.arg("--mode") .arg("portable") @@ -5124,12 +5313,178 @@ fn mode_portable_artifact_signing_rejects_non_pe_targets_before_remote_submit() .arg("prof") .arg("--artifact-signing-access-token") .arg("test-token") + .arg("--artifact-signing-endpoint-base-url") + .arg(&endpoint) .arg(&cab_path); + cmd.assert() + .success() + .stdout(predicate::str::contains("Signed:")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let mut verify = portable_cmd(); + verify.arg("verify-cab").arg(&cab_path); + verify.assert().success(); +} + +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +#[test] +fn mode_portable_artifact_signing_uses_file_list_skip_signed_and_parallelism() { + let dir = tempfile::tempdir().unwrap(); + let unsigned_cab = dir.path().join("unsigned.cab"); + let already_signed_cab = dir.path().join("already-signed.cab"); + let file_list = dir.path().join("files.txt"); + std::fs::write(&unsigned_cab, minimal_unsigned_cab_fixture_bytes()) + .expect("write unsigned CAB"); + std::fs::copy(tiny_signed_cab_fixture(), &already_signed_cab).expect("copy signed CAB"); + std::fs::write( + &file_list, + format!( + "{}\n{}\n", + already_signed_cab.display(), + unsigned_cab.display() + ), + ) + .expect("write file list"); + + let (mut guard, endpoint) = spawn_psign_artifact_signing_server(2); + let mut cmd = Command::cargo_bin("psign-tool").unwrap(); + cmd.arg("--mode") + .arg("portable") + .arg("sign") + .arg("--digest") + .arg("sha256") + .arg("--artifact-signing-account-name") + .arg("acct") + .arg("--artifact-signing-profile-name") + .arg("prof") + .arg("--artifact-signing-access-token") + .arg("test-token") + .arg("--artifact-signing-endpoint-base-url") + .arg(&endpoint) + .arg("--input-file-list") + .arg(&file_list) + .arg("--skip-signed") + .arg("--max-degree-of-parallelism") + .arg("2"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Skipped (already signed):")) + .stdout(predicate::str::contains("Signed:")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let mut verify = portable_cmd(); + verify.arg("verify-cab").arg(&unsigned_cab); + verify.assert().success(); +} + +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +#[test] +fn mode_portable_artifact_signing_continue_on_error_reports_partial_failure() { + let dir = tempfile::tempdir().unwrap(); + let cab_path = dir.path().join("tiny.cab"); + let unsupported_path = dir.path().join("unsupported.ps1"); + std::fs::write(&cab_path, minimal_unsigned_cab_fixture_bytes()).expect("write unsigned CAB"); + std::fs::write(&unsupported_path, b"not a portable Artifact Signing target") + .expect("write unsupported target"); + + let (mut guard, endpoint) = spawn_psign_artifact_signing_server(2); + let mut cmd = Command::cargo_bin("psign-tool").unwrap(); + cmd.arg("--mode") + .arg("portable") + .arg("sign") + .arg("--digest") + .arg("sha256") + .arg("--artifact-signing-account-name") + .arg("acct") + .arg("--artifact-signing-profile-name") + .arg("prof") + .arg("--artifact-signing-access-token") + .arg("test-token") + .arg("--artifact-signing-endpoint-base-url") + .arg(&endpoint) + .arg("--continue-on-error") + .arg(&cab_path) + .arg(&unsupported_path); + cmd.assert() + .failure() + .stdout(predicate::str::contains("Signed:")) + .stdout(predicate::str::contains("Failed:")) + .stdout(predicate::str::contains( + "portable Artifact Signing is currently implemented for PE/WinMD, CAB, MSI/MSP, and flat MSIX/AppX targets", + )); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let mut verify = portable_cmd(); + verify.arg("verify-cab").arg(&cab_path); + verify.assert().success(); +} + +#[cfg(feature = "artifact-signing-rest")] +#[test] +fn mode_portable_artifact_signing_rejects_unsupported_targets_before_remote_submit() { + let dir = tempfile::tempdir().unwrap(); + let script_path = dir.path().join("unsigned.ps1"); + std::fs::write(&script_path, b"Write-Host test").expect("write script"); + + let mut cmd = Command::cargo_bin("psign-tool").unwrap(); + cmd.arg("--mode") + .arg("portable") + .arg("sign") + .arg("--digest") + .arg("sha256") + .arg("--artifact-signing-account-name") + .arg("acct") + .arg("--artifact-signing-profile-name") + .arg("prof") + .arg("--artifact-signing-access-token") + .arg("test-token") + .arg(&script_path); cmd.assert().failure().stderr(predicate::str::contains( - "portable Artifact Signing is currently implemented only for PE/WinMD targets", + "portable Artifact Signing is currently implemented for PE/WinMD, CAB, MSI/MSP, and flat MSIX/AppX targets", )); } +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +#[test] +fn mode_portable_artifact_signing_signs_flat_msix() { + let dir = tempfile::tempdir().unwrap(); + let msix_path = dir.path().join("sample.msix"); + std::fs::copy( + repo_root().join("tests/fixtures/generated-unsigned/msix/sample.msix"), + &msix_path, + ) + .expect("copy MSIX fixture"); + + let (mut guard, endpoint) = spawn_psign_artifact_signing_server(2); + let mut cmd = Command::cargo_bin("psign-tool").unwrap(); + cmd.arg("--mode") + .arg("portable") + .arg("sign") + .arg("--digest") + .arg("sha256") + .arg("--artifact-signing-account-name") + .arg("acct") + .arg("--artifact-signing-profile-name") + .arg("prof") + .arg("--artifact-signing-access-token") + .arg("test-token") + .arg("--artifact-signing-endpoint-base-url") + .arg(&endpoint) + .arg(&msix_path); + cmd.assert() + .success() + .stdout(predicate::str::contains("Signed:")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let mut verify = portable_cmd(); + verify.arg("verify-msix").arg(&msix_path); + verify.assert().success(); +} + #[cfg(all( feature = "timestamp-server", feature = "timestamp-http", diff --git a/tests/code_command.rs b/tests/code_command.rs index e373e1c..41438f1 100644 --- a/tests/code_command.rs +++ b/tests/code_command.rs @@ -1,7 +1,7 @@ use assert_cmd::Command; use predicates::prelude::*; use psign_opc_sign::nuget; -use psign_sip_digest::pkcs7; +use psign_sip_digest::{pkcs7, verify_pe}; use rand::rngs::OsRng; use rsa::RsaPrivateKey; use rsa::pkcs1v15::SigningKey; @@ -387,6 +387,59 @@ fn code_signs_top_level_pe_with_local_cert_key() { .success(); } +#[cfg(all(feature = "timestamp-server", feature = "timestamp-http"))] +#[test] +fn code_signs_top_level_pe_with_rfc3161_timestamp() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("app.exe"); + let output = base.join("app.timestamped.exe"); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + write_test_rsa_cert_key(&cert, &key); + std::fs::copy( + repo_root().join("tests/fixtures/pe-authenticode-upstream/tiny32.efi"), + &input, + ) + .unwrap(); + let (mut guard, timestamp_url) = spawn_timestamp_server(); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--timestamp-url", + ×tamp_url, + "--timestamp-digest", + "sha256", + "--output", + ]) + .arg(&output) + .arg("app.exe"); + cmd.assert().success(); + let status = guard.0.wait().expect("timestamp server exit"); + assert!(status.success(), "timestamp server failed with {status}"); + + let mut verify = psign(); + verify + .args(["portable", "verify-pe"]) + .arg(&output) + .assert() + .success(); + + let pkcs7_der = + verify_pe::pe_nth_pkcs7_signed_data_der(&std::fs::read(&output).unwrap(), 0).unwrap(); + assert_pkcs7_has_unsigned_attr( + &pkcs7_der, + pkcs7::MS_RFC3161_TIMESTAMP_TOKEN_OID, + pkcs7::PKCS9_RFC3161_TIMESTAMP_TOKEN_OID, + ); +} + #[cfg(all(feature = "timestamp-server", feature = "azure-kv-sign"))] #[test] fn code_signs_pe_with_azure_key_vault_identity() { @@ -1780,6 +1833,61 @@ fn code_signs_nupkg_with_rfc3161_timestamp() { .stdout(predicate::str::contains("1.2.840.113549.1.9.16.2.14")); } +#[cfg(all(feature = "timestamp-server", feature = "timestamp-http"))] +#[test] +fn code_signs_nupkg_nested_pe_with_rfc3161_timestamp() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("with-pe.nupkg"); + let output = base.join("with-pe.timestamped.nupkg"); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + let nested_pe = base.join("tiny32.timestamped.dll"); + write_test_rsa_cert_key(&cert, &key); + std::fs::copy( + repo_root().join("tests/fixtures/package-signing/unsigned/with-pe.nupkg"), + &input, + ) + .unwrap(); + let (mut guard, timestamp_url) = spawn_timestamp_server_with_max_requests(2); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--timestamp-url", + ×tamp_url, + "--timestamp-digest", + "sha256", + "--output", + ]) + .arg(&output) + .arg("with-pe.nupkg"); + cmd.assert().success(); + let status = guard.0.wait().expect("timestamp server exit"); + assert!(status.success(), "timestamp server failed with {status}"); + + let signature_der = nuget::extract_signature_path(&output).expect("extract NuGet signature"); + assert_pkcs7_has_unsigned_attr( + &signature_der, + pkcs7::PKCS9_RFC3161_TIMESTAMP_TOKEN_OID, + pkcs7::MS_RFC3161_TIMESTAMP_TOKEN_OID, + ); + + extract_zip_entry(&output, "lib/net8.0/tiny32.dll", &nested_pe); + let nested_pkcs7 = + verify_pe::pe_nth_pkcs7_signed_data_der(&std::fs::read(&nested_pe).unwrap(), 0).unwrap(); + assert_pkcs7_has_unsigned_attr( + &nested_pkcs7, + pkcs7::MS_RFC3161_TIMESTAMP_TOKEN_OID, + pkcs7::PKCS9_RFC3161_TIMESTAMP_TOKEN_OID, + ); +} + #[test] fn code_rejects_zero_max_concurrency() { let mut cmd = psign(); @@ -2378,7 +2486,13 @@ impl Drop for PsignServerGuard { #[cfg(all(feature = "timestamp-server", feature = "timestamp-http"))] fn spawn_timestamp_server() -> (PsignServerGuard, String) { + spawn_timestamp_server_with_max_requests(1) +} + +#[cfg(all(feature = "timestamp-server", feature = "timestamp-http"))] +fn spawn_timestamp_server_with_max_requests(max_requests: u64) -> (PsignServerGuard, String) { let mut server_cmd = std::process::Command::new(assert_cmd::cargo::cargo_bin("psign-server")); + let max_requests = max_requests.to_string(); server_cmd.args([ "timestamp-server", "--listen", @@ -2386,7 +2500,7 @@ fn spawn_timestamp_server() -> (PsignServerGuard, String) { "--gen-time", "20240102030405Z", "--max-requests", - "1", + max_requests.as_str(), ]); server_cmd.stdout(std::process::Stdio::piped()); server_cmd.stderr(std::process::Stdio::piped());