From b8b08ac5235e4963295d44fd6120a5b856d2a9f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 14 Aug 2026 09:46:13 -0400 Subject: [PATCH 1/4] Add Artifact Signing support for PowerShell scripts --- docs/gap-analysis-signing-platforms.md | 10 ++--- docs/migration-artifact-signing.md | 2 +- docs/psign-cli-matrix.json | 2 +- docs/psign-cli-matrix.md | 2 +- src/portable_sign.rs | 51 ++++++++++++++++++++++--- tests/cli_pe_digest.rs | 53 ++++++++++++++++++++++---- 6 files changed, 99 insertions(+), 21 deletions(-) diff --git a/docs/gap-analysis-signing-platforms.md b/docs/gap-analysis-signing-platforms.md index a39e4e3..b89e474 100644 --- a/docs/gap-analysis-signing-platforms.md +++ b/docs/gap-analysis-signing-platforms.md @@ -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, 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. +**Artifact Signing REST** (`:sign` LRO) returns **signature material** for a **hash**; PE/WinMD, PowerShell Authenticode scripts, 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, PowerShell scripts, 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 @@ -85,14 +85,14 @@ 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, and native-shaped portable sign now also covers PowerShell Authenticode script formats (`.ps1`, `.psd1`, `.psm1`, `.ps1xml`, `.psc1`, `.cdxml`, `.mof`). **`azure-key-vault-sign-digest`** remains available for lower-level **`keys/sign`** workflows. Gaps: other non-PE remote-sign embedders still require 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 ...`**. 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. | +| **Drop-in Linux replacement for Artifact Signing (dlib / REST)** | Partial | PE/WinMD and PowerShell Authenticode scripts (`.ps1`, `.psd1`, `.psm1`, `.ps1xml`, `.psc1`, `.cdxml`, `.mof`) are supported through **`psign-tool --mode portable sign --artifact-signing-* --timestamp-url ...`**; PE/WinMD also supports **`psign-tool portable sign-pe --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, unsupported non-PE SIP 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; 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. | +| **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, PowerShell Authenticode scripts, CAB, MSI/MSP, and generic catalogs | Portable gaps include MSIX signing/embed, unsupported non-PE SIP 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 and PowerShell script 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). +**Practical Linux path today:** Use **`psign-tool portable`** for **digest computation**, **local signing** of PE/CAB/MSI/generic catalogs, **Key Vault PE and PowerShell script signing** (`portable sign-pe` or `--mode portable sign`), **Artifact Signing REST PE and PowerShell script 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)). +**Long-term Linux signing** (if required): extend the portable **CMS `SignerInfo` production** (inside **`SignedData`**) + **format-specific embedding** beyond the current PE/CAB/MSI/catalog/PowerShell script 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 unsupported non-PE SIP timestamp mutation remain backlog (see [`rust-sip-gaps.md`](rust-sip-gaps.md)). --- diff --git a/docs/migration-artifact-signing.md b/docs/migration-artifact-signing.md index 5d0a4fa..cf4da9c 100644 --- a/docs/migration-artifact-signing.md +++ b/docs/migration-artifact-signing.md @@ -68,7 +68,7 @@ psign-tool --mode portable sign \ 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. 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. +Artifact Signing certificates are short-lived; include **`--timestamp-url http://timestamp.acs.microsoft.com/ --timestamp-digest sha256`** for production signatures. Portable PE/WinMD, PowerShell Authenticode scripts (`.ps1`, `.psd1`, `.psm1`, `.ps1xml`, `.psc1`, `.cdxml`, `.mof`), 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: diff --git a/docs/psign-cli-matrix.json b/docs/psign-cli-matrix.json index d87b104..1706ae0 100644 --- a/docs/psign-cli-matrix.json +++ b/docs/psign-cli-matrix.json @@ -62,7 +62,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", "notes": "Windows dlib metadata; in --mode portable Artifact Signing PE/WinMD signing, accepted as metadata JSON without loading the dlib"}, + {"native": "/dmdf", "rust": "--dmdf", "tier": "P0", "status": "implemented", "notes": "Windows dlib metadata; in --mode portable Artifact Signing PE/WinMD and PowerShell Authenticode script 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"}, diff --git a/docs/psign-cli-matrix.md b/docs/psign-cli-matrix.md index 528d97f..0e111c8 100644 --- a/docs/psign-cli-matrix.md +++ b/docs/psign-cli-matrix.md @@ -38,7 +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. +- **Artifact Signing REST for PE/WinMD and PowerShell scripts**: `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 or PowerShell Authenticode signatures without Microsoft client DLLs. Portable script signing supports `.ps1`, `.psd1`, `.psm1`, `.ps1xml`, `.psc1`, `.cdxml`, and `.mof`; Windows dlib mode remains available for unsupported SIP formats. ## Expanded capability model diff --git a/src/portable_sign.rs b/src/portable_sign.rs index 8641c85..0813be8 100644 --- a/src/portable_sign.rs +++ b/src/portable_sign.rs @@ -634,6 +634,15 @@ fn sign_one_target_artifact_signing(target: &Path, args: &SignArgs) -> Result<() let tmp = temporary_output_path(target); let result = match ext.as_str() { ext if is_pe_winmd_extension(ext) => run_portable_sign_pe_artifact_signing(target, &tmp, args), + ext if is_portable_powershell_script_extension(ext) => { + if args.append_signature { + Err(anyhow!( + "--as/--append-signature is only supported for portable PE/WinMD signing" + )) + } else { + run_portable_sign_script_artifact_signing(target, &tmp, args) + } + } _ if args.append_signature => Err(anyhow!( "--as/--append-signature is only supported for portable PE/WinMD signing" )), @@ -644,7 +653,7 @@ fn sign_one_target_artifact_signing(target: &Path, args: &SignArgs) -> Result<() "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 {}", + "portable Artifact Signing is currently implemented for PE/WinMD, PowerShell Authenticode scripts (.ps1, .psd1, .psm1, .ps1xml, .psc1, .cdxml, .mof), CAB, MSI/MSP, and flat MSIX/AppX targets; got {}", target.display() )), } @@ -1018,6 +1027,25 @@ fn run_portable_sign_msix_artifact_signing( target: &Path, output: &Path, args: &SignArgs, +) -> Result<()> { + run_portable_sign_portable_core_artifact_signing(target, output, args, "MSIX/AppX") +} + +#[cfg(feature = "artifact-signing-rest")] +fn run_portable_sign_script_artifact_signing( + target: &Path, + output: &Path, + args: &SignArgs, +) -> Result<()> { + run_portable_sign_portable_core_artifact_signing(target, output, args, "PowerShell script") +} + +#[cfg(feature = "artifact-signing-rest")] +fn run_portable_sign_portable_core_artifact_signing( + target: &Path, + output: &Path, + args: &SignArgs, + target_kind: &str, ) -> Result<()> { let metadata = artifact_signing_metadata(args)?; let endpoint = text_opt(args.artifact_signing_endpoint.as_deref()) @@ -1032,17 +1060,17 @@ fn run_portable_sign_msix_artifact_signing( }) .ok_or_else(|| { anyhow!( - "portable MSIX/AppX Artifact Signing requires --artifact-signing-endpoint, --artifact-signing-endpoint-base-url, --artifact-signing-region, or metadata Endpoint" + "portable {target_kind} 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"))?; + .ok_or_else(|| anyhow!("portable {target_kind} 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"))?; + .ok_or_else(|| anyhow!("portable {target_kind} 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())); @@ -1052,7 +1080,7 @@ fn run_portable_sign_msix_artifact_signing( || 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" + "native-shaped portable {target_kind} Artifact Signing does not yet support correlation ID, signature-algorithm, api-version, or authority overrides" )); } @@ -1102,7 +1130,7 @@ fn run_portable_sign_msix_artifact_signing( .map(|_| ()) .with_context(|| { format!( - "portable Artifact Signing MSIX/AppX target '{}'", + "portable Artifact Signing {target_kind} target '{}'", target.display() ) }) @@ -1119,6 +1147,17 @@ fn run_portable_sign_msix_artifact_signing( )) } +#[cfg(not(feature = "artifact-signing-rest"))] +fn run_portable_sign_script_artifact_signing( + _target: &Path, + _output: &Path, + _args: &SignArgs, +) -> Result<()> { + Err(anyhow!( + "portable PowerShell script 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 diff --git a/tests/cli_pe_digest.rs b/tests/cli_pe_digest.rs index acba6a3..7000976 100644 --- a/tests/cli_pe_digest.rs +++ b/tests/cli_pe_digest.rs @@ -5621,12 +5621,23 @@ fn mode_portable_artifact_signing_continue_on_error_reports_partial_failure() { verify.assert().success(); } -#[cfg(feature = "artifact-signing-rest")] +#[cfg(all( + feature = "timestamp-server", + feature = "timestamp-http", + feature = "artifact-signing-rest" +))] #[test] -fn mode_portable_artifact_signing_rejects_unsupported_targets_before_remote_submit() { +fn mode_portable_artifact_signing_signs_and_timestamps_psd1() { 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 script_path = dir.path().join("sample.artifact-mode-portable-signed.psd1"); + std::fs::copy( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/unsigned-sample.psd1"), + &script_path, + ) + .expect("copy unsigned psd1"); + + let (mut artifact_guard, endpoint) = spawn_psign_artifact_signing_server(2); + let (mut timestamp_guard, timestamp_url) = spawn_psign_server(&[]); let mut cmd = Command::cargo_bin("psign-tool").unwrap(); cmd.arg("--mode") @@ -5640,10 +5651,38 @@ fn mode_portable_artifact_signing_rejects_unsupported_targets_before_remote_subm .arg("prof") .arg("--artifact-signing-access-token") .arg("test-token") + .arg("--artifact-signing-endpoint") + .arg(&endpoint) + .arg("--timestamp-url") + .arg(×tamp_url) + .arg("--timestamp-digest") + .arg("sha256") + .arg("--exit-codes") + .arg("azure") .arg(&script_path); - cmd.assert().failure().stderr(predicate::str::contains( - "portable Artifact Signing is currently implemented for PE/WinMD, CAB, MSI/MSP, and flat MSIX/AppX targets", - )); + cmd.assert() + .success() + .stdout(predicate::str::contains("Signed:")); + let artifact_status = artifact_guard + .0 + .wait() + .expect("Artifact Signing server exit"); + assert!( + artifact_status.success(), + "Artifact Signing 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 mut verify = portable_cmd(); + verify.arg("verify-script").arg(&script_path); + verify.assert().success(); + + let signed = std::fs::read_to_string(&script_path).expect("read signed psd1"); + assert!(signed.contains("# SIG # Begin signature block")); } #[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] From a2fcec98dff9a6688dba5c43538abcabf9ffae14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 14 Aug 2026 10:55:15 -0400 Subject: [PATCH 2/4] Expand portable CLI compatibility --- Cargo.toml | 4 +- crates/psign-digest-cli/src/main.rs | 110 +++-- crates/psign-portable-core/src/lib.rs | 48 +- docs/gap-analysis-signing-platforms.md | 10 +- docs/linux-signing-pipelines.md | 12 +- docs/migration-azuresigntool.md | 8 +- docs/psign-cli-matrix.json | 20 +- docs/psign-cli-matrix.md | 3 +- src/lib.rs | 259 +++++++--- src/portable_remove.rs | 266 +++++++++++ src/portable_sign.rs | 637 ++++++++++++------------- tests/cert_store_cli.rs | 118 ++++- tests/cli_pe_digest.rs | 342 ++++++++++++- tests/portable_remove.rs | 81 ++++ 14 files changed, 1452 insertions(+), 466 deletions(-) create mode 100644 src/portable_remove.rs create mode 100644 tests/portable_remove.rs diff --git a/Cargo.toml b/Cargo.toml index aeb7e41..c2ad9c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,6 @@ default = [ ] ## Azure Key Vault signing (`AuthenticatorDigestSign` callback + REST); enables Azure-shaped CLI flags on `sign`. azure-kv-sign = [ - "dep:psign-portable-core", "dep:psign-azure-kv-rest", "dep:reqwest", "psign-digest-cli/azure-kv-sign-portable", @@ -55,7 +54,6 @@ azure-kv-sign = [ ## Azure Artifact Signing / Trusted Signing **data-plane** hash signing (REST LRO); experimental helper command `artifact-signing-submit`. artifact-signing-rest = [ "dep:psign-codesigning-rest", - "dep:psign-portable-core", "psign-digest-cli/artifact-signing-rest", "psign-portable-core/artifact-signing-rest", ] @@ -69,7 +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 } +psign-portable-core = { path = "crates/psign-portable-core" } anyhow = "1" clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } diff --git a/crates/psign-digest-cli/src/main.rs b/crates/psign-digest-cli/src/main.rs index 8723730..788a4e9 100644 --- a/crates/psign-digest-cli/src/main.rs +++ b/crates/psign-digest-cli/src/main.rs @@ -2153,7 +2153,8 @@ enum Command { }, /// Attach an RFC3161 timestamp token to an existing embedded PE Authenticode signature. /// - /// Accepts either a raw `timeStampToken` `ContentInfo` DER file or a `TimeStampResp` DER file containing one. + /// Accepts a raw `timeStampToken` `ContentInfo` DER file, a `TimeStampResp` DER file containing one, + /// or posts a request to a TSA with `--rfc3161-url` and `--digest`. TimestampPeRfc3161 { /// Signed PE path to mutate. #[arg(value_name = "PATH")] @@ -2165,11 +2166,17 @@ enum Command { #[arg(long, default_value_t = 0)] signer_index: usize, /// Raw RFC3161 timeStampToken ContentInfo DER. - #[arg(long, value_name = "PATH", conflicts_with = "response")] + #[arg(long, value_name = "PATH", conflicts_with_all = ["response", "rfc3161_url"])] token: Option, /// RFC3161 TimeStampResp DER containing a granted timeStampToken. - #[arg(long, value_name = "PATH", conflicts_with = "token")] + #[arg(long, value_name = "PATH", conflicts_with_all = ["token", "rfc3161_url"])] response: Option, + /// RFC3161 TSA endpoint. Requires `--digest` and the `timestamp-http` feature. + #[arg(long, visible_alias = "tr", conflicts_with_all = ["token", "response"])] + rfc3161_url: Option, + /// RFC3161 timestamp message-imprint digest. Required with `--rfc3161-url`. + #[arg(long, visible_alias = "td", value_enum, requires = "rfc3161_url")] + digest: Option, /// Output PE path. #[arg(long, value_name = "PATH")] output: PathBuf, @@ -4783,14 +4790,34 @@ where signer_index, token, response, + rfc3161_url, + digest, output, } => { let pe = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; - let token_der = match (token, response) { - (Some(token), None) => { - std::fs::read(&token).with_context(|| format!("read {}", token.display()))? + let pkcs7_der = verify_pe::pe_nth_pkcs7_signed_data_der(&pe, index) + .with_context(|| format!("extract PE PKCS#7 row {index} from {}", path.display()))?; + let stamped_pkcs7 = match (token, response, rfc3161_url) { + (Some(token), None, None) => { + let token_der = + std::fs::read(&token).with_context(|| format!("read {}", token.display()))?; + let sd = pkcs7::parse_pkcs7_signed_data_der(&pkcs7_der).with_context(|| { + format!("parse PE PKCS#7 row {index} from {}", path.display()) + })?; + let stamped = pkcs7::signed_data_add_rfc3161_timestamp_token( + &sd, + signer_index, + &token_der, + ) + .with_context(|| { + format!( + "attach RFC3161 timestamp to {} row {index} signer {signer_index}", + path.display() + ) + })?; + pkcs7::encode_pkcs7_content_info_signed_data_der(&stamped)? } - (None, Some(response)) => { + (None, Some(response), None) => { let bytes = std::fs::read(&response) .with_context(|| format!("read {}", response.display()))?; let parsed = parse_time_stamp_resp_der(&bytes).ok_or_else(|| { @@ -4802,29 +4829,58 @@ where parsed.pki_status.as_raw_integer() )); } - parsed + let token_der = parsed .time_stamp_token - .map(|t| t.to_vec()) - .ok_or_else(|| anyhow!("TimeStampResp has no timeStampToken"))? + .ok_or_else(|| anyhow!("TimeStampResp has no timeStampToken"))?; + let sd = pkcs7::parse_pkcs7_signed_data_der(&pkcs7_der).with_context(|| { + format!("parse PE PKCS#7 row {index} from {}", path.display()) + })?; + let stamped = pkcs7::signed_data_add_rfc3161_timestamp_token( + &sd, + signer_index, + token_der, + ) + .with_context(|| { + format!( + "attach RFC3161 timestamp to {} row {index} signer {signer_index}", + path.display() + ) + })?; + pkcs7::encode_pkcs7_content_info_signed_data_der(&stamped)? + } + (None, None, Some(url)) => { + if signer_index != 0 { + return Err(anyhow!( + "timestamp-pe-rfc3161 RFC3161 HTTP timestamping supports only signer_index 0" + )); + } + let digest = digest.ok_or_else(|| { + anyhow!("timestamp-pe-rfc3161 requires --digest with --rfc3161-url") + })?; + #[cfg(feature = "timestamp-http")] + { + timestamp_pkcs7_der_rfc3161( + &pkcs7_der, + &url, + digest, + Rfc3161TimestampAttribute::MicrosoftAuthenticode, + ) + .context("request and attach RFC3161 timestamp")? + } + #[cfg(not(feature = "timestamp-http"))] + { + let _ = (url, digest); + return Err(anyhow!( + "timestamp-pe-rfc3161 RFC3161 timestamping requires the timestamp-http feature" + )); + } + } + _ => { + return Err(anyhow!( + "provide exactly one of --token, --response, or --rfc3161-url with --digest" + )); } - _ => return Err(anyhow!("provide exactly one of --token or --response")), }; - let pkcs7_der = verify_pe::pe_nth_pkcs7_signed_data_der(&pe, index) - .with_context(|| format!("extract PE PKCS#7 row {index} from {}", path.display()))?; - let sd = pkcs7::parse_pkcs7_signed_data_der(&pkcs7_der) - .with_context(|| format!("parse PE PKCS#7 row {index} from {}", path.display()))?; - let stamped = pkcs7::signed_data_add_rfc3161_timestamp_token( - &sd, - signer_index, - &token_der, - ) - .with_context(|| { - format!( - "attach RFC3161 timestamp to {} row {index} signer {signer_index}", - path.display() - ) - })?; - let stamped_pkcs7 = pkcs7::encode_pkcs7_content_info_signed_data_der(&stamped)?; let out_image = pe_embed::pe_replace_authenticode_pkcs7_certificate_at(pe, index, &stamped_pkcs7) .with_context(|| { diff --git a/crates/psign-portable-core/src/lib.rs b/crates/psign-portable-core/src/lib.rs index 7612515..354cc50 100644 --- a/crates/psign-portable-core/src/lib.rs +++ b/crates/psign-portable-core/src/lib.rs @@ -2104,11 +2104,25 @@ fn sign_script(request: &PortableSignRequest, output_path: &Path) -> Result<()> let pkcs7 = maybe_timestamp_pkcs7(request, pkcs7) .with_context(|| format!("timestamp {}", request.path.display()))?; let block = format_powershell_signature_block(&pkcs7, &ext); - let mut signed = script; - signed.extend_from_slice(block.as_bytes()); + let signed = append_script_signature_block(script, &block); std::fs::write(output_path, signed).with_context(|| format!("write {}", output_path.display())) } +fn append_script_signature_block(mut script: Vec, block: &str) -> Vec { + if script.starts_with(&[0xFF, 0xFE]) { + for unit in block.encode_utf16() { + script.extend_from_slice(&unit.to_le_bytes()); + } + } else if script.starts_with(&[0xFE, 0xFF]) { + for unit in block.encode_utf16() { + script.extend_from_slice(&unit.to_be_bytes()); + } + } else { + script.extend_from_slice(block.as_bytes()); + } + script +} + fn create_script_authenticode_pkcs7_with_provider( provider: &SigningProvider, script: &[u8], @@ -3369,6 +3383,36 @@ mod tests { ); } + #[test] + fn appends_script_signature_block_using_source_utf16_encoding() { + let block = "\r\n# SIG # Begin signature block\r\n"; + let signed_le = append_script_signature_block(vec![0xFF, 0xFE, b'x', 0], block); + assert_eq!(&signed_le[..4], &[0xFF, 0xFE, b'x', 0]); + assert_eq!( + String::from_utf16( + &signed_le[2..] + .chunks_exact(2) + .map(|bytes| u16::from_le_bytes([bytes[0], bytes[1]])) + .collect::>() + ) + .expect("UTF-16LE script"), + format!("x{block}") + ); + + let signed_be = append_script_signature_block(vec![0xFE, 0xFF, 0, b'x'], block); + assert_eq!(&signed_be[..4], &[0xFE, 0xFF, 0, b'x']); + assert_eq!( + String::from_utf16( + &signed_be[2..] + .chunks_exact(2) + .map(|bytes| u16::from_be_bytes([bytes[0], bytes[1]])) + .collect::>() + ) + .expect("UTF-16BE script"), + format!("x{block}") + ); + } + #[test] fn infers_powershell_source_extensions() { assert_eq!( diff --git a/docs/gap-analysis-signing-platforms.md b/docs/gap-analysis-signing-platforms.md index b89e474..9855a10 100644 --- a/docs/gap-analysis-signing-platforms.md +++ b/docs/gap-analysis-signing-platforms.md @@ -38,9 +38,9 @@ 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`, 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. | +| **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, and native-shaped `--mode portable sign` using PFX/certificate-store, Azure Key Vault, or Artifact Signing (where documented), plus PKCS#7 extraction/prehash and 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`. | +| **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, and native-shaped `--mode portable sign` using PFX/certificate-store, Azure Key Vault, or Artifact Signing (where documented), plus PKCS#7 extraction/prehash and 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`; 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. | @@ -84,7 +84,7 @@ The committed corpus already includes generated unsigned and signed vectors for | Goal | Today | Gap | |------|--------|-----| | **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, and native-shaped portable sign now also covers PowerShell Authenticode script formats (`.ps1`, `.psd1`, `.psm1`, `.ps1xml`, `.psc1`, `.cdxml`, `.mof`). **`azure-key-vault-sign-digest`** remains available for lower-level **`keys/sign`** workflows. Gaps: other non-PE remote-sign embedders still require Windows mode or future portable signer support. | +| **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 portable signatures with Key Vault RSA signing. Native-shaped signing supports PE/WinMD, CAB, MSI/MSP, flat MSIX/AppX, NuGet/SNuGet, VSIX, ClickOnce manifests, App Installer descriptors, ZIP, and PowerShell Authenticode scripts; it also supports input lists, continuation, parallelism, and Azure-style batch exits. **`azure-key-vault-sign-digest`** remains available for lower-level **`keys/sign`** workflows. Gaps: catalog targets, WSH scripts, and MSIX/AppX bundles require a dedicated or future route. | | **Drop-in Linux replacement for Artifact Signing (dlib / REST)** | Partial | PE/WinMD and PowerShell Authenticode scripts (`.ps1`, `.psd1`, `.psm1`, `.ps1xml`, `.psc1`, `.cdxml`, `.mof`) are supported through **`psign-tool --mode portable sign --artifact-signing-* --timestamp-url ...`**; PE/WinMD also supports **`psign-tool portable sign-pe --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, unsupported non-PE SIP 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. | @@ -202,7 +202,7 @@ Portable support is intentionally split by lifecycle stage. This keeps Linux/mac | Local-key signing | Top-level `sign` returns an explicit portable-not-implemented error | `sign-pe`, `sign-cab`, `sign-msi`, `sign-catalog`, `rdp` | Supported for PE, unsigned single-volume CAB, MSI/MSP, generic catalogs, and RDP local RSA signing; other Authenticode SIP subjects remain backlog | | CMS creation from scratch | Not exposed through the native-shaped verb | PE/CAB/MSI Authenticode CMS creation through `sign-pe`, `sign-cab`, `sign-msi`, generic CTL/catalog CMS creation through `sign-catalog`, and `psign-sip-digest` helpers | Supported for PE, CAB, MSI, and generic catalog RSA/SHA-2; reusable CMS work remains to extend MSIX | | Format-specific Authenticode embed | Not implemented | `sign-pe` for PE, `sign-cab` for unsigned single-volume CABs, `sign-msi` for MSI/MSP `DigitalSignature` streams, `sign-catalog` for CTL `eContent` authoring; `append-pe-pkcs7` remains lower-level PE append plumbing | PE supported; CAB initial signing supported; MSI stream signing supported; generic catalog authoring supported; MSIX production embedder is backlog | -| Timestamp embedding | `sign --timestamp-url --timestamp-digest` is routed for portable PE signing; top-level standalone `timestamp` returns an explicit portable-not-implemented error | `sign-pe --timestamp-url --timestamp-digest` timestamps at sign time; NuGet `nupkg-signature-pkcs7`, `nupkg-sign`, and `psign-tool code` NuGet/App Installer companion CMS signing can attach RFC3161 tokens; `timestamp-pe-rfc3161` attaches a granted RFC3161 `timeStampToken` to existing PE `SignedData`; request/response helpers can prepare or inspect TSA traffic | PE and NuGet/App Installer local CMS sign-time timestamping supported; VSIX/ClickOnce timestamping and standalone native-shaped timestamp routing remain backlog | +| Timestamp embedding | `sign --timestamp-url --timestamp-digest` and RFC3161 `timestamp --rfc3161-url --digest ` are routed for portable PE signatures | `sign-pe --timestamp-url --timestamp-digest` timestamps at sign time; NuGet `nupkg-signature-pkcs7`, `nupkg-sign`, and `psign-tool code` NuGet/App Installer companion CMS signing can attach RFC3161 tokens; `timestamp-pe-rfc3161` attaches a granted RFC3161 `timeStampToken` to existing PE `SignedData`; request/response helpers can prepare or inspect TSA traffic | Existing PE/WinMD primary signatures support RFC3161 post-sign timestamping. Legacy `/t`, sealing, PKCS#7-file timestamps, and signature-index selection remain unsupported; VSIX/ClickOnce timestamping remains backlog | | Signature removal / mutation | Top-level `remove` returns an explicit portable-unsupported error | No remove verb | Backlog only after production embedders exist | | Catalog database operations | Top-level `catdb` returns an explicit portable-unsupported error | `sign-catalog` authors explicit generic catalogs; `verify-catalog-member` verifies explicit file + catalog membership without a database | OS catalog database search, driver/INF policy, and catalog store mutation remain out of scope | @@ -210,7 +210,7 @@ The compatibility rule is: **portable mode may prove digest/CMS consistency and **Remote signing steps:** With **`--features azure-kv-sign-portable`**, **`sign-pe --azure-key-vault-*`** performs full PE Authenticode signing with Key Vault RSA signatures, and top-level **`--mode portable sign --azure-key-vault-*`** now covers PE/WinMD plus PowerShell Authenticode script targets. **`azure-key-vault-sign-digest`** performs Azure Key Vault **`keys/sign`** on a **raw digest file** for lower-level workflows. **`pe-signer-rs256-prehash`**, **`cab-signer-rs256-prehash`**, **`msi-signer-rs256-prehash`**, and **`catalog-signer-rs256-prehash`** (**`--encoding raw`**) emit the **32-byte** **`RS256`** input over **`SignerInfo.signedAttrs`** (distinct from subject-layout digests and from **`verify-catalog`**’s CTL **`eContent`** / PKCS#9 checks). With **`--features artifact-signing-rest`**, **`artifact-signing-submit`** calls Trusted Signing **`:sign`**, and **`sign-pe --artifact-signing-*`** / top-level **`--mode portable sign --artifact-signing-*`** use that REST signature to embed PE/WinMD Authenticode. CAB/MSI/catalog remote-sign CLI routing, MSIX embedding, and broader native-shaped remote-sign routing remain future portable embedder work. -**RFC 3161 TSA helpers:** **`rfc3161-timestamp-req`** builds **`TimeStampReq`** DER from **`--digest-hex`** / **`--digest-file`** (message-imprint preimage; optional **`--nonce`**, **`--cert-req`**) for **`curl`** / OpenSSL **`ts`** against a timestamp URL. **`rfc3161-timestamp-resp-inspect`** prints **`pki_status`** / **`pki_status_int`** (raw status INTEGER) / **`granted`** / token length, **`time_stamp_token_prefix_hex`** (first **16** octets of the raw **`timeStampToken`** TLV, or **`-`** when absent — handy for **`ContentInfo`** / CMS shape checks), **`status_strings_json`** (**`PKIFreeText`**), **`fail_info_tlv_hex`**, and **`fail_info_flags_json`** (RFC 2510 Appendix A **`PKIFailureInfo`** bit names through **`badPOP`**, then **`bit_N`**; **`null`** when the **`BIT STRING`** body is not decodable). Parseable CMS **`id-ct-TSTInfo`** tokens also surface structural **`tst_info_*`** diagnostics: policy OID, message-imprint digest OID/hash, serial, **`genTime`**, and nonce. Optional **`rfc3161-timestamp-http-post`** (**`--features timestamp-http`**) performs the HTTPS POST without **`curl`**. **`timestamp-pe-rfc3161`** can then attach a raw **`timeStampToken`** or granted **`TimeStampResp`** token to an existing PE Authenticode `SignerInfo` as the Microsoft RFC3161 unsigned attribute. This still does not clone every **`SignerTimeStampEx3`** policy branch or timestamp non-PE subjects. +**RFC 3161 TSA helpers:** **`rfc3161-timestamp-req`** builds **`TimeStampReq`** DER from **`--digest-hex`** / **`--digest-file`** (message-imprint preimage; optional **`--nonce`**, **`--cert-req`**) for **`curl`** / OpenSSL **`ts`** against a timestamp URL. **`rfc3161-timestamp-resp-inspect`** prints **`pki_status`** / **`pki_status_int`** (raw status INTEGER) / **`granted`** / token length, **`time_stamp_token_prefix_hex`** (first **16** octets of the raw **`timeStampToken`** TLV, or **`-`** when absent — handy for **`ContentInfo`** / CMS shape checks), **`status_strings_json`** (**`PKIFreeText`**), **`fail_info_tlv_hex`**, and **`fail_info_flags_json`** (RFC 2510 Appendix A **`PKIFailureInfo`** bit names through **`badPOP`**, then **`bit_N`**; **`null`** when the **`BIT STRING`** body is not decodable). Parseable CMS **`id-ct-TSTInfo`** tokens also surface structural **`tst_info_*`** diagnostics: policy OID, message-imprint digest OID/hash, serial, **`genTime`**, and nonce. Optional **`rfc3161-timestamp-http-post`** (**`--features timestamp-http`**) performs the HTTPS POST without **`curl`**. **`timestamp-pe-rfc3161`** can attach a raw **`timeStampToken`**, granted **`TimeStampResp`**, or (with **`--rfc3161-url --digest`**) a newly requested token to an existing PE Authenticode `SignerInfo` as the Microsoft RFC3161 unsigned attribute. The native-shaped portable form is `psign-tool --mode portable timestamp --rfc3161-url --digest `; it safely targets only the primary embedded signature. This still does not clone every **`SignerTimeStampEx3`** policy branch or timestamp non-PE subjects. **Formats with portable digest + PKCS#7 consistency (and optional trust):** diff --git a/docs/linux-signing-pipelines.md b/docs/linux-signing-pipelines.md index 14a8d87..2315420 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. 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. +**`psign-tool portable`** on Linux/macOS can sign PE, CAB, MSI/MSP, flat MSIX/AppX, NuGet/SNuGet, VSIX, ClickOnce manifests, App Installer descriptors, ZIP, and PowerShell scripts through native-shaped portable local PFX/certificate-store or Azure Key Vault routes. It can also sign unsigned single-volume CAB, MSI/MSP, generic catalogs, and RDP files with scoped local RSA/SHA-2 commands; Azure Artifact Signing REST supports its documented native-shaped subset. 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). @@ -18,7 +18,7 @@ Automation: **`scripts/linux-portable-validation.sh`**, GitHub **`ci-unix`**, an ## 1.1 Local portable signing -Local RSA/SHA-2 signing is intentionally exposed through scoped portable commands before routing the native-shaped `sign` verb: +Scoped portable commands remain available for explicit cert/key output paths: ```bash psign-tool portable sign-pe --cert cert.der --key key.pk8 --output signed.exe unsigned.exe @@ -29,7 +29,9 @@ psign-tool portable sign-catalog --cert cert.der --key key.pk8 --output files.ca `sign-catalog` authors generic CTL member entries and signs the catalog PKCS#7. Pair it with `verify-catalog` and `verify-catalog-member --catalog files.cat file1.exe`; driver/INF policy and OS catalog database lookup remain Windows-only. -## 1.2 Portable PE signing with Azure Key Vault +For native-shaped in-place local signing, use either `--pfx`/`--password` or portable certificate-store material selected by `--sha1`. The route supports PE/WinMD, CAB, MSI/MSP, flat MSIX/AppX, NuGet/SNuGet, VSIX, ClickOnce manifests, App Installer descriptors, ZIP, and PowerShell scripts; it also accepts input file lists and batch controls. Catalog targets, WSH scripts, and MSIX/AppX bundles remain explicit unsupported cases. + +## 1.2 Portable signing with Azure Key Vault With **`--features azure-kv-sign-portable`**, PE/WinMD signing can use Azure Key Vault for the RSA signature while building and embedding Authenticode CMS locally: @@ -44,7 +46,7 @@ psign-tool portable sign-pe ./MyApp.exe \ --output ./MyApp.signed.exe ``` -The PE subset of the native-shaped verb is also available for in-place signing: +The native-shaped in-place route covers the same portable-core formats (including CAB, MSI/MSP, flat MSIX/AppX, packages, and PowerShell) and supports batch input lists, continuation, parallelism, and Azure-style exit codes: ```bash psign-tool --mode portable sign \ @@ -57,7 +59,7 @@ psign-tool --mode portable sign \ ./MyApp.exe ``` -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. +Portable Key Vault 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`. MSIX/AppX bundles, catalog targets, and WSH scripts remain unsupported by this native-shaped route. `timestamp-pe-rfc3161` remains available as a separate mutation step when you already have a timestamp token or granted response. ## 1.3 Portable signing with Azure Artifact Signing REST diff --git a/docs/migration-azuresigntool.md b/docs/migration-azuresigntool.md index 396f9c6..e7e3e84 100644 --- a/docs/migration-azuresigntool.md +++ b/docs/migration-azuresigntool.md @@ -1,6 +1,6 @@ # Migrating from AzureSignTool -This project can replace **AzureSignTool** for Windows signing when built with **`--features azure-kv-sign`**. **`psign-tool portable`** covers digest checks, verification, and (with **`--features azure-kv-sign-portable`**) Key Vault **`keys/sign`** on digest files plus PE Authenticode signing through **`portable sign-pe`** and native-shaped **`--mode portable sign`** for PE/WinMD plus PowerShell Authenticode script formats (`.ps1`, `.psd1`, `.psm1`, `.ps1xml`, `.psc1`, `.cdxml`, `.mof`). Windows mode remains the broader native-shaped signing path. +This project can replace **AzureSignTool** for Windows signing when built with **`--features azure-kv-sign`**. **`psign-tool portable`** covers digest checks, verification, and (with **`--features azure-kv-sign-portable`**) Key Vault **`keys/sign`** on digest files plus portable PE Authenticode signing through **`portable sign-pe`**. Native-shaped **`--mode portable sign --azure-key-vault-*`** supports PE/WinMD, CAB, MSI/MSP, flat MSIX/AppX, NuGet/SNuGet, VSIX, ClickOnce manifests, App Installer descriptors, ZIP, and PowerShell Authenticode script formats (`.ps1`, `.psd1`, `.psm1`, `.ps1xml`, `.psc1`, `.cdxml`, `.mof`). Windows mode remains the broader native-shaped signing path. **Azure Artifact Signing (Trusted Signing)** via Microsoft’s decoupled **`Azure.CodeSigning.Dlib.dll`** is **not** the Key Vault path: use **`--dlib`** / **`--trusted-signing-dlib-root`** with **`--dmdf`** only (never mixed with **`--azure-key-vault-url`**). See [`migration-artifact-signing.md`](migration-artifact-signing.md). PowerShell OpenAuthenticode overlap (inspect JSON, REST submit, EKU prefix selection) is summarized in [`psa-interoperability.md`](psa-interoperability.md). @@ -73,7 +73,7 @@ The old helper executable name is no longer emitted; use **`psign-tool sign --ex Default **`signtool`** exit codes remain **`0` / `1` / `2`**. -### Linux / CI: portable PE signing with Key Vault +### Linux / CI: portable signing with Key Vault For Windows PE artifacts on Linux/macOS, use the portable PE signer. This assembles Authenticode CMS locally, asks Key Vault to sign the CMS authenticated-attribute digest, embeds the resulting PKCS#7 in the PE certificate table, and recomputes the PE checksum: @@ -88,7 +88,7 @@ psign-tool portable sign-pe ./MyApp.exe \ --output ./MyApp.signed.exe ``` -The native-shaped PE subset also works in-place: +The native-shaped in-place route also signs the supported package and script formats, and accepts `--input-file-list`, `--continue-on-error`, `--max-degree-of-parallelism`, and `--exit-codes azure`: ```bash psign-tool --mode portable sign \ @@ -101,7 +101,7 @@ psign-tool --mode portable sign \ ./MyApp.exe ``` -The portable path currently supports PE/WinMD, SHA-2 digests, Key Vault signer certificates, optional **`--ac` / `--chain-cert`** certificates, and RFC3161 sign-time timestamping. Use **`psign-tool portable timestamp-pe-rfc3161`** as a second portable step only when you already have a timestamp token/response. +The portable Key Vault path supports SHA-2 digests, Key Vault signer certificates, optional **`--ac` / `--chain-cert`** certificates, and RFC3161 sign-time timestamping. MSIX/AppX bundles, catalog targets, and WSH scripts remain unsupported by this native-shaped route; use the dedicated catalog command where applicable. Use **`psign-tool portable timestamp-pe-rfc3161`** as a second portable step only when you already have a timestamp token/response. ### Linux / CI: Key Vault **`keys/sign`** on a raw digest diff --git a/docs/psign-cli-matrix.json b/docs/psign-cli-matrix.json index 1706ae0..1ff4916 100644 --- a/docs/psign-cli-matrix.json +++ b/docs/psign-cli-matrix.json @@ -101,8 +101,8 @@ {"native": "/ad", "rust": "--catalog-search default", "tier": "P0", "status": "implemented"}, {"native": "/as", "rust": "--catalog-search system", "tier": "P0", "status": "partial", "notes": "Subsystem GUID best-effort"}, {"native": "/ag", "rust": "--catalog-database-guid", "tier": "P0", "status": "implemented"}, - {"native": "/c", "rust": "--catalog", "tier": "P0", "status": "implemented"}, - {"native": "/hash", "rust": "--catalog-hash-algorithm", "tier": "P0", "status": "implemented"}, + {"native": "/c", "rust": "--catalog", "tier": "P0", "status": "implemented", "notes": "In `--mode portable verify`, an explicit catalog is trusted once through `trust-verify-catalog`, then every trailing subject is checked through `verify-catalog-member`. This is not OS catalog-database lookup or driver/OS policy verification."}, + {"native": "/hash", "rust": "--catalog-hash-algorithm", "tier": "P0", "status": "implemented", "notes": "Windows catalog lookup selector. `--mode portable verify --catalog` accepts only the default sha256 spelling because member verification derives its algorithm from each catalog CTL entry."}, {"native": "/o", "rust": "--os-version-check", "tier": "P0", "status": "implemented", "notes": "Catalog WinTrust only: Rust sets `WTD_USE_DEFAULT_OSVER_CHECK` in `verify_with_catalog`; embedded `/pa` + `/o` is rejected by current signtool unless `/a`/`/c`/… so Rust errors without `--catalog`/`--catalog-search`. Parity: `verify_pa_os_version_check_exit_match`"}, {"native": "/pa", "rust": "--policy pa", "tier": "P0", "status": "implemented"}, {"native": "/pg", "rust": "--policy pg --policy-guid", "tier": "P0", "status": "implemented"}, @@ -119,10 +119,10 @@ {"native": "/sha1", "rust": "--signer-thumbprint-sha1", "tier": "P0", "status": "implemented", "notes": "Repeatable; signer cert SHA1 must match one value"}, {"native": "/ca", "rust": "--intermediate-ca-sha1", "tier": "P0", "status": "implemented", "notes": "Repeatable; at least one intermediate CA thumbprint must match chain"}, {"native": "/u", "rust": "--warn-if-missing-eku", "tier": "P0", "status": "implemented", "notes": "Verify verb: warn exit 2 if EKU OID missing (distinct from sign --eku-oid)"}, - {"native": "/p7content", "rust": "--detached-pkcs7-content", "tier": "P0", "status": "implemented", "notes": "Content file for detached PKCS#7 verify"}, - {"native": "(detached sig file)", "rust": "--detached-pkcs7 (--p7s)", "tier": "P0", "status": "implemented", "notes": "Alias p7s for detached PKCS#7 path"}, + {"native": "/p7content", "rust": "--detached-pkcs7-content", "tier": "P0", "status": "implemented", "notes": "Content file for detached PKCS#7 verify. In `--mode portable verify`, it is passed to `trust-verify-detached`; without it, the one required verify target is the content."}, + {"native": "(detached sig file)", "rust": "--detached-pkcs7 (--p7s)", "tier": "P0", "status": "implemented", "notes": "Alias p7s for detached PKCS#7 path. In `--mode portable verify`, this routes to one portable CMS/chain validation via `trust-verify-detached` and requires exactly one verify target."}, {"native": "(allow test roots)", "rust": "--allow-test-root (--testroot)", "tier": "P1", "status": "implemented", "notes": "Windows argv /testroot supported"}, - {"native": "(portable explicit root)", "rust": "--trusted-ca", "tier": "P1", "status": "implemented", "notes": "Portable trust only; repeatable PEM/DER root files, no OS trust-store writes. In `--mode portable verify`, supported formats route to portable trust by default when automatic AuthRoot is enabled; this flag supplies explicit roots and suppresses auto AuthRoot resolution."}, + {"native": "(portable explicit root)", "rust": "--trusted-ca", "tier": "P1", "status": "implemented", "notes": "Portable trust only; repeatable PEM/DER root files, no OS trust-store writes. In `--mode portable verify`, supported formats route to portable trust by default when automatic AuthRoot is enabled; this flag supplies explicit roots and suppresses auto AuthRoot resolution, including detached PKCS#7 and explicit catalog routing."}, {"native": "(portable anchor directory)", "rust": "--anchor-dir", "tier": "P1", "status": "implemented", "notes": "Portable trust only; loads .crt/.cer/.pem files as anchors without elevation or persistent store changes."}, {"native": "(portable AIA)", "rust": "--online-aia", "tier": "P2", "status": "partial", "notes": "Portable trust only; explicit in-memory HTTP AIA caIssuers fetch for missing issuers. Revocation is available through OCSP/CRL HTTP overrides and CRL Distribution Points."}, {"native": "(portable AIA test override)", "rust": "--aia-url-override", "tier": "P2", "status": "partial", "notes": "Portable trust only; deterministic local test override used before certificate AIA URLs."}, @@ -147,11 +147,11 @@ {"native": "(experimental)", "rust": "(verify rust sip)", "tier": "P2", "status": "partial", "notes": "Never replaces default WinVerifyTrust; consistency checks only"} ], "timestamp": [ - {"native": "/tr", "rust": "--rfc3161-url", "tier": "P0", "status": "implemented"}, - {"native": "/tseal", "rust": "--seal-timestamp-url (--tseal)", "tier": "P0", "status": "implemented", "notes": "Mutually exclusive with /tr and /t; uses same SignerTimeStampEx3 RFC3161 path as /tr in this build"}, - {"native": "/t", "rust": "--legacy-url", "tier": "P0", "status": "implemented"}, - {"native": "/td", "rust": "--digest", "tier": "P0", "status": "implemented", "notes": "Required for RFC3161 timestamp paths (`/tr` and `/tseal`); legacy `/t` does not require it"}, - {"native": "/tp", "rust": "--signature-index", "tier": "P0", "status": "implemented"}, + {"native": "/tr", "rust": "--rfc3161-url", "tier": "P0", "status": "implemented", "notes": "In `--mode portable timestamp`, RFC3161 only: existing PE/WinMD with a primary embedded Authenticode signature is timestamped through the portable HTTP/TSA and PE embedding path."}, + {"native": "/tseal", "rust": "--seal-timestamp-url (--tseal)", "tier": "P0", "status": "implemented", "notes": "Mutually exclusive with /tr and /t; uses same SignerTimeStampEx3 RFC3161 path as /tr in Windows mode. Portable mode explicitly rejects sealing."}, + {"native": "/t", "rust": "--legacy-url", "tier": "P0", "status": "implemented", "notes": "Portable mode explicitly rejects legacy Authenticode timestamping; use RFC3161 /tr."}, + {"native": "/td", "rust": "--digest", "tier": "P0", "status": "implemented", "notes": "Required for RFC3161 timestamp paths (`/tr` and `/tseal`); legacy `/t` does not require it. Portable timestamp accepts sha1, sha256, sha384, or sha512, not certHash."}, + {"native": "/tp", "rust": "--signature-index", "tier": "P0", "status": "implemented", "notes": "Portable mode explicitly rejects signature-index selection and safely timestamps only the primary embedded signature."}, {"native": "/p7", "rust": "--timestamp-pkcs7-files", "tier": "P1", "status": "partial", "notes": "Explicit error"}, {"native": "/force", "rust": "--remove-seal (--force)", "tier": "P1", "status": "partial", "notes": "CLI + argv; explicit not-implemented error"}, {"native": "/nosealwarn", "rust": "--no-seal-warn (--nosealwarn)", "tier": "P1", "status": "partial", "notes": "CLI + argv; explicit not-implemented error"} diff --git a/docs/psign-cli-matrix.md b/docs/psign-cli-matrix.md index 0e111c8..aa846e6 100644 --- a/docs/psign-cli-matrix.md +++ b/docs/psign-cli-matrix.md @@ -35,10 +35,11 @@ Exit codes follow native conventions where applicable: `0` success, `1` failure, Full native ↔ Rust mappings, tiers, and per-flag notes are **only** maintained in [`psign-cli-matrix.json`](psign-cli-matrix.json) to avoid drift. Highlights: - **Verify `/o`**: Catalog WinTrust only — `--os-version-check` sets `WTD_USE_DEFAULT_OSVER_CHECK` in `verify_with_catalog`; embedded verify without `--catalog` / `--catalog-search` / `--catalog-database-guid` errors to match current signtool (see JSON `verify` entry for `/o`). -- **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`). +- **Detached PKCS#7 / explicit catalogs**: Windows retains native verification. `--mode portable verify --detached-pkcs7` routes once through portable detached CMS/chain trust, while `--catalog ` trusts the catalog once then checks every subject’s CTL membership; neither route emulates catalog-database, driver, or OS policy. - **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 and PowerShell scripts**: `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 or PowerShell Authenticode signatures without Microsoft client DLLs. Portable script signing supports `.ps1`, `.psd1`, `.psm1`, `.ps1xml`, `.psc1`, `.cdxml`, and `.mof`; Windows dlib mode remains available for unsupported SIP formats. +- **Portable existing-PE RFC3161 timestamp**: `psign-tool --mode portable timestamp --rfc3161-url URL --digest sha256 signed.exe` posts an RFC3161 request and embeds the returned token in the primary PE/WinMD Authenticode signature. Legacy `/t`, sealing, `/p7`, and `/tp` remain Windows-only. ## Expanded capability model diff --git a/src/lib.rs b/src/lib.rs index c3544c0..3c9d6f1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod cert_store; pub mod cli; pub mod code; pub mod native_argv; +pub mod portable_remove; pub mod portable_sign; pub mod rdp; pub mod response_argv; @@ -159,8 +160,6 @@ fn portable_verify_unsupported(args: &crate::cli::VerifyArgs) -> bool { args.policy != crate::cli::VerifyPolicy::Default || args.policy_guid.is_some() || args.revocation_check - || args.detached_pkcs7.is_some() - || args.catalog.is_some() || args.catalog_search.is_some() || args.catalog_database_guid.is_some() || args.os_version_check.is_some() @@ -177,7 +176,7 @@ fn portable_verify_unsupported(args: &crate::cli::VerifyArgs) -> bool { || !args.signer_thumbprint_sha1.is_empty() || !args.intermediate_ca_sha1.is_empty() || !args.warn_if_missing_eku.is_empty() - || args.detached_pkcs7_content.is_some() + || (args.detached_pkcs7_content.is_some() && args.detached_pkcs7.is_none()) || args.warn_pca_2010 || args.no_warn_pca_2010 || args.verify_sealing_signatures @@ -193,6 +192,75 @@ fn portable_verify_unsupported(args: &crate::cli::VerifyArgs) -> bool { || args.enclave_policy } +fn append_portable_trust_args(args: &crate::cli::VerifyArgs, argv: &mut Vec) { + if let Some(dir) = &args.anchor_dir { + argv.push(std::ffi::OsString::from("--anchor-dir")); + argv.push(dir.as_os_str().to_os_string()); + } + for ca in &args.trusted_ca { + argv.push(std::ffi::OsString::from("--trusted-ca")); + argv.push(ca.as_os_str().to_os_string()); + } + if let Some(cab) = &args.authroot_cab { + argv.push(std::ffi::OsString::from("--authroot-cab")); + argv.push(cab.as_os_str().to_os_string()); + } + if let Some(expected) = &args.expect_authroot_cab_sha256 { + argv.push(std::ffi::OsString::from("--expect-authroot-cab-sha256")); + argv.push(std::ffi::OsString::from(expected)); + } + if args.verbose_chain { + argv.push(std::ffi::OsString::from("--verbose-chain")); + } + if args.allow_loose_signing_cert { + argv.push(std::ffi::OsString::from("--allow-loose-signing-cert")); + } + if args.prefer_timestamp_signing_time { + argv.push(std::ffi::OsString::from("--prefer-timestamp-signing-time")); + } + if args.require_valid_timestamp { + argv.push(std::ffi::OsString::from("--require-valid-timestamp")); + } + if args.online_aia { + argv.push(std::ffi::OsString::from("--online-aia")); + } + if let Some(url) = &args.aia_url_override { + argv.push(std::ffi::OsString::from("--aia-url-override")); + argv.push(std::ffi::OsString::from(url)); + } + if args.online_ocsp { + argv.push(std::ffi::OsString::from("--online-ocsp")); + } + if let Some(url) = &args.ocsp_url_override { + argv.push(std::ffi::OsString::from("--ocsp-url-override")); + argv.push(std::ffi::OsString::from(url)); + } + if let Some(mode) = args.revocation_mode { + argv.push(std::ffi::OsString::from("--revocation-mode")); + argv.push(std::ffi::OsString::from(mode.as_arg())); + } + if let Some(url) = &args.crl_url_override { + argv.push(std::ffi::OsString::from("--crl-url-override")); + argv.push(std::ffi::OsString::from(url)); + } + if let Some(as_of) = &args.as_of { + argv.push(std::ffi::OsString::from("--as-of")); + argv.push(std::ffi::OsString::from(as_of)); + } + if args.online_timeout_secs != 5 { + argv.push(std::ffi::OsString::from("--online-timeout-secs")); + argv.push(std::ffi::OsString::from( + args.online_timeout_secs.to_string(), + )); + } + if args.online_max_download_bytes != 1024 * 1024 { + argv.push(std::ffi::OsString::from("--online-max-download-bytes")); + argv.push(std::ffi::OsString::from( + args.online_max_download_bytes.to_string(), + )); + } +} + fn portable_verify_explicit_trust_requested(args: &crate::cli::VerifyArgs) -> bool { args.anchor_dir.is_some() || !args.trusted_ca.is_empty() @@ -235,6 +303,49 @@ fn execute_portable_verify(args: &crate::cli::VerifyArgs) -> anyhow::Result anyhow::Result anyhow::Result { + if args.legacy_url.is_some() { + return Err(anyhow::anyhow!( + "--mode portable timestamp does not support legacy --legacy-url (/t); use RFC3161 --rfc3161-url (/tr)" + )); + } + if args.seal_timestamp_url.is_some() || args.remove_seal || args.no_seal_warn { + return Err(anyhow::anyhow!( + "--mode portable timestamp does not support sealing (--seal-timestamp-url (/tseal), --remove-seal (/force), or --no-seal-warn (/nosealwarn))" + )); + } + if args.timestamp_pkcs7_files { + return Err(anyhow::anyhow!( + "--mode portable timestamp does not support --timestamp-pkcs7-files (/p7); only embedded PE/WinMD signatures are supported" + )); + } + if args.signature_index.is_some() { + return Err(anyhow::anyhow!( + "--mode portable timestamp does not support --signature-index (/tp); portable RFC3161 insertion safely timestamps only the primary embedded signature" + )); + } + let url = args.rfc3161_url.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "--mode portable timestamp requires RFC3161 --rfc3161-url (/tr) and --digest (/td)" + ) + })?; + let digest = match args.digest { + Some(crate::cli::DigestAlgorithm::Sha1) => "sha1", + Some(crate::cli::DigestAlgorithm::Sha256) => "sha256", + Some(crate::cli::DigestAlgorithm::Sha384) => "sha384", + Some(crate::cli::DigestAlgorithm::Sha512) => "sha512", + Some(crate::cli::DigestAlgorithm::CertHash) => { + return Err(anyhow::anyhow!( + "--mode portable timestamp does not support --digest certHash; use sha1, sha256, sha384, or sha512" + )); + } + None => { + return Err(anyhow::anyhow!( + "--mode portable timestamp requires --digest (/td) with --rfc3161-url (/tr)" + )); + } + }; + + for path in &args.files { + let image = + std::fs::read(path).map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?; + psign_sip_digest::verify_pe::pe_nth_pkcs7_signed_data_der(&image, 0).map_err(|e| { + anyhow::anyhow!( + "--mode portable timestamp supports only PE/WinMD files with a primary embedded Authenticode signature ({}): {e}", + path.display() + ) + })?; + let argv = [ + std::ffi::OsString::from("timestamp-pe-rfc3161"), + path.as_os_str().to_os_string(), + std::ffi::OsString::from("--rfc3161-url"), + std::ffi::OsString::from(url), + std::ffi::OsString::from("--digest"), + std::ffi::OsString::from(digest), + std::ffi::OsString::from("--output"), + path.as_os_str().to_os_string(), + ]; + run_portable_args(&argv)?; + } + Ok(CommandOutput::ok(String::new())) +} + #[cfg(windows)] fn execute_windows(cli: &crate::cli::Cli) -> anyhow::Result { use crate::cli::Command; @@ -378,15 +491,11 @@ fn execute_portable(cli: &crate::cli::Cli) -> anyhow::Result { Command::Verify(args) => execute_portable_verify(args), Command::InspectSignature(args) => execute_portable_inspect(args), Command::Sign(args) => crate::portable_sign::sign_file(args, &cli.global), - Command::Timestamp(_) => Err(anyhow::anyhow!( - "--mode portable timestamp is not implemented; portable timestamp helpers are available under `psign-tool portable ...`" - )), + Command::Timestamp(args) => execute_portable_timestamp(args), Command::Catdb(_) => Err(anyhow::anyhow!( "--mode portable catdb is unsupported because catalog database operations require Win32" )), - Command::Remove(_) => Err(anyhow::anyhow!( - "--mode portable remove is unsupported; embedded signature removal currently requires the Windows implementation" - )), + Command::Remove(args) => crate::portable_remove::remove_command(args, &cli.global), Command::Rdp(_) => Err(anyhow::anyhow!( "--mode portable rdp is available as `psign-tool portable rdp ...`" )), diff --git a/src/portable_remove.rs b/src/portable_remove.rs new file mode 100644 index 0000000..579b18a --- /dev/null +++ b/src/portable_remove.rs @@ -0,0 +1,266 @@ +use crate::CommandOutput; +use crate::cli::{GlobalOpts, RemoveArgs}; +use anyhow::{Context, Result, anyhow}; +use base64::Engine as _; +use std::path::Path; + +pub fn remove_command(args: &RemoveArgs, global: &GlobalOpts) -> Result { + if !args.strip_signature + || args.strip_chain_except_signer + || args.strip_unauthenticated_attributes + { + return Err(anyhow!( + "portable remove supports only --strip-signature (/s); partial embedded CMS removal (/c or /u) requires Windows mode" + )); + } + + let mut output = String::new(); + for path in &args.files { + let ext = extension_lower(path); + let removed = if is_pe_winmd_extension(&ext) { + remove_pe_signature(path)? + } else if psign_sip_digest::ps_script::extension_supported(&ext) { + remove_powershell_signature(path, &ext)? + } else { + return Err(anyhow!( + "portable remove supports PE/WinMD and PowerShell Authenticode scripts (.ps1, .psd1, .psm1, .ps1xml, .psc1, .cdxml, .mof); got {}", + path.display() + )); + }; + + if !global.quiet { + if removed { + output.push_str(&format!( + "Removed embedded Authenticode data from {}\n", + path.display() + )); + } else { + output.push_str(&format!( + "No embedded Authenticode data found in {}\n", + path.display() + )); + } + } + } + Ok(CommandOutput::ok(output)) +} + +fn remove_pe_signature(path: &Path) -> Result { + let bytes = std::fs::read(path).with_context(|| format!("read '{}'", path.display()))?; + let (unsigned, removed_count) = + psign_sip_digest::pe_embed::pe_remove_authenticode_certificates(bytes).with_context( + || { + format!( + "remove PE/WinMD Authenticode signature from '{}'", + path.display() + ) + }, + )?; + if removed_count > 0 { + std::fs::write(path, unsigned) + .with_context(|| format!("write unsigned PE/WinMD '{}'", path.display()))?; + } + Ok(removed_count > 0) +} + +fn remove_powershell_signature(path: &Path, ext: &str) -> Result { + let bytes = std::fs::read(path).with_context(|| format!("read '{}'", path.display()))?; + let (content, encoding) = ScriptEncoding::decode(&bytes) + .with_context(|| format!("decode PowerShell script '{}'", path.display()))?; + let Some(unsigned) = remove_script_signature_block(&content, ext) else { + return Ok(false); + }; + std::fs::write(path, encoding.encode(&unsigned)) + .with_context(|| format!("write unsigned PowerShell script '{}'", path.display()))?; + Ok(true) +} + +fn remove_script_signature_block(content: &str, ext: &str) -> Option { + let (begin, end) = match ext { + "ps1xml" | "psc1" | "cdxml" => ( + "", + "", + ), + "mof" => ( + "/* SIG # Begin signature block */", + "/* SIG # End signature block */", + ), + _ => ( + "# SIG # Begin signature block", + "# SIG # End signature block", + ), + }; + let begin_at = content.rfind(begin)?; + let end_at = content[begin_at + begin.len()..].find(end)? + begin_at + begin.len(); + let after_end = end_at + end.len(); + if !content[after_end..].trim().is_empty() + || !is_authenticode_signature_block(&content[begin_at + begin.len()..end_at], ext) + { + return None; + } + let mut remove_start = begin_at; + if content[..begin_at].ends_with("\r\n") { + remove_start -= 2; + } else if content[..begin_at].ends_with('\n') { + remove_start -= 1; + } + let mut remove_end = after_end; + if content[remove_end..].starts_with("\r\n") { + remove_end += 2; + } else if content[remove_end..].starts_with('\n') { + remove_end += 1; + } + let mut unsigned = String::with_capacity(content.len() - (remove_end - remove_start)); + unsigned.push_str(&content[..remove_start]); + unsigned.push_str(&content[remove_end..]); + Some(unsigned) +} + +fn is_authenticode_signature_block(body: &str, ext: &str) -> bool { + let mut payload = String::new(); + for line in body.lines().map(str::trim).filter(|line| !line.is_empty()) { + let encoded = match ext { + "ps1xml" | "psc1" | "cdxml" => line + .strip_prefix("")), + "mof" => line + .strip_prefix("/* ") + .and_then(|line| line.strip_suffix(" */")), + _ => line.strip_prefix("# "), + }; + let Some(encoded) = encoded else { + return false; + }; + payload.push_str(encoded); + } + if payload.is_empty() { + return false; + } + base64::engine::general_purpose::STANDARD + .decode(payload) + .is_ok() +} + +#[derive(Clone, Copy)] +enum ScriptEncoding { + Utf8 { bom: bool }, + Utf16Le, + Utf16Be, +} + +impl ScriptEncoding { + fn decode(bytes: &[u8]) -> Result<(String, Self)> { + if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { + return Ok(( + String::from_utf8(bytes[3..].to_vec()).context("invalid UTF-8 with BOM")?, + Self::Utf8 { bom: true }, + )); + } + if bytes.starts_with(&[0xFF, 0xFE]) { + return Ok((decode_utf16(&bytes[2..], true)?, Self::Utf16Le)); + } + if bytes.starts_with(&[0xFE, 0xFF]) { + return Ok((decode_utf16(&bytes[2..], false)?, Self::Utf16Be)); + } + Ok(( + String::from_utf8(bytes.to_vec()).context("invalid UTF-8 script")?, + Self::Utf8 { bom: false }, + )) + } + + fn encode(self, content: &str) -> Vec { + match self { + Self::Utf8 { bom } => { + let mut bytes = Vec::with_capacity(content.len() + usize::from(bom) * 3); + if bom { + bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]); + } + bytes.extend_from_slice(content.as_bytes()); + bytes + } + Self::Utf16Le => encode_utf16(content, true, [0xFF, 0xFE]), + Self::Utf16Be => encode_utf16(content, false, [0xFE, 0xFF]), + } + } +} + +fn decode_utf16(bytes: &[u8], little_endian: bool) -> Result { + if !bytes.len().is_multiple_of(2) { + return Err(anyhow!("UTF-16 script has an odd byte length")); + } + let words = bytes + .chunks_exact(2) + .map(|pair| { + if little_endian { + u16::from_le_bytes([pair[0], pair[1]]) + } else { + u16::from_be_bytes([pair[0], pair[1]]) + } + }) + .collect::>(); + String::from_utf16(&words).context("invalid UTF-16 script") +} + +fn encode_utf16(content: &str, little_endian: bool, bom: [u8; 2]) -> Vec { + let mut bytes = Vec::with_capacity(2 + content.len() * 2); + bytes.extend_from_slice(&bom); + for word in content.encode_utf16() { + let encoded = if little_endian { + word.to_le_bytes() + } else { + word.to_be_bytes() + }; + bytes.extend_from_slice(&encoded); + } + bytes +} + +fn extension_lower(path: &Path) -> String { + path.extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default() +} + +fn is_pe_winmd_extension(extension: &str) -> bool { + matches!(extension, "exe" | "dll" | "sys" | "ocx" | "efi" | "winmd") +} + +#[cfg(test)] +mod tests { + use super::{is_authenticode_signature_block, remove_script_signature_block}; + + #[test] + fn removes_script_signature_and_its_surrounding_newline() { + let signed = "Write-Output test\r\n# SIG # Begin signature block\r\n# YWJj\r\n# SIG # End signature block\r\n"; + assert!(is_authenticode_signature_block("\r\n# YWJj\r\n", "ps1")); + assert_eq!( + remove_script_signature_block(signed, "ps1"), + Some("Write-Output test".to_string()) + ); + } + + #[test] + fn removes_xml_and_mof_signature_blocks() { + assert_eq!( + remove_script_signature_block( + "\n\n\n\n", + "ps1xml" + ), + Some("".to_string()) + ); + assert_eq!( + remove_script_signature_block( + "instance of x {}\n/* SIG # Begin signature block */\n/* YWJj */\n/* SIG # End signature block */\n", + "mof" + ), + Some("instance of x {}".to_string()) + ); + } + + #[test] + fn does_not_remove_nonterminal_or_non_cms_marker_blocks() { + let source = "# SIG # Begin signature block\nnot-a-signature\n# SIG # End signature block\nWrite-Output test\n"; + assert_eq!(remove_script_signature_block(source, "ps1"), None); + } +} diff --git a/src/portable_sign.rs b/src/portable_sign.rs index 0813be8..83da4c9 100644 --- a/src/portable_sign.rs +++ b/src/portable_sign.rs @@ -10,6 +10,7 @@ use rayon::prelude::*; use serde::Deserialize; use std::collections::HashSet; use std::ffi::OsString; +use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; #[cfg(feature = "artifact-signing-rest")] @@ -25,11 +26,6 @@ struct ArtifactSigningMetadataDoc { ExcludeCredentials: Option>, } -enum SignOneTargetResult { - Signed(Vec), - SkippedAlreadySigned, -} - pub fn sign_file(args: &SignArgs, _global: &GlobalOpts) -> Result { if artifact_signing_requested(args) && azure_key_vault_requested(args) { return Err(anyhow!( @@ -43,45 +39,30 @@ pub fn sign_file(args: &SignArgs, _global: &GlobalOpts) -> Result return sign_file_azure_key_vault(args); } validate_supported_options(args)?; - if args.files.is_empty() { + let targets = expand_sign_targets(args)?; + if targets.is_empty() { return Err(anyhow!("portable sign requires at least one file")); } - let thumbprint = args - .cert_sha1 - .as_deref() - .ok_or_else(|| anyhow!("portable sign requires --sha1 "))?; - let identity = crate::cert_store::resolve_signing_identity( - args.cert_store_dir.as_deref(), - args.machine_store, - &args.store_name, - thumbprint, - )?; - let mut combined = String::new(); - for (idx, target) in args.files.iter().enumerate() { - if idx > 0 { - combined.push('\n'); - } - let result = sign_one_target(target, &identity, args.append_signature, args.skip_signed) - .with_context(|| format!("portable sign '{}'", target.display()))?; - match result { - SignOneTargetResult::Signed(signed) => { - std::fs::write(target, signed) - .with_context(|| format!("write signed file '{}'", target.display()))?; - combined.push_str(&format!( - "Signed: {}\nthumbprint_sha1={}\nstore={}\\{}\n", - target.display(), - identity.thumbprint_sha1, - identity.scope, - identity.store_name - )); - } - SignOneTargetResult::SkippedAlreadySigned => { - combined.push_str(&format!("Skipped (already signed): {}\n", target.display())); - } - } - } - Ok(CommandOutput::with_exit(combined, success_exit_code(args))) + let identity = args + .pfx + .is_none() + .then(|| { + let thumbprint = args.cert_sha1.as_deref().ok_or_else(|| { + anyhow!("portable sign requires --sha1 without --pfx") + })?; + crate::cert_store::resolve_signing_identity( + args.cert_store_dir.as_deref(), + args.machine_store, + &args.store_name, + thumbprint, + ) + }) + .transpose()?; + + execute_sign_batch(args, &targets, |target| { + try_sign_one_local(target, args, identity.as_ref()) + }) } fn sign_file_artifact_signing(args: &SignArgs) -> Result { @@ -93,18 +74,34 @@ fn sign_file_artifact_signing(args: &SignArgs) -> Result { )); } - let exit_style = resolved_sign_exit_codes(args); + execute_sign_batch(args, &targets, |target| { + try_sign_one_artifact_signing(target, args) + }) +} + +fn sign_file_azure_key_vault(args: &SignArgs) -> Result { + validate_azure_key_vault_supported_options(args)?; + let targets = expand_sign_targets(args)?; + if targets.is_empty() { + return Err(anyhow!( + "portable Azure Key Vault sign requires at least one file" + )); + } + + execute_sign_batch(args, &targets, |target| { + try_sign_one_azure_key_vault(target, args) + }) +} + +fn execute_sign_batch(args: &SignArgs, targets: &[PathBuf], sign_one: F) -> Result +where + F: Fn(&Path) -> Result + Sync, +{ 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 rows: Vec<(usize, Result)> = if parallel { let pool = ThreadPoolBuilder::new() .num_threads(threads.max(1)) .build() @@ -113,94 +110,64 @@ fn sign_file_artifact_signing(args: &SignArgs) -> Result { targets .par_iter() .enumerate() - .map(|(idx, target)| Row { - idx, - result: try_sign_one_artifact_signing(target, args), - }) + .map(|(idx, target)| (idx, sign_one(target))) .collect() }) } else { targets .iter() .enumerate() - .map(|(idx, target)| Row { - idx, - result: try_sign_one_artifact_signing(target, args), - }) + .map(|(idx, target)| (idx, sign_one(target))) .collect() }; let mut ordered = rows; - ordered.sort_by_key(|r| r.idx); - + ordered.sort_by_key(|(idx, _)| *idx); let mut combined = String::new(); - let mut successes: usize = 0; - let mut failures: usize = 0; - for (n, row) in ordered.into_iter().enumerate() { - if n > 0 { + let mut successes = 0; + let mut failures = 0; + for (position, (idx, result)) in ordered.into_iter().enumerate() { + if position > 0 { combined.push('\n'); } - let target_display = targets[row.idx].display().to_string(); - match row.result { + match result { Ok(block) => { successes += 1; combined.push_str(&block); } - Err(e) => { + Err(error) if args.continue_on_error => { failures += 1; - if args.continue_on_error { - combined.push_str(&format!("Failed: {target_display}: {e:#}\n")); - } else { - return Err(e); - } + combined.push_str(&format!("Failed: {}: {error:#}\n", targets[idx].display())); } + Err(error) => return Err(error), } } Ok(CommandOutput::with_exit( combined, - batch_exit_code(exit_style, successes, failures), + batch_exit_code(resolved_sign_exit_codes(args), successes, failures), )) } -fn sign_file_azure_key_vault(args: &SignArgs) -> Result { - validate_azure_key_vault_supported_options(args)?; - if args.files.is_empty() { - return Err(anyhow!( - "portable Azure Key Vault 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'); - } - if args.skip_signed && target_has_valid_existing_pe_signature(target)? { - combined.push_str(&format!("Skipped (already signed): {}\n", target.display())); - continue; +fn validate_supported_options(args: &SignArgs) -> Result<()> { + match args.digest { + DigestAlgorithm::Sha256 | DigestAlgorithm::Sha384 | DigestAlgorithm::Sha512 => {} + DigestAlgorithm::Sha1 | DigestAlgorithm::CertHash => { + return Err(anyhow!( + "portable sign supports only --fd SHA256, SHA384, or SHA512, got {}", + args.digest.as_signtool_name() + )); } - sign_one_target_azure_key_vault(target, args) - .with_context(|| format!("portable Azure Key Vault sign '{}'", target.display()))?; - combined.push_str(&format!( - "Signed: {}\nazure_key_vault_certificate={}\n", - target.display(), - args.azure_key_vault_certificate - .as_deref() - .unwrap_or("") + } + if args.pfx.is_some() && args.cert_sha1.is_some() { + return Err(anyhow!( + "portable sign accepts either --f/--pfx or --sha1 certificate-store material, not both" )); } - Ok(CommandOutput::with_exit(combined, success_exit_code(args))) -} - -fn validate_supported_options(args: &SignArgs) -> Result<()> { - if args.digest != DigestAlgorithm::Sha256 { + if args.password.is_some() && args.pfx.is_none() { return Err(anyhow!( - "portable sign currently supports only --fd SHA256, got {}", - args.digest.as_signtool_name() + "portable sign accepts --p/--password only together with --f/--pfx" )); } - 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)?; @@ -214,13 +181,20 @@ fn validate_supported_options(args: &SignArgs) -> Result<()> { "--trusted-signing-dlib-root", &args.trusted_signing_dlib_root, )?; - reject_string_option("--tr/--timestamp-url", &args.timestamp_url)?; + if args.timestamp_url.is_some() && args.timestamp_digest.is_none() { + return Err(anyhow!( + "portable sign requires --td/--timestamp-digest with --tr/--timestamp-url" + )); + } + if args.timestamp_url.is_none() && args.timestamp_digest.is_some() { + return Err(anyhow!( + "portable 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_option("--td/--timestamp-digest", args.timestamp_digest.is_some())?; reject_string_option("--d/--description", &args.description)?; reject_string_option("--du/--description-url", &args.description_url)?; - reject_vec_option("--ac/--additional-cert", &args.additional_certs)?; reject_string_option("--r/--root-subject-name", &args.root_subject_name)?; reject_string_option("--u/--eku-oid", &args.eku_oid)?; reject_bool_option( @@ -289,7 +263,7 @@ fn validate_supported_options(args: &SignArgs) -> Result<()> { reject_artifact_signing_options(args)?; if args.max_degree_parallelism == Some(0) { return Err(anyhow!( - "portable Artifact Signing sign requires --max-degree-of-parallelism to be at least 1" + "portable sign requires --max-degree-of-parallelism to be at least 1" )); } Ok(()) @@ -307,19 +281,22 @@ fn expand_glob_pattern( 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); - } + insert_sign_target(p, out, seen); } } else { let p = PathBuf::from(pattern); - if seen.insert(p.clone()) { - out.push(p); - } + insert_sign_target(p, out, seen); } Ok(()) } +fn insert_sign_target(path: PathBuf, out: &mut Vec, seen: &mut HashSet) { + let identity = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone()); + if seen.insert(identity) { + out.push(path); + } +} + fn expand_sign_targets(args: &SignArgs) -> Result> { let mut seen = HashSet::new(); let mut out = Vec::new(); @@ -455,12 +432,11 @@ fn validate_azure_key_vault_supported_options(args: &SignArgs) -> Result<()> { 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_option( - "--max-degree-of-parallelism", - args.max_degree_parallelism.is_some(), - )?; + if args.max_degree_parallelism == Some(0) { + return Err(anyhow!( + "portable Azure Key Vault sign requires --max-degree-of-parallelism to be at least 1" + )); + } Ok(()) } @@ -550,85 +526,182 @@ fn validate_artifact_signing_supported_options(args: &SignArgs) -> Result<()> { Ok(()) } -fn sign_one_target( +fn try_sign_one_local( + target: &Path, + args: &SignArgs, + identity: Option<&crate::cert_store::SigningIdentity>, +) -> Result { + if args.skip_signed && target_should_skip_signed(target)? { + return Ok(format!("Skipped (already signed): {}\n", target.display())); + } + + let mut request = portable_sign_request(target, temporary_output_path(target), args)?; + if let Some(pfx) = &args.pfx { + request.pfx_path = Some(pfx.clone()); + request.pfx_password = args.password.clone(); + } else { + let identity = identity.expect("certificate-store identity is resolved before signing"); + request.certificate_path = Some(identity.cert_path.clone()); + request.private_key_path = Some(identity.key_path.clone()); + } + run_portable_core_sign(request, "portable sign")?; + + let mut block = format!("Signed: {}\n", target.display()); + if let Some(identity) = identity { + block.push_str(&format!( + "thumbprint_sha1={}\nstore={}\\{}\n", + identity.thumbprint_sha1, identity.scope, identity.store_name + )); + } else { + block.push_str(&format!( + "pfx={}\n", + args.pfx.as_ref().expect("PFX source is selected").display() + )); + } + Ok(block) +} + +fn try_sign_one_azure_key_vault(target: &Path, args: &SignArgs) -> Result { + if args.skip_signed && target_should_skip_signed(target)? { + return Ok(format!("Skipped (already signed): {}\n", target.display())); + } + + let mut request = portable_sign_request(target, temporary_output_path(target), args)?; + request.azure_key_vault_url = + text_opt(args.azure_key_vault_url.as_deref()).map(ToOwned::to_owned); + request.azure_key_vault_certificate = + text_opt(args.azure_key_vault_certificate.as_deref()).map(ToOwned::to_owned); + request.azure_key_vault_certificate_version = + text_opt(args.azure_key_vault_certificate_version.as_deref()).map(ToOwned::to_owned); + request.azure_key_vault_access_token = + text_opt(args.azure_key_vault_access_token.as_deref()).map(ToOwned::to_owned); + request.azure_key_vault_client_id = + text_opt(args.azure_key_vault_client_id.as_deref()).map(ToOwned::to_owned); + request.azure_key_vault_client_secret = + text_opt(args.azure_key_vault_client_secret.as_deref()).map(ToOwned::to_owned); + request.azure_key_vault_tenant_id = + text_opt(args.azure_key_vault_tenant_id.as_deref()).map(ToOwned::to_owned); + request.azure_key_vault_managed_identity = + Some(effective_azure_key_vault_managed_identity(args)); + request.azure_authority = text_opt(args.azure_authority.as_deref()).map(ToOwned::to_owned); + + run_portable_core_sign(request, "portable Azure Key Vault sign")?; + Ok(format!( + "Signed: {}\nazure_key_vault_certificate={}\n", + target.display(), + args.azure_key_vault_certificate + .as_deref() + .unwrap_or("") + )) +} + +fn portable_sign_request( + target: &Path, + output: PathBuf, + args: &SignArgs, +) -> Result { + validate_portable_core_target(target, args.append_signature)?; + Ok(psign_portable_core::PortableSignRequest { + path: target.to_path_buf(), + append_signature: args.append_signature, + skip_signed: args.skip_signed, + output_path: Some(output), + 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()?, + ..Default::default() + }) +} + +fn validate_portable_core_target( target: &Path, - identity: &crate::cert_store::SigningIdentity, append_signature: bool, - skip_signed: bool, -) -> Result { +) -> Result { let ext = target_extension_lower(target); - if !is_pe_winmd_extension(&ext) { + if matches!(ext.as_str(), "msixbundle" | "appxbundle") { return Err(anyhow!( - "portable thumbprint signing is currently implemented only for PE/WinMD targets; got {}", + "portable signing supports flat MSIX/AppX packages but not MSIX/AppX bundles: {}", target.display() )); } - let mut bytes = - std::fs::read(target).with_context(|| format!("read '{}'", target.display()))?; - if skip_signed - && psign_sip_digest::verify_pe::verify_pe_authenticode_digest_consistency_if_signed(&bytes) - .with_context(|| { - format!( - "check existing PE/WinMD Authenticode signature on '{}'", - target.display() - ) - })? - .is_some() - { - return Ok(SignOneTargetResult::SkippedAlreadySigned); + let format = psign_portable_core::infer_format(target); + match format { + psign_portable_core::PortableFileFormat::Catalog => { + return Err(anyhow!( + "portable native-shaped signing does not support catalog targets; use `psign-tool portable sign-catalog` with an explicit subject list" + )); + } + psign_portable_core::PortableFileFormat::WshScript => { + return Err(anyhow!( + "portable native-shaped signing does not support WSH script targets (.vbs, .js, .wsf): {}", + target.display() + )); + } + psign_portable_core::PortableFileFormat::Unknown => { + return Err(anyhow!( + "portable native-shaped signing supports PE/WinMD, CAB, MSI/MSP, flat MSIX/AppX, NuGet, VSIX, ClickOnce manifests, App Installer, ZIP, and PowerShell scripts; got {}", + target.display() + )); + } + _ => {} } - if !append_signature { - bytes = psign_sip_digest::pe_embed::pe_remove_authenticode_certificates(bytes) - .with_context(|| { - format!( - "remove existing PE Authenticode signatures from '{}'", - target.display() - ) - })? - .0; + if append_signature && format != psign_portable_core::PortableFileFormat::Pe { + return Err(anyhow!( + "--as/--append-signature is only supported for portable PE/WinMD signing" + )); } - psign_sip_digest::pe_sign::sign_pe_image_rsa_sha256( - &bytes, - &identity.cert_der, - &identity.key_pem, - ) - .map(SignOneTargetResult::Signed) + Ok(format) } -fn sign_one_target_azure_key_vault(target: &Path, args: &SignArgs) -> Result<()> { - let ext = target_extension_lower(target); - let tmp = temporary_output_path(target); - let result = match ext.as_str() { - ext if is_pe_winmd_extension(ext) => run_portable_sign_pe_azure_key_vault(target, &tmp, args), - ext if is_portable_powershell_script_extension(ext) => { - if args.append_signature { - Err(anyhow!( - "--as/--append-signature is only supported for portable PE/WinMD signing" - )) - } else { - run_portable_sign_script_azure_key_vault(target, &tmp, args) - } - } - _ => Err(anyhow!( - "portable Azure Key Vault signing is currently implemented for PE/WinMD and PowerShell Authenticode script targets (.ps1, .psd1, .psm1, .ps1xml, .psc1, .cdxml, .mof); got {}", - target.display() - )), - } +fn run_portable_core_sign( + request: psign_portable_core::PortableSignRequest, + operation: &str, +) -> Result<()> { + let target = request.path.clone(); + let output = request + .output_path + .clone() + .expect("portable sign output is set"); + let format = validate_portable_core_target(&target, request.append_signature)?; + let companion = (format == psign_portable_core::PortableFileFormat::AppInstaller) + .then(|| appinstaller_companion_path(&output)); + let result = psign_portable_core::portable_sign(request) + .map(|_| ()) + .with_context(|| format!("{operation} '{}'", target.display())) .and_then(|_| { - std::fs::copy(&tmp, target) + std::fs::copy(&output, &target) .with_context(|| format!("replace '{}' with signed output", target.display()))?; + if let Some(companion) = &companion { + let target_companion = appinstaller_companion_path(&target); + std::fs::copy(companion, &target_companion).with_context(|| { + format!( + "replace App Installer companion '{}' with '{}'", + target_companion.display(), + companion.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); + let _ = std::fs::remove_file(&output); + if let Some(companion) = companion { + let _ = std::fs::remove_file(companion); } result } +fn appinstaller_companion_path(path: &Path) -> PathBuf { + path.with_extension( + path.extension() + .map(|extension| format!("{}.p7", extension.to_string_lossy())) + .unwrap_or_else(|| "p7".to_string()), + ) +} + fn sign_one_target_artifact_signing(target: &Path, args: &SignArgs) -> Result<()> { let ext = target_extension_lower(target); let tmp = temporary_output_path(target); @@ -706,137 +779,6 @@ fn target_has_valid_existing_pe_signature(target: &Path) -> Result { ) } -fn run_portable_sign_pe_azure_key_vault( - target: &Path, - output: &Path, - args: &SignArgs, -) -> Result<()> { - let mut argv = vec![ - OsString::from("psign-tool"), - OsString::from("sign-pe"), - target.as_os_str().to_os_string(), - OsString::from("--digest"), - OsString::from(portable_digest_name(args.digest)?), - ]; - if args.append_signature { - argv.push(OsString::from("--append-signature")); - } - 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)?)); - } - push_option( - &mut argv, - "--azure-key-vault-url", - &args.azure_key_vault_url, - ); - push_option( - &mut argv, - "--azure-key-vault-certificate", - &args.azure_key_vault_certificate, - ); - push_option( - &mut argv, - "--azure-key-vault-certificate-version", - &args.azure_key_vault_certificate_version, - ); - push_option( - &mut argv, - "--azure-key-vault-accesstoken", - &args.azure_key_vault_access_token, - ); - if effective_azure_key_vault_managed_identity(args) { - argv.push(OsString::from("--azure-key-vault-managed-identity")); - } - push_option( - &mut argv, - "--azure-key-vault-tenant-id", - &args.azure_key_vault_tenant_id, - ); - push_option( - &mut argv, - "--azure-key-vault-client-id", - &args.azure_key_vault_client_id, - ); - push_option( - &mut argv, - "--azure-key-vault-client-secret", - &args.azure_key_vault_client_secret, - ); - push_option(&mut argv, "--azure-authority", &args.azure_authority); - argv.push(OsString::from("--output")); - argv.push(output.as_os_str().to_os_string()); - - std::thread::Builder::new() - .name("psign-portable-sign-pe".to_string()) - .stack_size(8 * 1024 * 1024) - .spawn(move || psign_digest_cli::run_from(argv)) - .map_err(|e| anyhow!("spawn portable sign-pe runner: {e}"))? - .join() - .map_err(|_| anyhow!("portable sign-pe runner panicked"))? -} - -#[cfg(feature = "azure-kv-sign")] -fn run_portable_sign_script_azure_key_vault( - target: &Path, - output: &Path, - args: &SignArgs, -) -> Result<()> { - 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()?, - azure_key_vault_url: text_opt(args.azure_key_vault_url.as_deref()).map(ToOwned::to_owned), - azure_key_vault_certificate: text_opt(args.azure_key_vault_certificate.as_deref()) - .map(ToOwned::to_owned), - azure_key_vault_certificate_version: text_opt( - args.azure_key_vault_certificate_version.as_deref(), - ) - .map(ToOwned::to_owned), - azure_key_vault_access_token: text_opt(args.azure_key_vault_access_token.as_deref()) - .map(ToOwned::to_owned), - azure_key_vault_client_id: text_opt(args.azure_key_vault_client_id.as_deref()) - .map(ToOwned::to_owned), - azure_key_vault_client_secret: text_opt(args.azure_key_vault_client_secret.as_deref()) - .map(ToOwned::to_owned), - azure_key_vault_tenant_id: text_opt(args.azure_key_vault_tenant_id.as_deref()) - .map(ToOwned::to_owned), - azure_key_vault_managed_identity: Some(effective_azure_key_vault_managed_identity(args)), - azure_authority: text_opt(args.azure_authority.as_deref()).map(ToOwned::to_owned), - ..Default::default() - }; - psign_portable_core::portable_sign(request) - .map(|_| ()) - .with_context(|| { - format!( - "portable Azure Key Vault PowerShell script target '{}'", - target.display() - ) - }) -} - -#[cfg(not(feature = "azure-kv-sign"))] -fn run_portable_sign_script_azure_key_vault( - _target: &Path, - _output: &Path, - _args: &SignArgs, -) -> Result<()> { - Err(anyhow!( - "portable Azure Key Vault signing support is not compiled into this build (feature: azure-kv-sign)" - )) -} - fn run_portable_sign_pe_artifact_signing( target: &Path, output: &Path, @@ -1174,7 +1116,6 @@ fn artifact_signing_metadata(args: &SignArgs) -> Result Result { @@ -1184,13 +1125,12 @@ fn portable_core_digest( crate::cli::DigestAlgorithm::Sha512 => psign_portable_core::PortableDigestAlgorithm::Sha512, crate::cli::DigestAlgorithm::Sha1 | crate::cli::DigestAlgorithm::CertHash => { return Err(anyhow!( - "portable cloud signing supports SHA256, SHA384, and SHA512 file digests" + "portable signing supports SHA256, SHA384, and SHA512 file digests" )); } }) } -#[cfg(any(feature = "artifact-signing-rest", feature = "azure-kv-sign"))] fn portable_core_timestamp_digest( digest: crate::cli::DigestAlgorithm, ) -> Result { @@ -1209,7 +1149,7 @@ fn portable_core_timestamp_digest( } crate::cli::DigestAlgorithm::CertHash => { return Err(anyhow!( - "portable cloud signing timestamp digest does not support certHash" + "portable signing timestamp digest does not support certHash" )); } }) @@ -1354,11 +1294,23 @@ fn push_path_option(argv: &mut Vec, name: &str, value: Option<&PathBuf } fn temporary_output_path(target: &Path) -> PathBuf { - let file_name = target - .file_name() - .and_then(|n| n.to_str()) + let stem = target + .file_stem() + .and_then(|name| name.to_str()) .unwrap_or("signed-output"); - target.with_file_name(format!("{}.psign-{}.tmp", file_name, std::process::id())) + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + target.hash(&mut hasher); + let target_id = hasher.finish(); + match target.extension().and_then(|extension| extension.to_str()) { + Some(extension) => target.with_file_name(format!( + "{stem}.psign-{}-{target_id:016x}.{extension}", + std::process::id() + )), + None => target.with_file_name(format!( + "{stem}.psign-{}-{target_id:016x}", + std::process::id() + )), + } } fn azure_key_vault_requested(args: &SignArgs) -> bool { @@ -1436,12 +1388,6 @@ fn reject_workload_identity(name: &str, value: Option) -> R Ok(()) } -fn success_exit_code(args: &SignArgs) -> i32 { - 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; @@ -1485,7 +1431,7 @@ fn batch_exit_code(exit_style: SignExitCodes, successes: usize, failures: usize) 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, --skip-signed), Azure Key Vault PE/WinMD signing (--azure-key-vault-*, --fd SHA256/SHA384/SHA512, --skip-signed), and Azure Artifact Signing PE/WinMD signing (--artifact-signing-* or --dmdf, --skip-signed)" + "portable sign does not support {name}; local PFX/certificate-store and Azure Key Vault signing support PE/WinMD, CAB, MSI/MSP, flat MSIX/AppX, NuGet, VSIX, ClickOnce manifests, App Installer, ZIP, and PowerShell scripts, while Azure Artifact Signing supports its documented native-shaped subset" )); } Ok(()) @@ -1503,10 +1449,6 @@ fn reject_path_option(name: &str, value: &Option) -> Result<()> { reject_option(name, value.is_some()) } -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", @@ -1579,3 +1521,36 @@ fn reject_artifact_signing_options(args: &SignArgs) -> Result<()> { )?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::{insert_sign_target, temporary_output_path}; + use std::collections::HashSet; + + #[test] + fn deduplicates_existing_path_aliases() { + let directory = tempfile::tempdir().expect("temporary directory"); + let target = directory.path().join("target.ps1"); + std::fs::write(&target, "Write-Output test").expect("write target"); + let alias = directory.path().join(".").join("target.ps1"); + + let mut targets = Vec::new(); + let mut seen = HashSet::new(); + insert_sign_target(target, &mut targets, &mut seen); + insert_sign_target(alias, &mut targets, &mut seen); + + assert_eq!(targets.len(), 1); + } + + #[test] + fn temporary_output_paths_do_not_collide_for_same_named_targets() { + let directory = tempfile::tempdir().expect("temporary directory"); + let first = directory.path().join("one").join("target.ps1"); + let second = directory.path().join("two").join("target.ps1"); + + assert_ne!( + temporary_output_path(&first), + temporary_output_path(&second) + ); + } +} diff --git a/tests/cert_store_cli.rs b/tests/cert_store_cli.rs index e7fc256..7782ba8 100644 --- a/tests/cert_store_cli.rs +++ b/tests/cert_store_cli.rs @@ -1,7 +1,7 @@ use assert_cmd::Command; use base64::Engine as _; use predicates::prelude::*; -use psign_sip_digest::{pe_digest, pkcs7, verify_pe}; +use psign_sip_digest::{cab_digest, pe_digest, pkcs7, verify_pe}; use rand::rngs::OsRng; use rsa::RsaPrivateKey; use rsa::pkcs1v15::SigningKey; @@ -685,6 +685,122 @@ fn portable_sign_sha1_rejects_unsupported_format() { .stderr(predicate::str::contains("PE/WinMD")); } +#[test] +fn portable_sign_pfx_signs_cab_and_powershell_from_input_file_list() { + let temp = tempfile::tempdir().expect("tempdir"); + let pfx_path = temp.path().join("signing.pfx"); + let cab_path = temp.path().join("sample.cab"); + let script_path = temp.path().join("sample.psd1"); + let list_path = temp.path().join("inputs.txt"); + let fixture = test_cert("psign PFX portable package test"); + std::fs::write(&pfx_path, test_pfx_der(&fixture, "secret")).expect("write PFX"); + std::fs::copy( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/generated-unsigned/cab/sample.cab"), + &cab_path, + ) + .expect("copy unsigned CAB"); + std::fs::copy( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/unsigned-sample.psd1"), + &script_path, + ) + .expect("copy unsigned script"); + std::fs::write( + &list_path, + format!("{}\n{}\n", cab_path.display(), script_path.display()), + ) + .expect("write input list"); + + psign_tool() + .args(["--mode", "portable", "sign", "--pfx"]) + .arg(&pfx_path) + .args([ + "--password", + "secret", + "--input-file-list", + list_path.to_str().expect("UTF-8 path"), + "--max-degree-of-parallelism", + "2", + "--exit-codes", + "azure", + ]) + .assert() + .success() + .stdout(predicate::str::contains(format!( + "Signed: {}", + cab_path.display() + ))) + .stdout(predicate::str::contains(format!( + "Signed: {}", + script_path.display() + ))); + + let cab = std::fs::read(&cab_path).expect("read signed CAB"); + cab_digest::cab_signature_pkcs7_der(&cab).expect("CAB signature"); + assert!( + std::fs::read_to_string(&script_path) + .expect("read signed script") + .contains("# SIG # Begin signature block") + ); +} + +#[test] +fn portable_sign_certificate_store_signs_cab_and_rejects_non_pe_append() { + let temp = tempfile::tempdir().expect("tempdir"); + let store_dir = temp.path().join("cert-store"); + let cert_path = temp.path().join("cert.der"); + let key_path = temp.path().join("cert.key"); + let cab_path = temp.path().join("sample.cab"); + let fixture = test_cert("psign certificate-store CAB test"); + let thumbprint = sha1_upper(&fixture.der); + std::fs::write(&cert_path, &fixture.der).expect("write certificate"); + std::fs::write(&key_path, &fixture.key_pem).expect("write private key"); + std::fs::copy( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/generated-unsigned/cab/sample.cab"), + &cab_path, + ) + .expect("copy unsigned CAB"); + + psign_tool() + .args(["cert-store", "import", "--cert-store-dir"]) + .arg(&store_dir) + .arg("--key") + .arg(&key_path) + .arg(&cert_path) + .assert() + .success(); + + psign_tool() + .args(["--mode", "portable", "sign", "--cert-store-dir"]) + .arg(&store_dir) + .args([ + "--sha1", + &thumbprint, + "--append-signature", + "--digest", + "sha256", + ]) + .arg(&cab_path) + .assert() + .failure() + .stderr(predicate::str::contains( + "--as/--append-signature is only supported for portable PE/WinMD signing", + )); + + psign_tool() + .args(["--mode", "portable", "sign", "--cert-store-dir"]) + .arg(&store_dir) + .args(["--sha1", &thumbprint, "--digest", "sha256"]) + .arg(&cab_path) + .assert() + .success() + .stdout(predicate::str::contains("Signed:")); + cab_digest::cab_signature_pkcs7_der(&std::fs::read(&cab_path).expect("read signed CAB")) + .expect("CAB signature"); +} + fn test_pfx_der(fixture: &TestCert, password: &str) -> Vec { use picky::key::PrivateKey; use picky::pkcs12::{ diff --git a/tests/cli_pe_digest.rs b/tests/cli_pe_digest.rs index 7000976..c2de711 100644 --- a/tests/cli_pe_digest.rs +++ b/tests/cli_pe_digest.rs @@ -2714,6 +2714,86 @@ fn unified_verify_mode_portable_uses_digest_only_when_auto_trust_disabled() { cmd.assert().success(); } +#[test] +fn unified_verify_mode_portable_routes_detached_pkcs7() { + let repo = repo_root(); + let content = repo.join("tests/fixtures/generated-unsigned/appinstaller/sample.appinstaller"); + let signature = + repo.join("tests/fixtures/generated-signed/appinstaller/sample.appinstaller.p7"); + + let mut cmd = Command::cargo_bin("psign-tool").unwrap(); + cmd.args(["--mode", "portable", "verify"]) + .arg("--detached-pkcs7") + .arg(&signature) + .arg("--anchor-dir") + .arg(anchor_dir(&repo)) + .arg(&content); + cmd.assert() + .success() + .stdout(predicate::str::contains("trust-verify-detached: ok")); +} + +#[test] +fn unified_verify_mode_portable_routes_detached_pkcs7_with_content_override() { + let repo = repo_root(); + let content = repo.join("tests/fixtures/generated-unsigned/appinstaller/sample.appinstaller"); + let signature = + repo.join("tests/fixtures/generated-signed/appinstaller/sample.appinstaller.p7"); + + let mut cmd = Command::cargo_bin("psign-tool").unwrap(); + cmd.args(["--mode", "portable", "verify"]) + .arg("--detached-pkcs7") + .arg(&signature) + .arg("--detached-pkcs7-content") + .arg(&content) + .arg("--anchor-dir") + .arg(anchor_dir(&repo)) + .arg(&signature); + cmd.assert() + .success() + .stdout(predicate::str::contains("trust-verify-detached: ok")); +} + +#[test] +fn unified_verify_mode_portable_routes_explicit_catalog_and_each_subject() { + let repo = repo_root(); + let catalog = repo.join("tests/fixtures/generated-signed/catalog/sample.cat"); + let member = repo.join("tests/fixtures/generated-unsigned/catalog/member.sys"); + let inf = repo.join("tests/fixtures/generated-unsigned/catalog/sample.inf"); + + let mut cmd = Command::cargo_bin("psign-tool").unwrap(); + cmd.args(["--mode", "portable", "verify"]) + .arg("--catalog") + .arg(&catalog) + .arg("--anchor-dir") + .arg(anchor_dir(&repo)) + .arg(&member) + .arg(&inf); + cmd.assert() + .success() + .stdout(predicate::str::contains("trust-verify-catalog: ok")) + .stdout(predicate::str::contains("verify-catalog-member: ok")) + .stdout(predicate::str::contains("member.sys")) + .stdout(predicate::str::contains("sample.inf")); +} + +#[test] +fn unified_verify_mode_portable_rejects_catalog_hash_selection() { + let repo = repo_root(); + let catalog = repo.join("tests/fixtures/generated-signed/catalog/sample.cat"); + let member = repo.join("tests/fixtures/generated-unsigned/catalog/member.sys"); + + let mut cmd = Command::cargo_bin("psign-tool").unwrap(); + cmd.args(["--mode", "portable", "verify"]) + .arg("--catalog") + .arg(&catalog) + .args(["--catalog-hash-algorithm", "sha1"]) + .arg(&member); + cmd.assert().failure().stderr(predicate::str::contains( + "derives each member digest algorithm from the catalog", + )); +} + #[test] fn trust_verify_pe_ok_with_prefer_timestamp_signing_time_and_as_of() { let fixture = tiny32_fixture(); @@ -3205,6 +3285,141 @@ fn timestamp_pe_rfc3161_attaches_unsigned_timestamp_attribute() { .stdout(predicate::str::contains("1.3.6.1.4.1.311.3.3.1")); } +#[cfg(all(feature = "timestamp-server", feature = "timestamp-http"))] +#[test] +fn mode_portable_timestamp_posts_and_embeds_rfc3161_token_for_existing_pe() { + let dir = tempfile::tempdir().expect("tempdir"); + let cert = dir.path().join("signer.der"); + let key = dir.path().join("signer.pk8"); + let signed_pe = dir.path().join("tiny32.signed.exe"); + write_test_rsa_cert_key(&cert, &key); + + let mut sign = portable_cmd(); + sign.arg("sign-pe") + .arg(tiny32_unsigned_fixture()) + .arg("--cert") + .arg(&cert) + .arg("--key") + .arg(&key) + .arg("--output") + .arg(&signed_pe); + sign.assert().success(); + + let (mut guard, url) = spawn_psign_server(&[]); + let mut stamp = Command::cargo_bin("psign-tool").expect("binary"); + stamp + .args([ + "--mode", + "portable", + "timestamp", + "--rfc3161-url", + &url, + "--digest", + "sha256", + ]) + .arg(&signed_pe); + stamp + .assert() + .success() + .stdout(predicate::str::contains("timestamp-pe-rfc3161: ok")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let inspection = + inspect_pe_authenticode(&std::fs::read(&signed_pe).expect("read timestamped PE")) + .expect("inspect timestamped PE"); + assert!( + inspection + .entries + .iter() + .flat_map(|entry| entry.pkcs7.signers.iter()) + .flat_map(|signer| signer.timestamp_hints.iter()) + .any(|hint| hint.kind == "microsoft_nested_rfc3161_attribute"), + "expected embedded Microsoft RFC3161 timestamp token" + ); +} + +#[test] +fn mode_portable_timestamp_rejects_unsupported_modes_and_non_pe_inputs() { + let reject = |args: &[&str], expected: &str| { + let mut cmd = Command::cargo_bin("psign-tool").expect("binary"); + cmd.args(args); + cmd.assert() + .failure() + .stderr(predicate::str::contains(expected)); + }; + + reject( + &[ + "--mode", + "portable", + "timestamp", + "--legacy-url", + "http://tsa.example/legacy", + "missing.exe", + ], + "legacy", + ); + reject( + &[ + "--mode", + "portable", + "timestamp", + "--seal-timestamp-url", + "http://tsa.example/sealed", + "missing.exe", + ], + "sealing", + ); + reject( + &[ + "--mode", + "portable", + "timestamp", + "--rfc3161-url", + "http://tsa.example/rfc3161", + "--digest", + "sha256", + "--timestamp-pkcs7-files", + "missing.p7", + ], + "timestamp-pkcs7", + ); + reject( + &[ + "--mode", + "portable", + "timestamp", + "--rfc3161-url", + "http://tsa.example/rfc3161", + "--digest", + "sha256", + "--signature-index", + "1", + "missing.exe", + ], + "signature-index", + ); + + let dir = tempfile::tempdir().expect("tempdir"); + let non_pe = dir.path().join("not-a-pe.txt"); + std::fs::write(&non_pe, b"not a PE").expect("write non-PE"); + let mut cmd = Command::cargo_bin("psign-tool").expect("binary"); + cmd.args([ + "--mode", + "portable", + "timestamp", + "--rfc3161-url", + "http://tsa.example/rfc3161", + "--digest", + "sha256", + ]) + .arg(&non_pe); + cmd.assert() + .failure() + .stderr(predicate::str::contains("only PE/WinMD")); +} + #[test] fn inspect_pe_spc_indirect_matches_sip_digest_on_tiny_fixture() { let mut cmd = portable_cmd(); @@ -4796,6 +5011,129 @@ fn mode_portable_sign_uses_azure_key_vault_for_psd1() { assert!(signed.contains("# SIG # Begin signature block")); } +#[cfg(all(feature = "timestamp-server", feature = "azure-kv-sign"))] +#[test] +fn mode_portable_sign_azure_key_vault_signs_portable_formats_in_ordered_batch() { + let dir = tempfile::tempdir().unwrap(); + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let targets = [ + ("sample.cab", root.join("generated-unsigned/cab/sample.cab")), + ( + "tiny.msi", + root.join("generated-unsigned/installer/tiny.msi"), + ), + ( + "tiny-patch.msp", + root.join("generated-unsigned/installer/tiny-patch.msp"), + ), + ( + "sample.msix", + root.join("generated-unsigned/msix/sample.msix"), + ), + ( + "sample.appx", + root.join("generated-unsigned/msix/sample.appx"), + ), + ( + "sample.nupkg", + root.join("package-signing/unsigned/sample.nupkg"), + ), + ( + "sample.vsix", + root.join("package-signing/unsigned/sample.vsix"), + ), + ( + "sample.appinstaller", + root.join("generated-unsigned/appinstaller/sample.appinstaller"), + ), + ("sample.psd1", root.join("unsigned-sample.psd1")), + ]; + let mut paths = Vec::new(); + for (name, source) in targets { + let path = dir.path().join(name); + std::fs::copy(source, &path).expect("copy unsigned portable target"); + paths.push(path); + } + let unsupported = dir.path().join("unsupported.txt"); + std::fs::write(&unsupported, b"unsupported portable signing target") + .expect("write unsupported"); + let input_list = dir.path().join("inputs.txt"); + let mut entries = Vec::new(); + entries.push(paths[0].display().to_string()); + entries.push(unsupported.display().to_string()); + entries.extend(paths[1..].iter().map(|path| path.display().to_string())); + std::fs::write(&input_list, entries.join("\n")).expect("write input list"); + + // Keep serving until the client finishes: some package formats make more than one signing + // request, and the test only needs to verify completed client-side results. + let (_guard, url, certificate) = spawn_psign_azure_key_vault_server(0); + let mut cmd = Command::cargo_bin("psign-tool").unwrap(); + cmd.arg("--mode") + .arg("portable") + .arg("sign") + .arg("--digest") + .arg("sha256") + .arg("--azure-key-vault-url") + .arg(&url) + .arg("--azure-key-vault-certificate") + .arg(&certificate) + .arg("--azure-key-vault-accesstoken") + .arg("test-token") + .arg("--input-file-list") + .arg(&input_list) + .arg("--continue-on-error") + .arg("--max-degree-of-parallelism") + .arg("2") + .arg("--exit-codes") + .arg("azure"); + let output = cmd.output().expect("run Key Vault batch signing"); + assert!( + !output.status.success(), + "partial Azure-style batch should not return success" + ); + let stdout = String::from_utf8(output.stdout).expect("UTF-8 output"); + let first = stdout + .find(&format!("Signed: {}", paths[0].display())) + .expect("first signed target"); + let failed = stdout.find("Failed:").expect("unsupported target failure"); + let next = stdout + .find(&format!("Signed: {}", paths[1].display())) + .expect("second signed target"); + assert!( + first < failed && failed < next, + "batch results must retain input-list order:\n{stdout}" + ); + for path in &paths { + assert!( + stdout.contains(&format!("Signed: {}", path.display())), + "missing successful target {}:\n{stdout}", + path.display() + ); + } + assert!( + stdout.contains("native-shaped signing"), + "unsupported target should use the explicit format diagnostic:\n{stdout}" + ); + + cab_digest::cab_signature_pkcs7_der(&std::fs::read(&paths[0]).expect("read CAB")) + .expect("signed CAB"); + msi_digest::msi_digital_signature_pkcs7_der(&std::fs::read(&paths[1]).expect("read MSI")) + .expect("signed MSI"); + msi_digest::msi_digital_signature_pkcs7_der(&std::fs::read(&paths[2]).expect("read MSP")) + .expect("signed MSP"); + psign_sip_digest::msix_digest::verify_msix_digest_consistency(&paths[3]).expect("signed MSIX"); + psign_sip_digest::msix_digest::verify_msix_digest_consistency(&paths[4]).expect("signed AppX"); + assert!( + std::fs::read_to_string(&paths[8]) + .expect("read signed script") + .contains("# SIG # Begin signature block") + ); + assert!( + paths[7].with_extension("appinstaller.p7").is_file(), + "App Installer companion signature should replace the original companion" + ); +} + #[cfg(all(feature = "timestamp-server", feature = "azure-kv-sign"))] #[test] fn mode_portable_sign_azure_key_vault_skip_signed_skips_valid_pe_without_service() { @@ -5583,7 +5921,7 @@ fn mode_portable_artifact_signing_uses_file_list_skip_signed_and_parallelism() { 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"); + let unsupported_path = dir.path().join("unsupported.js"); 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"); @@ -5611,7 +5949,7 @@ fn mode_portable_artifact_signing_continue_on_error_reports_partial_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", + "portable Artifact Signing is currently implemented for PE/WinMD, PowerShell Authenticode scripts", )); let status = guard.0.wait().expect("server exit"); assert!(status.success(), "server failed with {status}"); diff --git a/tests/portable_remove.rs b/tests/portable_remove.rs new file mode 100644 index 0000000..f193873 --- /dev/null +++ b/tests/portable_remove.rs @@ -0,0 +1,81 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use psign_sip_digest::pe_embed; +use std::path::Path; + +fn portable_remove(path: &Path) -> Command { + let mut command = Command::cargo_bin("psign-tool").expect("psign-tool binary"); + command + .args(["--mode", "portable", "remove", "--strip-signature"]) + .arg(path); + command +} + +#[test] +fn mode_portable_remove_strips_powershell_signature_block() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("module.psd1"); + std::fs::write( + &path, + "@{ RootModule = 'module.psm1' }\r\n# SIG # Begin signature block\r\n# YWJj\r\n# SIG # End signature block\r\n", + ) + .expect("write signed script"); + + portable_remove(&path) + .assert() + .success() + .stdout(predicate::str::contains( + "Removed embedded Authenticode data", + )); + + assert_eq!( + std::fs::read_to_string(&path).expect("read unsigned script"), + "@{ RootModule = 'module.psm1' }" + ); +} + +#[test] +fn mode_portable_remove_strips_pe_signature() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("signed.exe"); + let unsigned = std::fs::read( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/generated-unsigned/pe/tiny32-pe-alias.exe"), + ) + .expect("read unsigned PE fixture"); + let signed = pe_embed::pe_append_authenticode_pkcs7_certificate(unsigned.clone(), &[1, 2, 3]) + .expect("add certificate table row"); + std::fs::write(&path, signed).expect("write PE with certificate table row"); + + portable_remove(&path) + .assert() + .success() + .stdout(predicate::str::contains( + "Removed embedded Authenticode data", + )); + + let removed = std::fs::read(&path).expect("read unsigned PE"); + let (_, remaining) = pe_embed::pe_remove_authenticode_certificates(removed) + .expect("inspect PE certificate table"); + assert_eq!(remaining, 0); +} + +#[test] +fn mode_portable_remove_rejects_partial_cms_mutation() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("module.ps1"); + std::fs::write(&path, "Write-Output test").expect("write script"); + + let mut command = Command::cargo_bin("psign-tool").expect("psign-tool binary"); + command + .args([ + "--mode", + "portable", + "remove", + "--strip-chain-except-signer", + ]) + .arg(&path); + command.assert().failure().stderr(predicate::str::contains( + "portable remove supports only --strip-signature", + )); +} From 9f6b72d4c51684b5352fbca3d9fc21f18d0cae4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 14 Aug 2026 11:40:37 -0400 Subject: [PATCH 3/4] Preserve repeated portable signing targets --- src/portable_sign.rs | 53 ++++++++++++++++++++++++++++++++++++++--- tests/cert_store_cli.rs | 27 +++++++++++++++++++-- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/portable_sign.rs b/src/portable_sign.rs index 83da4c9..661cd2b 100644 --- a/src/portable_sign.rs +++ b/src/portable_sign.rs @@ -97,7 +97,9 @@ fn execute_sign_batch(args: &SignArgs, targets: &[PathBuf], sign_one: F) -> R where F: Fn(&Path) -> Result + Sync, { - let parallel = args.max_degree_parallelism != Some(1) && targets.len() > 1; + let parallel = args.max_degree_parallelism != Some(1) + && targets.len() > 1 + && !has_duplicate_target_identities(targets); let threads = args .max_degree_parallelism .unwrap_or_else(rayon::current_num_threads); @@ -312,11 +314,26 @@ fn expand_sign_targets(args: &SignArgs) -> Result> { } } for p in &args.files { - expand_glob_pattern(&p.to_string_lossy(), &mut out, &mut seen)?; + let pattern = p.to_string_lossy(); + if pattern.contains('*') || pattern.contains('?') { + expand_glob_pattern(&pattern, &mut out, &mut seen)?; + } else { + // Native-shaped trailing targets are operations, not a set: signing the same + // PE twice with --append-signature intentionally adds two signatures. + out.push(p.clone()); + } } Ok(out) } +fn has_duplicate_target_identities(targets: &[PathBuf]) -> bool { + let mut seen = HashSet::new(); + targets.iter().any(|target| { + let identity = std::fs::canonicalize(target).unwrap_or_else(|_| target.clone()); + !seen.insert(identity) + }) +} + fn try_sign_one_artifact_signing(target: &Path, args: &SignArgs) -> Result { if args.skip_signed && target_should_skip_signed(target)? { return Ok(format!("Skipped (already signed): {}\n", target.display())); @@ -1524,7 +1541,12 @@ fn reject_artifact_signing_options(args: &SignArgs) -> Result<()> { #[cfg(test)] mod tests { - use super::{insert_sign_target, temporary_output_path}; + use super::{ + expand_sign_targets, has_duplicate_target_identities, insert_sign_target, + temporary_output_path, + }; + use crate::cli::{Cli, Command}; + use clap::Parser; use std::collections::HashSet; #[test] @@ -1553,4 +1575,29 @@ mod tests { temporary_output_path(&second) ); } + + #[test] + fn preserves_repeated_direct_targets_and_runs_them_sequentially() { + let directory = tempfile::tempdir().expect("temporary directory"); + let target = directory.path().join("target.exe"); + std::fs::write(&target, "test").expect("write target"); + let cli = Cli::try_parse_from([ + "psign-tool", + "sign", + "--sha1", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "--digest", + "sha256", + target.to_str().expect("UTF-8 target"), + target.to_str().expect("UTF-8 target"), + ]) + .expect("parse repeated targets"); + let Command::Sign(args) = cli.command else { + panic!("expected sign command"); + }; + + let targets = expand_sign_targets(&args).expect("expand targets"); + assert_eq!(targets, vec![target.clone(), target]); + assert!(has_duplicate_target_identities(&targets)); + } } diff --git a/tests/cert_store_cli.rs b/tests/cert_store_cli.rs index 7782ba8..e51f21f 100644 --- a/tests/cert_store_cli.rs +++ b/tests/cert_store_cli.rs @@ -499,15 +499,38 @@ fn portable_sign_sha1_replaces_existing_signature_by_default_and_appends_with_fl .success(); let replaced = std::fs::read(&replaced).expect("read replaced PE"); - let appended = std::fs::read(&appended).expect("read appended PE"); + let appended_bytes = std::fs::read(&appended).expect("read appended PE"); assert_eq!( verify_pe::pe_pkcs7_signed_data_entry_count(&replaced).expect("replaced PE entry count"), 1 ); assert_eq!( - verify_pe::pe_pkcs7_signed_data_entry_count(&appended).expect("appended PE entry count"), + verify_pe::pe_pkcs7_signed_data_entry_count(&appended_bytes) + .expect("appended PE entry count"), 2 ); + + psign_tool() + .args(["--mode", "portable", "sign", "--cert-store-dir"]) + .arg(&store_dir) + .args([ + "--sha1", + &thumbprint, + "--fd", + "SHA256", + "--append-signature", + ]) + .arg(&appended) + .arg(&appended) + .assert() + .success(); + + let appended_twice = std::fs::read(&appended).expect("read twice-appended PE"); + assert_eq!( + verify_pe::pe_pkcs7_signed_data_entry_count(&appended_twice) + .expect("twice-appended PE entry count"), + 4 + ); } #[test] From f8dbb51affc76da578b3e2f0df0d7664518359bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 14 Aug 2026 11:43:04 -0400 Subject: [PATCH 4/4] Bump version to 0.6.3 --- .github/workflows/release.yml | 2 +- Cargo.lock | 18 +++++++++--------- Cargo.toml | 2 +- .../Devolutions.Psign/Devolutions.Psign.psd1 | 2 +- README.md | 2 +- crates/psign-authenticode-trust/Cargo.toml | 2 +- crates/psign-azure-kv-rest/Cargo.toml | 2 +- crates/psign-codesigning-rest/Cargo.toml | 2 +- crates/psign-digest-cli/Cargo.toml | 2 +- crates/psign-opc-sign/Cargo.toml | 2 +- crates/psign-portable-core/Cargo.toml | 2 +- crates/psign-portable-ffi/Cargo.toml | 2 +- crates/psign-sip-digest/Cargo.toml | 2 +- nuget/tool/Devolutions.Psign.Tool.csproj | 2 +- 14 files changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e12f52c..4dc4c1c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: version: - description: Release version to build/publish (for example 0.6.2) + description: Release version to build/publish (for example 0.6.3) required: true type: string publish_nuget: diff --git a/Cargo.lock b/Cargo.lock index 39eac35..7fce58c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2010,7 +2010,7 @@ dependencies = [ [[package]] name = "psign" -version = "0.6.2" +version = "0.6.3" dependencies = [ "anyhow", "assert_cmd", @@ -2047,7 +2047,7 @@ dependencies = [ [[package]] name = "psign-authenticode-trust" -version = "0.6.2" +version = "0.6.3" dependencies = [ "anyhow", "authenticode", @@ -2072,7 +2072,7 @@ dependencies = [ [[package]] name = "psign-azure-kv-rest" -version = "0.6.2" +version = "0.6.3" dependencies = [ "anyhow", "base64", @@ -2087,7 +2087,7 @@ dependencies = [ [[package]] name = "psign-codesigning-rest" -version = "0.6.2" +version = "0.6.3" dependencies = [ "anyhow", "base64", @@ -2099,7 +2099,7 @@ dependencies = [ [[package]] name = "psign-digest-cli" -version = "0.6.2" +version = "0.6.3" dependencies = [ "anyhow", "assert_cmd", @@ -2125,7 +2125,7 @@ dependencies = [ [[package]] name = "psign-opc-sign" -version = "0.6.2" +version = "0.6.3" dependencies = [ "anyhow", "base64", @@ -2136,7 +2136,7 @@ dependencies = [ [[package]] name = "psign-portable-core" -version = "0.6.2" +version = "0.6.3" dependencies = [ "anyhow", "authenticode", @@ -2160,7 +2160,7 @@ dependencies = [ [[package]] name = "psign-portable-ffi" -version = "0.6.2" +version = "0.6.3" dependencies = [ "anyhow", "psign-portable-core", @@ -2170,7 +2170,7 @@ dependencies = [ [[package]] name = "psign-sip-digest" -version = "0.6.2" +version = "0.6.3" dependencies = [ "anyhow", "authenticode", diff --git a/Cargo.toml b/Cargo.toml index c2ad9c6..546a5ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ repository = "https://github.com/Devolutions/psign" [package] name = "psign" -version = "0.6.2" +version = "0.6.3" edition = "2024" description = "Rust port of the Windows SDK signtool.exe (Authenticode sign/verify/timestamp) with portable digest helpers." license.workspace = true diff --git a/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 b/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 index 0280a8d..60ffce0 100644 --- a/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 +++ b/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'Devolutions.Psign.psm1' - ModuleVersion = '0.6.2' + ModuleVersion = '0.6.3' GUID = 'e6e50e4b-bf25-4ed6-a343-49f904e79f8f' Author = 'Devolutions' CompanyName = 'Devolutions' diff --git a/README.md b/README.md index aef1ee3..9aab0be 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ dotnet tool run psign-tool -- --help Create local dotnet tool packages from prebuilt release artifacts: ```powershell -pwsh ./nuget/pack-psign-dotnet-tool.ps1 -Version 0.6.2 -ArtifactsRoot ./dist -OutputDir ./dist/nuget +pwsh ./nuget/pack-psign-dotnet-tool.ps1 -Version 0.6.3 -ArtifactsRoot ./dist -OutputDir ./dist/nuget ``` The package is built from native `psign-tool` artifacts for `win-x64`, `win-arm64`, `linux-x64`, `linux-arm64`, `osx-x64`, and `osx-arm64`, plus an `any` fallback package for unsupported runtimes. diff --git a/crates/psign-authenticode-trust/Cargo.toml b/crates/psign-authenticode-trust/Cargo.toml index d30ec95..0bffffd 100644 --- a/crates/psign-authenticode-trust/Cargo.toml +++ b/crates/psign-authenticode-trust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-authenticode-trust" -version = "0.6.2" +version = "0.6.3" edition = "2024" description = "Portable Authenticode PKCS#7 trust verification (anchors, chain, EKU) using picky-rs" license.workspace = true diff --git a/crates/psign-azure-kv-rest/Cargo.toml b/crates/psign-azure-kv-rest/Cargo.toml index fe8ff6b..cec6863 100644 --- a/crates/psign-azure-kv-rest/Cargo.toml +++ b/crates/psign-azure-kv-rest/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-azure-kv-rest" -version = "0.6.2" +version = "0.6.3" edition = "2024" description = "Azure Key Vault certificate metadata + keys/sign REST (portable, blocking HTTP)" license.workspace = true diff --git a/crates/psign-codesigning-rest/Cargo.toml b/crates/psign-codesigning-rest/Cargo.toml index 90e0f61..8314e6a 100644 --- a/crates/psign-codesigning-rest/Cargo.toml +++ b/crates/psign-codesigning-rest/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-codesigning-rest" -version = "0.6.2" +version = "0.6.3" edition = "2024" description = "Azure Code Signing data-plane CertificateProfileOperations Sign LRO (portable, blocking HTTP)" license.workspace = true diff --git a/crates/psign-digest-cli/Cargo.toml b/crates/psign-digest-cli/Cargo.toml index bc45784..60de41c 100644 --- a/crates/psign-digest-cli/Cargo.toml +++ b/crates/psign-digest-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-digest-cli" -version = "0.6.2" +version = "0.6.3" edition = "2024" description = "Linux/macOS-friendly CLI over portable Authenticode SIP digests (psign-sip-digest)" license.workspace = true diff --git a/crates/psign-opc-sign/Cargo.toml b/crates/psign-opc-sign/Cargo.toml index b61f276..c8115f7 100644 --- a/crates/psign-opc-sign/Cargo.toml +++ b/crates/psign-opc-sign/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-opc-sign" -version = "0.6.2" +version = "0.6.3" edition = "2024" description = "Portable OPC, VSIX, and NuGet package signing primitives" license.workspace = true diff --git a/crates/psign-portable-core/Cargo.toml b/crates/psign-portable-core/Cargo.toml index c29a5ab..fd63bdf 100644 --- a/crates/psign-portable-core/Cargo.toml +++ b/crates/psign-portable-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-portable-core" -version = "0.6.2" +version = "0.6.3" edition = "2024" description = "Reusable portable Authenticode signing and inspection APIs for psign" license.workspace = true diff --git a/crates/psign-portable-ffi/Cargo.toml b/crates/psign-portable-ffi/Cargo.toml index 2609c6e..d00ce92 100644 --- a/crates/psign-portable-ffi/Cargo.toml +++ b/crates/psign-portable-ffi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-portable-ffi" -version = "0.6.2" +version = "0.6.3" edition = "2024" description = "C ABI shared library for psign portable Authenticode operations" license.workspace = true diff --git a/crates/psign-sip-digest/Cargo.toml b/crates/psign-sip-digest/Cargo.toml index d48e3dd..e81ea99 100644 --- a/crates/psign-sip-digest/Cargo.toml +++ b/crates/psign-sip-digest/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-sip-digest" -version = "0.6.2" +version = "0.6.3" edition = "2024" description = "Portable Authenticode SIP digest recomputation (PE, CAB, MSI, MSIX, scripts, …) without Win32" license.workspace = true diff --git a/nuget/tool/Devolutions.Psign.Tool.csproj b/nuget/tool/Devolutions.Psign.Tool.csproj index b4c9dc5..8bfed36 100644 --- a/nuget/tool/Devolutions.Psign.Tool.csproj +++ b/nuget/tool/Devolutions.Psign.Tool.csproj @@ -8,7 +8,7 @@ psign-tool Devolutions.Psign.Tool - 0.6.2 + 0.6.3 Devolutions RID-specific dotnet tool wrapper around prebuilt psign-tool native executables. README.md