diff --git a/.gitea/workflows/build-app-preview.yml b/.gitea/workflows/build-app-preview.yml index 7426355..63c9701 100644 --- a/.gitea/workflows/build-app-preview.yml +++ b/.gitea/workflows/build-app-preview.yml @@ -7,6 +7,8 @@ name: Build App (Preview) # This is also the **PR build check**: it compiles Linux, macOS and Windows, so # a push that breaks any of them fails here. Its `test` job runs vitest and # `cargo test` too, so a push that breaks either suite fails here as well. +# Previews are not code-signed (releases are, in build-app.yml): see the +# comment on the Windows job's "Build Tauri app" step. # build-app.yml used to do the build-check job in parallel and publish nothing, # which meant six OS builds per push and one unreachable set of bundles; it is # now releases-only. @@ -75,7 +77,6 @@ on: - "app/**" - "VERSION" - ".gitea/workflows/build-app-preview.yml" - - "scripts/windows-*.ps1" workflow_dispatch: jobs: @@ -671,9 +672,8 @@ jobs: run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" rem Pinned to the @tauri-apps/cli version in app/package-lock.json, which - rem the Linux and macOS jobs run: the Windows code-signing path (sign - rem command, NSIS uninstaller signing) was verified against it, and "^2" - rem would change it underneath the pipeline on any Tauri release. + rem the Linux and macOS jobs run, and kept identical to build-app.yml so + rem a preview is built by the same bundler as the release it previews. cargo install tauri-cli --version "=2.11.0" --locked - name: Fix npm platform detection @@ -695,45 +695,23 @@ jobs: set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" npm run build - # Previews are signed exactly like releases (build-app.yml): a preview is - # what gets installed for testing, and SmartScreen treats an unsigned one - # no differently from malware. The setup fetches the Artifact Signing - # client and a job-local .NET runtime, and writes the Tauri config holding - # the sign command, which "Build Tauri app" passes with --config. - - name: Prepare code signing - env: - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - ARTIFACT_SIGNING_ENDPOINT: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }} - ARTIFACT_SIGNING_ACCOUNT_NAME: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT_NAME }} - ARTIFACT_SIGNING_PROFILE_NAME: ${{ secrets.ARTIFACT_SIGNING_PROFILE_NAME }} - run: powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts\windows-signing-setup.ps1 - - name: Build Tauri app working-directory: ./app - # The sign command comes in through --config, from the file "Prepare - # code signing" wrote. Not TAURI_CONFIG: the v2 CLI never reads that - # variable (the inline one this step used to set was a no-op), and - # "Verify signatures" is what caught it. + # Previews are not code-signed: signing is metered, previews are built + # on every PR push, and a PR's workflow runs the PR's own code - so the + # signing secrets stay out of this workflow entirely. Releases are + # signed in build-app.yml. + # + # beforeBuildCommand is blanked through --config because the frontend + # was built in the step above. Not TAURI_CONFIG: the v2 CLI never + # reads that variable, and the inline one this step used to set was a + # no-op. env: # See the matching comment on the Linux job's "Build Tauri app" step. TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }} - # Read by the signing dlib itself, never passed on a command line. - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" - cargo tauri build --config "%TRIPLE_C_TAURI_SIGN_CONFIG%" - - - name: Verify signatures - run: >- - powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass - -File scripts\windows-verify-signatures.ps1 - app\src-tauri\target\release\triple-c.exe - app\src-tauri\target\release\bundle\msi\*.msi - app\src-tauri\target\release\bundle\nsis\*.exe + cargo tauri build --config "{\"build\":{\"beforeBuildCommand\":\"\"}}" - name: Collect artifacts run: | diff --git a/.gitea/workflows/build-app.yml b/.gitea/workflows/build-app.yml index 40dfad3..3fa5f49 100644 --- a/.gitea/workflows/build-app.yml +++ b/.gitea/workflows/build-app.yml @@ -686,6 +686,12 @@ jobs: app\src-tauri\target\release\bundle\msi\*.msi app\src-tauri\target\release\bundle\nsis\*.exe + # Tauri reports a failed sign command as just "failed to run powershell"; + # windows-sign.ps1 keeps its own transcript, signtool /debug included. + - name: Show signing output + if: failure() + run: if exist .code-signing\sign-output.log type .code-signing\sign-output.log + - name: Collect artifacts run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" diff --git a/scripts/windows-sign.ps1 b/scripts/windows-sign.ps1 index bbd3580..56d4da7 100644 --- a/scripts/windows-sign.ps1 +++ b/scripts/windows-sign.ps1 @@ -1,7 +1,18 @@ # windows-sign.ps1 - sign one file with Azure Artifact Signing. # # Tauri's bundle.windows.signCommand, set up by windows-signing-setup.ps1. -# Tauri calls it once per file it signs and fails the build on a non-zero exit. +# Tauri calls it once per file it wants signed and fails the build on a +# non-zero exit - but shows none of this script's output when it does, so +# everything is also appended to $TRIPLE_C_SIGN_OUTPUT, which the workflow +# prints if the job fails. +# +# Only what ships is signed. Tauri also offers build-time tools - the WiX +# extension DLLs candle/light load, the NSIS plugins makensis embeds - and +# each signature is metered (about 1000 a month), so those are skipped. The +# allowlist below is the whole of what reaches users: the app binary, the MSI, +# the NSIS installer, and the uninstaller makensis writes to the job's temp +# directory. A file that already carries a valid signature is skipped too: +# Tauri presents the app binary once per bundle type. # # This may run as 32-bit PowerShell: the NSIS uninstaller is signed from inside # makensis, which is 32-bit and resolves `powershell` to the SysWOW64 copy. So @@ -15,47 +26,77 @@ param([Parameter(Mandatory = $true)][string]$Path) $ErrorActionPreference = 'Stop' +$utf8 = New-Object System.Text.UTF8Encoding $false -foreach ($name in 'TRIPLE_C_SIGNTOOL', 'TRIPLE_C_SIGN_DLIB', 'TRIPLE_C_SIGN_METADATA', 'TRIPLE_C_SIGN_TIMESTAMP', - 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') { - if (-not [Environment]::GetEnvironmentVariable($name)) { - throw "$name is not set - run windows-signing-setup.ps1 first and pass the AZURE_* secrets to this step" +function Write-Log([string]$Text) { + Write-Host $Text + if ($env:TRIPLE_C_SIGN_OUTPUT) { + try { [IO.File]::AppendAllText($env:TRIPLE_C_SIGN_OUTPUT, "$Text`n", $utf8) } catch { } } } -if (-not (Test-Path -LiteralPath $Path)) { throw "No such file to sign: $Path" } -# /d names the product in the UAC prompt, which for an MSI would otherwise show -# a temporary file name. The timestamp is what keeps the signature valid after -# the short-lived Artifact Signing certificate expires, so it is not optional. -$arguments = @( - 'sign', '/v', - '/fd', 'SHA256', - '/tr', $env:TRIPLE_C_SIGN_TIMESTAMP, '/td', 'SHA256', - '/d', 'Triple-C', - '/dlib', $env:TRIPLE_C_SIGN_DLIB, - '/dmdf', $env:TRIPLE_C_SIGN_METADATA, - $Path -) - -# Timestamp servers and the signing endpoint both fail transiently now and -# then; a retry is cheaper than a failed three-platform release. -# -# Stop is relaxed around the call: Tauri captures this script's output, and -# PowerShell 5.1 turns a native command's stderr into error records when its -# own streams are redirected - under Stop, signtool's first warning would kill -# the script before its exit code is read. -$ErrorActionPreference = 'Continue' -for ($attempt = 1; $attempt -le 3; $attempt++) { - & $env:TRIPLE_C_SIGNTOOL @arguments 2>&1 | ForEach-Object { "$_" } - if ($LASTEXITCODE -eq 0) { - # The evidence "Verify signatures" needs for files it cannot see - # afterwards - the NSIS uninstaller is embedded in the installer. - if ($env:TRIPLE_C_SIGN_LOG) { - [IO.File]::AppendAllText($env:TRIPLE_C_SIGN_LOG, "$Path`n", (New-Object System.Text.UTF8Encoding $false)) +try { + foreach ($name in 'TRIPLE_C_SIGNTOOL', 'TRIPLE_C_SIGN_DLIB', 'TRIPLE_C_SIGN_METADATA', 'TRIPLE_C_SIGN_TIMESTAMP', + 'TRIPLE_C_SIGN_TMP', 'TRIPLE_C_SIGN_LOG', 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') { + if (-not [Environment]::GetEnvironmentVariable($name)) { + throw "$name is not set - run windows-signing-setup.ps1 first and pass the AZURE_* secrets to this step" } + } + if (-not (Test-Path -LiteralPath $Path)) { throw "No such file to sign: $Path" } + $full = (Resolve-Path -LiteralPath $Path).ProviderPath + Write-Log "== $full" + + $ships = ($full -match '\\target\\release\\[^\\]+\.exe$') -or + ($full -match '\\target\\release\\bundle\\(msi|nsis)\\[^\\]+\.(msi|exe)$') -or + $full.StartsWith($env:TRIPLE_C_SIGN_TMP.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase) + if (-not $ships) { + Write-Log 'skipped: build-time file, not shipped' exit 0 } - Write-Host "signtool exited $LASTEXITCODE signing $Path (attempt $attempt of 3)" - if ($attempt -lt 3) { Start-Sleep -Seconds (10 * $attempt) } + + $existing = Get-AuthenticodeSignature -LiteralPath $full + if ($existing.Status -eq 'Valid' -and $existing.TimeStamperCertificate) { + Write-Log "skipped: already signed by $($existing.SignerCertificate.Subject)" + [IO.File]::AppendAllText($env:TRIPLE_C_SIGN_LOG, "$full`n", $utf8) + exit 0 + } + + # /d names the product in the UAC prompt, which for an MSI would otherwise + # show a temporary file name. The timestamp is what keeps the signature + # valid after the short-lived Artifact Signing certificate expires, so it + # is not optional. /debug makes the dlib say why it failed, into the log. + $arguments = @( + 'sign', '/v', '/debug', + '/fd', 'SHA256', + '/tr', $env:TRIPLE_C_SIGN_TIMESTAMP, '/td', 'SHA256', + '/d', 'Triple-C', + '/dlib', $env:TRIPLE_C_SIGN_DLIB, + '/dmdf', $env:TRIPLE_C_SIGN_METADATA, + $full + ) + + # Stop is relaxed around the call: Tauri captures this script's output, + # and PowerShell 5.1 turns a native command's stderr into error records + # when its own streams are redirected - under Stop, signtool's first + # warning would kill the script before its exit code is read. Timestamp + # servers and the signing endpoint fail transiently now and then, hence + # the retries. + $ErrorActionPreference = 'Continue' + for ($attempt = 1; $attempt -le 3; $attempt++) { + & $env:TRIPLE_C_SIGNTOOL @arguments 2>&1 | ForEach-Object { Write-Log "$_" } + $code = $LASTEXITCODE + if ($code -eq 0) { + # The evidence "Verify signatures" needs for the file it cannot see + # afterwards - the NSIS uninstaller is embedded in the installer. + [IO.File]::AppendAllText($env:TRIPLE_C_SIGN_LOG, "$full`n", $utf8) + Write-Log 'signed' + exit 0 + } + Write-Log "signtool exited $code (attempt $attempt of 3)" + if ($attempt -lt 3) { Start-Sleep -Seconds (10 * $attempt) } + } + exit 1 +} catch { + Write-Log "windows-sign.ps1 failed: $($_.Exception.Message)" + exit 1 } -exit 1 diff --git a/scripts/windows-signing-setup.ps1 b/scripts/windows-signing-setup.ps1 index a3bafaf..a123b74 100644 --- a/scripts/windows-signing-setup.ps1 +++ b/scripts/windows-signing-setup.ps1 @@ -145,6 +145,7 @@ $tauriConfigPath = Join-Path $root 'tauri.signing.conf.json' $tmpDir = Join-Path $root 'tmp' New-Item -ItemType Directory -Path $tmpDir | Out-Null $signLog = Join-Path $root 'signed.log' +$signOutput = Join-Path $root 'sign-output.log' # $GITHUB_ENV is KEY=VALUE lines. Written without a BOM: PowerShell 5.1's # utf8 encoding adds one, which would corrupt the first key. @@ -157,6 +158,7 @@ $lines = @( "DOTNET_ROOT_X64=$dotnetDir" "TRIPLE_C_TAURI_SIGN_CONFIG=$tauriConfigPath" "TRIPLE_C_SIGN_LOG=$signLog" + "TRIPLE_C_SIGN_OUTPUT=$signOutput" "TRIPLE_C_SIGN_TMP=$tmpDir" "TEMP=$tmpDir" "TMP=$tmpDir"