diff --git a/.github/actions/sign-windows-artifacts/action.yml b/.github/actions/sign-windows-artifacts/action.yml new file mode 100644 index 00000000..cbe847cc --- /dev/null +++ b/.github/actions/sign-windows-artifacts/action.yml @@ -0,0 +1,60 @@ +name: Sign Windows artifacts +description: Azure OIDC login + Trusted Signing of all .exe under target/distrib, then refresh zips so archives contain the signed binaries. + +inputs: + client-id: + required: true + description: Azure AD App Registration client ID (OIDC) + tenant-id: + required: true + description: Azure tenant ID + subscription-id: + required: true + description: Azure subscription ID + endpoint: + required: true + description: Trusted Signing Account endpoint URL + account-name: + required: true + description: Trusted Signing Account name + cert-profile: + required: true + description: Certificate profile name + +runs: + using: composite + steps: + - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + client-id: ${{ inputs.client-id }} + tenant-id: ${{ inputs.tenant-id }} + subscription-id: ${{ inputs.subscription-id }} + + - uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 + with: + endpoint: ${{ inputs.endpoint }} + trusted-signing-account-name: ${{ inputs.account-name }} + certificate-profile-name: ${{ inputs.cert-profile }} + files-folder: target/distrib + files-folder-filter: exe + files-folder-recurse: true + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Refresh zip archives with signed executables + shell: pwsh + run: | + Get-ChildItem -Path "target/distrib" -Filter "*.zip" | ForEach-Object { + $zipPath = $_.FullName + $stagingDir = Join-Path $_.DirectoryName $_.BaseName + if (Test-Path $stagingDir) { + Remove-Item $zipPath -Force + Compress-Archive -Path "$stagingDir\*" -DestinationPath $zipPath + $shaFile = "$zipPath.sha256" + if (Test-Path $shaFile) { + $hash = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLower() + "$hash $($_.Name)" | Set-Content $shaFile -NoNewline + } + } + } diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index 4a414b9f..b085579c 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -13,6 +13,7 @@ concurrency: permissions: actions: read contents: write + id-token: write issues: write pull-requests: write @@ -110,9 +111,14 @@ jobs: matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} runs-on: ${{ matrix.runner }} container: ${{ matrix.container && matrix.container.image || null }} + # Only claim the release env on main so the OIDC token's subject matches + # the AAD federated credential. Other branches get no env, no env secrets, + # and skip signing. + environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + HAS_AZURE_SIGNING: ${{ secrets.AZURE_CLIENT_ID != '' && secrets.AZURE_TENANT_ID != '' && secrets.AZURE_SUBSCRIPTION_ID != '' }} steps: - name: Enable windows longpaths run: git config --global core.longpaths true @@ -160,12 +166,36 @@ jobs: - name: Install dependencies run: ${{ matrix.packages_install }} + - name: Set up MSVC cross-compilation for Windows ARM64 + if: ${{ contains(matrix.targets, 'aarch64-pc-windows-msvc') }} + uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0 + with: + arch: amd64_arm64 + + - name: Set MSVC ARM64 linker for cargo cross-compilation + if: ${{ contains(matrix.targets, 'aarch64-pc-windows-msvc') }} + shell: pwsh + run: | + $link = (Get-Command link.exe -ErrorAction Stop).Source + "CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER=$link" >> $env:GITHUB_ENV + - name: Build artifacts shell: bash run: | dist build --tag="${{ needs.plan.outputs.dist-tag }}" --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json echo "dist ran successfully" + - name: Sign Windows artifacts + if: ${{ runner.os == 'Windows' && env.HAS_AZURE_SIGNING == 'true' }} + uses: ./.github/actions/sign-windows-artifacts + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }} + account-name: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }} + cert-profile: ${{ vars.AZURE_SIGNING_CERT_PROFILE }} + - id: dist-files name: Post-build shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 199bf570..f27a8d37 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,7 @@ on: permissions: contents: write + id-token: write env: CARGO_NET_GIT_FETCH_WITH_CLI: true @@ -95,10 +96,14 @@ jobs: matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} runs-on: ${{ matrix.runner }} container: ${{ matrix.container && matrix.container.image || null }} + # Azure federated identity credentials require an exact-subject match; we + # scope OIDC tokens via this environment instead of branch/tag patterns. + # Azure secrets and signing config live on the environment, not the repo. + environment: release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json - HAS_SSLDOTCOM_SIGNING: ${{ secrets.SSLDOTCOM_USERNAME != '' && secrets.SSLDOTCOM_PASSWORD != '' && secrets.SSLDOTCOM_CREDENTIAL_ID != '' && secrets.SSLDOTCOM_TOTP_SECRET != '' }} + HAS_AZURE_SIGNING: ${{ secrets.AZURE_CLIENT_ID != '' && secrets.AZURE_TENANT_ID != '' && secrets.AZURE_SUBSCRIPTION_ID != '' }} steps: - name: Enable windows longpaths run: git config --global core.longpaths true @@ -146,30 +151,18 @@ jobs: - name: Install dependencies run: ${{ matrix.packages_install }} - - name: Configure SSL.com signing env - if: ${{ runner.os == 'Windows' && env.HAS_SSLDOTCOM_SIGNING == 'true' && !fromJson(needs.plan.outputs.val).announcement_is_prerelease }} - shell: bash - env: - SSLDOTCOM_USERNAME: ${{ secrets.SSLDOTCOM_USERNAME }} - SSLDOTCOM_PASSWORD: ${{ secrets.SSLDOTCOM_PASSWORD }} - SSLDOTCOM_CREDENTIAL_ID: ${{ secrets.SSLDOTCOM_CREDENTIAL_ID }} - SSLDOTCOM_TOTP_SECRET: ${{ secrets.SSLDOTCOM_TOTP_SECRET }} - run: | - write_github_env() { - local key="$1" - local value="$2" - local delimiter="EOF_${key}_$$" - { - echo "${key}<<${delimiter}" - echo "${value}" - echo "${delimiter}" - } >> "$GITHUB_ENV" - } + - name: Set up MSVC cross-compilation for Windows ARM64 + if: ${{ contains(matrix.targets, 'aarch64-pc-windows-msvc') }} + uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0 + with: + arch: amd64_arm64 - write_github_env "SSLDOTCOM_USERNAME" "$SSLDOTCOM_USERNAME" - write_github_env "SSLDOTCOM_PASSWORD" "$SSLDOTCOM_PASSWORD" - write_github_env "SSLDOTCOM_CREDENTIAL_ID" "$SSLDOTCOM_CREDENTIAL_ID" - write_github_env "SSLDOTCOM_TOTP_SECRET" "$SSLDOTCOM_TOTP_SECRET" + - name: Set MSVC ARM64 linker for cargo cross-compilation + if: ${{ contains(matrix.targets, 'aarch64-pc-windows-msvc') }} + shell: pwsh + run: | + $link = (Get-Command link.exe -ErrorAction Stop).Source + "CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER=$link" >> $env:GITHUB_ENV - name: Build artifacts shell: bash @@ -179,6 +172,17 @@ jobs: dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json echo "dist ran successfully" + - name: Sign Windows artifacts + if: ${{ runner.os == 'Windows' && env.HAS_AZURE_SIGNING == 'true' }} + uses: ./.github/actions/sign-windows-artifacts + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }} + account-name: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }} + cert-profile: ${{ vars.AZURE_SIGNING_CERT_PROFILE }} + - id: dist-files name: Post-build shell: bash diff --git a/.github/workflows/test-azure-oidc.yml b/.github/workflows/test-azure-oidc.yml new file mode 100644 index 00000000..1a3800fd --- /dev/null +++ b/.github/workflows/test-azure-oidc.yml @@ -0,0 +1,33 @@ +name: test-azure-oidc + +# Validates that the federated identity credentials and GitHub repo secrets +# are wired up correctly. Does NOT invoke Trusted Signing — only confirms the +# OIDC handshake and that az can authenticate as the configured principal. +# Safe to run before the (paid) Trusted Signing Account is created. + +on: + workflow_dispatch: + +permissions: + id-token: write + contents: read + +jobs: + oidc: + runs-on: ubuntu-latest + # Sandbox AAD app's federated credential trusts this environment only. + environment: sandbox-release + steps: + - name: Azure login (OIDC) + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Confirm authenticated identity + run: | + az account show + echo + echo "Signed-in service principal:" + az ad sp show --id "${{ secrets.AZURE_CLIENT_ID }}" --query "{displayName: displayName, appId: appId}" -o table diff --git a/.github/workflows/test-signing-self-signed.yml b/.github/workflows/test-signing-self-signed.yml new file mode 100644 index 00000000..2db0e60e --- /dev/null +++ b/.github/workflows/test-signing-self-signed.yml @@ -0,0 +1,130 @@ +name: test-signing-self-signed + +# Zero-cost validation of the Windows signing pipeline. +# Mirrors the release.yml signing flow but uses a self-signed certificate +# via signtool directly instead of azure/artifact-signing-action. This proves +# the signing mechanics and the zip-refresh logic work end to end without +# any Azure resources or costs. + +on: + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/test-signing-self-signed.yml" + +permissions: + contents: read + +jobs: + signing-mechanics: + runs-on: windows-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Set up fake distrib layout (mirrors release.yml output) + shell: pwsh + run: | + New-Item -ItemType Directory -Path target/distrib/bt-x86_64-pc-windows-msvc -Force | Out-Null + # Use a real signable PE — copy a system exe so signtool has something legitimate to sign + Copy-Item C:\Windows\System32\where.exe target/distrib/bt-x86_64-pc-windows-msvc/bt.exe + # Build the matching zip that release.yml's "Refresh zip" step expects to find + Compress-Archive -Path target/distrib/bt-x86_64-pc-windows-msvc/* -DestinationPath target/distrib/bt-x86_64-pc-windows-msvc.zip + $hash = (Get-FileHash target/distrib/bt-x86_64-pc-windows-msvc.zip -Algorithm SHA256).Hash.ToLower() + "$hash bt-x86_64-pc-windows-msvc.zip" | Set-Content target/distrib/bt-x86_64-pc-windows-msvc.zip.sha256 -NoNewline + + - name: Create self-signed code-signing certificate + shell: pwsh + run: | + $cert = New-SelfSignedCertificate ` + -Subject "CN=BT Self-Signed Test" ` + -Type CodeSigningCert ` + -CertStoreLocation Cert:\CurrentUser\My ` + -KeyUsage DigitalSignature ` + -HashAlgorithm SHA256 ` + -NotAfter (Get-Date).AddDays(7) + $pfxPath = Join-Path $env:RUNNER_TEMP "test-signing.pfx" + $pwd = ConvertTo-SecureString -String "1d4cbcfd39a6560c5edabc499725ed1e7d4a7d6d72266803c91398821e06b744" -Force -AsPlainText + Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($cert.Thumbprint)" -FilePath $pfxPath -Password $pwd | Out-Null + "PFX_PATH=$pfxPath" >> $env:GITHUB_ENV + + - name: Locate signtool + shell: pwsh + run: | + $signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 + if (-not $signtool) { throw "signtool.exe not found on runner" } + "SIGNTOOL=$($signtool.FullName)" >> $env:GITHUB_ENV + + - name: Sign exe (mirrors azure/artifact-signing-action behavior) + shell: pwsh + run: | + Get-ChildItem -Path target/distrib -Filter *.exe -Recurse | ForEach-Object { + Write-Host "Signing: $($_.FullName)" + & $env:SIGNTOOL sign ` + /f $env:PFX_PATH ` + /p "1d4cbcfd39a6560c5edabc499725ed1e7d4a7d6d72266803c91398821e06b744" ` + /fd SHA256 ` + /tr http://timestamp.digicert.com ` + /td SHA256 ` + $_.FullName + if ($LASTEXITCODE -ne 0) { throw "signtool sign failed" } + } + + - name: Verify signature on raw exe + shell: pwsh + run: | + # Get-AuthenticodeSignature returns a structured status. We accept + # NotTrusted (untrusted self-signed root, but signature itself is valid + # and timestamped) — that's the expected state for this test. signtool + # verify can't be used because every supported way to install a self- + # signed cert into the runner's Root store hangs on a CryptUI prompt. + # Production signing uses Trusted Signing, which chains to a trusted + # Microsoft root and verifies cleanly without any of this. + $sig = Get-AuthenticodeSignature target/distrib/bt-x86_64-pc-windows-msvc/bt.exe + if ($sig.Status -notin @('Valid', 'NotTrusted')) { + throw "Signature invalid (status=$($sig.Status)): $($sig.StatusMessage)" + } + if (-not $sig.SignerCertificate) { throw "Signature missing signer certificate" } + if (-not $sig.TimeStamperCertificate) { throw "Signature missing RFC3161 timestamp" } + Write-Host "Raw exe signature OK (status=$($sig.Status), signer=$($sig.SignerCertificate.Subject))" + + - name: Refresh zip archives with signed executables (verbatim from release.yml) + shell: pwsh + run: | + Get-ChildItem -Path "target/distrib" -Filter "*.zip" | ForEach-Object { + $zipPath = $_.FullName + $stagingDir = Join-Path $_.DirectoryName $_.BaseName + if (Test-Path $stagingDir) { + Remove-Item $zipPath -Force + Compress-Archive -Path "$stagingDir\*" -DestinationPath $zipPath + $shaFile = "$zipPath.sha256" + if (Test-Path $shaFile) { + $hash = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLower() + "$hash $($_.Name)" | Set-Content $shaFile -NoNewline + } + } + } + + - name: Verify signature survives the zip refresh + shell: pwsh + run: | + $verifyDir = Join-Path $env:RUNNER_TEMP "verify" + New-Item -ItemType Directory -Path $verifyDir -Force | Out-Null + Expand-Archive -Path target/distrib/bt-x86_64-pc-windows-msvc.zip -DestinationPath $verifyDir -Force + $sig = Get-AuthenticodeSignature (Join-Path $verifyDir "bt.exe") + if ($sig.Status -notin @('Valid', 'NotTrusted')) { + throw "Signature lost after zip refresh (status=$($sig.Status)): $($sig.StatusMessage)" + } + if (-not $sig.SignerCertificate) { throw "Signature missing signer certificate after zip refresh" } + if (-not $sig.TimeStamperCertificate) { throw "Signature missing RFC3161 timestamp after zip refresh" } + Write-Host "Signature survived the zip refresh (status=$($sig.Status))" + + - name: Verify sha256 file matches the refreshed zip + shell: pwsh + run: | + $expected = (Get-Content target/distrib/bt-x86_64-pc-windows-msvc.zip.sha256 -Raw).Split(' ')[0].Trim() + $actual = (Get-FileHash target/distrib/bt-x86_64-pc-windows-msvc.zip -Algorithm SHA256).Hash.ToLower() + if ($expected -ne $actual) { throw "sha256 mismatch: expected=$expected actual=$actual" } + Write-Host "sha256 file matches the refreshed zip" diff --git a/dist-workspace.toml b/dist-workspace.toml index 81796755..c99c51e7 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -13,7 +13,6 @@ ci = "github" create-release = true # Which actions to run on pull requests pr-run-mode = "plan" -ssldotcom-windows-sign = "test" # The installers to generate for each app installers = ["shell", "powershell", "homebrew"] homepage = "https://github.com/braintrustdata/bt" @@ -29,4 +28,4 @@ windows-archive = ".zip" install-success-msg = "" [dist.github-custom-runners] -aarch64-pc-windows-msvc = "windows-11-arm" +aarch64-pc-windows-msvc = "windows-2022" # azure/artifact-signing-action doesn't run on ARM, so we need to cross-compile bt.exe for ARM64 and sign it on windows amd64