From 9ba83d6a143f56abb618450d4f234af885d74abe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 21 May 2026 15:59:06 -0400 Subject: [PATCH 01/10] Add portable Authenticode PowerShell module Introduce the portable core/FFI, PowerShell 7.4 module cmdlets, MSIX signing support, module-directory signing, expanded end-to-end tests, packaging, CI, and documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci-unix.yml | 10 +- .github/workflows/powershell-module.yml | 56 ++ .gitignore | 5 + Cargo.lock | 26 + Cargo.toml | 4 + .../Devolutions.Psign/Devolutions.Psign.psd1 | 22 + .../Devolutions.Psign/Devolutions.Psign.psm1 | 1 + PowerShell/build.ps1 | 47 + PowerShell/package.ps1 | 37 + .../tests/Invoke-PortableSignatureTests.ps1 | 165 ++++ README.md | 16 +- crates/psign-portable-core/Cargo.toml | 20 + crates/psign-portable-core/src/lib.rs | 922 ++++++++++++++++++ crates/psign-portable-ffi/Cargo.toml | 18 + crates/psign-portable-ffi/src/lib.rs | 192 ++++ crates/psign-sip-digest/src/msix_digest.rs | 77 ++ crates/psign-sip-digest/src/pkcs7.rs | 43 + docs/portable-core-ffi.md | 14 + docs/portable-powershell-module.md | 43 + .../Cmdlets/GetPortableSignatureCommand.cs | 56 ++ .../Cmdlets/SetPortableSignatureCommand.cs | 270 +++++ .../Devolutions.Psign.PowerShell.csproj | 17 + .../Models/PortableErrorResponse.cs | 15 + .../Models/PortableRequests.cs | 33 + .../Models/PortableSignResponse.cs | 21 + .../Models/PortableSignature.cs | 41 + .../Native/PsignNative.cs | 95 ++ .../Native/PsignNativeResolver.cs | 85 ++ .../Native/PsignPortableException.cs | 6 + .../Utilities/PathResolution.cs | 25 + .../Utilities/PortableModuleFiles.cs | 20 + scripts/linux-portable-validation.sh | 7 + src/bin/psign-server.rs | 19 +- 33 files changed, 2416 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/powershell-module.yml create mode 100644 PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 create mode 100644 PowerShell/Devolutions.Psign/Devolutions.Psign.psm1 create mode 100644 PowerShell/build.ps1 create mode 100644 PowerShell/package.ps1 create mode 100644 PowerShell/tests/Invoke-PortableSignatureTests.ps1 create mode 100644 crates/psign-portable-core/Cargo.toml create mode 100644 crates/psign-portable-core/src/lib.rs create mode 100644 crates/psign-portable-ffi/Cargo.toml create mode 100644 crates/psign-portable-ffi/src/lib.rs create mode 100644 docs/portable-core-ffi.md create mode 100644 docs/portable-powershell-module.md create mode 100644 dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPortableSignatureCommand.cs create mode 100644 dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs create mode 100644 dotnet/Devolutions.Psign.PowerShell/Devolutions.Psign.PowerShell.csproj create mode 100644 dotnet/Devolutions.Psign.PowerShell/Models/PortableErrorResponse.cs create mode 100644 dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs create mode 100644 dotnet/Devolutions.Psign.PowerShell/Models/PortableSignResponse.cs create mode 100644 dotnet/Devolutions.Psign.PowerShell/Models/PortableSignature.cs create mode 100644 dotnet/Devolutions.Psign.PowerShell/Native/PsignNative.cs create mode 100644 dotnet/Devolutions.Psign.PowerShell/Native/PsignNativeResolver.cs create mode 100644 dotnet/Devolutions.Psign.PowerShell/Native/PsignPortableException.cs create mode 100644 dotnet/Devolutions.Psign.PowerShell/Utilities/PathResolution.cs create mode 100644 dotnet/Devolutions.Psign.PowerShell/Utilities/PortableModuleFiles.cs diff --git a/.github/workflows/ci-unix.yml b/.github/workflows/ci-unix.yml index d751431..d5b1c8d 100644 --- a/.github/workflows/ci-unix.yml +++ b/.github/workflows/ci-unix.yml @@ -22,8 +22,8 @@ jobs: - name: Cargo.lock consistency run: cargo metadata --locked --format-version 1 > /dev/null - - name: clippy (portable digest + trust + Azure REST crates) - run: cargo clippy -p psign-sip-digest -p psign-digest-cli -p psign-authenticode-trust -p psign-codesigning-rest -p psign-azure-kv-rest --all-targets --locked -- -D warnings + - name: clippy (portable digest + trust + core + Azure REST crates) + run: cargo clippy -p psign-sip-digest -p psign-digest-cli -p psign-authenticode-trust -p psign-portable-core -p psign-portable-ffi -p psign-codesigning-rest -p psign-azure-kv-rest --all-targets --locked -- -D warnings - name: clippy (digest-cli with artifact-signing-rest) run: cargo clippy -p psign-digest-cli --all-targets --features artifact-signing-rest --locked -- -D warnings @@ -43,6 +43,12 @@ jobs: - name: Authenticode trust crate tests (no Win32) run: cargo test -p psign-authenticode-trust --lib --locked + - name: Portable core crate tests (no Win32) + run: cargo test -p psign-portable-core --locked + + - name: Portable FFI crate tests (no Win32) + run: cargo test -p psign-portable-ffi --locked + - name: Codesigning REST crate tests (no Win32) run: cargo test -p psign-codesigning-rest --locked diff --git a/.github/workflows/powershell-module.yml b/.github/workflows/powershell-module.yml new file mode 100644 index 0000000..28bbb29 --- /dev/null +++ b/.github/workflows/powershell-module.yml @@ -0,0 +1,56 @@ +name: powershell-module + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + build_and_test: + name: Build PowerShell module (${{ matrix.name }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: linux-x64 + os: ubuntu-latest + - name: windows-x64 + os: windows-2022 + - name: macos-arm64 + os: macos-14 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Rust stable + shell: pwsh + run: | + rustup toolchain install stable --profile minimal + rustup default stable + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 8.0.x + + - name: Cache cargo artifacts + uses: Swatinem/rust-cache@v2.9.1 + + - name: Build psign portable shared library and PowerShell module + shell: pwsh + run: ./PowerShell/build.ps1 -Configuration Release + + - name: Run PowerShell module end-to-end tests + shell: pwsh + run: ./PowerShell/tests/Invoke-PortableSignatureTests.ps1 -Configuration Release + + - name: Upload staged module + uses: actions/upload-artifact@v7 + with: + name: Devolutions.Psign-${{ matrix.name }} + path: PowerShell/Devolutions.Psign + if-no-files-found: error diff --git a/.gitignore b/.gitignore index cf19821..9e03101 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,8 @@ tmp-ci-*/ /nuget/tmp-tool/ /nuget/tool/bin/ /nuget/tool/obj/ +/dotnet/**/bin/ +/dotnet/**/obj/ +/PowerShell/Devolutions.Psign/lib/ +/PowerShell/Devolutions.Psign/runtimes/ +/artifacts/ diff --git a/Cargo.lock b/Cargo.lock index 18dd3f2..b0699c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2119,6 +2119,32 @@ dependencies = [ "zip", ] +[[package]] +name = "psign-portable-core" +version = "0.2.0" +dependencies = [ + "anyhow", + "base64", + "psign-authenticode-trust", + "psign-sip-digest", + "rsa 0.9.10", + "serde", + "serde_json", + "sha2 0.10.9", + "x509-cert", + "zip", +] + +[[package]] +name = "psign-portable-ffi" +version = "0.2.0" +dependencies = [ + "anyhow", + "psign-portable-core", + "serde", + "serde_json", +] + [[package]] name = "psign-sip-digest" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index da5d46c..8af5ca1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,8 @@ members = [ "crates/psign-codesigning-rest", "crates/psign-azure-kv-rest", "crates/psign-opc-sign", + "crates/psign-portable-core", + "crates/psign-portable-ffi", ] # Bare `cargo build` / `cargo test` at the repo root includes the unified `psign-tool` # target plus the portable crates. @@ -17,6 +19,8 @@ default-members = [ "crates/psign-codesigning-rest", "crates/psign-azure-kv-rest", "crates/psign-opc-sign", + "crates/psign-portable-core", + "crates/psign-portable-ffi", ] resolver = "2" diff --git a/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 b/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 new file mode 100644 index 0000000..352e765 --- /dev/null +++ b/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 @@ -0,0 +1,22 @@ +@{ + RootModule = 'Devolutions.Psign.psm1' + ModuleVersion = '0.2.0' + GUID = 'e6e50e4b-bf25-4ed6-a343-49f904e79f8f' + Author = 'Devolutions' + CompanyName = 'Devolutions' + Copyright = '(c) Devolutions. All rights reserved.' + Description = 'Portable Authenticode signing and inspection cmdlets backed by psign.' + CompatiblePSEditions = @('Core') + PowerShellVersion = '7.4' + NestedModules = @('lib/net8.0/Devolutions.Psign.PowerShell.dll') + CmdletsToExport = @('Get-PortableSignature', 'Set-PortableSignature') + FunctionsToExport = @() + AliasesToExport = @() + PrivateData = @{ + PSData = @{ + Tags = @('Authenticode', 'CodeSigning', 'Portable', 'psign') + LicenseUri = 'https://github.com/Devolutions/psign/blob/master/LICENSE' + ProjectUri = 'https://github.com/Devolutions/psign' + } + } +} diff --git a/PowerShell/Devolutions.Psign/Devolutions.Psign.psm1 b/PowerShell/Devolutions.Psign/Devolutions.Psign.psm1 new file mode 100644 index 0000000..ebdd58d --- /dev/null +++ b/PowerShell/Devolutions.Psign/Devolutions.Psign.psm1 @@ -0,0 +1 @@ +# Binary cmdlets are loaded through the module manifest's NestedModules entry. diff --git a/PowerShell/build.ps1 b/PowerShell/build.ps1 new file mode 100644 index 0000000..33ec22e --- /dev/null +++ b/PowerShell/build.ps1 @@ -0,0 +1,47 @@ +param( + [ValidateSet('Debug', 'Release')] + [string] $Configuration = 'Release' +) + +$ErrorActionPreference = 'Stop' + +$repo = Split-Path -Parent $PSScriptRoot +$moduleRoot = Join-Path $PSScriptRoot 'Devolutions.Psign' +$libOut = Join-Path (Join-Path $moduleRoot 'lib') 'net8.0' +$projectPath = Join-Path (Join-Path $repo 'dotnet') 'Devolutions.Psign.PowerShell' +$projectPath = Join-Path $projectPath 'Devolutions.Psign.PowerShell.csproj' + +Push-Location $repo +try { + cargo build -p psign-portable-ffi --profile ($Configuration -eq 'Release' ? 'release' : 'dev') + dotnet publish $projectPath -c $Configuration -o $libOut + + $rid = if ($IsWindows) { + 'win' + } elseif ($IsMacOS) { + 'osx' + } else { + 'linux' + } + $arch = switch ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture) { + 'X64' { 'x64' } + 'Arm64' { 'arm64' } + default { $_.ToString().ToLowerInvariant() } + } + $rid = "$rid-$arch" + $nativeName = if ($IsWindows) { + 'psign_portable.dll' + } elseif ($IsMacOS) { + 'libpsign_portable.dylib' + } else { + 'libpsign_portable.so' + } + $profileDir = if ($Configuration -eq 'Release') { 'release' } else { 'debug' } + $nativeSource = Join-Path (Join-Path (Join-Path $repo 'target') $profileDir) $nativeName + $nativeOut = Join-Path (Join-Path (Join-Path $moduleRoot 'runtimes') $rid) 'native' + New-Item -ItemType Directory -Force -Path $nativeOut | Out-Null + Copy-Item -Force -Path $nativeSource -Destination (Join-Path $nativeOut $nativeName) +} +finally { + Pop-Location +} diff --git a/PowerShell/package.ps1 b/PowerShell/package.ps1 new file mode 100644 index 0000000..df10764 --- /dev/null +++ b/PowerShell/package.ps1 @@ -0,0 +1,37 @@ +param( + [ValidateSet('Debug', 'Release')] + [string] $Configuration = 'Release', + + [string] $OutputDirectory = (Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts') 'powershell') +) + +$ErrorActionPreference = 'Stop' + +$repo = Split-Path -Parent $PSScriptRoot +$moduleRoot = Join-Path $PSScriptRoot 'Devolutions.Psign' +$localRepo = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N')) +$repoName = "DevolutionsPsignLocal$([System.Guid]::NewGuid().ToString('N'))" + +& (Join-Path $PSScriptRoot 'build.ps1') -Configuration $Configuration + +New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null +New-Item -ItemType Directory -Force -Path $localRepo | Out-Null + +try { + Register-PSRepository -Name $repoName -SourceLocation $localRepo -PublishLocation $localRepo -InstallationPolicy Trusted + Publish-Module -Path $moduleRoot -Repository $repoName -NuGetApiKey 'local-package' + + $package = Get-ChildItem -Path $localRepo -Filter 'Devolutions.Psign.*.nupkg' | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + if (-not $package) { + throw "PowerShell module package was not created in $localRepo" + } + + Copy-Item -Force -Path $package.FullName -Destination $OutputDirectory + Get-Item -LiteralPath (Join-Path $OutputDirectory $package.Name) +} +finally { + Unregister-PSRepository -Name $repoName -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $localRepo -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/PowerShell/tests/Invoke-PortableSignatureTests.ps1 b/PowerShell/tests/Invoke-PortableSignatureTests.ps1 new file mode 100644 index 0000000..22decb3 --- /dev/null +++ b/PowerShell/tests/Invoke-PortableSignatureTests.ps1 @@ -0,0 +1,165 @@ +param( + [string] $Configuration = 'Release' +) + +$ErrorActionPreference = 'Stop' + +$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 + +$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) + $request = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=psign portable test', + $rsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) + $cert = $request.CreateSelfSigned( + [System.DateTimeOffset]::UtcNow.AddDays(-1), + [System.DateTimeOffset]::UtcNow.AddDays(30)) + $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')) + + $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.' + } + + $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)" + } + + $after = Get-PortableSignature -LiteralPath $work + if ($after.Status -ne 'Valid') { + throw "Expected Valid from Get-PortableSignature after signing, got $($after.Status)." + } + + $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.' + } + + $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)" + } + $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)" + } + $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)" + } + $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)" + } + $scriptAfter = Get-PortableSignature -LiteralPath $scriptPath + if ($scriptAfter.Status -ne 'Valid') { + throw "Expected Valid from Get-PortableSignature for signed script, got $($scriptAfter.Status)." + } + 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)" + } + + $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 $nestedDir 'Helper.ps1') -Value '$script:PortableHelper = $true' -Encoding UTF8 + $moduleSigned = @(Set-PortableSignature -LiteralPath $moduleDir -CertificatePath $certPath -PrivateKeyPath $keyPath) + if ($moduleSigned.Count -ne 3) { + throw "Expected 3 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)" + } + $moduleValidated = @(Get-PortableSignature -LiteralPath $moduleDir) + if ($moduleValidated.Count -ne 3) { + throw "Expected 3 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)" + } + $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/README.md b/README.md index 5b59900..0d170aa 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,20 @@ cargo check -p psign-sip-digest -p psign-digest-cli -p psign-authenticode-trust Unix CI (`ci-unix`) runs **`cargo fmt`**, strict **`clippy -D warnings`** on portable / REST crates plus the **`psign` library**, and the digest CLI tests. Local mirror (bash): **`scripts/linux-portable-validation.sh`** from the repo root. +## PowerShell portable Authenticode module + +The repository also builds a PowerShell 7.4 / .NET 8 module, **`Devolutions.Psign`**, with portable cmdlets backed by the Rust `psign_portable` shared library through P/Invoke: + +```powershell +Import-Module .\PowerShell\Devolutions.Psign\Devolutions.Psign.psd1 +Set-PortableSignature -LiteralPath .\script.ps1 -Certificate $cert +Get-PortableSignature -LiteralPath .\script.ps1 +Set-PortableSignature -LiteralPath .\ModuleDirectory -CertificatePath .\signer.cer -PrivateKeyPath .\signer.key +Set-PortableSignature -LiteralPath .\package.msix -PfxPath .\signer.pfx -Password $password +``` + +`Set-PortableSignature` and `Get-PortableSignature` avoid Win32 SIPs and support PE, CAB, MSI, ZIP Authenticode, MSIX/AppX, PowerShell scripts, and whole PowerShell module directories (`.ps1`, `.psm1`, `.psd1`). See [`docs/portable-powershell-module.md`](docs/portable-powershell-module.md) and [`docs/portable-core-ffi.md`](docs/portable-core-ffi.md). + ## Portable certificate store `psign-tool cert-store ...` manages a simple file-based certificate store for portable workflows. The default base directory is **`~/.psign/cert-store`**; set **`PSIGN_CERT_STORE`** or pass **`--cert-store-dir`** to override it. Certificates are stored as DER-encoded X.509 files named by Windows-style SHA-1 thumbprint over the full DER certificate. Optional local private keys live beside the certificate as PEM-encoded, unencrypted PKCS#8 **`.key`** files with the same thumbprint name. @@ -149,7 +163,7 @@ psign-tool cert-store import-pfx --store MY --password "pfx-password" cert.pfx psign-tool --mode portable sign /sha1 ABCDEF0123456789ABCDEF0123456789ABCDEF01 /s MY /fd SHA256 file.exe ``` -The portable signing MVP supports local RSA/SHA-256 PE/WinMD Authenticode signing only. Unsupported native signing options, timestamping options, CSP/KSP selection, auto-selection, direct PFX signing, and non-PE formats return explicit errors in portable mode. +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. ## Generate binary manifest and dependency graph diff --git a/crates/psign-portable-core/Cargo.toml b/crates/psign-portable-core/Cargo.toml new file mode 100644 index 0000000..78a26c8 --- /dev/null +++ b/crates/psign-portable-core/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "psign-portable-core" +version = "0.2.0" +edition = "2024" +description = "Reusable portable Authenticode signing and inspection APIs for psign" +license.workspace = true +repository.workspace = true + +[dependencies] +anyhow = "1" +base64 = "0.22" +psign-authenticode-trust = { path = "../psign-authenticode-trust" } +psign-sip-digest = { path = "../psign-sip-digest" } +rsa = "0.9.10" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +x509-cert = { version = "0.2.5", default-features = false, features = ["pem"] } +zip = { version = "0.6.6", default-features = false, features = ["deflate"] } + diff --git a/crates/psign-portable-core/src/lib.rs b/crates/psign-portable-core/src/lib.rs new file mode 100644 index 0000000..f15de2d --- /dev/null +++ b/crates/psign-portable-core/src/lib.rs @@ -0,0 +1,922 @@ +//! Reusable portable Authenticode operations for CLI adapters and foreign-function callers. + +use std::io::{Cursor, Read, Write}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use base64::Engine as _; +use psign_authenticode_trust::inspect_authenticode_pkcs7_der; +use psign_authenticode_trust::inspect_pe_authenticode; +use psign_sip_digest::pkcs7::AuthenticodeSigningDigest; +use psign_sip_digest::verify_pe::verify_pe_authenticode_digest_consistency; +use psign_sip_digest::{ + cab_digest, msi_digest, msix_digest, pe_embed, pkcs7, ps_script, rdp, + verify_script_digest_consistency, zip_authenticode, +}; +use serde::{Deserialize, Serialize}; +use sha2::Digest as _; +use zip::write::FileOptions; +use zip::{CompressionMethod, ZipArchive, ZipWriter}; + +const SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum PortableFileFormat { + Pe, + Cab, + Msi, + Msix, + Catalog, + Zip, + PowerShellScript, + WshScript, + Unknown, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum PortableSignatureStatus { + Valid, + NotSigned, + HashMismatch, + NotTrusted, + NotSupportedFileFormat, + Incompatible, + UnknownError, +} + +#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum PortableDigestAlgorithm { + #[default] + Sha256, + Sha384, + Sha512, +} + +impl From for AuthenticodeSigningDigest { + fn from(value: PortableDigestAlgorithm) -> Self { + match value { + PortableDigestAlgorithm::Sha256 => Self::Sha256, + PortableDigestAlgorithm::Sha384 => Self::Sha384, + PortableDigestAlgorithm::Sha512 => Self::Sha512, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortableGetSignatureRequest { + pub path: PathBuf, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortableSignRequest { + pub path: PathBuf, + #[serde(default)] + pub output_path: Option, + #[serde(default)] + pub hash_algorithm: PortableDigestAlgorithm, + #[serde(default)] + pub certificate_path: Option, + #[serde(default)] + pub private_key_path: Option, + #[serde(default)] + pub certificate_der_base64: Option, + #[serde(default)] + pub private_key_der_base64: Option, + #[serde(default)] + pub chain_certificate_paths: Vec, + #[serde(default)] + pub chain_certificates_der_base64: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortableSignatureResponse { + pub schema_version: u32, + pub path: PathBuf, + pub format: PortableFileFormat, + pub status: PortableSignatureStatus, + pub status_message: String, + pub signature_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub digest_algorithm: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub timestamp_kinds: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortableSignResponse { + pub schema_version: u32, + pub input_path: PathBuf, + pub output_path: PathBuf, + pub format: PortableFileFormat, + pub signature: PortableSignatureResponse, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortableVersionResponse { + pub schema_version: u32, + pub crate_name: &'static str, + pub crate_version: &'static str, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortableErrorResponse { + pub schema_version: u32, + pub code: PortableErrorCode, + pub message: String, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum PortableErrorCode { + InvalidRequest, + Io, + NotSupportedFileFormat, + UnsupportedOperation, + OperationFailed, + Panic, +} + +pub fn version() -> PortableVersionResponse { + PortableVersionResponse { + schema_version: SCHEMA_VERSION, + crate_name: env!("CARGO_PKG_NAME"), + crate_version: env!("CARGO_PKG_VERSION"), + } +} + +pub fn portable_sign(request: PortableSignRequest) -> Result { + let format = infer_format(&request.path); + let output_path = request + .output_path + .clone() + .unwrap_or_else(|| request.path.clone()); + + match format { + PortableFileFormat::Pe => sign_pe(&request, &output_path), + PortableFileFormat::Cab => sign_cab(&request, &output_path), + PortableFileFormat::Msi => sign_msi(&request, &output_path), + PortableFileFormat::Msix => sign_msix(&request, &output_path), + PortableFileFormat::Zip => sign_zip(&request, &output_path), + PortableFileFormat::PowerShellScript => sign_script(&request, &output_path), + PortableFileFormat::WshScript => bail!("portable WSH script signing is not supported yet"), + PortableFileFormat::Catalog => bail!( + "portable catalog signing requires an explicit subject list and is not available through PortableSignRequest yet" + ), + PortableFileFormat::Unknown => { + bail!( + "unsupported portable signing format: {}", + request.path.display() + ) + } + }?; + + let signature = portable_get_signature(PortableGetSignatureRequest { + path: output_path.clone(), + })?; + + Ok(PortableSignResponse { + schema_version: SCHEMA_VERSION, + input_path: request.path, + output_path, + format, + signature, + }) +} + +pub fn portable_get_signature( + request: PortableGetSignatureRequest, +) -> Result { + let format = infer_format(&request.path); + let data = + std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; + + let response = match format { + PortableFileFormat::Pe => inspect_pe(&request.path, &data), + PortableFileFormat::Cab => inspect_cab(&request.path), + PortableFileFormat::Msi => inspect_msi(&request.path), + PortableFileFormat::Msix => inspect_msix(&request.path), + PortableFileFormat::Zip => inspect_zip(&request.path, &data), + PortableFileFormat::PowerShellScript | PortableFileFormat::WshScript => { + inspect_script(&request.path, &data) + } + PortableFileFormat::Catalog => inspect_pkcs7_file(&request.path, format), + PortableFileFormat::Unknown => Ok(base_response( + request.path, + format, + PortableSignatureStatus::NotSupportedFileFormat, + "Unsupported file format for portable Authenticode inspection.", + )), + }?; + + Ok(response) +} + +pub fn infer_format(path: &Path) -> PortableFileFormat { + let Some(ext) = path.extension().and_then(|e| e.to_str()) else { + return PortableFileFormat::Unknown; + }; + match ext.to_ascii_lowercase().as_str() { + "exe" | "dll" | "sys" | "efi" | "winmd" | "mui" | "ocx" | "scr" | "cpl" => { + PortableFileFormat::Pe + } + "cab" => PortableFileFormat::Cab, + "msi" | "msp" => PortableFileFormat::Msi, + "msix" | "appx" | "msixbundle" | "appxbundle" => PortableFileFormat::Msix, + "cat" => PortableFileFormat::Catalog, + "zip" | "vsix" | "nupkg" => PortableFileFormat::Zip, + "ps1" | "psm1" | "psd1" | "ps1xml" => PortableFileFormat::PowerShellScript, + "vbs" | "js" | "wsf" => PortableFileFormat::WshScript, + _ => PortableFileFormat::Unknown, + } +} + +pub fn portable_error_response( + code: PortableErrorCode, + error: impl std::fmt::Display, +) -> PortableErrorResponse { + PortableErrorResponse { + schema_version: SCHEMA_VERSION, + code, + message: error.to_string(), + } +} + +fn sign_pe(request: &PortableSignRequest, output_path: &Path) -> Result<()> { + let pe = + std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; + let (signer_cert, private_key, chain) = load_signing_material(request)?; + let pkcs7 = pkcs7::create_pe_authenticode_pkcs7_der_rsa( + &pe, + request.hash_algorithm.into(), + signer_cert, + chain, + private_key, + ) + .with_context(|| { + format!( + "create portable PE Authenticode signature for {}", + request.path.display() + ) + })?; + let signed = pe_embed::pe_append_authenticode_pkcs7_certificate(pe, &pkcs7) + .with_context(|| format!("embed Authenticode signature in {}", request.path.display()))?; + std::fs::write(output_path, signed).with_context(|| format!("write {}", output_path.display())) +} + +fn sign_cab(request: &PortableSignRequest, output_path: &Path) -> Result<()> { + let cab = + std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; + let (signer_cert, private_key, chain) = load_signing_material(request)?; + let pkcs7 = pkcs7::create_cab_authenticode_pkcs7_der_rsa( + &cab, + request.hash_algorithm.into(), + signer_cert, + chain, + private_key, + ) + .with_context(|| { + format!( + "create portable CAB Authenticode signature for {}", + request.path.display() + ) + })?; + let signed = cab_digest::cab_append_authenticode_pkcs7_signature(&cab, &pkcs7) + .with_context(|| format!("embed Authenticode signature in {}", request.path.display()))?; + std::fs::write(output_path, signed).with_context(|| format!("write {}", output_path.display())) +} + +fn sign_msi(request: &PortableSignRequest, output_path: &Path) -> Result<()> { + let msi = + std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; + let (signer_cert, private_key, chain) = load_signing_material(request)?; + let pkcs7 = pkcs7::create_msi_authenticode_pkcs7_der_rsa( + &msi, + request.hash_algorithm.into(), + signer_cert, + chain, + private_key, + ) + .with_context(|| { + format!( + "create portable MSI Authenticode signature for {}", + request.path.display() + ) + })?; + msi_digest::msi_embed_authenticode_pkcs7_signature(&request.path, output_path, &pkcs7) + .with_context(|| format!("embed Authenticode signature in {}", request.path.display())) +} + +fn sign_msix(request: &PortableSignRequest, output_path: &Path) -> Result<()> { + let ext = request + .path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if !matches!(ext.as_str(), "msix" | "appx") { + bail!("portable MSIX signing currently supports flat .msix/.appx packages"); + } + + let package = + std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; + let staged = stage_flat_msix_for_signature(&package, request.hash_algorithm) + .with_context(|| format!("stage {} for MSIX signing", request.path.display()))?; + let (signer_cert, private_key, chain) = load_signing_material(request)?; + let pkcs7 = pkcs7::create_msix_authenticode_pkcs7_der_rsa( + &staged, + &ext, + request.hash_algorithm.into(), + signer_cert, + chain, + private_key, + ) + .with_context(|| { + format!( + "create portable MSIX Authenticode signature for {}", + request.path.display() + ) + })?; + let mut p7x = b"PKCX".to_vec(); + p7x.extend_from_slice(&pkcs7); + let signed = replace_msix_signature_part(&staged, &p7x) + .with_context(|| format!("embed AppxSignature.p7x in {}", request.path.display()))?; + std::fs::write(output_path, signed).with_context(|| format!("write {}", output_path.display())) +} + +fn sign_zip(request: &PortableSignRequest, output_path: &Path) -> Result<()> { + let zip = + std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; + let digest = zip_authenticode::zip_authenticode_digest_string(&zip).with_context(|| { + format!( + "compute ZIP Authenticode digest for {}", + request.path.display() + ) + })?; + let script = zip_authenticode::unsigned_signature_script_bytes(&digest); + let (signer_cert, private_key, chain) = load_signing_material(request)?; + let pkcs7 = pkcs7::create_script_authenticode_pkcs7_der_rsa( + &script, + request.hash_algorithm.into(), + signer_cert, + chain, + private_key, + ) + .with_context(|| { + format!( + "create portable ZIP signature script Authenticode signature for {}", + request.path.display() + ) + })?; + let line = zip_authenticode::signature_comment_line_from_pkcs7_der(&digest, &pkcs7)?; + let signed = + zip_authenticode::embed_signature_comment_line(&zip, &line).with_context(|| { + format!( + "embed ZIP Authenticode comment in {}", + request.path.display() + ) + })?; + std::fs::write(output_path, signed).with_context(|| format!("write {}", output_path.display())) +} + +fn sign_script(request: &PortableSignRequest, output_path: &Path) -> Result<()> { + let ext = request + .path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("ps1") + .to_ascii_lowercase(); + if !matches!(ext.as_str(), "ps1" | "psd1" | "psm1") { + bail!("portable script signing currently supports ps1, psd1, and psm1 hash-marker scripts"); + } + + let script = + std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; + let (signer_cert, private_key, chain) = load_signing_material(request)?; + let pkcs7 = pkcs7::create_script_authenticode_pkcs7_der_rsa( + &script, + request.hash_algorithm.into(), + signer_cert, + chain, + private_key, + ) + .with_context(|| { + format!( + "create portable script Authenticode signature for {}", + request.path.display() + ) + })?; + let block = format_powershell_signature_block(&pkcs7); + let mut signed = script; + signed.extend_from_slice(block.as_bytes()); + std::fs::write(output_path, signed).with_context(|| format!("write {}", output_path.display())) +} + +fn load_signing_material( + request: &PortableSignRequest, +) -> Result<( + x509_cert::Certificate, + rsa::RsaPrivateKey, + Vec, +)> { + let cert_bytes = match (&request.certificate_der_base64, &request.certificate_path) { + (Some(_), Some(_)) => { + bail!("provide only one of certificate_der_base64 or certificate_path") + } + (Some(b64), None) => base64::engine::general_purpose::STANDARD + .decode(b64) + .context("decode certificate_der_base64")?, + (None, Some(path)) => { + std::fs::read(path).with_context(|| format!("read {}", path.display()))? + } + (None, None) => { + bail!("portable signing requires certificate_der_base64 or certificate_path") + } + }; + let key_bytes = match (&request.private_key_der_base64, &request.private_key_path) { + (Some(_), Some(_)) => { + bail!("provide only one of private_key_der_base64 or private_key_path") + } + (Some(b64), None) => base64::engine::general_purpose::STANDARD + .decode(b64) + .context("decode private_key_der_base64")?, + (None, Some(path)) => { + std::fs::read(path).with_context(|| format!("read {}", path.display()))? + } + (None, None) => { + bail!("portable signing requires private_key_der_base64 or private_key_path") + } + }; + + let signer_cert = rdp::parse_certificate(&cert_bytes).context("parse signer certificate")?; + let private_key = rdp::parse_rsa_private_key(&key_bytes).context("parse RSA private key")?; + let mut chain = Vec::new(); + for path in &request.chain_certificate_paths { + let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?; + chain.push( + rdp::parse_certificate(&bytes) + .with_context(|| format!("parse chain certificate {}", path.display()))?, + ); + } + for (index, b64) in request.chain_certificates_der_base64.iter().enumerate() { + let bytes = base64::engine::general_purpose::STANDARD + .decode(b64) + .with_context(|| format!("decode chain_certificates_der_base64[{index}]"))?; + chain.push( + rdp::parse_certificate(&bytes) + .with_context(|| format!("parse chain certificate {index}"))?, + ); + } + + Ok((signer_cert, private_key, chain)) +} + +fn inspect_pe(path: &Path, data: &[u8]) -> Result { + let inspect = inspect_pe_authenticode(data); + match verify_pe_authenticode_digest_consistency(data) { + Ok(result) => { + let (digest_algorithm, timestamp_kinds) = inspect + .ok() + .map(|r| summarize_pkcs7_reports(r.entries.into_iter().map(|e| e.pkcs7))) + .unwrap_or_default(); + Ok(PortableSignatureResponse { + schema_version: SCHEMA_VERSION, + path: path.to_path_buf(), + format: PortableFileFormat::Pe, + status: PortableSignatureStatus::Valid, + status_message: "Portable digest binding is valid; trust was not evaluated." + .to_string(), + signature_count: result.pkcs7_authenticode_entries, + digest_algorithm, + timestamp_kinds, + diagnostics: vec![format!( + "matched_attribute_certificate_index={}", + result.matched_attribute_certificate_index + )], + }) + } + Err(error) => Ok(map_digest_error(path, PortableFileFormat::Pe, error)), + } +} + +fn inspect_cab(path: &Path) -> Result { + match cab_digest::verify_cab_digest_consistency(path) { + Ok(()) => Ok(base_response( + path.to_path_buf(), + PortableFileFormat::Cab, + PortableSignatureStatus::Valid, + "Portable digest binding is valid; trust was not evaluated.", + )), + Err(error) => Ok(map_digest_error(path, PortableFileFormat::Cab, error)), + } +} + +fn inspect_msi(path: &Path) -> Result { + match msi_digest::verify_msi_digest_consistency(path) { + Ok(()) => Ok(base_response( + path.to_path_buf(), + PortableFileFormat::Msi, + PortableSignatureStatus::Valid, + "Portable digest binding is valid; trust was not evaluated.", + )), + Err(error) => Ok(map_digest_error(path, PortableFileFormat::Msi, error)), + } +} + +fn inspect_msix(path: &Path) -> Result { + match msix_digest::verify_msix_digest_consistency(path) { + Ok(()) => Ok(base_response( + path.to_path_buf(), + PortableFileFormat::Msix, + PortableSignatureStatus::Valid, + "Portable MSIX digest binding is valid; trust was not evaluated.", + )), + Err(error) => Ok(map_digest_error(path, PortableFileFormat::Msix, error)), + } +} + +fn inspect_zip(path: &Path, data: &[u8]) -> Result { + let sig = match zip_authenticode::verify_zip_digest_binding(data) { + Ok(sig) => sig, + Err(error) => return Ok(map_digest_error(path, PortableFileFormat::Zip, error)), + }; + let script = zip_authenticode::signature_script_from_parts(&sig.digest, &sig.pkcs7_base64); + if let Err(error) = verify_script_digest_consistency(script.as_bytes(), "ps1") { + return Ok(map_digest_error(path, PortableFileFormat::Zip, error)); + } + let pkcs7 = zip_authenticode::signature_pkcs7_der(&sig)?; + let report = inspect_authenticode_pkcs7_der(&pkcs7).ok(); + let (digest_algorithm, timestamp_kinds) = report + .map(|r| summarize_pkcs7_reports(std::iter::once(r))) + .unwrap_or_default(); + Ok(PortableSignatureResponse { + schema_version: SCHEMA_VERSION, + path: path.to_path_buf(), + format: PortableFileFormat::Zip, + status: PortableSignatureStatus::Valid, + status_message: "Portable ZIP digest binding is valid; trust was not evaluated." + .to_string(), + signature_count: 1, + digest_algorithm, + timestamp_kinds, + diagnostics: Vec::new(), + }) +} + +fn inspect_script(path: &Path, data: &[u8]) -> Result { + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("ps1"); + match verify_script_digest_consistency(data, ext) { + Ok(()) => { + let report = if ps_script::is_wsh_extension(&ext.to_ascii_lowercase()) { + None + } else { + ps_script::powershell_class_digest_report(data, ext) + .ok() + .and_then(|r| inspect_authenticode_pkcs7_der(&r.pkcs7_der).ok()) + }; + let (digest_algorithm, timestamp_kinds) = report + .map(|r| summarize_pkcs7_reports(std::iter::once(r))) + .unwrap_or_default(); + Ok(PortableSignatureResponse { + schema_version: SCHEMA_VERSION, + path: path.to_path_buf(), + format: infer_format(path), + status: PortableSignatureStatus::Valid, + status_message: "Portable script digest binding is valid; trust was not evaluated." + .to_string(), + signature_count: 1, + digest_algorithm, + timestamp_kinds, + diagnostics: Vec::new(), + }) + } + Err(error) => Ok(map_digest_error(path, infer_format(path), error)), + } +} + +fn inspect_pkcs7_file( + path: &Path, + format: PortableFileFormat, +) -> Result { + let data = std::fs::read(path).with_context(|| format!("read {}", path.display()))?; + match inspect_authenticode_pkcs7_der(&data) { + Ok(report) => { + let (digest_algorithm, timestamp_kinds) = + summarize_pkcs7_reports(std::iter::once(report)); + Ok(PortableSignatureResponse { + schema_version: SCHEMA_VERSION, + path: path.to_path_buf(), + format, + status: PortableSignatureStatus::Valid, + status_message: + "PKCS#7 structure is valid; detached content and trust were not evaluated." + .to_string(), + signature_count: 1, + digest_algorithm, + timestamp_kinds, + diagnostics: Vec::new(), + }) + } + Err(error) => Ok(map_digest_error(path, format, error)), + } +} + +fn summarize_pkcs7_reports( + reports: impl IntoIterator, +) -> (Option, Vec) { + let mut digest_algorithm = None; + let mut timestamp_kinds = Vec::new(); + for report in reports { + collect_pkcs7_summary(&report, &mut digest_algorithm, &mut timestamp_kinds); + } + (digest_algorithm, timestamp_kinds) +} + +fn collect_pkcs7_summary( + report: &psign_authenticode_trust::inspect::InspectPkcs7Report, + digest_algorithm: &mut Option, + timestamp_kinds: &mut Vec, +) { + if digest_algorithm.is_none() + && let Some(digest) = &report.authenticode_digest + { + *digest_algorithm = Some(digest.digest_algorithm_oid.clone()); + } + for signer in &report.signers { + for hint in &signer.timestamp_hints { + let kind = hint.kind.to_string(); + if !timestamp_kinds.contains(&kind) { + timestamp_kinds.push(kind); + } + } + } + for nested in &report.nested_signatures { + collect_pkcs7_summary(nested, digest_algorithm, timestamp_kinds); + } +} + +fn base_response( + path: PathBuf, + format: PortableFileFormat, + status: PortableSignatureStatus, + message: impl Into, +) -> PortableSignatureResponse { + PortableSignatureResponse { + schema_version: SCHEMA_VERSION, + path, + format, + status, + status_message: message.into(), + signature_count: usize::from(status == PortableSignatureStatus::Valid), + digest_algorithm: None, + timestamp_kinds: Vec::new(), + diagnostics: Vec::new(), + } +} + +fn map_digest_error( + path: &Path, + format: PortableFileFormat, + error: anyhow::Error, +) -> PortableSignatureResponse { + let message = error.to_string(); + let status = if looks_unsigned(&message) { + PortableSignatureStatus::NotSigned + } else if message.to_ascii_lowercase().contains("mismatch") { + PortableSignatureStatus::HashMismatch + } else if format == PortableFileFormat::Unknown { + PortableSignatureStatus::NotSupportedFileFormat + } else { + PortableSignatureStatus::Incompatible + }; + PortableSignatureResponse { + schema_version: SCHEMA_VERSION, + path: path.to_path_buf(), + format, + status, + status_message: message, + signature_count: 0, + digest_algorithm: None, + timestamp_kinds: Vec::new(), + diagnostics: Vec::new(), + } +} + +fn looks_unsigned(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("not found") + || lower.contains("not signed") + || lower.contains("no certificate table") + || lower.contains("no pkcs#7") + || lower.contains("digital signature stream") + || lower.contains("signature comment not found") + || lower.contains("appxsignature.p7x") + || lower.contains("signature block") +} + +fn stage_flat_msix_for_signature( + package: &[u8], + digest_algorithm: PortableDigestAlgorithm, +) -> Result> { + let mut source = ZipArchive::new(Cursor::new(package)).context("open MSIX ZIP")?; + let mut payloads = Vec::new(); + let mut content_types = None; + + for i in 0..source.len() { + let mut entry = source.by_index(i).context("read MSIX ZIP entry")?; + let name = entry.name().replace('\\', "/"); + if name.ends_with('/') { + continue; + } + match name.as_str() { + "[Content_Types].xml" => { + let mut data = Vec::new(); + entry.read_to_end(&mut data)?; + content_types = Some(data); + } + "AppxBlockMap.xml" | "AppxSignature.p7x" | "AppxMetadata/CodeIntegrity.cat" => {} + _ => { + let mut data = Vec::new(); + entry.read_to_end(&mut data)?; + payloads.push((name, data)); + } + } + } + + if !payloads.iter().any(|(name, _)| name == "AppxManifest.xml") { + bail!("flat MSIX package is missing AppxManifest.xml"); + } + let content_types = add_msix_signature_content_type( + std::str::from_utf8( + content_types + .as_deref() + .ok_or_else(|| anyhow::anyhow!("MSIX package is missing [Content_Types].xml"))?, + ) + .context("[Content_Types].xml is not UTF-8")?, + )?; + let block_map = build_flat_msix_block_map(&payloads, digest_algorithm)?; + + let mut out = Cursor::new(Vec::new()); + { + let mut writer = ZipWriter::new(&mut out); + let stored = FileOptions::default().compression_method(CompressionMethod::Stored); + for (name, data) in &payloads { + writer.start_file(name, stored)?; + writer.write_all(data)?; + } + writer.start_file("AppxBlockMap.xml", stored)?; + writer.write_all(block_map.as_bytes())?; + writer.start_file("[Content_Types].xml", stored)?; + writer.write_all(content_types.as_bytes())?; + writer.start_file("AppxSignature.p7x", stored)?; + writer.write_all(b"PKCX")?; + writer.finish()?; + } + Ok(out.into_inner()) +} + +fn replace_msix_signature_part(package: &[u8], p7x: &[u8]) -> Result> { + let mut source = ZipArchive::new(Cursor::new(package)).context("open staged MSIX ZIP")?; + let mut out = Cursor::new(Vec::new()); + { + let mut writer = ZipWriter::new(&mut out); + for i in 0..source.len() { + let mut entry = source.by_index(i).context("read staged MSIX ZIP entry")?; + if entry.name().ends_with('/') { + continue; + } + let name = entry.name().to_owned(); + let options = FileOptions::default().compression_method(entry.compression()); + writer.start_file(&name, options)?; + if name == "AppxSignature.p7x" { + writer.write_all(p7x)?; + } else { + std::io::copy(&mut entry, &mut writer)?; + } + } + writer.finish()?; + } + Ok(out.into_inner()) +} + +fn add_msix_signature_content_type(xml: &str) -> Result { + if xml.contains("PartName=\"/AppxSignature.p7x\"") + || xml.contains("PartName='/AppxSignature.p7x'") + { + return Ok(xml.to_string()); + } + let insertion = r#""#; + if let Some(pos) = xml.rfind("") { + let mut out = String::with_capacity(xml.len() + insertion.len()); + out.push_str(&xml[..pos]); + out.push_str(insertion); + out.push_str(&xml[pos..]); + return Ok(out); + } + bail!("[Content_Types].xml does not contain a closing element"); +} + +fn build_flat_msix_block_map( + payloads: &[(String, Vec)], + digest_algorithm: PortableDigestAlgorithm, +) -> Result { + let hash_method = match digest_algorithm { + PortableDigestAlgorithm::Sha256 => "http://www.w3.org/2001/04/xmlenc#sha256", + PortableDigestAlgorithm::Sha384 => "http://www.w3.org/2001/04/xmldsig-more#sha384", + PortableDigestAlgorithm::Sha512 => "http://www.w3.org/2001/04/xmlenc#sha512", + }; + let mut xml = format!( + r#""# + ); + for (name, data) in payloads { + let escaped_name = xml_escape_attr(name); + xml.push_str(&format!( + r#""#, + data.len(), + 30 + name.len() + )); + for chunk in data.chunks(64 * 1024) { + let digest = match digest_algorithm { + PortableDigestAlgorithm::Sha256 => sha2::Sha256::digest(chunk).to_vec(), + PortableDigestAlgorithm::Sha384 => sha2::Sha384::digest(chunk).to_vec(), + PortableDigestAlgorithm::Sha512 => sha2::Sha512::digest(chunk).to_vec(), + }; + let encoded = base64::engine::general_purpose::STANDARD.encode(digest); + xml.push_str(&format!(r#""#)); + } + xml.push_str(""); + } + xml.push_str(""); + Ok(xml) +} + +fn xml_escape_attr(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} + +fn format_powershell_signature_block(pkcs7_der: &[u8]) -> String { + let b64 = base64::engine::general_purpose::STANDARD.encode(pkcs7_der); + let mut block = String::from("\r\n# SIG # Begin signature block\r\n"); + for chunk in b64.as_bytes().chunks(64) { + block.push_str("# "); + block.push_str(std::str::from_utf8(chunk).expect("base64 is ASCII")); + block.push_str("\r\n"); + } + block.push_str("# SIG # End signature block\r\n"); + block +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn infers_expected_formats() { + assert_eq!(infer_format(Path::new("tool.exe")), PortableFileFormat::Pe); + assert_eq!( + infer_format(Path::new("driver.SYS")), + PortableFileFormat::Pe + ); + assert_eq!( + infer_format(Path::new("package.nupkg")), + PortableFileFormat::Zip + ); + assert_eq!( + infer_format(Path::new("install.msi")), + PortableFileFormat::Msi + ); + assert_eq!( + infer_format(Path::new("script.ps1")), + PortableFileFormat::PowerShellScript + ); + assert_eq!( + infer_format(Path::new("unknown.bin")), + PortableFileFormat::Unknown + ); + } + + #[test] + fn reports_unsigned_pe_without_error() { + let path = PathBuf::from("../../tests/fixtures/pe-authenticode-upstream/tiny32.efi"); + let response = + portable_get_signature(PortableGetSignatureRequest { path }).expect("inspect PE"); + assert_eq!(response.status, PortableSignatureStatus::NotSigned); + } + + #[test] + fn reports_signed_pe_as_valid_digest_binding() { + let path = PathBuf::from("../../tests/fixtures/pe-authenticode-upstream/tiny32.signed.efi"); + let response = + portable_get_signature(PortableGetSignatureRequest { path }).expect("inspect PE"); + assert_eq!(response.status, PortableSignatureStatus::Valid); + assert!(response.signature_count > 0); + } +} diff --git a/crates/psign-portable-ffi/Cargo.toml b/crates/psign-portable-ffi/Cargo.toml new file mode 100644 index 0000000..2f277e7 --- /dev/null +++ b/crates/psign-portable-ffi/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "psign-portable-ffi" +version = "0.2.0" +edition = "2024" +description = "C ABI shared library for psign portable Authenticode operations" +license.workspace = true +repository.workspace = true + +[lib] +name = "psign_portable" +crate-type = ["cdylib", "rlib"] + +[dependencies] +anyhow = "1" +psign-portable-core = { path = "../psign-portable-core" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + diff --git a/crates/psign-portable-ffi/src/lib.rs b/crates/psign-portable-ffi/src/lib.rs new file mode 100644 index 0000000..c3de669 --- /dev/null +++ b/crates/psign-portable-ffi/src/lib.rs @@ -0,0 +1,192 @@ +//! C ABI boundary for `psign-portable-core`. + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use psign_portable_core::{ + PortableErrorCode, PortableErrorResponse, portable_error_response, portable_get_signature, + portable_sign, version, +}; +use serde::Serialize; +use serde::de::DeserializeOwned; + +const STATUS_OK: u32 = 0; +const STATUS_INVALID_REQUEST: u32 = 1; +const STATUS_OPERATION_FAILED: u32 = 2; +const STATUS_PANIC: u32 = 3; + +#[repr(C)] +pub struct PsignFfiBuffer { + pub ptr: *mut u8, + pub len: usize, + pub cap: usize, +} + +#[repr(C)] +pub struct PsignFfiResult { + pub status_code: u32, + pub json: PsignFfiBuffer, +} + +#[unsafe(no_mangle)] +pub extern "C" fn psign_portable_version() -> PsignFfiResult { + ok_json(&version()) +} + +/// Free a buffer returned by another `psign_portable_*` function. +/// +/// # Safety +/// +/// `buffer` must be a `PsignFfiBuffer` returned by this library and must not have +/// been freed already. Passing any other pointer, length, or capacity is undefined behavior. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn psign_portable_free(buffer: PsignFfiBuffer) { + if buffer.ptr.is_null() { + return; + } + if buffer.cap == 0 { + return; + } + unsafe { + let _ = Vec::from_raw_parts(buffer.ptr, buffer.len, buffer.cap); + } +} + +/// Inspect a file's portable Authenticode signature. +/// +/// # Safety +/// +/// `request_json_ptr` must point to `request_json_len` readable UTF-8 bytes for the duration +/// of the call. The returned buffer must be released with `psign_portable_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn psign_portable_get_signature( + request_json_ptr: *const u8, + request_json_len: usize, +) -> PsignFfiResult { + invoke_json(request_json_ptr, request_json_len, portable_get_signature) +} + +/// Sign a file with the portable Authenticode core. +/// +/// # Safety +/// +/// `request_json_ptr` must point to `request_json_len` readable UTF-8 bytes for the duration +/// of the call. The returned buffer must be released with `psign_portable_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn psign_portable_sign( + request_json_ptr: *const u8, + request_json_len: usize, +) -> PsignFfiResult { + invoke_json(request_json_ptr, request_json_len, portable_sign) +} + +fn invoke_json( + request_json_ptr: *const u8, + request_json_len: usize, + f: impl FnOnce(TRequest) -> anyhow::Result, +) -> PsignFfiResult +where + TRequest: DeserializeOwned, + TResponse: Serialize, +{ + match catch_unwind(AssertUnwindSafe(|| { + let request_json = unsafe { request_json_string(request_json_ptr, request_json_len) }?; + let request = serde_json::from_str::(request_json) + .map_err(|e| portable_error_response(PortableErrorCode::InvalidRequest, e))?; + f(request).map_err(|e| portable_error_response(PortableErrorCode::OperationFailed, e)) + })) { + Ok(Ok(response)) => ok_json(&response), + Ok(Err(error)) => error_json(status_invalid_request_or_operation(&error), &error), + Err(_) => error_json( + STATUS_PANIC, + &portable_error_response( + PortableErrorCode::Panic, + "panic crossing psign portable FFI boundary", + ), + ), + } +} + +fn status_invalid_request_or_operation(error: &PortableErrorResponse) -> u32 { + match error.code { + PortableErrorCode::InvalidRequest => STATUS_INVALID_REQUEST, + _ => STATUS_OPERATION_FAILED, + } +} + +unsafe fn request_json_string<'a>( + request_json_ptr: *const u8, + request_json_len: usize, +) -> Result<&'a str, PortableErrorResponse> { + if request_json_ptr.is_null() { + return Err(portable_error_response( + PortableErrorCode::InvalidRequest, + "request JSON pointer is null", + )); + } + let slice = unsafe { std::slice::from_raw_parts(request_json_ptr, request_json_len) }; + std::str::from_utf8(slice) + .map_err(|e| portable_error_response(PortableErrorCode::InvalidRequest, e)) +} + +fn ok_json(value: &impl Serialize) -> PsignFfiResult { + match serde_json::to_vec(value) { + Ok(bytes) => PsignFfiResult { + status_code: STATUS_OK, + json: into_buffer(bytes), + }, + Err(error) => error_json( + STATUS_OPERATION_FAILED, + &portable_error_response(PortableErrorCode::OperationFailed, error), + ), + } +} + +fn error_json(status_code: u32, error: &PortableErrorResponse) -> PsignFfiResult { + let bytes = serde_json::to_vec(error).unwrap_or_else(|_| { + br#"{"schema_version":1,"code":"OperationFailed","message":"failed to serialize psign portable error"}"#.to_vec() + }); + PsignFfiResult { + status_code, + json: into_buffer(bytes), + } +} + +fn into_buffer(mut bytes: Vec) -> PsignFfiBuffer { + bytes.shrink_to_fit(); + let buffer = PsignFfiBuffer { + ptr: bytes.as_mut_ptr(), + len: bytes.len(), + cap: bytes.capacity(), + }; + std::mem::forget(bytes); + buffer +} + +#[cfg(test)] +mod tests { + use super::*; + + unsafe fn result_json(result: PsignFfiResult) -> String { + let slice = unsafe { std::slice::from_raw_parts(result.json.ptr, result.json.len) }; + let text = std::str::from_utf8(slice).expect("utf-8").to_owned(); + unsafe { psign_portable_free(result.json) }; + text + } + + #[test] + fn version_returns_json() { + let result = psign_portable_version(); + assert_eq!(result.status_code, STATUS_OK); + let json = unsafe { result_json(result) }; + assert!(json.contains("psign-portable-core")); + } + + #[test] + fn invalid_json_returns_structured_error() { + let request = b"{not json"; + let result = unsafe { psign_portable_get_signature(request.as_ptr(), request.len()) }; + assert_eq!(result.status_code, STATUS_INVALID_REQUEST); + let json = unsafe { result_json(result) }; + assert!(json.contains("InvalidRequest")); + } +} diff --git a/crates/psign-sip-digest/src/msix_digest.rs b/crates/psign-sip-digest/src/msix_digest.rs index c746ba8..8d310f9 100644 --- a/crates/psign-sip-digest/src/msix_digest.rs +++ b/crates/psign-sip-digest/src/msix_digest.rs @@ -1881,6 +1881,83 @@ pub fn verify_msix_digest_consistency(path: &Path) -> Result<()> { verify_msix_digest_consistency_bytes(&buf, &ext) } +/// Compute the APPX Authenticode `messageDigest` blob for a cleartext MSIX / APPX package. +/// +/// The input package must already contain `AppxSignature.p7x` and the matching +/// `[Content_Types].xml` declaration. The signature entry bytes are ignored for +/// the ZIP-derived AXPC/AXCD digest pieces, matching `AppxSip.dll` semantics. +pub fn msix_authenticode_digest_blob( + buf: &[u8], + ext: &str, +) -> Result<(PeAuthenticodeHashKind, Vec)> { + let ext = ext.to_ascii_lowercase(); + if is_encrypted_msix_extension(&ext) { + return Err(anyhow!( + "Rust MSIX SIP digest parity applies only to cleartext OPC/ZIP packages (.msix, .appx, bundles)" + )); + } + if !matches!(ext.as_str(), "msix" | "appx" | "msixbundle" | "appxbundle") { + return Err(anyhow!( + "Rust MSIX SIP digest applies only to `.msix` / `.appx` / `.msixbundle` / `.appxbundle` files" + )); + } + + let tail = parse_zip_tail(buf)?; + let mut layout_archive = ZipArchive::new(Cursor::new(buf))?; + validate_zip_file_layout(buf, &mut layout_archive, &tail)?; + let mut archive = ZipArchive::new(Cursor::new(buf))?; + let inventory = validate_package_part_inventory(&archive, &ext)?; + + let block_map_bytes = read_zip_entry_raw(&mut archive, std::str::from_utf8(BLOCK_MAP)?)?; + let (kind, block_map_files) = parse_block_map_xml(&block_map_bytes)?; + let ct_raw = read_zip_entry_raw(&mut archive, std::str::from_utf8(CONTENT_TYPES)?)?; + let content_types = validate_content_types_xml(&ct_raw)?; + validate_reserved_content_types(&content_types, inventory, &ext)?; + + let ci_raw = if inventory.has_code_integrity { + Some(read_zip_entry_raw( + &mut archive, + std::str::from_utf8(CODE_INTEGRITY)?, + )?) + } else { + None + }; + + let ct_hash = hash_bytes(kind, &ct_raw); + let bm_hash = hash_bytes(kind, &block_map_bytes); + let ci_hash = ci_raw.as_ref().map(|data| hash_bytes(kind, data)); + + let mut archive2 = ZipArchive::new(Cursor::new(buf))?; + let (data_hash, cd_off_virt) = hash_zip_file_data(buf, &mut archive2, &tail, kind)?; + let mut archive3 = ZipArchive::new(Cursor::new(buf))?; + let cd_hash = hash_cd_and_eocd(buf, &mut archive3, &tail, kind, cd_off_virt)?; + + validate_block_map_file_hashes(buf, kind, &block_map_files)?; + if matches!(ext.as_str(), "msixbundle" | "appxbundle") { + verify_bundle_child_packages(buf)?; + } + + let mut blob = Vec::with_capacity( + 4 + (4 + kind.digest_output_len()) * (4 + usize::from(ci_hash.is_some())), + ); + blob.extend_from_slice(SIG_APPX); + for (tag, digest) in [ + (TAG_AXPC, data_hash.as_slice()), + (TAG_AXCD, cd_hash.as_slice()), + (TAG_AXCT, ct_hash.as_slice()), + (TAG_AXBM, bm_hash.as_slice()), + ] { + blob.extend_from_slice(tag); + blob.extend_from_slice(digest); + } + if let Some(ci_hash) = ci_hash { + blob.extend_from_slice(TAG_AXCI); + blob.extend_from_slice(&ci_hash); + } + + Ok((kind, blob)) +} + fn verify_msix_digest_consistency_bytes(buf: &[u8], ext: &str) -> Result<()> { let tail = parse_zip_tail(buf)?; let mut layout_archive = ZipArchive::new(Cursor::new(buf))?; diff --git a/crates/psign-sip-digest/src/pkcs7.rs b/crates/psign-sip-digest/src/pkcs7.rs index a3e01c4..9f6712c 100644 --- a/crates/psign-sip-digest/src/pkcs7.rs +++ b/crates/psign-sip-digest/src/pkcs7.rs @@ -275,6 +275,49 @@ pub fn create_msi_authenticode_pkcs7_der_rsa( ) } +/// Create PKCS#7 `ContentInfo(SignedData)` DER for a cleartext MSIX / APPX package. +/// +/// `package_with_signature_part` must already contain the final `[Content_Types].xml` +/// declaration and an `AppxSignature.p7x` placeholder. The signature part bytes are +/// excluded from the APPX digest blob by the MSIX SIP hash routine. +pub fn create_msix_authenticode_pkcs7_der_rsa( + package_with_signature_part: &[u8], + extension: &str, + digest_algorithm: AuthenticodeSigningDigest, + signer_cert: Certificate, + chain_certs: Vec, + private_key: RsaPrivateKey, +) -> Result> { + let (kind, appx_blob) = + crate::msix_digest::msix_authenticode_digest_blob(package_with_signature_part, extension)?; + if kind != digest_algorithm.pe_hash_kind() { + return Err(anyhow!( + "MSIX AppxBlockMap HashMethod {:?} does not match requested signing digest {:?}", + kind, + digest_algorithm + )); + } + let indirect = SpcIndirectDataContent { + data: SpcAttributeTypeAndOptionalValue { + value_type: SPC_MSI_SIGINFO_OBJID, + value: Any::from_der(SPC_MSI_SIGINFO_VALUE_DER) + .map_err(|e| anyhow!("SPC_MSIX_SIGINFO Any: {e}"))?, + }, + message_digest: DigestInfo { + digest_algorithm: digest_algorithm.digest_algorithm(), + digest: OctetString::new(appx_blob) + .map_err(|e| anyhow!("APPX SpcIndirectData digest OCTET STRING: {e}"))?, + }, + }; + create_authenticode_pkcs7_der_rsa( + indirect, + digest_algorithm, + signer_cert, + chain_certs, + private_key, + ) +} + /// Create PKCS#7 `ContentInfo(SignedData)` DER for a PE Authenticode signature using an RSA private key. /// /// This is the portable CMS producer used before format-specific embedding (for PE, `pe_embed` wraps the diff --git a/docs/portable-core-ffi.md b/docs/portable-core-ffi.md new file mode 100644 index 0000000..2788103 --- /dev/null +++ b/docs/portable-core-ffi.md @@ -0,0 +1,14 @@ +# Portable core FFI + +`psign-portable-ffi` exposes the reusable portable Authenticode core as a native shared library for managed callers such as a PowerShell 7.4 / .NET 8 binary module. + +The ABI is intentionally small and JSON-based: + +- `psign_portable_version()` +- `psign_portable_get_signature(request_json_ptr, request_json_len)` +- `psign_portable_sign(request_json_ptr, request_json_len)` +- `psign_portable_free(buffer)` + +All request and response JSON uses UTF-8. Callers pass borrowed input buffers; Rust returns an owned `PsignFfiBuffer { ptr, len, cap }`. Managed callers must copy the bytes immediately and call `psign_portable_free` exactly once with the returned buffer. + +The first schema version supports portable digest/signature inspection and local RSA signing for PE, CAB, MSI, ZIP Authenticode, MSIX/AppX packages, and PowerShell script inputs. `Set-PortableSignature` accepts certificate/key paths, exportable `X509Certificate2` values, and exportable PFX files. `Set-PortableSignature` and `Get-PortableSignature` also accept PowerShell module directories and expand them to signable `.ps1`, `.psm1`, and `.psd1` files. Trust evaluation remains explicit and will be added to the same JSON ABI rather than falling back to OS trust. diff --git a/docs/portable-powershell-module.md b/docs/portable-powershell-module.md new file mode 100644 index 0000000..1f4b4e4 --- /dev/null +++ b/docs/portable-powershell-module.md @@ -0,0 +1,43 @@ +# Portable PowerShell module + +`Devolutions.Psign` is a PowerShell 7.4 / .NET 8 binary module that exposes portable Authenticode cmdlets over the Rust `psign_portable` shared library. The module does not call `WinVerifyTrust`, `CryptUIWizDigitalSign`, `SignerSignEx`, or registered Windows SIP DLLs. + +## Cmdlets + +- `Set-PortableSignature` +- `Get-PortableSignature` + +Both cmdlets accept `-FilePath` and `-LiteralPath`. When the input is a directory, the module treats it as a PowerShell module tree and recursively processes `.ps1`, `.psm1`, and `.psd1` files. + +## Signing inputs + +`Set-PortableSignature` supports local exportable RSA key material: + +```powershell +Set-PortableSignature -LiteralPath .\tool.exe -CertificatePath .\signer.cer -PrivateKeyPath .\signer.key +Set-PortableSignature -LiteralPath .\script.ps1 -Certificate $exportableCertificate +Set-PortableSignature -LiteralPath .\package.msix -PfxPath .\signer.pfx -Password $password +``` + +The P/Invoke ABI also accepts in-memory DER certificate and PKCS#8 private-key material. Non-exportable local keys fail explicitly; use file-backed key material or a future remote signing provider for those cases. + +## Supported portable formats + +The current module tests cover signing and validation through the PowerShell surface for: + +- PE files +- PowerShell scripts +- PowerShell module directories +- MSIX/AppX packages + +The shared core also exposes local signing/inspection for CAB, MSI/MSP, and Devolutions ZIP Authenticode. Signature inspection validates portable digest binding and signature structure; it does not claim OS trust. + +## Build, test, and package + +```powershell +pwsh -File .\PowerShell\build.ps1 -Configuration Release +pwsh -File .\PowerShell\tests\Invoke-PortableSignatureTests.ps1 -Configuration Release +pwsh -File .\PowerShell\package.ps1 -Configuration Release +``` + +The build stages the native library under the module RID layout, for example `runtimes\win-x64\native\psign_portable.dll`, so .NET can load it via the module resolver. diff --git a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPortableSignatureCommand.cs b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPortableSignatureCommand.cs new file mode 100644 index 0000000..cadea04 --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPortableSignatureCommand.cs @@ -0,0 +1,56 @@ +using System.Management.Automation; +using Devolutions.Psign.PowerShell.Models; +using Devolutions.Psign.PowerShell.Native; +using Devolutions.Psign.PowerShell.Utilities; + +namespace Devolutions.Psign.PowerShell.Cmdlets; + +[Cmdlet(VerbsCommon.Get, "PortableSignature", DefaultParameterSetName = FilePathParameterSet)] +[OutputType(typeof(PortableSignature))] +public sealed class GetPortableSignatureCommand : PSCmdlet +{ + private const string FilePathParameterSet = "FilePath"; + private const string LiteralPathParameterSet = "LiteralPath"; + + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true, ParameterSetName = FilePathParameterSet)] + [Alias("Path")] + public string[] FilePath { get; set; } = []; + + [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, ParameterSetName = LiteralPathParameterSet)] + [Alias("PSPath")] + public string[] LiteralPath { get; set; } = []; + + protected override void ProcessRecord() + { + bool literal = ParameterSetName == LiteralPathParameterSet; + string[] inputs = literal ? LiteralPath : FilePath; + foreach (string input in inputs) + { + foreach (string resolved in PathResolution.ResolveFilePaths(this, input, literal)) + { + WriteSignature(resolved); + } + } + } + + private void WriteSignature(string path) + { + try + { + if (Directory.Exists(path)) + { + foreach (string moduleFile in PortableModuleFiles.Enumerate(path)) + { + WriteSignature(moduleFile); + } + return; + } + + WriteObject(PsignNative.GetSignature(new PortableGetSignatureRequest { Path = path })); + } + catch (Exception ex) + { + WriteError(new ErrorRecord(ex, "GetPortableSignatureFailed", ErrorCategory.NotSpecified, path)); + } + } +} diff --git a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs new file mode 100644 index 0000000..18013e8 --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs @@ -0,0 +1,270 @@ +using System.Runtime.InteropServices; +using System.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Management.Automation; +using Devolutions.Psign.PowerShell.Models; +using Devolutions.Psign.PowerShell.Native; +using Devolutions.Psign.PowerShell.Utilities; + +namespace Devolutions.Psign.PowerShell.Cmdlets; + +[Cmdlet(VerbsCommon.Set, "PortableSignature", SupportsShouldProcess = true, DefaultParameterSetName = FilePathParameterSet)] +[OutputType(typeof(PortableSignature))] +public sealed class SetPortableSignatureCommand : PSCmdlet +{ + private const string FilePathParameterSet = "FilePath"; + private const string LiteralPathParameterSet = "LiteralPath"; + + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true, ParameterSetName = FilePathParameterSet)] + [Alias("Path")] + public string[] FilePath { get; set; } = []; + + [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, ParameterSetName = LiteralPathParameterSet)] + [Alias("PSPath")] + public string[] LiteralPath { get; set; } = []; + + [Parameter] + public X509Certificate2? Certificate { get; set; } + + [Parameter] + public string? CertificatePath { get; set; } + + [Parameter] + public string? PrivateKeyPath { get; set; } + + [Parameter] + public string? PfxPath { get; set; } + + [Parameter] + public SecureString? Password { get; set; } + + [Parameter] + [ValidateSet("Sha256", "Sha384", "Sha512")] + public string HashAlgorithm { get; set; } = "Sha256"; + + [Parameter] + public string? OutputPath { get; set; } + + [Parameter] + public SwitchParameter Force { get; set; } + + protected override void ProcessRecord() + { + ValidateSigningMaterial(); + bool literal = ParameterSetName == LiteralPathParameterSet; + string[] inputs = literal ? LiteralPath : FilePath; + if (OutputPath is not null && inputs.Length != 1) + { + ThrowTerminatingError(new ErrorRecord( + new PSInvalidOperationException("-OutputPath can only be used with a single input file."), + "PortableSignatureOutputPathRequiresSingleInput", + ErrorCategory.InvalidArgument, + OutputPath)); + } + + foreach (string input in inputs) + { + IReadOnlyList resolvedPaths = PathResolution.ResolveFilePaths(this, input, literal); + if (OutputPath is not null + && (resolvedPaths.Count != 1 || Directory.Exists(resolvedPaths[0]))) + { + ThrowTerminatingError(new ErrorRecord( + new PSInvalidOperationException("-OutputPath can only be used with a single input file, not module directories or wildcard groups."), + "PortableSignatureOutputPathRequiresSingleInput", + ErrorCategory.InvalidArgument, + input)); + } + + foreach (string resolved in resolvedPaths) + { + SignPath(resolved); + } + } + } + + private void SignPath(string path) + { + try + { + if (Directory.Exists(path)) + { + foreach (string moduleFile in PortableModuleFiles.Enumerate(path)) + { + SignPath(moduleFile); + } + return; + } + + string? outputPath = OutputPath is null + ? null + : SessionState.Path.GetUnresolvedProviderPathFromPSPath(OutputPath); + string target = outputPath ?? path; + if (!ShouldProcess(target, "Set portable Authenticode signature")) + { + return; + } + + FileAttributes? restoreAttributes = PrepareWritableTarget(path, target); + try + { + PortableSignResponse response = PsignNative.Sign(new PortableSignRequest + { + Path = path, + OutputPath = outputPath, + HashAlgorithm = HashAlgorithm, + CertificatePath = CertificatePath is null + ? null + : SessionState.Path.GetUnresolvedProviderPathFromPSPath(CertificatePath), + PrivateKeyPath = PrivateKeyPath is null + ? null + : SessionState.Path.GetUnresolvedProviderPathFromPSPath(PrivateKeyPath), + CertificateDerBase64 = GetCertificateDerBase64(), + PrivateKeyDerBase64 = GetPrivateKeyDerBase64(), + }); + WriteObject(response.Signature); + } + finally + { + if (restoreAttributes is not null) + { + File.SetAttributes(target, restoreAttributes.Value); + } + } + } + catch (Exception ex) + { + WriteError(new ErrorRecord(ex, "SetPortableSignatureFailed", ErrorCategory.NotSpecified, path)); + } + } + + private FileAttributes? PrepareWritableTarget(string inputPath, string targetPath) + { + if (!StringComparer.OrdinalIgnoreCase.Equals(Path.GetFullPath(inputPath), Path.GetFullPath(targetPath))) + { + return null; + } + + FileAttributes attributes = File.GetAttributes(targetPath); + if ((attributes & FileAttributes.ReadOnly) == 0) + { + return null; + } + + if (!Force) + { + throw new UnauthorizedAccessException( + $"File '{targetPath}' is read-only. Use -Force to sign it in place."); + } + + File.SetAttributes(targetPath, attributes & ~FileAttributes.ReadOnly); + return attributes; + } + + private void ValidateSigningMaterial() + { + int materialCount = 0; + if (Certificate is not null) + { + materialCount++; + } + if (CertificatePath is not null || PrivateKeyPath is not null) + { + if (CertificatePath is null || PrivateKeyPath is null) + { + ThrowTerminatingError(new ErrorRecord( + new PSInvalidOperationException("-CertificatePath and -PrivateKeyPath must be supplied together."), + "PortableSignatureIncompleteKeyPair", + ErrorCategory.InvalidArgument, + this)); + } + materialCount++; + } + if (PfxPath is not null) + { + materialCount++; + } + + if (materialCount != 1) + { + ThrowTerminatingError(new ErrorRecord( + new PSInvalidOperationException("Supply exactly one signing source: -Certificate, -CertificatePath/-PrivateKeyPath, or -PfxPath."), + "PortableSignatureSigningMaterialRequired", + ErrorCategory.InvalidArgument, + this)); + } + } + + private X509Certificate2? LoadPfxCertificate() + { + if (PfxPath is null) + { + return null; + } + + string resolved = SessionState.Path.GetUnresolvedProviderPathFromPSPath(PfxPath); + string? password = Password is null ? null : SecureStringToString(Password); + try + { + return new X509Certificate2( + resolved, + password, + X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet); + } + finally + { + if (password is not null) + { + password = null; + } + } + } + + private string? GetCertificateDerBase64() + { + X509Certificate2? cert = Certificate ?? LoadPfxCertificate(); + if (cert is null) + { + return null; + } + return Convert.ToBase64String(cert.Export(X509ContentType.Cert)); + } + + private string? GetPrivateKeyDerBase64() + { + X509Certificate2? cert = Certificate ?? LoadPfxCertificate(); + if (cert is null) + { + return null; + } + + using RSA? rsa = cert.GetRSAPrivateKey(); + if (rsa is null) + { + throw new PSInvalidOperationException("Portable signing requires an exportable RSA private key."); + } + try + { + return Convert.ToBase64String(rsa.ExportPkcs8PrivateKey()); + } + catch (CryptographicException ex) + { + throw new PSInvalidOperationException( + "Portable signing requires exportable key material. Use -CertificatePath/-PrivateKeyPath, -PfxPath with an exportable key, or a remote signer.", + ex); + } + } + + private static string SecureStringToString(SecureString value) + { + IntPtr ptr = Marshal.SecureStringToBSTR(value); + try + { + return Marshal.PtrToStringBSTR(ptr); + } + finally + { + Marshal.ZeroFreeBSTR(ptr); + } + } +} diff --git a/dotnet/Devolutions.Psign.PowerShell/Devolutions.Psign.PowerShell.csproj b/dotnet/Devolutions.Psign.PowerShell/Devolutions.Psign.PowerShell.csproj new file mode 100644 index 0000000..cba6fae --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Devolutions.Psign.PowerShell.csproj @@ -0,0 +1,17 @@ + + + net8.0 + Devolutions.Psign.PowerShell + Devolutions.Psign.PowerShell + enable + enable + true + true + false + $(NoWarn);CA2255 + + + + + + diff --git a/dotnet/Devolutions.Psign.PowerShell/Models/PortableErrorResponse.cs b/dotnet/Devolutions.Psign.PowerShell/Models/PortableErrorResponse.cs new file mode 100644 index 0000000..76faaf1 --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Models/PortableErrorResponse.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Devolutions.Psign.PowerShell.Models; + +internal sealed class PortableErrorResponse +{ + [JsonPropertyName("schema_version")] + public int SchemaVersion { get; init; } + + [JsonPropertyName("code")] + public string Code { get; init; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; init; } = string.Empty; +} diff --git a/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs b/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs new file mode 100644 index 0000000..165ea1f --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace Devolutions.Psign.PowerShell.Models; + +internal sealed class PortableGetSignatureRequest +{ + [JsonPropertyName("path")] + public required string Path { get; init; } +} + +internal sealed class PortableSignRequest +{ + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("output_path")] + public string? OutputPath { get; init; } + + [JsonPropertyName("hash_algorithm")] + public string HashAlgorithm { get; init; } = "Sha256"; + + [JsonPropertyName("certificate_path")] + public string? CertificatePath { get; init; } + + [JsonPropertyName("private_key_path")] + public string? PrivateKeyPath { get; init; } + + [JsonPropertyName("certificate_der_base64")] + public string? CertificateDerBase64 { get; init; } + + [JsonPropertyName("private_key_der_base64")] + public string? PrivateKeyDerBase64 { get; init; } +} diff --git a/dotnet/Devolutions.Psign.PowerShell/Models/PortableSignResponse.cs b/dotnet/Devolutions.Psign.PowerShell/Models/PortableSignResponse.cs new file mode 100644 index 0000000..be1cb18 --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Models/PortableSignResponse.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Devolutions.Psign.PowerShell.Models; + +internal sealed class PortableSignResponse +{ + [JsonPropertyName("schema_version")] + public int SchemaVersion { get; init; } + + [JsonPropertyName("input_path")] + public string InputPath { get; init; } = string.Empty; + + [JsonPropertyName("output_path")] + public string OutputPath { get; init; } = string.Empty; + + [JsonPropertyName("format")] + public string Format { get; init; } = string.Empty; + + [JsonPropertyName("signature")] + public PortableSignature Signature { get; init; } = new(); +} diff --git a/dotnet/Devolutions.Psign.PowerShell/Models/PortableSignature.cs b/dotnet/Devolutions.Psign.PowerShell/Models/PortableSignature.cs new file mode 100644 index 0000000..edb10b9 --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Models/PortableSignature.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; + +namespace Devolutions.Psign.PowerShell.Models; + +public sealed class PortableSignature +{ + [JsonPropertyName("schema_version")] + public int SchemaVersion { get; init; } + + [JsonPropertyName("path")] + public string Path { get; init; } = string.Empty; + + [JsonPropertyName("format")] + public string Format { get; init; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; init; } = string.Empty; + + [JsonPropertyName("status_message")] + public string StatusMessage { get; init; } = string.Empty; + + [JsonPropertyName("signature_count")] + public int SignatureCount { get; init; } + + [JsonPropertyName("digest_algorithm")] + public string? DigestAlgorithm { get; init; } + + [JsonPropertyName("timestamp_kinds")] + public string[] TimestampKinds { get; init; } = []; + + [JsonPropertyName("diagnostics")] + public string[] PortableDiagnostics { get; init; } = []; + + public object? SignerCertificate => null; + + public object? TimeStamperCertificate => null; + + public string SignatureType => Format; + + public bool IsOSBinary => false; +} diff --git a/dotnet/Devolutions.Psign.PowerShell/Native/PsignNative.cs b/dotnet/Devolutions.Psign.PowerShell/Native/PsignNative.cs new file mode 100644 index 0000000..71bb368 --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Native/PsignNative.cs @@ -0,0 +1,95 @@ +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using Devolutions.Psign.PowerShell.Models; + +namespace Devolutions.Psign.PowerShell.Native; + +internal static unsafe class PsignNative +{ + private const string LibraryName = "psign_portable"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = null, + WriteIndented = false, + }; + + internal static PortableSignature GetSignature(PortableGetSignatureRequest request) + { + return Invoke(request, psign_portable_get_signature); + } + + internal static PortableSignResponse Sign(PortableSignRequest request) + { + return Invoke(request, psign_portable_sign); + } + + private delegate PsignFfiResult NativeCall(IntPtr requestJsonPtr, UIntPtr requestJsonLen); + + private static TResponse Invoke(TRequest request, NativeCall nativeCall) + { + byte[] requestJson = JsonSerializer.SerializeToUtf8Bytes(request, JsonOptions); + PsignFfiResult result; + fixed (byte* requestPtr = requestJson) + { + result = nativeCall((IntPtr)requestPtr, (UIntPtr)requestJson.Length); + } + + byte[] responseJson; + try + { + responseJson = CopyResponse(result.Json); + } + finally + { + psign_portable_free(result.Json); + } + + if (result.StatusCode != 0) + { + PortableErrorResponse? error = JsonSerializer.Deserialize(responseJson, JsonOptions); + string message = error?.Message ?? Encoding.UTF8.GetString(responseJson); + throw new PsignPortableException(result.StatusCode, message); + } + + return JsonSerializer.Deserialize(responseJson, JsonOptions) + ?? throw new PsignPortableException(result.StatusCode, "psign portable returned an empty JSON response."); + } + + private static byte[] CopyResponse(PsignFfiBuffer buffer) + { + if (buffer.Ptr == IntPtr.Zero || buffer.Len == UIntPtr.Zero) + { + return []; + } + + byte[] bytes = new byte[(int)buffer.Len]; + Marshal.Copy(buffer.Ptr, bytes, 0, bytes.Length); + return bytes; + } + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + private static extern PsignFfiResult psign_portable_get_signature(IntPtr requestJsonPtr, UIntPtr requestJsonLen); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + private static extern PsignFfiResult psign_portable_sign(IntPtr requestJsonPtr, UIntPtr requestJsonLen); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + private static extern void psign_portable_free(PsignFfiBuffer buffer); +} + +[StructLayout(LayoutKind.Sequential)] +internal readonly struct PsignFfiBuffer +{ + public readonly IntPtr Ptr; + public readonly UIntPtr Len; + public readonly UIntPtr Cap; +} + +[StructLayout(LayoutKind.Sequential)] +internal readonly struct PsignFfiResult +{ + public readonly uint StatusCode; + public readonly PsignFfiBuffer Json; +} diff --git a/dotnet/Devolutions.Psign.PowerShell/Native/PsignNativeResolver.cs b/dotnet/Devolutions.Psign.PowerShell/Native/PsignNativeResolver.cs new file mode 100644 index 0000000..f845141 --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Native/PsignNativeResolver.cs @@ -0,0 +1,85 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace Devolutions.Psign.PowerShell.Native; + +internal static class PsignNativeResolver +{ + private const string LibraryName = "psign_portable"; + + [ModuleInitializer] + internal static void Register() + { + NativeLibrary.SetDllImportResolver(typeof(PsignNativeResolver).Assembly, Resolve); + } + + private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) + { + if (!StringComparer.Ordinal.Equals(libraryName, LibraryName)) + { + return IntPtr.Zero; + } + + string moduleRoot = GetModuleRoot(assembly); + string rid = GetCurrentRid(); + string fileName = GetNativeFileName(); + string nativePath = Path.Combine(moduleRoot, "runtimes", rid, "native", fileName); + + if (!File.Exists(nativePath)) + { + throw new DllNotFoundException( + $"Could not find psign portable native library for RID '{rid}'. Expected '{nativePath}'."); + } + + return NativeLibrary.Load(nativePath, assembly, searchPath); + } + + private static string GetModuleRoot(Assembly assembly) + { + string assemblyDirectory = Path.GetDirectoryName(assembly.Location) + ?? throw new InvalidOperationException("The module assembly has no load path."); + DirectoryInfo directory = new(assemblyDirectory); + + if (StringComparer.OrdinalIgnoreCase.Equals(directory.Name, "net8.0") + && directory.Parent is { Name: "lib" } lib + && lib.Parent is not null) + { + return lib.Parent.FullName; + } + + return assemblyDirectory; + } + + private static string GetNativeFileName() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "psign_portable.dll"; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "libpsign_portable.dylib"; + } + + return "libpsign_portable.so"; + } + + private static string GetCurrentRid() + { + string os = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "win" + : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "osx" + : "linux"; + string arch = RuntimeInformation.ProcessArchitecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + Architecture.X86 => "x86", + Architecture.Arm => "arm", + _ => RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(), + }; + + return $"{os}-{arch}"; + } +} diff --git a/dotnet/Devolutions.Psign.PowerShell/Native/PsignPortableException.cs b/dotnet/Devolutions.Psign.PowerShell/Native/PsignPortableException.cs new file mode 100644 index 0000000..e33ff9b --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Native/PsignPortableException.cs @@ -0,0 +1,6 @@ +namespace Devolutions.Psign.PowerShell.Native; + +internal sealed class PsignPortableException(uint statusCode, string message) : Exception(message) +{ + public uint StatusCode { get; } = statusCode; +} diff --git a/dotnet/Devolutions.Psign.PowerShell/Utilities/PathResolution.cs b/dotnet/Devolutions.Psign.PowerShell/Utilities/PathResolution.cs new file mode 100644 index 0000000..d184bcc --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Utilities/PathResolution.cs @@ -0,0 +1,25 @@ +using System.Collections.ObjectModel; +using System.Management.Automation; + +namespace Devolutions.Psign.PowerShell.Utilities; + +internal static class PathResolution +{ + internal static IReadOnlyList ResolveFilePaths(PSCmdlet cmdlet, string path, bool literal) + { + if (literal) + { + return [cmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath(path)]; + } + + Collection resolved = cmdlet.SessionState.Path.GetResolvedProviderPathFromPSPath( + path, + out ProviderInfo provider); + if (!StringComparer.OrdinalIgnoreCase.Equals(provider.Name, "FileSystem")) + { + throw new PSInvalidOperationException($"Only FileSystem paths are supported; provider was '{provider.Name}'."); + } + + return resolved; + } +} diff --git a/dotnet/Devolutions.Psign.PowerShell/Utilities/PortableModuleFiles.cs b/dotnet/Devolutions.Psign.PowerShell/Utilities/PortableModuleFiles.cs new file mode 100644 index 0000000..d55483b --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Utilities/PortableModuleFiles.cs @@ -0,0 +1,20 @@ +namespace Devolutions.Psign.PowerShell.Utilities; + +internal static class PortableModuleFiles +{ + private static readonly HashSet SignablePowerShellExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".ps1", + ".psm1", + ".psd1", + }; + + internal static IReadOnlyList Enumerate(string directory) + { + return Directory + .EnumerateFiles(directory, "*", SearchOption.AllDirectories) + .Where(path => SignablePowerShellExtensions.Contains(Path.GetExtension(path))) + .Order(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } +} diff --git a/scripts/linux-portable-validation.sh b/scripts/linux-portable-validation.sh index bc2a64a..cf57476 100644 --- a/scripts/linux-portable-validation.sh +++ b/scripts/linux-portable-validation.sh @@ -14,6 +14,7 @@ cargo metadata --locked --format-version 1 >/dev/null echo "== clippy portable crates ==" cargo clippy -p psign-sip-digest -p psign-digest-cli -p psign-authenticode-trust \ + -p psign-portable-core -p psign-portable-ffi \ -p psign-codesigning-rest -p psign-azure-kv-rest \ --all-targets --locked -- -D warnings @@ -35,6 +36,12 @@ cargo test -p psign-sip-digest --lib --locked echo "== unit tests: authenticode-trust ==" cargo test -p psign-authenticode-trust --lib --locked +echo "== unit tests: portable-core ==" +cargo test -p psign-portable-core --locked + +echo "== unit tests: portable-ffi ==" +cargo test -p psign-portable-ffi --locked + echo "== unit tests: codesigning-rest ==" cargo test -p psign-codesigning-rest --lib --locked diff --git a/src/bin/psign-server.rs b/src/bin/psign-server.rs index 3b9c2f0..fc3c0a8 100644 --- a/src/bin/psign-server.rs +++ b/src/bin/psign-server.rs @@ -54,6 +54,7 @@ struct Cli { } #[derive(Subcommand, Debug)] +#[allow(clippy::enum_variant_names)] enum Command { /// Serve a local RFC 3161 timestamp authority for deterministic tests. TimestampServer(TimestampServerArgs), @@ -1174,15 +1175,15 @@ fn handle_artifact_signing_client( .get("signatureAlgorithm") .and_then(Value::as_str) .ok_or_else(|| anyhow!("Artifact Signing submit body missing signatureAlgorithm"))?; - if let Some(expected) = authority.expect_signature_algorithm.as_deref() { - if alg != expected.trim() { - return write_json_response( - &mut stream, - 400, - "Bad Request", - &serde_json::json!({"error":{"code":"UnexpectedSignatureAlgorithm","expected":expected,"actual":alg}}), - ); - } + if let Some(expected) = authority.expect_signature_algorithm.as_deref() + && alg != expected.trim() + { + return write_json_response( + &mut stream, + 400, + "Bad Request", + &serde_json::json!({"error":{"code":"UnexpectedSignatureAlgorithm","expected":expected,"actual":alg}}), + ); } let digest_b64 = body .get("digest") From 606921a4ce066e6870adf3981b440146c1a7eccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 21 May 2026 20:19:28 -0400 Subject: [PATCH 02/10] Close portable PowerShell Authenticode gaps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/powershell-module.yml | 11 + Cargo.lock | 4 + PowerShell/package.ps1 | 28 + .../tests/Invoke-PortableSignatureTests.ps1 | 216 ++++++- README.md | 4 +- crates/psign-authenticode-trust/Cargo.toml | 1 + .../psign-authenticode-trust/src/inspect.rs | 60 +- crates/psign-authenticode-trust/src/lib.rs | 2 + .../src/trust_verify_script.rs | 42 ++ crates/psign-portable-core/Cargo.toml | 3 + crates/psign-portable-core/src/lib.rs | 585 ++++++++++++++++-- docs/portable-core-ffi.md | 4 +- docs/portable-powershell-module.md | 36 +- .../Cmdlets/GetPortableSignatureCommand.cs | 119 +++- .../Cmdlets/SetPortableSignatureCommand.cs | 317 +++++++++- .../Models/PortableRequests.cs | 42 ++ .../Models/PortableSignature.cs | 43 +- 17 files changed, 1445 insertions(+), 72 deletions(-) create mode 100644 crates/psign-authenticode-trust/src/trust_verify_script.rs diff --git a/.github/workflows/powershell-module.yml b/.github/workflows/powershell-module.yml index 28bbb29..aab6104 100644 --- a/.github/workflows/powershell-module.yml +++ b/.github/workflows/powershell-module.yml @@ -48,9 +48,20 @@ jobs: shell: pwsh run: ./PowerShell/tests/Invoke-PortableSignatureTests.ps1 -Configuration Release + - name: Package PowerShell module for current RID + shell: pwsh + run: ./PowerShell/package.ps1 -Configuration Release + - name: Upload staged module uses: actions/upload-artifact@v7 with: name: Devolutions.Psign-${{ matrix.name }} path: PowerShell/Devolutions.Psign if-no-files-found: error + + - name: Upload module package + uses: actions/upload-artifact@v7 + with: + name: Devolutions.Psign-package-${{ matrix.name }} + path: artifacts/powershell/*.nupkg + if-no-files-found: error diff --git a/Cargo.lock b/Cargo.lock index b0699c2..3739b57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2040,6 +2040,7 @@ version = "0.2.0" dependencies = [ "anyhow", "authenticode", + "base64", "cab", "cms", "der 0.7.10", @@ -2125,11 +2126,14 @@ version = "0.2.0" dependencies = [ "anyhow", "base64", + "picky", "psign-authenticode-trust", "psign-sip-digest", + "reqwest", "rsa 0.9.10", "serde", "serde_json", + "sha1 0.10.6", "sha2 0.10.9", "x509-cert", "zip", diff --git a/PowerShell/package.ps1 b/PowerShell/package.ps1 index df10764..8d5e06a 100644 --- a/PowerShell/package.ps1 +++ b/PowerShell/package.ps1 @@ -10,12 +10,23 @@ $ErrorActionPreference = 'Stop' $repo = Split-Path -Parent $PSScriptRoot $moduleRoot = Join-Path $PSScriptRoot 'Devolutions.Psign' $localRepo = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N')) +$installRoot = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N')) $repoName = "DevolutionsPsignLocal$([System.Guid]::NewGuid().ToString('N'))" & (Join-Path $PSScriptRoot 'build.ps1') -Configuration $Configuration New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null New-Item -ItemType Directory -Force -Path $localRepo | Out-Null +New-Item -ItemType Directory -Force -Path $installRoot | Out-Null + +$manifestPath = Join-Path $moduleRoot 'Devolutions.Psign.psd1' +$manifest = Test-ModuleManifest -Path $manifestPath +$expectedCmdlets = @('Get-PortableSignature', 'Set-PortableSignature') +foreach ($cmdlet in $expectedCmdlets) { + if ($manifest.ExportedCmdlets.Keys -notcontains $cmdlet) { + throw "Module manifest does not export expected cmdlet '$cmdlet'." + } +} try { Register-PSRepository -Name $repoName -SourceLocation $localRepo -PublishLocation $localRepo -InstallationPolicy Trusted @@ -29,9 +40,26 @@ try { } Copy-Item -Force -Path $package.FullName -Destination $OutputDirectory + Save-Module -Name 'Devolutions.Psign' -RequiredVersion $manifest.Version.ToString() -Repository $repoName -Path $installRoot + $savedManifest = Join-Path (Join-Path (Join-Path $installRoot 'Devolutions.Psign') $manifest.Version.ToString()) 'Devolutions.Psign.psd1' + Import-Module $savedManifest -Force + foreach ($cmdlet in $expectedCmdlets) { + if (-not (Get-Command $cmdlet -Module 'Devolutions.Psign' -ErrorAction SilentlyContinue)) { + throw "Installed package smoke test did not find cmdlet '$cmdlet'." + } + } + $nativeProbe = New-TemporaryFile + try { + $null = Get-PortableSignature -LiteralPath $nativeProbe.FullName -ErrorAction Stop + } + finally { + Remove-Item -LiteralPath $nativeProbe.FullName -Force -ErrorAction SilentlyContinue + } Get-Item -LiteralPath (Join-Path $OutputDirectory $package.Name) } finally { + Remove-Module Devolutions.Psign -Force -ErrorAction SilentlyContinue Unregister-PSRepository -Name $repoName -ErrorAction SilentlyContinue Remove-Item -LiteralPath $localRepo -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction SilentlyContinue } diff --git a/PowerShell/tests/Invoke-PortableSignatureTests.ps1 b/PowerShell/tests/Invoke-PortableSignatureTests.ps1 index 22decb3..20ba181 100644 --- a/PowerShell/tests/Invoke-PortableSignatureTests.ps1 +++ b/PowerShell/tests/Invoke-PortableSignatureTests.ps1 @@ -11,18 +11,97 @@ $buildScript = Join-Path (Join-Path $repo 'PowerShell') 'build.ps1' $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) - $cert = $request.CreateSelfSigned( + $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)) + [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' @@ -32,6 +111,24 @@ try { $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' @@ -43,6 +140,18 @@ try { 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') { @@ -53,11 +162,22 @@ try { 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 @@ -65,6 +185,47 @@ try { 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 + $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 @@ -72,6 +233,7 @@ try { 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)" @@ -84,6 +246,7 @@ try { 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)" @@ -98,6 +261,7 @@ try { 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)" @@ -112,16 +276,60 @@ param([string] $Name = "portable") 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)" } + $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 @@ -135,6 +343,9 @@ param([string] $Name = "portable") 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 3) { throw "Expected 3 validated PowerShell module files, got $($moduleValidated.Count)." @@ -154,6 +365,7 @@ param([string] $Name = "portable") 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)" diff --git a/README.md b/README.md index 0d170aa..d970292 100644 --- a/README.md +++ b/README.md @@ -119,9 +119,11 @@ Set-PortableSignature -LiteralPath .\script.ps1 -Certificate $cert Get-PortableSignature -LiteralPath .\script.ps1 Set-PortableSignature -LiteralPath .\ModuleDirectory -CertificatePath .\signer.cer -PrivateKeyPath .\signer.key Set-PortableSignature -LiteralPath .\package.msix -PfxPath .\signer.pfx -Password $password +Set-PortableSignature -LiteralPath .\tool.exe -Sha1 $thumbprint -CertStoreDirectory .\cert-store +Get-PortableSignature -LiteralPath .\tool.exe -TrustedCertificate $rootCertificate ``` -`Set-PortableSignature` and `Get-PortableSignature` avoid Win32 SIPs and support PE, CAB, MSI, ZIP Authenticode, MSIX/AppX, PowerShell scripts, and whole PowerShell module directories (`.ps1`, `.psm1`, `.psd1`). See [`docs/portable-powershell-module.md`](docs/portable-powershell-module.md) and [`docs/portable-core-ffi.md`](docs/portable-core-ffi.md). +`Set-PortableSignature` and `Get-PortableSignature` avoid Win32 SIPs and support PE, CAB, MSI, ZIP Authenticode, MSIX/AppX, PowerShell scripts, whole PowerShell module directories (`.ps1`, `.psm1`, `.psd1`), content-mode signing, RFC3161 timestamping, chain embedding, portable cert-store thumbprint selection, and explicit-anchor trust verification. See [`docs/portable-powershell-module.md`](docs/portable-powershell-module.md) and [`docs/portable-core-ffi.md`](docs/portable-core-ffi.md). ## Portable certificate store diff --git a/crates/psign-authenticode-trust/Cargo.toml b/crates/psign-authenticode-trust/Cargo.toml index c7df9ae..63ef21d 100644 --- a/crates/psign-authenticode-trust/Cargo.toml +++ b/crates/psign-authenticode-trust/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true [dependencies] anyhow = "1" +base64 = "0.22" serde = { version = "1", features = ["derive"] } cms = "0.2.3" der = { version = "0.7", features = ["derive"] } diff --git a/crates/psign-authenticode-trust/src/inspect.rs b/crates/psign-authenticode-trust/src/inspect.rs index 8b21bdd..e05f838 100644 --- a/crates/psign-authenticode-trust/src/inspect.rs +++ b/crates/psign-authenticode-trust/src/inspect.rs @@ -5,10 +5,12 @@ use anyhow::{Result, anyhow}; use authenticode::AuthenticodeSignature; +use base64::Engine as _; use cms::content_info::ContentInfo; use cms::signed_data::{SignedData, SignerInfo}; use der::asn1::ObjectIdentifier; -use der::{Decode, SliceReader}; +use der::{Decode, Encode, SliceReader}; +use psign_sip_digest::pkcs7::signed_data_certificate_for_signer_identifier; use psign_sip_digest::pkcs7_wire::normalize_pkcs7_der_for_authenticode; use psign_sip_digest::verify_pe::for_each_pe_pkcs7_signed_data; use serde::Serialize; @@ -37,6 +39,8 @@ pub struct InspectPkcs7Report { pub certificate_count: usize, #[serde(skip_serializing_if = "Option::is_none")] pub authenticode_digest: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp_signing_time: Option, pub signers: Vec, /// PKCS#7 blobs found under OID `1.3.6.1.4.1.311.2.4.1` (Microsoft nested signature). pub nested_signatures: Vec, @@ -53,6 +57,10 @@ pub struct InspectAuthenticodeDigest { pub struct InspectSigner { pub signer_index: usize, pub digest_algorithm_oid: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub signer_certificate_der_base64: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp_signer_certificate_der_base64: Option, pub signed_attribute_oids: Vec, pub unsigned_attribute_oids: Vec, pub timestamp_hints: Vec, @@ -123,7 +131,17 @@ fn inspect_pkcs7_recursive( } }); - inspect_signed_data(&sd, authenticode_digest, depth, max_depth) + let timestamp_signing_time = + crate::rfc3161_extract::utc_date_from_authenticode_timestamp_token(slice) + .map(|d| format!("{:04}-{:02}-{:02}T00:00:00Z", d.year(), d.month(), d.day())); + + inspect_signed_data( + &sd, + authenticode_digest, + timestamp_signing_time, + depth, + max_depth, + ) } fn decode_signed_data(pkcs7_der: &[u8]) -> Result { @@ -143,6 +161,7 @@ fn decode_signed_data(pkcs7_der: &[u8]) -> Result { fn inspect_signed_data( sd: &SignedData, authenticode_digest: Option, + timestamp_signing_time: Option, depth: usize, max_depth: usize, ) -> Result { @@ -153,7 +172,7 @@ fn inspect_signed_data( let mut nested_signatures = Vec::new(); for (signer_index, si) in sd.signer_infos.0.iter().enumerate() { - signers.push(inspect_signer(signer_index, si)); + signers.push(inspect_signer(sd, signer_index, si)); let Some(attrs) = si.unsigned_attrs.as_ref() else { continue; @@ -181,6 +200,7 @@ fn inspect_signed_data( encap_content_type_oid, certificate_count, authenticode_digest, + timestamp_signing_time, signers, nested_signatures, }) @@ -206,8 +226,23 @@ fn peel_octet_string_outer(bytes: &[u8]) -> Option<&[u8]> { Some(o.as_bytes()) } -fn inspect_signer(signer_index: usize, si: &SignerInfo) -> InspectSigner { +fn certificate_der_base64_for_signer(sd: &SignedData, si: &SignerInfo) -> Option { + signed_data_certificate_for_signer_identifier(sd, &si.sid) + .ok() + .and_then(|cert| cert.to_der().ok()) + .map(|der| base64::engine::general_purpose::STANDARD.encode(der)) +} + +fn timestamp_signer_cert_der_base64(payload: &[u8]) -> Option { + let der = decode_nested_pkcs7_payload(payload).ok()?; + let sd = decode_signed_data(&der).ok()?; + let si = sd.signer_infos.0.as_slice().first()?; + certificate_der_base64_for_signer(&sd, si) +} + +fn inspect_signer(sd: &SignedData, signer_index: usize, si: &SignerInfo) -> InspectSigner { let digest_algorithm_oid = si.digest_alg.oid.to_string(); + let signer_certificate_der_base64 = certificate_der_base64_for_signer(sd, si); let signed_attribute_oids = si .signed_attrs .as_ref() @@ -220,6 +255,7 @@ fn inspect_signer(signer_index: usize, si: &SignerInfo) -> InspectSigner { .unwrap_or_default(); let mut timestamp_hints = Vec::new(); + let mut timestamp_signer_certificate_der_base64 = None; if let Some(attrs) = si.signed_attrs.as_ref() { for attr in attrs.iter() { if attr.oid == OID_PKCS9_SIGNING_TIME { @@ -237,11 +273,25 @@ fn inspect_signer(signer_index: usize, si: &SignerInfo) -> InspectSigner { kind: "id_aa_time_stamp_token", attribute_oid: attr.oid.to_string(), }); + if timestamp_signer_certificate_der_base64.is_none() { + timestamp_signer_certificate_der_base64 = attr + .values + .iter() + .filter_map(|val| val.to_der().ok()) + .find_map(|der| timestamp_signer_cert_der_base64(&der)); + } } else if attr.oid == OID_MS_TIMESTAMP_TOKEN { timestamp_hints.push(TimestampHint { kind: "microsoft_nested_rfc3161_attribute", attribute_oid: attr.oid.to_string(), }); + if timestamp_signer_certificate_der_base64.is_none() { + timestamp_signer_certificate_der_base64 = attr + .values + .iter() + .filter_map(|val| val.to_der().ok()) + .find_map(|der| timestamp_signer_cert_der_base64(&der)); + } } } } @@ -249,6 +299,8 @@ fn inspect_signer(signer_index: usize, si: &SignerInfo) -> InspectSigner { InspectSigner { signer_index, digest_algorithm_oid, + signer_certificate_der_base64, + timestamp_signer_certificate_der_base64, signed_attribute_oids, unsigned_attribute_oids, timestamp_hints, diff --git a/crates/psign-authenticode-trust/src/lib.rs b/crates/psign-authenticode-trust/src/lib.rs index 6b6f1fe..2803455 100644 --- a/crates/psign-authenticode-trust/src/lib.rs +++ b/crates/psign-authenticode-trust/src/lib.rs @@ -21,6 +21,7 @@ pub mod trust_verify_detached; pub mod trust_verify_esd; pub mod trust_verify_msi; pub mod trust_verify_pe; +pub mod trust_verify_script; pub mod trust_verify_zip; pub mod verification_instant; @@ -38,5 +39,6 @@ pub use trust_verify_pe::{ TrustVerifyPeOptions, TrustVerifyPeReport, load_trust_material, pe_first_pkcs7_terminal_root, pkcs7_signed_data_der_terminal_root, trust_verify_pe_bytes, }; +pub use trust_verify_script::trust_verify_script_bytes; pub use trust_verify_zip::trust_verify_zip_bytes; pub use verification_instant::parse_verification_date_ymd; diff --git a/crates/psign-authenticode-trust/src/trust_verify_script.rs b/crates/psign-authenticode-trust/src/trust_verify_script.rs new file mode 100644 index 0000000..2dc97a1 --- /dev/null +++ b/crates/psign-authenticode-trust/src/trust_verify_script.rs @@ -0,0 +1,42 @@ +//! PowerShell script Authenticode trust (script SIP digest + PKCS#7 chain). + +use crate::trust_pkcs7::verify_authenticode_pkcs7_trust; +use crate::trust_verify_pe::{TrustVerifyPeOptions, TrustVerifyPeReport, load_trust_material}; +use crate::verification_instant::resolve_verification_instant_for_pkcs7_with_trust; +use anyhow::Result; +use psign_sip_digest::ps_script::powershell_class_digest_report; + +pub fn trust_verify_script_bytes( + data: &[u8], + extension: &str, + opts: &TrustVerifyPeOptions, +) -> Result { + let (anchors, anchor_certs) = load_trust_material(opts)?; + let script_report = powershell_class_digest_report(data, extension)?; + + let verification_instant = resolve_verification_instant_for_pkcs7_with_trust( + &script_report.pkcs7_der, + &opts.policy, + opts.verification_instant_override.as_ref(), + &anchors, + &anchor_certs, + &opts.online, + opts.verbose_chain, + )?; + verify_authenticode_pkcs7_trust( + &script_report.pkcs7_der, + 0, + &script_report.computed_digest, + &anchors, + &anchor_certs, + &opts.policy, + &opts.online, + &verification_instant, + opts.verbose_chain, + )?; + + Ok(TrustVerifyPeReport { + pkcs7_entries_verified: 1, + anchor_thumbprints: anchors.thumbprint_count(), + }) +} diff --git a/crates/psign-portable-core/Cargo.toml b/crates/psign-portable-core/Cargo.toml index 78a26c8..cd54b43 100644 --- a/crates/psign-portable-core/Cargo.toml +++ b/crates/psign-portable-core/Cargo.toml @@ -11,9 +11,12 @@ anyhow = "1" base64 = "0.22" psign-authenticode-trust = { path = "../psign-authenticode-trust" } psign-sip-digest = { path = "../psign-sip-digest" } +picky = { version = "7.0.0-rc.23", default-features = false } rsa = "0.9.10" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +sha1 = { version = "0.10", default-features = false } sha2 = "0.10" x509-cert = { version = "0.2.5", default-features = false, features = ["pem"] } zip = { version = "0.6.6", default-features = false, features = ["deflate"] } diff --git a/crates/psign-portable-core/src/lib.rs b/crates/psign-portable-core/src/lib.rs index f15de2d..4a5af6a 100644 --- a/crates/psign-portable-core/src/lib.rs +++ b/crates/psign-portable-core/src/lib.rs @@ -5,12 +5,17 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use base64::Engine as _; -use psign_authenticode_trust::inspect_authenticode_pkcs7_der; -use psign_authenticode_trust::inspect_pe_authenticode; +use picky::x509::date::UtcDate; +use psign_authenticode_trust::policy::{OnlineTrustOptions, RevocationMode}; +use psign_authenticode_trust::{ + AuthenticodeTrustPolicy, TrustVerifyPeOptions, inspect_authenticode_pkcs7_der, + inspect_pe_authenticode, trust_verify_cab_bytes, trust_verify_msi_bytes, trust_verify_pe_bytes, + trust_verify_script_bytes, trust_verify_zip_bytes, +}; use psign_sip_digest::pkcs7::AuthenticodeSigningDigest; use psign_sip_digest::verify_pe::verify_pe_authenticode_digest_consistency; use psign_sip_digest::{ - cab_digest, msi_digest, msix_digest, pe_embed, pkcs7, ps_script, rdp, + cab_digest, msi_digest, msix_digest, pe_embed, pkcs7, ps_script, rdp, timestamp, verify_script_digest_consistency, zip_authenticode, }; use serde::{Deserialize, Serialize}; @@ -55,6 +60,35 @@ pub enum PortableDigestAlgorithm { Sha512, } +#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum PortableTimestampDigestAlgorithm { + Sha1, + #[default] + Sha256, + Sha384, + Sha512, +} + +#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum PortableRevocationMode { + #[default] + Off, + BestEffort, + Require, +} + +impl From for RevocationMode { + fn from(value: PortableRevocationMode) -> Self { + match value { + PortableRevocationMode::Off => Self::Off, + PortableRevocationMode::BestEffort => Self::BestEffort, + PortableRevocationMode::Require => Self::Require, + } + } +} + impl From for AuthenticodeSigningDigest { fn from(value: PortableDigestAlgorithm) -> Self { match value { @@ -68,6 +102,44 @@ impl From for AuthenticodeSigningDigest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PortableGetSignatureRequest { pub path: PathBuf, + #[serde(default)] + pub trusted_certificate_paths: Vec, + #[serde(default)] + pub trusted_certificates_der_base64: Vec, + #[serde(default)] + pub anchor_directory: Option, + #[serde(default)] + pub authroot_cab: Option, + #[serde(default)] + pub as_of: Option, + #[serde(default)] + pub prefer_timestamp_signing_time: bool, + #[serde(default)] + pub require_valid_timestamp: bool, + #[serde(default)] + pub online_aia: bool, + #[serde(default)] + pub online_ocsp: bool, + #[serde(default)] + pub revocation_mode: PortableRevocationMode, +} + +impl PortableGetSignatureRequest { + pub fn path_only(path: PathBuf) -> Self { + Self { + path, + trusted_certificate_paths: Vec::new(), + trusted_certificates_der_base64: Vec::new(), + anchor_directory: None, + authroot_cab: None, + as_of: None, + prefer_timestamp_signing_time: false, + require_valid_timestamp: false, + online_aia: false, + online_ocsp: false, + revocation_mode: PortableRevocationMode::Off, + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -89,6 +161,10 @@ pub struct PortableSignRequest { pub chain_certificate_paths: Vec, #[serde(default)] pub chain_certificates_der_base64: Vec, + #[serde(default)] + pub timestamp_server: Option, + #[serde(default)] + pub timestamp_hash_algorithm: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -98,15 +174,31 @@ pub struct PortableSignatureResponse { pub format: PortableFileFormat, pub status: PortableSignatureStatus, pub status_message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trust_status: Option, pub signature_count: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signer_index: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signer_certificate_der_base64: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timestamper_certificate_der_base64: Option, + #[serde(default, skip_serializing_if = "is_zero")] + pub embedded_certificate_count: usize, #[serde(skip_serializing_if = "Option::is_none")] pub digest_algorithm: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub timestamp_kinds: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timestamp_signing_time: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub diagnostics: Vec, } +fn is_zero(value: &usize) -> bool { + *value == 0 +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PortableSignResponse { pub schema_version: u32, @@ -175,9 +267,8 @@ pub fn portable_sign(request: PortableSignRequest) -> Result inspect_pe(&request.path, &data), PortableFileFormat::Cab => inspect_cab(&request.path), PortableFileFormat::Msi => inspect_msi(&request.path), @@ -206,13 +297,15 @@ pub fn portable_get_signature( } PortableFileFormat::Catalog => inspect_pkcs7_file(&request.path, format), PortableFileFormat::Unknown => Ok(base_response( - request.path, + request.path.clone(), format, PortableSignatureStatus::NotSupportedFileFormat, "Unsupported file format for portable Authenticode inspection.", )), }?; + apply_trust_if_requested(&request, format, &data, &mut response)?; + Ok(response) } @@ -263,6 +356,8 @@ fn sign_pe(request: &PortableSignRequest, output_path: &Path) -> Result<()> { request.path.display() ) })?; + let pkcs7 = maybe_timestamp_pkcs7(request, pkcs7) + .with_context(|| format!("timestamp {}", request.path.display()))?; let signed = pe_embed::pe_append_authenticode_pkcs7_certificate(pe, &pkcs7) .with_context(|| format!("embed Authenticode signature in {}", request.path.display()))?; std::fs::write(output_path, signed).with_context(|| format!("write {}", output_path.display())) @@ -285,6 +380,8 @@ fn sign_cab(request: &PortableSignRequest, output_path: &Path) -> Result<()> { request.path.display() ) })?; + let pkcs7 = maybe_timestamp_pkcs7(request, pkcs7) + .with_context(|| format!("timestamp {}", request.path.display()))?; let signed = cab_digest::cab_append_authenticode_pkcs7_signature(&cab, &pkcs7) .with_context(|| format!("embed Authenticode signature in {}", request.path.display()))?; std::fs::write(output_path, signed).with_context(|| format!("write {}", output_path.display())) @@ -307,6 +404,8 @@ fn sign_msi(request: &PortableSignRequest, output_path: &Path) -> Result<()> { request.path.display() ) })?; + let pkcs7 = maybe_timestamp_pkcs7(request, pkcs7) + .with_context(|| format!("timestamp {}", request.path.display()))?; msi_digest::msi_embed_authenticode_pkcs7_signature(&request.path, output_path, &pkcs7) .with_context(|| format!("embed Authenticode signature in {}", request.path.display())) } @@ -341,6 +440,8 @@ fn sign_msix(request: &PortableSignRequest, output_path: &Path) -> Result<()> { request.path.display() ) })?; + let pkcs7 = maybe_timestamp_pkcs7(request, pkcs7) + .with_context(|| format!("timestamp {}", request.path.display()))?; let mut p7x = b"PKCX".to_vec(); p7x.extend_from_slice(&pkcs7); let signed = replace_msix_signature_part(&staged, &p7x) @@ -372,6 +473,8 @@ fn sign_zip(request: &PortableSignRequest, output_path: &Path) -> Result<()> { request.path.display() ) })?; + let pkcs7 = maybe_timestamp_pkcs7(request, pkcs7) + .with_context(|| format!("timestamp {}", request.path.display()))?; let line = zip_authenticode::signature_comment_line_from_pkcs7_der(&digest, &pkcs7)?; let signed = zip_authenticode::embed_signature_comment_line(&zip, &line).with_context(|| { @@ -410,6 +513,8 @@ fn sign_script(request: &PortableSignRequest, output_path: &Path) -> Result<()> request.path.display() ) })?; + let pkcs7 = maybe_timestamp_pkcs7(request, pkcs7) + .with_context(|| format!("timestamp {}", request.path.display()))?; let block = format_powershell_signature_block(&pkcs7); let mut signed = script; signed.extend_from_slice(block.as_bytes()); @@ -475,11 +580,261 @@ fn load_signing_material( Ok((signer_cert, private_key, chain)) } +fn maybe_timestamp_pkcs7(request: &PortableSignRequest, pkcs7_der: Vec) -> Result> { + let Some(timestamp_server) = request.timestamp_server.as_deref() else { + if request.timestamp_hash_algorithm.is_some() { + bail!("timestamp_hash_algorithm requires timestamp_server"); + } + return Ok(pkcs7_der); + }; + let alg = request.timestamp_hash_algorithm.unwrap_or_default(); + timestamp_pkcs7_der_rfc3161(&pkcs7_der, timestamp_server, alg) +} + +fn timestamp_pkcs7_der_rfc3161( + pkcs7_der: &[u8], + timestamp_server: &str, + timestamp_digest: PortableTimestampDigestAlgorithm, +) -> 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::anyhow!("PKCS#7 SignedData has no SignerInfo to timestamp"))?; + let imprint = digest_bytes_for_timestamp_alg(timestamp_digest, signer.signature.as_bytes()); + let request = timestamp::build_timestamp_request_bytes( + ×tamp::Rfc3161TimestampRequestPlan { + digest_alg_oid: timestamp_digest_oid(timestamp_digest), + nonce: None, + cert_req: true, + }, + &imprint, + ) + .ok_or_else(|| anyhow::anyhow!("build RFC3161 TimeStampReq"))?; + let response = reqwest::blocking::Client::new() + .post(timestamp_server) + .header("Content-Type", "application/timestamp-query") + .header("Accept", "application/timestamp-reply") + .body(request) + .send() + .with_context(|| format!("POST TimeStampReq to {timestamp_server}"))? + .error_for_status() + .with_context(|| format!("TSA returned an HTTP error from {timestamp_server}"))? + .bytes() + .context("read TSA TimeStampResp body")?; + let parsed = timestamp::parse_time_stamp_resp_der(&response) + .ok_or_else(|| anyhow::anyhow!("could not parse TimeStampResp DER from TSA response"))?; + if !parsed.pki_status.granted() { + bail!( + "TimeStampResp status is not granted (status={})", + parsed.pki_status.as_raw_integer() + ); + } + let token = parsed + .time_stamp_token + .ok_or_else(|| anyhow::anyhow!("TimeStampResp has no timeStampToken"))?; + let stamped = pkcs7::signed_data_add_rfc3161_timestamp_token(&sd, 0, token) + .context("attach RFC3161 timestamp token")?; + pkcs7::encode_pkcs7_content_info_signed_data_der(&stamped) +} + +fn timestamp_digest_oid(alg: PortableTimestampDigestAlgorithm) -> &'static str { + match alg { + PortableTimestampDigestAlgorithm::Sha1 => "1.3.14.3.2.26", + PortableTimestampDigestAlgorithm::Sha256 => "2.16.840.1.101.3.4.2.1", + PortableTimestampDigestAlgorithm::Sha384 => "2.16.840.1.101.3.4.2.2", + PortableTimestampDigestAlgorithm::Sha512 => "2.16.840.1.101.3.4.2.3", + } +} + +fn digest_bytes_for_timestamp_alg(alg: PortableTimestampDigestAlgorithm, input: &[u8]) -> Vec { + match alg { + PortableTimestampDigestAlgorithm::Sha1 => sha1::Sha1::digest(input).to_vec(), + PortableTimestampDigestAlgorithm::Sha256 => sha2::Sha256::digest(input).to_vec(), + PortableTimestampDigestAlgorithm::Sha384 => sha2::Sha384::digest(input).to_vec(), + PortableTimestampDigestAlgorithm::Sha512 => sha2::Sha512::digest(input).to_vec(), + } +} + +fn apply_trust_if_requested( + request: &PortableGetSignatureRequest, + format: PortableFileFormat, + data: &[u8], + response: &mut PortableSignatureResponse, +) -> Result<()> { + if !trust_requested(request) || response.status != PortableSignatureStatus::Valid { + return Ok(()); + } + + let (opts, temp_dir) = trust_options(request)?; + let trust_result = match format { + PortableFileFormat::Pe => trust_verify_pe_bytes(data, &opts).map(|r| { + format!( + "explicit_trust=valid pkcs7_entries_verified={} anchors={}", + r.pkcs7_entries_verified, r.anchor_thumbprints + ) + }), + PortableFileFormat::Cab => trust_verify_cab_bytes(data, &opts).map(|r| { + format!( + "explicit_trust=valid pkcs7_entries_verified={} anchors={}", + r.pkcs7_entries_verified, r.anchor_thumbprints + ) + }), + PortableFileFormat::Msi => trust_verify_msi_bytes(data, &opts).map(|r| { + format!( + "explicit_trust=valid pkcs7_entries_verified={} anchors={}", + r.pkcs7_entries_verified, r.anchor_thumbprints + ) + }), + PortableFileFormat::Zip => trust_verify_zip_bytes(data, &opts).map(|r| { + format!( + "explicit_trust=valid pkcs7_entries_verified={} anchors={}", + r.pkcs7_entries_verified, r.anchor_thumbprints + ) + }), + PortableFileFormat::PowerShellScript => { + let extension = request + .path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("ps1"); + trust_verify_script_bytes(data, extension, &opts).map(|r| { + format!( + "explicit_trust=valid pkcs7_entries_verified={} anchors={}", + r.pkcs7_entries_verified, r.anchor_thumbprints + ) + }) + } + _ => Err(anyhow::anyhow!( + "explicit trust verification is not implemented for format {:?}", + format + )), + }; + if let Some(dir) = temp_dir { + let _ = std::fs::remove_dir_all(dir); + } + + match trust_result { + Ok(diagnostic) => { + response.status_message = + "Portable digest binding and explicit trust verification are valid.".to_string(); + response.trust_status = Some(PortableSignatureStatus::Valid); + response.diagnostics.push(diagnostic); + } + Err(error) => { + response.status = PortableSignatureStatus::NotTrusted; + response.trust_status = Some(PortableSignatureStatus::NotTrusted); + response.status_message = error.to_string(); + response + .diagnostics + .push("explicit_trust=failed".to_string()); + } + } + + Ok(()) +} + +fn trust_requested(request: &PortableGetSignatureRequest) -> bool { + !request.trusted_certificate_paths.is_empty() + || !request.trusted_certificates_der_base64.is_empty() + || request.anchor_directory.is_some() + || request.authroot_cab.is_some() +} + +fn trust_options( + request: &PortableGetSignatureRequest, +) -> Result<(TrustVerifyPeOptions, Option)> { + let mut trusted_ca_files = request.trusted_certificate_paths.clone(); + let mut temp_dir = None; + if !request.trusted_certificates_der_base64.is_empty() { + let dir = std::env::temp_dir().join(format!( + "psign-portable-trust-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + std::fs::create_dir_all(&dir) + .with_context(|| format!("create temporary trust directory {}", dir.display()))?; + for (index, cert) in request.trusted_certificates_der_base64.iter().enumerate() { + let bytes = base64::engine::general_purpose::STANDARD + .decode(cert) + .with_context(|| format!("decode trusted_certificates_der_base64[{index}]"))?; + let path = dir.join(format!("trusted-{index}.cer")); + std::fs::write(&path, bytes).with_context(|| format!("write {}", path.display()))?; + trusted_ca_files.push(path); + } + temp_dir = Some(dir); + } + + Ok(( + TrustVerifyPeOptions { + anchor_dir: request.anchor_directory.clone(), + trusted_ca_files, + authroot_cab: request.authroot_cab.clone(), + expect_authroot_cab_sha256: None, + verification_instant_override: request + .as_of + .as_deref() + .map(parse_utc_date) + .transpose()?, + verbose_chain: false, + online: OnlineTrustOptions { + enable_aia: request.online_aia, + enable_ocsp: request.online_ocsp, + revocation_mode: request.revocation_mode.into(), + ..OnlineTrustOptions::default() + }, + policy: AuthenticodeTrustPolicy { + strict_code_signing_eku: false, + prefer_timestamp_signing_time: request.prefer_timestamp_signing_time + || request.require_valid_timestamp, + require_valid_timestamp: request.require_valid_timestamp, + }, + }, + temp_dir, + )) +} + +fn parse_utc_date(input: &str) -> Result { + let date = input + .split_once('T') + .map(|(date, _)| date) + .unwrap_or(input) + .trim(); + let mut parts = date.split('-'); + let year = parts + .next() + .context("missing year in as_of date")? + .parse::() + .with_context(|| format!("invalid year in as_of date '{input}'"))?; + let month = parts + .next() + .context("missing month in as_of date")? + .parse::() + .with_context(|| format!("invalid month in as_of date '{input}'"))?; + let day = parts + .next() + .context("missing day in as_of date")? + .parse::() + .with_context(|| format!("invalid day in as_of date '{input}'"))?; + if parts.next().is_some() { + bail!("invalid as_of date '{input}': expected yyyy-MM-dd"); + } + + UtcDate::ymd(year, month, day).ok_or_else(|| { + anyhow::anyhow!("invalid as_of date '{input}': expected a valid yyyy-MM-dd date") + }) +} + fn inspect_pe(path: &Path, data: &[u8]) -> Result { let inspect = inspect_pe_authenticode(data); match verify_pe_authenticode_digest_consistency(data) { Ok(result) => { - let (digest_algorithm, timestamp_kinds) = inspect + let summary = inspect .ok() .map(|r| summarize_pkcs7_reports(r.entries.into_iter().map(|e| e.pkcs7))) .unwrap_or_default(); @@ -490,9 +845,15 @@ fn inspect_pe(path: &Path, data: &[u8]) -> Result { status: PortableSignatureStatus::Valid, status_message: "Portable digest binding is valid; trust was not evaluated." .to_string(), + trust_status: None, signature_count: result.pkcs7_authenticode_entries, - digest_algorithm, - timestamp_kinds, + signer_index: summary.signer_index, + signer_certificate_der_base64: summary.signer_certificate_der_base64, + timestamper_certificate_der_base64: summary.timestamper_certificate_der_base64, + embedded_certificate_count: summary.embedded_certificate_count, + digest_algorithm: summary.digest_algorithm, + timestamp_kinds: summary.timestamp_kinds, + timestamp_signing_time: summary.timestamp_signing_time, diagnostics: vec![format!( "matched_attribute_certificate_index={}", result.matched_attribute_certificate_index @@ -505,36 +866,60 @@ fn inspect_pe(path: &Path, data: &[u8]) -> Result { fn inspect_cab(path: &Path) -> Result { match cab_digest::verify_cab_digest_consistency(path) { - Ok(()) => Ok(base_response( - path.to_path_buf(), - PortableFileFormat::Cab, - PortableSignatureStatus::Valid, - "Portable digest binding is valid; trust was not evaluated.", - )), + Ok(()) => { + let data = std::fs::read(path).with_context(|| format!("read {}", path.display()))?; + let summary = cab_digest::cab_signature_pkcs7_der(&data) + .ok() + .and_then(|pkcs7| inspect_authenticode_pkcs7_der(pkcs7).ok()) + .map(|r| summarize_pkcs7_reports(std::iter::once(r))) + .unwrap_or_default(); + Ok(valid_response( + path.to_path_buf(), + PortableFileFormat::Cab, + "Portable digest binding is valid; trust was not evaluated.", + summary, + )) + } Err(error) => Ok(map_digest_error(path, PortableFileFormat::Cab, error)), } } fn inspect_msi(path: &Path) -> Result { match msi_digest::verify_msi_digest_consistency(path) { - Ok(()) => Ok(base_response( - path.to_path_buf(), - PortableFileFormat::Msi, - PortableSignatureStatus::Valid, - "Portable digest binding is valid; trust was not evaluated.", - )), + Ok(()) => { + let data = std::fs::read(path).with_context(|| format!("read {}", path.display()))?; + let summary = msi_digest::msi_digital_signature_pkcs7_der(&data) + .ok() + .and_then(|pkcs7| inspect_authenticode_pkcs7_der(&pkcs7).ok()) + .map(|r| summarize_pkcs7_reports(std::iter::once(r))) + .unwrap_or_default(); + Ok(valid_response( + path.to_path_buf(), + PortableFileFormat::Msi, + "Portable digest binding is valid; trust was not evaluated.", + summary, + )) + } Err(error) => Ok(map_digest_error(path, PortableFileFormat::Msi, error)), } } fn inspect_msix(path: &Path) -> Result { match msix_digest::verify_msix_digest_consistency(path) { - Ok(()) => Ok(base_response( - path.to_path_buf(), - PortableFileFormat::Msix, - PortableSignatureStatus::Valid, - "Portable MSIX digest binding is valid; trust was not evaluated.", - )), + Ok(()) => { + let data = std::fs::read(path).with_context(|| format!("read {}", path.display()))?; + let summary = msix_signature_pkcs7_der(&data) + .ok() + .and_then(|pkcs7| inspect_authenticode_pkcs7_der(&pkcs7).ok()) + .map(|r| summarize_pkcs7_reports(std::iter::once(r))) + .unwrap_or_default(); + Ok(valid_response( + path.to_path_buf(), + PortableFileFormat::Msix, + "Portable MSIX digest binding is valid; trust was not evaluated.", + summary, + )) + } Err(error) => Ok(map_digest_error(path, PortableFileFormat::Msix, error)), } } @@ -550,7 +935,7 @@ fn inspect_zip(path: &Path, data: &[u8]) -> Result { } let pkcs7 = zip_authenticode::signature_pkcs7_der(&sig)?; let report = inspect_authenticode_pkcs7_der(&pkcs7).ok(); - let (digest_algorithm, timestamp_kinds) = report + let summary = report .map(|r| summarize_pkcs7_reports(std::iter::once(r))) .unwrap_or_default(); Ok(PortableSignatureResponse { @@ -560,9 +945,15 @@ fn inspect_zip(path: &Path, data: &[u8]) -> Result { status: PortableSignatureStatus::Valid, status_message: "Portable ZIP digest binding is valid; trust was not evaluated." .to_string(), + trust_status: None, signature_count: 1, - digest_algorithm, - timestamp_kinds, + signer_index: summary.signer_index, + signer_certificate_der_base64: summary.signer_certificate_der_base64, + timestamper_certificate_der_base64: summary.timestamper_certificate_der_base64, + embedded_certificate_count: summary.embedded_certificate_count, + digest_algorithm: summary.digest_algorithm, + timestamp_kinds: summary.timestamp_kinds, + timestamp_signing_time: summary.timestamp_signing_time, diagnostics: Vec::new(), }) } @@ -578,7 +969,7 @@ fn inspect_script(path: &Path, data: &[u8]) -> Result .ok() .and_then(|r| inspect_authenticode_pkcs7_der(&r.pkcs7_der).ok()) }; - let (digest_algorithm, timestamp_kinds) = report + let summary = report .map(|r| summarize_pkcs7_reports(std::iter::once(r))) .unwrap_or_default(); Ok(PortableSignatureResponse { @@ -588,9 +979,15 @@ fn inspect_script(path: &Path, data: &[u8]) -> Result status: PortableSignatureStatus::Valid, status_message: "Portable script digest binding is valid; trust was not evaluated." .to_string(), + trust_status: None, signature_count: 1, - digest_algorithm, - timestamp_kinds, + signer_index: summary.signer_index, + signer_certificate_der_base64: summary.signer_certificate_der_base64, + timestamper_certificate_der_base64: summary.timestamper_certificate_der_base64, + embedded_certificate_count: summary.embedded_certificate_count, + digest_algorithm: summary.digest_algorithm, + timestamp_kinds: summary.timestamp_kinds, + timestamp_signing_time: summary.timestamp_signing_time, diagnostics: Vec::new(), }) } @@ -605,8 +1002,7 @@ fn inspect_pkcs7_file( let data = std::fs::read(path).with_context(|| format!("read {}", path.display()))?; match inspect_authenticode_pkcs7_der(&data) { Ok(report) => { - let (digest_algorithm, timestamp_kinds) = - summarize_pkcs7_reports(std::iter::once(report)); + let summary = summarize_pkcs7_reports(std::iter::once(report)); Ok(PortableSignatureResponse { schema_version: SCHEMA_VERSION, path: path.to_path_buf(), @@ -615,9 +1011,15 @@ fn inspect_pkcs7_file( status_message: "PKCS#7 structure is valid; detached content and trust were not evaluated." .to_string(), + trust_status: None, signature_count: 1, - digest_algorithm, - timestamp_kinds, + signer_index: summary.signer_index, + signer_certificate_der_base64: summary.signer_certificate_der_base64, + timestamper_certificate_der_base64: summary.timestamper_certificate_der_base64, + embedded_certificate_count: summary.embedded_certificate_count, + digest_algorithm: summary.digest_algorithm, + timestamp_kinds: summary.timestamp_kinds, + timestamp_signing_time: summary.timestamp_signing_time, diagnostics: Vec::new(), }) } @@ -625,37 +1027,85 @@ fn inspect_pkcs7_file( } } +#[derive(Default)] +struct Pkcs7Summary { + digest_algorithm: Option, + timestamp_kinds: Vec, + timestamp_signing_time: Option, + signer_index: Option, + signer_certificate_der_base64: Option, + timestamper_certificate_der_base64: Option, + embedded_certificate_count: usize, +} + fn summarize_pkcs7_reports( reports: impl IntoIterator, -) -> (Option, Vec) { - let mut digest_algorithm = None; - let mut timestamp_kinds = Vec::new(); +) -> Pkcs7Summary { + let mut summary = Pkcs7Summary::default(); for report in reports { - collect_pkcs7_summary(&report, &mut digest_algorithm, &mut timestamp_kinds); + collect_pkcs7_summary(&report, &mut summary); } - (digest_algorithm, timestamp_kinds) + summary } fn collect_pkcs7_summary( report: &psign_authenticode_trust::inspect::InspectPkcs7Report, - digest_algorithm: &mut Option, - timestamp_kinds: &mut Vec, + summary: &mut Pkcs7Summary, ) { - if digest_algorithm.is_none() + summary.embedded_certificate_count += report.certificate_count; + if summary.timestamp_signing_time.is_none() { + summary.timestamp_signing_time = report.timestamp_signing_time.clone(); + } + if summary.digest_algorithm.is_none() && let Some(digest) = &report.authenticode_digest { - *digest_algorithm = Some(digest.digest_algorithm_oid.clone()); + summary.digest_algorithm = Some(digest.digest_algorithm_oid.clone()); } for signer in &report.signers { + if summary.signer_index.is_none() { + summary.signer_index = Some(signer.signer_index); + } + if summary.signer_certificate_der_base64.is_none() { + summary.signer_certificate_der_base64 = signer.signer_certificate_der_base64.clone(); + } + if summary.timestamper_certificate_der_base64.is_none() { + summary.timestamper_certificate_der_base64 = + signer.timestamp_signer_certificate_der_base64.clone(); + } for hint in &signer.timestamp_hints { let kind = hint.kind.to_string(); - if !timestamp_kinds.contains(&kind) { - timestamp_kinds.push(kind); + if !summary.timestamp_kinds.contains(&kind) { + summary.timestamp_kinds.push(kind); } } } for nested in &report.nested_signatures { - collect_pkcs7_summary(nested, digest_algorithm, timestamp_kinds); + collect_pkcs7_summary(nested, summary); + } +} + +fn valid_response( + path: PathBuf, + format: PortableFileFormat, + message: impl Into, + summary: Pkcs7Summary, +) -> PortableSignatureResponse { + PortableSignatureResponse { + schema_version: SCHEMA_VERSION, + path, + format, + status: PortableSignatureStatus::Valid, + status_message: message.into(), + trust_status: None, + signature_count: 1, + signer_index: summary.signer_index, + signer_certificate_der_base64: summary.signer_certificate_der_base64, + timestamper_certificate_der_base64: summary.timestamper_certificate_der_base64, + embedded_certificate_count: summary.embedded_certificate_count, + digest_algorithm: summary.digest_algorithm, + timestamp_kinds: summary.timestamp_kinds, + timestamp_signing_time: summary.timestamp_signing_time, + diagnostics: Vec::new(), } } @@ -671,9 +1121,15 @@ fn base_response( format, status, status_message: message.into(), + trust_status: None, signature_count: usize::from(status == PortableSignatureStatus::Valid), + signer_index: None, + signer_certificate_der_base64: None, + timestamper_certificate_der_base64: None, + embedded_certificate_count: 0, digest_algorithm: None, timestamp_kinds: Vec::new(), + timestamp_signing_time: None, diagnostics: Vec::new(), } } @@ -699,9 +1155,15 @@ fn map_digest_error( format, status, status_message: message, + trust_status: None, signature_count: 0, + signer_index: None, + signer_certificate_der_base64: None, + timestamper_certificate_der_base64: None, + embedded_certificate_count: 0, digest_algorithm: None, timestamp_kinds: Vec::new(), + timestamp_signing_time: None, diagnostics: Vec::new(), } } @@ -803,6 +1265,19 @@ fn replace_msix_signature_part(package: &[u8], p7x: &[u8]) -> Result> { Ok(out.into_inner()) } +fn msix_signature_pkcs7_der(package: &[u8]) -> Result> { + let mut archive = ZipArchive::new(Cursor::new(package)).context("open MSIX ZIP")?; + let mut entry = archive + .by_name("AppxSignature.p7x") + .context("read AppxSignature.p7x")?; + let mut p7x = Vec::new(); + entry.read_to_end(&mut p7x)?; + let pkcs7 = p7x + .strip_prefix(b"PKCX") + .ok_or_else(|| anyhow::anyhow!("AppxSignature.p7x missing PKCX prefix"))?; + Ok(pkcs7.to_vec()) +} + fn add_msix_signature_content_type(xml: &str) -> Result { if xml.contains("PartName=\"/AppxSignature.p7x\"") || xml.contains("PartName='/AppxSignature.p7x'") @@ -906,16 +1381,16 @@ mod tests { #[test] fn reports_unsigned_pe_without_error() { let path = PathBuf::from("../../tests/fixtures/pe-authenticode-upstream/tiny32.efi"); - let response = - portable_get_signature(PortableGetSignatureRequest { path }).expect("inspect PE"); + let response = portable_get_signature(PortableGetSignatureRequest::path_only(path)) + .expect("inspect PE"); assert_eq!(response.status, PortableSignatureStatus::NotSigned); } #[test] fn reports_signed_pe_as_valid_digest_binding() { let path = PathBuf::from("../../tests/fixtures/pe-authenticode-upstream/tiny32.signed.efi"); - let response = - portable_get_signature(PortableGetSignatureRequest { path }).expect("inspect PE"); + let response = portable_get_signature(PortableGetSignatureRequest::path_only(path)) + .expect("inspect PE"); assert_eq!(response.status, PortableSignatureStatus::Valid); assert!(response.signature_count > 0); } diff --git a/docs/portable-core-ffi.md b/docs/portable-core-ffi.md index 2788103..e085e49 100644 --- a/docs/portable-core-ffi.md +++ b/docs/portable-core-ffi.md @@ -11,4 +11,6 @@ The ABI is intentionally small and JSON-based: All request and response JSON uses UTF-8. Callers pass borrowed input buffers; Rust returns an owned `PsignFfiBuffer { ptr, len, cap }`. Managed callers must copy the bytes immediately and call `psign_portable_free` exactly once with the returned buffer. -The first schema version supports portable digest/signature inspection and local RSA signing for PE, CAB, MSI, ZIP Authenticode, MSIX/AppX packages, and PowerShell script inputs. `Set-PortableSignature` accepts certificate/key paths, exportable `X509Certificate2` values, and exportable PFX files. `Set-PortableSignature` and `Get-PortableSignature` also accept PowerShell module directories and expand them to signable `.ps1`, `.psm1`, and `.psd1` files. Trust evaluation remains explicit and will be added to the same JSON ABI rather than falling back to OS trust. +The first schema version supports portable digest/signature inspection and local RSA signing for PE, CAB, MSI, ZIP Authenticode, MSIX/AppX packages, and PowerShell script inputs. `Set-PortableSignature` accepts certificate/key paths, exportable `X509Certificate2` values, exportable PFX files, chain certificates, RFC3161 timestamp settings, and portable cert-store material resolved by managed callers. `Set-PortableSignature` and `Get-PortableSignature` also accept PowerShell module directories and expand them to signable `.ps1`, `.psm1`, and `.psd1` files. + +`psign_portable_get_signature` also accepts explicit trust material in the JSON request: trusted certificate paths, DER-encoded trusted certificates, anchor directory, AuthRoot CAB, `as_of`, timestamp-time policy booleans, online AIA/OCSP toggles, and revocation mode. The portable core never falls back to OS trust; when trust is requested, the response includes `trust_status` in addition to the digest/signature `status`. diff --git a/docs/portable-powershell-module.md b/docs/portable-powershell-module.md index 1f4b4e4..f5d4064 100644 --- a/docs/portable-powershell-module.md +++ b/docs/portable-powershell-module.md @@ -9,6 +9,13 @@ Both cmdlets accept `-FilePath` and `-LiteralPath`. When the input is a directory, the module treats it as a PowerShell module tree and recursively processes `.ps1`, `.psm1`, and `.psd1` files. +Both cmdlets also support the built-in Authenticode content parameter shape: + +```powershell +$signed = Set-PortableSignature -SourcePathOrExtension '.ps1' -Content $bytes -Certificate $exportableCertificate +Get-PortableSignature -SourcePathOrExtension '.ps1' -Content $signed.Content +``` + ## Signing inputs `Set-PortableSignature` supports local exportable RSA key material: @@ -17,20 +24,43 @@ Both cmdlets accept `-FilePath` and `-LiteralPath`. When the input is a director Set-PortableSignature -LiteralPath .\tool.exe -CertificatePath .\signer.cer -PrivateKeyPath .\signer.key Set-PortableSignature -LiteralPath .\script.ps1 -Certificate $exportableCertificate Set-PortableSignature -LiteralPath .\package.msix -PfxPath .\signer.pfx -Password $password +Set-PortableSignature -LiteralPath .\tool.exe -Thumbprint $sha1 -CertStoreDirectory .\cert-store ``` -The P/Invoke ABI also accepts in-memory DER certificate and PKCS#8 private-key material. Non-exportable local keys fail explicitly; use file-backed key material or a future remote signing provider for those cases. +The P/Invoke ABI also accepts in-memory DER certificate and PKCS#8 private-key material. Non-exportable local keys fail explicitly; use file-backed key material, PFX files with exportable keys, or the portable file-backed cert store. + +The portable cert store follows the same layout as `psign-tool cert-store`: `\\\.der` plus `.key`, where scope is `CurrentUser` or `LocalMachine` and the private key is unencrypted PKCS#8 PEM. `-Thumbprint` has `-Sha1` and `-PortableStoreThumbprint` aliases. If `-CertStoreDirectory` is omitted, the module uses `PSIGN_CERT_STORE` and then `~\.psign\cert-store`. + +`Set-PortableSignature` supports `-IncludeChain Signer|NotRoot|All`, optional `-ChainCertificatePath`, `-TimestampServer`, and `-TimestampHashAlgorithm Sha1|Sha256|Sha384|Sha512`. + +## Explicit trust + +`Get-PortableSignature` validates digest binding by default and does not claim OS trust. Explicit portable trust can be requested with anchors: + +```powershell +Get-PortableSignature -LiteralPath .\tool.exe -TrustedCertificate $rootCertificate +Get-PortableSignature -LiteralPath .\tool.exe -TrustedCertificatePath .\root.cer +Get-PortableSignature -LiteralPath .\tool.exe -AnchorDirectory .\anchors +Get-PortableSignature -LiteralPath .\tool.exe -TrustedCertificate $rootCertificate -AsOf (Get-Date) -RevocationMode Off +``` + +When trust is requested, the output object's `TrustStatus` is `Valid` or `NotTrusted`, while `Status` continues to report the overall portable signature result. Timestamped signatures expose `TimestampKinds`, `TimeStamperCertificate`, and `TimestampSigningTime` when the portable timestamp parser can extract the signing date. + +Trust verification is offline by default. `-OnlineAia` enables issuer retrieval, `-OnlineOcsp` enables OCSP checks, and `-RevocationMode Off|BestEffort|Require` controls revocation enforcement in the portable trust engine. ## Supported portable formats The current module tests cover signing and validation through the PowerShell surface for: - PE files +- CAB archives +- MSI/MSP installers +- Devolutions ZIP Authenticode packages - PowerShell scripts - PowerShell module directories - MSIX/AppX packages -The shared core also exposes local signing/inspection for CAB, MSI/MSP, and Devolutions ZIP Authenticode. Signature inspection validates portable digest binding and signature structure; it does not claim OS trust. +Signature inspection validates portable digest binding and signature structure. Explicit trust verification is currently implemented for PE, CAB, MSI/MSP, Devolutions ZIP Authenticode, and PowerShell script signatures. ## Build, test, and package @@ -40,4 +70,4 @@ pwsh -File .\PowerShell\tests\Invoke-PortableSignatureTests.ps1 -Configuration R pwsh -File .\PowerShell\package.ps1 -Configuration Release ``` -The build stages the native library under the module RID layout, for example `runtimes\win-x64\native\psign_portable.dll`, so .NET can load it via the module resolver. +The build stages the native library under the module RID layout, for example `runtimes\win-x64\native\psign_portable.dll`, so .NET can load it via the module resolver. The package script validates the module manifest, publishes to a temporary local repository, saves the generated module package, imports it, and confirms both cmdlets are exported. diff --git a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPortableSignatureCommand.cs b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPortableSignatureCommand.cs index cadea04..cd2511d 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPortableSignatureCommand.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPortableSignatureCommand.cs @@ -1,4 +1,6 @@ +using System.Globalization; using System.Management.Automation; +using System.Security.Cryptography.X509Certificates; using Devolutions.Psign.PowerShell.Models; using Devolutions.Psign.PowerShell.Native; using Devolutions.Psign.PowerShell.Utilities; @@ -11,6 +13,7 @@ public sealed class GetPortableSignatureCommand : PSCmdlet { private const string FilePathParameterSet = "FilePath"; private const string LiteralPathParameterSet = "LiteralPath"; + private const string ContentParameterSet = "Content"; [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true, ParameterSetName = FilePathParameterSet)] [Alias("Path")] @@ -20,9 +23,55 @@ public sealed class GetPortableSignatureCommand : PSCmdlet [Alias("PSPath")] public string[] LiteralPath { get; set; } = []; + [Parameter(Mandatory = true, ParameterSetName = ContentParameterSet)] + public string[] SourcePathOrExtension { get; set; } = []; + + [Parameter(Mandatory = true, ParameterSetName = ContentParameterSet)] + public byte[] Content { get; set; } = []; + + [Parameter] + public X509Certificate2[] TrustedCertificate { get; set; } = []; + + [Parameter] + public string[] TrustedCertificatePath { get; set; } = []; + + [Parameter] + public string? AnchorDirectory { get; set; } + + [Parameter] + public string? AuthRootCab { get; set; } + + [Parameter] + public DateTime? AsOf { get; set; } + + [Parameter] + public SwitchParameter PreferTimestampSigningTime { get; set; } + + [Parameter] + public SwitchParameter RequireValidTimestamp { get; set; } + + [Parameter] + public SwitchParameter OnlineAia { get; set; } + + [Parameter] + public SwitchParameter OnlineOcsp { get; set; } + + [Parameter] + [ValidateSet("Off", "BestEffort", "Require")] + public string RevocationMode { get; set; } = "Off"; + protected override void ProcessRecord() { bool literal = ParameterSetName == LiteralPathParameterSet; + if (ParameterSetName == ContentParameterSet) + { + foreach (string source in SourcePathOrExtension) + { + WriteContentSignature(source); + } + return; + } + string[] inputs = literal ? LiteralPath : FilePath; foreach (string input in inputs) { @@ -46,11 +95,79 @@ private void WriteSignature(string path) return; } - WriteObject(PsignNative.GetSignature(new PortableGetSignatureRequest { Path = path })); + WriteObject(PsignNative.GetSignature(CreateRequest(path))); } catch (Exception ex) { WriteError(new ErrorRecord(ex, "GetPortableSignatureFailed", ErrorCategory.NotSpecified, path)); } } + + private void WriteContentSignature(string sourcePathOrExtension) + { + string tempDirectory = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + string tempPath = System.IO.Path.Combine(tempDirectory, ContentFileName(sourcePathOrExtension)); + try + { + File.WriteAllBytes(tempPath, Content); + PortableSignature signature = PsignNative.GetSignature(CreateRequest(tempPath)); + signature.SourcePathOrExtension = sourcePathOrExtension; + WriteObject(signature); + } + catch (Exception ex) + { + WriteError(new ErrorRecord(ex, "GetPortableSignatureContentFailed", ErrorCategory.NotSpecified, sourcePathOrExtension)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + private PortableGetSignatureRequest CreateRequest(string path) + { + return new PortableGetSignatureRequest + { + Path = path, + TrustedCertificatePaths = TrustedCertificatePath + .Select(p => SessionState.Path.GetUnresolvedProviderPathFromPSPath(p)) + .ToArray(), + TrustedCertificatesDerBase64 = TrustedCertificate + .Select(c => Convert.ToBase64String(c.Export(X509ContentType.Cert))) + .ToArray(), + AnchorDirectory = AnchorDirectory is null + ? null + : SessionState.Path.GetUnresolvedProviderPathFromPSPath(AnchorDirectory), + AuthRootCab = AuthRootCab is null + ? null + : SessionState.Path.GetUnresolvedProviderPathFromPSPath(AuthRootCab), + AsOf = AsOf is null + ? null + : AsOf.Value.ToUniversalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + PreferTimestampSigningTime = PreferTimestampSigningTime.IsPresent || RequireValidTimestamp.IsPresent, + RequireValidTimestamp = RequireValidTimestamp.IsPresent, + OnlineAia = OnlineAia.IsPresent, + OnlineOcsp = OnlineOcsp.IsPresent, + RevocationMode = RevocationMode, + }; + } + + private static string ContentFileName(string sourcePathOrExtension) + { + string fileName = System.IO.Path.GetFileName(sourcePathOrExtension); + if (!string.IsNullOrWhiteSpace(fileName) + && System.IO.Path.HasExtension(fileName) + && !string.IsNullOrWhiteSpace(System.IO.Path.GetFileNameWithoutExtension(fileName))) + { + return fileName; + } + + string extension = sourcePathOrExtension.Trim(); + if (!extension.StartsWith('.')) + { + extension = "." + extension; + } + return "content" + extension; + } } diff --git a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs index 18013e8..623eebb 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs @@ -15,6 +15,10 @@ public sealed class SetPortableSignatureCommand : PSCmdlet { private const string FilePathParameterSet = "FilePath"; private const string LiteralPathParameterSet = "LiteralPath"; + private const string ContentParameterSet = "Content"; + private X509Certificate2? pfxCertificate; + private X509Certificate2? storeCertificate; + private string? storePrivateKeyDerBase64; [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true, ParameterSetName = FilePathParameterSet)] [Alias("Path")] @@ -24,6 +28,12 @@ public sealed class SetPortableSignatureCommand : PSCmdlet [Alias("PSPath")] public string[] LiteralPath { get; set; } = []; + [Parameter(Mandatory = true, ParameterSetName = ContentParameterSet)] + public string[] SourcePathOrExtension { get; set; } = []; + + [Parameter(Mandatory = true, ParameterSetName = ContentParameterSet)] + public byte[] Content { get; set; } = []; + [Parameter] public X509Certificate2? Certificate { get; set; } @@ -39,6 +49,33 @@ public sealed class SetPortableSignatureCommand : PSCmdlet [Parameter] public SecureString? Password { get; set; } + [Parameter] + [Alias("Sha1", "PortableStoreThumbprint")] + public string? Thumbprint { get; set; } + + [Parameter] + public string? CertStoreDirectory { get; set; } + + [Parameter] + public string StoreName { get; set; } = "MY"; + + [Parameter] + public SwitchParameter MachineStore { get; set; } + + [Parameter] + [ValidateSet("Signer", "NotRoot", "All")] + public string IncludeChain { get; set; } = "Signer"; + + [Parameter] + public string[] ChainCertificatePath { get; set; } = []; + + [Parameter] + public string? TimestampServer { get; set; } + + [Parameter] + [ValidateSet("Sha1", "Sha256", "Sha384", "Sha512")] + public string TimestampHashAlgorithm { get; set; } = "Sha256"; + [Parameter] [ValidateSet("Sha256", "Sha384", "Sha512")] public string HashAlgorithm { get; set; } = "Sha256"; @@ -52,6 +89,23 @@ public sealed class SetPortableSignatureCommand : PSCmdlet protected override void ProcessRecord() { ValidateSigningMaterial(); + if (ParameterSetName == ContentParameterSet) + { + if (OutputPath is not null) + { + ThrowTerminatingError(new ErrorRecord( + new PSInvalidOperationException("-OutputPath cannot be used with -Content. Read the signed bytes from the output object's Content property."), + "PortableSignatureContentOutputPathUnsupported", + ErrorCategory.InvalidArgument, + OutputPath)); + } + foreach (string source in SourcePathOrExtension) + { + SignContent(source); + } + return; + } + bool literal = ParameterSetName == LiteralPathParameterSet; string[] inputs = literal ? LiteralPath : FilePath; if (OutputPath is not null && inputs.Length != 1) @@ -83,6 +137,50 @@ protected override void ProcessRecord() } } + private void SignContent(string sourcePathOrExtension) + { + string tempDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDirectory); + string tempPath = Path.Combine(tempDirectory, ContentFileName(sourcePathOrExtension)); + try + { + File.WriteAllBytes(tempPath, Content); + if (!ShouldProcess(sourcePathOrExtension, "Set portable Authenticode signature on content")) + { + return; + } + + PortableSignResponse response = PsignNative.Sign(new PortableSignRequest + { + Path = tempPath, + HashAlgorithm = HashAlgorithm, + CertificatePath = CertificatePath is null + ? null + : SessionState.Path.GetUnresolvedProviderPathFromPSPath(CertificatePath), + PrivateKeyPath = PrivateKeyPath is null + ? null + : SessionState.Path.GetUnresolvedProviderPathFromPSPath(PrivateKeyPath), + CertificateDerBase64 = GetCertificateDerBase64(), + PrivateKeyDerBase64 = GetPrivateKeyDerBase64(), + ChainCertificatePaths = GetChainCertificatePaths(), + ChainCertificatesDerBase64 = GetChainCertificatesDerBase64(), + TimestampServer = TimestampServer, + TimestampHashAlgorithm = TimestampServer is null ? null : TimestampHashAlgorithm, + }); + response.Signature.SourcePathOrExtension = sourcePathOrExtension; + response.Signature.Content = File.ReadAllBytes(tempPath); + WriteObject(response.Signature); + } + catch (Exception ex) + { + WriteError(new ErrorRecord(ex, "SetPortableSignatureContentFailed", ErrorCategory.NotSpecified, sourcePathOrExtension)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + private void SignPath(string path) { try @@ -121,6 +219,10 @@ private void SignPath(string path) : SessionState.Path.GetUnresolvedProviderPathFromPSPath(PrivateKeyPath), CertificateDerBase64 = GetCertificateDerBase64(), PrivateKeyDerBase64 = GetPrivateKeyDerBase64(), + ChainCertificatePaths = GetChainCertificatePaths(), + ChainCertificatesDerBase64 = GetChainCertificatesDerBase64(), + TimestampServer = TimestampServer, + TimestampHashAlgorithm = TimestampServer is null ? null : TimestampHashAlgorithm, }); WriteObject(response.Signature); } @@ -184,11 +286,15 @@ private void ValidateSigningMaterial() { materialCount++; } + if (Thumbprint is not null) + { + materialCount++; + } if (materialCount != 1) { ThrowTerminatingError(new ErrorRecord( - new PSInvalidOperationException("Supply exactly one signing source: -Certificate, -CertificatePath/-PrivateKeyPath, or -PfxPath."), + new PSInvalidOperationException("Supply exactly one signing source: -Certificate, -CertificatePath/-PrivateKeyPath, -PfxPath, or -Thumbprint with a portable cert store."), "PortableSignatureSigningMaterialRequired", ErrorCategory.InvalidArgument, this)); @@ -197,6 +303,10 @@ private void ValidateSigningMaterial() private X509Certificate2? LoadPfxCertificate() { + if (pfxCertificate is not null) + { + return pfxCertificate; + } if (PfxPath is null) { return null; @@ -206,10 +316,11 @@ private void ValidateSigningMaterial() string? password = Password is null ? null : SecureStringToString(Password); try { - return new X509Certificate2( + pfxCertificate = new X509Certificate2( resolved, password, X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet); + return pfxCertificate; } finally { @@ -222,7 +333,7 @@ private void ValidateSigningMaterial() private string? GetCertificateDerBase64() { - X509Certificate2? cert = Certificate ?? LoadPfxCertificate(); + X509Certificate2? cert = Certificate ?? LoadPfxCertificate() ?? LoadStoreCertificate(); if (cert is null) { return null; @@ -232,6 +343,12 @@ private void ValidateSigningMaterial() private string? GetPrivateKeyDerBase64() { + if (Thumbprint is not null) + { + _ = LoadStoreCertificate(); + return storePrivateKeyDerBase64; + } + X509Certificate2? cert = Certificate ?? LoadPfxCertificate(); if (cert is null) { @@ -255,6 +372,200 @@ private void ValidateSigningMaterial() } } + private string[] GetChainCertificatePaths() + { + if (IncludeChain.Equals("Signer", StringComparison.OrdinalIgnoreCase)) + { + return []; + } + + return ChainCertificatePath + .Select(path => SessionState.Path.GetUnresolvedProviderPathFromPSPath(path)) + .ToArray(); + } + + private string[] GetChainCertificatesDerBase64() + { + if (IncludeChain.Equals("Signer", StringComparison.OrdinalIgnoreCase)) + { + return []; + } + + X509Certificate2? cert = Certificate ?? LoadPfxCertificate() ?? LoadStoreCertificate(); + if (cert is null) + { + return []; + } + + using X509Chain chain = new() + { + ChainPolicy = + { + RevocationMode = X509RevocationMode.NoCheck, + VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority, + }, + }; + chain.Build(cert); + + List encoded = []; + foreach (X509ChainElement element in chain.ChainElements) + { + X509Certificate2 chainCert = element.Certificate; + if (StringComparer.OrdinalIgnoreCase.Equals(chainCert.Thumbprint, cert.Thumbprint)) + { + continue; + } + if (IncludeChain.Equals("NotRoot", StringComparison.OrdinalIgnoreCase) + && IsSelfSigned(chainCert)) + { + continue; + } + encoded.Add(Convert.ToBase64String(chainCert.Export(X509ContentType.Cert))); + } + return encoded.ToArray(); + } + + private static bool IsSelfSigned(X509Certificate2 certificate) + { + return certificate.SubjectName.RawData.AsSpan().SequenceEqual(certificate.IssuerName.RawData); + } + + private X509Certificate2? LoadStoreCertificate() + { + if (storeCertificate is not null) + { + return storeCertificate; + } + if (Thumbprint is null) + { + return null; + } + + string normalized = NormalizeSha1Thumbprint(Thumbprint); + string baseDirectory = ResolveCertStoreBaseDirectory(); + string scope = MachineStore.IsPresent ? "LocalMachine" : "CurrentUser"; + string store = NormalizeStoreName(StoreName); + string storeDirectory = Path.Combine(baseDirectory, scope, store); + string certPath = Path.Combine(storeDirectory, normalized + ".der"); + string keyPath = Path.Combine(storeDirectory, normalized + ".key"); + if (!File.Exists(certPath)) + { + throw new FileNotFoundException( + $"Portable signing certificate SHA1 {normalized} was not found in {scope}\\{store}.", + certPath); + } + if (!File.Exists(keyPath)) + { + throw new FileNotFoundException( + $"Portable signing private key SHA1 {normalized} was not found in {scope}\\{store}.", + keyPath); + } + + storeCertificate = new X509Certificate2(File.ReadAllBytes(certPath)); + storePrivateKeyDerBase64 = Convert.ToBase64String(ReadPkcs8PrivateKeyDer(keyPath)); + return storeCertificate; + } + + private string ResolveCertStoreBaseDirectory() + { + if (CertStoreDirectory is not null) + { + return SessionState.Path.GetUnresolvedProviderPathFromPSPath(CertStoreDirectory); + } + + string? envStore = Environment.GetEnvironmentVariable("PSIGN_CERT_STORE"); + if (!string.IsNullOrWhiteSpace(envStore)) + { + return envStore; + } + + string? home = Environment.GetEnvironmentVariable("HOME") + ?? Environment.GetEnvironmentVariable("USERPROFILE"); + if (string.IsNullOrWhiteSpace(home)) + { + throw new PSInvalidOperationException( + "Cannot resolve the default portable cert-store path. Set -CertStoreDirectory or PSIGN_CERT_STORE."); + } + + return Path.Combine(home, ".psign", "cert-store"); + } + + private static string NormalizeStoreName(string storeName) + { + string trimmed = storeName.Trim(); + if (trimmed.Length == 0 || trimmed.Contains('/') || trimmed.Contains('\\') || trimmed.Contains('\0')) + { + throw new PSInvalidOperationException("Portable certificate store name must not be empty or contain path separators."); + } + + return trimmed.ToLowerInvariant() switch + { + "my" => "MY", + "root" => "Root", + "ca" => "CA", + "trust" => "Trust", + "disallowed" => "Disallowed", + _ => trimmed, + }; + } + + private static string NormalizeSha1Thumbprint(string thumbprint) + { + string clean = new(thumbprint.Where(c => c != ':' && !char.IsWhiteSpace(c)).ToArray()); + if (clean.Length != 40 || clean.Any(c => !Uri.IsHexDigit(c))) + { + throw new PSInvalidOperationException("SHA1 thumbprint must be 40 hexadecimal characters."); + } + + return clean.ToUpperInvariant(); + } + + private static byte[] ReadPkcs8PrivateKeyDer(string keyPath) + { + string text = File.ReadAllText(keyPath); + const string begin = "-----BEGIN PRIVATE KEY-----"; + const string end = "-----END PRIVATE KEY-----"; + int beginIndex = text.IndexOf(begin, StringComparison.Ordinal); + int endIndex = text.IndexOf(end, StringComparison.Ordinal); + if (beginIndex < 0 || endIndex < 0 || endIndex <= beginIndex) + { + throw new PSInvalidOperationException( + $"Portable cert-store key '{keyPath}' must be unencrypted PKCS#8 PEM (BEGIN PRIVATE KEY)."); + } + + int base64Start = beginIndex + begin.Length; + string base64 = text[base64Start..endIndex]; + string compact = new(base64.Where(c => !char.IsWhiteSpace(c)).ToArray()); + try + { + return Convert.FromBase64String(compact); + } + catch (FormatException ex) + { + throw new PSInvalidOperationException( + $"Portable cert-store key '{keyPath}' contains invalid PKCS#8 PEM base64.", + ex); + } + } + + private static string ContentFileName(string sourcePathOrExtension) + { + string fileName = Path.GetFileName(sourcePathOrExtension); + if (!string.IsNullOrWhiteSpace(fileName) + && Path.HasExtension(fileName) + && !string.IsNullOrWhiteSpace(Path.GetFileNameWithoutExtension(fileName))) + { + return fileName; + } + + string extension = sourcePathOrExtension.Trim(); + if (!extension.StartsWith('.')) + { + extension = "." + extension; + } + return "content" + extension; + } + private static string SecureStringToString(SecureString value) { IntPtr ptr = Marshal.SecureStringToBSTR(value); diff --git a/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs b/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs index 165ea1f..6e4e47b 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs @@ -6,6 +6,36 @@ internal sealed class PortableGetSignatureRequest { [JsonPropertyName("path")] public required string Path { get; init; } + + [JsonPropertyName("trusted_certificate_paths")] + public string[] TrustedCertificatePaths { get; init; } = []; + + [JsonPropertyName("trusted_certificates_der_base64")] + public string[] TrustedCertificatesDerBase64 { get; init; } = []; + + [JsonPropertyName("anchor_directory")] + public string? AnchorDirectory { get; init; } + + [JsonPropertyName("authroot_cab")] + public string? AuthRootCab { get; init; } + + [JsonPropertyName("as_of")] + public string? AsOf { get; init; } + + [JsonPropertyName("prefer_timestamp_signing_time")] + public bool PreferTimestampSigningTime { get; init; } + + [JsonPropertyName("require_valid_timestamp")] + public bool RequireValidTimestamp { get; init; } + + [JsonPropertyName("online_aia")] + public bool OnlineAia { get; init; } + + [JsonPropertyName("online_ocsp")] + public bool OnlineOcsp { get; init; } + + [JsonPropertyName("revocation_mode")] + public string RevocationMode { get; init; } = "Off"; } internal sealed class PortableSignRequest @@ -30,4 +60,16 @@ internal sealed class PortableSignRequest [JsonPropertyName("private_key_der_base64")] public string? PrivateKeyDerBase64 { get; init; } + + [JsonPropertyName("chain_certificate_paths")] + public string[] ChainCertificatePaths { get; init; } = []; + + [JsonPropertyName("chain_certificates_der_base64")] + public string[] ChainCertificatesDerBase64 { get; init; } = []; + + [JsonPropertyName("timestamp_server")] + public string? TimestampServer { get; init; } + + [JsonPropertyName("timestamp_hash_algorithm")] + public string? TimestampHashAlgorithm { get; init; } } diff --git a/dotnet/Devolutions.Psign.PowerShell/Models/PortableSignature.cs b/dotnet/Devolutions.Psign.PowerShell/Models/PortableSignature.cs index edb10b9..f05f3a5 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Models/PortableSignature.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Models/PortableSignature.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using System.Security.Cryptography.X509Certificates; namespace Devolutions.Psign.PowerShell.Models; @@ -19,23 +20,61 @@ public sealed class PortableSignature [JsonPropertyName("status_message")] public string StatusMessage { get; init; } = string.Empty; + [JsonPropertyName("trust_status")] + public string? TrustStatus { get; init; } + [JsonPropertyName("signature_count")] public int SignatureCount { get; init; } + [JsonPropertyName("signer_index")] + public int? SignerIndex { get; init; } + + [JsonPropertyName("signer_certificate_der_base64")] + public string? SignerCertificateDerBase64 { get; init; } + + [JsonPropertyName("timestamper_certificate_der_base64")] + public string? TimeStamperCertificateDerBase64 { get; init; } + + [JsonPropertyName("embedded_certificate_count")] + public int EmbeddedCertificateCount { get; init; } + [JsonPropertyName("digest_algorithm")] public string? DigestAlgorithm { get; init; } [JsonPropertyName("timestamp_kinds")] public string[] TimestampKinds { get; init; } = []; + [JsonPropertyName("timestamp_signing_time")] + public DateTime? TimestampSigningTime { get; init; } + [JsonPropertyName("diagnostics")] public string[] PortableDiagnostics { get; init; } = []; - public object? SignerCertificate => null; + [JsonIgnore] + public X509Certificate2? SignerCertificate => DecodeCertificate(SignerCertificateDerBase64); - public object? TimeStamperCertificate => null; + [JsonIgnore] + public X509Certificate2? TimeStamperCertificate => DecodeCertificate(TimeStamperCertificateDerBase64); + [JsonIgnore] public string SignatureType => Format; + [JsonIgnore] public bool IsOSBinary => false; + + [JsonIgnore] + public string? SourcePathOrExtension { get; set; } + + [JsonIgnore] + public byte[]? Content { get; set; } + + private static X509Certificate2? DecodeCertificate(string? derBase64) + { + if (string.IsNullOrWhiteSpace(derBase64)) + { + return null; + } + + return new X509Certificate2(Convert.FromBase64String(derBase64)); + } } From 2d71d4eec9cd7a7bad3d5b04a2497cc537e60a50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 21 May 2026 21:40:19 -0400 Subject: [PATCH 03/10] Fix portable module PFX loading on macOS Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Cmdlets/SetPortableSignatureCommand.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs index 623eebb..9033cee 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs @@ -316,10 +316,16 @@ private void ValidateSigningMaterial() string? password = Password is null ? null : SecureStringToString(Password); try { + X509KeyStorageFlags flags = X509KeyStorageFlags.Exportable; + if (!OperatingSystem.IsMacOS()) + { + flags |= X509KeyStorageFlags.EphemeralKeySet; + } + pfxCertificate = new X509Certificate2( resolved, password, - X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet); + flags); return pfxCertificate; } finally From 8bc637d47a507b8bc452a94a98a44ea3eeffb0a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 21 May 2026 21:49:42 -0400 Subject: [PATCH 04/10] Use exportable PFX key storage for portable module Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Cmdlets/SetPortableSignatureCommand.cs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs index 9033cee..8c0ab3c 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs @@ -316,16 +316,10 @@ private void ValidateSigningMaterial() string? password = Password is null ? null : SecureStringToString(Password); try { - X509KeyStorageFlags flags = X509KeyStorageFlags.Exportable; - if (!OperatingSystem.IsMacOS()) - { - flags |= X509KeyStorageFlags.EphemeralKeySet; - } - pfxCertificate = new X509Certificate2( resolved, password, - flags); + X509KeyStorageFlags.Exportable); return pfxCertificate; } finally From 7fe6037a3706b10b8f6f90d64b34477c3e250b72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Thu, 21 May 2026 22:00:01 -0400 Subject: [PATCH 05/10] Load PFX signing material in portable core Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/psign-portable-core/Cargo.toml | 2 +- crates/psign-portable-core/src/lib.rs | 163 +++++++++++++++--- .../Cmdlets/SetPortableSignatureCommand.cs | 12 +- .../Models/PortableRequests.cs | 6 + 4 files changed, 153 insertions(+), 30 deletions(-) diff --git a/crates/psign-portable-core/Cargo.toml b/crates/psign-portable-core/Cargo.toml index cd54b43..f01416c 100644 --- a/crates/psign-portable-core/Cargo.toml +++ b/crates/psign-portable-core/Cargo.toml @@ -11,7 +11,7 @@ anyhow = "1" base64 = "0.22" psign-authenticode-trust = { path = "../psign-authenticode-trust" } psign-sip-digest = { path = "../psign-sip-digest" } -picky = { version = "7.0.0-rc.23", default-features = false } +picky = { version = "7.0.0-rc.23", default-features = false, features = ["pkcs12"] } rsa = "0.9.10" reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } serde = { version = "1", features = ["derive"] } diff --git a/crates/psign-portable-core/src/lib.rs b/crates/psign-portable-core/src/lib.rs index 4a5af6a..626b098 100644 --- a/crates/psign-portable-core/src/lib.rs +++ b/crates/psign-portable-core/src/lib.rs @@ -5,6 +5,11 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use base64::Engine as _; +use picky::key::PrivateKey; +use picky::pkcs12::{ + Pfx, Pkcs12CryptoContext, Pkcs12ParsingParams, SafeBag, SafeBagKind, SafeContentsKind, +}; +use picky::x509::Cert as PickyCert; use picky::x509::date::UtcDate; use psign_authenticode_trust::policy::{OnlineTrustOptions, RevocationMode}; use psign_authenticode_trust::{ @@ -158,6 +163,10 @@ pub struct PortableSignRequest { #[serde(default)] pub private_key_der_base64: Option, #[serde(default)] + pub pfx_path: Option, + #[serde(default)] + pub pfx_password: Option, + #[serde(default)] pub chain_certificate_paths: Vec, #[serde(default)] pub chain_certificates_der_base64: Vec, @@ -528,33 +537,52 @@ fn load_signing_material( rsa::RsaPrivateKey, Vec, )> { - let cert_bytes = match (&request.certificate_der_base64, &request.certificate_path) { - (Some(_), Some(_)) => { - bail!("provide only one of certificate_der_base64 or certificate_path") - } - (Some(b64), None) => base64::engine::general_purpose::STANDARD - .decode(b64) - .context("decode certificate_der_base64")?, - (None, Some(path)) => { - std::fs::read(path).with_context(|| format!("read {}", path.display()))? - } - (None, None) => { - bail!("portable signing requires certificate_der_base64 or certificate_path") - } - }; - let key_bytes = match (&request.private_key_der_base64, &request.private_key_path) { - (Some(_), Some(_)) => { - bail!("provide only one of private_key_der_base64 or private_key_path") - } - (Some(b64), None) => base64::engine::general_purpose::STANDARD - .decode(b64) - .context("decode private_key_der_base64")?, - (None, Some(path)) => { - std::fs::read(path).with_context(|| format!("read {}", path.display()))? - } - (None, None) => { - bail!("portable signing requires private_key_der_base64 or private_key_path") - } + let uses_pfx = request.pfx_path.is_some(); + if uses_pfx + && (request.certificate_der_base64.is_some() + || request.certificate_path.is_some() + || request.private_key_der_base64.is_some() + || request.private_key_path.is_some()) + { + bail!("provide either pfx_path or certificate/private key material, not both"); + } + + let (cert_bytes, key_bytes) = if let Some(pfx_path) = &request.pfx_path { + let pfx_bytes = + std::fs::read(pfx_path).with_context(|| format!("read {}", pfx_path.display()))?; + let password = request.pfx_password.as_deref().unwrap_or_default(); + load_pfx_cert_and_key(&pfx_bytes, password) + .with_context(|| format!("parse PFX {}", pfx_path.display()))? + } else { + let cert_bytes = match (&request.certificate_der_base64, &request.certificate_path) { + (Some(_), Some(_)) => { + bail!("provide only one of certificate_der_base64 or certificate_path") + } + (Some(b64), None) => base64::engine::general_purpose::STANDARD + .decode(b64) + .context("decode certificate_der_base64")?, + (None, Some(path)) => { + std::fs::read(path).with_context(|| format!("read {}", path.display()))? + } + (None, None) => { + bail!("portable signing requires certificate_der_base64 or certificate_path") + } + }; + let key_bytes = match (&request.private_key_der_base64, &request.private_key_path) { + (Some(_), Some(_)) => { + bail!("provide only one of private_key_der_base64 or private_key_path") + } + (Some(b64), None) => base64::engine::general_purpose::STANDARD + .decode(b64) + .context("decode private_key_der_base64")?, + (None, Some(path)) => { + std::fs::read(path).with_context(|| format!("read {}", path.display()))? + } + (None, None) => { + bail!("portable signing requires private_key_der_base64 or private_key_path") + } + }; + (cert_bytes, key_bytes) }; let signer_cert = rdp::parse_certificate(&cert_bytes).context("parse signer certificate")?; @@ -580,6 +608,87 @@ fn load_signing_material( Ok((signer_cert, private_key, chain)) } +fn load_pfx_cert_and_key(bytes: &[u8], password: &str) -> Result<(Vec, Vec)> { + let crypto_context = Pkcs12CryptoContext::new_with_password(password)?; + let parsing_params = Pkcs12ParsingParams::default(); + let pfx = Pfx::from_der(bytes, &crypto_context, &parsing_params)?; + let mut certs: Vec> = Vec::new(); + let mut keys: Vec<(Vec, PrivateKey)> = Vec::new(); + for safe_contents in pfx.safe_contents() { + collect_pfx_bags(safe_contents.kind(), &mut certs, &mut keys)?; + } + if certs.is_empty() { + bail!("PFX did not contain an X.509 certificate"); + } + if keys.is_empty() { + bail!("PFX did not contain a private key"); + } + for cert in certs { + for (key_pem, key) in &keys { + if ensure_key_matches_cert(&cert, key).is_ok() { + return Ok((cert, key_pem.clone())); + } + } + } + bail!("PFX did not contain a certificate matching an included private key") +} + +fn collect_pfx_bags( + kind: &SafeContentsKind, + certs: &mut Vec>, + keys: &mut Vec<(Vec, PrivateKey)>, +) -> Result<()> { + match kind { + SafeContentsKind::SafeBags(bags) + | SafeContentsKind::EncryptedSafeBags { + safe_bags: bags, .. + } => { + for bag in bags { + collect_safe_bag(bag, certs, keys)?; + } + } + SafeContentsKind::Unknown => {} + } + Ok(()) +} + +fn collect_safe_bag( + bag: &SafeBag, + certs: &mut Vec>, + keys: &mut Vec<(Vec, PrivateKey)>, +) -> Result<()> { + match bag.kind() { + SafeBagKind::PrivateKey(key) | SafeBagKind::EncryptedPrivateKey { key, .. } => { + let mut pem = key + .to_pem_str() + .context("encode PFX private key as PKCS#8 PEM")?; + pem.push('\n'); + keys.push((pem.into_bytes(), key.clone())); + } + SafeBagKind::Certificate(cert) => { + certs.push(cert.to_der().context("encode PFX certificate as DER")?); + } + SafeBagKind::Nested(bags) => { + for nested in bags { + collect_safe_bag(nested, certs, keys)?; + } + } + SafeBagKind::Secret(_) | SafeBagKind::Unknown => {} + } + Ok(()) +} + +fn ensure_key_matches_cert(cert_der: &[u8], key: &PrivateKey) -> Result<()> { + let cert = PickyCert::from_der(cert_der).context("parse certificate for key matching")?; + let key_public = key + .to_public_key() + .context("derive public key from private key")?; + if cert.public_key() != &key_public { + bail!("private key does not match certificate public key"); + } + Ok(()) +} + fn maybe_timestamp_pkcs7(request: &PortableSignRequest, pkcs7_der: Vec) -> Result> { let Some(timestamp_server) = request.timestamp_server.as_deref() else { if request.timestamp_hash_algorithm.is_some() { diff --git a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs index 8c0ab3c..640a4af 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs @@ -162,6 +162,10 @@ private void SignContent(string sourcePathOrExtension) : SessionState.Path.GetUnresolvedProviderPathFromPSPath(PrivateKeyPath), CertificateDerBase64 = GetCertificateDerBase64(), PrivateKeyDerBase64 = GetPrivateKeyDerBase64(), + PfxPath = PfxPath is null + ? null + : SessionState.Path.GetUnresolvedProviderPathFromPSPath(PfxPath), + PfxPassword = Password is null ? null : SecureStringToString(Password), ChainCertificatePaths = GetChainCertificatePaths(), ChainCertificatesDerBase64 = GetChainCertificatesDerBase64(), TimestampServer = TimestampServer, @@ -219,6 +223,10 @@ private void SignPath(string path) : SessionState.Path.GetUnresolvedProviderPathFromPSPath(PrivateKeyPath), CertificateDerBase64 = GetCertificateDerBase64(), PrivateKeyDerBase64 = GetPrivateKeyDerBase64(), + PfxPath = PfxPath is null + ? null + : SessionState.Path.GetUnresolvedProviderPathFromPSPath(PfxPath), + PfxPassword = Password is null ? null : SecureStringToString(Password), ChainCertificatePaths = GetChainCertificatePaths(), ChainCertificatesDerBase64 = GetChainCertificatesDerBase64(), TimestampServer = TimestampServer, @@ -333,7 +341,7 @@ private void ValidateSigningMaterial() private string? GetCertificateDerBase64() { - X509Certificate2? cert = Certificate ?? LoadPfxCertificate() ?? LoadStoreCertificate(); + X509Certificate2? cert = Certificate ?? LoadStoreCertificate(); if (cert is null) { return null; @@ -349,7 +357,7 @@ private void ValidateSigningMaterial() return storePrivateKeyDerBase64; } - X509Certificate2? cert = Certificate ?? LoadPfxCertificate(); + X509Certificate2? cert = Certificate; if (cert is null) { return null; diff --git a/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs b/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs index 6e4e47b..f31681e 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs @@ -61,6 +61,12 @@ internal sealed class PortableSignRequest [JsonPropertyName("private_key_der_base64")] public string? PrivateKeyDerBase64 { get; init; } + [JsonPropertyName("pfx_path")] + public string? PfxPath { get; init; } + + [JsonPropertyName("pfx_password")] + public string? PfxPassword { get; init; } + [JsonPropertyName("chain_certificate_paths")] public string[] ChainCertificatePaths { get; init; } = []; From 47f11e0028941ca713f33732427fabaed8e475da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 22 May 2026 08:42:54 -0400 Subject: [PATCH 06/10] Align portable PowerShell signing parity Default IncludeChain to NotRoot and add XML/MOF PowerShell script marker signing support so ps1xml files sign and verify through the portable cmdlets. Extend module directory coverage and E2E tests for the new behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/Invoke-PortableSignatureTests.ps1 | 42 +++++++++- crates/psign-portable-core/src/lib.rs | 79 ++++++++++++++++--- docs/portable-powershell-module.md | 6 +- .../Cmdlets/SetPortableSignatureCommand.cs | 18 ++++- .../Utilities/PortableModuleFiles.cs | 1 + 5 files changed, 126 insertions(+), 20 deletions(-) diff --git a/PowerShell/tests/Invoke-PortableSignatureTests.ps1 b/PowerShell/tests/Invoke-PortableSignatureTests.ps1 index 20ba181..01816ec 100644 --- a/PowerShell/tests/Invoke-PortableSignatureTests.ps1 +++ b/PowerShell/tests/Invoke-PortableSignatureTests.ps1 @@ -221,6 +221,12 @@ try { $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)." @@ -291,6 +297,33 @@ param([string] $Name = "portable") 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') { @@ -335,10 +368,11 @@ param([string] $Name = "portable") 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 3) { - throw "Expected 3 signed PowerShell module files, got $($moduleSigned.Count)." + 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)" @@ -347,8 +381,8 @@ param([string] $Name = "portable") Assert-SignerCertificate -Signature $moduleSignature -ExpectedCertificate $cert -Label "module signing response $($moduleSignature.Path)" } $moduleValidated = @(Get-PortableSignature -LiteralPath $moduleDir) - if ($moduleValidated.Count -ne 3) { - throw "Expected 3 validated PowerShell module files, got $($moduleValidated.Count)." + 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)" diff --git a/crates/psign-portable-core/src/lib.rs b/crates/psign-portable-core/src/lib.rs index 626b098..9c70b05 100644 --- a/crates/psign-portable-core/src/lib.rs +++ b/crates/psign-portable-core/src/lib.rs @@ -331,7 +331,9 @@ pub fn infer_format(path: &Path) -> PortableFileFormat { "msix" | "appx" | "msixbundle" | "appxbundle" => PortableFileFormat::Msix, "cat" => PortableFileFormat::Catalog, "zip" | "vsix" | "nupkg" => PortableFileFormat::Zip, - "ps1" | "psm1" | "psd1" | "ps1xml" => PortableFileFormat::PowerShellScript, + "ps1" | "psm1" | "psd1" | "ps1xml" | "psc1" | "cdxml" | "mof" => { + PortableFileFormat::PowerShellScript + } "vbs" | "js" | "wsf" => PortableFileFormat::WshScript, _ => PortableFileFormat::Unknown, } @@ -502,8 +504,13 @@ fn sign_script(request: &PortableSignRequest, output_path: &Path) -> Result<()> .and_then(|e| e.to_str()) .unwrap_or("ps1") .to_ascii_lowercase(); - if !matches!(ext.as_str(), "ps1" | "psd1" | "psm1") { - bail!("portable script signing currently supports ps1, psd1, and psm1 hash-marker scripts"); + if !matches!( + ext.as_str(), + "ps1" | "psd1" | "psm1" | "ps1xml" | "psc1" | "cdxml" | "mof" + ) { + bail!( + "portable script signing supports ps1, psd1, psm1, ps1xml, psc1, cdxml, and mof scripts" + ); } let script = @@ -524,7 +531,7 @@ fn sign_script(request: &PortableSignRequest, output_path: &Path) -> Result<()> })?; let pkcs7 = maybe_timestamp_pkcs7(request, pkcs7) .with_context(|| format!("timestamp {}", request.path.display()))?; - let block = format_powershell_signature_block(&pkcs7); + let block = format_powershell_signature_block(&pkcs7, &ext); let mut signed = script; signed.extend_from_slice(block.as_bytes()); std::fs::write(output_path, signed).with_context(|| format!("write {}", output_path.display())) @@ -1446,15 +1453,35 @@ fn xml_escape_attr(value: &str) -> String { .replace('>', ">") } -fn format_powershell_signature_block(pkcs7_der: &[u8]) -> String { +fn format_powershell_signature_block(pkcs7_der: &[u8], extension: &str) -> String { let b64 = base64::engine::general_purpose::STANDARD.encode(pkcs7_der); - let mut block = String::from("\r\n# SIG # Begin signature block\r\n"); + let (begin, line_prefix, line_suffix, end) = match extension.to_ascii_lowercase().as_str() { + "ps1xml" | "psc1" | "cdxml" => ( + "\r\n\r\n", + "\r\n", + "\r\n", + ), + "mof" => ( + "\r\n/* SIG # Begin signature block */\r\n", + "/* ", + " */\r\n", + "/* SIG # End signature block */\r\n", + ), + _ => ( + "\r\n# SIG # Begin signature block\r\n", + "# ", + "\r\n", + "# SIG # End signature block\r\n", + ), + }; + let mut block = String::from(begin); for chunk in b64.as_bytes().chunks(64) { - block.push_str("# "); + block.push_str(line_prefix); block.push_str(std::str::from_utf8(chunk).expect("base64 is ASCII")); - block.push_str("\r\n"); + block.push_str(line_suffix); } - block.push_str("# SIG # End signature block\r\n"); + block.push_str(end); block } @@ -1481,12 +1508,46 @@ mod tests { infer_format(Path::new("script.ps1")), PortableFileFormat::PowerShellScript ); + assert_eq!( + infer_format(Path::new("types.ps1xml")), + PortableFileFormat::PowerShellScript + ); + assert_eq!( + infer_format(Path::new("console.psc1")), + PortableFileFormat::PowerShellScript + ); + assert_eq!( + infer_format(Path::new("module.cdxml")), + PortableFileFormat::PowerShellScript + ); + assert_eq!( + infer_format(Path::new("config.mof")), + PortableFileFormat::PowerShellScript + ); assert_eq!( infer_format(Path::new("unknown.bin")), PortableFileFormat::Unknown ); } + #[test] + fn formats_script_signature_marker_family() { + let ps1_block = format_powershell_signature_block(b"abc", "ps1"); + assert!(ps1_block.contains("# SIG # Begin signature block")); + assert!(ps1_block.contains("# YWJj")); + assert!(ps1_block.contains("# SIG # End signature block")); + + let ps1xml_block = format_powershell_signature_block(b"abc", "ps1xml"); + assert!(ps1xml_block.contains("")); + assert!(ps1xml_block.contains("")); + assert!(ps1xml_block.contains("")); + + let mof_block = format_powershell_signature_block(b"abc", "mof"); + assert!(mof_block.contains("/* SIG # Begin signature block */")); + assert!(mof_block.contains("/* YWJj */")); + assert!(mof_block.contains("/* SIG # End signature block */")); + } + #[test] fn reports_unsigned_pe_without_error() { let path = PathBuf::from("../../tests/fixtures/pe-authenticode-upstream/tiny32.efi"); diff --git a/docs/portable-powershell-module.md b/docs/portable-powershell-module.md index f5d4064..1f22f39 100644 --- a/docs/portable-powershell-module.md +++ b/docs/portable-powershell-module.md @@ -7,7 +7,7 @@ - `Set-PortableSignature` - `Get-PortableSignature` -Both cmdlets accept `-FilePath` and `-LiteralPath`. When the input is a directory, the module treats it as a PowerShell module tree and recursively processes `.ps1`, `.psm1`, and `.psd1` files. +Both cmdlets accept `-FilePath` and `-LiteralPath`. When the input is a directory, the module treats it as a PowerShell module tree and recursively processes `.ps1`, `.psm1`, `.psd1`, and `.ps1xml` files. Both cmdlets also support the built-in Authenticode content parameter shape: @@ -31,7 +31,7 @@ The P/Invoke ABI also accepts in-memory DER certificate and PKCS#8 private-key m The portable cert store follows the same layout as `psign-tool cert-store`: `\\\.der` plus `.key`, where scope is `CurrentUser` or `LocalMachine` and the private key is unencrypted PKCS#8 PEM. `-Thumbprint` has `-Sha1` and `-PortableStoreThumbprint` aliases. If `-CertStoreDirectory` is omitted, the module uses `PSIGN_CERT_STORE` and then `~\.psign\cert-store`. -`Set-PortableSignature` supports `-IncludeChain Signer|NotRoot|All`, optional `-ChainCertificatePath`, `-TimestampServer`, and `-TimestampHashAlgorithm Sha1|Sha256|Sha384|Sha512`. +`Set-PortableSignature` supports `-IncludeChain Signer|NotRoot|All` (default `NotRoot`), optional `-ChainCertificatePath`, `-TimestampServer`, and `-TimestampHashAlgorithm Sha1|Sha256|Sha384|Sha512`. ## Explicit trust @@ -56,7 +56,7 @@ The current module tests cover signing and validation through the PowerShell sur - CAB archives - MSI/MSP installers - Devolutions ZIP Authenticode packages -- PowerShell scripts +- PowerShell scripts, including `.ps1xml` XML marker signatures - PowerShell module directories - MSIX/AppX packages diff --git a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs index 640a4af..a88e981 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs @@ -64,7 +64,7 @@ public sealed class SetPortableSignatureCommand : PSCmdlet [Parameter] [ValidateSet("Signer", "NotRoot", "All")] - public string IncludeChain { get; set; } = "Signer"; + public string IncludeChain { get; set; } = "NotRoot"; [Parameter] public string[] ChainCertificatePath { get; set; } = []; @@ -387,9 +387,19 @@ private string[] GetChainCertificatePaths() return []; } - return ChainCertificatePath - .Select(path => SessionState.Path.GetUnresolvedProviderPathFromPSPath(path)) - .ToArray(); + List paths = []; + foreach (string path in ChainCertificatePath) + { + string resolved = SessionState.Path.GetUnresolvedProviderPathFromPSPath(path); + using X509Certificate2 chainCertificate = new(resolved); + if (IncludeChain.Equals("NotRoot", StringComparison.OrdinalIgnoreCase) + && IsSelfSigned(chainCertificate)) + { + continue; + } + paths.Add(resolved); + } + return paths.ToArray(); } private string[] GetChainCertificatesDerBase64() diff --git a/dotnet/Devolutions.Psign.PowerShell/Utilities/PortableModuleFiles.cs b/dotnet/Devolutions.Psign.PowerShell/Utilities/PortableModuleFiles.cs index d55483b..a9bb172 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Utilities/PortableModuleFiles.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Utilities/PortableModuleFiles.cs @@ -7,6 +7,7 @@ internal static class PortableModuleFiles ".ps1", ".psm1", ".psd1", + ".ps1xml", }; internal static IReadOnlyList Enumerate(string directory) From 8927477d15b8f1afb8861d880568b2615c1beafd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 22 May 2026 08:57:31 -0400 Subject: [PATCH 07/10] Split fast Unix formatting check Keep fmt-and-lockfile focused on rustfmt and lockfile validation, move the heavier portable clippy/test matrix into a separate cached portable-checks job to make the formatting gate complete faster without dropping coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci-unix.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-unix.yml b/.github/workflows/ci-unix.yml index d5b1c8d..8cd1ef1 100644 --- a/.github/workflows/ci-unix.yml +++ b/.github/workflows/ci-unix.yml @@ -10,11 +10,11 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Install Rust stable (rustfmt, clippy) + - name: Install Rust stable (rustfmt) run: | rustup toolchain install stable --profile minimal rustup default stable - rustup component add rustfmt clippy --toolchain stable + rustup component add rustfmt --toolchain stable - name: rustfmt run: cargo fmt --all --check @@ -22,6 +22,20 @@ jobs: - name: Cargo.lock consistency run: cargo metadata --locked --format-version 1 > /dev/null + portable-checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install Rust stable (clippy) + run: | + rustup toolchain install stable --profile minimal + rustup default stable + rustup component add clippy --toolchain stable + + - name: Cache cargo artifacts + uses: Swatinem/rust-cache@v2.9.1 + - name: clippy (portable digest + trust + core + Azure REST crates) run: cargo clippy -p psign-sip-digest -p psign-digest-cli -p psign-authenticode-trust -p psign-portable-core -p psign-portable-ffi -p psign-codesigning-rest -p psign-azure-kv-rest --all-targets --locked -- -D warnings From 10c53ff49f93e9c9ee023799b6b9fb4bedd42e51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 22 May 2026 09:15:04 -0400 Subject: [PATCH 08/10] Parallelize Unix portable validation Split the slow ci-unix portable checks into parallel clippy, crate-test, and matrixed CLI-test jobs. Keep a lightweight portable-checks aggregate job so required-check semantics are preserved while reducing wall-clock time for the cli_pe_digest feature runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci-unix.yml | 68 +++++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci-unix.yml b/.github/workflows/ci-unix.yml index 8cd1ef1..2875361 100644 --- a/.github/workflows/ci-unix.yml +++ b/.github/workflows/ci-unix.yml @@ -22,7 +22,7 @@ jobs: - name: Cargo.lock consistency run: cargo metadata --locked --format-version 1 > /dev/null - portable-checks: + portable-clippy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -51,6 +51,19 @@ jobs: - name: clippy (psign portable lib) run: cargo clippy -p psign --lib --locked -- -D warnings + portable-crate-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install Rust stable + run: | + rustup toolchain install stable --profile minimal + rustup default stable + + - name: Cache cargo artifacts + uses: Swatinem/rust-cache@v2.9.1 + - name: SIP digest crate tests (no Win32) run: cargo test -p psign-sip-digest --lib --locked @@ -69,17 +82,52 @@ jobs: - name: Azure KV REST crate tests (no Win32) run: cargo test -p psign-azure-kv-rest --locked - - name: Portable digest CLI (integration smoke) - run: cargo test -p psign --test cli_pe_digest --locked - - - name: Portable digest CLI (artifact-signing-rest subcommand) - run: cargo test -p psign --test cli_pe_digest --features artifact-signing-rest --locked - - - name: Portable digest CLI (azure-kv-sign-portable subcommand) - run: cargo test -p psign --test cli_pe_digest --features azure-kv-sign --locked - - name: Check psign stub binary + portable lib run: cargo check -p psign --bins --lib --locked - name: Portable lib unit tests (native argv / response files) run: cargo test -p psign --lib --locked + + portable-cli-tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: integration-smoke + features: "" + - name: artifact-signing-rest + features: "--features artifact-signing-rest" + - name: azure-kv-sign-portable + features: "--features azure-kv-sign" + steps: + - uses: actions/checkout@v6 + + - name: Install Rust stable + run: | + rustup toolchain install stable --profile minimal + rustup default stable + + - name: Cache cargo artifacts + uses: Swatinem/rust-cache@v2.9.1 + + - name: Portable digest CLI (${{ matrix.name }}) + run: cargo test -p psign --test cli_pe_digest ${{ matrix.features }} --locked + + portable-checks: + runs-on: ubuntu-latest + needs: + - portable-clippy + - portable-crate-tests + - portable-cli-tests + if: always() + steps: + - name: Check split portable jobs + env: + PORTABLE_CLIPPY: ${{ needs.portable-clippy.result }} + PORTABLE_CRATE_TESTS: ${{ needs.portable-crate-tests.result }} + PORTABLE_CLI_TESTS: ${{ needs.portable-cli-tests.result }} + run: | + test "$PORTABLE_CLIPPY" = "success" + test "$PORTABLE_CRATE_TESTS" = "success" + test "$PORTABLE_CLI_TESTS" = "success" From d206d3cbd583bfd776a724c3d190f53652aa92c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 22 May 2026 09:39:42 -0400 Subject: [PATCH 09/10] Rename portable native library to psign-core Stage the FFI shared library as psign-core.dll, libpsign-core.so, or libpsign-core.dylib and update the PowerShell module resolver to import psign-core. Rename the C ABI symbols to the psign_core prefix while keeping the Cargo target name as psign_core because Rust target names cannot contain hyphens. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PowerShell/build.ps1 | 18 +++++++++++++---- crates/psign-portable-ffi/Cargo.toml | 2 +- crates/psign-portable-ffi/src/lib.rs | 20 +++++++++---------- docs/portable-core-ffi.md | 14 ++++++------- docs/portable-powershell-module.md | 4 ++-- .../Native/PsignNative.cs | 14 ++++++------- .../Native/PsignNativeResolver.cs | 10 +++++----- 7 files changed, 46 insertions(+), 36 deletions(-) diff --git a/PowerShell/build.ps1 b/PowerShell/build.ps1 index 33ec22e..d2ade84 100644 --- a/PowerShell/build.ps1 +++ b/PowerShell/build.ps1 @@ -29,17 +29,27 @@ try { default { $_.ToString().ToLowerInvariant() } } $rid = "$rid-$arch" + $cargoNativeName = if ($IsWindows) { + 'psign_core.dll' + } elseif ($IsMacOS) { + 'libpsign_core.dylib' + } else { + 'libpsign_core.so' + } $nativeName = if ($IsWindows) { - 'psign_portable.dll' + 'psign-core.dll' } elseif ($IsMacOS) { - 'libpsign_portable.dylib' + 'libpsign-core.dylib' } else { - 'libpsign_portable.so' + 'libpsign-core.so' } $profileDir = if ($Configuration -eq 'Release') { 'release' } else { 'debug' } - $nativeSource = Join-Path (Join-Path (Join-Path $repo 'target') $profileDir) $nativeName + $nativeSource = Join-Path (Join-Path (Join-Path $repo 'target') $profileDir) $cargoNativeName $nativeOut = Join-Path (Join-Path (Join-Path $moduleRoot 'runtimes') $rid) 'native' New-Item -ItemType Directory -Force -Path $nativeOut | Out-Null + foreach ($staleName in @('psign_portable.dll', 'libpsign_portable.dylib', 'libpsign_portable.so')) { + Remove-Item -LiteralPath (Join-Path $nativeOut $staleName) -Force -ErrorAction SilentlyContinue + } Copy-Item -Force -Path $nativeSource -Destination (Join-Path $nativeOut $nativeName) } finally { diff --git a/crates/psign-portable-ffi/Cargo.toml b/crates/psign-portable-ffi/Cargo.toml index 2f277e7..79c7071 100644 --- a/crates/psign-portable-ffi/Cargo.toml +++ b/crates/psign-portable-ffi/Cargo.toml @@ -7,7 +7,7 @@ license.workspace = true repository.workspace = true [lib] -name = "psign_portable" +name = "psign_core" crate-type = ["cdylib", "rlib"] [dependencies] diff --git a/crates/psign-portable-ffi/src/lib.rs b/crates/psign-portable-ffi/src/lib.rs index c3de669..85159f9 100644 --- a/crates/psign-portable-ffi/src/lib.rs +++ b/crates/psign-portable-ffi/src/lib.rs @@ -28,18 +28,18 @@ pub struct PsignFfiResult { } #[unsafe(no_mangle)] -pub extern "C" fn psign_portable_version() -> PsignFfiResult { +pub extern "C" fn psign_core_version() -> PsignFfiResult { ok_json(&version()) } -/// Free a buffer returned by another `psign_portable_*` function. +/// Free a buffer returned by another `psign_core_*` function. /// /// # Safety /// /// `buffer` must be a `PsignFfiBuffer` returned by this library and must not have /// been freed already. Passing any other pointer, length, or capacity is undefined behavior. #[unsafe(no_mangle)] -pub unsafe extern "C" fn psign_portable_free(buffer: PsignFfiBuffer) { +pub unsafe extern "C" fn psign_core_free(buffer: PsignFfiBuffer) { if buffer.ptr.is_null() { return; } @@ -56,9 +56,9 @@ pub unsafe extern "C" fn psign_portable_free(buffer: PsignFfiBuffer) { /// # Safety /// /// `request_json_ptr` must point to `request_json_len` readable UTF-8 bytes for the duration -/// of the call. The returned buffer must be released with `psign_portable_free`. +/// of the call. The returned buffer must be released with `psign_core_free`. #[unsafe(no_mangle)] -pub unsafe extern "C" fn psign_portable_get_signature( +pub unsafe extern "C" fn psign_core_get_signature( request_json_ptr: *const u8, request_json_len: usize, ) -> PsignFfiResult { @@ -70,9 +70,9 @@ pub unsafe extern "C" fn psign_portable_get_signature( /// # Safety /// /// `request_json_ptr` must point to `request_json_len` readable UTF-8 bytes for the duration -/// of the call. The returned buffer must be released with `psign_portable_free`. +/// of the call. The returned buffer must be released with `psign_core_free`. #[unsafe(no_mangle)] -pub unsafe extern "C" fn psign_portable_sign( +pub unsafe extern "C" fn psign_core_sign( request_json_ptr: *const u8, request_json_len: usize, ) -> PsignFfiResult { @@ -169,13 +169,13 @@ mod tests { unsafe fn result_json(result: PsignFfiResult) -> String { let slice = unsafe { std::slice::from_raw_parts(result.json.ptr, result.json.len) }; let text = std::str::from_utf8(slice).expect("utf-8").to_owned(); - unsafe { psign_portable_free(result.json) }; + unsafe { psign_core_free(result.json) }; text } #[test] fn version_returns_json() { - let result = psign_portable_version(); + let result = psign_core_version(); assert_eq!(result.status_code, STATUS_OK); let json = unsafe { result_json(result) }; assert!(json.contains("psign-portable-core")); @@ -184,7 +184,7 @@ mod tests { #[test] fn invalid_json_returns_structured_error() { let request = b"{not json"; - let result = unsafe { psign_portable_get_signature(request.as_ptr(), request.len()) }; + let result = unsafe { psign_core_get_signature(request.as_ptr(), request.len()) }; assert_eq!(result.status_code, STATUS_INVALID_REQUEST); let json = unsafe { result_json(result) }; assert!(json.contains("InvalidRequest")); diff --git a/docs/portable-core-ffi.md b/docs/portable-core-ffi.md index e085e49..2016c44 100644 --- a/docs/portable-core-ffi.md +++ b/docs/portable-core-ffi.md @@ -1,16 +1,16 @@ # Portable core FFI -`psign-portable-ffi` exposes the reusable portable Authenticode core as a native shared library for managed callers such as a PowerShell 7.4 / .NET 8 binary module. +`psign-portable-ffi` exposes the reusable portable Authenticode core as a native shared library for managed callers such as a PowerShell 7.4 / .NET 8 binary module. The packaged shared library name is `psign-core`: `psign-core.dll` on Windows, `libpsign-core.so` on Linux, and `libpsign-core.dylib` on macOS. Cargo builds the internal target as `psign_core` because Rust library target names cannot contain hyphens; module packaging stages it under the hyphenated `psign-core` file names. The ABI is intentionally small and JSON-based: -- `psign_portable_version()` -- `psign_portable_get_signature(request_json_ptr, request_json_len)` -- `psign_portable_sign(request_json_ptr, request_json_len)` -- `psign_portable_free(buffer)` +- `psign_core_version()` +- `psign_core_get_signature(request_json_ptr, request_json_len)` +- `psign_core_sign(request_json_ptr, request_json_len)` +- `psign_core_free(buffer)` -All request and response JSON uses UTF-8. Callers pass borrowed input buffers; Rust returns an owned `PsignFfiBuffer { ptr, len, cap }`. Managed callers must copy the bytes immediately and call `psign_portable_free` exactly once with the returned buffer. +All request and response JSON uses UTF-8. Callers pass borrowed input buffers; Rust returns an owned `PsignFfiBuffer { ptr, len, cap }`. Managed callers must copy the bytes immediately and call `psign_core_free` exactly once with the returned buffer. The first schema version supports portable digest/signature inspection and local RSA signing for PE, CAB, MSI, ZIP Authenticode, MSIX/AppX packages, and PowerShell script inputs. `Set-PortableSignature` accepts certificate/key paths, exportable `X509Certificate2` values, exportable PFX files, chain certificates, RFC3161 timestamp settings, and portable cert-store material resolved by managed callers. `Set-PortableSignature` and `Get-PortableSignature` also accept PowerShell module directories and expand them to signable `.ps1`, `.psm1`, and `.psd1` files. -`psign_portable_get_signature` also accepts explicit trust material in the JSON request: trusted certificate paths, DER-encoded trusted certificates, anchor directory, AuthRoot CAB, `as_of`, timestamp-time policy booleans, online AIA/OCSP toggles, and revocation mode. The portable core never falls back to OS trust; when trust is requested, the response includes `trust_status` in addition to the digest/signature `status`. +`psign_core_get_signature` also accepts explicit trust material in the JSON request: trusted certificate paths, DER-encoded trusted certificates, anchor directory, AuthRoot CAB, `as_of`, timestamp-time policy booleans, online AIA/OCSP toggles, and revocation mode. The portable core never falls back to OS trust; when trust is requested, the response includes `trust_status` in addition to the digest/signature `status`. diff --git a/docs/portable-powershell-module.md b/docs/portable-powershell-module.md index 1f22f39..5120351 100644 --- a/docs/portable-powershell-module.md +++ b/docs/portable-powershell-module.md @@ -1,6 +1,6 @@ # Portable PowerShell module -`Devolutions.Psign` is a PowerShell 7.4 / .NET 8 binary module that exposes portable Authenticode cmdlets over the Rust `psign_portable` shared library. The module does not call `WinVerifyTrust`, `CryptUIWizDigitalSign`, `SignerSignEx`, or registered Windows SIP DLLs. +`Devolutions.Psign` is a PowerShell 7.4 / .NET 8 binary module that exposes portable Authenticode cmdlets over the Rust `psign-core` shared library. The module does not call `WinVerifyTrust`, `CryptUIWizDigitalSign`, `SignerSignEx`, or registered Windows SIP DLLs. ## Cmdlets @@ -70,4 +70,4 @@ pwsh -File .\PowerShell\tests\Invoke-PortableSignatureTests.ps1 -Configuration R pwsh -File .\PowerShell\package.ps1 -Configuration Release ``` -The build stages the native library under the module RID layout, for example `runtimes\win-x64\native\psign_portable.dll`, so .NET can load it via the module resolver. The package script validates the module manifest, publishes to a temporary local repository, saves the generated module package, imports it, and confirms both cmdlets are exported. +The build stages the native library under the module RID layout, for example `runtimes\win-x64\native\psign-core.dll`, so .NET can load it via the module resolver. The package script validates the module manifest, publishes to a temporary local repository, saves the generated module package, imports it, and confirms both cmdlets are exported. diff --git a/dotnet/Devolutions.Psign.PowerShell/Native/PsignNative.cs b/dotnet/Devolutions.Psign.PowerShell/Native/PsignNative.cs index 71bb368..a93aaf1 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Native/PsignNative.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Native/PsignNative.cs @@ -7,7 +7,7 @@ namespace Devolutions.Psign.PowerShell.Native; internal static unsafe class PsignNative { - private const string LibraryName = "psign_portable"; + private const string LibraryName = "psign-core"; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -17,12 +17,12 @@ internal static unsafe class PsignNative internal static PortableSignature GetSignature(PortableGetSignatureRequest request) { - return Invoke(request, psign_portable_get_signature); + return Invoke(request, psign_core_get_signature); } internal static PortableSignResponse Sign(PortableSignRequest request) { - return Invoke(request, psign_portable_sign); + return Invoke(request, psign_core_sign); } private delegate PsignFfiResult NativeCall(IntPtr requestJsonPtr, UIntPtr requestJsonLen); @@ -43,7 +43,7 @@ private static TResponse Invoke(TRequest request, NativeCal } finally { - psign_portable_free(result.Json); + psign_core_free(result.Json); } if (result.StatusCode != 0) @@ -70,13 +70,13 @@ private static byte[] CopyResponse(PsignFfiBuffer buffer) } [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] - private static extern PsignFfiResult psign_portable_get_signature(IntPtr requestJsonPtr, UIntPtr requestJsonLen); + private static extern PsignFfiResult psign_core_get_signature(IntPtr requestJsonPtr, UIntPtr requestJsonLen); [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] - private static extern PsignFfiResult psign_portable_sign(IntPtr requestJsonPtr, UIntPtr requestJsonLen); + private static extern PsignFfiResult psign_core_sign(IntPtr requestJsonPtr, UIntPtr requestJsonLen); [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] - private static extern void psign_portable_free(PsignFfiBuffer buffer); + private static extern void psign_core_free(PsignFfiBuffer buffer); } [StructLayout(LayoutKind.Sequential)] diff --git a/dotnet/Devolutions.Psign.PowerShell/Native/PsignNativeResolver.cs b/dotnet/Devolutions.Psign.PowerShell/Native/PsignNativeResolver.cs index f845141..6190a10 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Native/PsignNativeResolver.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Native/PsignNativeResolver.cs @@ -6,7 +6,7 @@ namespace Devolutions.Psign.PowerShell.Native; internal static class PsignNativeResolver { - private const string LibraryName = "psign_portable"; + private const string LibraryName = "psign-core"; [ModuleInitializer] internal static void Register() @@ -29,7 +29,7 @@ private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSe if (!File.Exists(nativePath)) { throw new DllNotFoundException( - $"Could not find psign portable native library for RID '{rid}'. Expected '{nativePath}'."); + $"Could not find psign core native library for RID '{rid}'. Expected '{nativePath}'."); } return NativeLibrary.Load(nativePath, assembly, searchPath); @@ -55,15 +55,15 @@ private static string GetNativeFileName() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - return "psign_portable.dll"; + return "psign-core.dll"; } if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { - return "libpsign_portable.dylib"; + return "libpsign-core.dylib"; } - return "libpsign_portable.so"; + return "libpsign-core.so"; } private static string GetCurrentRid() From ca13eb6e6f512e1e933599a5f19382c584896539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 22 May 2026 10:17:01 -0400 Subject: [PATCH 10/10] Package PowerShell module from prebuilt psign-core Build psign-core shared libraries alongside psign-tool in the release matrix, sign Windows DLLs through the existing release signing flow, and package the PowerShell module from downloaded native artifacts. Add PSGallery publishing based on the Pinget release workflow using PSGALLERY_NUGET_API_KEY. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 254 ++++++++++++++++++++++++++++- PowerShell/build.ps1 | 18 +- PowerShell/import-native.ps1 | 57 +++++++ PowerShell/package.ps1 | 31 +++- docs/portable-powershell-module.md | 8 + 5 files changed, 360 insertions(+), 8 deletions(-) create mode 100644 PowerShell/import-native.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 98a4e1f..fe9eed3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,11 @@ on: required: false default: false type: boolean + publish_psgallery: + description: Publish Devolutions.Psign to PowerShell Gallery + required: false + default: false + type: boolean github-env: description: GitHub environment selection required: false @@ -44,6 +49,7 @@ jobs: build_ref: ${{ steps.info.outputs.build_ref }} publish_env: ${{ steps.info.outputs.publish_env }} publish_nuget: ${{ steps.info.outputs.publish_nuget }} + publish_psgallery: ${{ steps.info.outputs.publish_psgallery }} dry_run: ${{ steps.info.outputs.dry_run }} steps: - name: Checkout @@ -93,6 +99,7 @@ jobs: $releaseTag = "v$releaseVersion" $isMasterBranch = $sourceBranch -eq 'master' $publishNuget = Parse-BoolOrDefault '${{ github.event.inputs.publish_nuget }}' $false + $publishPsgallery = Parse-BoolOrDefault '${{ github.event.inputs.publish_psgallery }}' $false $dryRun = Parse-BoolOrDefault '${{ github.event.inputs.dry_run_nuget }}' $true $requestedEnvironment = '${{ inputs['github-env'] }}'.Trim().ToLowerInvariant() if ([string]::IsNullOrWhiteSpace($requestedEnvironment)) { @@ -114,7 +121,7 @@ jobs: } } - if ($requestedEnvironment -eq 'test' -and ($isMasterBranch -or ((-not $dryRun) -and $publishNuget))) { + if ($requestedEnvironment -eq 'test' -and ($isMasterBranch -or ((-not $dryRun) -and ($publishNuget -or $publishPsgallery)))) { $publishEnv = 'publish-prod' Write-Host '::notice::github-env=test was promoted to publish-prod' } @@ -123,7 +130,7 @@ jobs: $dryRun = $true } - if (-not $publishNuget) { + if ((-not $publishNuget) -and (-not $publishPsgallery)) { $dryRun = $true } @@ -131,6 +138,7 @@ jobs: "build_ref=$buildRef" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "publish_env=$publishEnv" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "publish_nuget=$($publishNuget.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + "publish_psgallery=$($publishPsgallery.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "dry_run=$($dryRun.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append Write-Host "::notice::Release tag: $releaseTag" @@ -139,6 +147,7 @@ jobs: Write-Host "::notice::GitHub environment mode: $requestedEnvironment" Write-Host "::notice::Publish environment: $publishEnv" Write-Host "::notice::Publish NuGet: $($publishNuget.ToString().ToLowerInvariant())" + Write-Host "::notice::Publish PowerShell Gallery: $($publishPsgallery.ToString().ToLowerInvariant())" Write-Host "::notice::NuGet dry-run: $($dryRun.ToString().ToLowerInvariant())" build: @@ -150,36 +159,42 @@ jobs: matrix: include: - name: linux-x64 + rid: linux-x64 os: ubuntu-latest target: x86_64-unknown-linux-gnu archive: psign-tool-linux-x64.zip binary: psign-tool - name: linux-arm64 + rid: linux-arm64 os: ubuntu-latest target: aarch64-unknown-linux-gnu archive: psign-tool-linux-arm64.zip binary: psign-tool - name: windows-x64 + rid: win-x64 os: windows-2022 target: x86_64-pc-windows-msvc archive: psign-tool-windows-x64.zip binary: psign-tool.exe - name: windows-arm64 + rid: win-arm64 os: windows-2022 target: aarch64-pc-windows-msvc archive: psign-tool-windows-arm64.zip binary: psign-tool.exe - name: macos-x64 + rid: osx-x64 os: macos-14 target: x86_64-apple-darwin archive: psign-tool-macos-x64.zip binary: psign-tool - name: macos-arm64 + rid: osx-arm64 os: macos-14 target: aarch64-apple-darwin archive: psign-tool-macos-arm64.zip @@ -232,6 +247,45 @@ jobs: cargo @cargoArgs + - name: Build psign-core shared library + shell: pwsh + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + run: | + $target = '${{ matrix.target }}' + cargo build --locked --release -p psign-portable-ffi --target $target + + - name: Stage psign-core native library + shell: pwsh + env: + RID: ${{ matrix.rid }} + TARGET: ${{ matrix.target }} + run: | + $sourceName = if ($env:TARGET -like '*windows-msvc') { + 'psign_core.dll' + } elseif ($env:TARGET -like '*apple-darwin') { + 'libpsign_core.dylib' + } else { + 'libpsign_core.so' + } + + $destName = if ($env:TARGET -like '*windows-msvc') { + 'psign-core.dll' + } elseif ($env:TARGET -like '*apple-darwin') { + 'libpsign-core.dylib' + } else { + 'libpsign-core.so' + } + + $sourcePath = Join-Path (Join-Path (Join-Path 'target' $env:TARGET) 'release') $sourceName + if (-not (Test-Path -LiteralPath $sourcePath)) { + throw "psign-core native library not found: $sourcePath" + } + + $nativeOut = Join-Path (Join-Path 'psign-core' $env:RID) 'native' + New-Item -ItemType Directory -Force -Path $nativeOut | Out-Null + Copy-Item -Force -LiteralPath $sourcePath -Destination (Join-Path $nativeOut $destName) + - name: Verify Windows version info if: contains(matrix.target, 'windows-msvc') shell: pwsh @@ -296,6 +350,22 @@ jobs: path: target/${{ matrix.target }}/release/${{ matrix.binary }} if-no-files-found: error + - name: Upload psign-core native library + if: ${{ !contains(matrix.target, 'windows-msvc') }} + uses: actions/upload-artifact@v7 + with: + name: psign-core-${{ matrix.rid }} + path: psign-core/${{ matrix.rid }}/native/* + if-no-files-found: error + + - name: Upload unsigned Windows psign-core native library + if: ${{ contains(matrix.target, 'windows-msvc') }} + uses: actions/upload-artifact@v7 + with: + name: unsigned-psign-core-${{ matrix.rid }} + path: psign-core/${{ matrix.rid }}/native/* + if-no-files-found: error + sign_windows: name: Sign and package Windows artifacts runs-on: ubuntu-latest @@ -317,6 +387,18 @@ jobs: name: psign-tool-windows-arm64.zip-unsigned-bin path: work/win-arm64 + - name: Download unsigned Windows x64 psign-core + uses: actions/download-artifact@v8 + with: + name: unsigned-psign-core-win-x64 + path: work/psign-core-win-x64 + + - name: Download unsigned Windows arm64 psign-core + uses: actions/download-artifact@v8 + with: + name: unsigned-psign-core-win-arm64 + path: work/psign-core-win-arm64 + - name: Download Linux x64 psign-tool uses: actions/download-artifact@v8 with: @@ -435,6 +517,38 @@ jobs: throw "psign-tool signing failed for win-arm64 with exit code $LASTEXITCODE" } + psign-tool --mode portable --verbose sign ` + --azure-key-vault-tenant-id "$env:AZURE_TENANT_ID" ` + --azure-key-vault-url "$env:CODE_SIGNING_KEYVAULT_URL" ` + --azure-key-vault-client-id "$env:CODE_SIGNING_CLIENT_ID" ` + --azure-key-vault-client-secret "$env:CODE_SIGNING_CLIENT_SECRET" ` + --azure-key-vault-certificate "$env:CODE_SIGNING_CERTIFICATE_NAME" ` + --timestamp-url "$env:CODE_SIGNING_TIMESTAMP_SERVER" ` + --timestamp-digest sha256 ` + --digest sha256 ` + --exit-codes azure ` + "work/psign-core-win-x64/psign-core.dll" + + if ($LASTEXITCODE -ne 0) { + throw "psign-core signing failed for win-x64 with exit code $LASTEXITCODE" + } + + psign-tool --mode portable --verbose sign ` + --azure-key-vault-tenant-id "$env:AZURE_TENANT_ID" ` + --azure-key-vault-url "$env:CODE_SIGNING_KEYVAULT_URL" ` + --azure-key-vault-client-id "$env:CODE_SIGNING_CLIENT_ID" ` + --azure-key-vault-client-secret "$env:CODE_SIGNING_CLIENT_SECRET" ` + --azure-key-vault-certificate "$env:CODE_SIGNING_CERTIFICATE_NAME" ` + --timestamp-url "$env:CODE_SIGNING_TIMESTAMP_SERVER" ` + --timestamp-digest sha256 ` + --digest sha256 ` + --exit-codes azure ` + "work/psign-core-win-arm64/psign-core.dll" + + if ($LASTEXITCODE -ne 0) { + throw "psign-core signing failed for win-arm64 with exit code $LASTEXITCODE" + } + - name: Package Windows artifacts shell: pwsh run: | @@ -463,6 +577,20 @@ jobs: path: psign-tool-windows-arm64.zip if-no-files-found: error + - name: Upload artifact psign-core-win-x64 + uses: actions/upload-artifact@v7 + with: + name: psign-core-win-x64 + path: work/psign-core-win-x64/psign-core.dll + if-no-files-found: error + + - name: Upload artifact psign-core-win-arm64 + uses: actions/upload-artifact@v7 + with: + name: psign-core-win-arm64 + path: work/psign-core-win-arm64/psign-core.dll + if-no-files-found: error + pack_dotnet_tool: name: Pack dotnet tool from prebuilt binaries runs-on: ubuntu-latest @@ -525,6 +653,65 @@ jobs: path: dist/nuget/Devolutions.Psign.Tool*.nupkg if-no-files-found: error + pack_powershell_module: + name: Pack PowerShell module from prebuilt psign-core libraries + runs-on: ubuntu-latest + needs: + - preflight + - sign_windows + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.build_ref }} + + - name: Download psign-core native artifacts + uses: actions/download-artifact@v8 + with: + pattern: psign-core-* + path: dist/native + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 8.0.x + + - name: Package PowerShell module + shell: pwsh + env: + RELEASE_TAG: ${{ needs.preflight.outputs.release_tag }} + run: | + $tag = $env:RELEASE_TAG + if (-not $tag.StartsWith('v')) { + throw "Release tag must start with 'v'. Actual: $tag" + } + + $version = $tag.Substring(1) + New-Item -ItemType Directory -Force -Path dist | Out-Null + $archivePath = Join-Path 'dist' "Devolutions.Psign.$version.zip" + + ./PowerShell/package.ps1 ` + -Configuration Release ` + -NativeArtifactsRoot 'dist/native' ` + -SkipNativeBuild ` + -OutputDirectory 'dist/powershell' ` + -ModuleArchivePath $archivePath + + - name: Upload PowerShell module package artifact + uses: actions/upload-artifact@v7 + with: + name: devolutions-psign-powershell-nupkg + path: dist/powershell/Devolutions.Psign*.nupkg + if-no-files-found: error + + - name: Upload PowerShell module artifact + uses: actions/upload-artifact@v7 + with: + name: powershell-module + path: dist/Devolutions.Psign.*.zip + if-no-files-found: error + publish: name: Publish GitHub release runs-on: ubuntu-latest @@ -532,6 +719,7 @@ jobs: - preflight - sign_windows - pack_dotnet_tool + - pack_powershell_module steps: - name: Download platform zip artifacts @@ -546,6 +734,18 @@ jobs: name: devolutions-psign-tool-nupkg path: dist + - name: Download PowerShell module NuGet artifact + uses: actions/download-artifact@v8 + with: + name: devolutions-psign-powershell-nupkg + path: dist + + - name: Download PowerShell module archive artifact + uses: actions/download-artifact@v8 + with: + name: powershell-module + path: dist + - name: Generate checksums shell: pwsh working-directory: dist @@ -553,7 +753,9 @@ jobs: $releaseAssets = Get-ChildItem -Recurse -File | Where-Object { $_.Name -like 'psign-tool-*.zip' -or - $_.Name -like 'Devolutions.Psign.Tool*.nupkg' + $_.Name -like 'Devolutions.Psign.Tool*.nupkg' -or + $_.Name -like 'Devolutions.Psign.*.nupkg' -or + $_.Name -like 'Devolutions.Psign.*.zip' } | Sort-Object Name @@ -583,7 +785,9 @@ jobs: $assets = Get-ChildItem -Path dist -Recurse -File | Where-Object { $_.Name -like 'psign-tool-*.zip' -or - $_.Name -like 'Devolutions.Psign.Tool*.nupkg' + $_.Name -like 'Devolutions.Psign.Tool*.nupkg' -or + $_.Name -like 'Devolutions.Psign.*.nupkg' -or + $_.Name -like 'Devolutions.Psign.*.zip' } | Sort-Object Name | ForEach-Object { $_.FullName } @@ -726,3 +930,45 @@ jobs: & dotnet @pushArgs } } + + publish_psgallery: + name: Publish PowerShell Gallery + runs-on: ubuntu-latest + needs: + - preflight + - pack_powershell_module + - publish + if: needs.preflight.outputs.dry_run != 'true' && needs.preflight.outputs.publish_psgallery == 'true' + environment: ${{ needs.preflight.outputs.publish_env }} + env: + PSGALLERY_NUGET_API_KEY: ${{ secrets.PSGALLERY_NUGET_API_KEY }} + permissions: + contents: read + + steps: + - name: Download PowerShell module artifact + uses: actions/download-artifact@v8 + with: + name: powershell-module + path: dist/powershell-module + + - name: Publish Devolutions.Psign to PowerShell Gallery + shell: pwsh + run: | + if ([string]::IsNullOrWhiteSpace($env:PSGALLERY_NUGET_API_KEY)) { + throw 'PSGALLERY_NUGET_API_KEY is not configured.' + } + + $archive = Get-ChildItem -Path dist/powershell-module -Filter 'Devolutions.Psign.*.zip' | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + if ($null -eq $archive) { + throw 'PowerShell module archive was not downloaded.' + } + + $expandedRoot = Join-Path 'dist' 'powershell-gallery' + Expand-Archive -Path $archive.FullName -DestinationPath $expandedRoot -Force + $modulePath = (Resolve-Path (Join-Path $expandedRoot 'Devolutions.Psign')).Path + + Test-ModuleManifest -Path (Join-Path $modulePath 'Devolutions.Psign.psd1') | Out-Null + Publish-Module -Path $modulePath -NuGetApiKey $env:PSGALLERY_NUGET_API_KEY -Verbose diff --git a/PowerShell/build.ps1 b/PowerShell/build.ps1 index d2ade84..f87bfb3 100644 --- a/PowerShell/build.ps1 +++ b/PowerShell/build.ps1 @@ -1,6 +1,10 @@ param( [ValidateSet('Debug', 'Release')] - [string] $Configuration = 'Release' + [string] $Configuration = 'Release', + + [string] $NativeArtifactsRoot, + + [switch] $SkipNativeBuild ) $ErrorActionPreference = 'Stop' @@ -13,9 +17,19 @@ $projectPath = Join-Path $projectPath 'Devolutions.Psign.PowerShell.csproj' Push-Location $repo try { - cargo build -p psign-portable-ffi --profile ($Configuration -eq 'Release' ? 'release' : 'dev') dotnet publish $projectPath -c $Configuration -o $libOut + if ($NativeArtifactsRoot) { + & (Join-Path $PSScriptRoot 'import-native.ps1') -ArtifactsRoot $NativeArtifactsRoot -ModuleRoot $moduleRoot + return + } + + if ($SkipNativeBuild) { + return + } + + cargo build -p psign-portable-ffi --profile ($Configuration -eq 'Release' ? 'release' : 'dev') + $rid = if ($IsWindows) { 'win' } elseif ($IsMacOS) { diff --git a/PowerShell/import-native.ps1 b/PowerShell/import-native.ps1 new file mode 100644 index 0000000..699af30 --- /dev/null +++ b/PowerShell/import-native.ps1 @@ -0,0 +1,57 @@ +param( + [Parameter(Mandatory)] + [string] $ArtifactsRoot, + + [string] $ModuleRoot = (Join-Path $PSScriptRoot 'Devolutions.Psign') +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $ArtifactsRoot -PathType Container)) { + throw "Native artifacts root does not exist: $ArtifactsRoot" +} + +$staleNames = @('psign_portable.dll', 'libpsign_portable.dylib', 'libpsign_portable.so') +$artifactDirectories = Get-ChildItem -LiteralPath $ArtifactsRoot -Directory -Recurse | + Where-Object { $_.Name -match '^psign-core-(?.+)$' } | + Sort-Object FullName + +if (-not $artifactDirectories) { + throw "No psign-core native artifacts were found under $ArtifactsRoot" +} + +$imported = 0 +foreach ($artifactDirectory in $artifactDirectories) { + if ($artifactDirectory.Name -notmatch '^psign-core-(?.+)$') { + continue + } + $rid = $Matches['rid'] + $nativeName = if ($rid.StartsWith('win-', [System.StringComparison]::OrdinalIgnoreCase)) { + 'psign-core.dll' + } elseif ($rid.StartsWith('osx-', [System.StringComparison]::OrdinalIgnoreCase)) { + 'libpsign-core.dylib' + } elseif ($rid.StartsWith('linux-', [System.StringComparison]::OrdinalIgnoreCase)) { + 'libpsign-core.so' + } else { + throw "Unsupported psign-core artifact RID '$rid' from $($artifactDirectory.FullName)" + } + + $nativeFiles = @(Get-ChildItem -LiteralPath $artifactDirectory.FullName -Recurse -File -Filter $nativeName) + if ($nativeFiles.Count -ne 1) { + throw "Expected exactly one $nativeName in $($artifactDirectory.FullName), found $($nativeFiles.Count)." + } + + $nativeOut = Join-Path (Join-Path (Join-Path $ModuleRoot 'runtimes') $rid) 'native' + New-Item -ItemType Directory -Force -Path $nativeOut | Out-Null + foreach ($staleName in $staleNames) { + Remove-Item -LiteralPath (Join-Path $nativeOut $staleName) -Force -ErrorAction SilentlyContinue + } + Copy-Item -Force -LiteralPath $nativeFiles[0].FullName -Destination (Join-Path $nativeOut $nativeName) + $imported++ +} + +if ($imported -eq 0) { + throw "No psign-core native libraries were imported from $ArtifactsRoot" +} + +Write-Host "Imported $imported psign-core native librar$(if ($imported -eq 1) { 'y' } else { 'ies' })." diff --git a/PowerShell/package.ps1 b/PowerShell/package.ps1 index 8d5e06a..9e43f77 100644 --- a/PowerShell/package.ps1 +++ b/PowerShell/package.ps1 @@ -2,7 +2,13 @@ param( [ValidateSet('Debug', 'Release')] [string] $Configuration = 'Release', - [string] $OutputDirectory = (Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts') 'powershell') + [string] $OutputDirectory = (Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts') 'powershell'), + + [string] $NativeArtifactsRoot, + + [switch] $SkipNativeBuild, + + [string] $ModuleArchivePath ) $ErrorActionPreference = 'Stop' @@ -13,9 +19,24 @@ $localRepo = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid $installRoot = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N')) $repoName = "DevolutionsPsignLocal$([System.Guid]::NewGuid().ToString('N'))" -& (Join-Path $PSScriptRoot 'build.ps1') -Configuration $Configuration +$buildArgs = @{ + Configuration = $Configuration +} +if ($NativeArtifactsRoot) { + $buildArgs.NativeArtifactsRoot = $NativeArtifactsRoot +} +if ($SkipNativeBuild) { + $buildArgs.SkipNativeBuild = $true +} +& (Join-Path $PSScriptRoot 'build.ps1') @buildArgs New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null +if ($ModuleArchivePath) { + $moduleArchiveParent = Split-Path -Parent $ModuleArchivePath + if (-not [string]::IsNullOrWhiteSpace($moduleArchiveParent)) { + New-Item -ItemType Directory -Force -Path $moduleArchiveParent | Out-Null + } +} New-Item -ItemType Directory -Force -Path $localRepo | Out-Null New-Item -ItemType Directory -Force -Path $installRoot | Out-Null @@ -55,6 +76,12 @@ try { finally { Remove-Item -LiteralPath $nativeProbe.FullName -Force -ErrorAction SilentlyContinue } + if ($ModuleArchivePath) { + if (Test-Path -LiteralPath $ModuleArchivePath) { + Remove-Item -LiteralPath $ModuleArchivePath -Force + } + Compress-Archive -Path $moduleRoot -DestinationPath $ModuleArchivePath -Force + } Get-Item -LiteralPath (Join-Path $OutputDirectory $package.Name) } finally { diff --git a/docs/portable-powershell-module.md b/docs/portable-powershell-module.md index 5120351..2355b8b 100644 --- a/docs/portable-powershell-module.md +++ b/docs/portable-powershell-module.md @@ -71,3 +71,11 @@ pwsh -File .\PowerShell\package.ps1 -Configuration Release ``` The build stages the native library under the module RID layout, for example `runtimes\win-x64\native\psign-core.dll`, so .NET can load it via the module resolver. The package script validates the module manifest, publishes to a temporary local repository, saves the generated module package, imports it, and confirms both cmdlets are exported. + +Release packaging can import prebuilt `psign-core-` native artifacts instead of rebuilding the current RID locally: + +```powershell +pwsh -File .\PowerShell\package.ps1 -Configuration Release -NativeArtifactsRoot .\dist\native -SkipNativeBuild +``` + +The native artifact root should contain directories such as `psign-core-win-x64`, `psign-core-linux-x64`, and `psign-core-osx-arm64`, each containing the packaged native library name for that RID.