diff --git a/.github/workflows/Sign-WithKeyLocker.ps1 b/.github/workflows/Sign-WithKeyLocker.ps1 new file mode 100644 index 00000000..fbc63a79 --- /dev/null +++ b/.github/workflows/Sign-WithKeyLocker.ps1 @@ -0,0 +1,107 @@ +<# +.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. 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 + +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" + + # 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. + # 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::" + } + Write-Host "KeyLocker signing complete for: $($Path -join ', ')" +} +finally { + Remove-Item $p12 -ErrorAction SilentlyContinue + Remove-Item Env:\SM_CLIENT_CERT_FILE -ErrorAction SilentlyContinue +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c42fe1a7..83a0c18f 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: |