Skip to content

Commit 82548aa

Browse files
Add PowerShell skip-signed signing option
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 1fc216b commit 82548aa

9 files changed

Lines changed: 272 additions & 18 deletions

File tree

‎PowerShell/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Import-Module ./PowerShell/Devolutions.Psign/Devolutions.Psign.psd1
2222
| `Protect-PsignModule` | Batch-sign all policy-checked files in a module |
2323
| `Unprotect-PsignSignature` | Strip script signature blocks and clear PE signatures |
2424

25-
`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.
25+
`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.
2626

2727
## Quick Start
2828

‎PowerShell/tests/PsignSignature.NativeFeatures.Tests.ps1‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,33 @@ Describe 'Psign native signature features' {
8888
$after.SignatureCount | Should -Be 1
8989
$after.SignedCms | Should -Not -BeNullOrEmpty
9090
}
91+
92+
It 'signs unsigned PE files through Set-PsignSignature -SkipSigned' {
93+
$source = Join-Path $script:RepoRoot 'tests\fixtures\pe-authenticode-upstream\tiny32.efi'
94+
$pfxPath = Join-Path $script:RepoRoot 'tests\fixtures\devolutions-authenticode\authenticode-test-cert.pfx'
95+
$path = Join-Path $script:TempRoot 'tiny32.skip-signed-unsigned.efi'
96+
Copy-Item -LiteralPath $source -Destination $path
97+
98+
$signed = Set-PsignSignature -LiteralPath $path -PfxPath $pfxPath -Password (ConvertTo-SecureString 'CodeSign123!' -AsPlainText -Force) -SkipSigned
99+
100+
$signed.Status | Should -Be ([System.Management.Automation.SignatureStatus]::Valid)
101+
$after = Get-PsignSignature -LiteralPath $path -SkipTrust
102+
$after.SignatureCount | Should -Be 1
103+
}
104+
105+
It 'skips already signed PE files through Set-PsignSignature -SkipSigned' {
106+
$source = Join-Path $script:RepoRoot 'tests\fixtures\pe-authenticode-upstream\tiny32.signed.efi'
107+
$pfxPath = Join-Path $script:RepoRoot 'tests\fixtures\devolutions-authenticode\authenticode-test-cert.pfx'
108+
$path = Join-Path $script:TempRoot 'tiny32.skip-signed-existing.efi'
109+
Copy-Item -LiteralPath $source -Destination $path
110+
111+
$before = [Convert]::ToBase64String([IO.File]::ReadAllBytes($path))
112+
$signed = Set-PsignSignature -LiteralPath $path -PfxPath $pfxPath -Password (ConvertTo-SecureString 'CodeSign123!' -AsPlainText -Force) -SkipSigned
113+
$after = [Convert]::ToBase64String([IO.File]::ReadAllBytes($path))
114+
115+
$signed.Status | Should -Be ([System.Management.Automation.SignatureStatus]::Valid)
116+
$after | Should -Be $before
117+
$signature = Get-PsignSignature -LiteralPath $path -SkipTrust
118+
$signature.SignatureCount | Should -Be 1
119+
}
91120
}

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ Set-PsignSignature -LiteralPath .\tool.exe -Sha1 $thumbprint -CertStoreDirectory
177177
Get-PsignSignature -LiteralPath .\tool.exe -TrustedCertificate $rootCertificate
178178
```
179179

180-
`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).
180+
`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).
181181

182182
## Portable certificate store
183183

‎crates/psign-portable-core/src/lib.rs‎

Lines changed: 220 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use psign_authenticode_trust::{
2323
use psign_sip_digest::pkcs7::AuthenticodeSigningDigest;
2424
use psign_sip_digest::verify_pe::{
2525
pe_nth_pkcs7_signed_data_der, verify_pe_authenticode_digest_consistency,
26+
verify_pe_authenticode_digest_consistency_if_signed,
2627
};
2728
use psign_sip_digest::{
2829
cab_digest, catalog_digest, msi_digest, msix_digest, pe_digest, pe_embed, pkcs7, ps_script,
@@ -279,6 +280,8 @@ pub struct PortableSignRequest {
279280
#[serde(default)]
280281
pub append_signature: bool,
281282
#[serde(default)]
283+
pub skip_signed: bool,
284+
#[serde(default)]
282285
pub output_path: Option<PathBuf>,
283286
#[serde(default)]
284287
pub hash_algorithm: PortableDigestAlgorithm,
@@ -349,6 +352,7 @@ impl Default for PortableSignRequest {
349352
Self {
350353
path: PathBuf::new(),
351354
append_signature: false,
355+
skip_signed: false,
352356
output_path: None,
353357
hash_algorithm: PortableDigestAlgorithm::default(),
354358
certificate_path: None,
@@ -455,13 +459,19 @@ fn is_zero(value: &usize) -> bool {
455459
*value == 0
456460
}
457461

462+
fn is_false(value: &bool) -> bool {
463+
!*value
464+
}
465+
458466
#[derive(Debug, Clone, Serialize, Deserialize)]
459467
pub struct PortableSignResponse {
460468
pub schema_version: u32,
461469
pub input_path: PathBuf,
462470
pub output_path: PathBuf,
463471
pub format: PortableFileFormat,
464472
pub signature: PortableSignatureResponse,
473+
#[serde(default, skip_serializing_if = "is_false")]
474+
pub skipped: bool,
465475
}
466476

467477
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -519,17 +529,44 @@ pub fn portable_sign(request: PortableSignRequest) -> Result<PortableSignRespons
519529
.clone()
520530
.unwrap_or_else(|| request.path.clone());
521531

522-
match format {
523-
PortableFileFormat::Pe => sign_pe(&request, &output_path),
524-
PortableFileFormat::Cab => sign_cab(&request, &output_path),
525-
PortableFileFormat::Msi => sign_msi(&request, &output_path),
526-
PortableFileFormat::Msix => sign_msix(&request, &output_path),
527-
PortableFileFormat::NuGet => sign_nuget(&request, &output_path),
528-
PortableFileFormat::Vsix => sign_vsix(&request, &output_path),
529-
PortableFileFormat::ClickOnceManifest => sign_clickonce_manifest(&request, &output_path),
530-
PortableFileFormat::AppInstaller => sign_appinstaller(&request, &output_path),
531-
PortableFileFormat::Zip => sign_zip(&request, &output_path),
532-
PortableFileFormat::PowerShellScript => sign_script(&request, &output_path),
532+
let skipped = match format {
533+
PortableFileFormat::Pe => sign_pe(&request, &output_path)?,
534+
PortableFileFormat::Cab => {
535+
sign_cab(&request, &output_path)?;
536+
false
537+
}
538+
PortableFileFormat::Msi => {
539+
sign_msi(&request, &output_path)?;
540+
false
541+
}
542+
PortableFileFormat::Msix => {
543+
sign_msix(&request, &output_path)?;
544+
false
545+
}
546+
PortableFileFormat::NuGet => {
547+
sign_nuget(&request, &output_path)?;
548+
false
549+
}
550+
PortableFileFormat::Vsix => {
551+
sign_vsix(&request, &output_path)?;
552+
false
553+
}
554+
PortableFileFormat::ClickOnceManifest => {
555+
sign_clickonce_manifest(&request, &output_path)?;
556+
false
557+
}
558+
PortableFileFormat::AppInstaller => {
559+
sign_appinstaller(&request, &output_path)?;
560+
false
561+
}
562+
PortableFileFormat::Zip => {
563+
sign_zip(&request, &output_path)?;
564+
false
565+
}
566+
PortableFileFormat::PowerShellScript => {
567+
sign_script(&request, &output_path)?;
568+
false
569+
}
533570
PortableFileFormat::WshScript => bail!("portable WSH script signing is not supported yet"),
534571
PortableFileFormat::Catalog => bail!(
535572
"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<PortableSignRespons
540577
request.path.display()
541578
)
542579
}
543-
}?;
580+
};
544581

545582
let inspect_request = PortableGetSignatureRequest::path_only(output_path.clone());
546583
let signature = portable_get_signature(inspect_request)?;
@@ -551,6 +588,7 @@ pub fn portable_sign(request: PortableSignRequest) -> Result<PortableSignRespons
551588
output_path,
552589
format,
553590
signature,
591+
skipped,
554592
})
555593
}
556594

@@ -1619,9 +1657,31 @@ fn authenticode_digest_from_vsix_algorithm(
16191657
}
16201658
}
16211659

1622-
fn sign_pe(request: &PortableSignRequest, output_path: &Path) -> Result<()> {
1660+
fn sign_pe(request: &PortableSignRequest, output_path: &Path) -> Result<bool> {
16231661
let mut pe =
16241662
std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?;
1663+
if request.skip_signed
1664+
&& verify_pe_authenticode_digest_consistency_if_signed(&pe)
1665+
.with_context(|| {
1666+
format!(
1667+
"check existing PE/WinMD Authenticode signature for {}",
1668+
request.path.display()
1669+
)
1670+
})?
1671+
.is_some()
1672+
{
1673+
if output_path != request.path.as_path() {
1674+
std::fs::copy(&request.path, output_path).with_context(|| {
1675+
format!(
1676+
"copy {} to {}",
1677+
request.path.display(),
1678+
output_path.display()
1679+
)
1680+
})?;
1681+
}
1682+
return Ok(true);
1683+
}
1684+
16251685
if !request.append_signature {
16261686
pe = pe_embed::pe_remove_authenticode_certificates(pe)
16271687
.with_context(|| {
@@ -1648,7 +1708,9 @@ fn sign_pe(request: &PortableSignRequest, output_path: &Path) -> Result<()> {
16481708
.with_context(|| format!("timestamp {}", request.path.display()))?;
16491709
let signed = pe_embed::pe_append_authenticode_pkcs7_certificate(pe, &pkcs7)
16501710
.with_context(|| format!("embed Authenticode signature in {}", request.path.display()))?;
1651-
std::fs::write(output_path, signed).with_context(|| format!("write {}", output_path.display()))
1711+
std::fs::write(output_path, signed)
1712+
.with_context(|| format!("write {}", output_path.display()))?;
1713+
Ok(false)
16521714
}
16531715

16541716
fn clear_pe_signature(
@@ -3600,6 +3662,149 @@ mod tests {
36003662
let _ = std::fs::remove_dir_all(temp_dir);
36013663
}
36023664

3665+
#[test]
3666+
fn pe_sign_skip_signed_signs_unsigned_and_skips_valid_pe() {
3667+
let temp_dir = std::env::temp_dir().join(format!(
3668+
"psign-portable-pe-skip-signed-{}-{}",
3669+
std::process::id(),
3670+
std::time::SystemTime::now()
3671+
.duration_since(std::time::UNIX_EPOCH)
3672+
.unwrap_or_default()
3673+
.as_nanos()
3674+
));
3675+
std::fs::create_dir_all(&temp_dir).expect("create temp dir");
3676+
3677+
let fixture_dir = PathBuf::from("../../tests/fixtures/devolutions-authenticode");
3678+
let unsigned_source =
3679+
PathBuf::from("../../tests/fixtures/pe-authenticode-upstream/tiny32.efi");
3680+
let signed_source =
3681+
PathBuf::from("../../tests/fixtures/pe-authenticode-upstream/tiny32.signed.efi");
3682+
let unsigned = temp_dir.join("tiny32.unsigned.efi");
3683+
let already_signed = temp_dir.join("tiny32.already-signed.efi");
3684+
std::fs::copy(&unsigned_source, &unsigned).expect("copy unsigned PE");
3685+
std::fs::copy(&signed_source, &already_signed).expect("copy signed PE");
3686+
3687+
let signed_response = portable_sign(PortableSignRequest {
3688+
path: unsigned.clone(),
3689+
skip_signed: true,
3690+
pfx_path: Some(fixture_dir.join("authenticode-test-cert.pfx")),
3691+
pfx_password: Some("CodeSign123!".to_string()),
3692+
..default_sign_request()
3693+
})
3694+
.expect("sign unsigned PE with skip-signed");
3695+
assert!(!signed_response.skipped);
3696+
assert_eq!(signed_response.signature.signature_count, 1);
3697+
3698+
let before = std::fs::read(&already_signed).expect("read already signed PE before skip");
3699+
let skipped_response = portable_sign(PortableSignRequest {
3700+
path: already_signed.clone(),
3701+
skip_signed: true,
3702+
pfx_path: Some(fixture_dir.join("authenticode-test-cert.pfx")),
3703+
pfx_password: Some("CodeSign123!".to_string()),
3704+
..default_sign_request()
3705+
})
3706+
.expect("skip already signed PE");
3707+
let after = std::fs::read(&already_signed).expect("read already signed PE after skip");
3708+
assert!(skipped_response.skipped);
3709+
assert_eq!(before, after);
3710+
assert_eq!(skipped_response.signature.signature_count, 1);
3711+
3712+
let _ = std::fs::remove_dir_all(temp_dir);
3713+
}
3714+
3715+
#[test]
3716+
fn pe_sign_skip_signed_copies_valid_pe_to_output_path() {
3717+
let temp_dir = std::env::temp_dir().join(format!(
3718+
"psign-portable-pe-skip-copy-{}-{}",
3719+
std::process::id(),
3720+
std::time::SystemTime::now()
3721+
.duration_since(std::time::UNIX_EPOCH)
3722+
.unwrap_or_default()
3723+
.as_nanos()
3724+
));
3725+
std::fs::create_dir_all(&temp_dir).expect("create temp dir");
3726+
3727+
let fixture_dir = PathBuf::from("../../tests/fixtures/devolutions-authenticode");
3728+
let signed_source =
3729+
PathBuf::from("../../tests/fixtures/pe-authenticode-upstream/tiny32.signed.efi");
3730+
let output = temp_dir.join("tiny32.output.efi");
3731+
3732+
let skipped_response = portable_sign(PortableSignRequest {
3733+
path: signed_source.clone(),
3734+
output_path: Some(output.clone()),
3735+
skip_signed: true,
3736+
pfx_path: Some(fixture_dir.join("authenticode-test-cert.pfx")),
3737+
pfx_password: Some("CodeSign123!".to_string()),
3738+
..default_sign_request()
3739+
})
3740+
.expect("copy skipped PE to output path");
3741+
3742+
assert!(skipped_response.skipped);
3743+
assert_eq!(
3744+
std::fs::read(&signed_source).expect("read signed source"),
3745+
std::fs::read(&output).expect("read copied output")
3746+
);
3747+
assert_eq!(skipped_response.signature.signature_count, 1);
3748+
3749+
let _ = std::fs::remove_dir_all(temp_dir);
3750+
}
3751+
3752+
#[test]
3753+
fn pe_sign_skip_signed_rejects_corrupt_existing_signature() {
3754+
let temp_dir = std::env::temp_dir().join(format!(
3755+
"psign-portable-pe-skip-corrupt-{}-{}",
3756+
std::process::id(),
3757+
std::time::SystemTime::now()
3758+
.duration_since(std::time::UNIX_EPOCH)
3759+
.unwrap_or_default()
3760+
.as_nanos()
3761+
));
3762+
std::fs::create_dir_all(&temp_dir).expect("create temp dir");
3763+
3764+
let fixture_dir = PathBuf::from("../../tests/fixtures/devolutions-authenticode");
3765+
let target = temp_dir.join("tiny32.corrupt-signed.efi");
3766+
let mut corrupt =
3767+
std::fs::read("../../tests/fixtures/pe-authenticode-upstream/tiny32.signed.efi")
3768+
.expect("read signed PE");
3769+
tamper_hashed_pe_byte(&mut corrupt);
3770+
verify_pe_authenticode_digest_consistency(&corrupt)
3771+
.expect_err("tampered PE should fail digest verification");
3772+
std::fs::write(&target, &corrupt).expect("write corrupt signed PE");
3773+
3774+
let err = portable_sign(PortableSignRequest {
3775+
path: target.clone(),
3776+
skip_signed: true,
3777+
pfx_path: Some(fixture_dir.join("authenticode-test-cert.pfx")),
3778+
pfx_password: Some("CodeSign123!".to_string()),
3779+
..default_sign_request()
3780+
})
3781+
.expect_err("corrupt signed PE should not be skipped");
3782+
let msg = format!("{err:?}");
3783+
assert!(
3784+
msg.contains("check existing PE/WinMD Authenticode signature"),
3785+
"unexpected error: {msg}"
3786+
);
3787+
assert!(msg.contains("mismatch"), "unexpected error: {msg}");
3788+
assert_eq!(
3789+
std::fs::read(&target).expect("read corrupt PE after failed skip"),
3790+
corrupt
3791+
);
3792+
3793+
let _ = std::fs::remove_dir_all(temp_dir);
3794+
}
3795+
3796+
fn tamper_hashed_pe_byte(bytes: &mut [u8]) {
3797+
let ranges =
3798+
pe_digest::pe_authenticode_digest_file_ranges(bytes).expect("PE digest file ranges");
3799+
let offset = ranges
3800+
.into_iter()
3801+
.rev()
3802+
.find(|range| !range.is_empty())
3803+
.expect("non-empty PE digest range")
3804+
.start;
3805+
bytes[offset] ^= 0x01;
3806+
}
3807+
36033808
#[test]
36043809
fn rejects_mixed_azure_key_vault_and_local_material() {
36053810
let request = PortableSignRequest {
@@ -3669,6 +3874,7 @@ mod tests {
36693874
PortableSignRequest {
36703875
path: PathBuf::from("test.dll"),
36713876
append_signature: false,
3877+
skip_signed: false,
36723878
output_path: None,
36733879
hash_algorithm: PortableDigestAlgorithm::Sha256,
36743880
certificate_path: None,

‎docs/portable-core-ffi.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ The ABI is intentionally small and JSON-based:
1212

1313
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.
1414

15-
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.
15+
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.
1616

1717
`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`.
1818

0 commit comments

Comments
 (0)