From a7a5bb20573c7f980acdf039973e0067f465ba76 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:05:29 +0100 Subject: [PATCH] Tauri sign fixes for security alerts (#6122) --- .github/workflows/multiOSReleases.yml | 310 ++++++++++++-------------- .github/workflows/tauri-build.yml | 288 +++++++----------------- 2 files changed, 231 insertions(+), 367 deletions(-) diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 91a57cc297..6e5810d6e2 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -21,6 +21,14 @@ on: - windows - macos - linux + sign: + description: "Code sign the binaries (requires signing secrets)" + required: false + default: "true" + type: choice + options: + - "true" + - "false" release: types: [created] @@ -144,6 +152,9 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json + - name: Install Task + uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0 + - name: Build JAR run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube env: @@ -219,88 +230,21 @@ jobs: with: gradle-version: 9.3.1 - - name: Build Java backend with JLink - working-directory: ./ - shell: bash - run: | - chmod +x ./gradlew - echo "🔧 Building Stirling-PDF JAR..." - ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube + - name: Install Task + uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0 - # Find the built JAR - STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1) - echo "✅ Built JAR: $STIRLING_JAR" - - # Create Tauri directories - mkdir -p ./frontend/src-tauri/libs - mkdir -p ./frontend/src-tauri/runtime - - # Copy JAR to Tauri libs - cp "$STIRLING_JAR" ./frontend/src-tauri/libs/ - echo "✅ JAR copied to Tauri libs" - - # Analyze JAR dependencies for jlink modules - echo "🔍 Analyzing JAR dependencies..." - if command -v jdeps &> /dev/null; then - DETECTED_MODULES=$(jdeps --print-module-deps --ignore-missing-deps "$STIRLING_JAR" 2>/dev/null || echo "") - if [ -n "$DETECTED_MODULES" ]; then - echo "📋 jdeps detected modules: $DETECTED_MODULES" - MODULES="$DETECTED_MODULES,java.compiler,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported" - else - echo "⚠️ jdeps analysis failed, using predefined modules" - MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported" - fi - else - echo "⚠️ jdeps not available, using predefined modules" - MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported" - fi - - # Create custom JRE with jlink - echo "🔧 Creating custom JRE with jlink..." - echo "📋 Using modules: $MODULES" - - # Remove any existing JRE - rm -rf ./frontend/src-tauri/runtime/jre - - # Create the custom JRE - jlink \ - --add-modules "$MODULES" \ - --strip-debug \ - --compress=2 \ - --no-header-files \ - --no-man-pages \ - --output ./frontend/src-tauri/runtime/jre - - if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then - echo "❌ Failed to create JLink runtime" - exit 1 - fi - - # Test the bundled runtime - if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then - RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1) - echo "✅ Custom JRE created successfully: $RUNTIME_VERSION" - else - echo "❌ Custom JRE executable not found" - exit 1 - fi - - # Calculate runtime size - RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1) - echo "📊 Custom JRE size: $RUNTIME_SIZE" + - name: Prepare desktop build + run: task desktop:prepare env: MAVEN_USER: ${{ secrets.MAVEN_USER }} MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} DISABLE_ADDITIONAL_FEATURES: true - - name: Install frontend dependencies - run: task frontend:install - # DigiCert KeyLocker Setup (Cloud HSM) - name: Setup DigiCert KeyLocker id: digicert-setup - if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }} + if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1 env: SM_API_KEY: ${{ secrets.SM_API_KEY }} @@ -310,7 +254,7 @@ jobs: SM_HOST: ${{ secrets.SM_HOST }} - name: Setup DigiCert KeyLocker Certificate - if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }} + if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} shell: pwsh run: | Write-Host "Setting up DigiCert KeyLocker environment..." @@ -345,7 +289,7 @@ jobs: # Traditional PFX Certificate Import (fallback if KeyLocker not configured) - name: Import Windows Code Signing Certificate - if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }} + if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} env: WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} @@ -376,7 +320,7 @@ jobs: } - name: Import Apple Developer Certificate - if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') + if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} @@ -397,7 +341,7 @@ jobs: rm certificate.p12 - name: Verify Certificate - if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') + if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') run: | echo "Verifying Apple Developer Certificate..." KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db @@ -408,6 +352,62 @@ jobs: echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV echo "Certificate imported successfully." + # Pre-flight: verify smctl can talk to DigiCert and sync cert before we sign. + # Mirrors the setup from working public Tauri+KeyLocker repos (Labric, Meetily). + # Without this, signCommand failures are opaque (Tauri captures but drops + # smctl's stderr) - running these loudly surfaces auth/env/keypair issues. + - name: Preflight smctl + if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + shell: pwsh + env: + KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} + run: | + & smctl healthcheck + if ($LASTEXITCODE -ne 0) { Write-Host "[ERROR] smctl healthcheck failed"; exit 1 } + & smctl keypair ls + if ($LASTEXITCODE -ne 0) { Write-Host "[ERROR] smctl keypair ls failed"; exit 1 } + & smctl windows certsync --keypair-alias "$env:KEYPAIR_ALIAS" + if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] smctl windows certsync returned non-zero - continuing" } + Write-Host "[SUCCESS] smctl preflight passed" + + # Write platform-specific Tauri config that adds signCommand for Windows. + # Tauri auto-merges tauri.windows.conf.json with tauri.conf.json (RFC 7396). + # Tauri calls this command on every binary BEFORE bundling into the MSI, + # substituting %1 with the file path. + # + # Why OBJECT form (cmd + args) instead of string: + # Tauri's string-form parser does a naive split(' ') with no shell/quote handling. + # Args with spaces or quote characters get mangled. The object form passes each + # arg directly to Rust's Command::arg which handles Windows CreateProcess quoting. + # + # Why --keypair-alias instead of --fingerprint: + # --fingerprint requires smctl windows certsync to have synced the cert to the + # Windows cert store first. --keypair-alias goes direct through PKCS11 and works + # without certsync. All real-world working Tauri+smctl examples use this flag. + # + # smctl reads SM_HOST, SM_API_KEY, SM_CLIENT_CERT_FILE, SM_CLIENT_CERT_PASSWORD + # from env (set by prior DigiCert setup step). No --config-file needed. + - name: Configure Windows code signing + if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + shell: bash + env: + KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} + run: | + cat > ./frontend/src-tauri/tauri.windows.conf.json <&1 - if ($LASTEXITCODE -eq 0) { - Write-Host "[SUCCESS] Connected to DigiCert KeyLocker" - } else { - Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker" - Write-Host $healthCheck - exit 1 - } - Write-Host "" - - # Sync certificates to Windows certificate store - Write-Host "Syncing certificates to Windows certificate store..." - $syncOutput = & smctl windows certsync 2>&1 - Write-Host "Cert sync result: $syncOutput" - Write-Host "" - - # Find only the files we need to sign - $filesToSign = @() - - # Main application executable - $mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue - if ($mainExe) { $filesToSign += $mainExe } - - # MSI installer + # Check MSI installer (outer wrapper - what users download) $msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File - $filesToSign += $msiFiles - - if ($filesToSign.Count -eq 0) { - Write-Host "[ERROR] No files found to sign" + if ($msiFiles.Count -eq 0) { + Write-Host "[ERROR] No MSI found under target/" exit 1 } - - Write-Host "Found $($filesToSign.Count) files to sign:" - foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" } - Write-Host "" - - $signedCount = 0 - foreach ($file in $filesToSign) { - Write-Host "Signing: $($file.Name)" - - # Get PKCS11 config file path - $pkcs11Config = $env:PKCS11_CONFIG - if (-not $pkcs11Config) { - Write-Host "[ERROR] PKCS11_CONFIG environment variable not set" - exit 1 + foreach ($msi in $msiFiles) { + $sig = Get-AuthenticodeSignature -FilePath $msi.FullName + Write-Host "MSI: Status=$($sig.Status), Signer=$($sig.SignerCertificate.Subject)" + if ($sig.Status -ne "Valid") { + Write-Host "[ERROR] MSI is not signed" + $allSigned = $false } - - Write-Host "Using PKCS11 config: $pkcs11Config" - - # Try signing with certificate fingerprint first (if available) - $fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}" - if ($fingerprint -and $fingerprint -ne "") { - Write-Host "Attempting to sign with certificate fingerprint..." - $output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1 - $exitCode = $LASTEXITCODE - } else { - Write-Host "No fingerprint provided, using keypair alias..." - $output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1 - $exitCode = $LASTEXITCODE - } - - Write-Host "Exit code: $exitCode" - Write-Host "Output: $output" - - if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") { - Write-Host "[ERROR] Signing failed for $($file.Name)" - exit 1 - } - - if ($exitCode -ne 0) { - Write-Host "[ERROR] Failed to sign $($file.Name)" - Write-Host "Full error output:" - Write-Host $output - exit 1 - } - - $signedCount++ - Write-Host "[SUCCESS] Signed: $($file.Name)" - Write-Host "" } - Write-Host "=== Summary ===" - Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully" + # Extract MSI and verify the inner exe (the file that actually gets installed). + # This is the critical check - AV flags the installed exe at runtime. + $msi = $msiFiles[0].FullName + $extractDir = Join-Path $env:RUNNER_TEMP "msi-verify" + if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force } + $proc = Start-Process msiexec.exe -ArgumentList '/a', $msi, '/qn', "TARGETDIR=$extractDir" -Wait -PassThru -NoNewWindow + if ($proc.ExitCode -eq 0) { + $innerExe = Get-ChildItem -Path $extractDir -Filter "stirling-pdf.exe" -Recurse -File | Select-Object -First 1 + if ($innerExe) { + $sig = Get-AuthenticodeSignature -FilePath $innerExe.FullName + Write-Host "Inner EXE (from MSI): Status=$($sig.Status), Signer=$($sig.SignerCertificate.Subject)" + if ($sig.Status -ne "Valid") { + Write-Host "[ERROR] Inner exe extracted from MSI is NOT signed - AV will flag this at runtime" + $allSigned = $false + } + } else { + Write-Host "[ERROR] Could not find stirling-pdf.exe inside MSI" + $allSigned = $false + } + } else { + Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))" + $allSigned = $false + } + if (-not $allSigned) { + Write-Host "[ERROR] Signature verification failed" + exit 1 + } + Write-Host "[SUCCESS] MSI and installed exe are properly signed" + + # Dump smctl log files on failure. Tauri's signCommand captures smctl output + # but drops stderr when the command exits non-zero, making failures opaque. + # The real errors live in smctl's log files - surface them here for debugging. + - name: Dump smctl logs on failure + if: ${{ failure() && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }} + shell: pwsh + run: | + $logDir = "$env:USERPROFILE\.signingmanager\logs" + if (Test-Path $logDir) { + Get-ChildItem $logDir | ForEach-Object { + Write-Host "=== $($_.FullName) ===" + Get-Content $_.FullName -Tail 200 + Write-Host "" + } + } else { + Write-Host "smctl log directory not found at $logDir" + } + + # Rename + Upload: use always() so artifacts are still collected when verify + # fails - we need them to manually inspect what actually came out of the build. - name: Rename artifacts + if: always() && steps.digicert-setup.conclusion != 'failure' shell: bash run: | mkdir -p ./dist @@ -533,7 +517,8 @@ jobs: # Find and rename artifacts based on platform if [ "${{ matrix.platform }}" = "windows-latest" ]; then - find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \; + # Only ship the MSI installer on Windows. The loose exe and WiX toolset exes + # are not the user-facing installer - the MSI contains the signed inner exe. find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \; elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \; @@ -544,6 +529,7 @@ jobs: fi - name: Upload build artifacts + if: always() && steps.digicert-setup.conclusion != 'failure' uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: Stirling-PDF-${{ matrix.name }} diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 6924ecf489..7e6eb34d5f 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -260,6 +260,38 @@ jobs: echo "Available tools:" ls -la /usr/bin/hd* || echo "No hd* tools found" + - name: Preflight smctl + if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} + shell: pwsh + env: + KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} + run: | + & smctl healthcheck + if ($LASTEXITCODE -ne 0) { Write-Host "[ERROR] smctl healthcheck failed"; exit 1 } + & smctl keypair ls + if ($LASTEXITCODE -ne 0) { Write-Host "[ERROR] smctl keypair ls failed"; exit 1 } + & smctl windows certsync --keypair-alias "$env:KEYPAIR_ALIAS" + if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] smctl windows certsync returned non-zero - continuing" } + + - name: Configure Windows code signing + if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} + shell: bash + env: + KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} + run: | + cat > ./frontend/src-tauri/tauri.windows.conf.json <&1 - if ($LASTEXITCODE -eq 0) { - Write-Host "[SUCCESS] Connected to DigiCert KeyLocker" - } else { - Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker" - Write-Host $healthCheck - exit 1 - } - Write-Host "" - - # Sync certificates to Windows certificate store - Write-Host "Syncing certificates to Windows certificate store..." - $syncOutput = & smctl windows certsync 2>&1 - Write-Host "Cert sync result: $syncOutput" - Write-Host "" - - # List available certificates and check if they have certificates attached - Write-Host "Checking for available certificates..." - $certList = & smctl keypair ls 2>&1 - Write-Host "Keypair list output:" - Write-Host $certList - Write-Host "" - - # Parse the output to check certificate status - $lines = $certList -split "`n" - $foundKeypair = $false - $hasCertificate = $false - - foreach ($line in $lines) { - if ($line -match "${{ secrets.SM_KEYPAIR_ALIAS }}") { - $foundKeypair = $true - Write-Host "[SUCCESS] Found keypair in list" - - # Check if this line has certificate info (not just empty spaces after alias) - $parts = $line -split "\s+" - if ($parts.Count -gt 2 -and $parts[1] -ne "" -and $parts[1] -ne "CERTIFICATE") { - $hasCertificate = $true - Write-Host "[SUCCESS] Certificate is associated with keypair" - } - } - } - - if (-not $foundKeypair) { - Write-Host "[ERROR] Keypair not found: ${{ secrets.SM_KEYPAIR_ALIAS }}" - Write-Host "Available keypairs are listed above" - Write-Host "" - Write-Host "Please verify:" - Write-Host " 1. Keypair alias is correct in GitHub secret" - Write-Host " 2. API key has access to this keypair" - exit 1 - } - - if (-not $hasCertificate) { - Write-Host "[ERROR] No certificate associated with keypair" - Write-Host "This usually means:" - Write-Host " 1. Certificate not yet synced to KeyLocker (run sync manually)" - Write-Host " 2. Certificate is pending approval" - Write-Host " 3. Certificate needs to be attached to the keypair" - Write-Host "" - Write-Host "Try running in DigiCert ONE portal:" - Write-Host " smctl keypair sync" - exit 1 - } - - Write-Host "[SUCCESS] Certificate check passed" - Write-Host "" - - # Find only the files we need to sign (not build scripts) - $filesToSign = @() - - # Main application executable - $mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue - if ($mainExe) { $filesToSign += $mainExe } - - # MSI installer - $msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File - $filesToSign += $msiFiles - - if ($filesToSign.Count -eq 0) { - Write-Host "[ERROR] No files found to sign" - exit 1 - } - - Write-Host "Found $($filesToSign.Count) files to sign:" - foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" } - Write-Host "" - - $signedCount = 0 - foreach ($file in $filesToSign) { - Write-Host "Signing: $($file.Name)" - - # Get PKCS11 config file path (set by DigiCert action) - $pkcs11Config = $env:PKCS11_CONFIG - if (-not $pkcs11Config) { - Write-Host "[ERROR] PKCS11_CONFIG environment variable not set" - Write-Host "DigiCert KeyLocker action may not have run correctly" - exit 1 - } - - Write-Host "Using PKCS11 config: $pkcs11Config" - - # Try signing with certificate fingerprint first (if available) - $fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}" - if ($fingerprint -and $fingerprint -ne "") { - Write-Host "Attempting to sign with certificate fingerprint..." - $output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1 - $exitCode = $LASTEXITCODE - } else { - Write-Host "No fingerprint provided, using keypair alias..." - # Use smctl to sign with keypair alias - $output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1 - $exitCode = $LASTEXITCODE - } - - Write-Host "Exit code: $exitCode" - Write-Host "Output: $output" - - # Check if output contains "FAILED" even with exit code 0 - if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") { - Write-Host "" - Write-Host "[ERROR] Signing failed for $($file.Name)" - Write-Host "[ERROR] smctl returned success but output indicates failure" - Write-Host "" - Write-Host "Possible issues:" - Write-Host " 1. Certificate not fully synced to KeyLocker (wait a few minutes)" - Write-Host " 2. Incorrect keypair alias" - Write-Host " 3. API key lacks signing permissions" - Write-Host "" - Write-Host "Please verify in DigiCert ONE portal:" - Write-Host " - Certificate status is 'Issued' (not Pending)" - Write-Host " - Keypair status is 'Online'" - Write-Host " - 'Can sign' is set to 'Yes'" - exit 1 - } - - if ($exitCode -ne 0) { - Write-Host "[ERROR] Failed to sign $($file.Name)" - Write-Host "Full error output:" - Write-Host $output - exit 1 - } - - $signedCount++ - Write-Host "[SUCCESS] Signed: $($file.Name)" - Write-Host "" - } - - Write-Host "=== Summary ===" - Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully" - - name: Verify notarization (macOS only) if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel' run: | @@ -466,7 +338,8 @@ jobs: # Find and rename artifacts based on platform if [ "${{ matrix.platform }}" = "windows-latest" ]; then - find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \; + # Only ship the MSI installer. The loose exe and WiX toolset exes + # are not the user-facing installer - the MSI contains the signed inner exe. find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \; elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \; @@ -475,64 +348,69 @@ jobs: find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \; fi + # Verify the MSI AND the inner exe extracted from it are signed. + # The inner exe is what gets installed on users' machines and what AV scans. - name: Verify Windows Code Signature - if: matrix.platform == 'windows-latest' && github.ref == 'refs/heads/main' + if: matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' shell: pwsh run: | - Write-Host "Verifying Windows code signatures..." - - $exePath = "./dist/Stirling-PDF-${{ matrix.name }}.exe" + $allSigned = $true $msiPath = "./dist/Stirling-PDF-${{ matrix.name }}.msi" - $allSigned = $true - $usingKeyLocker = "${{ env.SM_API_KEY }}" -ne "" - $usingPfx = "${{ env.WINDOWS_CERTIFICATE }}" -ne "" - - # Check EXE signature - if (Test-Path $exePath) { - $exeSig = Get-AuthenticodeSignature -FilePath $exePath - Write-Host "EXE Signature Status: $($exeSig.Status)" - Write-Host "EXE Signer: $($exeSig.SignerCertificate.Subject)" - Write-Host "EXE Timestamp: $($exeSig.TimeStamperCertificate.NotAfter)" - - if ($exeSig.Status -ne "Valid") { - Write-Host "[WARNING] EXE is not properly signed (Status: $($exeSig.Status))" - if ($usingKeyLocker -or $usingPfx) { - Write-Host "[ERROR] Certificate was provided but signing failed" - $allSigned = $false - } else { - Write-Host "[INFO] Building unsigned binary (no certificate provided)" - } - } else { - Write-Host "[SUCCESS] EXE is properly signed" - } - } - - # Check MSI signature + # Check MSI (outer wrapper) if (Test-Path $msiPath) { - $msiSig = Get-AuthenticodeSignature -FilePath $msiPath - Write-Host "MSI Signature Status: $($msiSig.Status)" - Write-Host "MSI Signer: $($msiSig.SignerCertificate.Subject)" - Write-Host "MSI Timestamp: $($msiSig.TimeStamperCertificate.NotAfter)" + $sig = Get-AuthenticodeSignature -FilePath $msiPath + Write-Host "MSI: Status=$($sig.Status), Signer=$($sig.SignerCertificate.Subject)" + if ($sig.Status -ne "Valid") { + Write-Host "[ERROR] MSI is not signed" + $allSigned = $false + } - if ($msiSig.Status -ne "Valid") { - Write-Host "[WARNING] MSI is not properly signed (Status: $($msiSig.Status))" - if ($usingKeyLocker -or $usingPfx) { - Write-Host "[ERROR] Certificate was provided but signing failed" - $allSigned = $false + # Extract MSI and verify inner exe + $extractDir = Join-Path $env:RUNNER_TEMP "msi-verify" + if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force } + $proc = Start-Process msiexec.exe -ArgumentList '/a', $msiPath, '/qn', "TARGETDIR=$extractDir" -Wait -PassThru -NoNewWindow + if ($proc.ExitCode -eq 0) { + $innerExe = Get-ChildItem -Path $extractDir -Filter "stirling-pdf.exe" -Recurse -File | Select-Object -First 1 + if ($innerExe) { + $sig = Get-AuthenticodeSignature -FilePath $innerExe.FullName + Write-Host "Inner EXE: Status=$($sig.Status), Signer=$($sig.SignerCertificate.Subject)" + if ($sig.Status -ne "Valid") { + Write-Host "[ERROR] Inner exe is NOT signed - AV will flag this at runtime" + $allSigned = $false + } } else { - Write-Host "[INFO] Building unsigned binary (no certificate provided)" + Write-Host "[ERROR] Could not find stirling-pdf.exe inside MSI" + $allSigned = $false } } else { - Write-Host "[SUCCESS] MSI is properly signed" + Write-Host "[ERROR] MSI extraction failed (exit code: $($proc.ExitCode))" + $allSigned = $false } + } else { + Write-Host "[ERROR] MSI not found at $msiPath" + $allSigned = $false } - if (($usingKeyLocker -or $usingPfx) -and -not $allSigned) { - Write-Host "[ERROR] Code signing verification failed" + if (-not $allSigned) { + Write-Host "[ERROR] Signature verification failed" exit 1 + } + Write-Host "[SUCCESS] MSI and inner exe are properly signed" + + - name: Dump smctl logs on failure + if: ${{ failure() && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }} + shell: pwsh + run: | + $logDir = "$env:USERPROFILE\.signingmanager\logs" + if (Test-Path $logDir) { + Get-ChildItem $logDir | ForEach-Object { + Write-Host "=== $($_.FullName) ===" + Get-Content $_.FullName -Tail 200 + Write-Host "" + } } else { - Write-Host "[SUCCESS] Code signature verification completed" + Write-Host "smctl log directory not found at $logDir" } - name: Upload artifacts