diff --git a/.github/workflows/companion-signing-readiness.yml b/.github/workflows/companion-signing-readiness.yml index 2f6e48f8..d1393631 100644 --- a/.github/workflows/companion-signing-readiness.yml +++ b/.github/workflows/companion-signing-readiness.yml @@ -60,20 +60,119 @@ jobs: windows-authenticode: runs-on: windows-latest + environment: windows-code-signing + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v7 - - name: Validate Windows Authenticode credentials + - name: Select Windows signing provider shell: pwsh env: + STACKCHAN_WINDOWS_SIGNING_PROVIDER: ${{ vars.STACKCHAN_WINDOWS_SIGNING_PROVIDER }} + STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ENDPOINT: ${{ vars.STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ENDPOINT }} + STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ACCOUNT: ${{ vars.STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ACCOUNT }} + STACKCHAN_WINDOWS_ARTIFACT_SIGNING_PROFILE: ${{ vars.STACKCHAN_WINDOWS_ARTIFACT_SIGNING_PROFILE }} + STACKCHAN_AZURE_CLIENT_ID: ${{ vars.STACKCHAN_AZURE_CLIENT_ID }} + STACKCHAN_AZURE_TENANT_ID: ${{ vars.STACKCHAN_AZURE_TENANT_ID }} + STACKCHAN_AZURE_SUBSCRIPTION_ID: ${{ vars.STACKCHAN_AZURE_SUBSCRIPTION_ID }} STACKCHAN_WINDOWS_PFX_B64: ${{ secrets.STACKCHAN_WINDOWS_PFX_B64 }} STACKCHAN_WINDOWS_PFX_PASSWORD: ${{ secrets.STACKCHAN_WINDOWS_PFX_PASSWORD }} run: | - ./tools/check_desktop_release_signing_readiness.ps1 ` - -Platform windows ` - -RequireReady ` - -RequireNativeToolchain ` - -Json + $provider = ([string]$env:STACKCHAN_WINDOWS_SIGNING_PROVIDER).Trim() + if ($provider -notin @("azure-artifact-signing", "pfx")) { + throw "STACKCHAN_WINDOWS_SIGNING_PROVIDER must be azure-artifact-signing or pfx." + } + if ($provider -eq "azure-artifact-signing") { + $required = @( + "STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ENDPOINT", + "STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ACCOUNT", + "STACKCHAN_WINDOWS_ARTIFACT_SIGNING_PROFILE", + "STACKCHAN_AZURE_CLIENT_ID", + "STACKCHAN_AZURE_TENANT_ID", + "STACKCHAN_AZURE_SUBSCRIPTION_ID" + ) + foreach ($name in $required) { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { + throw "$name is required for Azure Artifact Signing readiness." + } + } + $endpoint = $null + if (-not [Uri]::TryCreate($env:STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ENDPOINT, [UriKind]::Absolute, [ref]$endpoint) -or + $endpoint.Scheme -ne "https" -or + $endpoint.Host -notmatch '^[a-z0-9-]+\.codesigning\.azure\.net$') { + throw "STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ENDPOINT must be an HTTPS Azure Artifact Signing endpoint." + } + foreach ($name in @("STACKCHAN_AZURE_CLIENT_ID", "STACKCHAN_AZURE_TENANT_ID", "STACKCHAN_AZURE_SUBSCRIPTION_ID")) { + $value = [Environment]::GetEnvironmentVariable($name) + $parsed = [guid]::Empty + if (-not [guid]::TryParse($value, [ref]$parsed)) { throw "$name must be a GUID." } + } + } else { + ./tools/check_desktop_release_signing_readiness.ps1 ` + -Platform windows ` + -RequireReady ` + -RequireNativeToolchain ` + -Json + } + + - name: Build Azure Artifact Signing readiness probe + if: vars.STACKCHAN_WINDOWS_SIGNING_PROVIDER == 'azure-artifact-signing' + shell: pwsh + run: | + $probeRoot = Join-Path $env:RUNNER_TEMP "stackchan-windows-signing-probe" + $publishRoot = Join-Path $probeRoot "publish" + dotnet new console --name stackchan-windows-signing-probe --output $probeRoot --framework net8.0 --force + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet publish $probeRoot --configuration Release --runtime win-x64 --self-contained false --output $publishRoot + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $probe = Get-Item (Join-Path $publishRoot "stackchan-windows-signing-probe.exe") + "STACKCHAN_WINDOWS_SIGNING_PROBE=$($probe.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Azure login with GitHub OIDC + if: vars.STACKCHAN_WINDOWS_SIGNING_PROVIDER == 'azure-artifact-signing' + uses: azure/login@v3 + with: + client-id: ${{ vars.STACKCHAN_AZURE_CLIENT_ID }} + tenant-id: ${{ vars.STACKCHAN_AZURE_TENANT_ID }} + subscription-id: ${{ vars.STACKCHAN_AZURE_SUBSCRIPTION_ID }} + + - name: Sign readiness probe with Azure Artifact Signing + if: vars.STACKCHAN_WINDOWS_SIGNING_PROVIDER == 'azure-artifact-signing' + uses: azure/artifact-signing-action@v2 + with: + endpoint: ${{ vars.STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ENDPOINT }} + signing-account-name: ${{ vars.STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ vars.STACKCHAN_WINDOWS_ARTIFACT_SIGNING_PROFILE }} + files: ${{ env.STACKCHAN_WINDOWS_SIGNING_PROBE }} + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + description: Stackchan Companion signing readiness probe + description-url: https://github.com/RobVanProd/stackchan_alive + + - name: Verify Azure Artifact Signing readiness probe + if: vars.STACKCHAN_WINDOWS_SIGNING_PROVIDER == 'azure-artifact-signing' + shell: pwsh + run: | + $signature = Get-AuthenticodeSignature -LiteralPath $env:STACKCHAN_WINDOWS_SIGNING_PROBE + if ($signature.Status -ne "Valid") { + throw "Azure Artifact Signing probe did not have a valid Authenticode signature: $($signature.StatusMessage)" + } + if ($null -eq $signature.TimeStamperCertificate) { + throw "Azure Artifact Signing probe did not have an RFC3161 timestamp." + } + [ordered]@{ + schema = "stackchan.windows-artifact-signing-readiness.v1" + status = "windows-artifact-signing-ready" + provider = "azure-artifact-signing" + oidc = $true + staticClientSecret = $false + signerSubject = [string]$signature.SignerCertificate.Subject + signerThumbprint = [string]$signature.SignerCertificate.Thumbprint + timestampSubject = [string]$signature.TimeStamperCertificate.Subject + } | ConvertTo-Json -Depth 4 macos-developer-id: runs-on: macos-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2bb22437..78b5fb61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -236,11 +236,9 @@ jobs: "STACKCHAN_DESKTOP_PYTHON_RUNTIME_ROOT=$runtimeRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Validate production desktop signing credentials - if: matrix.platform == 'windows' || matrix.platform == 'macos' + if: matrix.platform == 'macos' shell: pwsh env: - STACKCHAN_WINDOWS_PFX_B64: ${{ secrets.STACKCHAN_WINDOWS_PFX_B64 }} - STACKCHAN_WINDOWS_PFX_PASSWORD: ${{ secrets.STACKCHAN_WINDOWS_PFX_PASSWORD }} STACKCHAN_MACOS_CERTIFICATE_B64: ${{ secrets.STACKCHAN_MACOS_CERTIFICATE_B64 }} STACKCHAN_MACOS_CERTIFICATE_PASSWORD: ${{ secrets.STACKCHAN_MACOS_CERTIFICATE_PASSWORD }} STACKCHAN_MACOS_SIGNING_IDENTITY: ${{ secrets.STACKCHAN_MACOS_SIGNING_IDENTITY }} @@ -248,16 +246,12 @@ jobs: STACKCHAN_MACOS_NOTARIZATION_PASSWORD: ${{ secrets.STACKCHAN_MACOS_NOTARIZATION_PASSWORD }} STACKCHAN_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.STACKCHAN_MACOS_NOTARIZATION_TEAM_ID }} run: | - $arguments = @{ - Platform = "${{ matrix.platform }}" - RequireReady = $true - RequireNativeToolchain = $true - Json = $true - } - if ("${{ matrix.platform }}" -eq "macos") { - $arguments.ValidateAppleNotaryCredentials = $true - } - ./tools/check_desktop_release_signing_readiness.ps1 @arguments + ./tools/check_desktop_release_signing_readiness.ps1 ` + -Platform macos ` + -RequireReady ` + -RequireNativeToolchain ` + -ValidateAppleNotaryCredentials ` + -Json - name: Prepare macOS Developer ID keychain if: matrix.platform == 'macos' @@ -329,37 +323,8 @@ jobs: ./gradlew :app-desktop:notarizeDmg if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Authenticode sign Windows package - if: matrix.platform == 'windows' - shell: pwsh - env: - STACKCHAN_WINDOWS_PFX_B64: ${{ secrets.STACKCHAN_WINDOWS_PFX_B64 }} - STACKCHAN_WINDOWS_PFX_PASSWORD: ${{ secrets.STACKCHAN_WINDOWS_PFX_PASSWORD }} - run: | - foreach ($name in @("STACKCHAN_WINDOWS_PFX_B64", "STACKCHAN_WINDOWS_PFX_PASSWORD")) { - if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { - throw "$name is required for a tagged Windows companion release." - } - } - $packages = @(Get-ChildItem "companion/app-desktop/build/compose/binaries/main/msi" -File -Filter "*.msi") - if ($packages.Count -ne 1) { throw "Expected one Windows MSI; found $($packages.Count)." } - $pfxPath = Join-Path $env:RUNNER_TEMP "stackchan-authenticode.pfx" - try { - [IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:STACKCHAN_WINDOWS_PFX_B64)) - $signTool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -File -Filter signtool.exe | - Where-Object { $_.FullName -match '[\\/]x64[\\/]signtool\.exe$' } | - Sort-Object FullName -Descending | - Select-Object -First 1 - if ($null -eq $signTool) { throw "signtool.exe was not found in the Windows SDK." } - & $signTool.FullName sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /f $pfxPath /p $env:STACKCHAN_WINDOWS_PFX_PASSWORD /d "Stackchan Companion" /du "https://github.com/RobVanProd/stackchan_alive" $packages[0].FullName - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - & $signTool.FullName verify /pa /all /tw /v $packages[0].FullName - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - } finally { - Remove-Item -LiteralPath $pfxPath -Force -ErrorAction SilentlyContinue - } - - name: Launch exact native desktop package headlessly + if: matrix.platform != 'windows' shell: pwsh run: | $packages = @(Get-ChildItem "companion/app-desktop/build/compose/binaries/main/${{ matrix.package_dir }}" -File -Filter "*.${{ matrix.extension }}") @@ -374,6 +339,7 @@ jobs: -Json - name: Export native desktop package evidence + if: matrix.platform != 'windows' shell: pwsh run: | $packages = @(Get-ChildItem "companion/app-desktop/build/compose/binaries/main/${{ matrix.package_dir }}" -File -Filter "*.${{ matrix.extension }}") @@ -412,6 +378,7 @@ jobs: } - name: Upload desktop release input + if: matrix.platform != 'windows' uses: actions/upload-artifact@v7 with: name: companion-desktop-release-${{ matrix.platform }} @@ -422,8 +389,189 @@ jobs: output/desktop-python-runtime/${{ matrix.platform }}-package-evidence.json if-no-files-found: error + - name: Upload unsigned Windows signing input + if: matrix.platform == 'windows' + uses: actions/upload-artifact@v7 + with: + name: companion-desktop-unsigned-windows + path: | + companion/app-desktop/build/compose/binaries/main/msi/*.msi + companion/app-desktop/build/generated/native-app-resources/common/python-runtime/** + output/desktop-python-runtime/windows-prepare.json + if-no-files-found: error + + companion-windows-sign: + needs: companion-desktop-release + runs-on: windows-latest + environment: windows-code-signing + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v7 + + - name: Download unsigned Windows signing input + uses: actions/download-artifact@v7 + with: + name: companion-desktop-unsigned-windows + path: . + + - name: Select Windows signing provider + shell: pwsh + env: + STACKCHAN_WINDOWS_SIGNING_PROVIDER: ${{ vars.STACKCHAN_WINDOWS_SIGNING_PROVIDER }} + STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ENDPOINT: ${{ vars.STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ENDPOINT }} + STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ACCOUNT: ${{ vars.STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ACCOUNT }} + STACKCHAN_WINDOWS_ARTIFACT_SIGNING_PROFILE: ${{ vars.STACKCHAN_WINDOWS_ARTIFACT_SIGNING_PROFILE }} + STACKCHAN_AZURE_CLIENT_ID: ${{ vars.STACKCHAN_AZURE_CLIENT_ID }} + STACKCHAN_AZURE_TENANT_ID: ${{ vars.STACKCHAN_AZURE_TENANT_ID }} + STACKCHAN_AZURE_SUBSCRIPTION_ID: ${{ vars.STACKCHAN_AZURE_SUBSCRIPTION_ID }} + STACKCHAN_WINDOWS_PFX_B64: ${{ secrets.STACKCHAN_WINDOWS_PFX_B64 }} + STACKCHAN_WINDOWS_PFX_PASSWORD: ${{ secrets.STACKCHAN_WINDOWS_PFX_PASSWORD }} + run: | + $provider = ([string]$env:STACKCHAN_WINDOWS_SIGNING_PROVIDER).Trim() + if ($provider -notin @("azure-artifact-signing", "pfx")) { + throw "STACKCHAN_WINDOWS_SIGNING_PROVIDER must be azure-artifact-signing or pfx." + } + if ($provider -eq "azure-artifact-signing") { + $required = @( + "STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ENDPOINT", + "STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ACCOUNT", + "STACKCHAN_WINDOWS_ARTIFACT_SIGNING_PROFILE", + "STACKCHAN_AZURE_CLIENT_ID", + "STACKCHAN_AZURE_TENANT_ID", + "STACKCHAN_AZURE_SUBSCRIPTION_ID" + ) + foreach ($name in $required) { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { + throw "$name is required for a tagged Windows release using Azure Artifact Signing." + } + } + $endpoint = $null + if (-not [Uri]::TryCreate($env:STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ENDPOINT, [UriKind]::Absolute, [ref]$endpoint) -or + $endpoint.Scheme -ne "https" -or + $endpoint.Host -notmatch '^[a-z0-9-]+\.codesigning\.azure\.net$') { + throw "STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ENDPOINT must be an HTTPS Azure Artifact Signing endpoint." + } + foreach ($name in @("STACKCHAN_AZURE_CLIENT_ID", "STACKCHAN_AZURE_TENANT_ID", "STACKCHAN_AZURE_SUBSCRIPTION_ID")) { + $value = [Environment]::GetEnvironmentVariable($name) + $parsed = [guid]::Empty + if (-not [guid]::TryParse($value, [ref]$parsed)) { throw "$name must be a GUID." } + } + } else { + ./tools/check_desktop_release_signing_readiness.ps1 ` + -Platform windows ` + -RequireReady ` + -RequireNativeToolchain ` + -Json + } + $packages = @(Get-ChildItem "companion/app-desktop/build/compose/binaries/main/msi" -File -Filter "*.msi") + if ($packages.Count -ne 1) { throw "Expected one Windows MSI; found $($packages.Count)." } + "STACKCHAN_WINDOWS_MSI_PATH=$($packages[0].FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Azure login with GitHub OIDC + if: vars.STACKCHAN_WINDOWS_SIGNING_PROVIDER == 'azure-artifact-signing' + uses: azure/login@v3 + with: + client-id: ${{ vars.STACKCHAN_AZURE_CLIENT_ID }} + tenant-id: ${{ vars.STACKCHAN_AZURE_TENANT_ID }} + subscription-id: ${{ vars.STACKCHAN_AZURE_SUBSCRIPTION_ID }} + + - name: Sign Windows package with Azure Artifact Signing + if: vars.STACKCHAN_WINDOWS_SIGNING_PROVIDER == 'azure-artifact-signing' + uses: azure/artifact-signing-action@v2 + with: + endpoint: ${{ vars.STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ENDPOINT }} + signing-account-name: ${{ vars.STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ vars.STACKCHAN_WINDOWS_ARTIFACT_SIGNING_PROFILE }} + files: ${{ env.STACKCHAN_WINDOWS_MSI_PATH }} + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + description: Stackchan Companion + description-url: https://github.com/RobVanProd/stackchan_alive + + - name: Authenticode sign Windows package with PFX fallback + if: vars.STACKCHAN_WINDOWS_SIGNING_PROVIDER == 'pfx' + shell: pwsh + env: + STACKCHAN_WINDOWS_PFX_B64: ${{ secrets.STACKCHAN_WINDOWS_PFX_B64 }} + STACKCHAN_WINDOWS_PFX_PASSWORD: ${{ secrets.STACKCHAN_WINDOWS_PFX_PASSWORD }} + run: | + $pfxPath = Join-Path $env:RUNNER_TEMP "stackchan-authenticode.pfx" + try { + [IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:STACKCHAN_WINDOWS_PFX_B64)) + $signTool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -File -Filter signtool.exe | + Where-Object { $_.FullName -match '[\\/]x64[\\/]signtool\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 + if ($null -eq $signTool) { throw "signtool.exe was not found in the Windows SDK." } + & $signTool.FullName sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /f $pfxPath /p $env:STACKCHAN_WINDOWS_PFX_PASSWORD /d "Stackchan Companion" /du "https://github.com/RobVanProd/stackchan_alive" $env:STACKCHAN_WINDOWS_MSI_PATH + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } finally { + Remove-Item -LiteralPath $pfxPath -Force -ErrorAction SilentlyContinue + } + + - name: Verify timestamped Windows Authenticode package + shell: pwsh + run: | + $signature = Get-AuthenticodeSignature -LiteralPath $env:STACKCHAN_WINDOWS_MSI_PATH + if ($signature.Status -ne "Valid") { + throw "Windows MSI did not have a valid Authenticode signature: $($signature.StatusMessage)" + } + if ($null -eq $signature.TimeStamperCertificate) { + throw "Windows MSI did not have an RFC3161 timestamp." + } + $signTool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -File -Filter signtool.exe | + Where-Object { $_.FullName -match '[\\/]x64[\\/]signtool\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 + if ($null -eq $signTool) { throw "signtool.exe was not found in the Windows SDK." } + & $signTool.FullName verify /pa /all /tw /v $env:STACKCHAN_WINDOWS_MSI_PATH + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Launch exact signed Windows package headlessly + shell: pwsh + run: | + ./tools/test_desktop_package_launch.ps1 ` + -Platform windows ` + -PackagePath $env:STACKCHAN_WINDOWS_MSI_PATH ` + -ExtractionRoot (Join-Path $env:RUNNER_TEMP "stackchan-package-extraction-windows") ` + -OutPath "output/desktop-python-runtime/windows-package-launch.json" ` + -Json + + - name: Export signed Windows package evidence + shell: pwsh + run: | + ./tools/export_desktop_package_evidence.ps1 ` + -Platform windows ` + -PackagePath $env:STACKCHAN_WINDOWS_MSI_PATH ` + -RuntimePrepareJsonPath "output/desktop-python-runtime/windows-prepare.json" ` + -ProcessedRuntimeRoot "companion/app-desktop/build/generated/native-app-resources/common/python-runtime" ` + -PackageExtractionRoot (Join-Path $env:RUNNER_TEMP "stackchan-package-extraction-windows") ` + -LaunchEvidencePath "output/desktop-python-runtime/windows-package-launch.json" ` + -Version "${{ github.ref_name }}" ` + -Commit "${{ github.sha }}" ` + -OutPath "output/desktop-python-runtime/windows-package-evidence.json" ` + -RequireInstallerPayload ` + -RequireLaunchEvidence ` + -RequireDistributionTrust ` + -UseExistingPackageExtraction ` + -Json + + - name: Upload signed Windows release input + uses: actions/upload-artifact@v7 + with: + name: companion-desktop-release-windows + path: | + companion/app-desktop/build/compose/binaries/main/msi/*.msi + output/desktop-python-runtime/windows-prepare.json + output/desktop-python-runtime/windows-package-launch.json + output/desktop-python-runtime/windows-package-evidence.json + if-no-files-found: error + release: - needs: [companion-android-release, companion-android-emulator-smoke, companion-desktop-release] + needs: [companion-android-release, companion-android-emulator-smoke, companion-desktop-release, companion-windows-sign] runs-on: windows-latest steps: - uses: actions/checkout@v7 diff --git a/docs/COMPANION_CROSS_PLATFORM_PLAN.md b/docs/COMPANION_CROSS_PLATFORM_PLAN.md index 7e8a2530..0c7c5fda 100644 --- a/docs/COMPANION_CROSS_PLATFORM_PLAN.md +++ b/docs/COMPANION_CROSS_PLATFORM_PLAN.md @@ -298,14 +298,12 @@ pre-existing Stackchan installation must be explicitly replaced with `-AllowRepl records the pre-install registration, its successful uninstall, and the post-install registration so same-version MSI maintenance cannot be mistaken for a fresh exact-package install. -Repository secrets required before the first public tag: +Android and macOS file-based repository secrets required before the first public tag: - `STACKCHAN_ANDROID_KEYSTORE_B64` - `STACKCHAN_ANDROID_KEYSTORE_PASSWORD` - `STACKCHAN_ANDROID_KEY_ALIAS` - `STACKCHAN_ANDROID_KEY_PASSWORD` -- `STACKCHAN_WINDOWS_PFX_B64` -- `STACKCHAN_WINDOWS_PFX_PASSWORD` - `STACKCHAN_MACOS_CERTIFICATE_B64` - `STACKCHAN_MACOS_CERTIFICATE_PASSWORD` - `STACKCHAN_MACOS_SIGNING_IDENTITY` @@ -313,8 +311,16 @@ Repository secrets required before the first public tag: - `STACKCHAN_MACOS_NOTARIZATION_PASSWORD` - `STACKCHAN_MACOS_NOTARIZATION_TEAM_ID` +The preferred Windows path is Azure Artifact Signing with GitHub OIDC. The +`windows-code-signing` environment holds only non-secret variables for provider, endpoint, +account, certificate profile, Azure client ID, tenant ID, and subscription ID. Azure holds the +private key in a managed HSM and GitHub exchanges an environment-scoped OIDC token; there is no +PFX, static client secret, or password file to back up. An existing publicly trusted exportable +PFX remains an explicit fallback through `STACKCHAN_WINDOWS_PFX_B64` and +`STACKCHAN_WINDOWS_PFX_PASSWORD`, but a self-signed certificate is never a production substitute. + Back up the upload keystore outside the repository; losing it breaks Android update -continuity. Back up the Windows signing PFX and macOS Developer ID PKCS#12 independently as well. +continuity. Back up any PFX fallback and the macOS Developer ID PKCS#12 independently as well. Run `tools/check_release_credential_hygiene.cmd -Json` before provisioning or rotating those files; it fails if private-key bundle extensions are not ignored, if one is tracked, or if a tracked text file contains a private-key marker. Release packaging and companion CI enforce the same boundary, @@ -322,12 +328,15 @@ and the checker reports paths only rather than credential contents. The tag workflow now fails closed unless the MSI has a valid timestamped Authenticode signature and the DMG contains a Developer ID signed app accepted by Gatekeeper plus a stapled Apple notarization ticket. Every final asset also receives a GitHub Sigstore-backed provenance attestation before the -prerelease is created. The repository currently has none of these Actions secrets configured, so a -consumer tag remains externally blocked until the owner provisions and backs them up. +prerelease is created. The Windows Artifact Signing account, identity validation, certificate +profile, OIDC federation, and GitHub environment variables are external prerequisites. Android +upload signing and macOS Developer ID/notarization remain independently blocked until their owner +credentials are provisioned. Once provisioned, the manual `Companion Signing Readiness` workflow validates the Android upload keystore, selected private-key entry, passwords, RSA 4096-bit minimum, non-debug subject, and Play -certificate lifetime in temporary runner storage. It also uses the Windows private code-signing -certificate and native SignTool to sign and verify a temporary executable, imports the macOS +certificate lifetime in temporary runner storage. It also signs and timestamp-verifies a Windows +probe through Azure Artifact Signing with secretless OIDC, or through the explicitly selected PFX +fallback, and imports the macOS Developer ID identity into a temporary keychain to sign and verify a hardened-runtime probe, requires each desktop certificate chain to terminate at a native-host trusted root, and validates Apple notarization authentication without publishing any artifact. The tag workflow repeats these @@ -468,7 +477,7 @@ Until then the label stays "fake engine". **C8 — Distribution hardening.** The native all-platform tag workflow, strict package evidence, and operator target-install collector/checker contracts are source-complete. Remaining work is upload-keystore provisioning, -Windows/macOS signing credential provisioning, native tag evidence, executing the three target +Windows Artifact Signing identity/OIDC provisioning, macOS signing credential provisioning, native tag evidence, executing the three target installs and human reviews, and the first end-to-end tagged prerelease and promotion. Conveyor or another updater may be evaluated only after the native packages are accepted. *Gate C8:* one tag publishes verified APK/AAB/MSI/DEB/DMG artifacts, the Android artifacts diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index d8f8fe56..b141da05 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -345,10 +345,39 @@ the embedded test firmware, renders preview media, creates and verifies an audit builds upload-signed Android APK/AAB artifacts, builds runtime-bundled Windows MSI, Linux DEB, and macOS DMG packages on their native runners, and attaches the complete verified asset set to one GitHub release. Before the first public companion tag, provision the four -`STACKCHAN_ANDROID_*` Actions secrets documented in `ANDROID_PLAY_RELEASE.md`, plus: +`STACKCHAN_ANDROID_*` Actions secrets documented in `ANDROID_PLAY_RELEASE.md`. For Windows, +the preferred production route is Azure Artifact Signing with GitHub OIDC. Create the +`windows-code-signing` GitHub environment and set these environment variables: + +- `STACKCHAN_WINDOWS_SIGNING_PROVIDER=azure-artifact-signing` +- `STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ENDPOINT` +- `STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ACCOUNT` +- `STACKCHAN_WINDOWS_ARTIFACT_SIGNING_PROFILE` +- `STACKCHAN_AZURE_CLIENT_ID` +- `STACKCHAN_AZURE_TENANT_ID` +- `STACKCHAN_AZURE_SUBSCRIPTION_ID` + +The three Azure IDs and Artifact Signing account metadata are identifiers, not private keys. +The Entra application must have a federated credential whose subject is +`repo:RobVanProd/stackchan_alive:environment:windows-code-signing`, and its service principal +must have the `Artifact Signing Certificate Profile Signer` role on the selected certificate +profile. The workflow requests a short-lived token with `id-token: write`; do not create or store +an Azure client secret. Configure the GitHub environment to allow the reviewed release branch and +version tags before dispatching readiness or a release. + +The explicit legacy fallback is `STACKCHAN_WINDOWS_SIGNING_PROVIDER=pfx` plus these Actions +secrets: - `STACKCHAN_WINDOWS_PFX_B64` - `STACKCHAN_WINDOWS_PFX_PASSWORD` + +Use the fallback only when a currently valid publicly trusted certificate is already available as +an exportable PFX. New publicly trusted OV certificates generally require hardware or cloud HSM +key storage, so do not assume a newly issued certificate can be exported. A self-signed certificate +never satisfies the public release gate. + +macOS remains a separate release gate and uses: + - `STACKCHAN_MACOS_CERTIFICATE_B64` - `STACKCHAN_MACOS_CERTIFICATE_PASSWORD` - `STACKCHAN_MACOS_SIGNING_IDENTITY` @@ -356,11 +385,13 @@ to one GitHub release. Before the first public companion tag, provision the four - `STACKCHAN_MACOS_NOTARIZATION_PASSWORD` - `STACKCHAN_MACOS_NOTARIZATION_TEAM_ID` -The Windows certificate must be a trusted code-signing certificate exported as a password-protected -PFX. The macOS certificate must be a Developer ID Application certificate exported as a +Azure Artifact Signing keeps the Windows private key in Microsoft's managed HSM service and the +workflow signs only the MSI digest. Its RFC3161 timestamp is still mandatory. The macOS certificate +must be a Developer ID Application certificate exported as a password-protected PKCS#12 file; notarization uses an app-specific Apple password. Keep independent -offline backups and do not add any certificate or password to the repository. The tag workflow -fails before publishing when any signing credential or native trust proof is absent. +offline backups of file-based credentials and do not add any certificate or password to the +repository. The tag workflow fails before publishing when any signing identity, provider metadata, +or native trust proof is absent. Before provisioning or rotating any signing material, run the source-checkout hygiene gate: @@ -373,8 +404,8 @@ bundle extensions remain ignored and untracked, and that no tracked text file co marker. `package_release.ps1` runs the same gate before building, while companion CI runs its negative contract tests. The report exposes paths and status only, never credential contents. -After provisioning all twelve companion signing secrets (four Android, two Windows, and six -macOS), run the manual **Companion Signing Readiness** workflow before creating a tag: +After provisioning Android, the selected Windows provider, and macOS, run the manual +**Companion Signing Readiness** workflow before creating a tag: ```powershell gh workflow run companion-signing-readiness.yml --ref @@ -384,16 +415,18 @@ gh run watch (gh run list --workflow companion-signing-readiness.yml --limit 1 - That workflow has read-only repository permission and does not build, upload, or publish release assets. Its Android job decodes the upload keystore only into runner temporary storage and verifies the selected private-key entry, both passwords, RSA 4096-bit minimum, non-debug subject, and Play -certificate lifetime. Its native Windows job signs and verifies a temporary executable with the -private code-signing certificate and SignTool; its native macOS job imports the Developer ID +certificate lifetime. Its native Windows job either signs a temporary executable through Azure +Artifact Signing using GitHub OIDC or exercises the explicit PFX fallback with SignTool. Both paths +require a valid trusted Authenticode signature and RFC3161 timestamp. Its native macOS job imports the Developer ID identity into a temporary keychain, signs and verifies a temporary hardened-runtime executable, and asks Apple `notarytool` to authenticate the configured account, app-specific password, and team ID. The native desktop jobs also require the certificate chain to terminate at a root trusted by that host. The same strict preflights run again in the tag workflow before packaging. For a local desktop credential-material check, set the corresponding environment variables and run `tools\check_desktop_release_signing_readiness.cmd -Platform windows|macos -RequireReady -Json`; -add `-RequireNativeToolchain` only on the matching OS. The checker emits certificate metadata and -status only, never PKCS#12 bytes or passwords. +add `-RequireNativeToolchain` only on the matching OS. The local Windows checker covers the PFX +fallback; the cloud path is proven by the manual workflow's real signed probe. The checker emits +certificate metadata and status only, never PKCS#12 bytes or passwords. If GitHub Actions cannot run, the manual helper remains available for a firmware-only recovery publication: diff --git a/tools/test_desktop_release_signing_readiness_contract.ps1 b/tools/test_desktop_release_signing_readiness_contract.ps1 index c55cc10a..2a8b6e77 100644 --- a/tools/test_desktop_release_signing_readiness_contract.ps1 +++ b/tools/test_desktop_release_signing_readiness_contract.ps1 @@ -35,7 +35,20 @@ $readinessWorkflow = Get-Content -LiteralPath $readinessWorkflowPath -Raw foreach ($pattern in @( "workflow_dispatch", "contents: read", + "id-token: write", + "environment: windows-code-signing", "check_desktop_release_signing_readiness.ps1", + "STACKCHAN_WINDOWS_SIGNING_PROVIDER", + "STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ENDPOINT", + "STACKCHAN_WINDOWS_ARTIFACT_SIGNING_ACCOUNT", + "STACKCHAN_WINDOWS_ARTIFACT_SIGNING_PROFILE", + "STACKCHAN_AZURE_CLIENT_ID", + "STACKCHAN_AZURE_TENANT_ID", + "STACKCHAN_AZURE_SUBSCRIPTION_ID", + "azure/login@v3", + "azure/artifact-signing-action@v2", + "http://timestamp.acs.microsoft.com", + "staticClientSecret = `$false", "STACKCHAN_WINDOWS_PFX_B64", "STACKCHAN_WINDOWS_PFX_PASSWORD", "STACKCHAN_MACOS_CERTIFICATE_B64", @@ -51,7 +64,7 @@ foreach ($pattern in @( throw "Manual signing readiness workflow is missing: $pattern" } } -foreach ($forbidden in @("gh release", "upload-artifact", "contents: write")) { +foreach ($forbidden in @("gh release", "upload-artifact", "contents: write", "AZURE_CLIENT_SECRET")) { if ($readinessWorkflow -match [regex]::Escape($forbidden)) { throw "Manual signing readiness workflow must not publish artifacts or releases: $forbidden" } @@ -75,13 +88,36 @@ $passCount++ Write-Host "[PASS] native desktop credential checks sign and verify temporary executables" $releaseWorkflow = Get-Content -LiteralPath $releaseWorkflowPath -Raw -foreach ($pattern in @("Validate production desktop signing credentials", "check_desktop_release_signing_readiness.ps1", "RequireNativeToolchain", "ValidateAppleNotaryCredentials")) { +foreach ($pattern in @( + "Validate production desktop signing credentials", + "check_desktop_release_signing_readiness.ps1", + "RequireNativeToolchain", + "ValidateAppleNotaryCredentials", + "environment: windows-code-signing", + "companion-windows-sign", + "companion-desktop-unsigned-windows", + "Upload signed Windows release input", + "STACKCHAN_WINDOWS_SIGNING_PROVIDER", + "azure/login@v3", + "azure/artifact-signing-action@v2", + "STACKCHAN_WINDOWS_MSI_PATH", + "Get-AuthenticodeSignature", + "TimeStamperCertificate", + "http://timestamp.acs.microsoft.com", + "Authenticode sign Windows package with PFX fallback" +)) { if ($releaseWorkflow -notmatch [regex]::Escape($pattern)) { throw "Tagged release workflow is missing desktop signing preflight: $pattern" } } +if ($releaseWorkflow -match [regex]::Escape("AZURE_CLIENT_SECRET")) { + throw "Tagged release workflow must use GitHub OIDC rather than an Azure client secret." +} +if ([regex]::Matches($releaseWorkflow, [regex]::Escape("environment: windows-code-signing")).Count -ne 1) { + throw "Only the isolated Windows signing job may use the windows-code-signing environment." +} $passCount++ -Write-Host "[PASS] tagged release runs native desktop signing preflight" +Write-Host "[PASS] tagged release runs native desktop signing preflight with secretless Azure Artifact Signing and explicit PFX fallback" $firmwareWorkflowPath = Join-Path $repoRoot ".github/workflows/firmware.yml" $firmwareWorkflow = Get-Content -LiteralPath $firmwareWorkflowPath -Raw