# ============================================================================= # Build the sample .NET app and code-sign it with Keyfactor Signum. # # Pipeline shape: # build -> compiles and publishes SignumSample.App.exe + SignumSample.Library.dll # (no secrets are available in this job) # sign -> installs the Signum Windows Agent, authenticates it against the # Signum server, then signs and verifies the artifacts with signtool # # The split matters: the signing credentials are only ever exposed to the `sign` # job, which never runs for pull requests. See README.md for the full setup. # ============================================================================= name: Build and sign on: push: branches: [ main ] # PRs build but never sign - untrusted code must not reach the signing keys. pull_request: workflow_dispatch: inputs: sign: description: "Sign the artifacts with Signum" type: boolean default: true # Least privilege: this workflow only needs to read the repository. Add # `id-token: write` here if you later switch to OIDC-based authentication. permissions: contents: read # One run per ref. Cancels superseded runs so two jobs never fight over the # agent installation on a shared self-hosted runner. concurrency: group: build-and-sign-${{ github.ref }} cancel-in-progress: true defaults: run: # Explicit so the scripts below behave the same on GitHub-hosted and # self-hosted Windows runners (PowerShell 7, not Windows PowerShell 5.1). shell: pwsh env: DOTNET_VERSION: "8.0.x" CONFIGURATION: Release PUBLISH_DIR: artifacts/publish DOTNET_NOLOGO: "true" DOTNET_CLI_TELEMETRY_OPTOUT: "true" jobs: # =========================================================================== # JOB 1 - BUILD # Plain compile + publish. Deliberately has no access to any secret, so it is # safe to run on pull requests from forks. # =========================================================================== build: runs-on: windows-latest steps: - uses: actions/checkout@v4 - name: Set up .NET SDK uses: actions/setup-dotnet@v4 with: # Pinned so the build does not silently move to a new SDK. Bump this # (and the TargetFramework in the .csproj files) together. dotnet-version: ${{ env.DOTNET_VERSION }} - name: Restore run: dotnet restore - name: Build run: dotnet build --configuration $env:CONFIGURATION --no-restore - name: Publish # Publishing the app project also copies SignumSample.Library.dll next to # the .exe, so both signing targets end up in one directory. run: > dotnet publish src/SignumSample.App/SignumSample.App.csproj --configuration $env:CONFIGURATION --no-build --output $env:PUBLISH_DIR - name: Smoke test (unsigned) run: | # Without --require-signatures the app only reports and exits 0, so this # step proves the binary runs and loads the library. Any non-zero exit # code here means the build is genuinely broken. & "$env:PUBLISH_DIR\SignumSample.App.exe" - name: Upload unsigned artifacts uses: actions/upload-artifact@v4 with: name: unsigned path: ${{ env.PUBLISH_DIR }} if-no-files-found: error # =========================================================================== # JOB 2 - SIGN # Installs the Signum Windows Agent in Server Mode, logs it in, and signs the # published binaries with signtool. The agent exposes the HSM-backed key from # the Signum server through the Windows certificate store (KSP), so signtool # selects it with /sha1 exactly like a local certificate. # # The steps run in three phases, marked by the banners below: # 1. INSTALL THE AGENT - preflight the runner, then install the MSI # 2. CONFIGURE THE AGENT - log in, and confirm the certificate showed up # 3. SIGN - signtool sign, verify, run, publish, log out # # One job, not three, on purpose: every phase depends on machine state left # behind by the previous one (an installed service, a logged-in session, a # populated certificate store). A GitHub-hosted runner is a fresh VM per job, # so splitting these across jobs would lose exactly the state they hand over. # =========================================================================== sign: needs: build # Never on pull_request: that is what keeps the signing credentials away # from unreviewed code. Manual runs can opt out via the `sign` input. if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.sign) # RUNNER CHOICE # windows-latest - fine when the Signum server is reachable from the # public internet (Signum SaaS) and its network # policy accepts GitHub's runner IP ranges. # self-hosted runner - required for an on-prem / private Signum server. # Set the repository variable SIGNING_RUNNER to the # runner label, e.g. `signum-windows`. runs-on: ${{ vars.SIGNING_RUNNER || 'windows-latest' }} # Uncomment to route signing through a protected GitHub Environment. That # lets you scope the secrets to this environment and require a manual # approval before anything is signed. Recommended for real release signing. # environment: code-signing env: # ---- Where to get the agent installer ------------------------------- # URL to the Signum Windows Agent .msi downloaded from the Signum SaaS # portal and republished somewhere the runner can reach (an internal # artifact repo, blob storage, ...). Not needed when the agent is already # installed on a self-hosted runner. # This is a variable, so it is NOT masked in logs and is readable by # anyone with repository read access. If the URL has to carry a # credential (a SAS token, a signed URL), move it back to secrets. SIGNUM_AGENT_MSI_URL: ${{ vars.SIGNUM_AGENT_MSI_URL }} # ---- Signum server connection --------------------------------------- # Passed to the MSI as RTPRIMARY / RTSECONDARY and to rtsetup.exe. SIGNUM_PRIMARY_SERVER: ${{ vars.SIGNUM_PRIMARY_SERVER }} SIGNUM_SECONDARY_SERVER: ${{ vars.SIGNUM_SECONDARY_SERVER }} # Signum requires outbound 443; override only for a non-standard setup. SIGNUM_SERVICE_PORT: ${{ vars.SIGNUM_SERVICE_PORT || '443' }} # Client ID from the Signum SaaS portal, identifies this agent install. SIGNUM_CLIENT_ID: ${{ secrets.SIGNUM_CLIENT_ID }} # ---- Agent authentication ------------------------------------------- # This workflow uses authMode=LocalUsers (username + password of a Signum # service account). rtsetup.exe also supports: # -authMode=Ldap -> same username/password flags, LDAP-backed # -authMode=Certificate -> -thumbprint= # Certificate mode avoids storing a password, but needs the client # certificate provisioned on the runner beforehand. SIGNUM_USERNAME: ${{ vars.SIGNUM_USERNAME }} SIGNUM_PASSWORD: ${{ secrets.SIGNUM_PASSWORD }} # ---- Signing certificate & store ------------------------------------ # SHA-1 thumbprint of the code-signing certificate the Signum policy # grants this account. A public value: it identifies the certificate, and # the same thumbprint is embedded in every signed artifact. SIGNUM_CERT_THUMBPRINT: ${{ vars.SIGNUM_CERT_THUMBPRINT }} # Which Windows store rtsetup.exe -targetStore drops the certificate in: # LocalMachine -> machine store, matches Server Mode; signtool needs /sm # My -> current user store; signtool without /sm SIGNUM_TARGET_STORE: ${{ vars.SIGNUM_TARGET_STORE || 'LocalMachine' }} # ---- Timestamping ---------------------------------------------------- # RFC 3161 timestamp authority. Timestamping is what keeps signatures # valid after the signing certificate expires - do not drop it. TIMESTAMP_URL: ${{ vars.TIMESTAMP_URL || 'http://timestamp.digicert.com' }} # ---- Signature verification ------------------------------------------ # `signtool verify /pa` validates the whole trust chain, which fails on a # runner that does not trust the issuing CA - a test certificate, or an # internal PKI whose root is not in the runner's store. Set this variable # to 'false' to downgrade a chain-trust failure to a warning. # # It only makes trust optional. The step still fails on an unsigned file # or a digest that does not match its contents, because those mean signing # did not work rather than that the runner does not know the CA. REQUIRE_TRUSTED_CHAIN: ${{ vars.REQUIRE_TRUSTED_CHAIN || 'true' }} # Fixed install location of the agent CLI. RTSETUP: C:\Program Files\KeyFactor\rtsetup.exe steps: # ####################################################################### # PHASE 1 of 3 - INSTALL THE AGENT # # Preflight the runner first - scripts, configuration, reachability, the # artifacts to sign - and only then install the agent. Everything here is # cheap and reversible, and none of it uses the signing credentials beyond # checking that they are set, so a failure in this phase means the runner # is not ready rather than that signing went wrong. # # The order is deliberate: the preflight checks that could fail are all # ahead of the install, because installing the agent and logging it in are # the steps that leave state behind on a self-hosted runner. # ####################################################################### # ----------------------------------------------------------------------- # Only the helper scripts, not the source: this job signs artifacts built # by the `build` job and has no reason to have the code on disk. Must come # before the artifact download, because checkout cleans the workspace. # ----------------------------------------------------------------------- - name: Check out the workflow scripts uses: actions/checkout@v4 with: sparse-checkout: .github/scripts sparse-checkout-cone-mode: false # ----------------------------------------------------------------------- # Fail fast with a readable message when the repo is not configured yet, # instead of a cryptic msiexec or rtsetup error 20 steps later. # ----------------------------------------------------------------------- - name: Check required configuration run: | $required = @( "SIGNUM_PRIMARY_SERVER", "SIGNUM_CLIENT_ID", "SIGNUM_USERNAME", "SIGNUM_PASSWORD", "SIGNUM_CERT_THUMBPRINT" ) $missing = $required | Where-Object { [string]::IsNullOrWhiteSpace((Get-Item "env:$_" -ErrorAction SilentlyContinue).Value) } if ($missing) { throw "Missing repository configuration: $($missing -join ', '). " + "See README.md for which are variables and which are secrets." } Write-Host "Configuration present. Target certificate store: $env:SIGNUM_TARGET_STORE" # ----------------------------------------------------------------------- # Prove the runner can actually reach the Signum server before installing # anything. This is the failure this pipeline is most likely to hit on a # GitHub-hosted runner: the agent needs outbound 443 to the deployment, # and a private or IP-restricted Signum server simply will not answer. # Without this, an unreachable server surfaces much later as an opaque # rtsetup login error. DNS and TCP are fatal, the TLS handshake is only # reported - see the script for why. # # SIGNUM_PRIMARY_SERVER may be a bare hostname, host:port or a full URL; # the script accepts all three, as the agent does. # ----------------------------------------------------------------------- - name: Test connectivity to the Signum server run: > .github/scripts/Test-SignumConnectivity.ps1 -Server "$env:SIGNUM_PRIMARY_SERVER" -Port $env:SIGNUM_SERVICE_PORT -TimeoutSeconds 10 - name: Download unsigned artifacts uses: actions/download-artifact@v4 with: name: unsigned path: ${{ env.PUBLISH_DIR }} # ----------------------------------------------------------------------- # Install the Signum Windows Agent, unattended and in Server Mode. # # A no-op when rtsetup.exe is already there, which is the self-hosted # runner path. The MSI properties, the exit-code table and the # what-did-we-actually-download diagnostics live in the script. # # SIGNUM_AGENT_MSI_URL and SIGNUM_CLIENT_ID are not passed as arguments: # the script reads them from the environment, so neither the token in the # URL nor the client ID lands in this process's command line. # ----------------------------------------------------------------------- - name: Install the Signum Agent run: > .github/scripts/Install-SignumAgent.ps1 -RtSetupPath "$env:RTSETUP" -PrimaryServer "$env:SIGNUM_PRIMARY_SERVER" -SecondaryServer "$env:SIGNUM_SECONDARY_SERVER" -LogPath "$env:RUNNER_TEMP\signum-agent-install.log" # ----------------------------------------------------------------------- # Wait for the agent service to come up. # # msiexec returns as soon as the package is installed, which is before # RTService has finished starting. Going straight to rtsetup.exe races the # service and surfaces as an opaque connection or login error. # # This also covers the pre-installed self-hosted runner path, where the # install step is skipped but the service could still be stopped. # ----------------------------------------------------------------------- - name: Wait for the Signum agent service run: > .github/scripts/Wait-AgentService.ps1 -ServiceName RTService -TimeoutSeconds 120 -AllowStart # ----------------------------------------------------------------------- # A fully dumped verbose log can still be truncated in the run view, so # keep the raw file downloadable whenever anything up to here failed - # including a service that never started, where the install log often # explains why. # ----------------------------------------------------------------------- - name: Upload the agent install log if: failure() uses: actions/upload-artifact@v4 with: name: signum-agent-install-log path: ${{ runner.temp }}/signum-agent-install.log if-no-files-found: ignore # ####################################################################### # PHASE 2 of 3 - CONFIGURE THE AGENT # # Turn the installed agent into a usable signing provider: log it in, let # it restart, and confirm the entitled certificate actually appeared in # the Windows store. This is the first phase to touch the signing # credentials, and the one that leaves a session on the machine - which is # why the logout at the end of phase 3 runs even on failure. # # A failure here is a Signum-side problem (credentials, policy, client ID) # rather than a build or signtool problem. # ####################################################################### # ----------------------------------------------------------------------- # Authenticate the agent against the Signum server. # # rtsetup.exe is the Server Mode CLI. Once logged in, the certificates the # account is entitled to (via its Signum policy) appear in the Windows # store selected by -targetStore, backed by the remote HSM. # # rtsetup.exe reports the real outcome to the Application event log rather # than to stdout, so those entries are printed after every attempt - # successful or not. On a failure they are usually the only place that says # why; on a success they confirm which account and policy were used. # ----------------------------------------------------------------------- - name: Authenticate the agent against Signum run: | # Secrets are read from env vars rather than interpolated by Actions, # so they are not written into the step's temporary script file. $arguments = @( "-authMode=LocalUsers", "-username=$env:SIGNUM_USERNAME", "-password=$env:SIGNUM_PASSWORD", "-targetStore=$env:SIGNUM_TARGET_STORE" ) # Captured before the call so the event query cannot pick up entries # from an earlier login on a shared self-hosted runner. $loginStartedAt = (Get-Date).AddSeconds(-2) & $env:RTSETUP @arguments $rtsetupExit = $LASTEXITCODE Write-Host "rtsetup.exe exit code: $rtsetupExit" # Deliberately outside the exit-code check: the events are wanted # either way, and -SettleSeconds covers the event landing slightly # after the process exits. & .github/scripts/Show-AgentEvents.ps1 ` -Since $loginStartedAt ` -SettleSeconds 3 ` -FallbackAll if ($rtsetupExit -ne 0) { throw "rtsetup.exe failed with exit code $rtsetupExit." } # ----------------------------------------------------------------------- # Wait for the service again: logging in restarts RTService so it picks up # the new session, and the certificate store is only populated once it is # back up. Without this, the next step can look for the certificate while # the service is mid-restart and report it as missing. # # -StableSeconds is the difference from the post-install wait. Immediately # after login the service may still be Running because it has not gone # down *yet*, so a bare Running check would pass on the pre-restart # process. Requiring it to hold Running under one process id for 15s # covers that; the script restarts the window if the pid changes. # ----------------------------------------------------------------------- - name: Wait for the Signum agent service after login run: > .github/scripts/Wait-AgentService.ps1 -ServiceName RTService -TimeoutSeconds 120 -StableSeconds 15 -AllowStart # ----------------------------------------------------------------------- # Confirm the agent actually surfaced the expected certificate. Without # this, a policy misconfiguration shows up as an opaque signtool error. # The script lists every certificate in the store before matching, so a # wrong thumbprint and an empty store are distinguishable in the log. # ----------------------------------------------------------------------- - name: Verify the signing certificate is available run: > .github/scripts/Test-SigningCertificate.ps1 -Thumbprint "$env:SIGNUM_CERT_THUMBPRINT" -TargetStore "$env:SIGNUM_TARGET_STORE" # ####################################################################### # PHASE 3 of 3 - SIGN # # With the certificate visible, signing is ordinary signtool work: locate # it, sign both binaries, verify them independently, prove the signed exe # still runs, and publish it. The agent forwards every private-key # operation to Signum, so nothing in this phase holds a key. # # The logout at the end unwinds phase 2 and runs even when a step above # failed, because on a self-hosted runner the session would otherwise # outlive the job. # ####################################################################### # ----------------------------------------------------------------------- # Locate signtool. It ships with the Windows SDK, which is pre-installed # on GitHub-hosted windows-* runners but whose version changes over time, # so the path is discovered rather than hardcoded. # ----------------------------------------------------------------------- - name: Locate signtool id: signtool run: | $candidates = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" ` -ErrorAction SilentlyContinue | Where-Object { $_.Directory.Parent.Name -as [version] } # skip unversioned bin\x64 if (-not $candidates) { throw "signtool.exe not found. Install the Windows SDK on this runner." } # Newest SDK wins. $signtool = $candidates | Sort-Object { [version]$_.Directory.Parent.Name } -Descending | Select-Object -First 1 Write-Host "signtool: $($signtool.FullName)" "path=$($signtool.FullName)" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 # ----------------------------------------------------------------------- # Sign the executable and the library. # # signtool flags used here: # /fd SHA256 file digest algorithm # /sha1 pick the certificate by thumbprint (the one Signum exposes) # /tr RFC 3161 timestamp server (prefer over the legacy /t) # /td SHA256 timestamp digest algorithm # /sm look in the machine store instead of the user store # /v verbose, so the log shows which key was used # Alternative selectors: /n "" instead of /sha1, useful when # the certificate is rotated and the thumbprint changes. # ----------------------------------------------------------------------- - name: Sign the executable and the library env: SIGNTOOL: ${{ steps.signtool.outputs.path }} run: | $files = Get-ChildItem $env:PUBLISH_DIR -Recurse -Include SignumSample*.exe, SignumSample*.dll | Select-Object -ExpandProperty FullName if (-not $files) { throw "No binaries to sign in $env:PUBLISH_DIR." } $thumbprint = $env:SIGNUM_CERT_THUMBPRINT -replace '[^0-9A-Fa-f]', '' $arguments = @( "sign", "/fd", "SHA256", "/sha1", $thumbprint, "/tr", $env:TIMESTAMP_URL, "/td", "SHA256", "/v" ) if ($env:SIGNUM_TARGET_STORE -eq "LocalMachine") { $arguments += "/sm" } Write-Host "Signing:`n$($files -join "`n")" & $env:SIGNTOOL @arguments @files if ($LASTEXITCODE -ne 0) { throw "signtool sign failed with exit code $LASTEXITCODE." } # ----------------------------------------------------------------------- # Independent verification against the Authenticode policy: checks the # digest, the full trust chain and the timestamp. This is the step that # would catch a chain the runner does not trust. # # Trust is optional here, gated on REQUIRE_TRUSTED_CHAIN. When it is off # and signtool rejects the chain, the step falls back to # Get-AuthenticodeSignature, which separates the two cases signtool's exit # code conflates: # NotSigned / HashMismatch -> signing genuinely failed, still fatal # NotTrusted / UnknownError -> signed correctly, runner does not trust # the issuer, reported as a warning # ----------------------------------------------------------------------- - name: Verify the signatures env: SIGNTOOL: ${{ steps.signtool.outputs.path }} run: | $files = Get-ChildItem $env:PUBLISH_DIR -Recurse -Include SignumSample*.exe, SignumSample*.dll | Select-Object -ExpandProperty FullName $requireTrust = $env:REQUIRE_TRUSTED_CHAIN -ne "false" Write-Host "Trust chain required: $requireTrust" # /pa use the Authenticode policy (not the driver policy) # /all also check secondary signatures and the timestamp & $env:SIGNTOOL verify /pa /all /v @files $verifyExit = $LASTEXITCODE if ($verifyExit -eq 0) { Write-Host "signtool verify passed, trust chain included." exit 0 } if ($requireTrust) { throw "signtool verify failed with exit code $verifyExit." } Write-Host "::warning::signtool verify failed with exit code $verifyExit and REQUIRE_TRUSTED_CHAIN is false; checking signature presence and digest integrity instead of trust." $untrusted = @() $broken = @() foreach ($file in $files) { $signature = Get-AuthenticodeSignature -FilePath $file $name = Split-Path $file -Leaf Write-Host "$name : $($signature.Status)" if ($signature.SignerCertificate) { Write-Host " Signer : $($signature.SignerCertificate.Subject)" Write-Host " Thumbprint : $($signature.SignerCertificate.Thumbprint)" } # No timestamp means the signature dies with the certificate, which # is worth surfacing even when trust itself is being waived. if ($signature.TimeStamperCertificate) { Write-Host " Timestamped: yes ($($signature.TimeStamperCertificate.Subject))" } else { Write-Host " Timestamped: NO" } if ($signature.StatusMessage) { Write-Host " Detail : $($signature.StatusMessage)" } switch ($signature.Status) { "Valid" { } "NotSigned" { $broken += "${name}: not signed" } "HashMismatch" { $broken += "${name}: digest does not match the file contents" } default { # Any other status is only a trust problem if a signature is # actually there. Without a signer certificate the status is # something else entirely - UnknownError is also what a file that # is not a valid PE reports - and waiving it would let an # unsigned artifact through. if ($signature.SignerCertificate) { $untrusted += "${name}: $($signature.Status)" } else { $broken += "${name}: $($signature.Status), and no signature is present" } } } } if ($broken) { Write-Host "::error::Signing did not work, which REQUIRE_TRUSTED_CHAIN does not waive:" $broken | ForEach-Object { Write-Host " $_" } throw "Signature verification failed for reasons unrelated to trust." } if ($untrusted) { Write-Host "::warning::Signatures are present and their digests match, but the chain is not trusted on this runner:" $untrusted | ForEach-Object { Write-Host "::warning:: $_" } Write-Host "Set REQUIRE_TRUSTED_CHAIN to true once the issuing CA is trusted where these artifacts are consumed." } exit 0 # ----------------------------------------------------------------------- # End-to-end check: run the signed exe, which loads the signed dll and # reports both signatures. Exit code 0 means both are in place. # ----------------------------------------------------------------------- - name: Run the signed binary run: | # --require-signatures turns a missing signature into a non-zero exit, # so this step fails if signing silently skipped one of the binaries. & "$env:PUBLISH_DIR\SignumSample.App.exe" --require-signatures - name: Upload signed artifacts uses: actions/upload-artifact@v4 with: name: signed path: ${{ env.PUBLISH_DIR }} if-no-files-found: error # ----------------------------------------------------------------------- # Always drop the agent session, even when signing failed. Matters most on # self-hosted runners, where the machine state outlives the job. # ----------------------------------------------------------------------- - name: Log the agent out if: always() run: | if (Test-Path $env:RTSETUP) { & $env:RTSETUP logout if ($LASTEXITCODE -ne 0) { Write-Host "::warning::rtsetup.exe logout returned $LASTEXITCODE." } } # Actions appends `exit $LASTEXITCODE` to every pwsh step, so a failed # logout would fail this step and mask the real failure of the job. exit 0