From d84bc0c625a24c43385bb97afba06665b746eb61 Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Sat, 26 Sep 2026 23:13:32 -0700 Subject: [PATCH] ci(release-studio): sign Windows with a Studio-owned Certum certificate SignPath has no quota left, so Studio Windows builds could only ship unsigned. Port the 1.x Certum SimplySign signer, pointed at Studio's own account and certificate. - Signing runs only when STUDIO_SIGNING_ENABLED=true, as three jobs, so the SimplySign session is never up while third-party code runs: windows-sign-payload signs the window, kernel and computer-use helper; windows-package builds the installer and zip on a separate runner with no environment or secrets; windows-sign-installer signs the installer. The two signing jobs check out only workflow_sha, install no toolchain, leave Defender on, and stop SimplySign after signing. - Verification checks trust, the pinned thumbprint, the STUDIO_SIGNING_SUBJECT subject, an issuer and a timestamp on every signed executable, on the installer, and on the zip's copies. - The SimplySign MSI is pinned by SHA-256 as well as by its signature. - With the switch on, a missing secret or subject, or any signing failure, blocks publish. The release body's Windows sentence follows windows-sign-installer's result. - Credentials are STUDIO_CERTUM_USERNAME, STUDIO_CERTUM_OTP_URI and STUDIO_CERTUM_KEY_ID only. The concurrency group is studio-certum-signing, shared with a new non-publishing smoke workflow. - publish downloads every package artifact by its exact name, taking Windows from windows-sign-installer when it ran, and fails when two artifacts carry the same file name. - signing-contract validates and fingerprints both the tag's tree and the workflow_sha tree. - The SignPath request steps, artifact configurations and request helper are removed. The contract now covers the Certum files, and its call graph detects Certum credentials. - The runbook lists the repository settings to decide on before enabling and says which PE files stay unsigned (Electron DLLs, elevate.exe, the NSIS uninstaller). --- .github/CODEOWNERS | 4 +- .github/actions/setup-certum/action.yml | 35 ++ .github/workflows/ci.yml | 1 + .github/workflows/release-studio.yml | 531 +++++++++++++----- .../workflows/studio-certum-signing-smoke.yml | 117 ++++ .../studio-windows-installer.xml | 24 - .../studio-windows-payload.xml | 23 - .signpath/contracts/release-signing.yml | 7 +- README.md | 3 +- README.zh-CN.md | 3 +- cmd/signpath-contract/main.go | 3 +- cmd/signpath-contract/main_test.go | 32 +- docs/STUDIO_RELEASE.md | 77 ++- scripts/complete-signpath-request.ps1 | 161 ------ scripts/sign-certum.ps1 | 46 ++ scripts/verify-windows-authenticode.ps1 | 55 +- 16 files changed, 726 insertions(+), 396 deletions(-) create mode 100644 .github/actions/setup-certum/action.yml create mode 100644 .github/workflows/studio-certum-signing-smoke.yml delete mode 100644 .signpath/artifact-configurations/studio-windows-installer.xml delete mode 100644 .signpath/artifact-configurations/studio-windows-payload.xml delete mode 100644 scripts/complete-signpath-request.ps1 create mode 100644 scripts/sign-certum.ps1 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7694a02ad1..922ac61f60 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -27,6 +27,8 @@ # Release signing: provider policy, its contract and the workflow that uses it. /.signpath/ @esengine @SivanCola /.github/workflows/release-studio.yml @esengine @SivanCola +/.github/workflows/studio-certum-signing-smoke.yml @esengine @SivanCola +/.github/actions/setup-certum/ @esengine @SivanCola /cmd/signpath-contract/ @esengine @SivanCola -/scripts/complete-signpath-request.ps1 @esengine @SivanCola +/scripts/sign-certum.ps1 @esengine @SivanCola /scripts/verify-windows-authenticode.ps1 @esengine @SivanCola diff --git a/.github/actions/setup-certum/action.yml b/.github/actions/setup-certum/action.yml new file mode 100644 index 0000000000..25e8298f51 --- /dev/null +++ b/.github/actions/setup-certum/action.yml @@ -0,0 +1,35 @@ +name: Connect to Certum cloud signing +description: Verify the pinned SimplySign installer and connect to the configured certificate. +inputs: + username: + required: true + description: SimplySign account + otp-uri: + required: true + description: TOTP provisioning URI + thumbprint: + required: true + description: Expected SHA-1 certificate thumbprint +runs: + using: composite + steps: + - name: Verify SimplySign installer + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Invoke-WebRequest 'https://files.certum.eu/software/SimplySignDesktop/Windows/9.4.3.90/SimplySignDesktop-9.4.3.90-64-bit-en.msi' -OutFile SimplySignDesktop.msi + $hash = (Get-FileHash -Algorithm SHA256 SimplySignDesktop.msi).Hash + if ($hash -ne 'CE2B38EF0124A574BC08E5558726099CAAC79A7C58B8D3D712F629371FE1BCB7') { + throw "SimplySign installer SHA-256 $hash does not match the pinned 9.4.3.90 build" + } + $signature = Get-AuthenticodeSignature SimplySignDesktop.msi + if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'Asseco Data Systems') { + throw 'SimplySign installer signature or publisher verification failed' + } + - name: Authenticate cloud certificate + uses: dismine/windows-app-signing-setup-action@89ae3b032d4bc7a5b98d1a42a34e61ecb6faad64 + with: + certum-username: ${{ inputs.username }} + certum-otp-uri: ${{ inputs.otp-uri }} + certum-key-id: ${{ inputs.thumbprint }} + capture-diagnostics: 'false' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d749ec82f6..2e659f31d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -492,6 +492,7 @@ jobs: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 \ -ignore 'label "windows-11-arm" is unknown' \ .github/workflows/release-studio.yml \ + .github/workflows/studio-certum-signing-smoke.yml \ .github/workflows/ci.yml govulncheck: diff --git a/.github/workflows/release-studio.yml b/.github/workflows/release-studio.yml index cf03316ca0..21da8c042f 100644 --- a/.github/workflows/release-studio.yml +++ b/.github/workflows/release-studio.yml @@ -10,11 +10,12 @@ run-name: Release Studio ${{ inputs.tag || github.ref_name }} # the desktop line's catalog. Studio artifacts land under the tag prefix, which # cannot collide because the tag name itself differs. # -# Windows artifacts are Authenticode-signed through SignPath in two stages: the -# executables a user runs, then the installer built around them. Signing only -# the container leaves them unsigned on disk after installation, which is what -# reputation heuristics quarantine. Signing engages only when SIGNPATH_API_TOKEN -# is set, so forks and token-less runs still build unsigned. +# Windows artifacts are Authenticode-signed with a Certum SimplySign cloud +# certificate in two stages: the executables a user runs, then the installer +# built around them. Signing only the container leaves them unsigned on disk +# after installation, which is what reputation heuristics quarantine. The +# repository variable STUDIO_SIGNING_ENABLED=true turns it on; once on, a +# release that cannot sign does not publish. # # Windows and macOS both package in two steps because both need something done # to the bundle in between — a signature there, notarization here. @@ -94,13 +95,14 @@ jobs: exit 1 fi - # The contract is this repository's mirror of what SignPath's console trusts: - # which branch and which workflow may request the release certificate. It is - # checked before anything is built so a widened origin fails here rather than - # at the signing request, and fingerprinted so the protected files cannot move - # under an attestation that was recorded against different bytes. + # The contract names which branch and which workflows may reach the release + # certificate. The call graph is read from the workflows themselves, so a + # second workflow that learns to use the credentials fails here before + # anything is built, and the fingerprint pins the files the signing runs. + # Both trees are checked: the tag's, which is built, and the workflow's, + # whose scripts do the signing. signing-contract: - name: validate SignPath release contract + name: validate release signing contract needs: resolve runs-on: ubuntu-latest permissions: @@ -109,6 +111,12 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ needs.resolve.outputs.sha }} + persist-credentials: false + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.workflow_sha }} + path: release-control + persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: go.mod @@ -116,7 +124,10 @@ jobs: - name: Validate and fingerprint the contract run: | go run ./cmd/signpath-contract validate - echo "fingerprint=$(go run ./cmd/signpath-contract fingerprint)" >> "$GITHUB_STEP_SUMMARY" + echo "release fingerprint=$(go run ./cmd/signpath-contract fingerprint)" >> "$GITHUB_STEP_SUMMARY" + cd release-control + go run ./cmd/signpath-contract validate + echo "workflow fingerprint=$(go run ./cmd/signpath-contract fingerprint)" >> "$GITHUB_STEP_SUMMARY" build: name: build (${{ matrix.platform }}) @@ -124,16 +135,11 @@ jobs: runs-on: ${{ matrix.runner }} permissions: contents: read - actions: read # SignPath reads run details and downloads the unsigned artifact env: - # Two conditions, because they answer different questions. The token says - # a fork cannot try — that is the APPLE_* gate's shape. The variable says - # SignPath's console has been told to trust this line's origin, which no - # secret can express: SIGNPATH_API_TOKEN is a repository secret, so it is - # already set here, and without the second gate merging this file would - # make the next release request a signature the policy still refuses. - # Set STUDIO_SIGNING_ENABLED=true once the studio origin is provisioned. - HAS_SIGNPATH: ${{ secrets.SIGNPATH_API_TOKEN != '' && vars.STUDIO_SIGNING_ENABLED == 'true' }} + # The switch alone decides the path, never the presence of credentials: + # with it on, the Windows leg hands its bundle to windows-sign-payload, + # and a missing secret fails there instead of shipping an unsigned build. + SIGN_WINDOWS: ${{ vars.STUDIO_SIGNING_ENABLED == 'true' }} strategy: fail-fast: false matrix: @@ -141,17 +147,22 @@ jobs: # beside the release one rather than mapped in a step: the two # vocabularies disagree (x64 / amd64) and a reviewer has to see both. # Absent means the runner's own, which is what Windows and Linux build. + # artifact is the exact name publish downloads this leg's packages by. include: - platform: windows/amd64 runner: windows-latest + artifact: studio-build-windows-amd64 - platform: darwin/amd64 runner: macos-latest ebarch: x64 + artifact: studio-build-darwin-amd64 - platform: darwin/arm64 runner: macos-latest ebarch: arm64 + artifact: studio-build-darwin-arm64 - platform: linux/amd64 runner: ubuntu-22.04 + artifact: studio-build-linux-amd64 steps: # Real-time scanning inspects every file the job writes, and packaging # writes a quarter of a gigabyte twice. The runner is thrown away after @@ -327,74 +338,22 @@ jobs: pnpm exec electron-builder --prepackaged "../../$app" $arch \ -c.extraMetadata.version="${version#v}" - # Stage one: the executables a user actually runs, signed while they are - # still loose files. This shell ships two of its own -- the window and the - # kernel it spawns -- where the Wails one was a single binary. Everything - # else in the bundle belongs to Chromium or to electron-builder (the NSIS - # elevate.exe it writes in during the packaging step below, after this), so - # it is not listed: this request must not re-sign somebody else's binary. - - name: Stage the Windows payload - if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' - shell: bash - run: | - mkdir -p payload - cp "dist/win-unpacked/Reasonix Studio.exe" payload/ - cp dist/win-unpacked/resources/bin/reasonix-studio-host.exe payload/ - - - name: Upload unsigned Windows payload for SignPath - if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' - id: unsigned-payload + # With signing on, this leg only hands the bundle on: the signing jobs + # run nothing but the signer, and windows-package packages in between. + - name: Upload the unsigned Windows bundle for signing + if: runner.os == 'Windows' && env.SIGN_WINDOWS == 'true' uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: - name: unsigned-studio-payload-amd64 - path: payload/*.exe + name: unsigned-studio-windows-bundle + path: dist/win-unpacked if-no-files-found: error retention-days: 1 - - name: Submit Windows payload for Authenticode signing - if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' - id: submit-payload - uses: signpath/github-action-submit-signing-request@c92b958760219087e01f8d67a1669ed57afe2627 # v2 - with: - api-token: ${{ secrets.SIGNPATH_API_TOKEN }} - organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }} - project-slug: DeepSeek-Reasonix - signing-policy-slug: release-signing - artifact-configuration-slug: studio-windows-payload - github-artifact-id: ${{ steps.unsigned-payload.outputs.artifact-id }} - github-token: ${{ github.token }} - wait-for-completion: false - - # The release certificate requires an approval per request. The publish - # job's environment gate is the human one; the SignPath CI identity records - # the request approval so this does not become a second prompt. - - name: Approve and download signed payload - if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' - shell: pwsh - env: - SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} - run: | - ./scripts/complete-signpath-request.ps1 ` - -OrganizationId "${{ secrets.SIGNPATH_ORGANIZATION_ID }}" ` - -SigningRequestId "${{ steps.submit-payload.outputs.signing-request-id }}" ` - -ExpectedSigningPolicySlug "release-signing" ` - -OutputArtifactDirectory "signed-payload" ` - -TimeoutSeconds 1800 - - - name: Put the signed executables back in the bundle - if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' - shell: bash - run: | - cp "signed-payload/Reasonix Studio.exe" "dist/win-unpacked/Reasonix Studio.exe" - cp signed-payload/reasonix-studio-host.exe dist/win-unpacked/resources/bin/reasonix-studio-host.exe - - # The installer and the portable archive are both built from the bundle - # that now holds the signed executables, which is what makes the archive - # carry them too rather than a second unsigned copy. They are built at - # once, the zip from its own copy: NSIS writes elevate.exe into the tree - # it packs, and a zip read beside that would hold it or not by timing. + # The installer and the portable archive are built at once, the zip from + # its own copy: NSIS writes elevate.exe into the tree it packs, and a zip + # read beside that would hold it or not by timing. - name: Package Windows from the bundle - if: runner.os == 'Windows' + if: runner.os == 'Windows' && env.SIGN_WINDOWS != 'true' working-directory: desktop/electron shell: bash run: | @@ -409,76 +368,300 @@ jobs: wait "$zip_pid" mv ../../dist-zip/ReasonixStudio-windows-amd64.zip ../../dist/ - # Stage two: the finished NSIS container, signed only once the payload it - # was built from is present and already signed — the artifact - # configuration verifies that rather than trusting this job's ordering. - - name: Upload unsigned installer for SignPath - if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' - id: unsigned-installer - shell: bash - run: | - mkdir -p installer-bundle - cp dist/ReasonixStudio-windows-amd64-installer.exe installer-bundle/ - cp "dist/win-unpacked/Reasonix Studio.exe" installer-bundle/ - cp dist/win-unpacked/resources/bin/reasonix-studio-host.exe installer-bundle/ - - - name: Upload installer bundle - if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' - id: installer-artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + if: runner.os != 'Windows' || env.SIGN_WINDOWS != 'true' with: - name: unsigned-studio-installer-amd64 - path: installer-bundle/*.exe + name: ${{ matrix.artifact }} + path: dist/ReasonixStudio-* if-no-files-found: error - retention-days: 1 + retention-days: 7 - - name: Submit installer for Authenticode signing - if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' - id: submit-installer - uses: signpath/github-action-submit-signing-request@c92b958760219087e01f8d67a1669ed57afe2627 # v2 + # Windows signing is three jobs so the SimplySign session is never up while + # third-party code runs: the two signing jobs install no toolchain and run + # only the signer, and packaging runs between them on its own runner with no + # secrets. Both signing jobs share one group with the Studio smoke test, so no + # two runs drive the same SimplySign account at once. + windows-sign-payload: + name: sign Windows executables (Certum) + needs: [resolve, signing-contract, build] + if: vars.STUDIO_SIGNING_ENABLED == 'true' + runs-on: windows-2022 + timeout-minutes: 30 + environment: studio-release + permissions: + contents: read + concurrency: + group: studio-certum-signing + cancel-in-progress: false + steps: + # Only the workflow's own commit is checked out: these jobs run the signer + # and its checks, never anything the release tag builds. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - api-token: ${{ secrets.SIGNPATH_API_TOKEN }} - organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }} - project-slug: DeepSeek-Reasonix - signing-policy-slug: release-signing - artifact-configuration-slug: studio-windows-installer - github-artifact-id: ${{ steps.installer-artifact.outputs.artifact-id }} - github-token: ${{ github.token }} - wait-for-completion: false + ref: ${{ github.workflow_sha }} + persist-credentials: false - - name: Approve and download signed installer - if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' + # The switch is on, so nothing below may fall back to an unsigned build. + # Every missing value is reported under its own code before any fails. + - name: Require Certum configuration shell: pwsh env: - SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} + CERTUM_USERNAME: ${{ secrets.STUDIO_CERTUM_USERNAME }} + CERTUM_OTP_URI: ${{ secrets.STUDIO_CERTUM_OTP_URI }} + CERTUM_KEY_ID: ${{ secrets.STUDIO_CERTUM_KEY_ID }} + EXPECTED_SUBJECT: ${{ vars.STUDIO_SIGNING_SUBJECT }} run: | - ./scripts/complete-signpath-request.ps1 ` - -OrganizationId "${{ secrets.SIGNPATH_ORGANIZATION_ID }}" ` - -SigningRequestId "${{ steps.submit-installer.outputs.signing-request-id }}" ` - -ExpectedSigningPolicySlug "release-signing" ` - -OutputArtifactDirectory "signed-installer" ` - -TimeoutSeconds 1800 + $ErrorActionPreference = 'Stop' + $failed = $false + function Refuse([string]$code, [string]$message) { + Write-Output "::error title=$code::$message" + Set-Variable -Scope Script -Name failed -Value $true + } + if ([string]::IsNullOrWhiteSpace($env:CERTUM_USERNAME)) { + Refuse 'studio-signing.missing-username' 'Secret STUDIO_CERTUM_USERNAME is not set' + } + if ($env:CERTUM_OTP_URI -notmatch '^otpauth://totp/') { + Refuse 'studio-signing.invalid-otp-uri' 'Secret STUDIO_CERTUM_OTP_URI must be a complete otpauth://totp/ URI' + } + if ($env:CERTUM_KEY_ID -notmatch '^[0-9a-fA-F]{40}$') { + Refuse 'studio-signing.invalid-key-id' 'Secret STUDIO_CERTUM_KEY_ID must be the 40-character SHA-1 certificate thumbprint' + } + if ([string]::IsNullOrWhiteSpace($env:EXPECTED_SUBJECT)) { + Refuse 'studio-signing.subject-unset' 'Variable STUDIO_SIGNING_SUBJECT is empty; run the Studio Certum signing smoke test and copy the subject it reports' + } + if ($failed) { exit 1 } - - name: Replace the installer with the signed build - if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' - shell: bash - run: cp signed-installer/*installer*.exe dist/ + - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: unsigned-studio-windows-bundle + path: bundle - # Both stages are read back off the artifacts that will actually ship. A - # signature this job believes it applied but that is not on the bytes in - # dist/ is the one failure the two requests above cannot catch themselves. - - name: Verify the Windows Authenticode chain - if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' + # Stage one: the executables this project builds, as loose files. + - name: Stage the Windows payload shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + New-Item -ItemType Directory payload | Out-Null + Copy-Item -LiteralPath 'bundle/Reasonix Studio.exe' payload/ + Copy-Item -LiteralPath bundle/resources/bin/reasonix-studio-host.exe payload/ + Copy-Item -LiteralPath bundle/resources/bin/reasonix-computer-helper.exe payload/ + @('Reasonix Studio.exe', 'reasonix-studio-host.exe', 'reasonix-computer-helper.exe') | + Set-Content payload/signing-files.txt + + - name: Connect to Certum + uses: ./.github/actions/setup-certum + with: + username: ${{ secrets.STUDIO_CERTUM_USERNAME }} + otp-uri: ${{ secrets.STUDIO_CERTUM_OTP_URI }} + thumbprint: ${{ secrets.STUDIO_CERTUM_KEY_ID }} + + - name: Sign the executables + shell: pwsh + env: + CERTUM_KEY_ID: ${{ secrets.STUDIO_CERTUM_KEY_ID }} + run: ./scripts/sign-certum.ps1 -PayloadDirectory payload + + # The session signs for any process on the runner while it is up. + - name: Disconnect from Certum + if: always() + shell: pwsh + run: Get-Process -Name '*SimplySign*' -ErrorAction SilentlyContinue | Stop-Process -Force + + - name: Verify the signed executables + shell: pwsh + env: + CERTUM_KEY_ID: ${{ secrets.STUDIO_CERTUM_KEY_ID }} + EXPECTED_SUBJECT: ${{ vars.STUDIO_SIGNING_SUBJECT }} run: | ./scripts/verify-windows-authenticode.ps1 ` - -PayloadDirectory signed-payload ` - -InstallerPath "dist/ReasonixStudio-windows-amd64-installer.exe" ` - -PortableArchivePath "dist/ReasonixStudio-windows-amd64.zip" + -PayloadDirectory payload ` + -ExpectedThumbprint $env:CERTUM_KEY_ID ` + -ExpectedSubject $env:EXPECTED_SUBJECT - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: - name: studio-${{ strategy.job-index }} + name: signed-studio-windows-payload + path: payload/*.exe + if-no-files-found: error + retention-days: 1 + + # No environment and no secrets: electron-builder and its dependencies run + # here, on a runner that never held a signing session. + windows-package: + name: package Windows from the signed executables + needs: [resolve, windows-sign-payload] + runs-on: windows-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Stop Defender scanning the build + continue-on-error: true + shell: pwsh + run: Set-MpPreference -DisableRealtimeMonitoring $true + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.resolve.outputs.sha }} + persist-credentials: false + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 10 + run_install: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: "24" + cache: pnpm + cache-dependency-path: desktop/electron/pnpm-lock.yaml + + - name: Install the packager + working-directory: desktop/electron + shell: bash + run: pnpm install --frozen-lockfile + + - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: unsigned-studio-windows-bundle + path: dist/win-unpacked + + - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: signed-studio-windows-payload + path: payload + + - name: Put the signed executables in the bundle + shell: bash + run: | + cp "payload/Reasonix Studio.exe" "dist/win-unpacked/Reasonix Studio.exe" + cp payload/reasonix-studio-host.exe dist/win-unpacked/resources/bin/reasonix-studio-host.exe + cp payload/reasonix-computer-helper.exe dist/win-unpacked/resources/bin/reasonix-computer-helper.exe + + # Both packages come from the bundle that now holds the signed executables, + # so the archive carries them too rather than a second unsigned copy. The + # zip is packed from its own copy for the reason the build job gives. + - name: Package Windows from the signed bundle + working-directory: desktop/electron + shell: bash + run: | + version="${{ needs.resolve.outputs.version }}" + rm -rf ../../zip-src ../../dist-zip + cp -r ../../dist/win-unpacked ../../zip-src + pnpm exec electron-builder --prepackaged ../../zip-src --win zip \ + -c.extraMetadata.version="${version#v}" -c.directories.output=../../dist-zip & + zip_pid=$! + pnpm exec electron-builder --prepackaged ../../dist/win-unpacked --win nsis \ + -c.extraMetadata.version="${version#v}" + wait "$zip_pid" + mv ../../dist-zip/ReasonixStudio-windows-amd64.zip ../../dist/ + + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: packaged-studio-windows + path: | + dist/ReasonixStudio-windows-amd64-installer.exe + dist/ReasonixStudio-windows-amd64.zip + if-no-files-found: error + retention-days: 1 + + windows-sign-installer: + name: sign the Windows installer (Certum) + needs: [resolve, windows-sign-payload, windows-package] + runs-on: windows-2022 + timeout-minutes: 30 + environment: studio-release + permissions: + contents: read + concurrency: + group: studio-certum-signing + cancel-in-progress: false + steps: + # Only the workflow's own commit is checked out: these jobs run the signer + # and its checks, never anything the release tag builds. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + + # The switch is on, so nothing below may fall back to an unsigned build. + # Every missing value is reported under its own code before any fails. + - name: Require Certum configuration + shell: pwsh + env: + CERTUM_USERNAME: ${{ secrets.STUDIO_CERTUM_USERNAME }} + CERTUM_OTP_URI: ${{ secrets.STUDIO_CERTUM_OTP_URI }} + CERTUM_KEY_ID: ${{ secrets.STUDIO_CERTUM_KEY_ID }} + EXPECTED_SUBJECT: ${{ vars.STUDIO_SIGNING_SUBJECT }} + run: | + $ErrorActionPreference = 'Stop' + $failed = $false + function Refuse([string]$code, [string]$message) { + Write-Output "::error title=$code::$message" + Set-Variable -Scope Script -Name failed -Value $true + } + if ([string]::IsNullOrWhiteSpace($env:CERTUM_USERNAME)) { + Refuse 'studio-signing.missing-username' 'Secret STUDIO_CERTUM_USERNAME is not set' + } + if ($env:CERTUM_OTP_URI -notmatch '^otpauth://totp/') { + Refuse 'studio-signing.invalid-otp-uri' 'Secret STUDIO_CERTUM_OTP_URI must be a complete otpauth://totp/ URI' + } + if ($env:CERTUM_KEY_ID -notmatch '^[0-9a-fA-F]{40}$') { + Refuse 'studio-signing.invalid-key-id' 'Secret STUDIO_CERTUM_KEY_ID must be the 40-character SHA-1 certificate thumbprint' + } + if ([string]::IsNullOrWhiteSpace($env:EXPECTED_SUBJECT)) { + Refuse 'studio-signing.subject-unset' 'Variable STUDIO_SIGNING_SUBJECT is empty; run the Studio Certum signing smoke test and copy the subject it reports' + } + if ($failed) { exit 1 } + + - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: packaged-studio-windows + path: dist + + - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: signed-studio-windows-payload + path: payload + + - name: Connect to Certum + uses: ./.github/actions/setup-certum + with: + username: ${{ secrets.STUDIO_CERTUM_USERNAME }} + otp-uri: ${{ secrets.STUDIO_CERTUM_OTP_URI }} + thumbprint: ${{ secrets.STUDIO_CERTUM_KEY_ID }} + + # Stage two: the NSIS container, only once the payload inside it is signed. + - name: Sign the installer + shell: pwsh + env: + CERTUM_KEY_ID: ${{ secrets.STUDIO_CERTUM_KEY_ID }} + run: ./scripts/sign-certum.ps1 -FilePath dist/ReasonixStudio-windows-amd64-installer.exe + + # The session signs for any process on the runner while it is up. + - name: Disconnect from Certum + if: always() + shell: pwsh + run: Get-Process -Name '*SimplySign*' -ErrorAction SilentlyContinue | Stop-Process -Force + + # Read back off the bytes that will ship: the payload, the installer, and + # the executables inside the portable archive. + - name: Verify the Windows Authenticode chain + shell: pwsh + env: + CERTUM_KEY_ID: ${{ secrets.STUDIO_CERTUM_KEY_ID }} + EXPECTED_SUBJECT: ${{ vars.STUDIO_SIGNING_SUBJECT }} + run: | + ./scripts/verify-windows-authenticode.ps1 ` + -PayloadDirectory payload ` + -InstallerPath "dist/ReasonixStudio-windows-amd64-installer.exe" ` + -PortableArchivePath "dist/ReasonixStudio-windows-amd64.zip" ` + -ExpectedThumbprint $env:CERTUM_KEY_ID ` + -ExpectedSubject $env:EXPECTED_SUBJECT + + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: studio-windows-signed path: dist/ReasonixStudio-* if-no-files-found: error retention-days: 7 @@ -549,7 +732,15 @@ jobs: publish: name: sign, publish and mirror - needs: [resolve, build, cli] + needs: [resolve, build, cli, windows-sign-payload, windows-package, windows-sign-installer] + # The Windows signing jobs may be skipped only while the switch is off; with + # it on, any of them failing, cancelled or absent blocks the release. + if: >- + ${{ !cancelled() && needs.resolve.result == 'success' && needs.build.result == 'success' && + needs.cli.result == 'success' && ((needs.windows-sign-payload.result == 'success' && + needs.windows-package.result == 'success' && needs.windows-sign-installer.result == 'success') || + (vars.STUDIO_SIGNING_ENABLED != 'true' && needs.windows-sign-payload.result == 'skipped' && + needs.windows-package.result == 'skipped' && needs.windows-sign-installer.result == 'skipped')) }} runs-on: ubuntu-latest # Owner approval for the whole line. The gate sits here rather than before # build so the approval is given with the artifacts already proven to build. @@ -585,21 +776,56 @@ jobs: # One directory per artifact, because both sets below are taken from the # job that produced them. Merging them flattens that away, and the name # alone cannot tell a package apart from an input to signing it. + # Every package by its exact name, never a pattern: any job without + # secrets can upload an artifact, and a pattern would publish it. Windows + # comes from the installer signing job when it ran, else the build leg. - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: - path: staging + name: studio-build-darwin-amd64 + path: staging/studio-build-darwin-amd64 + + - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: studio-build-darwin-arm64 + path: staging/studio-build-darwin-arm64 + + - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: studio-build-linux-amd64 + path: staging/studio-build-linux-amd64 + + - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + if: needs.windows-sign-installer.result == 'success' + with: + name: studio-windows-signed + path: staging/studio-windows + + - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + if: needs.windows-sign-installer.result != 'success' + with: + name: studio-build-windows-amd64 + path: staging/studio-windows + + - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: cli-archives + path: staging/cli-archives - name: Collect artifacts run: | mkdir -p dist - # The packages, from the build jobs. The SignPath flow uploads the - # unsigned copies it hands over under their own artifacts, and those - # match ReasonixStudio-* as well — taking them by name would publish - # whichever the move happened to write last. - find staging/studio-* -type f -name 'ReasonixStudio-*' -exec mv {} dist/ \; - # The CLI archives and their digest list, from the job that produced - # them: a reasonix-studio-host.exe left in staging matched reasonix-*. - find staging/cli-archives -type f -exec mv {} dist/ \; + # The packages, from the build and signing jobs, then the CLI archives + # and their digest list. Two artifacts carrying one file name fail the + # release rather than letting move order pick. + while IFS= read -r -d '' f; do + name="$(basename "$f")" + if [ -e "dist/$name" ]; then + echo "::error::$name arrived in more than one artifact" + exit 1 + fi + mv "$f" "dist/$name" + done < <(find staging/studio-* -type f -name 'ReasonixStudio-*' -print0 + find staging/cli-archives -type f -print0) ls -la dist # Two Windows artifacts (installer, portable zip), two per macOS # architecture (dmg, zip) and the Linux .deb. Fewer means an artifact @@ -673,7 +899,7 @@ jobs: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.resolve.outputs.tag }} VERSION: ${{ needs.resolve.outputs.version }} - SIGNED_WINDOWS: ${{ secrets.SIGNPATH_API_TOKEN != '' && vars.STUDIO_SIGNING_ENABLED == 'true' }} + SIGNED_WINDOWS: ${{ needs.windows-sign-installer.result == 'success' }} run: | # A version's own notes live in release-notes/studio/.md, named # without the v the tag carries, and were rendered with their authors @@ -699,12 +925,11 @@ jobs: -f previous_tag_name="$previous_tag" \ --jq '.body' >> "$notes" printf '\n\n' >> "$notes" - # The Windows sentence follows the condition that decides it, read - # here from the same pair the build job gates signing on. Asserting a - # constant is how this line came to tell users to click through - # SmartScreen for a package that was signed. + # The Windows sentence follows the job that signed, not the switch + # that asked for it: a constant here tells users to click through + # SmartScreen for a signed package, or the reverse. if [ "$SIGNED_WINDOWS" = "true" ]; then - windows='**Windows** 的窗口、内核与安装器都经 SignPath 做了 Authenticode 签名。' + windows='**Windows** 的窗口、内核与安装器都经 Certum 代码签名证书做了 Authenticode 签名。' else windows='**Windows 包未签名**,SmartScreen 会告警:选「更多信息」,再选「仍要运行」。' fi diff --git a/.github/workflows/studio-certum-signing-smoke.yml b/.github/workflows/studio-certum-signing-smoke.yml new file mode 100644 index 0000000000..7cac672409 --- /dev/null +++ b/.github/workflows/studio-certum-signing-smoke.yml @@ -0,0 +1,117 @@ +name: Studio Certum signing smoke test + +# Signs two throwaway probes with the certificate release-studio.yml would use, +# and publishes nothing. It reports the signer's subject, which is the value +# STUDIO_SIGNING_SUBJECT must hold. +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: studio-certum-signing + cancel-in-progress: false + +jobs: + sign: + if: github.repository == 'esengine/DeepSeek-Reasonix' && github.ref == 'refs/heads/studio' + runs-on: windows-2022 + environment: studio-release + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: go.mod + cache: false + + - name: Check signing configuration + shell: pwsh + env: + CERTUM_USERNAME: ${{ secrets.STUDIO_CERTUM_USERNAME }} + CERTUM_OTP_URI: ${{ secrets.STUDIO_CERTUM_OTP_URI }} + CERTUM_KEY_ID: ${{ secrets.STUDIO_CERTUM_KEY_ID }} + run: | + $ErrorActionPreference = 'Stop' + if ([string]::IsNullOrWhiteSpace($env:CERTUM_USERNAME)) { throw 'Missing secret STUDIO_CERTUM_USERNAME' } + if ($env:CERTUM_OTP_URI -notmatch '^otpauth://totp/') { throw 'Secret STUDIO_CERTUM_OTP_URI must be a complete TOTP URI' } + if ($env:CERTUM_KEY_ID -notmatch '^[0-9a-fA-F]{40}$') { throw 'Secret STUDIO_CERTUM_KEY_ID must be the 40-character SHA-1 certificate thumbprint' } + + - name: Build open-source signing probes + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + # This minimal probe is covered by the repository's MIT license. + New-Item -ItemType Directory payload | Out-Null + 'package main; func main() { println("Reasonix Studio Certum signing probe") }' | Set-Content probe.go + $env:GOOS = 'windows' + $env:GOARCH = 'amd64' + $env:CGO_ENABLED = '0' + go build -trimpath -o payload/probe.exe probe.go + if ($LASTEXITCODE -ne 0) { throw 'Probe compilation failed' } + 'probe.exe' | Set-Content payload/signing-files.txt + Copy-Item payload/probe.exe installer-probe.exe + + - name: Connect to SimplySign cloud certificate + uses: ./.github/actions/setup-certum + with: + username: ${{ secrets.STUDIO_CERTUM_USERNAME }} + otp-uri: ${{ secrets.STUDIO_CERTUM_OTP_URI }} + thumbprint: ${{ secrets.STUDIO_CERTUM_KEY_ID }} + + - name: Sign and verify payload and installer probes + shell: pwsh + env: + CERTUM_KEY_ID: ${{ secrets.STUDIO_CERTUM_KEY_ID }} + run: | + ./scripts/sign-certum.ps1 -PayloadDirectory payload + ./scripts/sign-certum.ps1 -FilePath installer-probe.exe + + # The session signs for any process on the runner while it is up. + - name: Disconnect from Certum + if: always() + shell: pwsh + run: Get-Process -Name '*SimplySign*' -ErrorAction SilentlyContinue | Stop-Process -Force + + # With the switch on, an unset subject fails here as it would in a + # release; with it off, the value is reported so it can be recorded. + - name: Report and check the signer + shell: pwsh + env: + SIGNING_ENABLED: ${{ vars.STUDIO_SIGNING_ENABLED }} + EXPECTED_SUBJECT: ${{ vars.STUDIO_SIGNING_SUBJECT }} + run: | + $ErrorActionPreference = 'Stop' + $failed = $false + foreach ($file in @('payload/probe.exe', 'installer-probe.exe')) { + $signature = Get-AuthenticodeSignature -LiteralPath $file + $certificate = $signature.SignerCertificate + $stamp = if ($signature.TimeStamperCertificate) { $signature.TimeStamperCertificate.Subject } else { '(none)' } + @("### $file", '', "- subject: ``$($certificate.Subject)``", "- issuer: ``$($certificate.Issuer)``", + "- thumbprint: ``$($certificate.Thumbprint)``", "- timestamp: ``$stamp``", '') >> $env:GITHUB_STEP_SUMMARY + if (-not $signature.TimeStamperCertificate) { + Write-Output "::error title=studio-signing.timestamp-missing::$file carries no timestamp" + $failed = $true + } + if ([string]::IsNullOrWhiteSpace($certificate.Issuer)) { + Write-Output "::error title=studio-signing.issuer-missing::$file has a signer certificate without an issuer" + $failed = $true + } + if ([string]::IsNullOrWhiteSpace($env:EXPECTED_SUBJECT)) { + if ($env:SIGNING_ENABLED -eq 'true') { + Write-Output "::error title=studio-signing.subject-unset::STUDIO_SIGNING_SUBJECT is empty while STUDIO_SIGNING_ENABLED=true" + $failed = $true + } else { + Write-Output "::warning::STUDIO_SIGNING_SUBJECT is not set; record '$($certificate.Subject)'" + } + } elseif ($certificate.Subject -cne $env:EXPECTED_SUBJECT) { + Write-Output "::error title=studio-signing.unexpected-signer::STUDIO_SIGNING_SUBJECT is '$($env:EXPECTED_SUBJECT)' but $file is signed by '$($certificate.Subject)'" + $failed = $true + } + } + if ($failed) { exit 1 } + 'Certum payload and installer signing, signer, trust and timestamps passed.' >> $env:GITHUB_STEP_SUMMARY diff --git a/.signpath/artifact-configurations/studio-windows-installer.xml b/.signpath/artifact-configurations/studio-windows-installer.xml deleted file mode 100644 index 9df2535d5f..0000000000 --- a/.signpath/artifact-configurations/studio-windows-installer.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/.signpath/artifact-configurations/studio-windows-payload.xml b/.signpath/artifact-configurations/studio-windows-payload.xml deleted file mode 100644 index 6e237bf2c8..0000000000 --- a/.signpath/artifact-configurations/studio-windows-payload.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - diff --git a/.signpath/contracts/release-signing.yml b/.signpath/contracts/release-signing.yml index 66905b66b3..7bd58abf0f 100644 --- a/.signpath/contracts/release-signing.yml +++ b/.signpath/contracts/release-signing.yml @@ -6,11 +6,12 @@ allowed_branch_names: - studio allowed_build_definitions: - .github/workflows/release-studio.yml + - .github/workflows/studio-certum-signing-smoke.yml fingerprint_files: - .github/workflows/release-studio.yml - - .signpath/artifact-configurations/studio-windows-payload.xml - - .signpath/artifact-configurations/studio-windows-installer.xml - - scripts/complete-signpath-request.ps1 + - .github/workflows/studio-certum-signing-smoke.yml + - .github/actions/setup-certum/action.yml + - scripts/sign-certum.ps1 - scripts/verify-windows-authenticode.ps1 - desktop/electron/electron-builder.yml - cmd/signpath-contract/main.go diff --git a/README.md b/README.md index f31c0c7764..8b61aa64d4 100644 --- a/README.md +++ b/README.md @@ -126,8 +126,7 @@ brew install esengine/reasonix/reasonix # macOS The 1.x desktop app is on the [official download page](https://reasonix.io/?download=desktop#start). Windows -installers are code-signed through [SignPath.io](https://signpath.io/) with a -free certificate provided by the [SignPath Foundation](https://signpath.org/). +installers are code-signed with a Certum code-signing certificate. ### VS Code extension diff --git a/README.zh-CN.md b/README.zh-CN.md index 24fc7294d0..8281e1530b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -111,8 +111,7 @@ brew install esengine/reasonix/reasonix # macOS ``` 1.x 桌面端请前往[官方下载页](https://reasonix.io/?download=desktop#start)。 -Windows 安装器通过 [SignPath.io](https://signpath.io/) 完成代码签名,证书由 -[SignPath 基金会](https://signpath.org/) 免费提供。 +Windows 安装器使用 Certum 代码签名证书签名。 ### VS Code 扩展 diff --git a/cmd/signpath-contract/main.go b/cmd/signpath-contract/main.go index 5c53de2561..f039eb7471 100644 --- a/cmd/signpath-contract/main.go +++ b/cmd/signpath-contract/main.go @@ -293,7 +293,8 @@ func parseWorkflow(data []byte) (workflowInfo, error) { if uses := mappingScalar(job, "uses"); strings.HasPrefix(uses, "./.github/workflows/") { info.calls = append(info.calls, strings.TrimPrefix(uses, "./")) } - if nodeContains(job, "secrets.SIGNPATH_API_TOKEN") { + if nodeContains(job, "secrets.SIGNPATH_API_TOKEN") || nodeContains(job, "secrets.CERTUM_OTP_URI") || + nodeContains(job, "secrets.STUDIO_CERTUM_OTP_URI") { info.directSigning = true } steps := mappingValue(job, "steps") diff --git a/cmd/signpath-contract/main_test.go b/cmd/signpath-contract/main_test.go index d2b302e1aa..d0d5bff5b7 100644 --- a/cmd/signpath-contract/main_test.go +++ b/cmd/signpath-contract/main_test.go @@ -26,10 +26,13 @@ func TestTopLevelSignPathWorkflowCallGraph(t *testing.T) { if err != nil { t.Fatal(err) } - // The studio line publishes from one workflow. This is not a restatement of - // the contract — it is read from the call graph, so a second workflow that - // learns to reach SignPath fails here even if nobody widened the contract. - want := []string{".github/workflows/release-studio.yml"} + // The studio line signs from its release and its smoke test. Read from the + // call graph, not the contract, so a third workflow that learns to reach the + // signer fails here even if nobody widened the contract. + want := []string{ + ".github/workflows/release-studio.yml", + ".github/workflows/studio-certum-signing-smoke.yml", + } if !reflect.DeepEqual(got, want) { t.Fatalf("top-level workflows that reach SignPath = %v, want %v", got, want) } @@ -66,3 +69,24 @@ jobs: t.Fatalf("token-backed workflow was not classified as a signing entry point: %+v", info) } } + +func TestWorkflowUsingCertumCredentialsIsSigningEntryPoint(t *testing.T) { + for _, secret := range []string{"CERTUM_OTP_URI", "STUDIO_CERTUM_OTP_URI"} { + info, err := parseWorkflow([]byte(` +on: workflow_dispatch +jobs: + sign: + runs-on: windows-2022 + steps: + - uses: ./.github/actions/setup-certum + with: + otp-uri: ${{ secrets.` + secret + ` }} +`)) + if err != nil { + t.Fatal(err) + } + if !info.externallyTriggered || !info.directSigning { + t.Fatalf("%s signing entry point not detected: %+v", secret, info) + } + } +} diff --git a/docs/STUDIO_RELEASE.md b/docs/STUDIO_RELEASE.md index ed57926625..5e4d5cf4ee 100644 --- a/docs/STUDIO_RELEASE.md +++ b/docs/STUDIO_RELEASE.md @@ -2,7 +2,7 @@ owner: @esengine backup: @SivanCola status: active -reviewed: 2026-09-17 +reviewed: 2026-09-26 --- # Studio release runbook @@ -24,7 +24,7 @@ Workflow: `.github/workflows/release-studio.yml`. Trigger: push of a `studio-vX. | P2 | Every job of `CI` and `Studio` succeeded on that commit. A flaky job is rerun, not ignored. | `gh run list --branch studio --commit --json name,conclusion` | | P3 | `release-notes/studio/X.Y.Z.md` is in that commit and passes `make check`. | `git cat-file -e :release-notes/studio/X.Y.Z.md` | | P4 | The version is valid semver and above the latest tag. | `git tag -l 'studio-v*' --sort=-v:refname \| head -1` | -| P5 | The Windows signing mode is known. `true` signs through SignPath; `false` ships unsigned and the body says so. | `gh variable list \| grep STUDIO_SIGNING_ENABLED` | +| P5 | The Windows signing mode is known. `true` signs with Certum and blocks the release if signing fails; anything else ships unsigned and the body says so. | `gh variable list \| grep STUDIO_SIGNING` | ## 3. Steps @@ -52,10 +52,13 @@ Jobs in the run: | Job | Does | Gate | | --- | --- | --- | | `resolve` | Validates the tag shape and that the commit is on `studio`. | fails on any other ref | -| `signing-contract` | Validates `.signpath/` locally and prints its fingerprint. | does not call SignPath | -| `build` | Builds windows/amd64, darwin/amd64, darwin/arm64, linux/amd64; signs macOS and, if enabled, Windows. | Apple secrets are required | +| `signing-contract` | Validates `.signpath/contracts/release-signing.yml` against the workflows that reach the Certum credentials and prints its fingerprint. | fails on an undeclared signing workflow | +| `build` | Builds windows/amd64, darwin/amd64, darwin/arm64, linux/amd64; signs macOS. With signing on, the Windows leg uploads its bundle instead of packaging it. | Apple secrets are required | +| `windows-sign-payload` | Only with `STUDIO_SIGNING_ENABLED=true`. Signs the window, kernel and computer-use helper and verifies them. Installs no toolchain. | concurrency group `studio-certum-signing`, environment `studio-release` | +| `windows-package` | Builds the installer and zip from the signed executables. Holds no secrets. | none | +| `windows-sign-installer` | Signs the installer, then verifies trust, thumbprint, subject and timestamp on it, on the executables, and on their copies in the zip. | concurrency group `studio-certum-signing`, environment `studio-release` | | `cli` | Builds `reasonix` archives for six OS/arch targets plus `SHA256SUMS`. | fails on a missing archive | -| `publish` | Renders the notes with their authors, minisigns, writes `latest.json`, creates the GitHub prerelease, mirrors to R2. | environment `studio-release`; an unresolved `#N` stops it before signing | +| `publish` | Renders the notes with their authors, minisigns, writes `latest.json`, creates the GitHub prerelease, mirrors to R2. | environment `studio-release`; skipped unless all three Windows signing jobs succeeded or signing is off; an unresolved `#N` stops it before signing | The `studio-release` environment allows the `studio-v*` tag and the `studio` branch. It has no required reviewer. @@ -74,7 +77,10 @@ The `studio-release` environment allows the `studio-v*` tag and the `studio` bra | --- | --- | --- | | No run appears, or a rerun ends in `startup_failure` with no jobs. | GitHub Actions runner outage. | Wait for queued runs to drain, then `gh workflow run release-studio.yml --ref studio -f tag=studio-vX.Y.Z`. | | A build or publish step failed. | Workflow or runner fault. | Fix on `studio` if needed, then dispatch as above. The dispatch rebuilds from the tag's commit with the workflow from `studio`. | -| Windows signing fails with `Invalid request to SignPath API.` | SignPath rejected the request; the action hides the status. | `gh run rerun --failed --debug` to see it. On quota exhaustion set `STUDIO_SIGNING_ENABLED=false` and dispatch. | +| A signing job fails with an error titled `studio-signing.*`. | A credential or an expected-signer variable is missing or malformed; the title names which. | Fix it (section 7), then dispatch the same tag. | +| A signing job fails at `Connect to Certum` or `Sign the executables`. | SimplySign login, OTP or certificate problem. | Run the smoke test (section 7). To ship unsigned instead, set `STUDIO_SIGNING_ENABLED=false` and dispatch. | +| A signing job fails with `Unexpected signer subject` or `thumbprint`. | The certificate changed, or a value was copied wrong. | Compare with the smoke test's summary; correct `STUDIO_SIGNING_SUBJECT` or `STUDIO_CERTUM_KEY_ID`. | +| A signing job waits before starting. | A smoke test or another release holds `studio-certum-signing`. | Wait. A second run queued behind the same group cancels the earlier queued one; dispatch again if that happens. | | Signing is restored after an unsigned release. | Artifacts were published unsigned. | Set `STUDIO_SIGNING_ENABLED=true` and dispatch the same tag; `publish` replaces the assets. | | The body is missing or wrong. | Notes are read from the tag's commit, not the branch. | `gh release edit studio-vX.Y.Z --notes-file `; append the standing text from the previous body. | | The tag points at the wrong commit and `publish` has not run. | Tagging error. | `git push origin :refs/tags/studio-vX.Y.Z`, delete the local tag, restart at step 3. | @@ -114,13 +120,66 @@ A lookup GitHub refuses, or cannot answer after retries, fails `publish` with a - Windows 安装目录带 AppContainer 包 SID 授权时窗口可以正常打开 #10435 ``` -## 7. Reference +## 7. Windows signing + +Windows builds are signed with Studio's own Certum certificate through SimplySign cloud signing: the 1.x mechanism, not its certificate or account. + +| Rule | Detail | +| --- | --- | +| Signing authority | The private key stays in Certum's cloud, but whoever holds the three secrets can sign. | +| Default | Off until `STUDIO_SIGNING_ENABLED` is `true`; until then releases ship unsigned and the body says so. | +| Fail closed | With the switch on, a missing value or a failed signature blocks `publish`. | + +| Name | Kind | Content | +| --- | --- | --- | +| `STUDIO_CERTUM_USERNAME` | secret | SimplySign account | +| `STUDIO_CERTUM_OTP_URI` | secret | the complete `otpauth://totp/...` provisioning URI | +| `STUDIO_CERTUM_KEY_ID` | secret | the certificate's 40-character SHA-1 thumbprint | +| `STUDIO_SIGNING_SUBJECT` | variable | the certificate subject every signed executable must carry, exactly as the smoke test reports it | +| `STUDIO_SIGNING_ENABLED` | variable | `true` turns signing on | + +Prerequisites. The environment alone does not confine the secrets: its deployment rule admits any `studio-v*` tag, and without a ruleset anyone with write access can push one, or edit a workflow on a branch the rule admits. Before enabling, the maintainer decides on: + +| Setting | Effect | +| --- | --- | +| A tag ruleset on `studio-v*` restricting creation, update and deletion to maintainers | Only maintainers can start a signing release. | +| A required reviewer on `studio-release` | Every job that reads the secrets waits for a person; a release then asks for approval at each signing job and at `publish`. | +| `studio` branch protection requiring review | The workflow and scripts that do the signing change only through review. | + +Once the certificate arrives: + +1. Add the three secrets as environment secrets of `studio-release`, so only jobs bound to that environment can read them: + + ```bash + gh secret set STUDIO_CERTUM_USERNAME --env studio-release + gh secret set STUDIO_CERTUM_OTP_URI --env studio-release + gh secret set STUDIO_CERTUM_KEY_ID --env studio-release + ``` + +2. Run the smoke test. It signs two probes, publishes nothing, and writes the signer's subject, issuer, thumbprint and timestamp to the job summary: + + ```bash + gh workflow run studio-certum-signing-smoke.yml --ref studio + ``` + +3. Copy the subject from that summary, character for character: `gh variable set STUDIO_SIGNING_SUBJECT --body ''`. +4. Run the smoke test again. It now fails unless the signer's subject matches. +5. `gh variable set STUDIO_SIGNING_ENABLED --body true`. From the next release on, the three Windows signing jobs must succeed before `publish` runs, and the body states that Windows is signed. A missing secret or subject fails the release with an error titled `studio-signing.*`. + +What is signed: the three executables Studio builds and the installer. Electron's DLLs, electron-builder's `elevate.exe` and the NSIS uninstaller are not signed by this workflow; electron-builder signs the uninstaller only through an in-process hook, which would put the session inside the packaging job. + +The SimplySign session can sign for any process on its runner while it is up. The two signing jobs therefore check out only the workflow's own commit, install no toolchain, and stop SimplySign after signing; `windows-package` runs electron-builder on a separate runner with no secrets. + +The release and the smoke test share the concurrency group `studio-certum-signing`, so no two runs hold a SimplySign session at once. + +## 8. Reference | Name | Kind | Used by | | --- | --- | --- | | `APPLE_CERT_P12`, `APPLE_CERT_PASSWORD`, `APPLE_API_KEY_P8`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER_ID` | secret | macOS signing and notarization | -| `SIGNPATH_API_TOKEN`, `SIGNPATH_ORGANIZATION_ID` | secret | Windows Authenticode signing | -| `STUDIO_SIGNING_ENABLED` | variable | Windows signing switch and the body's Windows sentence | +| `STUDIO_CERTUM_USERNAME`, `STUDIO_CERTUM_OTP_URI`, `STUDIO_CERTUM_KEY_ID` | secret | Windows Authenticode signing (section 7) | +| `STUDIO_SIGNING_ENABLED` | variable | Windows signing switch | +| `STUDIO_SIGNING_SUBJECT` | variable | the signer subject every signed executable must carry; required when signing is on | | `MINISIGN_PRIVATE_KEY`, `MINISIGN_PASSWORD` | secret | detached signatures verified by the updater | | `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ACCOUNT_ID`, `R2_BUCKET` | secret | artifact mirror and catalog | diff --git a/scripts/complete-signpath-request.ps1 b/scripts/complete-signpath-request.ps1 deleted file mode 100644 index 2197e2a3d5..0000000000 --- a/scripts/complete-signpath-request.ps1 +++ /dev/null @@ -1,161 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] - [ValidatePattern("^[0-9a-fA-F-]{36}$")] - [string]$OrganizationId, - - [Parameter(Mandatory = $true)] - [ValidatePattern("^[0-9a-fA-F-]{36}$")] - [string]$SigningRequestId, - - [Parameter(Mandatory = $true)] - [ValidateNotNullOrEmpty()] - [string]$ExpectedSigningPolicySlug, - - [Parameter(Mandatory = $true)] - [ValidateNotNullOrEmpty()] - [string]$OutputArtifactDirectory, - - [ValidateNotNullOrEmpty()] - [string]$ApiUrl = "https://app.signpath.io/api", - - [ValidateRange(30, 7200)] - [int]$TimeoutSeconds = 1800, - - [ValidateRange(1, 60)] - [int]$PollIntervalSeconds = 5, - - [switch]$WaitForExternalApproval -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" -$ProgressPreference = "SilentlyContinue" - -$apiToken = $env:SIGNPATH_API_TOKEN -if ([string]::IsNullOrWhiteSpace($apiToken)) { - throw "SIGNPATH_API_TOKEN is required to approve and download a SignPath request." -} - -$parsedApiUrl = $null -if ( - -not [Uri]::TryCreate($ApiUrl, [UriKind]::Absolute, [ref]$parsedApiUrl) -or - $parsedApiUrl.Scheme -ne "https" -) { - throw "ApiUrl must be an absolute HTTPS URL." -} - -$workspace = if ([string]::IsNullOrWhiteSpace($env:GITHUB_WORKSPACE)) { - [IO.Path]::GetFullPath((Get-Location).Path) -} else { - [IO.Path]::GetFullPath($env:GITHUB_WORKSPACE) -} -$outputDirectory = if ([IO.Path]::IsPathRooted($OutputArtifactDirectory)) { - [IO.Path]::GetFullPath($OutputArtifactDirectory) -} else { - [IO.Path]::GetFullPath((Join-Path $workspace $OutputArtifactDirectory)) -} -$workspacePrefix = $workspace.TrimEnd( - [IO.Path]::DirectorySeparatorChar, - [IO.Path]::AltDirectorySeparatorChar -) + [IO.Path]::DirectorySeparatorChar -if (-not $outputDirectory.StartsWith($workspacePrefix, [StringComparison]::OrdinalIgnoreCase)) { - throw "OutputArtifactDirectory must resolve inside GITHUB_WORKSPACE." -} - -$headers = @{ - Authorization = "Bearer $apiToken" -} -$requestBaseUrl = ( - $ApiUrl.TrimEnd("/") + - "/v1/$OrganizationId/SigningRequests/$SigningRequestId" -) - -$request = Invoke-RestMethod ` - -Method Get ` - -Uri $requestBaseUrl ` - -Headers $headers -if ($request.signingPolicySlug -ne $ExpectedSigningPolicySlug) { - throw ( - "SignPath request policy mismatch: got '$($request.signingPolicySlug)', " + - "expected '$ExpectedSigningPolicySlug'." - ) -} - -$deadline = [DateTimeOffset]::UtcNow.AddSeconds($TimeoutSeconds) -$approvalSubmitted = $false -$externalApprovalNoticeWritten = $false -while ($true) { - $status = Invoke-RestMethod ` - -Method Get ` - -Uri "$requestBaseUrl/Status" ` - -Headers $headers - Write-Host ( - "SignPath request $SigningRequestId status: " + - "$($status.status) ($($status.workflowStatus))" - ) - - if ($status.isFinalStatus) { - if ($status.status -ne "Completed") { - throw ( - "SignPath request ended with status '$($status.status)' " + - "and workflow status '$($status.workflowStatus)'." - ) - } - break - } - - if ($status.status -eq "WaitingForApproval" -and -not $approvalSubmitted) { - if ($WaitForExternalApproval) { - if (-not $externalApprovalNoticeWritten) { - Write-Host ( - "Waiting for an authorized SignPath user to approve request " + - "$SigningRequestId." - ) - $externalApprovalNoticeWritten = $true - } - } else { - Invoke-RestMethod ` - -Method Post ` - -Uri "$requestBaseUrl/Approve" ` - -Headers $headers | Out-Null - $approvalSubmitted = $true - Write-Host "Approved SignPath request $SigningRequestId through the release CI identity." - } - } - - if ([DateTimeOffset]::UtcNow -ge $deadline) { - throw "Timed out waiting for SignPath request $SigningRequestId." - } - Start-Sleep -Seconds $PollIntervalSeconds -} - -$temporaryRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { - [IO.Path]::GetTempPath() -} else { - $env:RUNNER_TEMP -} -$temporaryArchive = Join-Path $temporaryRoot "signpath-$SigningRequestId.zip" -try { - Invoke-WebRequest ` - -Method Get ` - -Uri "$requestBaseUrl/SignedArtifact" ` - -Headers $headers ` - -OutFile $temporaryArchive - if (-not (Test-Path -LiteralPath $temporaryArchive -PathType Leaf)) { - throw "SignPath did not return a signed artifact." - } - if ((Get-Item -LiteralPath $temporaryArchive).Length -eq 0) { - throw "SignPath returned an empty signed artifact." - } - - New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null - Expand-Archive ` - -LiteralPath $temporaryArchive ` - -DestinationPath $outputDirectory ` - -Force -} finally { - Remove-Item -LiteralPath $temporaryArchive -Force -ErrorAction SilentlyContinue -} - -Write-Host "Downloaded and extracted signed artifact to $outputDirectory." diff --git a/scripts/sign-certum.ps1 b/scripts/sign-certum.ps1 new file mode 100644 index 0000000000..4c0138c46c --- /dev/null +++ b/scripts/sign-certum.ps1 @@ -0,0 +1,46 @@ +param( + [Parameter(Mandatory = $true, ParameterSetName = 'Payload')] + [string]$PayloadDirectory, + [Parameter(Mandatory = $true, ParameterSetName = 'File')] + [string]$FilePath +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$thumbprint = $env:CERTUM_KEY_ID +if ($thumbprint -notmatch '^[0-9a-fA-F]{40}$') { throw 'Invalid CERTUM_KEY_ID' } +$tool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" | + Sort-Object FullName -Descending | Select-Object -First 1 +if (-not $tool) { throw 'Windows SDK SignTool not found' } + +if ($PSCmdlet.ParameterSetName -eq 'Payload') { + $root = (Resolve-Path -LiteralPath $PayloadDirectory).Path + $prefix = $root.TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar + $entries = @(Get-Content -LiteralPath (Join-Path $root 'signing-files.txt') | + ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') }) + if ($entries.Count -eq 0) { throw 'Empty signing manifest' } + $files = @($entries | ForEach-Object { + $candidate = [IO.Path]::GetFullPath((Join-Path $root $_)) + if (-not $candidate.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Signing manifest path escapes payload directory' + } + $candidate + }) +} else { + $files = @((Resolve-Path -LiteralPath $FilePath).Path) +} + +# Validate the entire manifest before the first signing operation. +foreach ($file in $files) { + if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { throw "Missing signing input: $file" } +} +foreach ($file in $files) { + & $tool.FullName sign /sha1 $thumbprint /fd SHA256 /tr http://timestamp.certum.pl /td SHA256 $file + if ($LASTEXITCODE -ne 0) { throw "Authenticode signing failed: $file" } + & $tool.FullName verify /pa /all /tw $file + if ($LASTEXITCODE -ne 0) { throw "Authenticode verification failed: $file" } + $signature = Get-AuthenticodeSignature -LiteralPath $file + if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Thumbprint -ne $thumbprint -or -not $signature.TimeStamperCertificate) { + throw "Unexpected signer, untrusted signature, or missing timestamp: $file" + } +} diff --git a/scripts/verify-windows-authenticode.ps1 b/scripts/verify-windows-authenticode.ps1 index a0fd95c560..8e450bcd32 100644 --- a/scripts/verify-windows-authenticode.ps1 +++ b/scripts/verify-windows-authenticode.ps1 @@ -2,32 +2,40 @@ # ship. A signature the release job believes it applied but that is not on the # bytes in dist/ is the one failure the signing requests cannot catch about # themselves, so every artifact is checked from disk rather than trusted from -# the order the steps ran in. +# the order the steps ran in. Given only the payload, it checks the payload. +[CmdletBinding(DefaultParameterSetName = "Payload")] param( [Parameter(Mandatory = $true)] [string]$PayloadDirectory, - [Parameter(Mandatory = $true)] + [Parameter(Mandatory = $true, ParameterSetName = "Release")] [string]$InstallerPath, - [Parameter(Mandatory = $true)] + [Parameter(Mandatory = $true, ParameterSetName = "Release")] [string]$PortableArchivePath, - [switch]$RequireTrusted + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F]{40}$')] + [string]$ExpectedThumbprint, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$ExpectedSubject ) $ErrorActionPreference = "Stop" -# The executables Studio ships of its own: the window, and the kernel it spawns. -# Everything else the bundle carries belongs to Chromium or to electron-builder -# -- resources/elevate.exe is the one that is also a PE file -- and arrives from -# its own publisher, so this verifies what we signed, not what we shipped. +# The executables Studio builds itself: the window, the kernel it spawns, and +# the computer-use helper. The release signs these and the installer, nothing +# else. Electron's DLLs, electron-builder's resources/elevate.exe and the NSIS +# uninstaller the installer writes ship with whatever signature their builder +# gave them, which for most of them is none. # -# Keyed by the path inside the bundle; the flat signing payload carries the leaf -# name, because SignPath receives a directory rather than a tree. +# Keyed by the path inside the bundle; the flat signing payload carries the leaf. $signedExecutables = [ordered]@{ - "Reasonix Studio.exe" = "Reasonix Studio.exe" - "resources/bin/reasonix-studio-host.exe" = "reasonix-studio-host.exe" + "Reasonix Studio.exe" = "Reasonix Studio.exe" + "resources/bin/reasonix-studio-host.exe" = "reasonix-studio-host.exe" + "resources/bin/reasonix-computer-helper.exe" = "reasonix-computer-helper.exe" } function Assert-AuthenticodeSignature { @@ -43,9 +51,25 @@ function Assert-AuthenticodeSignature { if ($null -eq $signature.SignerCertificate -or $signature.SignatureType -eq "None") { throw "Authenticode signature is missing: $Path" } - if ($RequireTrusted -and $signature.Status -ne "Valid") { + if ($signature.Status -ne "Valid") { throw "Authenticode signature is not trusted for $Path`: $($signature.Status) $($signature.StatusMessage)" } + $certificate = $signature.SignerCertificate + if ($certificate.Thumbprint -ne $ExpectedThumbprint.ToUpperInvariant()) { + throw "Unexpected signer thumbprint $($certificate.Thumbprint): $Path" + } + if ($certificate.Subject -cne $ExpectedSubject) { + throw "Unexpected signer subject '$($certificate.Subject)': $Path" + } + # The thumbprint pins one certificate and so its issuer; a Valid status + # already means that issuer chains to a trusted root. + if ([string]::IsNullOrWhiteSpace($certificate.Issuer)) { + throw "Signer certificate has no issuer: $Path" + } + Write-Host "Signer: $($certificate.Subject) / issuer: $($certificate.Issuer)" + if ($null -eq $signature.TimeStamperCertificate) { + throw "Authenticode timestamp is missing: $Path" + } Write-Host "Authenticode $($signature.Status): $Path" } @@ -59,6 +83,11 @@ foreach ($leaf in $signedExecutables.Values) { $payloadPaths[$leaf] = $path } +if ($PSCmdlet.ParameterSetName -eq "Payload") { + Write-Host "Windows Authenticode payload verified." + exit 0 +} + Assert-AuthenticodeSignature -Path $InstallerPath $extractRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("reasonix-authenticode-" + [guid]::NewGuid().ToString("N"))