From 7f30d6f2d5b9b790e0ef7ed889f4cc3c49bfc293 Mon Sep 17 00:00:00 2001 From: steveoh Date: Thu, 30 Oct 2025 10:15:43 -0600 Subject: [PATCH 1/2] ci: test certutil --- .github/workflows/release.yml | 8 + .github/workflows/signing-test.yml | 257 +++++++++++++++++------------ CHANGELOG.md | 7 - forge.config.ts | 32 ++-- 4 files changed, 174 insertions(+), 130 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c5be98..8880944 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,6 +63,8 @@ jobs: APPLE_USER_ID: ${{ secrets.APPLE_USER_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + GCP_KEYRING_PATH: ${{ secrets.GCP_KEYRING_PATH }} + GCP_KEY_NAME: ${{ secrets.GCP_KEY_NAME }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VITE_IS_BETA: ${{ github.event.release.prerelease == true }} @@ -99,6 +101,12 @@ jobs: env: CACHE_HIT: ${{ steps.cache-cng.outputs.cache-hit }} + - name: Install cert + shell: pwsh + run: | + certutil -user -addstore My ".\build\cert\windows.cer" + certutil -addstore My ".\build\cert\windows.cer" + - name: 🗝️ Authenticate to Google Cloud id: auth uses: google-github-actions/auth@v3 diff --git a/.github/workflows/signing-test.yml b/.github/workflows/signing-test.yml index 4d0bc62..4349a32 100644 --- a/.github/workflows/signing-test.yml +++ b/.github/workflows/signing-test.yml @@ -2,14 +2,6 @@ name: Windows Signing Test on: workflow_dispatch: - inputs: - cert_sha1: - description: 'Certificate SHA1 thumbprint (optional; falls back to repo var)' - required: false - timeout_minutes: - description: 'Timeout for the job in minutes' - required: false - default: 15 permissions: id-token: write @@ -21,21 +13,44 @@ jobs: runs-on: windows-latest environment: name: prod - timeout-minutes: ${{ github.event.inputs.timeout_minutes }} steps: - - name: ⬇️ Checkout code + - name: ⬇️ Set up code uses: actions/checkout@v5 + with: + show-progress: false - - name: 🧭 Add signtool to PATH and detect /ksp + - name: Create Google KMS CNG Config shell: pwsh - run: .\build\add-signtool-path.ps1 + env: + GCP_KEYRING_PATH: ${{ secrets.GCP_KEYRING_PATH }} + GCP_KEY_NAME: ${{ secrets.GCP_KEY_NAME }} + run: | + # The provider requires this specific directory and file + New-Item -Path "C:\Windows\KMSCNG" -ItemType Directory -Force - - name: 📦 Install Google Cloud KMS Provider (best-effort) + $cryptoPath = "$env:GCP_KEYRING_PATH/cryptoKeys/$env:GCP_KEY_NAME/cryptoKeyVersions/1" + + # This YAML content creates a "key container" named "gcloud-signing-key" + # and points it to your specific key version in KMS. + $yaml = @" + --- + resources: + - crypto_key_version: "projects/ut-dts-ugrc-code-sign-prod/locations/us-central1/keyRings/default/cryptoKeys/default/cryptoKeyVersions/1" + "@ + + Set-Content -Path "C:\Windows\KMSCNG\config.yaml" -Value $yaml + Write-Host "Created C:\Windows\KMSCNG\config.yaml" + + - name: View KMSCNG providers shell: pwsh - run: .\build\install-kms.ps1 - # Installation is best-effort for testing; skip cache here for simplicity + run: | + if (Test-Path 'C:\Windows\KMSCNG\config.yaml') { + Write-Host "path found" + } else { + Write-Warning "$path not found on runner" + } - - name: 🗝️ Authenticate to Google Cloud (OIDC) + - name: 🗝️ Authenticate to Google Cloud id: auth uses: google-github-actions/auth@v3 with: @@ -45,100 +60,130 @@ jobs: service_account: ${{ secrets.SERVICE_ACCOUNT_EMAIL }} create_credentials_file: true - - name: 📜 Import code signing certificate to CurrentUser\My + - name: 📦 Install Google Cloud KMS Provider (best-effort) shell: pwsh + run: .\build\install-kms.ps1 env: - GCP_KEYRING_PATH: ${{ secrets.GCP_KEYRING_PATH }} - GCP_KEY_NAME: ${{ secrets.GCP_KEY_NAME }} - CERT_SHA1_INPUT: ${{ github.event.inputs.cert_sha1 }} - run: | - $ErrorActionPreference = 'Stop' - $thumb = if ($env:CERT_SHA1_INPUT) { $env:CERT_SHA1_INPUT } else { '${{ vars.CERTIFICATE_SHA1 }}' } - if (-not $thumb) { Write-Error 'Certificate SHA1 not provided via input or repo var'; exit 1 } - Write-Host "Using certificate thumbprint: $thumb" - .\build\import-certificate.ps1 -CertSha1 $thumb + GH_TOKEN: ${{ github.token }} - - name: 🔐 KMS access preflight (getPublicKey) + - name: Install cert shell: pwsh - env: - ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }} - GCP_KEYRING_PATH: ${{ secrets.GCP_KEYRING_PATH }} - GCP_KEY_NAME: ${{ secrets.GCP_KEY_NAME }} - run: | - $ErrorActionPreference = 'Stop' - if (-not $env:ACCESS_TOKEN) { Write-Error 'No access token from auth step'; exit 1 } - if (-not $env:GCP_KEYRING_PATH) { Write-Error 'GCP_KEYRING_PATH secret is not set'; exit 1 } - $path = $env:GCP_KEYRING_PATH -replace '^gkms://', '' - $path = $path.TrimStart('/') - $pattern = '^projects/[^/]+/locations/([^/]+)/keyRings/[^/]+/cryptoKeys/[^/]+/cryptoKeyVersions/\d+$' - $m = [regex]::Match($path, $pattern) - if (-not $m.Success) { Write-Error 'GCP_KEYRING_PATH format invalid; expected projects/.../cryptoKeyVersions/N or provide a ring path and a key name.'; exit 1 } - $loc = $m.Groups[1].Value - $uri = "https://cloudkms.$loc.rep.googleapis.com/v1/$path/publicKey" - try { - $resp = Invoke-RestMethod -Method GET -Uri $uri -Headers @{ Authorization = "Bearer $($env:ACCESS_TOKEN)" } - if ($resp.pem) { - Write-Host "KMS getPublicKey ok. Algorithm: $($resp.algorithm)" - Set-Content -Path "$env:RUNNER_TEMP\kms-pub.pem" -Value $resp.pem -Encoding ascii - } else { Write-Error 'KMS getPublicKey returned no PEM'; exit 1 } - } catch { - Write-Error "KMS getPublicKey failed: $($_.Exception.Message)"; throw - } + run: certutil -user -addstore My ".\build\cert\windows.cer" - - name: 🔎 Compare certificate vs KMS public key (SPKI) - shell: pwsh - env: - CERT_SHA1_INPUT: ${{ github.event.inputs.cert_sha1 }} - run: | - $ErrorActionPreference = 'Stop' - $thumb = if ($env:CERT_SHA1_INPUT) { $env:CERT_SHA1_INPUT } else { '${{ vars.CERTIFICATE_SHA1 }}' } - if (-not $thumb) { Write-Error 'Certificate SHA1 not available'; exit 1 } - $cert = Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.Thumbprint -eq $thumb } - if (-not $cert) { Write-Error "Certificate $thumb not found in CurrentUser\My"; exit 1 } - $certFile = "$env:RUNNER_TEMP\cert.cer" - Export-Certificate -Cert $cert -FilePath $certFile -Force | Out-Null - - # Ensure openssl exists or try to install via choco - if (-not (Get-Command openssl -ErrorAction SilentlyContinue)) { - Write-Warning 'openssl not found in PATH; attempting choco install openssl.light (may require admin)' - if (Get-Command choco -ErrorAction SilentlyContinue) { choco install openssl.light -y --no-progress --limit-output } else { Write-Warning 'choco not available; cannot install openssl automatically' } - } - if (-not (Get-Command openssl -ErrorAction SilentlyContinue)) { Write-Warning 'openssl still not available; skipping SPKI compare'; exit 0 } - - $certHash = & openssl x509 -in $certFile -noout -pubkey 2>$null | openssl pkey -pubin -outform der 2>$null | openssl dgst -sha256 -r 2>$null - $certHash = ($certHash -split ' ' | Select-Object -First 1).Trim() - $kmsPem = "$env:RUNNER_TEMP\kms-pub.pem" - if (-not (Test-Path $kmsPem)) { Write-Error 'KMS public PEM not found'; exit 1 } - $kmsHash = & openssl pkey -pubin -in $kmsPem -outform der 2>$null | openssl dgst -sha256 -r 2>$null - $kmsHash = ($kmsHash -split ' ' | Select-Object -First 1).Trim() - Write-Host "Cert SPKI: $certHash" - Write-Host "KMS SPKI : $kmsHash" - if ($certHash -and $kmsHash -and ($certHash -ieq $kmsHash)) { Write-Host 'Public key match confirmed (SPKI SHA256).' } else { Write-Error 'Public key mismatch between cert and KMS'; exit 1 } - - - name: 🔏 Pre-sign sanity sign + - name: Link key shell: pwsh - env: - GCP_KEYRING_PATH: ${{ secrets.GCP_KEYRING_PATH }} - GCP_KEY_NAME: ${{ secrets.GCP_KEY_NAME }} - CERT_SHA1_INPUT: ${{ github.event.inputs.cert_sha1 }} - GOOGLE_APPLICATION_CREDENTIALS: ${{ steps.auth.outputs.credentials_file_path }} - run: | - $ErrorActionPreference = 'Stop' - dotnet --info | Out-Null - dotnet new console -n SignSanity -o .\SignSanity -f net8.0 --force | Out-Null - dotnet build .\SignSanity\SignSanity.csproj -c Release -v m | Out-Null - $exe = Get-ChildItem .\SignSanity\bin\Release\**\SignSanity.exe -ErrorAction Stop | Select-Object -First 1 - if (-not $exe) { Write-Error 'Failed to locate built test executable'; exit 1 } - Write-Host "Signing test executable: $($exe.FullName)" - $kc = $env:GCP_KEYRING_PATH - if ($kc -notmatch '^gkms://') { $kc = 'gkms://' + ($kc.TrimStart('/')) } - $thumb = if ($env:CERT_SHA1_INPUT) { $env:CERT_SHA1_INPUT } else { '${{ vars.CERTIFICATE_SHA1 }}' } - $useKsp = ($env:WINDOWS_SIGN_USE_KSP -eq 'true') - if ($useKsp) { - & signtool.exe sign /v /debug /fd SHA256 /tr http://timestamp.sectigo.com /td SHA256 /s my /ksp 'Google Cloud KMS Provider' /kc "$kc" /sha1 "$thumb" $exe.FullName - } else { - & signtool.exe sign /v /debug /fd SHA256 /tr http://timestamp.sectigo.com /td SHA256 /s my /csp 'Google Cloud KMS Provider' /kc "$kc" /sha1 "$thumb" $exe.FullName - } + run: certutil -f -user -csp "Google Cloud KMS Provider" -repairstore My "AFF534DFD7F78D2C73932997B7DAD510D5A7821C" + # - name: ⬇️ Checkout code + # uses: actions/checkout@v5 + + # - name: 🧭 Add signtool to PATH and detect /ksp + # shell: pwsh + # run: .\build\add-signtool-path.ps1 + + # - name: 🗝️ Authenticate to Google Cloud (OIDC) + # id: auth + # uses: google-github-actions/auth@v3 + # with: + # access_token_scopes: 'openid, https://www.googleapis.com/auth/cloudkms, https://www.googleapis.com/auth/cloud-platform' + # token_format: 'access_token' + # workload_identity_provider: ${{ secrets.IDENTITY_PROVIDER }} + # service_account: ${{ secrets.SERVICE_ACCOUNT_EMAIL }} + # create_credentials_file: true + + # - name: 📜 Import code signing certificate to CurrentUser\My + # shell: pwsh + # env: + # GCP_KEYRING_PATH: ${{ secrets.GCP_KEYRING_PATH }} + # GCP_KEY_NAME: ${{ secrets.GCP_KEY_NAME }} + # CERT_SHA1_INPUT: ${{ github.event.inputs.cert_sha1 }} + # run: | + # $ErrorActionPreference = 'Stop' + # $thumb = if ($env:CERT_SHA1_INPUT) { $env:CERT_SHA1_INPUT } else { '${{ vars.CERTIFICATE_SHA1 }}' } + # if (-not $thumb) { Write-Error 'Certificate SHA1 not provided via input or repo var'; exit 1 } + # Write-Host "Using certificate thumbprint: $thumb" + # .\build\import-certificate.ps1 -CertSha1 $thumb + + # - name: 🔐 KMS access preflight (getPublicKey) + # shell: pwsh + # env: + # ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }} + # GCP_KEYRING_PATH: ${{ secrets.GCP_KEYRING_PATH }} + # GCP_KEY_NAME: ${{ secrets.GCP_KEY_NAME }} + # run: | + # $ErrorActionPreference = 'Stop' + # if (-not $env:ACCESS_TOKEN) { Write-Error 'No access token from auth step'; exit 1 } + # if (-not $env:GCP_KEYRING_PATH) { Write-Error 'GCP_KEYRING_PATH secret is not set'; exit 1 } + # $path = $env:GCP_KEYRING_PATH -replace '^gkms://', '' + # $path = $path.TrimStart('/') + # $pattern = '^projects/[^/]+/locations/([^/]+)/keyRings/[^/]+/cryptoKeys/[^/]+/cryptoKeyVersions/\d+$' + # $m = [regex]::Match($path, $pattern) + # if (-not $m.Success) { Write-Error 'GCP_KEYRING_PATH format invalid; expected projects/.../cryptoKeyVersions/N or provide a ring path and a key name.'; exit 1 } + # $loc = $m.Groups[1].Value + # $uri = "https://cloudkms.$loc.rep.googleapis.com/v1/$path/publicKey" + # try { + # $resp = Invoke-RestMethod -Method GET -Uri $uri -Headers @{ Authorization = "Bearer $($env:ACCESS_TOKEN)" } + # if ($resp.pem) { + # Write-Host "KMS getPublicKey ok. Algorithm: $($resp.algorithm)" + # Set-Content -Path "$env:RUNNER_TEMP\kms-pub.pem" -Value $resp.pem -Encoding ascii + # } else { Write-Error 'KMS getPublicKey returned no PEM'; exit 1 } + # } catch { + # Write-Error "KMS getPublicKey failed: $($_.Exception.Message)"; throw + # } + + # - name: 🔎 Compare certificate vs KMS public key (SPKI) + # shell: pwsh + # env: + # CERT_SHA1_INPUT: ${{ github.event.inputs.cert_sha1 }} + # run: | + # $ErrorActionPreference = 'Stop' + # $thumb = if ($env:CERT_SHA1_INPUT) { $env:CERT_SHA1_INPUT } else { '${{ vars.CERTIFICATE_SHA1 }}' } + # if (-not $thumb) { Write-Error 'Certificate SHA1 not available'; exit 1 } + # $cert = Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.Thumbprint -eq $thumb } + # if (-not $cert) { Write-Error "Certificate $thumb not found in CurrentUser\My"; exit 1 } + # $certFile = "$env:RUNNER_TEMP\cert.cer" + # Export-Certificate -Cert $cert -FilePath $certFile -Force | Out-Null + + # # Ensure openssl exists or try to install via choco + # if (-not (Get-Command openssl -ErrorAction SilentlyContinue)) { + # Write-Warning 'openssl not found in PATH; attempting choco install openssl.light (may require admin)' + # if (Get-Command choco -ErrorAction SilentlyContinue) { choco install openssl.light -y --no-progress --limit-output } else { Write-Warning 'choco not available; cannot install openssl automatically' } + # } + # if (-not (Get-Command openssl -ErrorAction SilentlyContinue)) { Write-Warning 'openssl still not available; skipping SPKI compare'; exit 0 } + + # $certHash = & openssl x509 -in $certFile -noout -pubkey 2>$null | openssl pkey -pubin -outform der 2>$null | openssl dgst -sha256 -r 2>$null + # $certHash = ($certHash -split ' ' | Select-Object -First 1).Trim() + # $kmsPem = "$env:RUNNER_TEMP\kms-pub.pem" + # if (-not (Test-Path $kmsPem)) { Write-Error 'KMS public PEM not found'; exit 1 } + # $kmsHash = & openssl pkey -pubin -in $kmsPem -outform der 2>$null | openssl dgst -sha256 -r 2>$null + # $kmsHash = ($kmsHash -split ' ' | Select-Object -First 1).Trim() + # Write-Host "Cert SPKI: $certHash" + # Write-Host "KMS SPKI : $kmsHash" + # if ($certHash -and $kmsHash -and ($certHash -ieq $kmsHash)) { Write-Host 'Public key match confirmed (SPKI SHA256).' } else { Write-Error 'Public key mismatch between cert and KMS'; exit 1 } + + # - name: 🔏 Pre-sign sanity sign + # shell: pwsh + # env: + # GCP_KEYRING_PATH: ${{ secrets.GCP_KEYRING_PATH }} + # GCP_KEY_NAME: ${{ secrets.GCP_KEY_NAME }} + # CERT_SHA1_INPUT: ${{ github.event.inputs.cert_sha1 }} + # GOOGLE_APPLICATION_CREDENTIALS: ${{ steps.auth.outputs.credentials_file_path }} + # run: | + # $ErrorActionPreference = 'Stop' + # dotnet --info | Out-Null + # dotnet new console -n SignSanity -o .\SignSanity -f net8.0 --force | Out-Null + # dotnet build .\SignSanity\SignSanity.csproj -c Release -v m | Out-Null + # $exe = Get-ChildItem .\SignSanity\bin\Release\**\SignSanity.exe -ErrorAction Stop | Select-Object -First 1 + # if (-not $exe) { Write-Error 'Failed to locate built test executable'; exit 1 } + # Write-Host "Signing test executable: $($exe.FullName)" + # $kc = $env:GCP_KEYRING_PATH + # if ($kc -notmatch '^gkms://') { $kc = 'gkms://' + ($kc.TrimStart('/')) } + # $thumb = if ($env:CERT_SHA1_INPUT) { $env:CERT_SHA1_INPUT } else { '${{ vars.CERTIFICATE_SHA1 }}' } + # $useKsp = ($env:WINDOWS_SIGN_USE_KSP -eq 'true') + # if ($useKsp) { + # & signtool.exe sign /v /debug /fd SHA256 /tr http://timestamp.sectigo.com /td SHA256 /s my /ksp 'Google Cloud KMS Provider' /kc "$kc" /sha1 "$thumb" $exe.FullName + # } else { + # & signtool.exe sign /v /debug /fd SHA256 /tr http://timestamp.sectigo.com /td SHA256 /s my /csp 'Google Cloud KMS Provider' /kc "$kc" /sha1 "$thumb" $exe.FullName + # } - - name: ✅ Done (signing test finished) - run: Write-Host 'Signing test job completed. Review logs for success/failure.' + # - name: ✅ Done (signing test finished) + # run: Write-Host 'Signing test job completed. Review logs for success/failure.' diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a7d7fa..46694e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,5 @@ # Changelog -## [1.7.12](https://github.com/agrc/api-client/compare/v1.7.11...v1.7.12) (2025-10-28) - - -### Bug Fixes - -* correct windows deployment ([2678d92](https://github.com/agrc/api-client/commit/2678d9211af4863fc919a53b7bb809bc13123f76)) - ## [1.7.10](https://github.com/agrc/api-client/compare/v1.7.9...v1.7.10) (2025-08-04) diff --git a/forge.config.ts b/forge.config.ts index 4a3e5a4..fea65bb 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -34,24 +34,13 @@ const kmsKeyPath = (() => { const certPath = path.resolve(__dirname, 'build', 'cert', 'windows.cer'); const windowsSign = { - // Ensure the library uses SHA256 and RFC3161 timestamp server (avoids default /t and sha1) digestAlgorithm: 'sha256', - rfcTimestampServer: 'http://timestamp.sectigo.com', - // Append KMS-specific args; also add verbosity and explicit store - additionalSignToolArgs: [ - '/v', - '/debug', - '/fd', - 'sha256', - '/t', - 'http://timestamp.sectigo.com', - '/f', - certPath, - '/csp', - 'Google Cloud KMS Provider', - '/kc', - kmsKeyPath, - ], + hashes: ['sha256'], + certificateFile: certPath, + timestampServer: 'http://timestamp.sectigo.com', + description: 'UGRC API Client', + website: 'https://gis.utah.gov/products/sgid/address/api-client/', + signWithParams: ['/v', '/csp', 'Google Cloud KMS Provider', '/kc', kmsKeyPath], }; const config: ForgeConfig = { @@ -107,6 +96,15 @@ const config: ForgeConfig = { noMsi: true, setupExe: `ugrc-api-client-${version}-win32-setup.exe`, setupIcon: path.resolve(assets, 'logo.ico'), + windowsSign: { + // @ts-expect-error matches enum value + hashes: ['sha256'], + certificateFile: certPath, + timestampServer: 'http://timestamp.sectigo.com', + description: 'UGRC API Client', + website: 'https://gis.utah.gov/products/sgid/address/api-client/', + signWithParams: ['/v', '/csp', 'Google Cloud KMS Provider', '/kc', kmsKeyPath], + }, }), new MakerZIP({}, ['darwin']), new MakerDMG({ From 7e22373d81fcecb4e82c9ddee5bff355a4153a6d Mon Sep 17 00:00:00 2001 From: "ugrc-release-bot[bot]" <113075024+ugrc-release-bot[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:16:25 +0000 Subject: [PATCH 2/2] chore: release v1.7.11 --- CHANGELOG.md | 18 ++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46694e3..668d180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [1.7.11](https://github.com/agrc/api-client/compare/v1.7.10...v1.7.11) (2025-10-30) + + +### Features + +* reference GCP HSM key ([bc64d6b](https://github.com/agrc/api-client/commit/bc64d6ba532cd1536b9cad387588bb26c6d78b19)) + + +### Bug Fixes + +* sign windows app with GCP HSM key ([a1cc665](https://github.com/agrc/api-client/commit/a1cc66546105bcf147b478e0285448d225020312)) +* use google cloud kms to sign windows cert ([c766086](https://github.com/agrc/api-client/commit/c7660869c34510fba686db746e1d2e4df9dd2cc1)) + + +### Documentation + +* add windows certificate process ([b983128](https://github.com/agrc/api-client/commit/b983128f94e3c6e1595377fb2c6fa9ec5934224d)) + ## [1.7.10](https://github.com/agrc/api-client/compare/v1.7.9...v1.7.10) (2025-08-04) diff --git a/package-lock.json b/package-lock.json index ac8cc0b..533071a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ugrc-api-client", - "version": "1.7.12", + "version": "1.7.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ugrc-api-client", - "version": "1.7.12", + "version": "1.7.11", "license": "MIT", "dependencies": { "@headlessui/react": "^2.2.7", diff --git a/package.json b/package.json index 1599c78..443d001 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ugrc-api-client", - "version": "1.7.12", + "version": "1.7.11", "description": "The official UGRC API client", "keywords": [ "utah",