diff --git a/.github/workflows/signpath_sign.yml b/.github/workflows/signpath_sign.yml new file mode 100644 index 00000000..7f3f3dd0 --- /dev/null +++ b/.github/workflows/signpath_sign.yml @@ -0,0 +1,127 @@ +name: Code Signing (SignPath) + +# Manually started for now: builds Release, signs our own binaries, packages them, then signs the +# installer. Once the test certificate works end to end, these steps move into release.yml. + +on: + workflow_dispatch: + inputs: + signing-policy: + description: SignPath signing policy + type: choice + options: [test-signing, release-signing] + default: test-signing + +permissions: + actions: read # the SignPath action downloads the uploaded artifact through the GitHub API + contents: read + +jobs: + sign: + runs-on: windows-latest # SignPath requires GitHub-hosted runners for open source projects + name: Build, sign and package (${{ inputs.signing-policy }}) + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Required for GitVersion to compute version from tags + + - name: Install .NET Core + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Install InnoSetup + run: choco install innosetup --no-progress -y + + - name: Build + run: ./build.ps1 --target Clean Compile --configuration Release + + # The binaries are signed before packaging so the zip, the Chocolatey package and the + # installer all ship the signed files. + - name: Upload unsigned binaries + id: upload-unsigned-app + uses: actions/upload-artifact@v7 + with: + name: unsigned-app + path: bin/Release + retention-days: 1 + + - name: Sign binaries + uses: signpath/github-action-submit-signing-request@v3 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }} + signing-policy-slug: ${{ inputs.signing-policy }} + artifact-configuration-slug: app-files + github-artifact-id: ${{ steps.upload-unsigned-app.outputs.artifact-id }} + wait-for-completion: true + wait-for-completion-timeout-in-seconds: 3600 # release-signing waits for manual approval + output-artifact-directory: bin/Release-signed + + - name: Replace unsigned binaries with signed ones + shell: pwsh + run: | + Remove-Item bin/Release -Recurse -Force + Move-Item bin/Release-signed bin/Release + + - name: Check binary signatures + shell: pwsh + run: | + # Status is not 'Valid' with the test certificate (its root is not trusted), so only + # check that a signature is present. + $files = Get-ChildItem bin/Release -File | Where-Object { $_.Name -like 'LogExpert*.exe' -or $_.Name -like 'LogExpert*.dll' -or $_.Name -eq 'ColumnizerLib.dll' } + $signatures = $files | Get-AuthenticodeSignature + $signatures | Format-Table @{ n = 'File'; e = { Split-Path $_.Path -Leaf } }, Status, @{ n = 'Signer'; e = { $_.SignerCertificate.Subject } } -AutoSize + $unsigned = $signatures | Where-Object { $_.Status -eq 'NotSigned' } + if ($unsigned) { + Write-Host "::error::Unsigned: $($unsigned.Path -join ', ')" + exit 1 + } + + # Compile and Test are skipped so packaging uses the signed binaries instead of rebuilding. + - name: Package + run: ./build.ps1 --target Pack --configuration Release --skip Restore Compile Test + + - name: Upload unsigned installer + id: upload-unsigned-setup + uses: actions/upload-artifact@v7 + with: + path: bin/LogExpert-Setup-*.exe + archive: false + retention-days: 1 + + - name: Sign installer + uses: signpath/github-action-submit-signing-request@v3 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }} + signing-policy-slug: ${{ inputs.signing-policy }} + artifact-configuration-slug: initial + github-artifact-id: ${{ steps.upload-unsigned-setup.outputs.artifact-id }} + skip-decompress: true + wait-for-completion: true + wait-for-completion-timeout-in-seconds: 3600 + output-artifact-directory: bin/setup-signed + + - name: Replace unsigned installer with signed one + shell: pwsh + run: | + Get-ChildItem bin/setup-signed -Filter *.exe | Copy-Item -Destination bin -Force + Get-AuthenticodeSignature bin/LogExpert-Setup-*.exe | Format-Table Path, Status, @{ n = 'Signer'; e = { $_.SignerCertificate.Subject } } -AutoSize + + - name: Upload signed packages + uses: actions/upload-artifact@v7 + with: + name: signed-packages-${{ inputs.signing-policy }} + path: | + bin/LogExpert-Setup-*.exe + bin/LogExpert.*.zip + bin/LogExpert.ColumnizerLib.*.nupkg + bin/SftpFileSystem.x64.*.zip + bin/SftpFileSystem.x86.*.zip + bin/chocolatey/logexpert.*.nupkg + retention-days: 7 diff --git a/src/docs/CODE_SIGNING.md b/src/docs/CODE_SIGNING.md new file mode 100644 index 00000000..61f355a4 --- /dev/null +++ b/src/docs/CODE_SIGNING.md @@ -0,0 +1,139 @@ +# Code signing with SignPath + +How LogExpert's binaries get an Authenticode signature through the SignPath Foundation +(free signing for open source), and what has to be set up on each side. + +## How it works + +``` +build (Release) ──► upload bin/Release ──► SignPath signs our own exe/dlls ──► download + │ + signed installer ◄── SignPath signs setup.exe ◄── upload setup.exe ◄── Pack (no recompile) +``` + +- The signing key never touches GitHub. The workflow uploads the unsigned files as a GitHub + workflow artifact, SignPath fetches it directly from GitHub (that is how it proves the files came + from a real CI build of this repo), signs them and hands them back. +- The binaries are signed **before** packaging, so the zip, the Chocolatey package and the installer + all contain signed files. The installer is then signed in a second request. +- Workflow: [.github/workflows/signpath_sign.yml](../../.github/workflows/signpath_sign.yml), started by + hand (Actions → *Code Signing (SignPath)* → *Run workflow*). +- Artifact configurations (what to sign inside each upload) live only in the SignPath project. + Their intended content is listed under [Step 1](#step-1-in-signpath-appsignpathio). + +### What gets signed, and what doesn't + +| Signed | Not signed | Why not | +|---|---|---| +| `LogExpert.exe`, `LogExpert.dll`, `LogExpert.*.dll`, `ColumnizerLib.dll` | Third-party DLLs (Newtonsoft.Json, NLog, DockPanelSuite, …) | SignPath Foundation only signs binaries built from our own source. | +| `LogExpert-Setup-.exe` | Built-in plugins in `plugins/` and `pluginsx86/` | `PluginValidator` checks them against SHA256 hashes taken at compile time ([PluginHashGenerator.targets](../PluginRegistry/PluginHashGenerator.targets)). Signing changes the file bytes, so every built-in plugin would fail verification in Release. Fixing this needs the hashes to be computed *after* signing. That is a separate change. | +| | Inno Setup's uninstaller (`unins000.exe`) | Needs `SignTool` set up in the `.iss` script. Can be done later. | + +## Phase 1: test certificate + +### Step 1: In SignPath (app.signpath.io) + +The menu names below may differ a little from the live UI. + +1. **Organization ID**: a GUID, not the organization's slug. The UI hardly shows it. The easiest + place to find it is the address bar: `app.signpath.io/Web//...`. +2. **Trusted build system**: under *Trusted Build Systems*, add the predefined **GitHub.com** one, + then link it to the LogExpert project. +3. **Project**: note the project's **slug**, the short identifier, not the display name. +4. **Signing policies**: check that a policy with the slug `test-signing` exists. The Foundation + usually creates it, along with `release-signing`. If the slugs differ, change the `options:` list + at the top of the workflow. +5. **Artifact configurations**: the project needs two. + + Slug `app-files`, for our own binaries. The root is `` because `actions/upload-artifact` + zips `bin/Release`, and the paths are relative to that folder: + + ```xml + + + + + + + + + + + + + + + ``` + + Slug `initial` (display name "Initial Version"), for the installer. It is uploaded with + `archive: false`, so the root is the exe itself: + + ```xml + + + + + + + ``` + + Paste these by hand. Don't use a configuration that SignPath generates from an uploaded sample: + it also lists XML files, and signing those (``) is not part of the Foundation plan, so + SignPath rejects it. +6. **API token**: create a CI user (or use your own user) with the **Submitter** role on the + `test-signing` policy, and generate an API token for it. You need the token in the next step. +7. *(Optional)* Install the **SignPath GitHub App** on the repository. Not needed for a public repo, + but needed later if you want pipeline policies such as "only sign builds from protected branches". + +### Step 2: In GitHub (repo → Settings → Secrets and variables → Actions) + +| Kind | Name | Value | +|---|---|---| +| Secret | `SIGNPATH_API_TOKEN` | the API token from step 1.6 | +| Variable | `SIGNPATH_ORGANIZATION_ID` | the organization ID from step 1.1 | +| Variable | `SIGNPATH_PROJECT_SLUG` | the project slug from step 1.3 | + +The organization ID and slug are not secret, so they are stored as *variables* and stay visible in +logs. + +### Step 3: Run and check + +1. Merge the workflow to `Development`. `workflow_dispatch` workflows only show up in the Actions + tab once they are on the default branch. +2. Actions → **Code Signing (SignPath)** → *Run workflow* → policy `test-signing`. +3. The run should: + - print a table of signed binaries in *Check binary signatures*. With the test certificate the + status is `UnknownError` (untrusted root), not `Valid`. That is expected. + - finish with a `signed-packages-test-signing` artifact. +4. Download the artifact, right-click `LogExpert-Setup-*.exe` → *Properties* → *Digital Signatures*. + A signature from the SignPath test certificate should be listed. +5. Install it and make sure LogExpert starts **and the built-in columnizers load**. This confirms + that leaving the plugins unsigned kept the hash check working. + +Each run also shows a link to its signing request in SignPath, under the signing step's outputs. + +## Phase 2: release certificate + +Once the test runs pass, the SignPath Foundation reviews the project before it allows +`release-signing`. Their conditions, per [signpath.org/terms](https://signpath.org/terms): + +- **Code signing policy**: a section called "Code signing policy" on the project home page (the + README) and on the release/download page. It must include: + - the line *"Free code signing provided by [SignPath.io](https://about.signpath.io), certificate + by [SignPath Foundation](https://signpath.org)"* + - the team roles and who holds them: **Authors** (may change code without review), + **Reviewers** (review outside contributions), **Approvers** (approve each signing request) + - a privacy statement, for example: "This program will not transfer any information to other + networked systems unless specifically requested by the user or the person installing or + operating it." +- **MFA** turned on for every team member, on both GitHub and SignPath. +- **Manual approval**: every release-signing request waits for an Approver to click *Approve* + in SignPath. The workflow waits up to 60 minutes (`wait-for-completion-timeout-in-seconds`). +- **Consistent metadata**: the product name must be the project name, and the product version must + be the same on every signed file. `src/Directory.Build.props` sets `LogExpert`, + and the Nuke `Compile` target stamps the GitVersion number onto every assembly, so this is + already the case. + +After that, either run the same workflow with `release-signing`, or move its steps into +[release.yml](../../.github/workflows/release.yml) between *Install InnoSetup* and +*Create GitHub Release*. Then every published release is signed automatically, after approval.