From 439a5ca2dc5f2e1e9f6485dca94f8bab157dc724 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Wed, 2 Sep 2026 15:05:31 -0700 Subject: [PATCH 1/2] ci: scaffold macOS/Windows code signing for gddy releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Developer ID codesign/notarize steps (macOS) and DigiCert KeyLocker Authenticode signing (Windows) to the release workflow, so gddy stops re-prompting for Keychain access on every version bump. Both signing paths are currently disabled pending secrets/vars on the repo — macOS notarization is commented out (DEVEX-1080) and Windows signing is commented out entirely (DEVEX-1081); only macOS codesign is live once SIGNING_CERTIFICATE_P12/_PASSWORD are set. Refs DEVEX-896 --- .github/workflows/Sign-WithKeyLocker.ps1 | 87 +++++++++++++++++ .github/workflows/release.yml | 114 +++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 .github/workflows/Sign-WithKeyLocker.ps1 diff --git a/.github/workflows/Sign-WithKeyLocker.ps1 b/.github/workflows/Sign-WithKeyLocker.ps1 new file mode 100644 index 00000000..577075d0 --- /dev/null +++ b/.github/workflows/Sign-WithKeyLocker.ps1 @@ -0,0 +1,87 @@ +<# +.SYNOPSIS + Authenticode-sign the gddy.exe artifact with DigiCert KeyLocker, then + verify the signature. + +.DESCRIPTION + Used by .github/workflows/release.yml's Windows build leg. Signing is + gated by the caller via the `vars.ENABLE_WINDOWS_SIGNING == '1'` step + condition; this script assumes it should sign when invoked. + + Uses a KeyLocker service-account client cert + API key and an EV Code + Signing certificate keypair alias (both provisioned out of band). The + DigiCert client (smctl + KSP) is installed manually from the KeyLocker + API. `smctl sign` delegates to signtool, so the Windows SDK signtool + directory is added to PATH. + + Adapted from gocode-client's .github/scripts/Sign-WithKeyLocker.ps1. + + Required environment variables (mapped from repo secrets by the caller): + SM_HOST KeyLocker client-auth host + SM_API_KEY KeyLocker API token + SM_CLIENT_CERT_PASSWORD password for the client auth .p12 + SM_CLIENT_CERT_FILE_B64 base64 of the client auth .p12 + +.PARAMETER Path + One or more paths to artifacts to sign. + +.PARAMETER KeypairAlias + KeyLocker keypair alias to sign with (repo variable KEYLOCKER_KEYPAIR_ALIAS). +#> +param( + [Parameter(Mandatory = $true)] + [string[]] $Path, + + [Parameter(Mandatory = $true)] + [string] $KeypairAlias +) + +$ErrorActionPreference = 'Stop' + +foreach ($v in 'SM_HOST', 'SM_API_KEY', 'SM_CLIENT_CERT_PASSWORD', 'SM_CLIENT_CERT_FILE_B64') { + if (-not (Test-Path "env:$v")) { throw "Sign-WithKeyLocker: required env var '$v' is not set." } +} + +# Materialize the client authentication certificate (.p12) from the base64 +# secret and point SM_CLIENT_CERT_FILE (read by smctl) at it. +$p12 = Join-Path $env:RUNNER_TEMP 'keylocker-client.p12' +[IO.File]::WriteAllBytes($p12, [Convert]::FromBase64String($env:SM_CLIENT_CERT_FILE_B64)) +$env:SM_CLIENT_CERT_FILE = $p12 + +# Install the DigiCert KeyLocker client tools (smctl + KSP + PKCS#11) if not +# already present on this runner. +$smHome = 'C:\Program Files\DigiCert\DigiCert Keylocker Tools' +if (-not (Test-Path (Join-Path $smHome 'smctl.exe'))) { + $msi = Join-Path $env:RUNNER_TEMP 'Keylockertools-windows-x64.msi' + curl.exe -fSs -X GET "$($env:SM_HOST)/signingmanager/api-ui/v1/releases/Keylockertools-windows-x64.msi/download" ` + -H "x-api-key:$($env:SM_API_KEY)" -o $msi + Start-Process msiexec.exe -ArgumentList "/i `"$msi`" /quiet /qn /norestart" -Wait +} + +# `smctl sign` shells out to signtool, which is not on PATH by default. +$signtool = (Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe' -ErrorAction SilentlyContinue | + Sort-Object FullName -Descending | Select-Object -First 1) +if (-not $signtool) { throw 'Sign-WithKeyLocker: signtool.exe not found in the Windows SDK.' } +$env:PATH = "$smHome;$($signtool.Directory.FullName);$env:PATH" + +# Validate credentials. Redirect output to a file rather than the console: +# `smctl healthcheck` echoes a partially-masked API key / cert password that +# GitHub's exact-match secret masking does not catch. +smctl healthcheck --all *> "$env:RUNNER_TEMP\keylocker-healthcheck.log" +if ($LASTEXITCODE -ne 0) { throw "Sign-WithKeyLocker: smctl healthcheck failed ($LASTEXITCODE) - log withheld to avoid leaking credentials." } + +# Register the DigiCert KSP and sync the leaf cert into the Windows store. +# (BCryptRegisterProvider 0xc0000035 == already registered; benign.) +smctl windows ksp register +smctl windows certsync --keypair-alias="$KeypairAlias" + +foreach ($f in $Path) { + $full = (Resolve-Path $f).Path + Write-Host "::group::sign $full" + smctl sign --keypair-alias "$KeypairAlias" --input $full --verbose + if ($LASTEXITCODE -ne 0) { throw "Sign-WithKeyLocker: smctl sign failed for $full ($LASTEXITCODE)." } + & $signtool.FullName verify /pa /v $full + if ($LASTEXITCODE -ne 0) { throw "Sign-WithKeyLocker: signtool verify failed for $full ($LASTEXITCODE)." } + Write-Host "::endgroup::" +} +Write-Host "KeyLocker signing complete for: $($Path -join ', ')" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c42fe1a7..1ef37cd8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -95,6 +95,120 @@ jobs: cargo build --release --target ${{ matrix.target }} fi + # macOS code signing + notarization. Gated on the signing secrets being + # present so releases keep working unsigned until they're provisioned: + # SIGNING_CERTIFICATE_P12 base64 Developer ID Application .p12 + # SIGNING_CERTIFICATE_PASSWORD password for that .p12 + # NOTARIZATION_API_KEY base64 App Store Connect API .p8 key + # NOTARIZATION_KEY_ID key ID for the above + # NOTARIZATION_ISSUER_ID issuer ID for the above + # + # gddy ships as a flat binary (no .app bundle, no sibling helper + # processes sharing keychain access), so unlike gocode-client this + # needs no bundle staging, provisioning profile, or entitlements — + # signing alone gives gddy a stable code identity across releases, + # which is what lets macOS Keychain remember "Always Allow" instead of + # re-prompting on every version bump. + - name: Set up signing keychain (macOS) + id: signing_keychain + if: matrix.os == 'macos-latest' + env: + P12_BASE64: ${{ secrets.SIGNING_CERTIFICATE_P12 }} + P12_PASSWORD: ${{ secrets.SIGNING_CERTIFICATE_PASSWORD }} + shell: bash + run: | + if [ -z "$P12_BASE64" ] || [ -z "$P12_PASSWORD" ]; then + echo "::warning::SIGNING_CERTIFICATE_P12 / _PASSWORD are not set - shipping an unsigned macOS binary" + exit 0 + fi + KEYCHAIN_PATH="$RUNNER_TEMP/sign.keychain-db" + KEYCHAIN_PASSWORD="$(openssl rand -base64 32)" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + P12_PATH="$RUNNER_TEMP/cert.p12" + printf '%s' "$P12_BASE64" | base64 -d > "$P12_PATH" + security import "$P12_PATH" \ + -k "$KEYCHAIN_PATH" \ + -P "$P12_PASSWORD" \ + -T /usr/bin/codesign + rm -f "$P12_PATH" + + existing_keychains=() + while IFS= read -r keychain; do + [ -n "$keychain" ] && existing_keychains+=("$keychain") + done < <(security list-keychains -d user | sed -e 's/^[[:space:]]*//' -e 's/"//g') + security list-keychains -d user -s "$KEYCHAIN_PATH" "${existing_keychains[@]}" + + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + security find-identity -v -p codesigning "$KEYCHAIN_PATH" + echo "keychain_path=$KEYCHAIN_PATH" >> "$GITHUB_OUTPUT" + + - name: Codesign gddy (macOS) + if: matrix.os == 'macos-latest' && steps.signing_keychain.outputs.keychain_path != '' + shell: bash + run: | + BIN="target/${{ matrix.target }}/release/gddy" + codesign --force --sign "Developer ID Application: GoDaddy Mobile LLC (7UMADG39Z9)" \ + --options runtime --timestamp "$BIN" + codesign --verify --strict --verbose=2 "$BIN" + + # Notarization is temporarily disabled — tracked in DEVEX-1080. Signing + # alone already fixes DEVEX-896's Keychain-reprompt bug; notarization + # only matters for browser-downloaded (quarantined) tarballs, so it's + # safe to land signing first and re-enable this once the + # NOTARIZATION_API_KEY / NOTARIZATION_KEY_ID / NOTARIZATION_ISSUER_ID + # secrets exist on this repo. + # - name: Notarize gddy (macOS) + # if: matrix.os == 'macos-latest' && steps.signing_keychain.outputs.keychain_path != '' + # shell: bash + # env: + # API_KEY_BASE64: ${{ secrets.NOTARIZATION_API_KEY }} + # KEY_ID: ${{ secrets.NOTARIZATION_KEY_ID }} + # ISSUER_ID: ${{ secrets.NOTARIZATION_ISSUER_ID }} + # run: | + # if [ -z "$API_KEY_BASE64" ] || [ -z "$KEY_ID" ] || [ -z "$ISSUER_ID" ]; then + # echo "::error::gddy was signed but NOTARIZATION_API_KEY / NOTARIZATION_KEY_ID / NOTARIZATION_ISSUER_ID are missing" + # exit 1 + # fi + # KEY_PATH="$RUNNER_TEMP/notary_key.p8" + # printf '%s' "$API_KEY_BASE64" | base64 -d > "$KEY_PATH" + # chmod 600 "$KEY_PATH" + # + # NOTARY_ZIP="$RUNNER_TEMP/notarize.zip" + # ditto -c -k --sequesterRsrc "target/${{ matrix.target }}/release/gddy" "$NOTARY_ZIP" + # + # xcrun notarytool submit "$NOTARY_ZIP" \ + # --key "$KEY_PATH" \ + # --key-id "$KEY_ID" \ + # --issuer "$ISSUER_ID" \ + # --wait + # + # rm -f "$KEY_PATH" "$NOTARY_ZIP" + + - name: Tear down signing keychain (macOS) + if: always() && matrix.os == 'macos-latest' && steps.signing_keychain.outputs.keychain_path != '' + shell: bash + run: security delete-keychain "${{ steps.signing_keychain.outputs.keychain_path }}" || true + + # Windows Authenticode signing via DigiCert KeyLocker is temporarily + # disabled — tracked in DEVEX-1081. No SM_HOST/SM_API_KEY/etc. secrets + # or ENABLE_WINDOWS_SIGNING/KEYLOCKER_KEYPAIR_ALIAS vars exist on this + # repo yet. Re-enable once those land (see .github/workflows/Sign-WithKeyLocker.ps1). + # - name: Sign gddy.exe (DigiCert KeyLocker) + # if: matrix.os == 'windows-latest' && vars.ENABLE_WINDOWS_SIGNING == '1' + # shell: pwsh + # env: + # SM_HOST: ${{ secrets.SM_HOST }} + # SM_API_KEY: ${{ secrets.SM_API_KEY }} + # SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} + # SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE }} + # run: ../.github/workflows/Sign-WithKeyLocker.ps1 -Path "target/${{ matrix.target }}/release/gddy.exe" -KeypairAlias "${{ vars.KEYLOCKER_KEYPAIR_ALIAS }}" + - name: Package gddy shell: bash run: | From e5b99e54b843233824b0797e4639bee742eab28a Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Wed, 2 Sep 2026 15:14:08 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20BSD=20base64=20flag,=20native=20exit-code=20checks,?= =?UTF-8?q?=20credential=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - base64 -d is a GNU flag; macOS ships BSD base64, which requires -D. Both macOS-only decode sites (active keychain-import step and the still-disabled notarize block) now use -D. - Sign-WithKeyLocker.ps1: check exit codes for curl.exe, msiexec, and smctl windows certsync so a failed download/install/certsync fails fast instead of surfacing as a confusing downstream error. ksp register gets a warning instead of a hard failure, since it can exit non-zero on the documented benign "already registered" case. - Clean up the materialized KeyLocker client .p12 and the healthcheck log (which can contain partially-masked credentials) via a try/finally instead of leaving them on disk. --- .github/workflows/Sign-WithKeyLocker.ps1 | 86 +++++++++++++++--------- .github/workflows/release.yml | 4 +- 2 files changed, 55 insertions(+), 35 deletions(-) diff --git a/.github/workflows/Sign-WithKeyLocker.ps1 b/.github/workflows/Sign-WithKeyLocker.ps1 index 577075d0..fbc63a79 100644 --- a/.github/workflows/Sign-WithKeyLocker.ps1 +++ b/.github/workflows/Sign-WithKeyLocker.ps1 @@ -43,45 +43,65 @@ foreach ($v in 'SM_HOST', 'SM_API_KEY', 'SM_CLIENT_CERT_PASSWORD', 'SM_CLIENT_CE } # Materialize the client authentication certificate (.p12) from the base64 -# secret and point SM_CLIENT_CERT_FILE (read by smctl) at it. +# secret and point SM_CLIENT_CERT_FILE (read by smctl) at it. Cleaned up in +# the `finally` block below regardless of how the script exits. $p12 = Join-Path $env:RUNNER_TEMP 'keylocker-client.p12' [IO.File]::WriteAllBytes($p12, [Convert]::FromBase64String($env:SM_CLIENT_CERT_FILE_B64)) $env:SM_CLIENT_CERT_FILE = $p12 -# Install the DigiCert KeyLocker client tools (smctl + KSP + PKCS#11) if not -# already present on this runner. -$smHome = 'C:\Program Files\DigiCert\DigiCert Keylocker Tools' -if (-not (Test-Path (Join-Path $smHome 'smctl.exe'))) { - $msi = Join-Path $env:RUNNER_TEMP 'Keylockertools-windows-x64.msi' - curl.exe -fSs -X GET "$($env:SM_HOST)/signingmanager/api-ui/v1/releases/Keylockertools-windows-x64.msi/download" ` - -H "x-api-key:$($env:SM_API_KEY)" -o $msi - Start-Process msiexec.exe -ArgumentList "/i `"$msi`" /quiet /qn /norestart" -Wait -} +try { + # Install the DigiCert KeyLocker client tools (smctl + KSP + PKCS#11) if + # not already present on this runner. + $smHome = 'C:\Program Files\DigiCert\DigiCert Keylocker Tools' + if (-not (Test-Path (Join-Path $smHome 'smctl.exe'))) { + $msi = Join-Path $env:RUNNER_TEMP 'Keylockertools-windows-x64.msi' + curl.exe -fSs -X GET "$($env:SM_HOST)/signingmanager/api-ui/v1/releases/Keylockertools-windows-x64.msi/download" ` + -H "x-api-key:$($env:SM_API_KEY)" -o $msi + if ($LASTEXITCODE -ne 0) { throw "Sign-WithKeyLocker: KeyLocker tools download failed ($LASTEXITCODE)." } + $install = Start-Process msiexec.exe -ArgumentList "/i `"$msi`" /quiet /qn /norestart" -Wait -PassThru + if ($install.ExitCode -ne 0) { throw "Sign-WithKeyLocker: KeyLocker tools install failed ($($install.ExitCode))." } + } -# `smctl sign` shells out to signtool, which is not on PATH by default. -$signtool = (Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe' -ErrorAction SilentlyContinue | - Sort-Object FullName -Descending | Select-Object -First 1) -if (-not $signtool) { throw 'Sign-WithKeyLocker: signtool.exe not found in the Windows SDK.' } -$env:PATH = "$smHome;$($signtool.Directory.FullName);$env:PATH" + # `smctl sign` shells out to signtool, which is not on PATH by default. + $signtool = (Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe' -ErrorAction SilentlyContinue | + Sort-Object FullName -Descending | Select-Object -First 1) + if (-not $signtool) { throw 'Sign-WithKeyLocker: signtool.exe not found in the Windows SDK.' } + $env:PATH = "$smHome;$($signtool.Directory.FullName);$env:PATH" -# Validate credentials. Redirect output to a file rather than the console: -# `smctl healthcheck` echoes a partially-masked API key / cert password that -# GitHub's exact-match secret masking does not catch. -smctl healthcheck --all *> "$env:RUNNER_TEMP\keylocker-healthcheck.log" -if ($LASTEXITCODE -ne 0) { throw "Sign-WithKeyLocker: smctl healthcheck failed ($LASTEXITCODE) - log withheld to avoid leaking credentials." } + # Validate credentials. Redirect output to a file rather than the console: + # `smctl healthcheck` echoes a partially-masked API key / cert password + # that GitHub's exact-match secret masking does not catch. Removed + # immediately on success; left in place on failure for debugging (the + # runner itself is torn down afterward regardless). + $healthcheckLog = "$env:RUNNER_TEMP\keylocker-healthcheck.log" + smctl healthcheck --all *> $healthcheckLog + if ($LASTEXITCODE -ne 0) { throw "Sign-WithKeyLocker: smctl healthcheck failed ($LASTEXITCODE) - log withheld to avoid leaking credentials." } + Remove-Item $healthcheckLog -ErrorAction SilentlyContinue -# Register the DigiCert KSP and sync the leaf cert into the Windows store. -# (BCryptRegisterProvider 0xc0000035 == already registered; benign.) -smctl windows ksp register -smctl windows certsync --keypair-alias="$KeypairAlias" + # Register the DigiCert KSP and sync the leaf cert into the Windows store. + # ksp register can exit non-zero when the KSP is already registered + # (BCryptRegisterProvider / STATUS_OBJECT_NAME_COLLISION 0xc0000035) — + # benign, so warn rather than fail the job; certsync has no such known + # benign-failure case, so it fails fast like the rest of the script. + smctl windows ksp register + if ($LASTEXITCODE -ne 0) { + Write-Warning "smctl windows ksp register exited $LASTEXITCODE - continuing (expected if the KSP is already registered)" + } + smctl windows certsync --keypair-alias="$KeypairAlias" + if ($LASTEXITCODE -ne 0) { throw "Sign-WithKeyLocker: smctl windows certsync failed ($LASTEXITCODE)." } -foreach ($f in $Path) { - $full = (Resolve-Path $f).Path - Write-Host "::group::sign $full" - smctl sign --keypair-alias "$KeypairAlias" --input $full --verbose - if ($LASTEXITCODE -ne 0) { throw "Sign-WithKeyLocker: smctl sign failed for $full ($LASTEXITCODE)." } - & $signtool.FullName verify /pa /v $full - if ($LASTEXITCODE -ne 0) { throw "Sign-WithKeyLocker: signtool verify failed for $full ($LASTEXITCODE)." } - Write-Host "::endgroup::" + foreach ($f in $Path) { + $full = (Resolve-Path $f).Path + Write-Host "::group::sign $full" + smctl sign --keypair-alias "$KeypairAlias" --input $full --verbose + if ($LASTEXITCODE -ne 0) { throw "Sign-WithKeyLocker: smctl sign failed for $full ($LASTEXITCODE)." } + & $signtool.FullName verify /pa /v $full + if ($LASTEXITCODE -ne 0) { throw "Sign-WithKeyLocker: signtool verify failed for $full ($LASTEXITCODE)." } + Write-Host "::endgroup::" + } + Write-Host "KeyLocker signing complete for: $($Path -join ', ')" +} +finally { + Remove-Item $p12 -ErrorAction SilentlyContinue + Remove-Item Env:\SM_CLIENT_CERT_FILE -ErrorAction SilentlyContinue } -Write-Host "KeyLocker signing complete for: $($Path -join ', ')" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ef37cd8..83a0c18f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,7 +128,7 @@ jobs: security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" P12_PATH="$RUNNER_TEMP/cert.p12" - printf '%s' "$P12_BASE64" | base64 -d > "$P12_PATH" + printf '%s' "$P12_BASE64" | base64 -D > "$P12_PATH" security import "$P12_PATH" \ -k "$KEYCHAIN_PATH" \ -P "$P12_PASSWORD" \ @@ -176,7 +176,7 @@ jobs: # exit 1 # fi # KEY_PATH="$RUNNER_TEMP/notary_key.p8" - # printf '%s' "$API_KEY_BASE64" | base64 -d > "$KEY_PATH" + # printf '%s' "$API_KEY_BASE64" | base64 -D > "$KEY_PATH" # chmod 600 "$KEY_PATH" # # NOTARY_ZIP="$RUNNER_TEMP/notarize.zip"