diff --git a/.github/workflows/ci-unix.yml b/.github/workflows/ci-unix.yml index d751431..2875361 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,8 +22,22 @@ 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 + portable-clippy: + 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 - name: clippy (digest-cli with artifact-signing-rest) run: cargo clippy -p psign-digest-cli --all-targets --features artifact-signing-rest --locked -- -D warnings @@ -37,29 +51,83 @@ 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 - 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 - 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" diff --git a/.github/workflows/powershell-module.yml b/.github/workflows/powershell-module.yml new file mode 100644 index 0000000..aab6104 --- /dev/null +++ b/.github/workflows/powershell-module.yml @@ -0,0 +1,67 @@ +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: 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/.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/.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..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", @@ -2119,6 +2120,35 @@ dependencies = [ "zip", ] +[[package]] +name = "psign-portable-core" +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", +] + +[[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..f87bfb3 --- /dev/null +++ b/PowerShell/build.ps1 @@ -0,0 +1,71 @@ +param( + [ValidateSet('Debug', 'Release')] + [string] $Configuration = 'Release', + + [string] $NativeArtifactsRoot, + + [switch] $SkipNativeBuild +) + +$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 { + 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) { + 'osx' + } else { + 'linux' + } + $arch = switch ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture) { + 'X64' { 'x64' } + 'Arm64' { 'arm64' } + 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-core.dll' + } elseif ($IsMacOS) { + 'libpsign-core.dylib' + } else { + 'libpsign-core.so' + } + $profileDir = if ($Configuration -eq 'Release') { 'release' } else { 'debug' } + $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 { + Pop-Location +} 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 new file mode 100644 index 0000000..9e43f77 --- /dev/null +++ b/PowerShell/package.ps1 @@ -0,0 +1,92 @@ +param( + [ValidateSet('Debug', 'Release')] + [string] $Configuration = 'Release', + + [string] $OutputDirectory = (Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts') 'powershell'), + + [string] $NativeArtifactsRoot, + + [switch] $SkipNativeBuild, + + [string] $ModuleArchivePath +) + +$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'))" + +$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 + +$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 + 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 + 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 + } + 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 { + 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 new file mode 100644 index 0000000..01816ec --- /dev/null +++ b/PowerShell/tests/Invoke-PortableSignatureTests.ps1 @@ -0,0 +1,411 @@ +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 + +function Assert-SignerCertificate { + param( + [Parameter(Mandatory)] + $Signature, + [Parameter(Mandatory)] + [System.Security.Cryptography.X509Certificates.X509Certificate2] $ExpectedCertificate, + [Parameter(Mandatory)] + [string] $Label + ) + + if ($null -eq $Signature.SignerCertificate) { + throw "Expected SignerCertificate for $Label." + } + if ($Signature.SignerCertificate.Thumbprint -ne $ExpectedCertificate.Thumbprint) { + throw "Unexpected SignerCertificate thumbprint for $Label." + } + if ($Signature.EmbeddedCertificateCount -lt 1) { + throw "Expected EmbeddedCertificateCount for $Label." + } +} + +function Start-PsignTimestampServer { + $psi = [System.Diagnostics.ProcessStartInfo]::new() + $psi.FileName = 'cargo' + foreach ($argument in @('run', '--quiet', '--bin', 'psign-server', '--', 'timestamp-server', '--max-requests', '1')) { + $psi.ArgumentList.Add($argument) + } + $psi.WorkingDirectory = $repo + $psi.RedirectStandardOutput = $true + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + $process = [System.Diagnostics.Process]::Start($psi) + $line = $process.StandardOutput.ReadLine() + if ($line -notlike 'psign-server timestamp-server listening on *') { + try { + if (-not $process.HasExited) { + $process.Kill($true) + } + } + catch { + } + throw "Failed to start psign timestamp server. First output: $line" + } + [pscustomobject]@{ + Process = $process + Url = $line.Substring('psign-server timestamp-server listening on '.Length) + } +} + +$temp = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Force -Path $temp | Out-Null +try { + $rsa = [System.Security.Cryptography.RSA]::Create(2048) + $rootRsa = [System.Security.Cryptography.RSA]::Create(2048) + $rootRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=psign portable root', + $rootRsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) + $rootRequest.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($true, $false, 0, $true)) + $rootRequest.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyCertSign -bor + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::CrlSign, + $true)) + $rootCert = $rootRequest.CreateSelfSigned( + [System.DateTimeOffset]::UtcNow.AddDays(-1), + [System.DateTimeOffset]::UtcNow.AddDays(31)) + + $request = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=psign portable test', + $rsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) + $request.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($false, $false, 0, $true)) + $request.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature, + $true)) + $ekuOids = [System.Security.Cryptography.OidCollection]::new() + $null = $ekuOids.Add([System.Security.Cryptography.Oid]::new('1.3.6.1.5.5.7.3.3')) + $request.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($ekuOids, $false)) + $issuedCert = $request.Create( + $rootCert, + [System.DateTimeOffset]::UtcNow.AddDays(-1), + [System.DateTimeOffset]::UtcNow.AddDays(30), + [byte[]](1, 2, 3, 4, 5, 6, 7, 8)) + $cert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::CopyWithPrivateKey($issuedCert, $rsa) + $certPath = Join-Path $temp 'signer.cer' + $keyPath = Join-Path $temp 'signer.key' + $pfxPath = Join-Path $temp 'signer.pfx' + $pfxPassword = ConvertTo-SecureString -String 'portable-test' -AsPlainText -Force + [System.IO.File]::WriteAllBytes($certPath, $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)) + [System.IO.File]::WriteAllText( + $keyPath, + [System.Security.Cryptography.PemEncoding]::WriteString('PRIVATE KEY', $rsa.ExportPkcs8PrivateKey())) + [System.IO.File]::WriteAllBytes($pfxPath, $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pkcs12, 'portable-test')) + $storeDir = Join-Path $temp 'cert-store' + $storeMyDir = Join-Path (Join-Path $storeDir 'CurrentUser') 'MY' + New-Item -ItemType Directory -Force -Path $storeMyDir | Out-Null + $storeCertPath = Join-Path $storeMyDir "$($cert.Thumbprint.ToUpperInvariant()).der" + $storeKeyPath = Join-Path $storeMyDir "$($cert.Thumbprint.ToUpperInvariant()).key" + Copy-Item -LiteralPath $certPath -Destination $storeCertPath + Copy-Item -LiteralPath $keyPath -Destination $storeKeyPath + $chainRsa = [System.Security.Cryptography.RSA]::Create(2048) + $chainRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=psign portable chain test', + $chainRsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) + $chainCert = $chainRequest.CreateSelfSigned( + [System.DateTimeOffset]::UtcNow.AddDays(-1), + [System.DateTimeOffset]::UtcNow.AddDays(30)) + $chainCertPath = Join-Path $temp 'chain.cer' + [System.IO.File]::WriteAllBytes($chainCertPath, $chainCert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)) + + $unsigned = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $repo 'tests') 'fixtures') 'generated-unsigned') 'pe') 'tiny32-pe-alias.exe' + $work = Join-Path $temp 'tiny.exe' + Copy-Item $unsigned $work + + if (-not (Get-Command Get-PortableSignature -ErrorAction SilentlyContinue)) { + throw 'Get-PortableSignature was not exported.' + } + if (-not (Get-Command Set-PortableSignature -ErrorAction SilentlyContinue)) { + throw 'Set-PortableSignature was not exported.' + } + $getParameters = (Get-Command Get-PortableSignature).Parameters + foreach ($parameterName in @('FilePath', 'LiteralPath', 'SourcePathOrExtension', 'Content', 'TrustedCertificate', 'TrustedCertificatePath', 'AnchorDirectory', 'AuthRootCab', 'AsOf', 'PreferTimestampSigningTime', 'RequireValidTimestamp', 'OnlineAia', 'OnlineOcsp', 'RevocationMode')) { + if (-not $getParameters.ContainsKey($parameterName)) { + throw "Get-PortableSignature is missing expected migration parameter '$parameterName'." + } + } + $setParameters = (Get-Command Set-PortableSignature).Parameters + foreach ($parameterName in @('FilePath', 'LiteralPath', 'SourcePathOrExtension', 'Content', 'Certificate', 'CertificatePath', 'PrivateKeyPath', 'PfxPath', 'Password', 'Thumbprint', 'CertStoreDirectory', 'StoreName', 'MachineStore', 'IncludeChain', 'ChainCertificatePath', 'TimestampServer', 'TimestampHashAlgorithm', 'HashAlgorithm', 'OutputPath', 'Force')) { + if (-not $setParameters.ContainsKey($parameterName)) { + throw "Set-PortableSignature is missing expected migration parameter '$parameterName'." + } + } + + $before = Get-PortableSignature -LiteralPath $work + if ($before.Status -ne 'NotSigned') { + throw "Expected NotSigned before signing, got $($before.Status)." + } + + $signed = Set-PortableSignature -LiteralPath $work -CertificatePath $certPath -PrivateKeyPath $keyPath + if ($signed.Status -ne 'Valid') { + throw "Expected Valid after signing, got $($signed.Status): $($signed.StatusMessage)" + } + Assert-SignerCertificate -Signature $signed -ExpectedCertificate $cert -Label 'PE signing response' + + $after = Get-PortableSignature -LiteralPath $work + if ($after.Status -ne 'Valid') { + throw "Expected Valid from Get-PortableSignature after signing, got $($after.Status)." + } + Assert-SignerCertificate -Signature $after -ExpectedCertificate $cert -Label 'PE get response' + + $trustedAfter = Get-PortableSignature -LiteralPath $work -TrustedCertificate $rootCert -AsOf ([System.DateTime]::UtcNow) -RevocationMode Off + if ($trustedAfter.Status -ne 'Valid' -or $trustedAfter.TrustStatus -ne 'Valid') { + throw "Expected explicit trust verification to succeed for signed PE, got status=$($trustedAfter.Status) trust=$($trustedAfter.TrustStatus): $($trustedAfter.StatusMessage)" + } + $untrustedAfter = Get-PortableSignature -LiteralPath $work -TrustedCertificate $chainCert + if ($untrustedAfter.Status -ne 'NotTrusted' -or $untrustedAfter.TrustStatus -ne 'NotTrusted') { + throw "Expected explicit trust verification to fail with wrong anchor, got status=$($untrustedAfter.Status) trust=$($untrustedAfter.TrustStatus): $($untrustedAfter.StatusMessage)" + } + + $length = (Get-Item -LiteralPath $work).Length + Set-PortableSignature -LiteralPath $work -CertificatePath $certPath -PrivateKeyPath $keyPath -WhatIf | Out-Null + if ((Get-Item -LiteralPath $work).Length -ne $length) { + throw 'Set-PortableSignature -WhatIf mutated the file.' + } + + $readOnlyWork = Join-Path $temp 'tiny-readonly.exe' + Copy-Item $unsigned $readOnlyWork + Set-ItemProperty -LiteralPath $readOnlyWork -Name IsReadOnly -Value $true + try { + $failedWithoutForce = $false + try { + Set-PortableSignature -LiteralPath $readOnlyWork -CertificatePath $certPath -PrivateKeyPath $keyPath -ErrorAction Stop | Out-Null + } + catch { + $failedWithoutForce = $true + } + if (-not $failedWithoutForce) { + throw 'Expected Set-PortableSignature to fail on a read-only file without -Force.' + } + $forceSigned = Set-PortableSignature -LiteralPath $readOnlyWork -CertificatePath $certPath -PrivateKeyPath $keyPath -Force + if ($forceSigned.Status -ne 'Valid') { + throw "Expected Valid after read-only file signing with -Force, got $($forceSigned.Status): $($forceSigned.StatusMessage)" + } + if (-not (Get-Item -LiteralPath $readOnlyWork).IsReadOnly) { + throw 'Expected Set-PortableSignature -Force to restore the read-only attribute.' + } + } + finally { + Set-ItemProperty -LiteralPath $readOnlyWork -Name IsReadOnly -Value $false -ErrorAction SilentlyContinue + } + + $storeWork = Join-Path $temp 'tiny-store.exe' + Copy-Item $unsigned $storeWork + $storeSigned = Set-PortableSignature -LiteralPath $storeWork -Sha1 $cert.Thumbprint -CertStoreDirectory $storeDir + if ($storeSigned.Status -ne 'Valid') { + throw "Expected Valid after portable cert-store signing, got $($storeSigned.Status): $($storeSigned.StatusMessage)" + } + Assert-SignerCertificate -Signature $storeSigned -ExpectedCertificate $cert -Label 'portable cert-store signing response' + + $chainWork = Join-Path $temp 'tiny-chain.exe' + Copy-Item $unsigned $chainWork + $defaultChainWork = Join-Path $temp 'tiny-chain-default.exe' + Copy-Item $unsigned $defaultChainWork + $defaultChainSigned = Set-PortableSignature -LiteralPath $defaultChainWork -CertificatePath $certPath -PrivateKeyPath $keyPath -ChainCertificatePath $chainCertPath + if ($defaultChainSigned.EmbeddedCertificateCount -ne 1) { + throw "Expected default IncludeChain NotRoot to exclude a self-signed root certificate, got $($defaultChainSigned.EmbeddedCertificateCount) embedded certificates." + } + $chainSigned = Set-PortableSignature -LiteralPath $chainWork -CertificatePath $certPath -PrivateKeyPath $keyPath -IncludeChain All -ChainCertificatePath $chainCertPath + if ($chainSigned.EmbeddedCertificateCount -lt 2) { + throw "Expected IncludeChain All with ChainCertificatePath to embed at least 2 certificates, got $($chainSigned.EmbeddedCertificateCount)." + } + + $unsignedCab = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $repo 'tests') 'fixtures') 'generated-unsigned') 'cab') 'sample.cab' + $cabWork = Join-Path $temp 'sample.cab' + Copy-Item $unsignedCab $cabWork + $cabSigned = Set-PortableSignature -LiteralPath $cabWork -CertificatePath $certPath -PrivateKeyPath $keyPath + if ($cabSigned.Status -ne 'Valid') { + throw "Expected Valid after CAB signing, got $($cabSigned.Status): $($cabSigned.StatusMessage)" + } + Assert-SignerCertificate -Signature $cabSigned -ExpectedCertificate $cert -Label 'CAB signing response' + $cabAfter = Get-PortableSignature -LiteralPath $cabWork + if ($cabAfter.Status -ne 'Valid') { + throw "Expected Valid from Get-PortableSignature for signed CAB, got $($cabAfter.Status): $($cabAfter.StatusMessage)" + } + + $unsignedMsi = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $repo 'tests') 'fixtures') 'generated-unsigned') 'installer') 'tiny.msi' + $msiWork = Join-Path $temp 'tiny.msi' + Copy-Item $unsignedMsi $msiWork + $msiSigned = Set-PortableSignature -LiteralPath $msiWork -CertificatePath $certPath -PrivateKeyPath $keyPath + if ($msiSigned.Status -ne 'Valid') { + throw "Expected Valid after MSI signing, got $($msiSigned.Status): $($msiSigned.StatusMessage)" + } + Assert-SignerCertificate -Signature $msiSigned -ExpectedCertificate $cert -Label 'MSI signing response' + $msiAfter = Get-PortableSignature -LiteralPath $msiWork + if ($msiAfter.Status -ne 'Valid') { + throw "Expected Valid from Get-PortableSignature for signed MSI, got $($msiAfter.Status): $($msiAfter.StatusMessage)" + } + + $zipSource = Join-Path $temp 'zip-source' + New-Item -ItemType Directory -Force -Path $zipSource | Out-Null + Set-Content -LiteralPath (Join-Path $zipSource 'payload.txt') -Value 'portable zip authenticode' -Encoding UTF8 + $zipWork = Join-Path $temp 'payload.zip' + Compress-Archive -LiteralPath (Join-Path $zipSource 'payload.txt') -DestinationPath $zipWork + $zipSigned = Set-PortableSignature -LiteralPath $zipWork -CertificatePath $certPath -PrivateKeyPath $keyPath + if ($zipSigned.Status -ne 'Valid') { + throw "Expected Valid after ZIP signing, got $($zipSigned.Status): $($zipSigned.StatusMessage)" + } + Assert-SignerCertificate -Signature $zipSigned -ExpectedCertificate $cert -Label 'ZIP signing response' + $zipAfter = Get-PortableSignature -LiteralPath $zipWork + if ($zipAfter.Status -ne 'Valid') { + throw "Expected Valid from Get-PortableSignature for signed ZIP, got $($zipAfter.Status): $($zipAfter.StatusMessage)" + } + + $scriptPath = Join-Path $temp 'Invoke-Test.ps1' + Set-Content -LiteralPath $scriptPath -Value @' +param([string] $Name = "portable") +"Hello $Name" +'@ -Encoding UTF8 + $scriptSigned = Set-PortableSignature -LiteralPath $scriptPath -Certificate $cert + if ($scriptSigned.Status -ne 'Valid') { + throw "Expected Valid for signed PowerShell script, got $($scriptSigned.Status): $($scriptSigned.StatusMessage)" + } + Assert-SignerCertificate -Signature $scriptSigned -ExpectedCertificate $cert -Label 'script signing response' + $scriptAfter = Get-PortableSignature -LiteralPath $scriptPath + if ($scriptAfter.Status -ne 'Valid') { + throw "Expected Valid from Get-PortableSignature for signed script, got $($scriptAfter.Status)." + } + $trustedScript = Get-PortableSignature -LiteralPath $scriptPath -TrustedCertificate $rootCert + if ($trustedScript.Status -ne 'Valid' -or $trustedScript.TrustStatus -ne 'Valid') { + throw "Expected explicit trust verification to succeed for signed script, got status=$($trustedScript.Status) trust=$($trustedScript.TrustStatus): $($trustedScript.StatusMessage)" + } + Add-Content -LiteralPath $scriptPath -Value '# tamper' + $scriptTampered = Get-PortableSignature -LiteralPath $scriptPath + if ($scriptTampered.Status -ne 'HashMismatch') { + throw "Expected HashMismatch for tampered signed script, got $($scriptTampered.Status): $($scriptTampered.StatusMessage)" + } + + $ps1xmlPath = Join-Path $temp 'Types.ps1xml' + Set-Content -LiteralPath $ps1xmlPath -Value @' + + + Portable.Type + + +'@ -Encoding UTF8 + $ps1xmlSigned = Set-PortableSignature -LiteralPath $ps1xmlPath -Certificate $cert + if ($ps1xmlSigned.Status -ne 'Valid') { + throw "Expected Valid for signed ps1xml, got $($ps1xmlSigned.Status): $($ps1xmlSigned.StatusMessage)" + } + Assert-SignerCertificate -Signature $ps1xmlSigned -ExpectedCertificate $cert -Label 'ps1xml signing response' + $ps1xmlText = Get-Content -LiteralPath $ps1xmlPath -Raw + if ($ps1xmlText -notmatch '') { + throw 'Expected signed ps1xml to use XML Authenticode signature markers.' + } + $ps1xmlAfter = Get-PortableSignature -LiteralPath $ps1xmlPath + if ($ps1xmlAfter.Status -ne 'Valid') { + throw "Expected Valid from Get-PortableSignature for signed ps1xml, got $($ps1xmlAfter.Status): $($ps1xmlAfter.StatusMessage)" + } + Add-Content -LiteralPath $ps1xmlPath -Value '' + $ps1xmlTampered = Get-PortableSignature -LiteralPath $ps1xmlPath + if ($ps1xmlTampered.Status -ne 'HashMismatch') { + throw "Expected HashMismatch for tampered signed ps1xml, got $($ps1xmlTampered.Status): $($ps1xmlTampered.StatusMessage)" + } + + $scriptContent = [System.Text.Encoding]::UTF8.GetBytes("'content mode'") + $contentSigned = Set-PortableSignature -SourcePathOrExtension '.ps1' -Content $scriptContent -Certificate $cert + if ($contentSigned.Status -ne 'Valid') { + throw "Expected Valid for signed PowerShell script content, got $($contentSigned.Status): $($contentSigned.StatusMessage)" + } + if ($null -eq $contentSigned.Content -or $contentSigned.Content.Length -le $scriptContent.Length) { + throw 'Expected Set-PortableSignature -Content to return signed content bytes.' + } + Assert-SignerCertificate -Signature $contentSigned -ExpectedCertificate $cert -Label 'script content signing response' + $contentAfter = Get-PortableSignature -SourcePathOrExtension '.ps1' -Content $contentSigned.Content + if ($contentAfter.Status -ne 'Valid') { + throw "Expected Valid from Get-PortableSignature -Content for signed script, got $($contentAfter.Status): $($contentAfter.StatusMessage)" + } + + $timestampServer = Start-PsignTimestampServer + try { + $timestampScript = Join-Path $temp 'Timestamped.ps1' + Set-Content -LiteralPath $timestampScript -Value '"timestamped"' -Encoding UTF8 + $timestamped = Set-PortableSignature -LiteralPath $timestampScript -Certificate $cert -TimestampServer $timestampServer.Url -TimestampHashAlgorithm Sha256 + if ($timestamped.Status -ne 'Valid') { + throw "Expected Valid for timestamped script, got $($timestamped.Status): $($timestamped.StatusMessage)" + } + if ($timestamped.TimestampKinds.Count -eq 0) { + throw 'Expected timestamped script to report a timestamp kind.' + } + if ($null -eq $timestamped.TimeStamperCertificate) { + throw 'Expected timestamped script to expose TimeStamperCertificate.' + } + if (-not $timestamped.PSObject.Properties.Match('TimestampSigningTime')) { + throw 'Expected timestamped script output to include TimestampSigningTime.' + } + } + finally { + if (-not $timestampServer.Process.HasExited) { + $timestampServer.Process.Kill($true) + } + $timestampServer.Process.Dispose() + } + + $moduleDir = Join-Path $temp 'PortableModule' + $nestedDir = Join-Path $moduleDir 'Private' + New-Item -ItemType Directory -Force -Path $nestedDir | Out-Null + Set-Content -LiteralPath (Join-Path $moduleDir 'PortableModule.psm1') -Value 'function Get-PortableGreeting { "hello" }' -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $moduleDir 'PortableModule.psd1') -Value "@{ RootModule = 'PortableModule.psm1'; ModuleVersion = '1.0.0'; GUID = '$([System.Guid]::NewGuid())' }" -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $moduleDir 'PortableModule.Types.ps1xml') -Value '' -Encoding UTF8 + Set-Content -LiteralPath (Join-Path $nestedDir 'Helper.ps1') -Value '$script:PortableHelper = $true' -Encoding UTF8 + $moduleSigned = @(Set-PortableSignature -LiteralPath $moduleDir -CertificatePath $certPath -PrivateKeyPath $keyPath) + if ($moduleSigned.Count -ne 4) { + throw "Expected 4 signed PowerShell module files, got $($moduleSigned.Count)." + } + if (@($moduleSigned | Where-Object Status -ne 'Valid').Count -ne 0) { + throw "Expected all signed module files to be Valid, got: $($moduleSigned | ConvertTo-Json -Depth 4)" + } + foreach ($moduleSignature in $moduleSigned) { + Assert-SignerCertificate -Signature $moduleSignature -ExpectedCertificate $cert -Label "module signing response $($moduleSignature.Path)" + } + $moduleValidated = @(Get-PortableSignature -LiteralPath $moduleDir) + if ($moduleValidated.Count -ne 4) { + throw "Expected 4 validated PowerShell module files, got $($moduleValidated.Count)." + } + if (@($moduleValidated | Where-Object Status -ne 'Valid').Count -ne 0) { + throw "Expected all validated module files to be Valid, got: $($moduleValidated | ConvertTo-Json -Depth 4)" + } + + $unsignedMsix = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $repo 'tests') 'fixtures') 'generated-unsigned') 'msix') 'sample.msix' + $msixWork = Join-Path $temp 'sample.msix' + Copy-Item $unsignedMsix $msixWork + $msixBefore = Get-PortableSignature -LiteralPath $msixWork + if ($msixBefore.Status -notin @('NotSigned', 'Incompatible')) { + throw "Expected unsigned MSIX preflight status before signing, got $($msixBefore.Status)." + } + $msixSigned = Set-PortableSignature -LiteralPath $msixWork -PfxPath $pfxPath -Password $pfxPassword + if ($msixSigned.Status -ne 'Valid') { + throw "Expected Valid after MSIX signing, got $($msixSigned.Status): $($msixSigned.StatusMessage)" + } + Assert-SignerCertificate -Signature $msixSigned -ExpectedCertificate $cert -Label 'MSIX signing response' + $msixAfter = Get-PortableSignature -LiteralPath $msixWork + if ($msixAfter.Status -ne 'Valid') { + throw "Expected Valid from Get-PortableSignature for signed MSIX, got $($msixAfter.Status): $($msixAfter.StatusMessage)" + } +} +finally { + Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue + Remove-Module Devolutions.Psign -Force -ErrorAction SilentlyContinue +} diff --git a/README.md b/README.md index 5b59900..d970292 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,22 @@ 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 -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, 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 `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 +165,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-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 new file mode 100644 index 0000000..f01416c --- /dev/null +++ b/crates/psign-portable-core/Cargo.toml @@ -0,0 +1,23 @@ +[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" } +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"] } +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 new file mode 100644 index 0000000..9c70b05 --- /dev/null +++ b/crates/psign-portable-core/src/lib.rs @@ -0,0 +1,1567 @@ +//! 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 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::{ + 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, timestamp, + 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, +} + +#[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 { + PortableDigestAlgorithm::Sha256 => Self::Sha256, + PortableDigestAlgorithm::Sha384 => Self::Sha384, + PortableDigestAlgorithm::Sha512 => Self::Sha512, + } + } +} + +#[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)] +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 pfx_path: Option, + #[serde(default)] + pub pfx_password: Option, + #[serde(default)] + 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)] +pub struct PortableSignatureResponse { + pub schema_version: u32, + pub path: PathBuf, + 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, + 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_only(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 mut 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.clone(), + format, + PortableSignatureStatus::NotSupportedFileFormat, + "Unsupported file format for portable Authenticode inspection.", + )), + }?; + + apply_trust_if_requested(&request, format, &data, &mut response)?; + + 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" | "psc1" | "cdxml" | "mof" => { + 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 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())) +} + +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 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())) +} + +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() + ) + })?; + 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())) +} + +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 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) + .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 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(|| { + 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" | "ps1xml" | "psc1" | "cdxml" | "mof" + ) { + bail!( + "portable script signing supports ps1, psd1, psm1, ps1xml, psc1, cdxml, and mof 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 pkcs7 = maybe_timestamp_pkcs7(request, pkcs7) + .with_context(|| format!("timestamp {}", request.path.display()))?; + let block = format_powershell_signature_block(&pkcs7, &ext); + let mut signed = script; + signed.extend_from_slice(block.as_bytes()); + 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 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")?; + 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 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() { + 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 summary = 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(), + trust_status: None, + signature_count: result.pkcs7_authenticode_entries, + 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 + )], + }) + } + 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(()) => { + 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(()) => { + 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(()) => { + 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)), + } +} + +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 summary = 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(), + 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(), + }) +} + +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 summary = 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(), + 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(), + }) + } + 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 summary = 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(), + 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(), + }) + } + Err(error) => Ok(map_digest_error(path, format, error)), + } +} + +#[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, +) -> Pkcs7Summary { + let mut summary = Pkcs7Summary::default(); + for report in reports { + collect_pkcs7_summary(&report, &mut summary); + } + summary +} + +fn collect_pkcs7_summary( + report: &psign_authenticode_trust::inspect::InspectPkcs7Report, + summary: &mut Pkcs7Summary, +) { + 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 + { + 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 !summary.timestamp_kinds.contains(&kind) { + summary.timestamp_kinds.push(kind); + } + } + } + for nested in &report.nested_signatures { + 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(), + } +} + +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(), + 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(), + } +} + +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, + 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(), + } +} + +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 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'") + { + 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], extension: &str) -> String { + let b64 = base64::engine::general_purpose::STANDARD.encode(pkcs7_der); + 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(line_prefix); + block.push_str(std::str::from_utf8(chunk).expect("base64 is ASCII")); + block.push_str(line_suffix); + } + block.push_str(end); + 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("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"); + 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_only(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..79c7071 --- /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_core" +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..85159f9 --- /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_core_version() -> PsignFfiResult { + ok_json(&version()) +} + +/// 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_core_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_core_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn psign_core_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_core_free`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn psign_core_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_core_free(result.json) }; + text + } + + #[test] + fn version_returns_json() { + let result = psign_core_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_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/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..2016c44 --- /dev/null +++ b/docs/portable-core-ffi.md @@ -0,0 +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. 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_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_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_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 new file mode 100644 index 0000000..2355b8b --- /dev/null +++ b/docs/portable-powershell-module.md @@ -0,0 +1,81 @@ +# Portable PowerShell module + +`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 + +- `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`, `.psd1`, and `.ps1xml` 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: + +```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 +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, 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` (default `NotRoot`), 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, including `.ps1xml` XML marker signatures +- PowerShell module directories +- MSIX/AppX packages + +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 + +```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-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. diff --git a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPortableSignatureCommand.cs b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPortableSignatureCommand.cs new file mode 100644 index 0000000..cd2511d --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPortableSignatureCommand.cs @@ -0,0 +1,173 @@ +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; + +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"; + private const string ContentParameterSet = "Content"; + + [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(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) + { + 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(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 new file mode 100644 index 0000000..a88e981 --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPortableSignatureCommand.cs @@ -0,0 +1,599 @@ +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"; + 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")] + public string[] FilePath { get; set; } = []; + + [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, ParameterSetName = LiteralPathParameterSet)] + [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; } + + [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] + [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; } = "NotRoot"; + + [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"; + + [Parameter] + public string? OutputPath { get; set; } + + [Parameter] + public SwitchParameter Force { get; set; } + + 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) + { + 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 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(), + PfxPath = PfxPath is null + ? null + : SessionState.Path.GetUnresolvedProviderPathFromPSPath(PfxPath), + PfxPassword = Password is null ? null : SecureStringToString(Password), + 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 + { + 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(), + PfxPath = PfxPath is null + ? null + : SessionState.Path.GetUnresolvedProviderPathFromPSPath(PfxPath), + PfxPassword = Password is null ? null : SecureStringToString(Password), + ChainCertificatePaths = GetChainCertificatePaths(), + ChainCertificatesDerBase64 = GetChainCertificatesDerBase64(), + TimestampServer = TimestampServer, + TimestampHashAlgorithm = TimestampServer is null ? null : TimestampHashAlgorithm, + }); + 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 (Thumbprint is not null) + { + materialCount++; + } + + if (materialCount != 1) + { + ThrowTerminatingError(new ErrorRecord( + new PSInvalidOperationException("Supply exactly one signing source: -Certificate, -CertificatePath/-PrivateKeyPath, -PfxPath, or -Thumbprint with a portable cert store."), + "PortableSignatureSigningMaterialRequired", + ErrorCategory.InvalidArgument, + this)); + } + } + + private X509Certificate2? LoadPfxCertificate() + { + if (pfxCertificate is not null) + { + return pfxCertificate; + } + if (PfxPath is null) + { + return null; + } + + string resolved = SessionState.Path.GetUnresolvedProviderPathFromPSPath(PfxPath); + string? password = Password is null ? null : SecureStringToString(Password); + try + { + pfxCertificate = new X509Certificate2( + resolved, + password, + X509KeyStorageFlags.Exportable); + return pfxCertificate; + } + finally + { + if (password is not null) + { + password = null; + } + } + } + + private string? GetCertificateDerBase64() + { + X509Certificate2? cert = Certificate ?? LoadStoreCertificate(); + if (cert is null) + { + return null; + } + return Convert.ToBase64String(cert.Export(X509ContentType.Cert)); + } + + private string? GetPrivateKeyDerBase64() + { + if (Thumbprint is not null) + { + _ = LoadStoreCertificate(); + return storePrivateKeyDerBase64; + } + + X509Certificate2? cert = Certificate; + 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 string[] GetChainCertificatePaths() + { + if (IncludeChain.Equals("Signer", StringComparison.OrdinalIgnoreCase)) + { + return []; + } + + 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() + { + 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); + 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..f31681e --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs @@ -0,0 +1,81 @@ +using System.Text.Json.Serialization; + +namespace Devolutions.Psign.PowerShell.Models; + +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 +{ + [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; } + + [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; } = []; + + [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/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..f05f3a5 --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Models/PortableSignature.cs @@ -0,0 +1,80 @@ +using System.Text.Json.Serialization; +using System.Security.Cryptography.X509Certificates; + +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("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; } = []; + + [JsonIgnore] + public X509Certificate2? SignerCertificate => DecodeCertificate(SignerCertificateDerBase64); + + [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)); + } +} diff --git a/dotnet/Devolutions.Psign.PowerShell/Native/PsignNative.cs b/dotnet/Devolutions.Psign.PowerShell/Native/PsignNative.cs new file mode 100644 index 0000000..a93aaf1 --- /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-core"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = null, + WriteIndented = false, + }; + + internal static PortableSignature GetSignature(PortableGetSignatureRequest request) + { + return Invoke(request, psign_core_get_signature); + } + + internal static PortableSignResponse Sign(PortableSignRequest request) + { + return Invoke(request, psign_core_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_core_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_core_get_signature(IntPtr requestJsonPtr, UIntPtr requestJsonLen); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + private static extern PsignFfiResult psign_core_sign(IntPtr requestJsonPtr, UIntPtr requestJsonLen); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + private static extern void psign_core_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..6190a10 --- /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-core"; + + [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 core 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-core.dll"; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "libpsign-core.dylib"; + } + + return "libpsign-core.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..a9bb172 --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Utilities/PortableModuleFiles.cs @@ -0,0 +1,21 @@ +namespace Devolutions.Psign.PowerShell.Utilities; + +internal static class PortableModuleFiles +{ + private static readonly HashSet SignablePowerShellExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".ps1", + ".psm1", + ".psd1", + ".ps1xml", + }; + + 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")