From 90d1fa3438478cd3971a6d96d982f66cb2507fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 20 May 2026 19:18:30 -0400 Subject: [PATCH] improve azure artifact signing support --- README.md | 2 +- crates/psign-codesigning-rest/src/lib.rs | 66 +++- .../tests/codesign_rest_mock.rs | 79 ++++ crates/psign-digest-cli/Cargo.toml | 1 + crates/psign-digest-cli/src/main.rs | 295 ++++++++++++++- docs/gap-analysis-signing-platforms.md | 8 +- docs/linux-signing-pipelines.md | 36 +- docs/migration-artifact-signing.md | 44 ++- docs/psign-cli-matrix.json | 6 +- docs/psign-cli-matrix.md | 1 + src/bin/psign-server.rs | 10 +- src/cli.rs | 36 +- src/portable_sign.rs | 352 +++++++++++++++++- src/win/sign_core.rs | 57 +++ tests/cli_pe_digest.rs | 200 +++++++++- 15 files changed, 1157 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 0638787..7a1c77d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Canonical repository: . - `timestamp`: Rust mssign32 core (`SignerTimeStampEx3`/`SignerTimeStampEx2`) plus AppX restrictions. - `rdp`: Rust port of **`rdpsign.exe`** for `.rdp` files (`SignScope` / `Signature` records, detached PKCS#7 over the secure-settings blob). - `cert-store`: Portable file-backed certificate store under `~/.psign/cert-store` by default, with Windows-style store/thumbprint selection. -- `portable ...`: Cross-platform digest, verification, trust, signing, package, RFC3161, and remote-hash helpers that avoid Win32 APIs. +- `portable ...`: Cross-platform digest, verification, trust, signing, package, RFC3161, and remote-hash helpers that avoid Win32 APIs, including PE/WinMD signing through Azure Artifact Signing REST without Microsoft client DLLs. ## MSIX parity notes diff --git a/crates/psign-codesigning-rest/src/lib.rs b/crates/psign-codesigning-rest/src/lib.rs index c792ee8..6c29980 100644 --- a/crates/psign-codesigning-rest/src/lib.rs +++ b/crates/psign-codesigning-rest/src/lib.rs @@ -8,6 +8,7 @@ use serde_json::Value; use std::thread; use std::time::Duration; +pub const DEFAULT_API_VERSION: &str = "2024-06-15"; const DEFAULT_SCOPE: &str = "https://codesigning.azure.net/.default"; const MI_RESOURCE: &str = "https://codesigning.azure.net"; @@ -41,6 +42,13 @@ pub struct CodesigningSubmitParams { pub endpoint_base_url: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodesigningSignatureResult { + pub signature: Vec, + pub signing_certificate: Vec, + pub final_json: Value, +} + fn data_plane_base_url(params: &CodesigningSubmitParams) -> String { if let Some(ref u) = params.endpoint_base_url { let t = u.trim().trim_end_matches('/'); @@ -51,6 +59,17 @@ fn data_plane_base_url(params: &CodesigningSubmitParams) -> String { format!("https://{}.codesigning.azure.net", params.region.trim()) } +fn operation_location_url(base: &str, location: &str) -> String { + let trimmed = location.trim(); + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + return trimmed.to_string(); + } + if trimmed.starts_with('/') { + return format!("{}{}", base.trim_end_matches('/'), trimmed); + } + format!("{}/{}", base.trim_end_matches('/'), trimmed) +} + fn normalize_authority(authority: Option<&str>) -> String { authority .unwrap_or("https://login.microsoftonline.com") @@ -210,7 +229,9 @@ pub fn submit_codesign_hash_blocking( .map(str::trim) .filter(|s| !s.is_empty()) { - req = req.header("x-ms-correlation-id", c); + req = req + .header("x-correlation-id", c) + .header("x-ms-correlation-id", c); } let rsp = req.send().context("codesign :sign POST")?; @@ -234,8 +255,14 @@ pub fn submit_codesign_hash_blocking( } let poll_url = if let Some(loc) = op_location { - loc - } else if let Some(id) = accept_json.get("id").and_then(|v| v.as_str()) { + operation_location_url(&base, &loc) + } else if accept_json.get("status").and_then(Value::as_str) == Some("Succeeded") { + return Ok(accept_json); + } else if let Some(id) = accept_json + .get("id") + .or_else(|| accept_json.get("operationId")) + .and_then(Value::as_str) + { format!( "{base}/codesigningaccounts/{account}/certificateprofiles/{profile}/sign/{id}?api-version={api}", ) @@ -248,6 +275,39 @@ pub fn submit_codesign_hash_blocking( poll_operation(&http, &token, &poll_url) } +fn sign_result_object(v: &Value) -> &Value { + v.get("result").unwrap_or(v) +} + +fn decode_standard_base64_field(obj: &Value, field: &str) -> Result> { + let s = obj + .get(field) + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("codesign result missing {field}"))?; + base64::engine::general_purpose::STANDARD + .decode(s.trim()) + .with_context(|| format!("decode codesign result {field}")) +} + +pub fn codesign_signature_result_from_json( + final_json: Value, +) -> Result { + let result = sign_result_object(&final_json); + Ok(CodesigningSignatureResult { + signature: decode_standard_base64_field(result, "signature")?, + signing_certificate: decode_standard_base64_field(result, "signingCertificate")?, + final_json, + }) +} + +pub fn submit_codesign_hash_signature_blocking( + params: &CodesigningSubmitParams, + debug: impl Fn(&str), +) -> Result { + let final_json = submit_codesign_hash_blocking(params, debug)?; + codesign_signature_result_from_json(final_json) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/psign-codesigning-rest/tests/codesign_rest_mock.rs b/crates/psign-codesigning-rest/tests/codesign_rest_mock.rs index d8472f0..36493c7 100644 --- a/crates/psign-codesigning-rest/tests/codesign_rest_mock.rs +++ b/crates/psign-codesigning-rest/tests/codesign_rest_mock.rs @@ -217,6 +217,85 @@ fn submit_sign_sends_x_ms_correlation_id_when_set() { poll_mock.assert(); } +#[test] +fn submit_sign_sends_stable_x_correlation_id_when_set() { + let mut server = Server::new(); + let base = server.url().trim_end_matches('/').to_string(); + let poll_url = format!("{base}/operations/corr-stable"); + + let post_mock = server + .mock( + "POST", + Matcher::Regex( + r"/codesigningaccounts/c/certificateprofiles/p:sign(\?.*)?$".to_string(), + ), + ) + .match_header("x-correlation-id", "trace-stable") + .with_status(202) + .with_header("Operation-Location", &poll_url) + .with_body("{}") + .create(); + + let poll_mock = server + .mock( + "GET", + Matcher::Regex(r"/operations/corr-stable(\?.*)?$".to_string()), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"status":"Succeeded"}"#) + .create(); + + let params = CodesigningSubmitParams { + region: "unused".into(), + account_name: "c".into(), + profile_name: "p".into(), + digest: vec![1, 2, 3], + signature_algorithm: "RS256".into(), + api_version: psign_codesigning_rest::DEFAULT_API_VERSION.into(), + correlation_id: Some("trace-stable".into()), + authority: None, + auth: CodesigningAuth::Bearer("tok".into()), + endpoint_base_url: Some(base), + }; + + submit_codesign_hash_blocking(¶ms, |_| {}).expect("submit"); + post_mock.assert(); + poll_mock.assert(); +} + +#[test] +fn typed_signature_result_accepts_stable_result_wrapper() { + let sig = vec![0xabu8; 256]; + let cert = vec![0x30, 0x82, 0x01, 0x23]; + let json = json!({ + "status": "Succeeded", + "result": { + "signature": base64::engine::general_purpose::STANDARD.encode(&sig), + "signingCertificate": base64::engine::general_purpose::STANDARD.encode(&cert) + } + }); + + let parsed = psign_codesigning_rest::codesign_signature_result_from_json(json).expect("parse"); + assert_eq!(parsed.signature, sig); + assert_eq!(parsed.signing_certificate, cert); +} + +#[test] +fn typed_signature_result_accepts_legacy_top_level_fields() { + let sig = vec![0xcdu8; 256]; + let cert = vec![0x30, 0x82, 0x02, 0x34]; + let json = json!({ + "status": "Succeeded", + "signature": base64::engine::general_purpose::STANDARD.encode(&sig), + "signingCertificate": base64::engine::general_purpose::STANDARD.encode(&cert) + }); + + let parsed = psign_codesigning_rest::codesign_signature_result_from_json(json).expect("parse"); + assert_eq!(parsed.signature, sig); + assert_eq!(parsed.signing_certificate, cert); +} + #[test] fn submit_poll_failed_status_returns_error() { let mut server = Server::new(); diff --git a/crates/psign-digest-cli/Cargo.toml b/crates/psign-digest-cli/Cargo.toml index 9d2b52c..f3fa124 100644 --- a/crates/psign-digest-cli/Cargo.toml +++ b/crates/psign-digest-cli/Cargo.toml @@ -34,6 +34,7 @@ base64 = { version = "0.22", optional = true } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"], optional = true } sha1 = "0.10" sha2 = "0.10" +x509-cert = "0.2.5" [dev-dependencies] assert_cmd = "2" diff --git a/crates/psign-digest-cli/src/main.rs b/crates/psign-digest-cli/src/main.rs index 17b389c..e5f3fa4 100644 --- a/crates/psign-digest-cli/src/main.rs +++ b/crates/psign-digest-cli/src/main.rs @@ -4,7 +4,7 @@ // for formats implemented in `psign-sip-digest`. This does not replace full `psign` verify. use anyhow::{Context, Result, anyhow}; -#[cfg(feature = "azure-kv-sign-portable")] +#[cfg(any(feature = "azure-kv-sign-portable", feature = "artifact-signing-rest"))] use base64::Engine as _; use clap::{Args, Parser, Subcommand, ValueEnum}; use psign_authenticode_trust::{ @@ -21,7 +21,8 @@ use psign_azure_kv_rest::{ }; #[cfg(feature = "artifact-signing-rest")] use psign_codesigning_rest::{ - CodesigningAuth, CodesigningSubmitParams, submit_codesign_hash_blocking, + CodesigningAuth, CodesigningSubmitParams, DEFAULT_API_VERSION, + submit_codesign_hash_blocking, submit_codesign_hash_signature_blocking, }; use psign_opc_sign::{nuget, vsix}; use psign_sip_digest::cab_digest::{self, @@ -722,6 +723,8 @@ enum Command { azure_key_vault_client_secret: Option, #[arg(long = "azure-authority")] azure_authority: Option, + #[command(flatten)] + artifact_signing: ArtifactSigningPortableOptions, /// Output signed PE path. #[arg(long, value_name = "PATH")] output: PathBuf, @@ -1201,7 +1204,7 @@ struct ArtifactSigningSubmitPortableArgs { digest_file: PathBuf, #[arg(long, default_value = "RS256")] signature_algorithm: String, - #[arg(long, default_value = "2023-06-15-preview")] + #[arg(long, default_value = DEFAULT_API_VERSION)] api_version: String, #[arg(long)] correlation_id: Option, @@ -1222,6 +1225,44 @@ struct ArtifactSigningSubmitPortableArgs { endpoint_base_url: Option, } +#[derive(Args, Debug, Clone, Default)] +struct ArtifactSigningPortableOptions { + /// Artifact Signing metadata JSON (same shape as Microsoft's dlib /dmdf file). + #[arg(long = "artifact-signing-metadata", value_name = "PATH")] + metadata: Option, + /// Regional hostname segment, e.g. `westus`, when not using metadata Endpoint. + #[arg(long = "artifact-signing-region")] + region: Option, + /// Explicit data-plane endpoint, e.g. `https://wus2.codesigning.azure.net`. + #[arg(long = "artifact-signing-endpoint")] + endpoint: Option, + #[arg(long = "artifact-signing-account-name")] + account_name: Option, + #[arg(long = "artifact-signing-profile-name")] + profile_name: Option, + #[arg(long = "artifact-signing-signature-algorithm")] + signature_algorithm: Option, + #[arg(long = "artifact-signing-api-version")] + api_version: Option, + #[arg(long = "artifact-signing-correlation-id")] + correlation_id: Option, + #[arg(long = "artifact-signing-access-token")] + access_token: Option, + #[arg(long = "artifact-signing-managed-identity")] + managed_identity: bool, + #[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-authority")] + authority: Option, + /// Override data-plane origin for deterministic local tests. + #[arg(long = "artifact-signing-endpoint-base-url", hide = true)] + endpoint_base_url: Option, +} + #[cfg(feature = "artifact-signing-rest")] fn validate_portable_submit_args(args: &ArtifactSigningSubmitPortableArgs) -> Result<()> { let has_tok = args @@ -1581,9 +1622,220 @@ struct ArtifactSigningMetadataDoc { CodeSigningAccountName: String, CertificateProfileName: String, #[serde(default)] + CorrelationId: Option, + #[serde(default)] ExcludeCredentials: Option>, } +fn text_opt(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned) +} + +#[cfg(feature = "artifact-signing-rest")] +fn artifact_signature_algorithm_for_digest(digest: PortableSignDigest) -> &'static str { + match digest { + PortableSignDigest::Sha256 => "RS256", + PortableSignDigest::Sha384 => "RS384", + PortableSignDigest::Sha512 => "RS512", + } +} + +fn artifact_signing_requested(args: &ArtifactSigningPortableOptions) -> bool { + args.metadata.is_some() + || text_opt(args.region.as_deref()).is_some() + || text_opt(args.endpoint.as_deref()).is_some() + || text_opt(args.account_name.as_deref()).is_some() + || text_opt(args.profile_name.as_deref()).is_some() + || text_opt(args.signature_algorithm.as_deref()).is_some() + || text_opt(args.api_version.as_deref()).is_some() + || text_opt(args.correlation_id.as_deref()).is_some() + || text_opt(args.access_token.as_deref()).is_some() + || args.managed_identity + || 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.authority.as_deref()).is_some() + || text_opt(args.endpoint_base_url.as_deref()).is_some() +} + +#[cfg(feature = "artifact-signing-rest")] +fn portable_submit_auth_parts( + access_token: Option<&str>, + managed_identity: bool, + tenant_id: Option<&str>, + client_id: Option<&str>, + client_secret: Option<&str>, +) -> 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(), + }) +} + +#[cfg(feature = "artifact-signing-rest")] +fn artifact_signing_params_for_digest( + args: &ArtifactSigningPortableOptions, + digest: Vec, + default_signature_algorithm: &str, +) -> Result { + let metadata = if let Some(path) = args.metadata.as_deref() { + let raw = read_json_input(Some(path))?; + Some( + serde_json::from_slice::(&raw) + .context("parse Artifact Signing metadata JSON")?, + ) + } else { + None + }; + let endpoint = text_opt(args.endpoint_base_url.as_deref()) + .or_else(|| text_opt(args.endpoint.as_deref())) + .or_else(|| metadata.as_ref().and_then(|m| text_opt(Some(&m.Endpoint)))); + let region = text_opt(args.region.as_deref()).unwrap_or_else(|| "unused".to_string()); + let account_name = text_opt(args.account_name.as_deref()) + .or_else(|| { + metadata + .as_ref() + .and_then(|m| text_opt(Some(&m.CodeSigningAccountName))) + }) + .ok_or_else(|| anyhow!("Artifact Signing requires --artifact-signing-account-name or metadata CodeSigningAccountName"))?; + let profile_name = text_opt(args.profile_name.as_deref()) + .or_else(|| { + metadata + .as_ref() + .and_then(|m| text_opt(Some(&m.CertificateProfileName))) + }) + .ok_or_else(|| anyhow!("Artifact Signing requires --artifact-signing-profile-name or metadata CertificateProfileName"))?; + let auth = portable_submit_auth_parts( + args.access_token.as_deref(), + args.managed_identity, + args.tenant_id.as_deref(), + args.client_id.as_deref(), + args.client_secret.as_deref(), + )?; + Ok(CodesigningSubmitParams { + region, + account_name, + profile_name, + digest, + signature_algorithm: text_opt(args.signature_algorithm.as_deref()) + .unwrap_or_else(|| default_signature_algorithm.to_string()), + api_version: text_opt(args.api_version.as_deref()) + .unwrap_or_else(|| DEFAULT_API_VERSION.to_string()), + correlation_id: text_opt(args.correlation_id.as_deref()) + .or_else(|| metadata.as_ref().and_then(|m| text_opt(m.CorrelationId.as_deref()))), + authority: text_opt(args.authority.as_deref()), + auth, + endpoint_base_url: endpoint, + }) +} + +#[cfg(feature = "artifact-signing-rest")] +fn parse_artifact_signing_certificates(bytes: &[u8]) -> Result<(x509_cert::Certificate, Vec)> { + if let Ok(text) = std::str::from_utf8(bytes) + && text.contains("-----BEGIN CERTIFICATE-----") + { + let mut certs = Vec::new(); + let mut rest = text; + while let Some(start) = rest.find("-----BEGIN CERTIFICATE-----") { + rest = &rest[start..]; + let Some(end) = rest.find("-----END CERTIFICATE-----") else { + return Err(anyhow!("unterminated PEM certificate in Artifact Signing signingCertificate")); + }; + let end = end + "-----END CERTIFICATE-----".len(); + certs.push( + rdp::parse_certificate(rest[..end].as_bytes()) + .context("parse Artifact Signing PEM certificate")?, + ); + rest = &rest[end..]; + } + let mut iter = certs.into_iter(); + let signer = iter + .next() + .ok_or_else(|| anyhow!("Artifact Signing signingCertificate did not contain a certificate"))?; + return Ok((signer, iter.collect())); + } + Ok(( + rdp::parse_certificate(bytes).context("parse Artifact Signing DER signing certificate")?, + Vec::new(), + )) +} + +#[cfg(feature = "artifact-signing-rest")] +fn create_pe_authenticode_pkcs7_der_artifact_signing( + pe: &[u8], + digest: PortableSignDigest, + chain_certs: Vec, + args: &ArtifactSigningPortableOptions, +) -> Result> { + let digest_algorithm: pkcs7::AuthenticodeSigningDigest = digest.into(); + let pe_digest = pe_authenticode_digest(pe, digest_algorithm.pe_hash_kind())?; + let indirect = pkcs7::pe_spc_indirect_data(digest_algorithm, &pe_digest)?; + 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) = parse_artifact_signing_certificates(&signed.signing_certificate)?; + 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()))?, + ); + } + pkcs7::create_authenticode_pkcs7_der_with_rsa_signature( + indirect, + digest_algorithm, + signer_cert, + chain, + &signed.signature, + ) +} + fn read_json_input(path: Option<&Path>) -> Result> { use std::io::Read; match path { @@ -2051,6 +2303,7 @@ where azure_key_vault_client_id, azure_key_vault_client_secret, azure_authority, + artifact_signing, output, } => { let pe = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; @@ -2080,12 +2333,40 @@ where || azure_authority .as_deref() .is_some_and(|s| !s.trim().is_empty()); - if has_local && has_kv { + let has_artifact = artifact_signing_requested(&artifact_signing); + if [has_local, has_kv, has_artifact] + .into_iter() + .filter(|x| *x) + .count() + > 1 + { return Err(anyhow!( - "portable sign-pe accepts either --cert/--key or --azure-key-vault-* options, not both" + "portable sign-pe accepts only one signing source: --cert/--key, --azure-key-vault-*, or --artifact-signing-*" )); } - let mut pkcs7 = if has_kv { + let mut pkcs7 = if has_artifact { + #[cfg(feature = "artifact-signing-rest")] + { + create_pe_authenticode_pkcs7_der_artifact_signing( + &pe, + digest, + chain_certs, + &artifact_signing, + ) + .with_context(|| { + format!( + "create portable Azure Artifact Signing Authenticode signature for {}", + path.display() + ) + })? + } + #[cfg(not(feature = "artifact-signing-rest"))] + { + return Err(anyhow!( + "portable sign-pe Artifact Signing support requires the artifact-signing-rest feature" + )); + } + } else if has_kv { #[cfg(feature = "azure-kv-sign-portable")] { create_pe_authenticode_pkcs7_der_azure_kv( @@ -2122,7 +2403,7 @@ where (Some(cert), Some(key)) => (cert, key), _ => { return Err(anyhow!( - "portable sign-pe requires either --cert and --key, or --azure-key-vault-url and --azure-key-vault-certificate" + "portable sign-pe requires --cert and --key, --azure-key-vault-url and --azure-key-vault-certificate, or --artifact-signing-* options" )); } }; diff --git a/docs/gap-analysis-signing-platforms.md b/docs/gap-analysis-signing-platforms.md index a208f2c..15b6eb3 100644 --- a/docs/gap-analysis-signing-platforms.md +++ b/docs/gap-analysis-signing-platforms.md @@ -12,7 +12,7 @@ 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, timestamp-pe-rfc3161 | +| 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 | | ESD / WIM | Sign, WT verify | Same | verify-esd | @@ -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**; embedding still requires **Windows `SignerSignEx3` + dlib** or **future portable PKCS#7 + embed** (see roadmap). +**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. ## Expanded signable-surface audit by mode @@ -84,12 +84,12 @@ 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 | **`artifact-signing-submit`** (**`--features artifact-signing-rest`**) runs on **Linux/macOS** via **`psign-tool portable`** or on Windows via **`psign-tool`** — same **`:sign`** LRO (**hash → JSON**). **Embedding** PKCS#7 still requires **`SignerSignEx3`** + dlib or future portable CMS/embed. **`psign-tool portable`** validates **`--dmdf`** JSON without network. | +| **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. | | **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. | -**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`), **Key Vault `keys/sign`** on digest files (**`azure-key-vault-sign-digest`** with **`--features azure-kv-sign-portable`**), **`: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). +**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). **Long-term Linux signing** (if required): extend the portable **CMS `SignerInfo` production** (inside **`SignedData`**) + **format-specific embedding** beyond the current PE/CAB/MSI/catalog subset to MSIX `ContentTypes` / manifest glue and other package-native formats, then combine with **remote signing** (KV REST, Artifact Signing `:sign` LRO). [`pkcs7.rs`](crates/psign-sip-digest/src/pkcs7.rs) holds parse/replace helpers, **`signed_data_replace_first_signer_info`**, **`encode_pkcs7_content_info_signed_data_der`**, **RSA PKCS#1 RS256** prehash ↔ **`SignerInfo.signature`** parity tests (`rsa_pkcs1v15_signed_attrs_verify`), and **`signer_info_sha256_digest_over_signed_attrs`** (documented KV **`RS256`** input shape); [`pe_embed.rs`](crates/psign-sip-digest/src/pe_embed.rs) can **wrap PKCS#7**, **append** rows (including after signer splice experiments), and **recompute `CheckSum`**. **`psign-tool portable pe-signer-rs256-prehash`** surfaces the **32-byte** prehash for Linux KV workflows; MSIX signing/embed and non-PE timestamp mutation remain backlog (see [`rust-sip-gaps.md`](rust-sip-gaps.md)). diff --git a/docs/linux-signing-pipelines.md b/docs/linux-signing-pipelines.md index 8a6da90..44002e6 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 or Azure Key Vault RSA signing, 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. 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. 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,6 +59,34 @@ 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 + +With **`--features artifact-signing-rest`**, PE/WinMD 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 \ + --artifact-signing-metadata ./artifact-signing-metadata.json \ + --artifact-signing-managed-identity \ + --timestamp-url http://timestamp.acs.microsoft.com/ \ + --timestamp-digest sha256 \ + --digest sha256 \ + --output ./MyApp.signed.exe +``` + +The native-shaped in-place form accepts the same metadata file through `--dmdf`: + +```bash +psign-tool --mode portable sign \ + --dmdf ./artifact-signing-metadata.json \ + --artifact-signing-managed-identity \ + --timestamp-url http://timestamp.acs.microsoft.com/ \ + --timestamp-digest sha256 \ + --digest sha256 \ + ./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. + ## 1.5 RFC 3161 TSA query/reply (DER only; no embed) **`psign-tool portable rfc3161-timestamp-req`** builds **`TimeStampReq`** DER from **`--digest-hex`** / **`--digest-file`** (message-imprint preimage; optional **`--nonce`**, **`--cert-req`**). **`rfc3161-timestamp-resp-inspect`** prints **`pki_status`** / **`pki_status_int`** (raw **`PKIStatus`** INTEGER) / **`granted`** / token length, **`time_stamp_token_prefix_hex`** (first **16** octets of the **`timeStampToken`** TLV), **`status_strings_json`**, **`fail_info_tlv_hex`**, and **`fail_info_flags_json`** from **`TimeStampResp`** DER. When the token is a parseable CMS **`id-ct-TSTInfo`** timestamp token, it also prints structural **`tst_info_*`** fields for policy OID, message-imprint digest OID/hash, serial, **`genTime`**, and nonce; **`--expect-digest-hex`** and **`--expect-nonce`** add request-binding diagnostics (`tst_info_message_imprint_match`, `tst_info_nonce_match`). These fields are diagnostic only and do not imply TSA trust or CMS signature validation. Build with **`--features timestamp-http`** for **`rfc3161-timestamp-http-post --url …`** (Rustls POST **`application/timestamp-query`**, response DER to stdout / **`--output`**); otherwise use **`curl`** or OpenSSL **`ts`**. **`timestamp-pe-rfc3161`** can attach the granted token to an existing PE Authenticode `SignerInfo`; non-PE timestamp mutation still goes through **`psign-tool`** / **`SignerTimeStampEx3`** today. @@ -74,9 +102,9 @@ It serves RFC 3161 **`POST`** requests as **`application/timestamp-reply`** with For Windows parser/trust experiments, **`--cert-output PATH`** writes the generated root CA certificate and **`--tsa-cert-output PATH`** writes the generated TSA leaf certificate. The token includes the leaf and root certificates; local trust-store setup is still test-only. -## 2. Azure Artifact Signing — digest + REST on Linux, embed on Windows +## 2. Azure Artifact Signing — low-level digest + REST helper -Build **`psign-tool portable`** with **`--features artifact-signing-rest`**. +Build **`psign-tool portable`** with **`--features artifact-signing-rest`**. For PE/WinMD, prefer section 1.3. Use this lower-level helper only when another pipeline already prepared the exact digest that the service should sign. 1. **Subject digest** (raw bytes for REST body): @@ -101,7 +129,7 @@ Build **`psign-tool portable`** with **`--features artifact-signing-rest`**. --managed-identity # or --access-token / tenant + client-id + client-secret ``` -3. **Embed** PKCS#7 / complete Authenticode: still **`psign-tool`** + **`SignerSignEx3`** (and typically **`--dlib`** / **`--dmdf`** for Trusted Signing) until a portable embedder exists. +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. Optional debug: **`SIGNTOOL_PORTABLE_DEBUG=1`**. diff --git a/docs/migration-artifact-signing.md b/docs/migration-artifact-signing.md index f7867e9..96a6388 100644 --- a/docs/migration-artifact-signing.md +++ b/docs/migration-artifact-signing.md @@ -4,13 +4,13 @@ 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`**; use it **after** embedding for digest consistency checks and **anchor-based trust verification** (see [Portable post-sign verification](#portable-post-sign-verification) below). With **`--features artifact-signing-rest`** it can still call the same **`:sign`** REST LRO as **`psign-tool`** (hash in → JSON out — embedding remains a separate step). +**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. -### Optional: Azure Code Signing **REST** hash signing (experimental) +### Azure Code Signing **REST** hash signing PowerShell OpenAuthenticode can sign via the **`Azure.CodeSigning.Sdk`** client against the same **data-plane** API documented in Azure REST specs (**`CertificateProfileOperations_Sign`**, host template **`https://{region}.codesigning.azure.net/`**, OAuth scope **`https://codesigning.azure.net/.default`**). -With **`cargo build -p psign --features artifact-signing-rest --bin psign-tool`**: +With **`cargo build -p psign --features artifact-signing-rest --bin psign-tool`**, the low-level helper remains available: ```powershell psign-tool.exe artifact-signing-submit ` @@ -22,17 +22,17 @@ psign-tool.exe artifact-signing-submit ` --managed-identity ``` -This runs the **`:sign`** LRO and prints the final JSON (**`signature`**, **`signingCertificate`**, …). It does **not** embed an Authenticode PKCS#7 into a PE by itself — combine with your signing pipeline or continue using **`--dlib`** / **`--trusted-signing-dlib-root`** for **`SignerSignEx3`** embedding. +This runs the **`:sign`** LRO and prints the final JSON. The helper accepts both the current stable wrapped result shape (`result.signature`, `result.signingCertificate`) and older top-level test/service shapes. #### Linux / CI: same REST helper from **`psign-tool portable`** -Build or install with **`--features artifact-signing-rest`**, then use **`artifact-signing-submit`** with the same flags as Windows. Produce a raw Authenticode digest file from an unsigned PE with **`pe-digest --encoding raw --output digest.bin`** (SHA-256 → 32 bytes). +Build or install with **`--features artifact-signing-rest`**, then use **`artifact-signing-submit`** with the same flags as Windows when you need a low-level digest-to-signature call. **Do not confuse digest roles:** **`pe-digest`** is the **PE Authenticode image** fingerprint (typical **`:sign`** subject-hash samples for **unsigned** binaries). **`pe-signer-rs256-prehash --encoding raw`** is the **CMS RFC 5652 §5.4** **SHA-256** over the signer’s authenticated-attribute **`SET`** — the raw input Azure Key Vault **`keys/sign`** uses for **`RS256`** when you are re-signing **`SignerInfo`** on an **embedded PKCS#7** (see [`migration-azuresigntool.md`](migration-azuresigntool.md)). Trusted Signing **`:sign`** contracts follow Microsoft’s profile/docs; use the digest shape your integration expects. ```bash cargo build -p psign-digest-cli --features artifact-signing-rest --locked -./target/debug/psign-tool portable pe-digest --algorithm sha256 --encoding raw --output digest.bin ./MyApp.exe +./target/debug/psign-tool portable pe-signer-rs256-prehash --encoding raw --output digest.bin ./MyApp.signed-template.exe ./target/debug/psign-tool portable artifact-signing-submit \ --region westus --account-name myAccount --profile-name myProfile \ --digest-file digest.bin --signature-algorithm RS256 --managed-identity @@ -40,13 +40,43 @@ 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) + +For PE/WinMD, prefer the first-class portable signer instead of manually staging a digest: + +```bash +psign-tool portable sign-pe ./MyApp.exe \ + --artifact-signing-metadata ./artifact-signing-metadata.json \ + --artifact-signing-managed-identity \ + --timestamp-url http://timestamp.acs.microsoft.com/ \ + --timestamp-digest sha256 \ + --digest sha256 \ + --output ./MyApp.signed.exe +``` + +The native-shaped in-place form is also available: + +```bash +psign-tool --mode portable sign \ + --dmdf ./artifact-signing-metadata.json \ + --artifact-signing-managed-identity \ + --timestamp-url http://timestamp.acs.microsoft.com/ \ + --timestamp-digest sha256 \ + --digest sha256 \ + ./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`**. + +Artifact Signing certificates are short-lived; include **`--timestamp-url http://timestamp.acs.microsoft.com/ --timestamp-digest sha256`** for production signatures. + ## Flag mapping (Microsoft sample → psign-tool) | SignTool / docs | psign-tool | |-----------------|------------------| | `/dlib` path to `Azure.CodeSigning.Dlib.dll` | `--dlib ` | | Same, but NuGet extract root | `--trusted-signing-dlib-root ` → resolves to `\bin\x64\Azure.CodeSigning.Dlib.dll` or `\bin\x86\...` matching **this executable’s** architecture (`cfg!(target_pointer_width)`) | -| `/dmdf` metadata JSON | `--dmdf ` | +| `/dmdf` metadata JSON | Windows dlib: `--dmdf `; portable REST PE: `--dmdf ` or `--artifact-signing-metadata ` | | `/fd SHA256` | `--digest sha256` | | `/tr` RFC3161 URL | `--timestamp-url ` | | `/td SHA256` | `--timestamp-digest sha256` | diff --git a/docs/psign-cli-matrix.json b/docs/psign-cli-matrix.json index a32ef76..5309916 100644 --- a/docs/psign-cli-matrix.json +++ b/docs/psign-cli-matrix.json @@ -39,7 +39,7 @@ {"native": "/di", "rust": "--digest-ingest", "tier": "P1", "status": "partial"}, {"native": "/ds", "rust": "--digest-sign-only", "tier": "P1", "status": "partial"}, {"native": "/dlib", "rust": "--dlib", "tier": "P0", "status": "implemented"}, - {"native": "/dmdf", "rust": "--dmdf", "tier": "P0", "status": "implemented"}, + {"native": "/dmdf", "rust": "--dmdf", "tier": "P0", "status": "implemented", "notes": "Windows dlib metadata; in --mode portable Artifact Signing PE/WinMD signing, accepted as metadata JSON without loading the dlib"}, {"native": "/dxml", "rust": "--digest-xml", "tier": "P1", "status": "partial"}, {"native": "/du", "rust": "--description-url", "tier": "P0", "status": "implemented"}, {"native": "/f", "rust": "--pfx", "tier": "P0", "status": "implemented"}, @@ -244,7 +244,7 @@ {"name": "list-pe-pkcs7", "maps_to_native_concept": "Enumerate embedded PKCS#7 rows (byte_len per index)"}, {"name": "inspect-pe-spc-indirect", "maps_to_native_concept": "JSON for SpcIndirectDataContent / digest in embedded PE PKCS#7"}, {"name": "append-pe-pkcs7", "maps_to_native_concept": "Experimental: append PKCS#7 row + recompute PE CheckSum — not SignerSignEx3"}, - {"name": "sign-pe", "maps_to_native_concept": "Portable PE Authenticode signing: create RSA/SHA-2 SignedData with local key or Azure Key Vault RSA signing, optionally attach RFC3161 timestamp, append WIN_CERTIFICATE, recompute CheckSum; no WinTrust policy"}, + {"name": "sign-pe", "maps_to_native_concept": "Portable PE Authenticode signing: create RSA/SHA-2 SignedData with local key, Azure Key Vault RSA signing, or Azure Artifact Signing REST; optionally attach RFC3161 timestamp, append WIN_CERTIFICATE, recompute CheckSum; no WinTrust policy"}, {"name": "sign-cab", "maps_to_native_concept": "Portable unsigned single-volume CAB Authenticode signing: insert reserve header, create RSA/SHA-2 SignedData, append tail PKCS#7"}, {"name": "sign-msi", "maps_to_native_concept": "Portable MSI/MSP Authenticode signing: create RSA/SHA-2 SignedData and write the DigitalSignature OLE stream"}, {"name": "sign-catalog", "maps_to_native_concept": "Portable generic catalog signing: author CTL member entries, create RSA/SHA-2 SignedData over Microsoft CTL eContent; no catalog database or driver/INF policy"}, @@ -253,7 +253,7 @@ {"name": "pe-checksum", "maps_to_native_concept": "Optional header CheckSum vs ImageHlp-style computation"}, {"name": "inspect-authenticode", "maps_to_native_concept": "Portable PKCS#7 inspection JSON (PE or raw)"}, {"name": "artifact-signing-metadata-check", "maps_to_native_concept": "Validate --dmdf-style JSON shape (no network)"}, - {"name": "artifact-signing-submit", "maps_to_native_concept": "Trusted Signing :sign LRO (feature artifact-signing-rest); hash file in, JSON out"}, + {"name": "artifact-signing-submit", "maps_to_native_concept": "Trusted Signing :sign LRO (feature artifact-signing-rest); hash file in, JSON out; PE/WinMD embedding is handled by sign-pe --artifact-signing-*"}, {"name": "azure-key-vault-sign-digest", "maps_to_native_concept": "KV keys/sign on digest file (feature azure-kv-sign-portable); AzureSignTool remote step analogue"}, {"name": "nupkg-signature-info", "maps_to_native_concept": "NuGet package-signature marker inspection for `.signature.p7s` (not SIP; groundwork for dotnet nuget sign-compatible portable signing)"}, {"name": "nupkg-digest", "maps_to_native_concept": "Unsigned NuGet package byte hash used by the package-signature properties document (SHA-256/384/512; rejects already signed packages)"}, diff --git a/docs/psign-cli-matrix.md b/docs/psign-cli-matrix.md index 7c11760..7423b4a 100644 --- a/docs/psign-cli-matrix.md +++ b/docs/psign-cli-matrix.md @@ -38,6 +38,7 @@ Full native ↔ Rust mappings, tiers, and per-flag notes are **only** maintained - **Detached PKCS#7**: Implemented with chain policy; bare CMS `SignedData` from `signtool /p7` is normalized to PKCS#7 `ContentInfo` before `CryptVerifyDetachedMessageSignature` (`src/win/verify_detached.rs`). - **Verify `/bp`, `/enclave`**: CLI accepted; explicit not-implemented errors pending published WinTrust action/policy GUIDs (JSON marks partial). - **RDP signing**: `psign-tool rdp --sha256 file.rdp` ports `rdpsign.exe` by writing native `SignScope` / `Signature` records using detached PKCS#7 over the RDP secure-settings blob. `psign-tool portable rdp --cert cert.der --key key.pk8 file.rdp` uses the same RDP blob/record logic with portable RSA/SHA-256 CMS creation; fixtures cover UTF-8, UTF-16 with/without BOM, stale/partial signatures, malformed records, and a repo-test-cert signed sample. +- **Artifact Signing REST for PE/WinMD**: `psign-tool portable sign-pe --artifact-signing-* --timestamp-url ...` and `psign-tool --mode portable sign --dmdf metadata.json --artifact-signing-* --timestamp-url ...` build, timestamp, and embed PE Authenticode signatures without Microsoft client DLLs. Windows dlib mode remains available for MSIX/AppX and other SIP formats. ## Gaps intentionally partial diff --git a/src/bin/psign-server.rs b/src/bin/psign-server.rs index 0c8f74d..ed86e55 100644 --- a/src/bin/psign-server.rs +++ b/src/bin/psign-server.rs @@ -1038,10 +1038,12 @@ fn handle_artifact_signing_client( serde_json::json!({ "id": operation_id, "status": "Succeeded", - "signature": base64::engine::general_purpose::STANDARD.encode(signature), - "signingCertificate": base64::engine::general_purpose::STANDARD.encode(authority.identity.leaf_der()?), - "codeSigningAccountName": account, - "certificateProfileName": profile, + "result": { + "signature": base64::engine::general_purpose::STANDARD.encode(signature), + "signingCertificate": base64::engine::general_purpose::STANDARD.encode(authority.identity.leaf_der()?), + "codeSigningAccountName": account, + "certificateProfileName": profile, + } }) } ArtifactResponseMode::Failed => serde_json::json!({ diff --git a/src/cli.rs b/src/cli.rs index b3eb219..2bc34ec 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -320,7 +320,7 @@ pub struct ArtifactSigningSubmitArgs { pub digest_file: PathBuf, #[arg(long, default_value = "RS256")] pub signature_algorithm: String, - #[arg(long, default_value = "2023-06-15-preview")] + #[arg(long, default_value = "2024-06-15")] pub api_version: String, #[arg(long)] pub correlation_id: Option, @@ -696,6 +696,40 @@ pub struct SignArgs { /// OAuth authority host prefix (`-au`), e.g. `https://login.microsoftonline.com`. #[arg(long = "azure-authority", visible_alias = "au")] pub azure_authority: Option, + /// Artifact Signing metadata JSON (same shape as Microsoft's dlib `/dmdf` file) for REST-backed portable signing. + #[arg(long = "artifact-signing-metadata")] + pub artifact_signing_metadata: Option, + /// Artifact Signing regional hostname segment, e.g. `westus`, when not using metadata `Endpoint`. + #[arg(long = "artifact-signing-region")] + pub artifact_signing_region: Option, + /// Artifact Signing data-plane endpoint, e.g. `https://wus2.codesigning.azure.net`. + #[arg(long = "artifact-signing-endpoint")] + pub artifact_signing_endpoint: Option, + #[arg(long = "artifact-signing-account-name")] + pub artifact_signing_account_name: Option, + #[arg(long = "artifact-signing-profile-name")] + pub artifact_signing_profile_name: Option, + #[arg(long = "artifact-signing-signature-algorithm")] + pub artifact_signing_signature_algorithm: Option, + #[arg(long = "artifact-signing-api-version")] + pub artifact_signing_api_version: Option, + #[arg(long = "artifact-signing-correlation-id")] + pub artifact_signing_correlation_id: Option, + #[arg(long = "artifact-signing-access-token")] + pub artifact_signing_access_token: Option, + #[arg(long = "artifact-signing-managed-identity")] + pub artifact_signing_managed_identity: bool, + #[arg(long = "artifact-signing-tenant-id")] + pub artifact_signing_tenant_id: Option, + #[arg(long = "artifact-signing-client-id")] + pub artifact_signing_client_id: Option, + #[arg(long = "artifact-signing-client-secret")] + pub artifact_signing_client_secret: Option, + #[arg(long = "artifact-signing-authority")] + pub artifact_signing_authority: Option, + /// Override Artifact Signing data-plane origin for deterministic local tests. + #[arg(long = "artifact-signing-endpoint-base-url", hide = true)] + pub artifact_signing_endpoint_base_url: Option, /// Optional text file listing extra inputs to sign, one path per line (`-ifl`). #[arg(long = "input-file-list", visible_alias = "ifl")] pub sign_input_file_list: Option, diff --git a/src/portable_sign.rs b/src/portable_sign.rs index 9d09919..1e570ac 100644 --- a/src/portable_sign.rs +++ b/src/portable_sign.rs @@ -5,6 +5,14 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; pub fn sign_file(args: &SignArgs, _global: &GlobalOpts) -> Result { + if artifact_signing_requested(args) && azure_key_vault_requested(args) { + return Err(anyhow!( + "portable sign accepts either Azure Artifact Signing or Azure Key Vault options, not both" + )); + } + if artifact_signing_requested(args) { + return sign_file_artifact_signing(args); + } if azure_key_vault_requested(args) { return sign_file_azure_key_vault(args); } @@ -43,6 +51,32 @@ pub fn sign_file(args: &SignArgs, _global: &GlobalOpts) -> Result Ok(CommandOutput::with_exit(combined, success_exit_code(args))) } +fn sign_file_artifact_signing(args: &SignArgs) -> Result { + validate_artifact_signing_supported_options(args)?; + if args.files.is_empty() { + return Err(anyhow!( + "portable Artifact Signing sign requires at least one file" + )); + } + + let mut combined = String::new(); + for (idx, target) in args.files.iter().enumerate() { + if idx > 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("") + )); + } + Ok(CommandOutput::with_exit(combined, success_exit_code(args))) +} + fn sign_file_azure_key_vault(args: &SignArgs) -> Result { validate_azure_key_vault_supported_options(args)?; if args.files.is_empty() { @@ -160,6 +194,7 @@ fn validate_supported_options(args: &SignArgs) -> Result<()> { args.azure_key_vault_managed_identity, )?; 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)?; @@ -245,6 +280,96 @@ fn validate_azure_key_vault_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_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(), + )?; + Ok(()) +} + +fn validate_artifact_signing_supported_options(args: &SignArgs) -> Result<()> { + match args.digest { + DigestAlgorithm::Sha256 | DigestAlgorithm::Sha384 | DigestAlgorithm::Sha512 => {} + DigestAlgorithm::Sha1 | DigestAlgorithm::CertHash => { + return Err(anyhow!( + "portable Artifact Signing sign supports only --fd SHA256, SHA384, or SHA512, got {}", + args.digest.as_signtool_name() + )); + } + } + reject_path_option("--f/--pfx", &args.pfx)?; + reject_string_option("--p/--password", &args.password)?; + reject_bool_option("--a/--auto-select", args.auto_select)?; + reject_string_option("--n/--subject-name", &args.subject_name)?; + reject_string_option("--i/--issuer-name", &args.issuer_name)?; + reject_string_option("--sha1", &args.cert_sha1)?; + reject_string_option("--csp", &args.csp)?; + reject_string_option("--kc/--key-container", &args.key_container)?; + reject_bool_option("--sm/--machine-store", args.machine_store)?; + reject_option("--s/--store", args.store_name != "MY")?; + reject_path_option("--cert-store-dir", &args.cert_store_dir)?; + reject_bool_option("--as/--append-signature", args.append_signature)?; + reject_bool_option("--ph/--page-hashes", args.page_hashes)?; + reject_bool_option("--nph/--no-page-hashes", args.no_page_hashes)?; + reject_path_option("--dlib", &args.dlib)?; + reject_path_option( + "--trusted-signing-dlib-root", + &args.trusted_signing_dlib_root, + )?; + if args.artifact_signing_metadata.is_some() && args.dmdf.is_some() { + return Err(anyhow!( + "use either --artifact-signing-metadata or --dmdf as Artifact Signing metadata, not both" + )); + } + 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" + )); + } + if args.timestamp_url.is_none() && args.timestamp_digest.is_some() { + return Err(anyhow!( + "portable Artifact Signing sign requires --tr/--timestamp-url with --td/--timestamp-digest" + )); + } + reject_string_option("--t/--legacy-timestamp-url", &args.legacy_timestamp_url)?; + reject_string_option("--tseal/--seal-timestamp-url", &args.seal_timestamp_url)?; + reject_string_option("--d/--description", &args.description)?; + reject_string_option("--du/--description-url", &args.description_url)?; + reject_string_option("--r/--root-subject-name", &args.root_subject_name)?; + reject_string_option("--u/--eku-oid", &args.eku_oid)?; + reject_bool_option( + "--uw/--eku-windows-system-component", + args.eku_windows_system_component, + )?; + reject_string_option( + "--signing-cert-eku-prefix", + &args.signing_cert_eku_oid_prefix, + )?; + reject_path_option("--dg/--digest-generate", &args.digest_generate)?; + reject_bool_option("--ds/--digest-sign-only", args.digest_sign_only)?; + reject_path_option("--di/--digest-ingest", &args.digest_ingest)?; + reject_bool_option("--dxml/--digest-xml", args.digest_xml)?; + reject_path_option("--p7/--pkcs7-output-dir", &args.pkcs7_output_dir)?; + reject_string_option("--p7co/--pkcs7-content-oid", &args.pkcs7_content_oid)?; + reject_option( + "--p7ce/--pkcs7-content-embedding", + args.pkcs7_content_embedding.is_some(), + )?; + reject_string_option("--certificate-template", &args.certificate_template)?; + reject_option("--sa/--sign-auth", !args.sign_auth_pairs.is_empty())?; + reject_bool_option("--fdchw", args.warn_fd_digest_vs_cert_signature_hash)?; + reject_bool_option("--tdchw", args.warn_td_digest_vs_cert_signature_hash)?; + reject_bool_option("--rmc", args.relaxed_pe_marker_check)?; + reject_bool_option("--seal", args.add_sealing_signature)?; + reject_bool_option("--itos", args.intent_to_seal)?; + reject_bool_option("--force", args.force_seal_or_resign)?; + 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)?; @@ -314,6 +439,39 @@ fn sign_one_target_azure_key_vault(target: &Path, args: &SignArgs) -> Result<()> result } +fn sign_one_target_artifact_signing(target: &Path, args: &SignArgs) -> Result<()> { + let ext = target + .extension() + .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 {}", + 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()))?; + Ok(()) + }) + .and_then(|_| { + std::fs::remove_file(&tmp) + .with_context(|| format!("remove temporary output '{}'", tmp.display())) + }); + if result.is_err() { + let _ = std::fs::remove_file(&tmp); + } + result +} + fn run_portable_sign_pe_azure_key_vault( target: &Path, output: &Path, @@ -385,6 +543,111 @@ fn run_portable_sign_pe_azure_key_vault( .map_err(|_| anyhow!("portable sign-pe runner panicked"))? } +fn run_portable_sign_pe_artifact_signing( + target: &Path, + output: &Path, + args: &SignArgs, +) -> Result<()> { + let mut argv = Vec::new(); + argv.push(OsString::from("psign-tool")); + argv.push(OsString::from("sign-pe")); + argv.push(target.as_os_str().to_os_string()); + argv.push(OsString::from("--digest")); + argv.push(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_option(&mut 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)?)); + } + let metadata = args + .artifact_signing_metadata + .as_ref() + .or(args.dmdf.as_ref()); + push_path_option(&mut argv, "--artifact-signing-metadata", metadata); + push_option( + &mut argv, + "--artifact-signing-region", + &args.artifact_signing_region, + ); + push_option( + &mut argv, + "--artifact-signing-endpoint", + &args.artifact_signing_endpoint, + ); + push_option( + &mut argv, + "--artifact-signing-account-name", + &args.artifact_signing_account_name, + ); + push_option( + &mut argv, + "--artifact-signing-profile-name", + &args.artifact_signing_profile_name, + ); + push_option( + &mut argv, + "--artifact-signing-signature-algorithm", + &args.artifact_signing_signature_algorithm, + ); + push_option( + &mut argv, + "--artifact-signing-api-version", + &args.artifact_signing_api_version, + ); + push_option( + &mut argv, + "--artifact-signing-correlation-id", + &args.artifact_signing_correlation_id, + ); + push_option( + &mut argv, + "--artifact-signing-access-token", + &args.artifact_signing_access_token, + ); + if args.artifact_signing_managed_identity { + argv.push(OsString::from("--artifact-signing-managed-identity")); + } + push_option( + &mut argv, + "--artifact-signing-tenant-id", + &args.artifact_signing_tenant_id, + ); + push_option( + &mut argv, + "--artifact-signing-client-id", + &args.artifact_signing_client_id, + ); + push_option( + &mut argv, + "--artifact-signing-client-secret", + &args.artifact_signing_client_secret, + ); + push_option( + &mut argv, + "--artifact-signing-authority", + &args.artifact_signing_authority, + ); + push_option( + &mut argv, + "--artifact-signing-endpoint-base-url", + &args.artifact_signing_endpoint_base_url, + ); + argv.push(OsString::from("--output")); + argv.push(output.as_os_str().to_os_string()); + + std::thread::Builder::new() + .name("psign-portable-sign-pe-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-pe runner: {e}"))? + .join() + .map_err(|_| anyhow!("portable Artifact Signing sign-pe runner panicked"))? +} + fn portable_digest_name(digest: DigestAlgorithm) -> Result<&'static str> { match digest { DigestAlgorithm::Sha256 => Ok("sha256"), @@ -415,6 +678,13 @@ fn push_option(argv: &mut Vec, name: &str, value: &Option) { } } +fn push_path_option(argv: &mut Vec, name: &str, value: Option<&PathBuf>) { + if let Some(value) = value { + argv.push(OsString::from(name)); + argv.push(value.as_os_str().to_os_string()); + } +} + fn temporary_output_path(target: &Path) -> PathBuf { let file_name = target .file_name() @@ -435,6 +705,25 @@ fn azure_key_vault_requested(args: &SignArgs) -> bool { || text_present(&args.azure_authority) } +fn artifact_signing_requested(args: &SignArgs) -> bool { + args.artifact_signing_metadata.is_some() + || args.dmdf.is_some() + || text_present(&args.artifact_signing_region) + || text_present(&args.artifact_signing_endpoint) + || text_present(&args.artifact_signing_account_name) + || text_present(&args.artifact_signing_profile_name) + || text_present(&args.artifact_signing_signature_algorithm) + || text_present(&args.artifact_signing_api_version) + || text_present(&args.artifact_signing_correlation_id) + || text_present(&args.artifact_signing_access_token) + || args.artifact_signing_managed_identity + || 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_authority) + || text_present(&args.artifact_signing_endpoint_base_url) +} + fn text_present(value: &Option) -> bool { value.as_deref().is_some_and(|s| !s.trim().is_empty()) } @@ -449,7 +738,7 @@ fn success_exit_code(args: &SignArgs) -> i32 { fn reject_option(name: &str, present: bool) -> Result<()> { if present { return Err(anyhow!( - "portable sign does not support {name}; supported subsets are local store PE/WinMD signing (--sha1, --store/--s, --machine-store/--sm, --cert-store-dir, --fd SHA256) and Azure Key Vault PE/WinMD signing (--azure-key-vault-*, --fd SHA256/SHA384/SHA512)" + "portable sign does not support {name}; supported subsets are local store PE/WinMD signing (--sha1, --store/--s, --machine-store/--sm, --cert-store-dir, --fd SHA256), Azure Key Vault PE/WinMD signing (--azure-key-vault-*, --fd SHA256/SHA384/SHA512), and Azure Artifact Signing PE/WinMD signing (--artifact-signing-* or --dmdf)" )); } Ok(()) @@ -470,3 +759,64 @@ fn reject_path_option(name: &str, value: &Option) -> Result<()> { fn reject_vec_option(name: &str, value: &[PathBuf]) -> Result<()> { reject_option(name, !value.is_empty()) } + +fn reject_artifact_signing_options(args: &SignArgs) -> Result<()> { + reject_path_option( + "--artifact-signing-metadata", + &args.artifact_signing_metadata, + )?; + reject_string_option("--artifact-signing-region", &args.artifact_signing_region)?; + reject_string_option( + "--artifact-signing-endpoint", + &args.artifact_signing_endpoint, + )?; + reject_string_option( + "--artifact-signing-account-name", + &args.artifact_signing_account_name, + )?; + reject_string_option( + "--artifact-signing-profile-name", + &args.artifact_signing_profile_name, + )?; + reject_string_option( + "--artifact-signing-signature-algorithm", + &args.artifact_signing_signature_algorithm, + )?; + reject_string_option( + "--artifact-signing-api-version", + &args.artifact_signing_api_version, + )?; + reject_string_option( + "--artifact-signing-correlation-id", + &args.artifact_signing_correlation_id, + )?; + reject_string_option( + "--artifact-signing-access-token", + &args.artifact_signing_access_token, + )?; + reject_bool_option( + "--artifact-signing-managed-identity", + args.artifact_signing_managed_identity, + )?; + reject_string_option( + "--artifact-signing-tenant-id", + &args.artifact_signing_tenant_id, + )?; + reject_string_option( + "--artifact-signing-client-id", + &args.artifact_signing_client_id, + )?; + reject_string_option( + "--artifact-signing-client-secret", + &args.artifact_signing_client_secret, + )?; + reject_string_option( + "--artifact-signing-authority", + &args.artifact_signing_authority, + )?; + reject_string_option( + "--artifact-signing-endpoint-base-url", + &args.artifact_signing_endpoint_base_url, + )?; + Ok(()) +} diff --git a/src/win/sign_core.rs b/src/win/sign_core.rs index 36fa059..86d39c7 100644 --- a/src/win/sign_core.rs +++ b/src/win/sign_core.rs @@ -1,6 +1,7 @@ use crate::cli::{DigestAlgorithm, GlobalOpts, SignArgs}; use crate::win::code_sign_format; use anyhow::{Context, Result, anyhow}; +use serde::Deserialize; use std::ffi::CString; use std::iter::once; use std::mem::size_of; @@ -288,6 +289,61 @@ pub(crate) fn resolved_decoupled_dlib_path(args: &SignArgs) -> Option>, +} + +fn validate_artifact_signing_metadata_if_azure_dlib( + dlib: &std::path::Path, + dmdf: &std::path::Path, + metadata: &[u8], +) -> Result<()> { + let is_azure_dlib = dlib + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.eq_ignore_ascii_case("Azure.CodeSigning.Dlib.dll")); + if !is_azure_dlib { + return Ok(()); + } + let doc: ArtifactSigningMetadataDoc = serde_json::from_slice(metadata) + .with_context(|| format!("parse Azure Artifact Signing metadata '{}'", dmdf.display()))?; + if doc.Endpoint.trim().is_empty() { + return Err(anyhow!( + "Azure Artifact Signing metadata '{}' has an empty Endpoint", + dmdf.display() + )); + } + if doc.CodeSigningAccountName.trim().is_empty() { + return Err(anyhow!( + "Azure Artifact Signing metadata '{}' has an empty CodeSigningAccountName", + dmdf.display() + )); + } + if doc.CertificateProfileName.trim().is_empty() { + return Err(anyhow!( + "Azure Artifact Signing metadata '{}' has an empty CertificateProfileName", + dmdf.display() + )); + } + if let Some(excluded) = &doc.ExcludeCredentials { + for (i, value) in excluded.iter().enumerate() { + if value.trim().is_empty() { + return Err(anyhow!( + "Azure Artifact Signing metadata '{}' has an empty ExcludeCredentials[{i}]", + dmdf.display() + )); + } + } + } + Ok(()) +} + fn load_decoupled_digest_info( dlib: &std::path::Path, dmdf: &std::path::Path, @@ -303,6 +359,7 @@ fn load_decoupled_digest_info( if metadata.is_empty() { return Err(anyhow!("dmdf metadata file must not be empty")); } + validate_artifact_signing_metadata_if_azure_dlib(dlib, dmdf, &metadata)?; let mut metadata_owned = metadata; let mut blob = CRYPT_INTEGER_BLOB { cbData: metadata_owned.len() as u32, diff --git a/tests/cli_pe_digest.rs b/tests/cli_pe_digest.rs index 3b546a9..32bc6c3 100644 --- a/tests/cli_pe_digest.rs +++ b/tests/cli_pe_digest.rs @@ -2772,6 +2772,14 @@ fn spawn_psign_azure_key_vault_server(max_requests: u64) -> (PsignServerGuard, S #[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] fn spawn_psign_artifact_signing_server(max_requests: u64) -> (PsignServerGuard, String) { + spawn_psign_artifact_signing_server_with_args(max_requests, &[]) +} + +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +fn spawn_psign_artifact_signing_server_with_args( + max_requests: u64, + extra_args: &[&str], +) -> (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([ @@ -2781,6 +2789,7 @@ fn spawn_psign_artifact_signing_server(max_requests: u64) -> (PsignServerGuard, "--max-requests", max_requests.as_str(), ]); + server_cmd.args(extra_args); server_cmd.stdout(std::process::Stdio::piped()); server_cmd.stderr(std::process::Stdio::piped()); let mut guard = PsignServerGuard(server_cmd.spawn().expect("spawn psign-server")); @@ -3153,7 +3162,8 @@ fn psign_server_artifact_signing_submit_serves_portable_cli() { json.get("status").and_then(Value::as_str), Some("Succeeded") ); - let sig = json + let result = json.get("result").unwrap_or(&json); + let sig = result .get("signature") .and_then(Value::as_str) .and_then(|s| base64::engine::general_purpose::STANDARD.decode(s).ok()) @@ -3161,6 +3171,194 @@ fn psign_server_artifact_signing_submit_serves_portable_cli() { assert_eq!(sig.len(), 256, "RSA-2048 signature length"); } +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +#[test] +fn psign_server_artifact_signing_signs_pe_with_portable_cli() { + let dir = tempfile::tempdir().unwrap(); + let out_pe = dir.path().join("tiny32.artifact-portable-signed.exe"); + + let (mut guard, endpoint) = spawn_psign_artifact_signing_server(2); + let mut cmd = portable_cmd(); + cmd.arg("sign-pe") + .arg(tiny32_unsigned_fixture()) + .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_pe); + cmd.assert() + .success() + .stdout(predicate::str::contains("sign-pe: ok")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let mut verify = portable_cmd(); + verify.arg("verify-pe").arg(&out_pe); + verify.assert().success(); + + let signed = std::fs::read(&out_pe).expect("read signed PE"); + let pkcs7 = verify_pe::pe_nth_pkcs7_signed_data_der(&signed, 0).expect("extract 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 Authenticode RSA signature verifies"); +} + +#[cfg(all( + feature = "timestamp-server", + feature = "timestamp-http", + feature = "artifact-signing-rest" +))] +#[test] +fn psign_server_artifact_signing_signs_and_timestamps_pe_with_portable_cli() { + let dir = tempfile::tempdir().unwrap(); + let out_pe = dir.path().join("tiny32.artifact-portable-timestamped.exe"); + + let (mut 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-pe") + .arg(tiny32_unsigned_fixture()) + .arg("--timestamp-url") + .arg(×tamp_url) + .arg("--timestamp-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("--output") + .arg(&out_pe); + cmd.assert() + .success() + .stdout(predicate::str::contains("sign-pe: ok")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + let timestamp_status = timestamp_guard.0.wait().expect("timestamp server exit"); + assert!( + timestamp_status.success(), + "timestamp server failed with {timestamp_status}" + ); + + let mut inspect = portable_cmd(); + inspect + .arg("inspect-authenticode") + .arg(&out_pe) + .arg("--input") + .arg("pe"); + inspect + .assert() + .success() + .stdout(predicate::str::contains( + "microsoft_nested_rfc3161_attribute", + )) + .stdout(predicate::str::contains("1.3.6.1.4.1.311.3.3.1")); +} + +#[cfg(all( + feature = "timestamp-server", + feature = "timestamp-http", + feature = "artifact-signing-rest" +))] +#[test] +fn mode_portable_sign_uses_artifact_signing_metadata_and_rfc3161_timestamp_for_pe() { + let dir = tempfile::tempdir().unwrap(); + let pe_path = dir + .path() + .join("tiny32.artifact-mode-portable-timestamped.exe"); + let metadata_path = dir.path().join("metadata.json"); + std::fs::copy(tiny32_unsigned_fixture(), &pe_path).expect("copy unsigned PE"); + + let (mut guard, endpoint) = spawn_psign_artifact_signing_server(2); + let (mut timestamp_guard, timestamp_url) = spawn_psign_server(&[]); + std::fs::write( + &metadata_path, + format!( + r#"{{"Endpoint":"{}","CodeSigningAccountName":"acct","CertificateProfileName":"prof","CorrelationId":"test-corr"}}"#, + endpoint.trim_end_matches('/') + ), + ) + .expect("write metadata"); + + let mut cmd = Command::cargo_bin("psign-tool").unwrap(); + cmd.arg("--mode") + .arg("portable") + .arg("sign") + .arg("--digest") + .arg("sha256") + .arg("--timestamp-url") + .arg(×tamp_url) + .arg("--timestamp-digest") + .arg("sha256") + .arg("--dmdf") + .arg(&metadata_path) + .arg("--artifact-signing-access-token") + .arg("test-token") + .arg(&pe_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 timestamp_status = timestamp_guard.0.wait().expect("timestamp server exit"); + assert!( + timestamp_status.success(), + "timestamp server failed with {timestamp_status}" + ); + + let mut inspect = portable_cmd(); + inspect + .arg("inspect-authenticode") + .arg(&pe_path) + .arg("--input") + .arg("pe"); + inspect + .assert() + .success() + .stdout(predicate::str::contains("1.3.6.1.4.1.311.3.3.1")); +} + +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +#[test] +fn psign_server_artifact_signing_failed_status_fails_portable_cli() { + let dir = tempfile::tempdir().unwrap(); + let digest_path = dir.path().join("digest.bin"); + std::fs::write(&digest_path, [0xcdu8; 32]).expect("write digest"); + + let (mut guard, endpoint) = + spawn_psign_artifact_signing_server_with_args(2, &["--response-mode", "failed"]); + let mut cmd = portable_cmd(); + cmd.arg("artifact-signing-submit") + .arg("--region") + .arg("local") + .arg("--account-name") + .arg("acct") + .arg("--profile-name") + .arg("prof") + .arg("--digest-file") + .arg(&digest_path) + .arg("--access-token") + .arg("test-token") + .arg("--endpoint-base-url") + .arg(&endpoint); + cmd.assert() + .failure() + .stderr(predicate::str::contains("codesign operation failed")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); +} + #[cfg(all( windows, feature = "timestamp-server",