diff --git a/.gitattributes b/.gitattributes
index 303d14c..6b16159 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -24,8 +24,11 @@ tests/fixtures/**/*.msixbundle binary
tests/fixtures/**/*.msi binary
tests/fixtures/**/*.msp binary
tests/fixtures/**/*.mst binary
+tests/fixtures/**/*.nupkg binary
tests/fixtures/**/*.ocx binary
tests/fixtures/**/*.p7 binary
tests/fixtures/**/*.pfx binary
+tests/fixtures/**/*.snupkg binary
tests/fixtures/**/*.sys binary
+tests/fixtures/**/*.vsix binary
tests/fixtures/**/*.winmd binary
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fe9eed3..919bfec 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.2.0)
+ description: Release version to build/publish (for example 0.3.0)
required: true
type: string
publish_nuget:
diff --git a/Cargo.lock b/Cargo.lock
index 3739b57..23d1825 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2001,7 +2001,7 @@ dependencies = [
[[package]]
name = "psign"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"anyhow",
"assert_cmd",
@@ -2017,6 +2017,7 @@ dependencies = [
"psign-azure-kv-rest",
"psign-codesigning-rest",
"psign-digest-cli",
+ "psign-opc-sign",
"psign-sip-digest",
"rand 0.8.6",
"rayon",
@@ -2036,7 +2037,7 @@ dependencies = [
[[package]]
name = "psign-authenticode-trust"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"anyhow",
"authenticode",
@@ -2060,7 +2061,7 @@ dependencies = [
[[package]]
name = "psign-azure-kv-rest"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"anyhow",
"base64",
@@ -2075,7 +2076,7 @@ dependencies = [
[[package]]
name = "psign-codesigning-rest"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"anyhow",
"base64",
@@ -2087,7 +2088,7 @@ dependencies = [
[[package]]
name = "psign-digest-cli"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"anyhow",
"assert_cmd",
@@ -2113,21 +2114,26 @@ dependencies = [
[[package]]
name = "psign-opc-sign"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"anyhow",
+ "base64",
"sha2 0.10.9",
"zip",
]
[[package]]
name = "psign-portable-core"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"anyhow",
"base64",
+ "der 0.7.10",
"picky",
"psign-authenticode-trust",
+ "psign-azure-kv-rest",
+ "psign-codesigning-rest",
+ "psign-opc-sign",
"psign-sip-digest",
"reqwest",
"rsa 0.9.10",
@@ -2141,7 +2147,7 @@ dependencies = [
[[package]]
name = "psign-portable-ffi"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"anyhow",
"psign-portable-core",
@@ -2151,7 +2157,7 @@ dependencies = [
[[package]]
name = "psign-sip-digest"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"anyhow",
"authenticode",
diff --git a/Cargo.toml b/Cargo.toml
index 8af5ca1..663a679 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -30,7 +30,7 @@ repository = "https://github.com/Devolutions/psign"
[package]
name = "psign"
-version = "0.2.0"
+version = "0.3.0"
edition = "2024"
description = "Rust port of the Windows SDK signtool.exe (Authenticode sign/verify/timestamp) with portable digest helpers."
license.workspace = true
@@ -55,12 +55,13 @@ artifact-signing-rest = ["dep:psign-codesigning-rest", "psign-digest-cli/artifac
## Portable RFC 3161 TSA HTTP POST helper under `psign-tool portable`.
timestamp-http = ["psign-digest-cli/timestamp-http"]
## Local RFC 3161 timestamp test server (`psign-server`).
-timestamp-server = ["dep:cms", "dep:der", "dep:rand", "dep:rsa", "x509-cert/builder"]
+timestamp-server = ["dep:cms", "dep:der", "dep:rand", "x509-cert/builder"]
[dependencies]
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" }
anyhow = "1"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
@@ -73,15 +74,16 @@ picky = { version = "7.0.0-rc.23", default-features = false, features = ["pkcs12
cms = { version = "0.2.3", features = ["builder"], optional = true }
der = { version = "0.7", features = ["derive"], optional = true }
rand = { version = "0.8", optional = true }
-rsa = { version = "0.9.10", features = ["sha2"], optional = true }
+rsa = { version = "0.9.10", features = ["sha2"] }
x509-cert = "0.2.5"
+zip = { version = "0.6.6", default-features = false, features = ["deflate"] }
+reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"], optional = true }
+psign-azure-kv-rest = { path = "crates/psign-azure-kv-rest", optional = true }
+psign-codesigning-rest = { path = "crates/psign-codesigning-rest", optional = true }
[target.'cfg(windows)'.dependencies]
glob = "0.3"
rayon = "1.10"
-psign-azure-kv-rest = { path = "crates/psign-azure-kv-rest", optional = true }
-psign-codesigning-rest = { path = "crates/psign-codesigning-rest", optional = true }
-reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"], optional = true }
uuid = "1"
windows = { version = "0.59", features = [
"Win32_Foundation",
@@ -103,7 +105,6 @@ rand = "0.8"
rsa = { version = "0.9.10", features = ["sha2"] }
tempfile = "3"
x509-cert = { version = "0.2.5", features = ["builder"] }
-zip = { version = "0.6.6", default-features = false, features = ["deflate"] }
[build-dependencies]
winresource = "0.1.31"
diff --git a/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 b/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1
index 352e765..d78a56b 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.2.0'
+ ModuleVersion = '0.3.0'
GUID = 'e6e50e4b-bf25-4ed6-a343-49f904e79f8f'
Author = 'Devolutions'
CompanyName = 'Devolutions'
diff --git a/PowerShell/tests/Invoke-PortableSignatureTests.ps1 b/PowerShell/tests/Invoke-PortableSignatureTests.ps1
index 01816ec..f646ab8 100644
--- a/PowerShell/tests/Invoke-PortableSignatureTests.ps1
+++ b/PowerShell/tests/Invoke-PortableSignatureTests.ps1
@@ -8,404 +8,24 @@ $repo = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$buildScript = Join-Path (Join-Path $repo 'PowerShell') 'build.ps1'
& $buildScript -Configuration $Configuration
-$modulePath = Join-Path (Join-Path (Join-Path $repo 'PowerShell') 'Devolutions.Psign') 'Devolutions.Psign.psd1'
-Import-Module $modulePath -Force
-
-function Assert-SignerCertificate {
- param(
- [Parameter(Mandatory)]
- $Signature,
- [Parameter(Mandatory)]
- [System.Security.Cryptography.X509Certificates.X509Certificate2] $ExpectedCertificate,
- [Parameter(Mandatory)]
- [string] $Label
- )
-
- if ($null -eq $Signature.SignerCertificate) {
- throw "Expected SignerCertificate for $Label."
- }
- if ($Signature.SignerCertificate.Thumbprint -ne $ExpectedCertificate.Thumbprint) {
- throw "Unexpected SignerCertificate thumbprint for $Label."
- }
- if ($Signature.EmbeddedCertificateCount -lt 1) {
- throw "Expected EmbeddedCertificateCount for $Label."
- }
+$pester = Get-Module -ListAvailable Pester |
+ Sort-Object Version -Descending |
+ Select-Object -First 1
+if ($null -eq $pester) {
+ throw 'Pester 5.x is required to run the PowerShell module test suite.'
}
-function Start-PsignTimestampServer {
- $psi = [System.Diagnostics.ProcessStartInfo]::new()
- $psi.FileName = 'cargo'
- foreach ($argument in @('run', '--quiet', '--bin', 'psign-server', '--', 'timestamp-server', '--max-requests', '1')) {
- $psi.ArgumentList.Add($argument)
- }
- $psi.WorkingDirectory = $repo
- $psi.RedirectStandardOutput = $true
- $psi.UseShellExecute = $false
- $psi.CreateNoWindow = $true
- $process = [System.Diagnostics.Process]::Start($psi)
- $line = $process.StandardOutput.ReadLine()
- if ($line -notlike 'psign-server timestamp-server listening on *') {
- try {
- if (-not $process.HasExited) {
- $process.Kill($true)
- }
- }
- catch {
- }
- throw "Failed to start psign timestamp server. First output: $line"
- }
- [pscustomobject]@{
- Process = $process
- Url = $line.Substring('psign-server timestamp-server listening on '.Length)
- }
-}
+Import-Module $pester.Path -Force
-$temp = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N'))
-New-Item -ItemType Directory -Force -Path $temp | Out-Null
+$env:PSIGN_PWSH_TEST_SKIP_BUILD = '1'
+$env:PSIGN_PWSH_TEST_CONFIGURATION = $Configuration
try {
- $rsa = [System.Security.Cryptography.RSA]::Create(2048)
- $rootRsa = [System.Security.Cryptography.RSA]::Create(2048)
- $rootRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new(
- 'CN=psign portable root',
- $rootRsa,
- [System.Security.Cryptography.HashAlgorithmName]::SHA256,
- [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
- $rootRequest.CertificateExtensions.Add(
- [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($true, $false, 0, $true))
- $rootRequest.CertificateExtensions.Add(
- [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new(
- [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyCertSign -bor
- [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::CrlSign,
- $true))
- $rootCert = $rootRequest.CreateSelfSigned(
- [System.DateTimeOffset]::UtcNow.AddDays(-1),
- [System.DateTimeOffset]::UtcNow.AddDays(31))
-
- $request = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new(
- 'CN=psign portable test',
- $rsa,
- [System.Security.Cryptography.HashAlgorithmName]::SHA256,
- [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
- $request.CertificateExtensions.Add(
- [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($false, $false, 0, $true))
- $request.CertificateExtensions.Add(
- [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new(
- [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature,
- $true))
- $ekuOids = [System.Security.Cryptography.OidCollection]::new()
- $null = $ekuOids.Add([System.Security.Cryptography.Oid]::new('1.3.6.1.5.5.7.3.3'))
- $request.CertificateExtensions.Add(
- [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($ekuOids, $false))
- $issuedCert = $request.Create(
- $rootCert,
- [System.DateTimeOffset]::UtcNow.AddDays(-1),
- [System.DateTimeOffset]::UtcNow.AddDays(30),
- [byte[]](1, 2, 3, 4, 5, 6, 7, 8))
- $cert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::CopyWithPrivateKey($issuedCert, $rsa)
- $certPath = Join-Path $temp 'signer.cer'
- $keyPath = Join-Path $temp 'signer.key'
- $pfxPath = Join-Path $temp 'signer.pfx'
- $pfxPassword = ConvertTo-SecureString -String 'portable-test' -AsPlainText -Force
- [System.IO.File]::WriteAllBytes($certPath, $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert))
- [System.IO.File]::WriteAllText(
- $keyPath,
- [System.Security.Cryptography.PemEncoding]::WriteString('PRIVATE KEY', $rsa.ExportPkcs8PrivateKey()))
- [System.IO.File]::WriteAllBytes($pfxPath, $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pkcs12, 'portable-test'))
- $storeDir = Join-Path $temp 'cert-store'
- $storeMyDir = Join-Path (Join-Path $storeDir 'CurrentUser') 'MY'
- New-Item -ItemType Directory -Force -Path $storeMyDir | Out-Null
- $storeCertPath = Join-Path $storeMyDir "$($cert.Thumbprint.ToUpperInvariant()).der"
- $storeKeyPath = Join-Path $storeMyDir "$($cert.Thumbprint.ToUpperInvariant()).key"
- Copy-Item -LiteralPath $certPath -Destination $storeCertPath
- Copy-Item -LiteralPath $keyPath -Destination $storeKeyPath
- $chainRsa = [System.Security.Cryptography.RSA]::Create(2048)
- $chainRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new(
- 'CN=psign portable chain test',
- $chainRsa,
- [System.Security.Cryptography.HashAlgorithmName]::SHA256,
- [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
- $chainCert = $chainRequest.CreateSelfSigned(
- [System.DateTimeOffset]::UtcNow.AddDays(-1),
- [System.DateTimeOffset]::UtcNow.AddDays(30))
- $chainCertPath = Join-Path $temp 'chain.cer'
- [System.IO.File]::WriteAllBytes($chainCertPath, $chainCert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert))
-
- $unsigned = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $repo 'tests') 'fixtures') 'generated-unsigned') 'pe') 'tiny32-pe-alias.exe'
- $work = Join-Path $temp 'tiny.exe'
- Copy-Item $unsigned $work
-
- if (-not (Get-Command Get-PortableSignature -ErrorAction SilentlyContinue)) {
- throw 'Get-PortableSignature was not exported.'
- }
- if (-not (Get-Command Set-PortableSignature -ErrorAction SilentlyContinue)) {
- throw 'Set-PortableSignature was not exported.'
- }
- $getParameters = (Get-Command Get-PortableSignature).Parameters
- foreach ($parameterName in @('FilePath', 'LiteralPath', 'SourcePathOrExtension', 'Content', 'TrustedCertificate', 'TrustedCertificatePath', 'AnchorDirectory', 'AuthRootCab', 'AsOf', 'PreferTimestampSigningTime', 'RequireValidTimestamp', 'OnlineAia', 'OnlineOcsp', 'RevocationMode')) {
- if (-not $getParameters.ContainsKey($parameterName)) {
- throw "Get-PortableSignature is missing expected migration parameter '$parameterName'."
- }
- }
- $setParameters = (Get-Command Set-PortableSignature).Parameters
- foreach ($parameterName in @('FilePath', 'LiteralPath', 'SourcePathOrExtension', 'Content', 'Certificate', 'CertificatePath', 'PrivateKeyPath', 'PfxPath', 'Password', 'Thumbprint', 'CertStoreDirectory', 'StoreName', 'MachineStore', 'IncludeChain', 'ChainCertificatePath', 'TimestampServer', 'TimestampHashAlgorithm', 'HashAlgorithm', 'OutputPath', 'Force')) {
- if (-not $setParameters.ContainsKey($parameterName)) {
- throw "Set-PortableSignature is missing expected migration parameter '$parameterName'."
- }
- }
-
- $before = Get-PortableSignature -LiteralPath $work
- if ($before.Status -ne 'NotSigned') {
- throw "Expected NotSigned before signing, got $($before.Status)."
- }
-
- $signed = Set-PortableSignature -LiteralPath $work -CertificatePath $certPath -PrivateKeyPath $keyPath
- if ($signed.Status -ne 'Valid') {
- throw "Expected Valid after signing, got $($signed.Status): $($signed.StatusMessage)"
- }
- Assert-SignerCertificate -Signature $signed -ExpectedCertificate $cert -Label 'PE signing response'
-
- $after = Get-PortableSignature -LiteralPath $work
- if ($after.Status -ne 'Valid') {
- throw "Expected Valid from Get-PortableSignature after signing, got $($after.Status)."
- }
- Assert-SignerCertificate -Signature $after -ExpectedCertificate $cert -Label 'PE get response'
-
- $trustedAfter = Get-PortableSignature -LiteralPath $work -TrustedCertificate $rootCert -AsOf ([System.DateTime]::UtcNow) -RevocationMode Off
- if ($trustedAfter.Status -ne 'Valid' -or $trustedAfter.TrustStatus -ne 'Valid') {
- throw "Expected explicit trust verification to succeed for signed PE, got status=$($trustedAfter.Status) trust=$($trustedAfter.TrustStatus): $($trustedAfter.StatusMessage)"
- }
- $untrustedAfter = Get-PortableSignature -LiteralPath $work -TrustedCertificate $chainCert
- if ($untrustedAfter.Status -ne 'NotTrusted' -or $untrustedAfter.TrustStatus -ne 'NotTrusted') {
- throw "Expected explicit trust verification to fail with wrong anchor, got status=$($untrustedAfter.Status) trust=$($untrustedAfter.TrustStatus): $($untrustedAfter.StatusMessage)"
- }
-
- $length = (Get-Item -LiteralPath $work).Length
- Set-PortableSignature -LiteralPath $work -CertificatePath $certPath -PrivateKeyPath $keyPath -WhatIf | Out-Null
- if ((Get-Item -LiteralPath $work).Length -ne $length) {
- throw 'Set-PortableSignature -WhatIf mutated the file.'
- }
-
- $readOnlyWork = Join-Path $temp 'tiny-readonly.exe'
- Copy-Item $unsigned $readOnlyWork
- Set-ItemProperty -LiteralPath $readOnlyWork -Name IsReadOnly -Value $true
- try {
- $failedWithoutForce = $false
- try {
- Set-PortableSignature -LiteralPath $readOnlyWork -CertificatePath $certPath -PrivateKeyPath $keyPath -ErrorAction Stop | Out-Null
- }
- catch {
- $failedWithoutForce = $true
- }
- if (-not $failedWithoutForce) {
- throw 'Expected Set-PortableSignature to fail on a read-only file without -Force.'
- }
- $forceSigned = Set-PortableSignature -LiteralPath $readOnlyWork -CertificatePath $certPath -PrivateKeyPath $keyPath -Force
- if ($forceSigned.Status -ne 'Valid') {
- throw "Expected Valid after read-only file signing with -Force, got $($forceSigned.Status): $($forceSigned.StatusMessage)"
- }
- if (-not (Get-Item -LiteralPath $readOnlyWork).IsReadOnly) {
- throw 'Expected Set-PortableSignature -Force to restore the read-only attribute.'
- }
- }
- finally {
- Set-ItemProperty -LiteralPath $readOnlyWork -Name IsReadOnly -Value $false -ErrorAction SilentlyContinue
- }
-
- $storeWork = Join-Path $temp 'tiny-store.exe'
- Copy-Item $unsigned $storeWork
- $storeSigned = Set-PortableSignature -LiteralPath $storeWork -Sha1 $cert.Thumbprint -CertStoreDirectory $storeDir
- if ($storeSigned.Status -ne 'Valid') {
- throw "Expected Valid after portable cert-store signing, got $($storeSigned.Status): $($storeSigned.StatusMessage)"
- }
- Assert-SignerCertificate -Signature $storeSigned -ExpectedCertificate $cert -Label 'portable cert-store signing response'
-
- $chainWork = Join-Path $temp 'tiny-chain.exe'
- Copy-Item $unsigned $chainWork
- $defaultChainWork = Join-Path $temp 'tiny-chain-default.exe'
- Copy-Item $unsigned $defaultChainWork
- $defaultChainSigned = Set-PortableSignature -LiteralPath $defaultChainWork -CertificatePath $certPath -PrivateKeyPath $keyPath -ChainCertificatePath $chainCertPath
- if ($defaultChainSigned.EmbeddedCertificateCount -ne 1) {
- throw "Expected default IncludeChain NotRoot to exclude a self-signed root certificate, got $($defaultChainSigned.EmbeddedCertificateCount) embedded certificates."
- }
- $chainSigned = Set-PortableSignature -LiteralPath $chainWork -CertificatePath $certPath -PrivateKeyPath $keyPath -IncludeChain All -ChainCertificatePath $chainCertPath
- if ($chainSigned.EmbeddedCertificateCount -lt 2) {
- throw "Expected IncludeChain All with ChainCertificatePath to embed at least 2 certificates, got $($chainSigned.EmbeddedCertificateCount)."
- }
-
- $unsignedCab = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $repo 'tests') 'fixtures') 'generated-unsigned') 'cab') 'sample.cab'
- $cabWork = Join-Path $temp 'sample.cab'
- Copy-Item $unsignedCab $cabWork
- $cabSigned = Set-PortableSignature -LiteralPath $cabWork -CertificatePath $certPath -PrivateKeyPath $keyPath
- if ($cabSigned.Status -ne 'Valid') {
- throw "Expected Valid after CAB signing, got $($cabSigned.Status): $($cabSigned.StatusMessage)"
- }
- Assert-SignerCertificate -Signature $cabSigned -ExpectedCertificate $cert -Label 'CAB signing response'
- $cabAfter = Get-PortableSignature -LiteralPath $cabWork
- if ($cabAfter.Status -ne 'Valid') {
- throw "Expected Valid from Get-PortableSignature for signed CAB, got $($cabAfter.Status): $($cabAfter.StatusMessage)"
- }
-
- $unsignedMsi = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $repo 'tests') 'fixtures') 'generated-unsigned') 'installer') 'tiny.msi'
- $msiWork = Join-Path $temp 'tiny.msi'
- Copy-Item $unsignedMsi $msiWork
- $msiSigned = Set-PortableSignature -LiteralPath $msiWork -CertificatePath $certPath -PrivateKeyPath $keyPath
- if ($msiSigned.Status -ne 'Valid') {
- throw "Expected Valid after MSI signing, got $($msiSigned.Status): $($msiSigned.StatusMessage)"
- }
- Assert-SignerCertificate -Signature $msiSigned -ExpectedCertificate $cert -Label 'MSI signing response'
- $msiAfter = Get-PortableSignature -LiteralPath $msiWork
- if ($msiAfter.Status -ne 'Valid') {
- throw "Expected Valid from Get-PortableSignature for signed MSI, got $($msiAfter.Status): $($msiAfter.StatusMessage)"
- }
-
- $zipSource = Join-Path $temp 'zip-source'
- New-Item -ItemType Directory -Force -Path $zipSource | Out-Null
- Set-Content -LiteralPath (Join-Path $zipSource 'payload.txt') -Value 'portable zip authenticode' -Encoding UTF8
- $zipWork = Join-Path $temp 'payload.zip'
- Compress-Archive -LiteralPath (Join-Path $zipSource 'payload.txt') -DestinationPath $zipWork
- $zipSigned = Set-PortableSignature -LiteralPath $zipWork -CertificatePath $certPath -PrivateKeyPath $keyPath
- if ($zipSigned.Status -ne 'Valid') {
- throw "Expected Valid after ZIP signing, got $($zipSigned.Status): $($zipSigned.StatusMessage)"
- }
- Assert-SignerCertificate -Signature $zipSigned -ExpectedCertificate $cert -Label 'ZIP signing response'
- $zipAfter = Get-PortableSignature -LiteralPath $zipWork
- if ($zipAfter.Status -ne 'Valid') {
- throw "Expected Valid from Get-PortableSignature for signed ZIP, got $($zipAfter.Status): $($zipAfter.StatusMessage)"
- }
-
- $scriptPath = Join-Path $temp 'Invoke-Test.ps1'
- Set-Content -LiteralPath $scriptPath -Value @'
-param([string] $Name = "portable")
-"Hello $Name"
-'@ -Encoding UTF8
- $scriptSigned = Set-PortableSignature -LiteralPath $scriptPath -Certificate $cert
- if ($scriptSigned.Status -ne 'Valid') {
- throw "Expected Valid for signed PowerShell script, got $($scriptSigned.Status): $($scriptSigned.StatusMessage)"
- }
- Assert-SignerCertificate -Signature $scriptSigned -ExpectedCertificate $cert -Label 'script signing response'
- $scriptAfter = Get-PortableSignature -LiteralPath $scriptPath
- if ($scriptAfter.Status -ne 'Valid') {
- throw "Expected Valid from Get-PortableSignature for signed script, got $($scriptAfter.Status)."
- }
- $trustedScript = Get-PortableSignature -LiteralPath $scriptPath -TrustedCertificate $rootCert
- if ($trustedScript.Status -ne 'Valid' -or $trustedScript.TrustStatus -ne 'Valid') {
- throw "Expected explicit trust verification to succeed for signed script, got status=$($trustedScript.Status) trust=$($trustedScript.TrustStatus): $($trustedScript.StatusMessage)"
- }
- Add-Content -LiteralPath $scriptPath -Value '# tamper'
- $scriptTampered = Get-PortableSignature -LiteralPath $scriptPath
- if ($scriptTampered.Status -ne 'HashMismatch') {
- throw "Expected HashMismatch for tampered signed script, got $($scriptTampered.Status): $($scriptTampered.StatusMessage)"
- }
-
- $ps1xmlPath = Join-Path $temp 'Types.ps1xml'
- Set-Content -LiteralPath $ps1xmlPath -Value @'
-
-
- Portable.Type
-
-
-'@ -Encoding UTF8
- $ps1xmlSigned = Set-PortableSignature -LiteralPath $ps1xmlPath -Certificate $cert
- if ($ps1xmlSigned.Status -ne 'Valid') {
- throw "Expected Valid for signed ps1xml, got $($ps1xmlSigned.Status): $($ps1xmlSigned.StatusMessage)"
- }
- Assert-SignerCertificate -Signature $ps1xmlSigned -ExpectedCertificate $cert -Label 'ps1xml signing response'
- $ps1xmlText = Get-Content -LiteralPath $ps1xmlPath -Raw
- if ($ps1xmlText -notmatch '') {
- throw 'Expected signed ps1xml to use XML Authenticode signature markers.'
- }
- $ps1xmlAfter = Get-PortableSignature -LiteralPath $ps1xmlPath
- if ($ps1xmlAfter.Status -ne 'Valid') {
- throw "Expected Valid from Get-PortableSignature for signed ps1xml, got $($ps1xmlAfter.Status): $($ps1xmlAfter.StatusMessage)"
- }
- Add-Content -LiteralPath $ps1xmlPath -Value ''
- $ps1xmlTampered = Get-PortableSignature -LiteralPath $ps1xmlPath
- if ($ps1xmlTampered.Status -ne 'HashMismatch') {
- throw "Expected HashMismatch for tampered signed ps1xml, got $($ps1xmlTampered.Status): $($ps1xmlTampered.StatusMessage)"
- }
-
- $scriptContent = [System.Text.Encoding]::UTF8.GetBytes("'content mode'")
- $contentSigned = Set-PortableSignature -SourcePathOrExtension '.ps1' -Content $scriptContent -Certificate $cert
- if ($contentSigned.Status -ne 'Valid') {
- throw "Expected Valid for signed PowerShell script content, got $($contentSigned.Status): $($contentSigned.StatusMessage)"
- }
- if ($null -eq $contentSigned.Content -or $contentSigned.Content.Length -le $scriptContent.Length) {
- throw 'Expected Set-PortableSignature -Content to return signed content bytes.'
- }
- Assert-SignerCertificate -Signature $contentSigned -ExpectedCertificate $cert -Label 'script content signing response'
- $contentAfter = Get-PortableSignature -SourcePathOrExtension '.ps1' -Content $contentSigned.Content
- if ($contentAfter.Status -ne 'Valid') {
- throw "Expected Valid from Get-PortableSignature -Content for signed script, got $($contentAfter.Status): $($contentAfter.StatusMessage)"
- }
-
- $timestampServer = Start-PsignTimestampServer
- try {
- $timestampScript = Join-Path $temp 'Timestamped.ps1'
- Set-Content -LiteralPath $timestampScript -Value '"timestamped"' -Encoding UTF8
- $timestamped = Set-PortableSignature -LiteralPath $timestampScript -Certificate $cert -TimestampServer $timestampServer.Url -TimestampHashAlgorithm Sha256
- if ($timestamped.Status -ne 'Valid') {
- throw "Expected Valid for timestamped script, got $($timestamped.Status): $($timestamped.StatusMessage)"
- }
- if ($timestamped.TimestampKinds.Count -eq 0) {
- throw 'Expected timestamped script to report a timestamp kind.'
- }
- if ($null -eq $timestamped.TimeStamperCertificate) {
- throw 'Expected timestamped script to expose TimeStamperCertificate.'
- }
- if (-not $timestamped.PSObject.Properties.Match('TimestampSigningTime')) {
- throw 'Expected timestamped script output to include TimestampSigningTime.'
- }
- }
- finally {
- if (-not $timestampServer.Process.HasExited) {
- $timestampServer.Process.Kill($true)
- }
- $timestampServer.Process.Dispose()
- }
-
- $moduleDir = Join-Path $temp 'PortableModule'
- $nestedDir = Join-Path $moduleDir 'Private'
- New-Item -ItemType Directory -Force -Path $nestedDir | Out-Null
- Set-Content -LiteralPath (Join-Path $moduleDir 'PortableModule.psm1') -Value 'function Get-PortableGreeting { "hello" }' -Encoding UTF8
- Set-Content -LiteralPath (Join-Path $moduleDir 'PortableModule.psd1') -Value "@{ RootModule = 'PortableModule.psm1'; ModuleVersion = '1.0.0'; GUID = '$([System.Guid]::NewGuid())' }" -Encoding UTF8
- Set-Content -LiteralPath (Join-Path $moduleDir 'PortableModule.Types.ps1xml') -Value '' -Encoding UTF8
- Set-Content -LiteralPath (Join-Path $nestedDir 'Helper.ps1') -Value '$script:PortableHelper = $true' -Encoding UTF8
- $moduleSigned = @(Set-PortableSignature -LiteralPath $moduleDir -CertificatePath $certPath -PrivateKeyPath $keyPath)
- if ($moduleSigned.Count -ne 4) {
- throw "Expected 4 signed PowerShell module files, got $($moduleSigned.Count)."
- }
- if (@($moduleSigned | Where-Object Status -ne 'Valid').Count -ne 0) {
- throw "Expected all signed module files to be Valid, got: $($moduleSigned | ConvertTo-Json -Depth 4)"
- }
- foreach ($moduleSignature in $moduleSigned) {
- Assert-SignerCertificate -Signature $moduleSignature -ExpectedCertificate $cert -Label "module signing response $($moduleSignature.Path)"
- }
- $moduleValidated = @(Get-PortableSignature -LiteralPath $moduleDir)
- if ($moduleValidated.Count -ne 4) {
- throw "Expected 4 validated PowerShell module files, got $($moduleValidated.Count)."
- }
- if (@($moduleValidated | Where-Object Status -ne 'Valid').Count -ne 0) {
- throw "Expected all validated module files to be Valid, got: $($moduleValidated | ConvertTo-Json -Depth 4)"
- }
-
- $unsignedMsix = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $repo 'tests') 'fixtures') 'generated-unsigned') 'msix') 'sample.msix'
- $msixWork = Join-Path $temp 'sample.msix'
- Copy-Item $unsignedMsix $msixWork
- $msixBefore = Get-PortableSignature -LiteralPath $msixWork
- if ($msixBefore.Status -notin @('NotSigned', 'Incompatible')) {
- throw "Expected unsigned MSIX preflight status before signing, got $($msixBefore.Status)."
- }
- $msixSigned = Set-PortableSignature -LiteralPath $msixWork -PfxPath $pfxPath -Password $pfxPassword
- if ($msixSigned.Status -ne 'Valid') {
- throw "Expected Valid after MSIX signing, got $($msixSigned.Status): $($msixSigned.StatusMessage)"
- }
- Assert-SignerCertificate -Signature $msixSigned -ExpectedCertificate $cert -Label 'MSIX signing response'
- $msixAfter = Get-PortableSignature -LiteralPath $msixWork
- if ($msixAfter.Status -ne 'Valid') {
- throw "Expected Valid from Get-PortableSignature for signed MSIX, got $($msixAfter.Status): $($msixAfter.StatusMessage)"
+ $result = Invoke-Pester -Path (Join-Path $PSScriptRoot '*.Tests.ps1') -PassThru
+ if ($result.FailedCount -gt 0) {
+ throw "PowerShell module tests failed: $($result.FailedCount) failed, $($result.PassedCount) passed."
}
}
finally {
- Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue
- Remove-Module Devolutions.Psign -Force -ErrorAction SilentlyContinue
+ Remove-Item Env:PSIGN_PWSH_TEST_SKIP_BUILD -ErrorAction SilentlyContinue
+ Remove-Item Env:PSIGN_PWSH_TEST_CONFIGURATION -ErrorAction SilentlyContinue
}
diff --git a/PowerShell/tests/PortableSignature.LegacySmoke.Tests.ps1 b/PowerShell/tests/PortableSignature.LegacySmoke.Tests.ps1
new file mode 100644
index 0000000..c5da8c5
--- /dev/null
+++ b/PowerShell/tests/PortableSignature.LegacySmoke.Tests.ps1
@@ -0,0 +1,12 @@
+Describe 'Portable PowerShell module legacy smoke suite' {
+ It 'passes the pre-existing end-to-end smoke checks' {
+ $configuration = if ($env:PSIGN_PWSH_TEST_CONFIGURATION) {
+ $env:PSIGN_PWSH_TEST_CONFIGURATION
+ }
+ else {
+ 'Release'
+ }
+
+ & (Join-Path $PSScriptRoot 'PortableSignature.LegacySmoke.ps1') -Configuration $configuration
+ }
+}
diff --git a/PowerShell/tests/PortableSignature.LegacySmoke.ps1 b/PowerShell/tests/PortableSignature.LegacySmoke.ps1
new file mode 100644
index 0000000..9fe7e5c
--- /dev/null
+++ b/PowerShell/tests/PortableSignature.LegacySmoke.ps1
@@ -0,0 +1,413 @@
+param(
+ [string] $Configuration = 'Release'
+)
+
+$ErrorActionPreference = 'Stop'
+
+$repo = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
+$buildScript = Join-Path (Join-Path $repo 'PowerShell') 'build.ps1'
+if (-not $env:PSIGN_PWSH_TEST_SKIP_BUILD) {
+ & $buildScript -Configuration $Configuration
+}
+
+$modulePath = Join-Path (Join-Path (Join-Path $repo 'PowerShell') 'Devolutions.Psign') 'Devolutions.Psign.psd1'
+Import-Module $modulePath -Force
+
+function Assert-SignerCertificate {
+ param(
+ [Parameter(Mandatory)]
+ $Signature,
+ [Parameter(Mandatory)]
+ [System.Security.Cryptography.X509Certificates.X509Certificate2] $ExpectedCertificate,
+ [Parameter(Mandatory)]
+ [string] $Label
+ )
+
+ if ($null -eq $Signature.SignerCertificate) {
+ throw "Expected SignerCertificate for $Label."
+ }
+ if ($Signature.SignerCertificate.Thumbprint -ne $ExpectedCertificate.Thumbprint) {
+ throw "Unexpected SignerCertificate thumbprint for $Label."
+ }
+ if ($Signature.EmbeddedCertificateCount -lt 1) {
+ throw "Expected EmbeddedCertificateCount for $Label."
+ }
+}
+
+function Start-PsignTimestampServer {
+ $psi = [System.Diagnostics.ProcessStartInfo]::new()
+ $psi.FileName = 'cargo'
+ foreach ($argument in @('run', '--quiet', '--bin', 'psign-server', '--', 'timestamp-server', '--max-requests', '1')) {
+ $psi.ArgumentList.Add($argument)
+ }
+ $psi.WorkingDirectory = $repo
+ $psi.RedirectStandardOutput = $true
+ $psi.UseShellExecute = $false
+ $psi.CreateNoWindow = $true
+ $process = [System.Diagnostics.Process]::Start($psi)
+ $line = $process.StandardOutput.ReadLine()
+ if ($line -notlike 'psign-server timestamp-server listening on *') {
+ try {
+ if (-not $process.HasExited) {
+ $process.Kill($true)
+ }
+ }
+ catch {
+ }
+ throw "Failed to start psign timestamp server. First output: $line"
+ }
+ [pscustomobject]@{
+ Process = $process
+ Url = $line.Substring('psign-server timestamp-server listening on '.Length)
+ }
+}
+
+$temp = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N'))
+New-Item -ItemType Directory -Force -Path $temp | Out-Null
+try {
+ $rsa = [System.Security.Cryptography.RSA]::Create(2048)
+ $rootRsa = [System.Security.Cryptography.RSA]::Create(2048)
+ $rootRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new(
+ 'CN=psign portable root',
+ $rootRsa,
+ [System.Security.Cryptography.HashAlgorithmName]::SHA256,
+ [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
+ $rootRequest.CertificateExtensions.Add(
+ [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($true, $false, 0, $true))
+ $rootRequest.CertificateExtensions.Add(
+ [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new(
+ [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyCertSign -bor
+ [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::CrlSign,
+ $true))
+ $rootCert = $rootRequest.CreateSelfSigned(
+ [System.DateTimeOffset]::UtcNow.AddDays(-1),
+ [System.DateTimeOffset]::UtcNow.AddDays(31))
+
+ $request = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new(
+ 'CN=psign portable test',
+ $rsa,
+ [System.Security.Cryptography.HashAlgorithmName]::SHA256,
+ [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
+ $request.CertificateExtensions.Add(
+ [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($false, $false, 0, $true))
+ $request.CertificateExtensions.Add(
+ [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new(
+ [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature,
+ $true))
+ $ekuOids = [System.Security.Cryptography.OidCollection]::new()
+ $null = $ekuOids.Add([System.Security.Cryptography.Oid]::new('1.3.6.1.5.5.7.3.3'))
+ $request.CertificateExtensions.Add(
+ [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($ekuOids, $false))
+ $issuedCert = $request.Create(
+ $rootCert,
+ [System.DateTimeOffset]::UtcNow.AddDays(-1),
+ [System.DateTimeOffset]::UtcNow.AddDays(30),
+ [byte[]](1, 2, 3, 4, 5, 6, 7, 8))
+ $cert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::CopyWithPrivateKey($issuedCert, $rsa)
+ $certPath = Join-Path $temp 'signer.cer'
+ $keyPath = Join-Path $temp 'signer.key'
+ $pfxPath = Join-Path $temp 'signer.pfx'
+ $pfxPassword = ConvertTo-SecureString -String 'portable-test' -AsPlainText -Force
+ [System.IO.File]::WriteAllBytes($certPath, $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert))
+ [System.IO.File]::WriteAllText(
+ $keyPath,
+ [System.Security.Cryptography.PemEncoding]::WriteString('PRIVATE KEY', $rsa.ExportPkcs8PrivateKey()))
+ [System.IO.File]::WriteAllBytes($pfxPath, $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pkcs12, 'portable-test'))
+ $storeDir = Join-Path $temp 'cert-store'
+ $storeMyDir = Join-Path (Join-Path $storeDir 'CurrentUser') 'MY'
+ New-Item -ItemType Directory -Force -Path $storeMyDir | Out-Null
+ $storeCertPath = Join-Path $storeMyDir "$($cert.Thumbprint.ToUpperInvariant()).der"
+ $storeKeyPath = Join-Path $storeMyDir "$($cert.Thumbprint.ToUpperInvariant()).key"
+ Copy-Item -LiteralPath $certPath -Destination $storeCertPath
+ Copy-Item -LiteralPath $keyPath -Destination $storeKeyPath
+ $chainRsa = [System.Security.Cryptography.RSA]::Create(2048)
+ $chainRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new(
+ 'CN=psign portable chain test',
+ $chainRsa,
+ [System.Security.Cryptography.HashAlgorithmName]::SHA256,
+ [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
+ $chainCert = $chainRequest.CreateSelfSigned(
+ [System.DateTimeOffset]::UtcNow.AddDays(-1),
+ [System.DateTimeOffset]::UtcNow.AddDays(30))
+ $chainCertPath = Join-Path $temp 'chain.cer'
+ [System.IO.File]::WriteAllBytes($chainCertPath, $chainCert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert))
+
+ $unsigned = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $repo 'tests') 'fixtures') 'generated-unsigned') 'pe') 'tiny32-pe-alias.exe'
+ $work = Join-Path $temp 'tiny.exe'
+ Copy-Item $unsigned $work
+
+ if (-not (Get-Command Get-PortableSignature -ErrorAction SilentlyContinue)) {
+ throw 'Get-PortableSignature was not exported.'
+ }
+ if (-not (Get-Command Set-PortableSignature -ErrorAction SilentlyContinue)) {
+ throw 'Set-PortableSignature was not exported.'
+ }
+ $getParameters = (Get-Command Get-PortableSignature).Parameters
+ foreach ($parameterName in @('FilePath', 'LiteralPath', 'SourcePathOrExtension', 'Content', 'TrustedCertificate', 'TrustedCertificatePath', 'AnchorDirectory', 'AuthRootCab', 'AsOf', 'PreferTimestampSigningTime', 'RequireValidTimestamp', 'OnlineAia', 'OnlineOcsp', 'RevocationMode')) {
+ if (-not $getParameters.ContainsKey($parameterName)) {
+ throw "Get-PortableSignature is missing expected migration parameter '$parameterName'."
+ }
+ }
+ $setParameters = (Get-Command Set-PortableSignature).Parameters
+ foreach ($parameterName in @('FilePath', 'LiteralPath', 'SourcePathOrExtension', 'Content', 'Certificate', 'CertificatePath', 'PrivateKeyPath', 'PfxPath', 'Password', 'Thumbprint', 'CertStoreDirectory', 'StoreName', 'MachineStore', 'IncludeChain', 'ChainCertificatePath', 'TimestampServer', 'TimestampHashAlgorithm', 'HashAlgorithm', 'OutputPath', 'Force')) {
+ if (-not $setParameters.ContainsKey($parameterName)) {
+ throw "Set-PortableSignature is missing expected migration parameter '$parameterName'."
+ }
+ }
+
+ $before = Get-PortableSignature -LiteralPath $work
+ if ($before.Status -ne 'NotSigned') {
+ throw "Expected NotSigned before signing, got $($before.Status)."
+ }
+
+ $signed = Set-PortableSignature -LiteralPath $work -CertificatePath $certPath -PrivateKeyPath $keyPath
+ if ($signed.Status -ne 'Valid') {
+ throw "Expected Valid after signing, got $($signed.Status): $($signed.StatusMessage)"
+ }
+ Assert-SignerCertificate -Signature $signed -ExpectedCertificate $cert -Label 'PE signing response'
+
+ $after = Get-PortableSignature -LiteralPath $work
+ if ($after.Status -ne 'Valid') {
+ throw "Expected Valid from Get-PortableSignature after signing, got $($after.Status)."
+ }
+ Assert-SignerCertificate -Signature $after -ExpectedCertificate $cert -Label 'PE get response'
+
+ $trustedAfter = Get-PortableSignature -LiteralPath $work -TrustedCertificate $rootCert -AsOf ([System.DateTime]::UtcNow) -RevocationMode Off
+ if ($trustedAfter.Status -ne 'Valid' -or $trustedAfter.TrustStatus -ne 'Valid') {
+ throw "Expected explicit trust verification to succeed for signed PE, got status=$($trustedAfter.Status) trust=$($trustedAfter.TrustStatus): $($trustedAfter.StatusMessage)"
+ }
+ $untrustedAfter = Get-PortableSignature -LiteralPath $work -TrustedCertificate $chainCert
+ if ($untrustedAfter.Status -ne 'NotTrusted' -or $untrustedAfter.TrustStatus -ne 'NotTrusted') {
+ throw "Expected explicit trust verification to fail with wrong anchor, got status=$($untrustedAfter.Status) trust=$($untrustedAfter.TrustStatus): $($untrustedAfter.StatusMessage)"
+ }
+
+ $length = (Get-Item -LiteralPath $work).Length
+ Set-PortableSignature -LiteralPath $work -CertificatePath $certPath -PrivateKeyPath $keyPath -WhatIf | Out-Null
+ if ((Get-Item -LiteralPath $work).Length -ne $length) {
+ throw 'Set-PortableSignature -WhatIf mutated the file.'
+ }
+
+ $readOnlyWork = Join-Path $temp 'tiny-readonly.exe'
+ Copy-Item $unsigned $readOnlyWork
+ Set-ItemProperty -LiteralPath $readOnlyWork -Name IsReadOnly -Value $true
+ try {
+ $failedWithoutForce = $false
+ try {
+ Set-PortableSignature -LiteralPath $readOnlyWork -CertificatePath $certPath -PrivateKeyPath $keyPath -ErrorAction Stop | Out-Null
+ }
+ catch {
+ $failedWithoutForce = $true
+ }
+ if (-not $failedWithoutForce) {
+ throw 'Expected Set-PortableSignature to fail on a read-only file without -Force.'
+ }
+ $forceSigned = Set-PortableSignature -LiteralPath $readOnlyWork -CertificatePath $certPath -PrivateKeyPath $keyPath -Force
+ if ($forceSigned.Status -ne 'Valid') {
+ throw "Expected Valid after read-only file signing with -Force, got $($forceSigned.Status): $($forceSigned.StatusMessage)"
+ }
+ if (-not (Get-Item -LiteralPath $readOnlyWork).IsReadOnly) {
+ throw 'Expected Set-PortableSignature -Force to restore the read-only attribute.'
+ }
+ }
+ finally {
+ Set-ItemProperty -LiteralPath $readOnlyWork -Name IsReadOnly -Value $false -ErrorAction SilentlyContinue
+ }
+
+ $storeWork = Join-Path $temp 'tiny-store.exe'
+ Copy-Item $unsigned $storeWork
+ $storeSigned = Set-PortableSignature -LiteralPath $storeWork -Sha1 $cert.Thumbprint -CertStoreDirectory $storeDir
+ if ($storeSigned.Status -ne 'Valid') {
+ throw "Expected Valid after portable cert-store signing, got $($storeSigned.Status): $($storeSigned.StatusMessage)"
+ }
+ Assert-SignerCertificate -Signature $storeSigned -ExpectedCertificate $cert -Label 'portable cert-store signing response'
+
+ $chainWork = Join-Path $temp 'tiny-chain.exe'
+ Copy-Item $unsigned $chainWork
+ $defaultChainWork = Join-Path $temp 'tiny-chain-default.exe'
+ Copy-Item $unsigned $defaultChainWork
+ $defaultChainSigned = Set-PortableSignature -LiteralPath $defaultChainWork -CertificatePath $certPath -PrivateKeyPath $keyPath -ChainCertificatePath $chainCertPath
+ if ($defaultChainSigned.EmbeddedCertificateCount -ne 1) {
+ throw "Expected default IncludeChain NotRoot to exclude a self-signed root certificate, got $($defaultChainSigned.EmbeddedCertificateCount) embedded certificates."
+ }
+ $chainSigned = Set-PortableSignature -LiteralPath $chainWork -CertificatePath $certPath -PrivateKeyPath $keyPath -IncludeChain All -ChainCertificatePath $chainCertPath
+ if ($chainSigned.EmbeddedCertificateCount -lt 2) {
+ throw "Expected IncludeChain All with ChainCertificatePath to embed at least 2 certificates, got $($chainSigned.EmbeddedCertificateCount)."
+ }
+
+ $unsignedCab = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $repo 'tests') 'fixtures') 'generated-unsigned') 'cab') 'sample.cab'
+ $cabWork = Join-Path $temp 'sample.cab'
+ Copy-Item $unsignedCab $cabWork
+ $cabSigned = Set-PortableSignature -LiteralPath $cabWork -CertificatePath $certPath -PrivateKeyPath $keyPath
+ if ($cabSigned.Status -ne 'Valid') {
+ throw "Expected Valid after CAB signing, got $($cabSigned.Status): $($cabSigned.StatusMessage)"
+ }
+ Assert-SignerCertificate -Signature $cabSigned -ExpectedCertificate $cert -Label 'CAB signing response'
+ $cabAfter = Get-PortableSignature -LiteralPath $cabWork
+ if ($cabAfter.Status -ne 'Valid') {
+ throw "Expected Valid from Get-PortableSignature for signed CAB, got $($cabAfter.Status): $($cabAfter.StatusMessage)"
+ }
+
+ $unsignedMsi = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $repo 'tests') 'fixtures') 'generated-unsigned') 'installer') 'tiny.msi'
+ $msiWork = Join-Path $temp 'tiny.msi'
+ Copy-Item $unsignedMsi $msiWork
+ $msiSigned = Set-PortableSignature -LiteralPath $msiWork -CertificatePath $certPath -PrivateKeyPath $keyPath
+ if ($msiSigned.Status -ne 'Valid') {
+ throw "Expected Valid after MSI signing, got $($msiSigned.Status): $($msiSigned.StatusMessage)"
+ }
+ Assert-SignerCertificate -Signature $msiSigned -ExpectedCertificate $cert -Label 'MSI signing response'
+ $msiAfter = Get-PortableSignature -LiteralPath $msiWork
+ if ($msiAfter.Status -ne 'Valid') {
+ throw "Expected Valid from Get-PortableSignature for signed MSI, got $($msiAfter.Status): $($msiAfter.StatusMessage)"
+ }
+
+ $zipSource = Join-Path $temp 'zip-source'
+ New-Item -ItemType Directory -Force -Path $zipSource | Out-Null
+ Set-Content -LiteralPath (Join-Path $zipSource 'payload.txt') -Value 'portable zip authenticode' -Encoding UTF8
+ $zipWork = Join-Path $temp 'payload.zip'
+ Compress-Archive -LiteralPath (Join-Path $zipSource 'payload.txt') -DestinationPath $zipWork
+ $zipSigned = Set-PortableSignature -LiteralPath $zipWork -CertificatePath $certPath -PrivateKeyPath $keyPath
+ if ($zipSigned.Status -ne 'Valid') {
+ throw "Expected Valid after ZIP signing, got $($zipSigned.Status): $($zipSigned.StatusMessage)"
+ }
+ Assert-SignerCertificate -Signature $zipSigned -ExpectedCertificate $cert -Label 'ZIP signing response'
+ $zipAfter = Get-PortableSignature -LiteralPath $zipWork
+ if ($zipAfter.Status -ne 'Valid') {
+ throw "Expected Valid from Get-PortableSignature for signed ZIP, got $($zipAfter.Status): $($zipAfter.StatusMessage)"
+ }
+
+ $scriptPath = Join-Path $temp 'Invoke-Test.ps1'
+ Set-Content -LiteralPath $scriptPath -Value @'
+param([string] $Name = "portable")
+"Hello $Name"
+'@ -Encoding UTF8
+ $scriptSigned = Set-PortableSignature -LiteralPath $scriptPath -Certificate $cert
+ if ($scriptSigned.Status -ne 'Valid') {
+ throw "Expected Valid for signed PowerShell script, got $($scriptSigned.Status): $($scriptSigned.StatusMessage)"
+ }
+ Assert-SignerCertificate -Signature $scriptSigned -ExpectedCertificate $cert -Label 'script signing response'
+ $scriptAfter = Get-PortableSignature -LiteralPath $scriptPath
+ if ($scriptAfter.Status -ne 'Valid') {
+ throw "Expected Valid from Get-PortableSignature for signed script, got $($scriptAfter.Status)."
+ }
+ $trustedScript = Get-PortableSignature -LiteralPath $scriptPath -TrustedCertificate $rootCert
+ if ($trustedScript.Status -ne 'Valid' -or $trustedScript.TrustStatus -ne 'Valid') {
+ throw "Expected explicit trust verification to succeed for signed script, got status=$($trustedScript.Status) trust=$($trustedScript.TrustStatus): $($trustedScript.StatusMessage)"
+ }
+ Add-Content -LiteralPath $scriptPath -Value '# tamper'
+ $scriptTampered = Get-PortableSignature -LiteralPath $scriptPath
+ if ($scriptTampered.Status -ne 'HashMismatch') {
+ throw "Expected HashMismatch for tampered signed script, got $($scriptTampered.Status): $($scriptTampered.StatusMessage)"
+ }
+
+ $ps1xmlPath = Join-Path $temp 'Types.ps1xml'
+ Set-Content -LiteralPath $ps1xmlPath -Value @'
+
+
+ Portable.Type
+
+
+'@ -Encoding UTF8
+ $ps1xmlSigned = Set-PortableSignature -LiteralPath $ps1xmlPath -Certificate $cert
+ if ($ps1xmlSigned.Status -ne 'Valid') {
+ throw "Expected Valid for signed ps1xml, got $($ps1xmlSigned.Status): $($ps1xmlSigned.StatusMessage)"
+ }
+ Assert-SignerCertificate -Signature $ps1xmlSigned -ExpectedCertificate $cert -Label 'ps1xml signing response'
+ $ps1xmlText = Get-Content -LiteralPath $ps1xmlPath -Raw
+ if ($ps1xmlText -notmatch '') {
+ throw 'Expected signed ps1xml to use XML Authenticode signature markers.'
+ }
+ $ps1xmlAfter = Get-PortableSignature -LiteralPath $ps1xmlPath
+ if ($ps1xmlAfter.Status -ne 'Valid') {
+ throw "Expected Valid from Get-PortableSignature for signed ps1xml, got $($ps1xmlAfter.Status): $($ps1xmlAfter.StatusMessage)"
+ }
+ Add-Content -LiteralPath $ps1xmlPath -Value ''
+ $ps1xmlTampered = Get-PortableSignature -LiteralPath $ps1xmlPath
+ if ($ps1xmlTampered.Status -ne 'HashMismatch') {
+ throw "Expected HashMismatch for tampered signed ps1xml, got $($ps1xmlTampered.Status): $($ps1xmlTampered.StatusMessage)"
+ }
+
+ $scriptContent = [System.Text.Encoding]::UTF8.GetBytes("'content mode'")
+ $contentSigned = Set-PortableSignature -SourcePathOrExtension '.ps1' -Content $scriptContent -Certificate $cert
+ if ($contentSigned.Status -ne 'Valid') {
+ throw "Expected Valid for signed PowerShell script content, got $($contentSigned.Status): $($contentSigned.StatusMessage)"
+ }
+ if ($null -eq $contentSigned.Content -or $contentSigned.Content.Length -le $scriptContent.Length) {
+ throw 'Expected Set-PortableSignature -Content to return signed content bytes.'
+ }
+ Assert-SignerCertificate -Signature $contentSigned -ExpectedCertificate $cert -Label 'script content signing response'
+ $contentAfter = Get-PortableSignature -SourcePathOrExtension '.ps1' -Content $contentSigned.Content
+ if ($contentAfter.Status -ne 'Valid') {
+ throw "Expected Valid from Get-PortableSignature -Content for signed script, got $($contentAfter.Status): $($contentAfter.StatusMessage)"
+ }
+
+ $timestampServer = Start-PsignTimestampServer
+ try {
+ $timestampScript = Join-Path $temp 'Timestamped.ps1'
+ Set-Content -LiteralPath $timestampScript -Value '"timestamped"' -Encoding UTF8
+ $timestamped = Set-PortableSignature -LiteralPath $timestampScript -Certificate $cert -TimestampServer $timestampServer.Url -TimestampHashAlgorithm Sha256
+ if ($timestamped.Status -ne 'Valid') {
+ throw "Expected Valid for timestamped script, got $($timestamped.Status): $($timestamped.StatusMessage)"
+ }
+ if ($timestamped.TimestampKinds.Count -eq 0) {
+ throw 'Expected timestamped script to report a timestamp kind.'
+ }
+ if ($null -eq $timestamped.TimeStamperCertificate) {
+ throw 'Expected timestamped script to expose TimeStamperCertificate.'
+ }
+ if (-not $timestamped.PSObject.Properties.Match('TimestampSigningTime')) {
+ throw 'Expected timestamped script output to include TimestampSigningTime.'
+ }
+ }
+ finally {
+ if (-not $timestampServer.Process.HasExited) {
+ $timestampServer.Process.Kill($true)
+ }
+ $timestampServer.Process.Dispose()
+ }
+
+ $moduleDir = Join-Path $temp 'PortableModule'
+ $nestedDir = Join-Path $moduleDir 'Private'
+ New-Item -ItemType Directory -Force -Path $nestedDir | Out-Null
+ Set-Content -LiteralPath (Join-Path $moduleDir 'PortableModule.psm1') -Value 'function Get-PortableGreeting { "hello" }' -Encoding UTF8
+ Set-Content -LiteralPath (Join-Path $moduleDir 'PortableModule.psd1') -Value "@{ RootModule = 'PortableModule.psm1'; ModuleVersion = '1.0.0'; GUID = '$([System.Guid]::NewGuid())' }" -Encoding UTF8
+ Set-Content -LiteralPath (Join-Path $moduleDir 'PortableModule.Types.ps1xml') -Value '' -Encoding UTF8
+ Set-Content -LiteralPath (Join-Path $nestedDir 'Helper.ps1') -Value '$script:PortableHelper = $true' -Encoding UTF8
+ $moduleSigned = @(Set-PortableSignature -LiteralPath $moduleDir -CertificatePath $certPath -PrivateKeyPath $keyPath)
+ if ($moduleSigned.Count -ne 4) {
+ throw "Expected 4 signed PowerShell module files, got $($moduleSigned.Count)."
+ }
+ if (@($moduleSigned | Where-Object Status -ne 'Valid').Count -ne 0) {
+ throw "Expected all signed module files to be Valid, got: $($moduleSigned | ConvertTo-Json -Depth 4)"
+ }
+ foreach ($moduleSignature in $moduleSigned) {
+ Assert-SignerCertificate -Signature $moduleSignature -ExpectedCertificate $cert -Label "module signing response $($moduleSignature.Path)"
+ }
+ $moduleValidated = @(Get-PortableSignature -LiteralPath $moduleDir)
+ if ($moduleValidated.Count -ne 4) {
+ throw "Expected 4 validated PowerShell module files, got $($moduleValidated.Count)."
+ }
+ if (@($moduleValidated | Where-Object Status -ne 'Valid').Count -ne 0) {
+ throw "Expected all validated module files to be Valid, got: $($moduleValidated | ConvertTo-Json -Depth 4)"
+ }
+
+ $unsignedMsix = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $repo 'tests') 'fixtures') 'generated-unsigned') 'msix') 'sample.msix'
+ $msixWork = Join-Path $temp 'sample.msix'
+ Copy-Item $unsignedMsix $msixWork
+ $msixBefore = Get-PortableSignature -LiteralPath $msixWork
+ if ($msixBefore.Status -notin @('NotSigned', 'Incompatible')) {
+ throw "Expected unsigned MSIX preflight status before signing, got $($msixBefore.Status)."
+ }
+ $msixSigned = Set-PortableSignature -LiteralPath $msixWork -PfxPath $pfxPath -Password $pfxPassword
+ if ($msixSigned.Status -ne 'Valid') {
+ throw "Expected Valid after MSIX signing, got $($msixSigned.Status): $($msixSigned.StatusMessage)"
+ }
+ Assert-SignerCertificate -Signature $msixSigned -ExpectedCertificate $cert -Label 'MSIX signing response'
+ $msixAfter = Get-PortableSignature -LiteralPath $msixWork
+ if ($msixAfter.Status -ne 'Valid') {
+ throw "Expected Valid from Get-PortableSignature for signed MSIX, got $($msixAfter.Status): $($msixAfter.StatusMessage)"
+ }
+}
+finally {
+ Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue
+ Remove-Module Devolutions.Psign -Force -ErrorAction SilentlyContinue
+}
diff --git a/PowerShell/tests/PortableSignature.PackageNative.Tests.ps1 b/PowerShell/tests/PortableSignature.PackageNative.Tests.ps1
new file mode 100644
index 0000000..b7e0558
--- /dev/null
+++ b/PowerShell/tests/PortableSignature.PackageNative.Tests.ps1
@@ -0,0 +1,285 @@
+Set-StrictMode -Version Latest
+
+function script:Ensure-PortableSignatureModule {
+ if (-not (Get-Command Set-PortableSignature -ErrorAction SilentlyContinue)) {
+ $repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
+ $modulePath = Join-Path (Join-Path $repoRoot 'PowerShell\Devolutions.Psign') 'Devolutions.Psign.psd1'
+ Import-Module $modulePath -Force
+ }
+}
+
+function script:New-PortableSigningMaterial {
+ param(
+ [Parameter(Mandatory)]
+ [string] $BasePath
+ )
+
+ $rsa = [System.Security.Cryptography.RSA]::Create(2048)
+ $rootRsa = [System.Security.Cryptography.RSA]::Create(2048)
+ $rootRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new(
+ 'CN=psign portable root',
+ $rootRsa,
+ [System.Security.Cryptography.HashAlgorithmName]::SHA256,
+ [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
+ $rootRequest.CertificateExtensions.Add(
+ [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($true, $false, 0, $true))
+ $rootRequest.CertificateExtensions.Add(
+ [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new(
+ [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyCertSign -bor
+ [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::CrlSign,
+ $true))
+ $rootCert = $rootRequest.CreateSelfSigned(
+ [System.DateTimeOffset]::UtcNow.AddDays(-1),
+ [System.DateTimeOffset]::UtcNow.AddDays(31))
+
+ $request = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new(
+ 'CN=psign portable test',
+ $rsa,
+ [System.Security.Cryptography.HashAlgorithmName]::SHA256,
+ [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
+ $request.CertificateExtensions.Add(
+ [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($false, $false, 0, $true))
+ $request.CertificateExtensions.Add(
+ [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new(
+ [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature,
+ $true))
+ $ekuOids = [System.Security.Cryptography.OidCollection]::new()
+ $null = $ekuOids.Add([System.Security.Cryptography.Oid]::new('1.3.6.1.5.5.7.3.3'))
+ $request.CertificateExtensions.Add(
+ [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($ekuOids, $false))
+ $issuedCert = $request.Create(
+ $rootCert,
+ [System.DateTimeOffset]::UtcNow.AddDays(-1),
+ [System.DateTimeOffset]::UtcNow.AddDays(30),
+ [byte[]](1, 2, 3, 4, 5, 6, 7, 8))
+ $cert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::CopyWithPrivateKey($issuedCert, $rsa)
+
+ $certPath = Join-Path $BasePath 'signer.cer'
+ $keyPath = Join-Path $BasePath 'signer.key'
+ [System.IO.File]::WriteAllBytes($certPath, $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert))
+ [System.IO.File]::WriteAllText(
+ $keyPath,
+ [System.Security.Cryptography.PemEncoding]::WriteString('PRIVATE KEY', $rsa.ExportPkcs8PrivateKey()))
+
+ [pscustomobject]@{
+ Certificate = $cert
+ RootCertificate = $rootCert
+ CertificatePath = $certPath
+ PrivateKeyPath = $keyPath
+ }
+}
+
+function script:Get-ZipEntryNames {
+ param(
+ [Parameter(Mandatory)]
+ [string] $Path
+ )
+
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
+ $archive = [System.IO.Compression.ZipFile]::OpenRead($Path)
+ try {
+ return @($archive.Entries | ForEach-Object FullName)
+ }
+ finally {
+ $archive.Dispose()
+ }
+}
+
+function script:New-ClickOnceDocument {
+ param(
+ [Parameter(Mandatory)]
+ [string] $Path,
+ [Parameter(Mandatory)]
+ [string] $RootName
+ )
+
+ $xml = @"
+
+<$RootName>
+
+$RootName>
+"@
+ Set-Content -LiteralPath $Path -Value $xml -Encoding UTF8
+}
+
+Describe 'Portable PowerShell package-native coverage' {
+ BeforeAll {
+ Ensure-PortableSignatureModule
+ $script:RepoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
+ $script:ModulePath = Join-Path (Join-Path $script:RepoRoot 'PowerShell\Devolutions.Psign') 'Devolutions.Psign.psd1'
+ $script:TempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N'))
+ New-Item -ItemType Directory -Force -Path $script:TempRoot | Out-Null
+ $script:Signing = New-PortableSigningMaterial -BasePath $script:TempRoot
+ }
+
+ AfterAll {
+ if ($script:TempRoot) {
+ Remove-Item -LiteralPath $script:TempRoot -Recurse -Force -ErrorAction SilentlyContinue
+ }
+ }
+
+ Context 'Set-PortableSignature validation' {
+ It 'requires -AzureKeyVaultCertificate when -AzureKeyVaultUrl is used' {
+ $errorRecord = $null
+ try {
+ Set-PortableSignature -LiteralPath 'placeholder.exe' -AzureKeyVaultUrl 'https://vault.example' -ErrorAction Stop | Out-Null
+ }
+ catch {
+ $errorRecord = $_
+ }
+
+ $errorRecord | Should -Not -BeNullOrEmpty
+ $errorRecord.FullyQualifiedErrorId | Should -Be 'PortableSignatureAkvCertificateRequired,Devolutions.Psign.PowerShell.Cmdlets.SetPortableSignatureCommand'
+ }
+
+ It 'rejects mixed local and Azure Key Vault signing sources' {
+ $errorRecord = $null
+ try {
+ Set-PortableSignature -LiteralPath 'placeholder.exe' `
+ -CertificatePath 'signer.cer' `
+ -PrivateKeyPath 'signer.key' `
+ -AzureKeyVaultUrl 'https://vault.example' `
+ -AzureKeyVaultCertificate 'signer' `
+ -ErrorAction Stop | Out-Null
+ }
+ catch {
+ $errorRecord = $_
+ }
+
+ $errorRecord | Should -Not -BeNullOrEmpty
+ $errorRecord.FullyQualifiedErrorId | Should -Be 'PortableSignatureSigningMaterialRequired,Devolutions.Psign.PowerShell.Cmdlets.SetPortableSignatureCommand'
+ }
+
+ It 'rejects -OutputPath with -Content' {
+ $errorRecord = $null
+ try {
+ Set-PortableSignature -SourcePathOrExtension '.ps1' `
+ -Content ([System.Text.Encoding]::UTF8.GetBytes('"hello"')) `
+ -CertificatePath $script:Signing.CertificatePath `
+ -PrivateKeyPath $script:Signing.PrivateKeyPath `
+ -OutputPath 'out.ps1' `
+ -ErrorAction Stop | Out-Null
+ }
+ catch {
+ $errorRecord = $_
+ }
+
+ $errorRecord | Should -Not -BeNullOrEmpty
+ $errorRecord.FullyQualifiedErrorId | Should -Be 'PortableSignatureContentOutputPathUnsupported,Devolutions.Psign.PowerShell.Cmdlets.SetPortableSignatureCommand'
+ }
+ }
+
+ Context 'Set-PortableSignature package-native formats' {
+ It 'signs and inspects NuGet packages' {
+ $work = Join-Path $script:TempRoot 'sample.nupkg'
+ Copy-Item -LiteralPath (Join-Path $script:RepoRoot 'tests\fixtures\package-signing\unsigned\sample.nupkg') -Destination $work -Force
+
+ $signed = Set-PortableSignature -LiteralPath $work -CertificatePath $script:Signing.CertificatePath -PrivateKeyPath $script:Signing.PrivateKeyPath
+ $signed.Format | Should -Be 'NuGet'
+ $signed.Status | Should -Be 'Valid'
+ (Get-ZipEntryNames -Path $work) | Should -Contain '.signature.p7s'
+
+ $inspected = Get-PortableSignature -LiteralPath $work
+ $inspected.Format | Should -Be 'NuGet'
+ $inspected.Status | Should -Be 'Valid'
+ }
+
+ It 'signs and inspects symbol NuGet packages' {
+ $work = Join-Path $script:TempRoot 'sample.snupkg'
+ Copy-Item -LiteralPath (Join-Path $script:RepoRoot 'tests\fixtures\package-signing\unsigned\sample.snupkg') -Destination $work -Force
+
+ $signed = Set-PortableSignature -LiteralPath $work -CertificatePath $script:Signing.CertificatePath -PrivateKeyPath $script:Signing.PrivateKeyPath
+ $signed.Format | Should -Be 'NuGet'
+ $signed.Status | Should -Be 'Valid'
+ (Get-ZipEntryNames -Path $work) | Should -Contain '.signature.p7s'
+ }
+
+ It 'signs and inspects VSIX packages' {
+ $work = Join-Path $script:TempRoot 'sample.vsix'
+ Copy-Item -LiteralPath (Join-Path $script:RepoRoot 'tests\fixtures\package-signing\unsigned\sample.vsix') -Destination $work -Force
+
+ $signed = Set-PortableSignature -LiteralPath $work -CertificatePath $script:Signing.CertificatePath -PrivateKeyPath $script:Signing.PrivateKeyPath
+ $signed.Format | Should -Be 'Vsix'
+ $signed.Status | Should -Be 'Valid'
+ @(Get-ZipEntryNames -Path $work | Where-Object { $_ -like 'package/services/digital-signature/*' }).Count | Should -BeGreaterThan 0
+
+ $inspected = Get-PortableSignature -LiteralPath $work
+ $inspected.Format | Should -Be 'Vsix'
+ $inspected.Status | Should -Be 'Valid'
+ }
+
+ It 'signs and inspects ClickOnce XML manifests for .manifest, .application, and .vsto' -TestCases @(
+ @{ FileName = 'sample.manifest'; RootName = 'assembly' }
+ @{ FileName = 'sample.application'; RootName = 'deployment' }
+ @{ FileName = 'sample.vsto'; RootName = 'deployment' }
+ ) {
+ param($FileName, $RootName)
+
+ $work = Join-Path $script:TempRoot $FileName
+ New-ClickOnceDocument -Path $work -RootName $RootName
+
+ $signed = Set-PortableSignature -LiteralPath $work -CertificatePath $script:Signing.CertificatePath -PrivateKeyPath $script:Signing.PrivateKeyPath
+ $signed.Format | Should -Be 'ClickOnceManifest'
+ $signed.Status | Should -Be 'Valid'
+ (Get-Content -LiteralPath $work -Raw) | Should -Match ''
+
+ $inspected = Get-PortableSignature -LiteralPath $work
+ $inspected.Format | Should -Be 'ClickOnceManifest'
+ $inspected.Status | Should -Be 'Valid'
+ }
+
+ It 'signs App Installer descriptors and writes the detached companion' {
+ $work = Join-Path $script:TempRoot 'sample.appinstaller'
+ Copy-Item -LiteralPath (Join-Path $script:RepoRoot 'tests\fixtures\generated-unsigned\appinstaller\sample.appinstaller') -Destination $work -Force
+
+ $signed = Set-PortableSignature -LiteralPath $work -CertificatePath $script:Signing.CertificatePath -PrivateKeyPath $script:Signing.PrivateKeyPath
+ $signed.Format | Should -Be 'AppInstaller'
+ $signed.Status | Should -Be 'Valid'
+
+ $companion = "$work.p7"
+ Test-Path -LiteralPath $companion | Should -BeTrue
+
+ $inspected = Get-PortableSignature -LiteralPath $work
+ $inspected.Format | Should -Be 'AppInstaller'
+ $inspected.Status | Should -Be 'Valid'
+ }
+ }
+
+ Context 'Directory recursion covers newly signable PowerShell module neighbors' {
+ It 'recurses into package-native and ClickOnce files in a module tree' {
+ $moduleRoot = Join-Path $script:TempRoot 'PortableModuleWithPackages'
+ $privateRoot = Join-Path $moduleRoot 'Private'
+ New-Item -ItemType Directory -Force -Path $privateRoot | Out-Null
+
+ Set-Content -LiteralPath (Join-Path $moduleRoot 'PortableModule.psm1') -Value 'function Get-PortableGreeting { "hello" }' -Encoding UTF8
+ Set-Content -LiteralPath (Join-Path $moduleRoot 'PortableModule.psd1') -Value "@{ RootModule = 'PortableModule.psm1'; ModuleVersion = '1.0.0'; GUID = '$([System.Guid]::NewGuid())' }" -Encoding UTF8
+ Copy-Item -LiteralPath (Join-Path $script:RepoRoot 'tests\fixtures\package-signing\unsigned\sample.nupkg') -Destination (Join-Path $moduleRoot 'sample.nupkg')
+ Copy-Item -LiteralPath (Join-Path $script:RepoRoot 'tests\fixtures\package-signing\unsigned\sample.snupkg') -Destination (Join-Path $moduleRoot 'sample.snupkg')
+ Copy-Item -LiteralPath (Join-Path $script:RepoRoot 'tests\fixtures\package-signing\unsigned\sample.vsix') -Destination (Join-Path $moduleRoot 'sample.vsix')
+ Copy-Item -LiteralPath (Join-Path $script:RepoRoot 'tests\fixtures\generated-unsigned\appinstaller\sample.appinstaller') -Destination (Join-Path $moduleRoot 'sample.appinstaller')
+ New-ClickOnceDocument -Path (Join-Path $moduleRoot 'sample.manifest') -RootName 'assembly'
+ New-ClickOnceDocument -Path (Join-Path $moduleRoot 'sample.application') -RootName 'deployment'
+ New-ClickOnceDocument -Path (Join-Path $privateRoot 'sample.vsto') -RootName 'deployment'
+ Set-Content -LiteralPath (Join-Path $moduleRoot 'ignored.txt') -Value 'ignore me' -Encoding UTF8
+
+ $signed = @(Set-PortableSignature -LiteralPath $moduleRoot -CertificatePath $script:Signing.CertificatePath -PrivateKeyPath $script:Signing.PrivateKeyPath)
+ $formats = @($signed | ForEach-Object Format)
+ $formats | Should -Contain 'PowerShellScript'
+ $formats | Should -Contain 'NuGet'
+ $formats | Should -Contain 'Vsix'
+ $formats | Should -Contain 'ClickOnceManifest'
+ $formats | Should -Contain 'AppInstaller'
+
+ $signedPaths = @($signed | ForEach-Object Path)
+ $signedPaths | Should -Not -Contain (Join-Path $moduleRoot 'ignored.txt')
+ Test-Path -LiteralPath (Join-Path $moduleRoot 'sample.appinstaller.p7') | Should -BeTrue
+
+ $validated = @(Get-PortableSignature -LiteralPath $moduleRoot)
+ @($validated | Where-Object Status -ne 'Valid').Count | Should -Be 0
+ @($validated | ForEach-Object Format) | Should -Contain 'NuGet'
+ @($validated | ForEach-Object Format) | Should -Contain 'Vsix'
+ @($validated | ForEach-Object Format) | Should -Contain 'ClickOnceManifest'
+ @($validated | ForEach-Object Format) | Should -Contain 'AppInstaller'
+ }
+ }
+}
diff --git a/README.md b/README.md
index d970292..b6dc30d 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@ Canonical repository: .
- `timestamp`: Rust mssign32 core (`SignerTimeStampEx3`/`SignerTimeStampEx2`) plus AppX restrictions.
- `rdp`: Rust port of **`rdpsign.exe`** for `.rdp` files (`SignScope` / `Signature` records, detached PKCS#7 over the secure-settings blob).
- `cert-store`: Portable file-backed certificate store under `~/.psign/cert-store` by default, with Windows-style store/thumbprint selection.
+- `code`: dotnet/sign-style orchestration entry point. It supports `--dry-run` / `--plan-json` planning over inputs, file lists, globs, and nested ZIP/OPC containers, plus guarded local cert/key, PFX, portable cert-store SHA-1, Azure Key Vault, or Artifact Signing execution for PE/WinMD, NuGet/SNuGet, VSIX, generic ZIP nested package entries, MSIX/AppX unsigned-package prepare including nested packages inside upload/bundle containers, encrypted MSIX/AppX OS-only diagnostics, ClickOnce `.manifest` / `.application` / `.vsto` XMLDSig signing, PE-like ClickOnce `.deploy` payloads, App Installer publisher updates + top-level or nested companion signatures, `--continue-on-error`, `--skip-signed`, `--overwrite`, and inside-out VSIX/ZIP -> NuGet/VSIX -> PE/ClickOnce-manifest signing.
- `portable ...`: Cross-platform digest, verification, trust, signing, package, RFC3161, and remote-hash helpers that avoid Win32 APIs, including PE/WinMD signing through Azure Artifact Signing REST without Microsoft client DLLs.
## MSIX parity notes
@@ -65,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.2.0 -ArtifactsRoot ./dist -OutputDir ./dist/nuget
+pwsh ./nuget/pack-psign-dotnet-tool.ps1 -Version 0.3.0 -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.
@@ -99,7 +100,57 @@ cargo build -p psign --bin psign-tool --locked
# Portable package inspection helpers:
# psign-tool portable nupkg-signature-info package.nupkg
# psign-tool portable nupkg-digest package.nupkg --algorithm sha256
+# psign-tool portable nupkg-signature-content package.nupkg --output signature-content.txt
+# psign-tool portable nupkg-signature-pkcs7 package.nupkg --cert signer.der --key signer.pkcs8 --timestamp-url http://tsa --timestamp-digest sha256 --output signature.p7s
+# psign-tool portable nupkg-signature-pkcs7-prehash package.nupkg --encoding raw --output prehash.bin
+# psign-tool portable nupkg-signature-pkcs7-from-signature package.nupkg --cert signer.der --signature remote.sig --output signature.p7s
+# psign-tool portable nupkg-verify-signature-content package.nupkg --content signature-content.txt
+# psign-tool portable nupkg-embed-signature package.nupkg --signature signature.p7s --output signed.nupkg
+# psign-tool portable nupkg-sign package.nupkg --cert signer.der --key signer.pkcs8 --timestamp-url http://tsa --timestamp-digest sha256 --output signed.nupkg
+# psign-tool portable nupkg-verify-signature signed.nupkg --trusted-ca signer.der --allow-loose-signing-cert
# psign-tool portable vsix-signature-info extension.vsix
+# psign-tool portable vsix-signature-reference-xml extension.vsix --output signature-reference.xml
+# psign-tool portable vsix-verify-signature-reference-xml extension.vsix --signature-xml signature-reference.xml
+# psign-tool portable vsix-signature-xml extension.vsix --cert signer.der --key signer.pkcs8 --output signature.xml
+# psign-tool portable vsix-signature-xml-prehash extension.vsix --encoding raw --output prehash.bin
+# psign-tool portable vsix-signature-xml-from-signature extension.vsix --cert signer.der --signature remote.sig --output signature.xml
+# psign-tool portable vsix-verify-signature-xml extension.vsix --signature-xml signature.xml --cert signer.der --trusted-ca root.der
+# psign-tool portable vsix-embed-signature-xml extension.vsix --signature-xml signature.xml --output signed.vsix
+# psign-tool portable vsix-sign extension.vsix --cert signer.der --key signer.pkcs8 --output signed.vsix
+# psign-tool portable vsix-verify-signature signed.vsix --trusted-ca root.der
+# psign-tool portable appinstaller-info app.appinstaller --signature app.appinstaller.p7
+# psign-tool portable appinstaller-sign-companion app.appinstaller --cert signer.der --key signer.pkcs8 --timestamp-url http://tsa --timestamp-digest sha256 --output app.appinstaller.p7
+# psign-tool portable appinstaller-sign-companion-prehash app.appinstaller --encoding raw --output prehash.bin
+# psign-tool portable appinstaller-sign-companion-from-signature app.appinstaller --cert signer.der --signature remote.sig --output app.appinstaller.p7
+# psign-tool portable appinstaller-verify-companion app.appinstaller --signature app.appinstaller.p7 --anchor-dir anchors
+# psign-tool portable appinstaller-set-publisher app.appinstaller --publisher "CN=Example" --output updated.appinstaller
+# psign-tool portable business-central-app-info package.app
+# psign-tool portable msix-manifest-info package.msix
+# psign-tool portable msix-set-publisher package.msix --publisher "CN=Example" --output updated.msix
+# psign-tool portable clickonce-deploy-info app.exe.deploy
+# psign-tool portable clickonce-copy-deploy-payload app.exe.deploy --output app.exe
+# psign-tool portable clickonce-update-manifest-hashes app.exe.manifest --base-directory . --output updated.manifest
+# psign-tool portable clickonce-manifest-hashes updated.manifest --base-directory .
+# psign-tool portable clickonce-sign-manifest updated.manifest --cert signer.der --key signer.pkcs8 --output signed.manifest
+# psign-tool portable clickonce-sign-manifest-prehash updated.manifest --encoding raw --output prehash.bin
+# psign-tool portable clickonce-sign-manifest-from-signature updated.manifest --cert signer.der --signature remote.sig --output signed.manifest
+# psign-tool portable clickonce-verify-manifest-signature signed.manifest --trusted-ca signer.der
+# dotnet/sign-style dry-run planning for nested package orchestration:
+# psign-tool code --dry-run --plan-json --base-directory . --file-list files.txt
+# Initial guarded code execution for PE/NuGet/VSIX/ZIP/MSIX/ClickOnce/App Installer inputs:
+# psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.exe app.exe
+# psign-tool code --base-directory . --pfx signer.pfx --password "pfx-password" --output signed.nupkg package.nupkg
+# psign-tool code --base-directory . --cert-store-dir ~/.psign/cert-store --sha1 --output signed.nupkg package.nupkg
+# psign-tool code --base-directory . --azure-key-vault-url https://vault.vault.azure.net --azure-key-vault-certificate cert --azure-key-vault-accesstoken "$TOKEN" --output signed.nupkg package.nupkg
+# psign-tool code --base-directory . --artifact-signing-endpoint https://wus2.codesigning.azure.net --artifact-signing-account-name acct --artifact-signing-profile-name profile --artifact-signing-access-token "$TOKEN" --output signed.nupkg package.nupkg
+# psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --timestamp-url http://tsa --timestamp-digest sha256 --output signed.nupkg package.nupkg
+# psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.vsix extension.vsix
+# psign-tool code --base-directory . --overwrite --cert signer.der --key signer.pkcs8 --output resigned.nupkg signed-package.nupkg
+# psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.zip package-bundle.zip
+# psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --publisher-name "CN=Publisher" --output prepared.msix app.msix
+# psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output signed.manifest app.exe.manifest
+# psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --output app.signed.exe.deploy app.exe.deploy
+# psign-tool code --base-directory . --cert signer.der --key signer.pkcs8 --publisher-name "CN=Publisher" --output updated.appinstaller.p7 app.appinstaller
# Optional portable REST helpers (Linux/macOS):
# cargo build -p psign --bin psign-tool --locked --features artifact-signing-rest
# cargo build -p psign --bin psign-tool --locked --features azure-kv-sign
@@ -167,6 +218,13 @@ psign-tool --mode portable sign /sha1 ABCDEF0123456789ABCDEF0123456789ABCDEF01 /
The portable signing path supports local RSA/SHA-2 Authenticode signing for PE/WinMD plus the package/script formats exposed by the portable core. Unsupported native signing options, CSP/KSP selection, auto-selection, and non-exportable local keys return explicit errors in portable mode.
+Cloud-backed signing options also accept Azure.Identity-style selectors:
+`--azure-key-vault-credential-type` and `--artifact-signing-credential-type`
+(`default`, `managed-identity`, `access-token`, `client-secret`,
+`workload-identity`). Managed identity maps to the existing managed-identity
+flows; workload identity is represented in provider planning but explicit
+signing execution is not wired yet.
+
## Generate binary manifest and dependency graph
```powershell
diff --git a/crates/psign-authenticode-trust/Cargo.toml b/crates/psign-authenticode-trust/Cargo.toml
index 63ef21d..dd55db5 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.2.0"
+version = "0.3.0"
edition = "2024"
description = "Portable Authenticode PKCS#7 trust verification (anchors, chain, EKU) using picky-rs"
license.workspace = true
diff --git a/crates/psign-authenticode-trust/src/anchor.rs b/crates/psign-authenticode-trust/src/anchor.rs
index 36ef42d..3660eb9 100644
--- a/crates/psign-authenticode-trust/src/anchor.rs
+++ b/crates/psign-authenticode-trust/src/anchor.rs
@@ -109,7 +109,7 @@ pub fn cert_sha1_thumbprint(cert: &Cert) -> Result<[u8; 20]> {
Ok(out)
}
-fn parse_cert_bytes(raw: &[u8]) -> Result {
+pub fn parse_cert_bytes(raw: &[u8]) -> Result {
let trimmed = raw.trim_ascii_start();
if trimmed.starts_with(b"-----BEGIN ") {
let s =
diff --git a/crates/psign-azure-kv-rest/Cargo.toml b/crates/psign-azure-kv-rest/Cargo.toml
index ec3f273..b2b7513 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.2.0"
+version = "0.3.0"
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 a828320..3a5f1c0 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.2.0"
+version = "0.3.0"
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 f3fa124..6411fe1 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.2.0"
+version = "0.3.0"
edition = "2024"
description = "Linux/macOS-friendly CLI over portable Authenticode SIP digests (psign-sip-digest)"
license.workspace = true
@@ -34,7 +34,9 @@ base64 = { version = "0.22", optional = true }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"], optional = true }
sha1 = "0.10"
sha2 = "0.10"
+rsa = { version = "0.9.10", features = ["sha2"] }
x509-cert = "0.2.5"
+zip = { version = "0.6.6", default-features = false, features = ["deflate"] }
[dev-dependencies]
assert_cmd = "2"
diff --git a/crates/psign-digest-cli/src/main.rs b/crates/psign-digest-cli/src/main.rs
index 6363379..ea548b7 100644
--- a/crates/psign-digest-cli/src/main.rs
+++ b/crates/psign-digest-cli/src/main.rs
@@ -14,6 +14,7 @@ use psign_authenticode_trust::{
trust_verify_cab_bytes, trust_verify_catalog_bytes, trust_verify_detached_bytes,
trust_verify_msi_bytes, trust_verify_pe_bytes, trust_verify_wim_esd_path,
trust_verify_zip_bytes,
+ trust_verify_pe::load_trust_material,
};
#[cfg(feature = "azure-kv-sign-portable")]
use psign_azure_kv_rest::{
@@ -50,11 +51,19 @@ use psign_sip_digest::timestamp::{
use psign_sip_digest::verify_pe;
use psign_sip_digest::verify_script_digest_consistency;
use psign_sip_digest::zip_authenticode;
+use rsa::pkcs8::DecodePublicKey as _;
+use rsa::signature::{SignatureEncoding as _, Signer as _, Verifier as _};
use serde::Deserialize;
use sha1::Sha1;
use sha2::{Digest as _, Sha256, Sha384, Sha512};
use std::ffi::{OsStr, OsString};
+use std::fs::File;
+use std::io::{Read as _, Write as _};
use std::path::{Path, PathBuf};
+use x509_cert::der::{
+ Encode as _,
+ asn1::{ObjectIdentifier, OctetString},
+};
#[derive(Parser)]
#[command(name = "psign-tool")]
@@ -167,6 +176,84 @@ fn trust_verify_options_from_shared(a: &TrustVerifySharedArgs) -> Result bool {
+ a.anchor_dir.is_some()
+ || !a.trusted_ca.is_empty()
+ || a.authroot_cab.is_some()
+ || a.expect_authroot_cab_sha256.is_some()
+ || a.as_of.is_some()
+ || a.online_aia
+ || a.online_ocsp
+ || a.revocation_mode != CliRevocationMode::Off
+ || a.crl_url_override.is_some()
+ || a.aia_url_override.is_some()
+ || a.ocsp_url_override.is_some()
+}
+
+fn verify_xml_signer_certificate_trust(cert_der: &[u8], shared: &TrustVerifySharedArgs) -> Result {
+ let opts = trust_verify_options_from_shared(shared)?;
+ let (anchors, anchor_certs) = load_trust_material(&opts)?;
+ let leaf = psign_authenticode_trust::anchor::parse_cert_bytes(cert_der)
+ .context("parse XMLDSig signer certificate for trust verification")?;
+ let mut merged =
+ psign_authenticode_trust::chain::merge_unique_certs(vec![leaf.clone()], anchor_certs)?;
+ let chain_owned = psign_authenticode_trust::chain::issuer_chain_excluding_leaf_online(
+ &leaf,
+ &mut merged,
+ &opts.online,
+ )?;
+ let root = psign_authenticode_trust::chain::terminal_root_cert_owned(&leaf, &chain_owned);
+ let root_thumb = psign_authenticode_trust::anchor::cert_sha1_thumbprint(root)?;
+ if !anchors.contains_thumbprint(&root_thumb) {
+ return Err(anyhow!(
+ "XMLDSig terminal root certificate is not in the anchor store (SHA-1 thumbprint {:02x}{:02x}...)",
+ root_thumb[0],
+ root_thumb[1]
+ ));
+ }
+ psign_authenticode_trust::online::check_revocation_chain(&leaf, &chain_owned, &opts.online)?;
+
+ let verification_instant = match opts.verification_instant_override.as_ref() {
+ Some(instant) => instant.clone(),
+ None if opts.policy.prefer_timestamp_signing_time && opts.policy.require_valid_timestamp => {
+ return Err(anyhow!(
+ "VSIX XMLDSig timestamp trust verification is not implemented; use --as-of for deterministic certificate-chain validation"
+ ));
+ }
+ None => psign_authenticode_trust::verification_instant::resolve_verification_utc_date(
+ b"",
+ &opts.policy,
+ )?,
+ };
+
+ let chain_refs: Vec<_> = chain_owned.iter().collect();
+ let leaf_verifier = leaf.verifier();
+ let verifier = leaf_verifier
+ .chain(chain_refs.iter().copied())
+ .exact_date(&verification_instant);
+ verifier
+ .verify()
+ .map_err(|e| anyhow!("XMLDSig certificate chain verification: {e}"))?;
+
+ if opts.verbose_chain {
+ let thumb_hex: String = root_thumb.iter().map(|b| format!("{b:02x}")).collect();
+ eprintln!("xml-trust: leaf subject: {}", leaf.subject_name());
+ for (i, cert) in chain_refs.iter().enumerate() {
+ eprintln!(
+ "xml-trust: chain[{i}] subject: {} issuer: {}",
+ cert.subject_name(),
+ cert.issuer_name()
+ );
+ }
+ eprintln!(
+ "xml-trust: root subject: {} (thumb SHA-1 {thumb_hex})",
+ root.subject_name()
+ );
+ }
+
+ Ok(anchors.thumbprint_count())
+}
+
fn digest_byte_len_for_hash_alg(alg: HashAlg) -> usize {
match alg {
HashAlg::Sha1 => 20,
@@ -283,512 +370,2033 @@ fn run_rfc3161_timestamp_req(
Ok(())
}
-fn run_rfc3161_timestamp_resp_inspect(
- path: &Path,
- expect_digest_hex: Option<&str>,
- expect_nonce: Option,
-) -> Result<()> {
- let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
- let expected_digest = expect_digest_hex
- .map(normalize_even_hex)
- .transpose()
- .context("parse --expect-digest-hex")?;
- let expected_nonce = expect_nonce.map(rfc3161_nonce_hex);
- let p = parse_time_stamp_resp_der(&bytes).ok_or_else(|| {
- anyhow!("could not parse TimeStampResp DER (definite ASN.1 subset or trailing garbage)")
- })?;
- let tok_len = p.time_stamp_token.map(|t| t.len()).unwrap_or(0);
- println!(
- "pki_status={} pki_status_int={} granted={} time_stamp_token_len={}",
- pki_status_label(p.pki_status),
- p.pki_status.as_raw_integer(),
- if p.pki_status.granted() { "yes" } else { "no" },
- tok_len
- );
- println!(
- "time_stamp_token_prefix_hex={}",
- time_stamp_token_prefix_hex(p.time_stamp_token)
- );
- println!(
- "status_strings_json={}",
- serde_json::to_string(&p.status_strings).context("encode PKIStatusInfo.statusString")?
- );
- match p.fail_info_tlv {
- Some(fi) => println!("fail_info_tlv_hex={}", hex_lower(fi)),
- None => println!("fail_info_tlv_hex=-"),
- }
- let flags_json = match p.fail_info_tlv {
- None => serde_json::Value::Array(vec![]),
- Some(fi) => match pkifailure_info_flag_labels_from_bit_string_tlv(fi) {
- Some(labels) => serde_json::to_value(&labels).context("encode failInfo flags")?,
- None => serde_json::Value::Null,
- },
- };
- println!("fail_info_flags_json={flags_json}");
- if let Some(tst) = p.time_stamp_token.and_then(parse_time_stamp_token_tst_info) {
- println!("tst_info_present=yes");
- println!("tst_info_policy_oid={}", tst.policy_oid);
- println!(
- "tst_info_message_imprint_digest_alg_oid={}",
- tst.message_imprint_digest_alg_oid
- );
- println!(
- "tst_info_message_imprint_hashed_message_hex={}",
- hex_lower(&tst.message_imprint_hashed_message)
- );
- println!("tst_info_serial_hex={}", tst.serial_number_hex);
- println!("tst_info_gen_time={}", tst.gen_time);
- println!(
- "tst_info_nonce_hex={}",
- tst.nonce_hex.as_deref().unwrap_or("-")
- );
- if let Some(expected) = expected_digest.as_deref() {
- println!(
- "tst_info_message_imprint_match={}",
- if hex_lower(&tst.message_imprint_hashed_message) == expected {
- "yes"
- } else {
- "no"
- }
- );
- }
- if let Some(expected) = expected_nonce.as_deref() {
- println!(
- "tst_info_nonce_match={}",
- if tst.nonce_hex.as_deref() == Some(expected) {
- "yes"
- } else {
- "no"
- }
- );
- }
- } else {
- println!("tst_info_present=no");
- if expected_digest.is_some() {
- println!("tst_info_message_imprint_match=no");
- }
- if expected_nonce.is_some() {
- println!("tst_info_nonce_match=no");
+#[derive(Debug, Eq, PartialEq)]
+struct AppInstallerDescriptorInfo {
+ root: &'static str,
+ namespace: Option,
+ has_main_package: bool,
+ has_main_bundle: bool,
+ publisher: Option,
+}
+
+#[derive(Debug, Eq, PartialEq)]
+struct BusinessCentralAppInfo {
+ is_navx: bool,
+ len: u64,
+}
+
+#[derive(Debug, Eq, PartialEq)]
+struct MsixManifestInfo {
+ package_name: Option,
+ publisher: Option,
+ version: Option,
+ processor_architecture: Option,
+}
+
+#[derive(Debug, Eq, PartialEq)]
+struct ClickOnceDeployInfo {
+ deployed: bool,
+ content_name: Option,
+ len: u64,
+}
+
+#[derive(Debug, Eq, PartialEq)]
+struct ClickOnceManifestHashEntry {
+ path: String,
+ algorithm: HashAlg,
+ expected_size: Option,
+ actual_size: u64,
+ expected_digest_b64: String,
+ actual_digest_b64: String,
+}
+
+impl ClickOnceManifestHashEntry {
+ fn status(&self) -> &'static str {
+ if self.expected_size.is_some_and(|size| size != self.actual_size) {
+ "mismatch"
+ } else if self.expected_digest_b64 == self.actual_digest_b64 {
+ "valid"
+ } else {
+ "mismatch"
}
}
- Ok(())
}
-#[cfg(feature = "timestamp-http")]
-fn run_rfc3161_timestamp_http_post(
- url: String,
- algorithm: HashAlg,
- digest_file: Option,
- digest_hex: Option,
- nonce: Option,
- cert_req: bool,
- output: Option,
-) -> Result<()> {
- use std::io::Write;
- let preimage =
- load_timestamp_imprint_preimage(digest_hex.as_ref(), digest_file.as_ref(), algorithm)?;
- let plan = Rfc3161TimestampRequestPlan {
- digest_alg_oid: hash_alg_timestamp_oid(algorithm),
- nonce,
- cert_req,
- };
- let der = build_timestamp_request_bytes(&plan, &preimage).ok_or_else(|| {
- anyhow!("unsupported digest OID / preimage length for RFC3161 TimeStampReq")
- })?;
- let client = reqwest::blocking::Client::builder()
- .use_rustls_tls()
- .timeout(std::time::Duration::from_secs(120))
- .build()
- .context("build HTTP client (timestamp-http feature)")?;
- let resp = client
- .post(url.trim())
- .header("Content-Type", "application/timestamp-query")
- .header(
- "Accept",
- "application/timestamp-reply, application/timestamp-response",
- )
- .body(der)
- .send()
- .with_context(|| format!("POST TimeStampReq to {}", url.trim()))?;
- let status = resp.status();
- let body = resp.bytes().context("read TSA response body")?;
- if !status.is_success() {
+#[derive(Debug, Eq, PartialEq)]
+struct ClickOnceManifestSignatureReport {
+ digest: PortableSignDigest,
+ manifest_digest_b64: String,
+ signature_len: usize,
+}
+
+fn inspect_clickonce_deploy_payload(path: &Path) -> Result {
+ let metadata = std::fs::metadata(path).with_context(|| format!("stat {}", path.display()))?;
+ let content_name = clickonce_deploy_content_name(path);
+ Ok(ClickOnceDeployInfo {
+ deployed: content_name.is_some(),
+ content_name,
+ len: metadata.len(),
+ })
+}
+
+fn clickonce_deploy_content_name(path: &Path) -> Option {
+ let file_name = path.file_name()?.to_string_lossy();
+ file_name
+ .strip_suffix(".deploy")
+ .filter(|name| !name.is_empty())
+ .map(str::to_owned)
+}
+
+fn copy_clickonce_deploy_payload(input: &Path, output: &Path) -> Result {
+ let Some(_) = clickonce_deploy_content_name(input) else {
return Err(anyhow!(
- "TSA HTTP {} — first {} body bytes (hex): {}",
- status,
- body.len().min(256),
- hex_lower(&body[..body.len().min(256)])
+ "ClickOnce deploy payload name must end with .deploy: {}",
+ input.display()
));
+ };
+ std::fs::copy(input, output)
+ .with_context(|| format!("copy {} to {}", input.display(), output.display()))
+}
+
+fn clickonce_manifest_hashes(
+ manifest_path: &Path,
+ base_directory: Option<&Path>,
+) -> Result> {
+ let text = std::fs::read_to_string(manifest_path)
+ .with_context(|| format!("read ClickOnce manifest {}", manifest_path.display()))?;
+ let base = base_directory
+ .map(Path::to_path_buf)
+ .unwrap_or_else(|| manifest_path.parent().unwrap_or(Path::new(".")).to_path_buf());
+ clickonce_manifest_hashes_from_text(&text, &base)
+}
+
+fn clickonce_manifest_hashes_from_text(
+ text: &str,
+ base_directory: &Path,
+) -> Result> {
+ let entries = clickonce_manifest_reference_spans(text)?;
+ let mut out = Vec::with_capacity(entries.len());
+ for entry in entries {
+ let file_path = resolve_clickonce_manifest_path(base_directory, &entry.path)?;
+ let bytes =
+ std::fs::read(&file_path).with_context(|| format!("read {}", file_path.display()))?;
+ let digest = digest_bytes_for_hash_alg(entry.algorithm, &bytes);
+ out.push(ClickOnceManifestHashEntry {
+ path: entry.path,
+ algorithm: entry.algorithm,
+ expected_size: entry.size,
+ actual_size: bytes.len() as u64,
+ expected_digest_b64: entry.digest_value,
+ actual_digest_b64: base64_encode(&digest),
+ });
}
- match output.as_ref() {
- Some(p) => std::fs::write(p, &body).with_context(|| format!("write {}", p.display()))?,
- None => std::io::stdout()
- .write_all(&body)
- .context("write TimeStampResp DER to stdout")?,
- }
- Ok(())
+ Ok(out)
}
-#[cfg(feature = "timestamp-http")]
-fn post_rfc3161_timestamp_request(
- url: &str,
+fn update_clickonce_manifest_hashes(
+ manifest_path: &Path,
+ base_directory: Option<&Path>,
+ output: &Path,
algorithm: HashAlg,
- message_imprint: &[u8],
-) -> Result> {
- if message_imprint.len() != digest_byte_len_for_hash_alg(algorithm) {
- return Err(anyhow!(
- "timestamp message imprint must be exactly {} bytes for {:?}, got {}",
- digest_byte_len_for_hash_alg(algorithm),
- algorithm,
- message_imprint.len()
+) -> Result {
+ let text = std::fs::read_to_string(manifest_path)
+ .with_context(|| format!("read ClickOnce manifest {}", manifest_path.display()))?;
+ let base = base_directory
+ .map(Path::to_path_buf)
+ .unwrap_or_else(|| manifest_path.parent().unwrap_or(Path::new(".")).to_path_buf());
+ let updated = update_clickonce_manifest_hashes_in_text(&text, &base, algorithm)?;
+ std::fs::write(output, updated.text)
+ .with_context(|| format!("write ClickOnce manifest {}", output.display()))?;
+ Ok(updated.updated)
+}
+
+#[derive(Debug)]
+struct ClickOnceManifestReference {
+ tag_start: usize,
+ tag_end: usize,
+ path: String,
+ size: Option,
+ algorithm: HashAlg,
+ digest_value: String,
+ digest_method_tag_start: usize,
+ digest_method_tag_end: usize,
+ digest_value_content_start: usize,
+ digest_value_content_end: usize,
+}
+
+#[derive(Debug)]
+struct UpdatedClickOnceManifest {
+ text: String,
+ updated: usize,
+}
+
+fn update_clickonce_manifest_hashes_in_text(
+ text: &str,
+ base_directory: &Path,
+ algorithm: HashAlg,
+) -> Result {
+ let entries = clickonce_manifest_reference_spans(text)?;
+ let mut replacements = Vec::with_capacity(entries.len() * 3);
+ for entry in &entries {
+ let file_path = resolve_clickonce_manifest_path(base_directory, &entry.path)?;
+ let bytes =
+ std::fs::read(&file_path).with_context(|| format!("read {}", file_path.display()))?;
+ let digest = digest_bytes_for_hash_alg(algorithm, &bytes);
+ let size = bytes.len().to_string();
+ replacements.push((
+ entry.tag_start,
+ entry.tag_end + 1,
+ replace_or_insert_xml_attr(&text[entry.tag_start..=entry.tag_end], "size", &size)?,
));
- }
- let plan = Rfc3161TimestampRequestPlan {
- digest_alg_oid: hash_alg_timestamp_oid(algorithm),
- nonce: None,
- cert_req: true,
- };
- let der = build_timestamp_request_bytes(&plan, message_imprint).ok_or_else(|| {
- anyhow!("unsupported digest OID / preimage length for RFC3161 TimeStampReq")
- })?;
- let client = reqwest::blocking::Client::builder()
- .use_rustls_tls()
- .timeout(std::time::Duration::from_secs(120))
- .build()
- .context("build HTTP client (timestamp-http feature)")?;
- let resp = client
- .post(url.trim())
- .header("Content-Type", "application/timestamp-query")
- .header(
- "Accept",
- "application/timestamp-reply, application/timestamp-response",
- )
- .body(der)
- .send()
- .with_context(|| format!("POST TimeStampReq to {}", url.trim()))?;
- let status = resp.status();
- let body = resp.bytes().context("read TSA response body")?;
- if !status.is_success() {
- return Err(anyhow!(
- "TSA HTTP {} — first {} body bytes (hex): {}",
- status,
- body.len().min(256),
- hex_lower(&body[..body.len().min(256)])
+ replacements.push((
+ entry.digest_method_tag_start,
+ entry.digest_method_tag_end + 1,
+ replace_or_insert_xml_attr(
+ &text[entry.digest_method_tag_start..=entry.digest_method_tag_end],
+ "Algorithm",
+ clickonce_digest_algorithm_uri(algorithm),
+ )?,
+ ));
+ replacements.push((
+ entry.digest_value_content_start,
+ entry.digest_value_content_end,
+ base64_encode(&digest),
));
}
- Ok(body.to_vec())
+
+ replacements.sort_by_key(|(start, _, _)| *start);
+ let mut out = String::with_capacity(text.len());
+ let mut cursor = 0usize;
+ for (start, end, replacement) in replacements {
+ if start < cursor {
+ return Err(anyhow!("internal ClickOnce manifest replacement overlap"));
+ }
+ out.push_str(&text[cursor..start]);
+ out.push_str(&replacement);
+ cursor = end;
+ }
+ out.push_str(&text[cursor..]);
+ Ok(UpdatedClickOnceManifest {
+ text: out,
+ updated: entries.len(),
+ })
}
-#[cfg(feature = "timestamp-http")]
-fn timestamp_pkcs7_der_rfc3161(
- pkcs7_der: &[u8],
- timestamp_url: &str,
- timestamp_digest: HashAlg,
-) -> Result> {
- let sd = pkcs7::parse_pkcs7_signed_data_der(pkcs7_der).context("parse PKCS#7 SignedData")?;
- let signer = sd
- .signer_infos
- .0
- .as_slice()
- .first()
- .ok_or_else(|| anyhow!("PKCS#7 SignedData has no SignerInfo to timestamp"))?;
- let imprint = digest_bytes_for_hash_alg(timestamp_digest, signer.signature.as_bytes());
- let response = post_rfc3161_timestamp_request(timestamp_url, timestamp_digest, &imprint)?;
- let parsed = parse_time_stamp_resp_der(&response)
- .ok_or_else(|| anyhow!("could not parse TimeStampResp DER from TSA response"))?;
- if !parsed.pki_status.granted() {
+fn clickonce_manifest_reference_spans(text: &str) -> Result> {
+ let mut refs = Vec::new();
+ collect_clickonce_manifest_references(text, "file", "name", &mut refs)?;
+ collect_clickonce_manifest_references(text, "dependentAssembly", "codebase", &mut refs)?;
+ refs.sort_by_key(|entry| entry.tag_start);
+ Ok(refs)
+}
+
+fn collect_clickonce_manifest_references(
+ text: &str,
+ tag: &str,
+ path_attr: &str,
+ refs: &mut Vec,
+) -> Result<()> {
+ let mut cursor = 0usize;
+ while let Some(start) = find_xml_start_tag(text, tag, cursor) {
+ let tag_end = text[start..]
+ .find('>')
+ .map(|offset| start + offset)
+ .ok_or_else(|| anyhow!("ClickOnce manifest <{tag}> tag is not closed"))?;
+ let start_tag = &text[start..=tag_end];
+ cursor = tag_end + 1;
+ if start_tag.ends_with("/>") {
+ continue;
+ }
+ let Some(path) = xml_attr(start_tag, path_attr) else {
+ continue;
+ };
+ let close = format!("{tag}>");
+ let Some(close_start) = text[cursor..].find(&close).map(|offset| cursor + offset) else {
+ continue;
+ };
+ let block_end = close_start + close.len();
+ let block = &text[tag_end + 1..close_start];
+ let Some(method) = find_xml_start_tag_by_local_name(block, "DigestMethod", 0)? else {
+ continue;
+ };
+ let Some(value) = find_xml_element_by_local_name(block, "DigestValue", 0)? else {
+ continue;
+ };
+ let method_tag = &block[method.start..=method.end];
+ let algorithm = xml_attr(method_tag, "Algorithm")
+ .as_deref()
+ .map(clickonce_hash_alg_from_uri)
+ .transpose()?
+ .unwrap_or(HashAlg::Sha256);
+ refs.push(ClickOnceManifestReference {
+ tag_start: start,
+ tag_end,
+ path,
+ size: xml_attr(start_tag, "size")
+ .map(|s| s.parse::())
+ .transpose()
+ .context("parse ClickOnce manifest size attribute")?,
+ algorithm,
+ digest_value: block[value.content_start..value.content_end]
+ .trim()
+ .to_owned(),
+ digest_method_tag_start: tag_end + 1 + method.start,
+ digest_method_tag_end: tag_end + 1 + method.end,
+ digest_value_content_start: tag_end + 1 + value.content_start,
+ digest_value_content_end: tag_end + 1 + value.content_end,
+ });
+ cursor = block_end;
+ }
+ Ok(())
+}
+
+#[derive(Debug)]
+struct XmlStartTagSpan {
+ start: usize,
+ end: usize,
+ name: String,
+}
+
+#[derive(Debug)]
+struct XmlElementSpan {
+ content_start: usize,
+ content_end: usize,
+}
+
+fn find_xml_start_tag(text: &str, tag: &str, from: usize) -> Option {
+ let needle = format!("<{tag}");
+ let mut cursor = from;
+ while let Some(rel) = text[cursor..].find(&needle) {
+ let start = cursor + rel;
+ let next = text[start + needle.len()..].chars().next();
+ if matches!(next, Some(' ' | '\t' | '\r' | '\n' | '>' | '/')) {
+ return Some(start);
+ }
+ cursor = start + needle.len();
+ }
+ None
+}
+
+fn find_xml_start_tag_by_local_name(
+ text: &str,
+ local_name: &str,
+ from: usize,
+) -> Result