diff --git a/PowerShell/README.md b/PowerShell/README.md index 065d02e..b55f13e 100644 --- a/PowerShell/README.md +++ b/PowerShell/README.md @@ -22,7 +22,7 @@ Import-Module ./PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 | `Protect-PsignModule` | Batch-sign all policy-checked files in a module | | `Unprotect-PsignSignature` | Strip script signature blocks and clear PE signatures | -`Set-PsignSignature -AppendSignature` appends PE Authenticode signatures; without it, PE signing replaces existing signatures. Signature inspection exposes decoded CMS details through the `SignedCms` property when PKCS#7 bytes are available. +`Set-PsignSignature -AppendSignature` appends PE Authenticode signatures; without it, PE signing replaces existing signatures. Use `-SkipSigned` to leave PE/WinMD files with an intact embedded Authenticode signature unchanged. Signature inspection exposes decoded CMS details through the `SignedCms` property when PKCS#7 bytes are available. ## Quick Start diff --git a/PowerShell/tests/PsignSignature.NativeFeatures.Tests.ps1 b/PowerShell/tests/PsignSignature.NativeFeatures.Tests.ps1 index 047f399..be14a05 100644 --- a/PowerShell/tests/PsignSignature.NativeFeatures.Tests.ps1 +++ b/PowerShell/tests/PsignSignature.NativeFeatures.Tests.ps1 @@ -88,4 +88,33 @@ Describe 'Psign native signature features' { $after.SignatureCount | Should -Be 1 $after.SignedCms | Should -Not -BeNullOrEmpty } + + It 'signs unsigned PE files through Set-PsignSignature -SkipSigned' { + $source = Join-Path $script:RepoRoot 'tests\fixtures\pe-authenticode-upstream\tiny32.efi' + $pfxPath = Join-Path $script:RepoRoot 'tests\fixtures\devolutions-authenticode\authenticode-test-cert.pfx' + $path = Join-Path $script:TempRoot 'tiny32.skip-signed-unsigned.efi' + Copy-Item -LiteralPath $source -Destination $path + + $signed = Set-PsignSignature -LiteralPath $path -PfxPath $pfxPath -Password (ConvertTo-SecureString 'CodeSign123!' -AsPlainText -Force) -SkipSigned + + $signed.Status | Should -Be ([System.Management.Automation.SignatureStatus]::Valid) + $after = Get-PsignSignature -LiteralPath $path -SkipTrust + $after.SignatureCount | Should -Be 1 + } + + It 'skips already signed PE files through Set-PsignSignature -SkipSigned' { + $source = Join-Path $script:RepoRoot 'tests\fixtures\pe-authenticode-upstream\tiny32.signed.efi' + $pfxPath = Join-Path $script:RepoRoot 'tests\fixtures\devolutions-authenticode\authenticode-test-cert.pfx' + $path = Join-Path $script:TempRoot 'tiny32.skip-signed-existing.efi' + Copy-Item -LiteralPath $source -Destination $path + + $before = [Convert]::ToBase64String([IO.File]::ReadAllBytes($path)) + $signed = Set-PsignSignature -LiteralPath $path -PfxPath $pfxPath -Password (ConvertTo-SecureString 'CodeSign123!' -AsPlainText -Force) -SkipSigned + $after = [Convert]::ToBase64String([IO.File]::ReadAllBytes($path)) + + $signed.Status | Should -Be ([System.Management.Automation.SignatureStatus]::Valid) + $after | Should -Be $before + $signature = Get-PsignSignature -LiteralPath $path -SkipTrust + $signature.SignatureCount | Should -Be 1 + } } diff --git a/README.md b/README.md index 3e92739..ba8c3a8 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ Set-PsignSignature -LiteralPath .\tool.exe -Sha1 $thumbprint -CertStoreDirectory Get-PsignSignature -LiteralPath .\tool.exe -TrustedCertificate $rootCertificate ``` -`Set-PsignSignature` and `Get-PsignSignature` avoid Win32 SIPs and support PE, CAB, MSI, ZIP Authenticode, MSIX/AppX, PowerShell scripts, whole PowerShell module directories (`.ps1`, `.psm1`, `.psd1`), content-mode signing, RFC3161 timestamping, chain embedding, portable cert-store thumbprint selection, and explicit-anchor trust verification. Their output remains portable-specific but exposes built-in-compatible `SignatureStatus` / `SignatureType` properties for migration from `Get-AuthenticodeSignature`. See [`docs/portable-powershell-module.md`](docs/portable-powershell-module.md) and [`docs/portable-core-ffi.md`](docs/portable-core-ffi.md). +`Set-PsignSignature` and `Get-PsignSignature` avoid Win32 SIPs and support PE, CAB, MSI, ZIP Authenticode, MSIX/AppX, PowerShell scripts, whole PowerShell module directories (`.ps1`, `.psm1`, `.psd1`), content-mode signing, RFC3161 timestamping, chain embedding, `-SkipSigned` for already-signed PE/WinMD files, portable cert-store thumbprint selection, and explicit-anchor trust verification. Their output remains portable-specific but exposes built-in-compatible `SignatureStatus` / `SignatureType` properties for migration from `Get-AuthenticodeSignature`. See [`docs/portable-powershell-module.md`](docs/portable-powershell-module.md) and [`docs/portable-core-ffi.md`](docs/portable-core-ffi.md). ## Portable certificate store diff --git a/crates/psign-portable-core/src/lib.rs b/crates/psign-portable-core/src/lib.rs index 427e69b..a9003af 100644 --- a/crates/psign-portable-core/src/lib.rs +++ b/crates/psign-portable-core/src/lib.rs @@ -23,6 +23,7 @@ use psign_authenticode_trust::{ use psign_sip_digest::pkcs7::AuthenticodeSigningDigest; use psign_sip_digest::verify_pe::{ pe_nth_pkcs7_signed_data_der, verify_pe_authenticode_digest_consistency, + verify_pe_authenticode_digest_consistency_if_signed, }; use psign_sip_digest::{ cab_digest, catalog_digest, msi_digest, msix_digest, pe_digest, pe_embed, pkcs7, ps_script, @@ -279,6 +280,8 @@ pub struct PortableSignRequest { #[serde(default)] pub append_signature: bool, #[serde(default)] + pub skip_signed: bool, + #[serde(default)] pub output_path: Option, #[serde(default)] pub hash_algorithm: PortableDigestAlgorithm, @@ -349,6 +352,7 @@ impl Default for PortableSignRequest { Self { path: PathBuf::new(), append_signature: false, + skip_signed: false, output_path: None, hash_algorithm: PortableDigestAlgorithm::default(), certificate_path: None, @@ -455,6 +459,10 @@ fn is_zero(value: &usize) -> bool { *value == 0 } +fn is_false(value: &bool) -> bool { + !*value +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PortableSignResponse { pub schema_version: u32, @@ -462,6 +470,8 @@ pub struct PortableSignResponse { pub output_path: PathBuf, pub format: PortableFileFormat, pub signature: PortableSignatureResponse, + #[serde(default, skip_serializing_if = "is_false")] + pub skipped: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -519,17 +529,44 @@ pub fn portable_sign(request: PortableSignRequest) -> Result sign_pe(&request, &output_path), - PortableFileFormat::Cab => sign_cab(&request, &output_path), - PortableFileFormat::Msi => sign_msi(&request, &output_path), - PortableFileFormat::Msix => sign_msix(&request, &output_path), - PortableFileFormat::NuGet => sign_nuget(&request, &output_path), - PortableFileFormat::Vsix => sign_vsix(&request, &output_path), - PortableFileFormat::ClickOnceManifest => sign_clickonce_manifest(&request, &output_path), - PortableFileFormat::AppInstaller => sign_appinstaller(&request, &output_path), - PortableFileFormat::Zip => sign_zip(&request, &output_path), - PortableFileFormat::PowerShellScript => sign_script(&request, &output_path), + let skipped = match format { + PortableFileFormat::Pe => sign_pe(&request, &output_path)?, + PortableFileFormat::Cab => { + sign_cab(&request, &output_path)?; + false + } + PortableFileFormat::Msi => { + sign_msi(&request, &output_path)?; + false + } + PortableFileFormat::Msix => { + sign_msix(&request, &output_path)?; + false + } + PortableFileFormat::NuGet => { + sign_nuget(&request, &output_path)?; + false + } + PortableFileFormat::Vsix => { + sign_vsix(&request, &output_path)?; + false + } + PortableFileFormat::ClickOnceManifest => { + sign_clickonce_manifest(&request, &output_path)?; + false + } + PortableFileFormat::AppInstaller => { + sign_appinstaller(&request, &output_path)?; + false + } + PortableFileFormat::Zip => { + sign_zip(&request, &output_path)?; + false + } + PortableFileFormat::PowerShellScript => { + sign_script(&request, &output_path)?; + false + } PortableFileFormat::WshScript => bail!("portable WSH script signing is not supported yet"), PortableFileFormat::Catalog => bail!( "portable catalog signing requires an explicit subject list and is not available through PortableSignRequest yet" @@ -540,7 +577,7 @@ pub fn portable_sign(request: PortableSignRequest) -> Result Result Result<()> { +fn sign_pe(request: &PortableSignRequest, output_path: &Path) -> Result { let mut pe = std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; + if request.skip_signed + && verify_pe_authenticode_digest_consistency_if_signed(&pe) + .with_context(|| { + format!( + "check existing PE/WinMD Authenticode signature for {}", + request.path.display() + ) + })? + .is_some() + { + if output_path != request.path.as_path() { + std::fs::copy(&request.path, output_path).with_context(|| { + format!( + "copy {} to {}", + request.path.display(), + output_path.display() + ) + })?; + } + return Ok(true); + } + if !request.append_signature { pe = pe_embed::pe_remove_authenticode_certificates(pe) .with_context(|| { @@ -1648,7 +1708,9 @@ fn sign_pe(request: &PortableSignRequest, output_path: &Path) -> Result<()> { .with_context(|| format!("timestamp {}", request.path.display()))?; let signed = pe_embed::pe_append_authenticode_pkcs7_certificate(pe, &pkcs7) .with_context(|| format!("embed Authenticode signature in {}", request.path.display()))?; - std::fs::write(output_path, signed).with_context(|| format!("write {}", output_path.display())) + std::fs::write(output_path, signed) + .with_context(|| format!("write {}", output_path.display()))?; + Ok(false) } fn clear_pe_signature( @@ -3600,6 +3662,149 @@ mod tests { let _ = std::fs::remove_dir_all(temp_dir); } + #[test] + fn pe_sign_skip_signed_signs_unsigned_and_skips_valid_pe() { + let temp_dir = std::env::temp_dir().join(format!( + "psign-portable-pe-skip-signed-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + + let fixture_dir = PathBuf::from("../../tests/fixtures/devolutions-authenticode"); + let unsigned_source = + PathBuf::from("../../tests/fixtures/pe-authenticode-upstream/tiny32.efi"); + let signed_source = + PathBuf::from("../../tests/fixtures/pe-authenticode-upstream/tiny32.signed.efi"); + let unsigned = temp_dir.join("tiny32.unsigned.efi"); + let already_signed = temp_dir.join("tiny32.already-signed.efi"); + std::fs::copy(&unsigned_source, &unsigned).expect("copy unsigned PE"); + std::fs::copy(&signed_source, &already_signed).expect("copy signed PE"); + + let signed_response = portable_sign(PortableSignRequest { + path: unsigned.clone(), + skip_signed: true, + pfx_path: Some(fixture_dir.join("authenticode-test-cert.pfx")), + pfx_password: Some("CodeSign123!".to_string()), + ..default_sign_request() + }) + .expect("sign unsigned PE with skip-signed"); + assert!(!signed_response.skipped); + assert_eq!(signed_response.signature.signature_count, 1); + + let before = std::fs::read(&already_signed).expect("read already signed PE before skip"); + let skipped_response = portable_sign(PortableSignRequest { + path: already_signed.clone(), + skip_signed: true, + pfx_path: Some(fixture_dir.join("authenticode-test-cert.pfx")), + pfx_password: Some("CodeSign123!".to_string()), + ..default_sign_request() + }) + .expect("skip already signed PE"); + let after = std::fs::read(&already_signed).expect("read already signed PE after skip"); + assert!(skipped_response.skipped); + assert_eq!(before, after); + assert_eq!(skipped_response.signature.signature_count, 1); + + let _ = std::fs::remove_dir_all(temp_dir); + } + + #[test] + fn pe_sign_skip_signed_copies_valid_pe_to_output_path() { + let temp_dir = std::env::temp_dir().join(format!( + "psign-portable-pe-skip-copy-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + + let fixture_dir = PathBuf::from("../../tests/fixtures/devolutions-authenticode"); + let signed_source = + PathBuf::from("../../tests/fixtures/pe-authenticode-upstream/tiny32.signed.efi"); + let output = temp_dir.join("tiny32.output.efi"); + + let skipped_response = portable_sign(PortableSignRequest { + path: signed_source.clone(), + output_path: Some(output.clone()), + skip_signed: true, + pfx_path: Some(fixture_dir.join("authenticode-test-cert.pfx")), + pfx_password: Some("CodeSign123!".to_string()), + ..default_sign_request() + }) + .expect("copy skipped PE to output path"); + + assert!(skipped_response.skipped); + assert_eq!( + std::fs::read(&signed_source).expect("read signed source"), + std::fs::read(&output).expect("read copied output") + ); + assert_eq!(skipped_response.signature.signature_count, 1); + + let _ = std::fs::remove_dir_all(temp_dir); + } + + #[test] + fn pe_sign_skip_signed_rejects_corrupt_existing_signature() { + let temp_dir = std::env::temp_dir().join(format!( + "psign-portable-pe-skip-corrupt-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + + let fixture_dir = PathBuf::from("../../tests/fixtures/devolutions-authenticode"); + let target = temp_dir.join("tiny32.corrupt-signed.efi"); + let mut corrupt = + std::fs::read("../../tests/fixtures/pe-authenticode-upstream/tiny32.signed.efi") + .expect("read signed PE"); + tamper_hashed_pe_byte(&mut corrupt); + verify_pe_authenticode_digest_consistency(&corrupt) + .expect_err("tampered PE should fail digest verification"); + std::fs::write(&target, &corrupt).expect("write corrupt signed PE"); + + let err = portable_sign(PortableSignRequest { + path: target.clone(), + skip_signed: true, + pfx_path: Some(fixture_dir.join("authenticode-test-cert.pfx")), + pfx_password: Some("CodeSign123!".to_string()), + ..default_sign_request() + }) + .expect_err("corrupt signed PE should not be skipped"); + let msg = format!("{err:?}"); + assert!( + msg.contains("check existing PE/WinMD Authenticode signature"), + "unexpected error: {msg}" + ); + assert!(msg.contains("mismatch"), "unexpected error: {msg}"); + assert_eq!( + std::fs::read(&target).expect("read corrupt PE after failed skip"), + corrupt + ); + + let _ = std::fs::remove_dir_all(temp_dir); + } + + fn tamper_hashed_pe_byte(bytes: &mut [u8]) { + let ranges = + pe_digest::pe_authenticode_digest_file_ranges(bytes).expect("PE digest file ranges"); + let offset = ranges + .into_iter() + .rev() + .find(|range| !range.is_empty()) + .expect("non-empty PE digest range") + .start; + bytes[offset] ^= 0x01; + } + #[test] fn rejects_mixed_azure_key_vault_and_local_material() { let request = PortableSignRequest { @@ -3669,6 +3874,7 @@ mod tests { PortableSignRequest { path: PathBuf::from("test.dll"), append_signature: false, + skip_signed: false, output_path: None, hash_algorithm: PortableDigestAlgorithm::Sha256, certificate_path: None, diff --git a/crates/psign-sip-digest/src/verify_pe.rs b/crates/psign-sip-digest/src/verify_pe.rs index 6ea7694..482ec10 100644 --- a/crates/psign-sip-digest/src/verify_pe.rs +++ b/crates/psign-sip-digest/src/verify_pe.rs @@ -12,6 +12,12 @@ pub struct PeDigestConsistencyResult { pub pkcs7_authenticode_entries: usize, } +enum PeDigestConsistencyStatus { + NoCertificateTable, + NoPkcs7Entries, + Signed(PeDigestConsistencyResult), +} + fn hex_lower(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } @@ -21,12 +27,41 @@ fn hex_lower(bytes: &[u8]) -> String { pub fn verify_pe_authenticode_digest_consistency( bytes: &[u8], ) -> Result { + match verify_pe_authenticode_digest_consistency_status(bytes)? { + PeDigestConsistencyStatus::Signed(result) => Ok(result), + PeDigestConsistencyStatus::NoCertificateTable => { + Err(anyhow!("PE has no certificate table")) + } + PeDigestConsistencyStatus::NoPkcs7Entries => Err(anyhow!( + "no PKCS#7 Authenticode entries found in certificate table" + )), + } +} + +/// Verify embedded PE Authenticode digest consistency when a PKCS#7 signature exists. +/// +/// Returns `Ok(None)` for unsigned PE images (no certificate table, or no PKCS#7 +/// Authenticode rows) and returns an error for malformed or digest-mismatched +/// existing signatures. +pub fn verify_pe_authenticode_digest_consistency_if_signed( + bytes: &[u8], +) -> Result> { + match verify_pe_authenticode_digest_consistency_status(bytes)? { + PeDigestConsistencyStatus::Signed(result) => Ok(Some(result)), + PeDigestConsistencyStatus::NoCertificateTable + | PeDigestConsistencyStatus::NoPkcs7Entries => Ok(None), + } +} + +fn verify_pe_authenticode_digest_consistency_status( + bytes: &[u8], +) -> Result { let parsed = ParsedPe::parse(bytes)?; let pe = parsed.as_pe_trait(); let Some(iter) = AttributeCertificateIterator::new(pe) .map_err(|e| anyhow!("certificate table invalid: {e}"))? else { - return Err(anyhow!("PE has no certificate table")); + return Ok(PeDigestConsistencyStatus::NoCertificateTable); }; let mut pkcs7_count = 0usize; @@ -57,16 +92,16 @@ pub fn verify_pe_authenticode_digest_consistency( } if pkcs7_count == 0 { - return Err(anyhow!( - "no PKCS#7 Authenticode entries found in certificate table" - )); + return Ok(PeDigestConsistencyStatus::NoPkcs7Entries); } - Ok(PeDigestConsistencyResult { - recomputed_digest_hex: last_hex, - matched_attribute_certificate_index: last_idx, - pkcs7_authenticode_entries: pkcs7_count, - }) + Ok(PeDigestConsistencyStatus::Signed( + PeDigestConsistencyResult { + recomputed_digest_hex: last_hex, + matched_attribute_certificate_index: last_idx, + pkcs7_authenticode_entries: pkcs7_count, + }, + )) } /// Invoke `f(index, pkcs7_der)` for each `WIN_CERT_TYPE_PKCS_SIGNED_DATA` attribute certificate. diff --git a/docs/linux-signing-pipelines.md b/docs/linux-signing-pipelines.md index 82c8533..14a8d87 100644 --- a/docs/linux-signing-pipelines.md +++ b/docs/linux-signing-pipelines.md @@ -135,7 +135,7 @@ psign-tool --mode portable sign \ --max-degree-of-parallelism 4 ``` -The file list accepts one path or glob per line; blank lines and `#` comments are ignored. Skip detection currently covers PE/WinMD certificate tables, CAB signatures, MSI/MSP `DigitalSignature` streams, and flat MSIX/AppX `AppxSignature.p7x` packages. +The file list accepts one path or glob per line; blank lines and `#` comments are ignored. Skip detection verifies PE/WinMD Authenticode digests before skipping, and also covers CAB signatures, MSI/MSP `DigitalSignature` streams, and flat MSIX/AppX `AppxSignature.p7x` packages. ## 1.4 Package-native helper workflows diff --git a/docs/migration-artifact-signing.md b/docs/migration-artifact-signing.md index f3e5241..5d0a4fa 100644 --- a/docs/migration-artifact-signing.md +++ b/docs/migration-artifact-signing.md @@ -110,7 +110,7 @@ psign-tool --mode portable sign \ --max-degree-of-parallelism 4 ``` -`--input-file-list` accepts one path or glob per line; blank lines and `#` comments are ignored. `--skip-signed` skips PE/WinMD, CAB, MSI/MSP, and flat MSIX/AppX files that already contain embedded signature material. `--continue-on-error` preserves per-file failure diagnostics and returns a non-zero batch exit code when any target fails. +`--input-file-list` accepts one path or glob per line; blank lines and `#` comments are ignored. `--skip-signed` skips PE/WinMD files only when existing Authenticode digest verification succeeds, and also skips CAB, MSI/MSP, and flat MSIX/AppX files that already contain embedded signature material. `--continue-on-error` preserves per-file failure diagnostics and returns a non-zero batch exit code when any target fails. ## Flag mapping (Microsoft sample → psign-tool) diff --git a/docs/migration-azuresigntool.md b/docs/migration-azuresigntool.md index ec38a52..de0b13f 100644 --- a/docs/migration-azuresigntool.md +++ b/docs/migration-azuresigntool.md @@ -44,7 +44,7 @@ psign-tool.exe sign ^ | `-coe` | `--continue-on-error` | | `-mdop` | `--max-degree-of-parallelism` | -**`-s` (skip signed)** in AzureSignTool conflicts with native **`/s` (certificate store name)** in this tool. Use **`--skip-signed`** instead. +**`-s` (skip signed)** in AzureSignTool conflicts with native **`/s` (certificate store name)** in this tool. Use **`--skip-signed`** instead. In `--mode portable sign`, PE/WinMD targets are skipped only when the embedded Authenticode digest verifies; unsigned files still sign normally, and corrupt existing signatures fail. ### Authentication notes diff --git a/docs/portable-core-ffi.md b/docs/portable-core-ffi.md index 7d37880..a35bffd 100644 --- a/docs/portable-core-ffi.md +++ b/docs/portable-core-ffi.md @@ -12,7 +12,7 @@ The ABI is intentionally small and JSON-based: All request and response JSON uses UTF-8. Callers pass borrowed input buffers; Rust returns an owned `PsignFfiBuffer { ptr, len, cap }`. Managed callers must copy the bytes immediately and call `psign_core_free` exactly once with the returned buffer. -The first schema version supports portable digest/signature inspection and local RSA signing for PE, CAB, MSI, ZIP Authenticode, MSIX/AppX packages, and PowerShell script inputs. `Set-PsignSignature` accepts certificate/key paths, exportable `X509Certificate2` values, exportable PFX files, chain certificates, RFC3161 timestamp settings, and portable cert-store material resolved by managed callers. `Set-PsignSignature` and `Get-PsignSignature` also accept PowerShell module directories and expand them to signable `.ps1`, `.psm1`, and `.psd1` files. +The first schema version supports portable digest/signature inspection and local RSA signing for PE, CAB, MSI, ZIP Authenticode, MSIX/AppX packages, and PowerShell script inputs. `Set-PsignSignature` accepts certificate/key paths, exportable `X509Certificate2` values, exportable PFX files, chain certificates, RFC3161 timestamp settings, `-SkipSigned` for intact PE/WinMD signatures, and portable cert-store material resolved by managed callers. `Set-PsignSignature` and `Get-PsignSignature` also accept PowerShell module directories and expand them to signable `.ps1`, `.psm1`, and `.psd1` files. `psign_core_get_signature` also accepts explicit trust material in the JSON request: trusted certificate paths, DER-encoded trusted certificates, anchor directory, AuthRoot CAB, `as_of`, timestamp-time policy booleans, online AIA/OCSP toggles, and revocation mode. The portable core never falls back to OS trust; when trust is requested, the response includes `trust_status` in addition to the digest/signature `status`. diff --git a/docs/portable-powershell-module.md b/docs/portable-powershell-module.md index 616ea90..3edd910 100644 --- a/docs/portable-powershell-module.md +++ b/docs/portable-powershell-module.md @@ -31,7 +31,7 @@ The P/Invoke ABI also accepts in-memory DER certificate and PKCS#8 private-key m The portable cert store follows the same layout as `psign-tool cert-store`: `\\\.der` plus `.key`, where scope is `CurrentUser` or `LocalMachine` and the private key is unencrypted PKCS#8 PEM. `-Thumbprint` has `-Sha1` and `-PortableStoreThumbprint` aliases. If `-CertStoreDirectory` is omitted, the module uses `PSIGN_CERT_STORE` and then `~\.psign\cert-store`. -`Set-PsignSignature` supports `-IncludeChain Signer|NotRoot|All` (default `NotRoot`), optional `-ChainCertificatePath`, `-TimestampServer`, and `-TimestampHashAlgorithm Sha1|Sha256|Sha384|Sha512`. +`Set-PsignSignature` supports `-IncludeChain Signer|NotRoot|All` (default `NotRoot`), optional `-ChainCertificatePath`, `-TimestampServer`, and `-TimestampHashAlgorithm Sha1|Sha256|Sha384|Sha512`. `-SkipSigned` leaves PE/WinMD files with an intact embedded Authenticode signature unchanged, while unsigned files still sign normally and corrupt existing signatures fail instead of being silently skipped. Cloud signing is available through Azure Key Vault parameters (`-AzureKeyVaultUrl`, `-AzureKeyVaultCertificate`, and access token, managed identity, or client credentials) or Azure Artifact Signing / Trusted Signing parameters (`-ArtifactSigningEndpoint`, account/profile, and access token, managed identity, or client credentials). The portable module native library is built with both cloud-signing feature sets. diff --git a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPsignSignatureCommand.cs b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPsignSignatureCommand.cs index e7b7bce..af5ca5f 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPsignSignatureCommand.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPsignSignatureCommand.cs @@ -91,6 +91,9 @@ public sealed class SetPsignSignatureCommand : PSCmdlet [Parameter] public SwitchParameter AppendSignature { get; set; } + [Parameter(HelpMessage = "Skip PE/WinMD files that already contain a valid Authenticode signature.")] + public SwitchParameter SkipSigned { get; set; } + // Azure Key Vault parameters [Parameter] public string? AzureKeyVaultUrl { get; set; } @@ -214,6 +217,7 @@ private void SignContent(string sourcePathOrExtension) { Path = tempPath, AppendSignature = AppendSignature.IsPresent, + SkipSigned = SkipSigned.IsPresent, HashAlgorithm = HashAlgorithm, CertificatePath = CertificatePath is null ? null @@ -247,6 +251,10 @@ private void SignContent(string sourcePathOrExtension) ArtifactSigningClientId = ArtifactSigningClientId, ArtifactSigningClientSecret = ArtifactSigningClientSecret, }); + if (response.Skipped) + { + WriteVerbose($"Skipped already signed content '{sourcePathOrExtension}'."); + } response.Signature.SourcePathOrExtension = sourcePathOrExtension; response.Signature.Content = File.ReadAllBytes(tempPath); WriteObject(response.Signature); @@ -290,6 +298,7 @@ private void SignPath(string path) { Path = path, AppendSignature = AppendSignature.IsPresent, + SkipSigned = SkipSigned.IsPresent, OutputPath = outputPath, HashAlgorithm = HashAlgorithm, CertificatePath = CertificatePath is null @@ -324,6 +333,10 @@ private void SignPath(string path) ArtifactSigningClientId = ArtifactSigningClientId, ArtifactSigningClientSecret = ArtifactSigningClientSecret, }); + if (response.Skipped) + { + WriteVerbose($"Skipped already signed file '{path}'."); + } WriteObject(response.Signature); } finally diff --git a/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs b/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs index 54ce39b..1d11ded 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs @@ -46,6 +46,9 @@ internal sealed class PortableSignRequest [JsonPropertyName("append_signature")] public bool AppendSignature { get; init; } + [JsonPropertyName("skip_signed")] + public bool SkipSigned { get; init; } + [JsonPropertyName("output_path")] public string? OutputPath { get; init; } diff --git a/dotnet/Devolutions.Psign.PowerShell/Models/PortableSignResponse.cs b/dotnet/Devolutions.Psign.PowerShell/Models/PortableSignResponse.cs index be1cb18..1491de3 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Models/PortableSignResponse.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Models/PortableSignResponse.cs @@ -18,4 +18,7 @@ internal sealed class PortableSignResponse [JsonPropertyName("signature")] public PortableSignature Signature { get; init; } = new(); + + [JsonPropertyName("skipped")] + public bool Skipped { get; init; } } diff --git a/src/cli.rs b/src/cli.rs index 38467a8..9fab1bf 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -938,7 +938,7 @@ pub struct SignArgs { /// Continue signing remaining files when one fails (`-coe`). #[arg(long = "continue-on-error", visible_alias = "coe")] pub continue_on_error: bool, - /// Skip files that already appear signed (PE certificate directory); AzureSignTool `-s` — native `/s` remains the certificate store name short flag. + /// Skip files that already have a valid embedded signature; AzureSignTool `-s` — native `/s` remains the certificate store name short flag. #[arg(long = "skip-signed")] pub skip_signed: bool, /// Cap concurrent signing threads for multi-file batches (`-mdop`). `1` forces sequential signing. diff --git a/src/portable_sign.rs b/src/portable_sign.rs index 96c02e2..8c6f177 100644 --- a/src/portable_sign.rs +++ b/src/portable_sign.rs @@ -25,6 +25,11 @@ 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!( @@ -57,17 +62,24 @@ pub fn sign_file(args: &SignArgs, _global: &GlobalOpts) -> Result if idx > 0 { combined.push('\n'); } - let signed = sign_one_target(target, &identity, args.append_signature) + let result = sign_one_target(target, &identity, args.append_signature, args.skip_signed) .with_context(|| format!("portable sign '{}'", target.display()))?; - 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 - )); + 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))) } @@ -163,6 +175,10 @@ fn sign_file_azure_key_vault(args: &SignArgs) -> Result { 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; + } sign_one_target_azure_key_vault(target, args) .with_context(|| format!("portable Azure Key Vault sign '{}'", target.display()))?; combined.push_str(&format!( @@ -325,7 +341,7 @@ fn expand_sign_targets(args: &SignArgs) -> Result> { } fn try_sign_one_artifact_signing(target: &Path, args: &SignArgs) -> Result { - if args.skip_signed && target_appears_signed(target) { + if args.skip_signed && target_should_skip_signed(target)? { return Ok(format!("Skipped (already signed): {}\n", target.display())); } sign_one_target_artifact_signing(target, args) @@ -339,28 +355,24 @@ fn try_sign_one_artifact_signing(target: &Path, args: &SignArgs) -> Result bool { - let ext = target - .extension() - .and_then(|e| e.to_str()) - .map(str::to_ascii_lowercase) - .unwrap_or_default(); - let Ok(bytes) = std::fs::read(target) else { - return false; - }; +fn target_should_skip_signed(target: &Path) -> Result { + let ext = target_extension_lower(target); match ext.as_str() { - "exe" | "dll" | "sys" | "ocx" | "efi" | "winmd" => { - psign_sip_digest::verify_pe::pe_pkcs7_signed_data_entry_count(&bytes) - .is_ok_and(|count| count > 0) + ext if is_pe_winmd_extension(ext) => target_has_valid_existing_pe_signature(target), + "cab" => { + let bytes = + std::fs::read(target).with_context(|| format!("read '{}'", target.display()))?; + Ok(psign_sip_digest::cab_digest::cab_signature_pkcs7_der(&bytes).is_ok()) } - "cab" => psign_sip_digest::cab_digest::cab_signature_pkcs7_der(&bytes).is_ok(), "msi" | "msp" => { - psign_sip_digest::msi_digest::msi_digital_signature_pkcs7_der(&bytes).is_ok() + let bytes = + std::fs::read(target).with_context(|| format!("read '{}'", target.display()))?; + Ok(psign_sip_digest::msi_digest::msi_digital_signature_pkcs7_der(&bytes).is_ok()) } "appx" | "msix" => { - psign_sip_digest::msix_digest::verify_msix_digest_consistency(target).is_ok() + Ok(psign_sip_digest::msix_digest::verify_msix_digest_consistency(target).is_ok()) } - _ => false, + _ => Ok(false), } } @@ -445,7 +457,6 @@ fn validate_azure_key_vault_supported_options(args: &SignArgs) -> Result<()> { reject_artifact_signing_options(args)?; reject_path_option("--input-file-list", &args.sign_input_file_list)?; reject_bool_option("--continue-on-error", args.continue_on_error)?; - reject_bool_option("--skip-signed", args.skip_signed)?; reject_option( "--max-degree-of-parallelism", args.max_degree_parallelism.is_some(), @@ -543,16 +554,10 @@ fn sign_one_target( target: &Path, identity: &crate::cert_store::SigningIdentity, append_signature: bool, -) -> Result> { - let ext = target - .extension() - .and_then(|e| e.to_str()) - .map(str::to_ascii_lowercase) - .unwrap_or_default(); - if !matches!( - ext.as_str(), - "exe" | "dll" | "sys" | "ocx" | "efi" | "winmd" - ) { + skip_signed: bool, +) -> Result { + let ext = target_extension_lower(target); + if !is_pe_winmd_extension(&ext) { return Err(anyhow!( "portable thumbprint signing is currently implemented only for PE/WinMD targets; got {}", target.display() @@ -560,6 +565,18 @@ fn sign_one_target( } 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); + } if !append_signature { bytes = psign_sip_digest::pe_embed::pe_remove_authenticode_certificates(bytes) .with_context(|| { @@ -575,18 +592,12 @@ fn sign_one_target( &identity.cert_der, &identity.key_pem, ) + .map(SignOneTargetResult::Signed) } fn sign_one_target_azure_key_vault(target: &Path, args: &SignArgs) -> Result<()> { - let ext = target - .extension() - .and_then(|e| e.to_str()) - .map(str::to_ascii_lowercase) - .unwrap_or_default(); - if !matches!( - ext.as_str(), - "exe" | "dll" | "sys" | "ocx" | "efi" | "winmd" - ) { + let ext = target_extension_lower(target); + if !is_pe_winmd_extension(&ext) { return Err(anyhow!( "portable Azure Key Vault signing is currently implemented only for PE/WinMD targets; got {}", target.display() @@ -611,16 +622,10 @@ fn sign_one_target_azure_key_vault(target: &Path, args: &SignArgs) -> Result<()> } fn sign_one_target_artifact_signing(target: &Path, args: &SignArgs) -> Result<()> { - let ext = target - .extension() - .and_then(|e| e.to_str()) - .map(str::to_ascii_lowercase) - .unwrap_or_default(); + let ext = target_extension_lower(target); let tmp = temporary_output_path(target); let result = match ext.as_str() { - "exe" | "dll" | "sys" | "ocx" | "efi" | "winmd" => { - run_portable_sign_pe_artifact_signing(target, &tmp, args) - } + ext if is_pe_winmd_extension(ext) => run_portable_sign_pe_artifact_signing(target, &tmp, args), _ if args.append_signature => Err(anyhow!( "--as/--append-signature is only supported for portable PE/WinMD signing" )), @@ -650,6 +655,36 @@ fn sign_one_target_artifact_signing(target: &Path, args: &SignArgs) -> Result<() result } +fn target_extension_lower(target: &Path) -> String { + target + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default() +} + +fn is_pe_winmd_extension(ext: &str) -> bool { + matches!(ext, "exe" | "dll" | "sys" | "ocx" | "efi" | "winmd") +} + +fn target_has_valid_existing_pe_signature(target: &Path) -> Result { + let ext = target_extension_lower(target); + if !is_pe_winmd_extension(&ext) { + return Ok(false); + } + let bytes = std::fs::read(target).with_context(|| format!("read '{}'", target.display()))?; + Ok( + 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(), + ) +} + fn run_portable_sign_pe_azure_key_vault( target: &Path, output: &Path, @@ -1343,7 +1378,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), Azure Key Vault PE/WinMD signing (--azure-key-vault-*, --fd SHA256/SHA384/SHA512), and Azure Artifact Signing PE/WinMD signing (--artifact-signing-* or --dmdf)" + "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)" )); } Ok(()) diff --git a/tests/cert_store_cli.rs b/tests/cert_store_cli.rs index f8d63c0..e7fc256 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::{pkcs7, verify_pe}; +use psign_sip_digest::{pe_digest, pkcs7, verify_pe}; use rand::rngs::OsRng; use rsa::RsaPrivateKey; use rsa::pkcs1v15::SigningKey; @@ -510,6 +510,109 @@ fn portable_sign_sha1_replaces_existing_signature_by_default_and_appends_with_fl ); } +#[test] +fn portable_sign_sha1_skip_signed_signs_unsigned_and_skips_valid_pe() { + 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 unsigned = temp.path().join("tiny32.unsigned.efi"); + let already_signed = temp.path().join("tiny32.already-signed.efi"); + let fixture = test_cert("psign portable skip signed test"); + let thumbprint = sha1_upper(&fixture.der); + std::fs::write(&cert_path, &fixture.der).expect("write cert"); + std::fs::write(&key_path, &fixture.key_pem).expect("write key"); + std::fs::copy(tiny32_unsigned_fixture(), &unsigned).expect("copy unsigned PE"); + std::fs::copy(tiny32_signed_fixture(), &already_signed).expect("copy signed PE"); + + psign_tool() + .args(["cert-store", "import", "--cert-store-dir"]) + .arg(&store_dir) + .args(["--key"]) + .arg(&key_path) + .arg(&cert_path) + .assert() + .success(); + + psign_tool() + .args(["--mode", "portable", "sign", "--cert-store-dir"]) + .arg(&store_dir) + .args(["--sha1", &thumbprint, "--fd", "SHA256", "--skip-signed"]) + .arg(&unsigned) + .assert() + .success() + .stdout(predicate::str::contains("Signed:")); + let unsigned_after = std::fs::read(&unsigned).expect("read signed formerly unsigned PE"); + verify_pe::verify_pe_authenticode_digest_consistency(&unsigned_after) + .expect("PE digest consistency"); + assert_eq!( + verify_pe::pe_pkcs7_signed_data_entry_count(&unsigned_after) + .expect("signed PE entry count"), + 1 + ); + + let before = std::fs::read(&already_signed).expect("read already signed PE before skip"); + psign_tool() + .args(["--mode", "portable", "sign", "--cert-store-dir"]) + .arg(&store_dir) + .args(["--sha1", &thumbprint, "--fd", "SHA256", "--skip-signed"]) + .arg(&already_signed) + .assert() + .success() + .stdout(predicate::str::contains("Skipped (already signed):")); + let after = std::fs::read(&already_signed).expect("read already signed PE after skip"); + assert_eq!(before, after); + assert_eq!( + verify_pe::pe_pkcs7_signed_data_entry_count(&after).expect("skipped PE entry count"), + 1 + ); +} + +#[test] +fn portable_sign_sha1_skip_signed_rejects_corrupt_existing_pe_signature() { + 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 target = temp.path().join("tiny32.corrupt-signed.efi"); + let fixture = test_cert("psign portable skip corrupt test"); + let thumbprint = sha1_upper(&fixture.der); + std::fs::write(&cert_path, &fixture.der).expect("write cert"); + std::fs::write(&key_path, &fixture.key_pem).expect("write key"); + + let mut corrupt = std::fs::read(tiny32_signed_fixture()).expect("read signed PE fixture"); + tamper_hashed_pe_byte(&mut corrupt); + verify_pe::verify_pe_authenticode_digest_consistency(&corrupt) + .expect_err("tampered PE should fail digest verification"); + std::fs::write(&target, &corrupt).expect("write corrupt signed PE"); + + psign_tool() + .args(["cert-store", "import", "--cert-store-dir"]) + .arg(&store_dir) + .args(["--key"]) + .arg(&key_path) + .arg(&cert_path) + .assert() + .success(); + + psign_tool() + .args(["--mode", "portable", "sign", "--cert-store-dir"]) + .arg(&store_dir) + .args(["--sha1", &thumbprint, "--fd", "SHA256", "--skip-signed"]) + .arg(&target) + .assert() + .failure() + .stderr(predicate::str::contains( + "check existing PE/WinMD Authenticode signature", + )) + .stderr(predicate::str::contains("mismatch")); + + assert_eq!( + std::fs::read(&target).expect("read corrupt PE after failed skip-signed"), + corrupt + ); +} + #[test] fn portable_sign_sha1_fails_when_private_key_missing() { let temp = tempfile::tempdir().expect("tempdir"); @@ -538,6 +641,18 @@ fn portable_sign_sha1_fails_when_private_key_missing() { .stderr(predicate::str::contains("private key")); } +fn tamper_hashed_pe_byte(bytes: &mut [u8]) { + let ranges = + pe_digest::pe_authenticode_digest_file_ranges(bytes).expect("PE digest file ranges"); + let offset = ranges + .into_iter() + .rev() + .find(|range| !range.is_empty()) + .expect("non-empty PE digest range") + .start; + bytes[offset] ^= 0x01; +} + #[test] fn portable_sign_sha1_rejects_unsupported_format() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/tests/cli_pe_digest.rs b/tests/cli_pe_digest.rs index a43f573..4d68b6a 100644 --- a/tests/cli_pe_digest.rs +++ b/tests/cli_pe_digest.rs @@ -4738,6 +4738,7 @@ fn mode_portable_sign_uses_azure_key_vault_for_pe() { .arg("sign") .arg("--digest") .arg("sha256") + .arg("--skip-signed") .arg("--azure-key-vault-url") .arg(&url) .arg("--azure-key-vault-certificate") @@ -4756,6 +4757,40 @@ fn mode_portable_sign_uses_azure_key_vault_for_pe() { verify.assert().success(); } +#[cfg(all(feature = "timestamp-server", feature = "azure-kv-sign"))] +#[test] +fn mode_portable_sign_azure_key_vault_skip_signed_skips_valid_pe_without_service() { + let dir = tempfile::tempdir().unwrap(); + let pe_path = dir.path().join("tiny32.kv-mode-portable-skipped.exe"); + std::fs::copy(tiny32_fixture(), &pe_path).expect("copy signed PE"); + let before = std::fs::read(&pe_path).expect("read signed PE before skip"); + + let mut cmd = Command::cargo_bin("psign-tool").unwrap(); + cmd.arg("--mode") + .arg("portable") + .arg("sign") + .arg("--digest") + .arg("sha256") + .arg("--skip-signed") + .arg("--azure-key-vault-url") + .arg("http://127.0.0.1:1") + .arg("--azure-key-vault-certificate") + .arg("test-cert") + .arg("--azure-key-vault-accesstoken") + .arg("test-token") + .arg(&pe_path); + cmd.assert() + .success() + .stdout(predicate::str::contains("Skipped (already signed):")); + + let after = std::fs::read(&pe_path).expect("read signed PE after skip"); + assert_eq!(before, after); + assert_eq!( + verify_pe::pe_pkcs7_signed_data_entry_count(&after).expect("skipped PE entry count"), + 1 + ); +} + #[cfg(all( feature = "timestamp-server", feature = "timestamp-http", @@ -4775,6 +4810,7 @@ fn mode_portable_sign_uses_azure_key_vault_and_rfc3161_timestamp_for_pe() { .arg("sign") .arg("--digest") .arg("sha256") + .arg("--skip-signed") .arg("--timestamp-url") .arg(×tamp_url) .arg("--timestamp-digest") @@ -5640,6 +5676,7 @@ fn mode_portable_sign_uses_artifact_signing_metadata_and_rfc3161_timestamp_for_p .arg("sign") .arg("--digest") .arg("sha256") + .arg("--skip-signed") .arg("--timestamp-url") .arg(×tamp_url) .arg("--timestamp-digest") @@ -5672,6 +5709,42 @@ fn mode_portable_sign_uses_artifact_signing_metadata_and_rfc3161_timestamp_for_p .stdout(predicate::str::contains("1.3.6.1.4.1.311.3.3.1")); } +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +#[test] +fn mode_portable_artifact_signing_skip_signed_skips_valid_pe_without_service() { + let dir = tempfile::tempdir().unwrap(); + let pe_path = dir.path().join("tiny32.artifact-mode-portable-skipped.exe"); + std::fs::copy(tiny32_fixture(), &pe_path).expect("copy signed PE"); + let before = std::fs::read(&pe_path).expect("read signed PE before skip"); + + let mut cmd = Command::cargo_bin("psign-tool").unwrap(); + cmd.arg("--mode") + .arg("portable") + .arg("sign") + .arg("--digest") + .arg("sha256") + .arg("--skip-signed") + .arg("--artifact-signing-account-name") + .arg("acct") + .arg("--artifact-signing-profile-name") + .arg("prof") + .arg("--artifact-signing-access-token") + .arg("test-token") + .arg("--artifact-signing-endpoint-base-url") + .arg("http://127.0.0.1:1") + .arg(&pe_path); + cmd.assert() + .success() + .stdout(predicate::str::contains("Skipped (already signed):")); + + let after = std::fs::read(&pe_path).expect("read signed PE after skip"); + assert_eq!(before, after); + assert_eq!( + verify_pe::pe_pkcs7_signed_data_entry_count(&after).expect("skipped PE entry count"), + 1 + ); +} + #[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] #[test] fn psign_server_artifact_signing_failed_status_fails_portable_cli() {